Compare commits

..

8 Commits

Author SHA1 Message Date
lebaudantoine 511247c14b ♻️(frontend) refactor audio/video tabs to share common layout components
Extract shared layout components from audio and video tabs to eliminate
code duplication and improve maintainability.

Creates reusable layout components that both tabs can utilize, reducing
redundancy and ensuring consistent styling and behavior across settings
tabs.
2025-08-20 12:23:26 +02:00
lebaudantoine 1380412a39 (frontend) add subscription video quality selector to video tab
Add configuration option allowing users to set maximum video quality
for subscribed tracks, requested by users who prefer manual control
over automatic behavior.

Implements custom handling for existing and new video tracks since
LiveKit lacks simple global subscription parameter mechanism. Users can
now override automatic quality decisions with their own preferences.
2025-08-20 12:23:26 +02:00
lebaudantoine 3f91271ab2 ♻️(frontend) refactor video tab i18n strings with common prefix
Reorganize internationalization strings for video tab to use shared
"video" prefix, improving code maintainability and consistency.
2025-08-20 12:23:26 +02:00
lebaudantoine 2b1f863a78 (frontend) add persistence for subscribed video resolution preferences
Implement user choice persistence for video resolution settings of
subscribed tracks from other participants.

Maintains user preferences for received video quality across sessions,
allowing consistent bandwidth optimization and viewing experience
without reconfiguring subscription settings each visit.
2025-08-20 12:23:26 +02:00
lebaudantoine b8d9bc7921 (frontend) add video resolution selector for publishing control
Introduce select option allowing users to set maximum publishing
resolution that instantly changes video track resolution for other
participants.

Essential for low bandwidth networks and follows common patterns across
major videoconferencing solutions. Users can optimize their video
quality based on network conditions without leaving the call.
2025-08-20 12:23:26 +02:00
lebaudantoine 2e6560570a (frontend) add persistence for video publishing resolution setting
Implement user choice persistence for video publishing resolution
configuration to maintain user preferences across sessions.

Stores selected video resolution in user settings, ensuring consistent
video quality preferences without requiring reconfiguration on each
visit.
2025-08-20 12:23:26 +02:00
lebaudantoine f08fe90f08 (frontend) add video tab to settings modal for camera configuration
Introduce new video tab in settings modal requested by users who found
it misleading to lack camera configuration options in settings.

Currently implements basic camera device selection. Future commits will
expand functionality to include resolution management, subscription
settings, and other video-related configurations.
2025-08-13 22:52:24 +02:00
lebaudantoine e4a70fdda6 ️(frontend) fix incorrect aria label on tab component
Correct wrong aria label that was accidentally copied from another
component during development.
2025-08-13 10:34:48 +02:00
232 changed files with 2018 additions and 8081 deletions
+4 -48
View File
@@ -31,10 +31,7 @@ jobs:
-
name: Login to DockerHub
if: github.event_name != 'pull_request'
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKER_HUB_USER }}
password: ${{ secrets.DOCKER_HUB_PASSWORD }}
run: echo "${{ secrets.DOCKER_HUB_PASSWORD }}" | docker login -u "${{ secrets.DOCKER_HUB_USER }}" --password-stdin
-
name: Run trivy scan
uses: numerique-gouv/action-trivy-cache@main
@@ -67,10 +64,7 @@ jobs:
-
name: Login to DockerHub
if: github.event_name != 'pull_request'
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKER_HUB_USER }}
password: ${{ secrets.DOCKER_HUB_PASSWORD }}
run: echo "${{ secrets.DOCKER_HUB_PASSWORD }}" | docker login -u "${{ secrets.DOCKER_HUB_USER }}" --password-stdin
-
name: Run trivy scan
uses: numerique-gouv/action-trivy-cache@main
@@ -104,10 +98,7 @@ jobs:
-
name: Login to DockerHub
if: github.event_name != 'pull_request'
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKER_HUB_USER }}
password: ${{ secrets.DOCKER_HUB_PASSWORD }}
run: echo "${{ secrets.DOCKER_HUB_PASSWORD }}" | docker login -u "${{ secrets.DOCKER_HUB_USER }}" --password-stdin
-
name: Run trivy scan
uses: numerique-gouv/action-trivy-cache@main
@@ -141,10 +132,7 @@ jobs:
-
name: Login to DockerHub
if: github.event_name != 'pull_request'
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKER_HUB_USER }}
password: ${{ secrets.DOCKER_HUB_PASSWORD }}
run: echo "${{ secrets.DOCKER_HUB_PASSWORD }}" | docker login -u "${{ secrets.DOCKER_HUB_USER }}" --password-stdin
-
name: Build and push
uses: docker/build-push-action@v6
@@ -157,44 +145,12 @@ jobs:
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
build-and-push-agents:
runs-on: ubuntu-latest
steps:
-
name: Checkout repository
uses: actions/checkout@v4
-
name: Docker meta
id: meta
uses: docker/metadata-action@v5
with:
images: lasuite/meet-agents
-
name: Login to DockerHub
if: github.event_name != 'pull_request'
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKER_HUB_USER }}
password: ${{ secrets.DOCKER_HUB_PASSWORD }}
-
name: Build and push
uses: docker/build-push-action@v6
with:
context: ./src/agents
file: ./src/agents/Dockerfile
target: production
build-args: DOCKER_USER=${{ env.DOCKER_USER }}:-1000
push: ${{ github.event_name != 'pull_request' }}
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
notify-argocd:
needs:
- build-and-push-frontend-generic
- build-and-push-frontend-dinum
- build-and-push-backend
- build-and-push-summary
- build-and-push-agents
runs-on: ubuntu-latest
if: github.event_name != 'pull_request'
steps:
-46
View File
@@ -20,18 +20,14 @@ jobs:
- name: show
run: git log
- name: Enforce absence of print statements in code
if: always()
run: |
! git diff origin/${{ github.event.pull_request.base.ref }}..HEAD -- . ':(exclude)**/meet.yml' | grep "print("
- name: Check absence of fixup commits
if: always()
run: |
! git log | grep 'fixup!'
- name: Install gitlint
if: always()
run: pip install --user requests gitlint
- name: Lint commit messages added to main
if: always()
run: ~/.local/bin/gitlint --commits origin/${{ github.event.pull_request.base.ref }}..HEAD
build-mails:
@@ -86,7 +82,6 @@ jobs:
uses: actions/setup-python@v5
with:
python-version: "3.13"
cache: "pip"
- name: Install development dependencies
run: pip install --user .[dev]
- name: Check code formatting with ruff
@@ -96,46 +91,6 @@ jobs:
- name: Lint code with pylint
run: ~/.local/bin/pylint meet demo core
lint-agents:
runs-on: ubuntu-latest
defaults:
run:
working-directory: src/agents
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Install Python
uses: actions/setup-python@v5
with:
python-version: "3.13"
cache: "pip"
- name: Install development dependencies
run: pip install --user .[dev]
- name: Check code formatting with ruff
run: ~/.local/bin/ruff format . --diff
- name: Lint code with ruff
run: ~/.local/bin/ruff check .
lint-summary:
runs-on: ubuntu-latest
defaults:
run:
working-directory: src/summary
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Install Python
uses: actions/setup-python@v5
with:
python-version: "3.13"
cache: "pip"
- name: Install development dependencies
run: pip install --user .[dev]
- name: Check code formatting with ruff
run: ~/.local/bin/ruff format . --diff
- name: Lint code with ruff
run: ~/.local/bin/ruff check .
test-back:
runs-on: ubuntu-latest
needs: build-mails
@@ -231,7 +186,6 @@ jobs:
uses: actions/setup-python@v5
with:
python-version: "3.13"
cache: "pip"
- name: Install development dependencies
run: pip install --user .[dev]
+1 -86
View File
@@ -45,7 +45,6 @@ COMPOSE_RUN = $(COMPOSE) run --rm
COMPOSE_RUN_APP = $(COMPOSE_RUN) app-dev
COMPOSE_RUN_CROWDIN = $(COMPOSE_RUN) crowdin crowdin
WAIT_DB = @$(COMPOSE_RUN) dockerize -wait tcp://$(DB_HOST):$(DB_PORT) -timeout 60s
WAIT_MINIO = @$(COMPOSE_RUN) dockerize -wait tcp://minio:9000 -timeout 60s
# -- Backend
MANAGE = $(COMPOSE_RUN_APP) python manage.py
@@ -54,21 +53,6 @@ MAIL_NPM = $(COMPOSE_RUN) -w /app/src/mail node npm
# -- Frontend
PATH_FRONT = ./src/frontend
# -- MinIO / Webhook
MINIO_ALIAS ?= meet
MINIO_ENDPOINT ?= http://127.0.0.1:9000
MINIO_ACCESS_KEY ?= meet
MINIO_SECRET_KEY ?= password
MINIO_BUCKET ?= meet-media-storage
MINIO_WEBHOOK_NAME ?= recording
MINIO_WEBHOOK_ENDPOINT ?= http://app-dev:8000/api/v1.0/recordings/storage-hook/
MINIO_QUEUE_DIR ?= /data/minio/events
MINIO_QUEUE_LIMIT ?= 100000
STORAGE_EVENT_TOKEN ?= password
# ==============================================================================
# RULES
@@ -87,8 +71,7 @@ create-env-files: \
env.d/development/common \
env.d/development/crowdin \
env.d/development/postgresql \
env.d/development/kc_postgresql \
env.d/development/summary
env.d/development/kc_postgresql
.PHONY: create-env-files
bootstrap: ## Prepare Docker images for the project
@@ -108,7 +91,6 @@ bootstrap: \
build: ## build the project containers
@$(MAKE) build-backend
@$(MAKE) build-frontend
@$(MAKE) build-agents
.PHONY: build
build-backend: ## build the app-dev container
@@ -120,10 +102,6 @@ build-frontend: ## build the frontend container
@$(COMPOSE) build frontend
.PHONY: build-frontend
build-agents: ## build the agents image(s)
@$(COMPOSE) build metadata-agent
.PHONY: build-agents
down: ## stop and remove containers, networks, images, and volumes
@$(COMPOSE) down
.PHONY: down
@@ -138,20 +116,9 @@ run-backend: ## start only the backend application and all needed services
@$(WAIT_DB)
.PHONY: run-backend
run-summary: ## start only the summary application and all needed services
@$(COMPOSE) up --force-recreate -d celery-summary-transcribe
@$(COMPOSE) up --force-recreate -d celery-summary-summarize
.PHONY: run-summary
run-agents: ## start agents
@$(COMPOSE) up --force-recreate -d metadata-agent
.PHONY: run-agents
run:
run: ## start the wsgi (production) and development server
@$(MAKE) run-backend
@$(MAKE) run-summary
@$(MAKE) run-agents
@$(COMPOSE) up --force-recreate -d frontend
.PHONY: run
@@ -163,55 +130,6 @@ stop: ## stop the development server using Docker
@$(COMPOSE) stop
.PHONY: stop
# -- MinIO webhook (configuration & events)
minio-wait: ## wait for minio to be ready
@echo "$(BOLD)Waiting for MinIO$(RESET)"
$(WAIT_MINIO)
.PHONY: minio-wait
minio-queue-dir: minio-wait ## ensure queue dir exists in MinIO container
@echo "$(BOLD)Ensuring MinIO queue dir$(RESET)"
@$(COMPOSE) exec minio sh -lc 'mkdir -p $(MINIO_QUEUE_DIR) && chmod -R 777 $(dir $(MINIO_QUEUE_DIR)) || true'
.PHONY: minio-queue-dir
minio-alias: minio-wait ## set mc alias to MinIO
@echo "$(BOLD)Setting mc alias $(MINIO_ALIAS) -> $(MINIO_ENDPOINT)$(RESET)"
@mc alias set $(MINIO_ALIAS) $(MINIO_ENDPOINT) $(MINIO_ACCESS_KEY) $(MINIO_SECRET_KEY) >/dev/null
.PHONY: minio-alias
minio-webhook-config: minio-alias minio-queue-dir ## configure webhook on MinIO
@echo "$(BOLD)Configuring MinIO webhook $(MINIO_WEBHOOK_NAME)$(RESET)"
@mc admin config set $(MINIO_ALIAS) notify_webhook:$(MINIO_WEBHOOK_NAME) \
endpoint="$(MINIO_WEBHOOK_ENDPOINT)" \
auth_token="Bearer $(STORAGE_EVENT_TOKEN)" \
queue_dir="$(MINIO_QUEUE_DIR)" \
queue_limit="$(MINIO_QUEUE_LIMIT)"
.PHONY: minio-webhook-config
minio-restart: minio-alias ## restart MinIO after config change
@echo "$(BOLD)Restarting MinIO service$(RESET)"
@mc admin service restart $(MINIO_ALIAS)
.PHONY: minio-restart
minio-events-reset: minio-alias ## remove all bucket notifications
@echo "$(BOLD)Removing existing bucket events on $(MINIO_BUCKET)$(RESET)"
@mc event remove --force $(MINIO_ALIAS)/$(MINIO_BUCKET) >/dev/null || true
.PHONY: minio-events-reset
minio-event-add: minio-alias ## add put event -> webhook
@echo "$(BOLD)Adding put event -> webhook $(MINIO_WEBHOOK_NAME)$(RESET)"
@mc event add $(MINIO_ALIAS)/$(MINIO_BUCKET) arn:minio:sqs::$(MINIO_WEBHOOK_NAME):webhook --event put
@mc event list $(MINIO_ALIAS)/$(MINIO_BUCKET)
.PHONY: minio-event-add
minio-webhook-setup: ## full setup: alias, config, restart, reset events, add event
minio-webhook-setup: \
minio-webhook-config \
minio-restart \
minio-events-reset \
minio-event-add
.PHONY: minio-webhook-setup
# -- Front
frontend-development-install: ## install the frontend locally
@@ -328,9 +246,6 @@ env.d/development/postgresql:
env.d/development/kc_postgresql:
cp -n env.d/development/kc_postgresql.dist env.d/development/kc_postgresql
env.d/development/summary:
cp -n env.d/development/summary.dist env.d/development/summary
# -- Internationalization
env.d/development/crowdin:
+2 -21
View File
@@ -58,26 +58,14 @@ docker_build(
'localhost:5001/meet-summary:latest',
context='../src/summary',
dockerfile='../src/summary/Dockerfile',
only=['.'],
only=['.', '../../docker', '../../.dockerignore'],
target = 'production',
live_update=[
sync('../src/summary', '/app'),
sync('../src/summary', '/home/summary'),
]
)
clean_old_images('localhost:5001/meet-summary')
docker_build(
'localhost:5001/meet-agents:latest',
context='../src/agents',
dockerfile='../src/agents/Dockerfile',
only=['.'],
target = 'production',
live_update=[
sync('../src/agents', '/app'),
]
)
clean_old_images('localhost:5001/meet-agents')
# Copy the mkcert root CA certificate to our Docker build context
# This is necessary because we need to inject the certificate into our LiveKit container
local_resource(
@@ -97,13 +85,6 @@ clean_old_images('localhost:5001/meet-livekit')
k8s_yaml(local('cd ../src/helm && helmfile -n meet -e ${DEV_ENV:-dev} template .'))
k8s_resource('minio-bucket', resource_deps=['minio'])
k8s_resource('meet-backend', resource_deps=['postgresql', 'minio', 'redis', 'livekit-livekit-server'])
k8s_resource('meet-backend-migrate', resource_deps=['meet-backend'])
k8s_resource('livekit-livekit-server-test-connection', resource_deps=['livekit-livekit-server'])
k8s_resource('keycloak', resource_deps=['kc-postgresql'])
k8s_resource('meet-backend-createsuperuser', resource_deps=['meet-backend-migrate'])
migration = '''
set -eu
# get k8s pod name from tilt resource name
-97
View File
@@ -221,100 +221,3 @@ services:
- ./docker/livekit/out:/out
depends_on:
- redis
redis-summary:
image: redis
ports:
- "6379:6379"
app-summary-dev:
build:
context: src/summary
target: development
args:
DOCKER_USER: ${DOCKER_USER:-1000}
user: ${DOCKER_USER:-1000}
env_file:
- env.d/development/summary
ports:
- "8001:8000"
volumes:
- ./src/summary:/app
depends_on:
- redis-summary
celery-summary-transcribe:
container_name: celery-summary-transcribe
build:
context: ./src/summary
dockerfile: Dockerfile
target: production
command: celery -A summary.core.celery_worker worker --pool=solo --loglevel=debug -Q transcribe-queue
env_file:
- env.d/development/summary
volumes:
- ./src/summary:/app
depends_on:
- redis-summary
- app-summary-dev
- minio
develop:
watch:
- action: rebuild
path: ./src/summary
celery-summary-summarize:
container_name: celery-summary-summarize
build:
context: ./src/summary
dockerfile: Dockerfile
target: production
command: celery -A summary.core.celery_worker worker --pool=solo --loglevel=debug -Q summarize-queue
env_file:
- env.d/development/summary
volumes:
- ./src/summary:/app
depends_on:
- redis-summary
- app-summary-dev
- minio
develop:
watch:
- action: rebuild
path: ./src/summary
metadata-agent:
build:
context: ./src/agents
dockerfile: Dockerfile
working_dir: /app
command: ["python", "metadata_extractor.py", "start"]
environment:
LIVEKIT_URL: "ws://livekit:7880"
LIVEKIT_API_KEY: "devkey"
LIVEKIT_API_SECRET: "secret"
ROOM_METADATA_AGENT_NAME: "metadata-extractor"
AWS_S3_ENDPOINT_URL: "minio:9000"
AWS_S3_ACCESS_KEY_ID: "meet"
AWS_S3_SECRET_ACCESS_KEY: "password"
AWS_S3_SECURE_ACCESS: "false"
PYTHONUNBUFFERED: "1"
AWS_STORAGE_BUCKET_NAME: "meet-media-storage"
AWS_S3_REGION_NAME: "local"
depends_on:
- livekit
- minio
transcriber-agent:
build:
context: ./src/agents
dockerfile: Dockerfile
working_dir: /app
command: ["python", "multi-user-transcriber.py", "start"]
environment:
LIVEKIT_URL: "ws://livekit:7880"
LIVEKIT_API_KEY: "devkey"
LIVEKIT_API_SECRET: "secret"
PYTHONUNBUFFERED: "1"
depends_on:
- livekit
+1 -1
View File
@@ -1,4 +1,4 @@
FROM livekit/livekit-server:v1.9.0
FROM livekit/livekit-server:v1.8.0
# We inject the nip.io certificate manually because the livekit chart doesn't support volume mounting
COPY rootCA.pem /etc/ssl/certs/
@@ -1,11 +1,5 @@
log_level: debug
redis:
address: redis:6379
keys:
devkey: secret
webhook:
api_key: devkey
urls:
- "http://app-dev:8000/api/v1.0/rooms/webhooks-livekit/"
+1 -1
View File
@@ -142,4 +142,4 @@ Once the Kubernetes cluster is ready, start the application stack locally:
$ make start-tilt-keycloak
```
Monitor Tilts progress at [http://localhost:10350/](http://localhost:10350/). After Tilt actions finish, you can access the app at [https://meet.127.0.0.1.nip.io/](https://meet.127.0.0.1.nip.io/).
Monitor Tilts progress at [http://localhost:10350/](http://localhost:10350/). After Tilt actions finish, you can access the app at [https://visio.127.0.0.1.nip.io/](https://visio.127.0.0.1.nip.io/).
-5
View File
@@ -53,11 +53,6 @@ LIVEKIT_VERIFY_SSL=False
ALLOW_UNREGISTERED_ROOMS=False
# Recording
RECORDING_ENABLE=True
RECORDING_STORAGE_EVENT_ENABLE=True
RECORDING_STORAGE_EVENT_TOKEN=password
SUMMARY_SERVICE_ENDPOINT=http://app-summary-dev:8000/api/v1/tasks/
SUMMARY_SERVICE_API_TOKEN=password
SCREEN_RECORDING_BASE_URL=http://localhost:3000/recordings
# Telephony
-23
View File
@@ -1,23 +0,0 @@
APP_NAME="meet-app-summary-dev"
APP_API_TOKEN="password"
AWS_STORAGE_BUCKET_NAME="meet-media-storage"
AWS_S3_ENDPOINT_URL="minio:9000"
AWS_S3_SECURE_ACCESS=false
AWS_S3_ACCESS_KEY_ID="meet"
AWS_S3_SECRET_ACCESS_KEY="password"
WHISPERX_BASE_URL="https://configure-your-url.com"
WHISPERX_ASR_MODEL="large-v2"
WHISPERX_API_KEY="your-secret-key"
LLM_BASE_URL="https://configure-your-url.com"
LLM_API_KEY="dev-apikey"
LLM_MODEL="Qwen/Qwen2.5-Coder-32B-Instruct-AWQ"
WEBHOOK_API_TOKEN="secret"
WEBHOOK_URL="https://configure-your-url.com"
POSTHOG_API_KEY="your-posthog-key"
POSTHOG_ENABLED="False"
-22
View File
@@ -1,22 +0,0 @@
FROM python:3.13-slim AS base
FROM base AS builder
WORKDIR /builder
COPY pyproject.toml .
RUN mkdir /install && \
pip install --prefix=/install .
FROM base AS production
WORKDIR /app
ARG DOCKER_USER
USER ${DOCKER_USER}
# Un-privileged user running the application
COPY --from=builder /install /usr/local
COPY . .
-395
View File
@@ -1,395 +0,0 @@
"""Metadata agent that extracts metadata from active room."""
import asyncio
import json
import logging
import os
from dataclasses import asdict, dataclass
from datetime import datetime, timezone
from io import BytesIO
from typing import List, Optional
from dotenv import find_dotenv, load_dotenv
from livekit import api, rtc
from livekit.agents import (
Agent,
AgentSession,
AutoSubscribe,
JobContext,
JobProcess,
JobRequest,
RoomInputOptions,
RoomIO,
RoomOutputOptions,
WorkerOptions,
WorkerPermissions,
cli,
utils,
)
from livekit.plugins import silero
from minio import Minio
from minio.error import S3Error
load_dotenv()
logger = logging.getLogger("metadata-extractor")
AGENT_NAME = os.getenv("ROOM_METADATA_EXTRACTOR_AGENT_NAME", "metadata-extractor")
@dataclass
class MetadataEvent:
"""Wip."""
participant_id: str
type: str
timestamp: datetime
data: Optional[str] = None
def serialize(self) -> dict:
"""Return a JSON-serializable dictionary representation of the event."""
data = asdict(self)
data["timestamp"] = self.timestamp.isoformat()
return data
class VADAgent(Agent):
"""Agent that monitors voice activity for a specific participant."""
def __init__(self, participant_identity: str, events: List):
"""Wip."""
super().__init__(
instructions="not-needed",
)
self.participant_identity = participant_identity
self.events = events
async def on_enter(self) -> None:
"""Initialize VAD monitoring for this participant."""
@self.session.on("user_state_changed")
def on_user_state(event):
timestamp = datetime.now(timezone.utc)
if event.new_state == "speaking":
event = MetadataEvent(
participant_id=self.participant_identity,
type="speech_start",
timestamp=timestamp,
)
self.events.append(event)
elif event.old_state == "speaking":
event = MetadataEvent(
participant_id=self.participant_identity,
type="speech_end",
timestamp=timestamp,
)
self.events.append(event)
class MetadataAgent:
"""Monitor and manage real-time metadata extraction from meeting rooms.
Oversees VAD (Voice Activity Detection) and participant metadata streams
to track and analyze real-time events, coordinating data collection across
participants for insights like speaking activity and engagement.
"""
def __init__(self, ctx: JobContext, recording_id: str):
"""Initialize metadata agent."""
self.minio_client = Minio(
endpoint=os.getenv("AWS_S3_ENDPOINT_URL"),
access_key=os.getenv("AWS_S3_ACCESS_KEY_ID"),
secret_key=os.getenv("AWS_S3_SECRET_ACCESS_KEY"),
secure=os.getenv("AWS_S3_SECURE_ACCESS", "False").lower() == "true",
)
# todo - raise error if none
self.bucket_name = os.getenv("AWS_STORAGE_BUCKET_NAME")
logger.info("Using S3 bucket: %s", self.bucket_name)
dotenv_path = find_dotenv()
logger.info(f"Fichier .env trouvé : {dotenv_path}")
self.ctx = ctx
self._sessions: dict[str, AgentSession] = {}
self._tasks: set[asyncio.Task] = set()
self.output_filename = (
f"{os.getenv('AWS_S3_OUTPUT_FOLDER', 'recordings').strip('/')}/"
f"{json.loads(recording_id).get('recording_id')}-metadata.json"
)
# Storage for events
self.events = []
self.participants = {}
logger.info("MetadataAgent initialized")
def start(self):
"""Start listening for participant connection events."""
self.ctx.room.on("participant_connected", self.on_participant_connected)
self.ctx.room.on("participant_disconnected", self.on_participant_disconnected)
self.ctx.room.on("participant_name_changed", self.on_participant_name_changed)
self.ctx.room.register_text_stream_handler("lk.chat", self.handle_chat_stream)
logger.info("Started listening for participant events")
async def on_chat_message_received(
self, reader: rtc.TextStreamReader, participant_identity: str
):
"""Wip."""
full_text = await reader.read_all()
logger.info(
"Received chat message from %s: '%s'", participant_identity, full_text
)
self.events.append(
MetadataEvent(
participant_id=participant_identity,
type="chat_received",
timestamp=datetime.now(timezone.utc),
data=full_text,
)
)
def handle_chat_stream(self, reader, participant_identity):
"""Wip."""
task = asyncio.create_task(
self.on_chat_message_received(reader, participant_identity)
)
self._tasks.add(task)
task.add_done_callback(lambda _: self._tasks.remove(task))
def save(self):
"""Wip."""
logger.info("Persisting processed metadata output to disk…")
participants = []
for k, v in self.participants.items():
participants.append({"participantId": k, "name": v})
sorted_event = sorted(self.events, key=lambda e: e.timestamp)
payload = {
"events": [event.serialize() for event in sorted_event],
"participants": participants,
}
data = json.dumps(payload, indent=2).encode("utf-8")
stream = BytesIO(data)
try:
self.minio_client.put_object(
self.bucket_name,
self.output_filename,
stream,
length=len(data),
content_type="application/json",
)
logger.info(
"Uploaded speaker meeting metadata",
)
except S3Error:
logger.exception(
"Failed to upload meeting metadata",
)
async def aclose(self):
"""Close all sessions and cleanup resources."""
logger.info("Closing all VAD monitoring sessions…")
await utils.aio.cancel_and_wait(*self._tasks)
await asyncio.gather(
*[self._close_session(session) for session in self._sessions.values()],
return_exceptions=True,
)
self.ctx.room.off("participant_connected", self.on_participant_connected)
self.ctx.room.off("participant_disconnected", self.on_participant_disconnected)
self.ctx.room.off("participant_name_changed", self.on_participant_name_changed)
logger.info("All VAD sessions closed")
self.save()
def on_participant_connected(self, participant: rtc.RemoteParticipant):
"""Handle new participant connection by starting VAD monitoring."""
if participant.identity in self._sessions:
logger.debug("Session already exists for %s", participant.identity)
return
self.events.append(
MetadataEvent(
participant_id=participant.identity,
type="participant_connected",
timestamp=datetime.now(timezone.utc),
)
)
self.participants[participant.identity] = participant.name
logger.info("New participant connected: %s", participant.identity)
task = asyncio.create_task(self._start_session(participant))
self._tasks.add(task)
def on_task_done(task: asyncio.Task):
try:
self._sessions[participant.identity] = task.result()
except Exception:
logger.exception("Failed to start session for %s", participant.identity)
finally:
self._tasks.discard(task)
task.add_done_callback(on_task_done)
def on_participant_disconnected(self, participant: rtc.RemoteParticipant):
"""Handle participant disconnection by closing VAD monitoring."""
self.events.append(
MetadataEvent(
participant_id=participant.identity,
type="participant_disconnected",
timestamp=datetime.now(timezone.utc),
)
)
session = self._sessions.pop(participant.identity, None)
if session is None:
logger.debug("No session found for %s", participant.identity)
return
logger.info("Participant disconnected: %s", participant.identity)
task = asyncio.create_task(self._close_session(session))
self._tasks.add(task)
def on_close_done(_):
self._tasks.discard(task)
logger.info(
"VAD session closed for %s (remaining sessions: %d)",
participant.identity,
len(self._sessions),
)
task.add_done_callback(on_close_done)
def on_participant_name_changed(self, participant: rtc.RemoteParticipant):
"""Wip."""
logger.info("Participant's name changed: %s", participant.identity)
self.participants[participant.identity] = participant.name
async def _start_session(self, participant: rtc.RemoteParticipant) -> AgentSession:
"""Create and start VAD monitoring session for participant."""
if participant.identity in self._sessions:
return self._sessions[participant.identity]
# Create session with VAD only - no STT, LLM, or TTS
session = AgentSession(
vad=self.ctx.proc.userdata["vad"],
turn_detection="vad",
user_away_timeout=30.0,
)
# Set up room IO to receive audio from this specific participant
room_io = RoomIO(
agent_session=session,
room=self.ctx.room,
participant=participant,
input_options=RoomInputOptions(
audio_enabled=True,
text_enabled=False,
),
output_options=RoomOutputOptions(
audio_enabled=False,
transcription_enabled=False,
),
)
await room_io.start()
await session.start(
agent=VADAgent(
participant_identity=participant.identity, events=self.events
)
)
return session
async def _close_session(self, session: AgentSession) -> None:
"""Close and cleanup VAD monitoring session."""
try:
await session.drain()
await session.aclose()
except Exception:
logger.exception("Error closing session")
async def entrypoint(ctx: JobContext):
"""Initialize and run the multi-user VAD monitor."""
logger.info("Starting metadata agent in room: %s", ctx.room.name)
recording_id = ctx.job.metadata
logger.info("Recording ID: %s", recording_id)
vad_monitor = MetadataAgent(ctx, recording_id)
vad_monitor.start()
# Connect to room and subscribe to audio only
await ctx.connect(auto_subscribe=AutoSubscribe.AUDIO_ONLY)
existing_participants = list(ctx.room.remote_participants.values())
for participant in existing_participants:
vad_monitor.on_participant_connected(participant)
async def cleanup():
logger.info("Shutting down VAD monitor...")
await vad_monitor.aclose()
ctx.add_shutdown_callback(cleanup)
async def handle_job_request(job_req: JobRequest) -> None:
"""Accept or reject the job request based on agent presence in the room."""
room_name = job_req.room.name
recording_id = job_req.job.metadata
agent_identity = f"{AGENT_NAME}-{room_name}"
async with api.LiveKitAPI() as lk:
try:
resp = await lk.room.list_participants(
list=api.ListParticipantsRequest(room=room_name)
)
already_present = any(
p.kind == rtc.ParticipantKind.PARTICIPANT_KIND_AGENT
and p.identity == agent_identity
for p in resp.participants
)
if already_present:
logger.info("Agent already in the room '%s' — reject", room_name)
await job_req.reject()
else:
logger.info(
"Accept job for '%s' — identity=%s", room_name, agent_identity
)
await job_req.accept(identity=agent_identity, metadata=recording_id)
except Exception:
logger.exception("Error treating the job for '%s'", room_name)
await job_req.reject()
def prewarm(proc: JobProcess):
"""Preload voice activity detection model."""
proc.userdata["vad"] = silero.VAD.load()
if __name__ == "__main__":
cli.run_app(
WorkerOptions(
entrypoint_fnc=entrypoint,
prewarm_fnc=prewarm,
request_fnc=handle_job_request,
agent_name=AGENT_NAME,
permissions=WorkerPermissions(
can_publish=False,
can_publish_data=False,
can_subscribe=True,
hidden=True,
),
)
)
-189
View File
@@ -1,189 +0,0 @@
"""Multi user transcription agent."""
import asyncio
import logging
import os
from dotenv import load_dotenv
from livekit import api, rtc
from livekit.agents import (
Agent,
AgentSession,
AutoSubscribe,
JobContext,
JobProcess,
JobRequest,
RoomInputOptions,
RoomIO,
RoomOutputOptions,
WorkerOptions,
WorkerPermissions,
cli,
utils,
)
from livekit.plugins import deepgram, silero
load_dotenv()
logger = logging.getLogger("transcriber")
TRANSCRIBER_AGENT_NAME = os.getenv("TRANSCRIBER_AGENT_NAME", "multi-user-transcriber")
class Transcriber(Agent):
"""Create a transcription agent for a specific participant."""
def __init__(self, *, participant_identity: str):
"""Init transcription agent."""
super().__init__(
instructions="not-needed",
stt=deepgram.STT(),
)
self.participant_identity = participant_identity
class MultiUserTranscriber:
"""Manage transcription sessions for multiple room participants."""
def __init__(self, ctx: JobContext):
"""Init multi user transcription agent."""
self.ctx = ctx
self._sessions: dict[str, AgentSession] = {}
self._tasks: set[asyncio.Task] = set()
def start(self):
"""Start listening for participant connection events."""
self.ctx.room.on("participant_connected", self.on_participant_connected)
self.ctx.room.on("participant_disconnected", self.on_participant_disconnected)
async def aclose(self):
"""Close all sessions and cleanup resources."""
await utils.aio.cancel_and_wait(*self._tasks)
await asyncio.gather(
*[self._close_session(session) for session in self._sessions.values()]
)
self.ctx.room.off("participant_connected", self.on_participant_connected)
self.ctx.room.off("participant_disconnected", self.on_participant_disconnected)
def on_participant_connected(self, participant: rtc.RemoteParticipant):
"""Handle new participant connection by starting transcription session."""
if participant.identity in self._sessions:
return
logger.info(f"starting session for {participant.identity}")
task = asyncio.create_task(self._start_session(participant))
self._tasks.add(task)
def on_task_done(task: asyncio.Task):
try:
self._sessions[participant.identity] = task.result()
finally:
self._tasks.discard(task)
task.add_done_callback(on_task_done)
def on_participant_disconnected(self, participant: rtc.RemoteParticipant):
"""Handle participant disconnection by closing transcription session."""
if (session := self._sessions.pop(participant.identity)) is None:
return
logger.info(f"closing session for {participant.identity}")
task = asyncio.create_task(self._close_session(session))
self._tasks.add(task)
task.add_done_callback(lambda _: self._tasks.discard(task))
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]
session = AgentSession(
vad=self.ctx.proc.userdata["vad"],
)
room_io = RoomIO(
agent_session=session,
room=self.ctx.room,
participant=participant,
input_options=RoomInputOptions(
text_enabled=False,
),
output_options=RoomOutputOptions(
transcription_enabled=True,
audio_enabled=False,
),
)
await room_io.start()
await session.start(
agent=Transcriber(
participant_identity=participant.identity,
)
)
return session
async def _close_session(self, sess: AgentSession) -> None:
"""Close and cleanup transcription session."""
await sess.drain()
await sess.aclose()
async def entrypoint(ctx: JobContext):
"""Initialize and run the multi-user transcriber."""
transcriber = MultiUserTranscriber(ctx)
transcriber.start()
await ctx.connect(auto_subscribe=AutoSubscribe.AUDIO_ONLY)
for participant in ctx.room.remote_participants.values():
transcriber.on_participant_connected(participant)
async def cleanup():
await transcriber.aclose()
ctx.add_shutdown_callback(cleanup)
async def handle_transcriber_job_request(job_req: JobRequest) -> None:
"""Accept job if no transcriber exists in room, otherwise reject."""
room_name = job_req.room.name
transcriber_id = f"{TRANSCRIBER_AGENT_NAME}-{room_name}"
async with api.LiveKitAPI() as lkapi:
try:
response = await lkapi.room.list_participants(
list=api.ListParticipantsRequest(room=room_name)
)
transcriber_exists = any(
p.kind == rtc.ParticipantKind.PARTICIPANT_KIND_AGENT
and p.identity == transcriber_id
for p in response.participants
)
if transcriber_exists:
logger.info(f"Transcriber exists in {room_name} - rejecting")
await job_req.reject()
else:
logger.info(f"Accepting job for {room_name}")
await job_req.accept(identity=transcriber_id)
except Exception:
logger.exception(f"Error processing job for {room_name}")
await job_req.reject()
def prewarm(proc: JobProcess):
"""Preload voice activity detection model."""
proc.userdata["vad"] = silero.VAD.load()
if __name__ == "__main__":
cli.run_app(
WorkerOptions(
entrypoint_fnc=entrypoint,
request_fnc=handle_transcriber_job_request,
prewarm_fnc=prewarm,
agent_name=TRANSCRIBER_AGENT_NAME,
permissions=WorkerPermissions(hidden=True),
)
)
-52
View File
@@ -1,52 +0,0 @@
[project]
name = "agents"
version = "0.1.38"
requires-python = ">=3.12"
dependencies = [
"livekit-agents==1.2.6",
"livekit-plugins-deepgram==1.2.6",
"livekit-plugins-silero==1.2.6",
"python-dotenv==1.1.1",
"minio==7.2.15",
]
[project.optional-dependencies]
dev = [
"ruff==0.12.0",
]
[build-system]
requires = ["setuptools>=61.0"]
build-backend = "setuptools.build_meta"
[tool.ruff]
target-version = "py313"
[tool.ruff.lint]
select = [
"B", # flake8-bugbear
"C4", # flake8-comprehensions
"D", # pydocstyle
"E", # pycodestyle error
"F", # Pyflakes
"I", # Isort
"ISC", # flake8-implicit-str-concat
"PLC", # Pylint Convention
"PLE", # Pylint Error
"PLR", # Pylint Refactor
"PLW", # Pylint Warning
"RUF100", # Ruff unused-noqa
"S", # flake8-bandit
"T20", # flake8-print
"W", # pycodestyle warning
]
[tool.ruff.lint.per-file-ignores]
"tests/*" = [
"S101", # use of assert
]
[tool.ruff.lint.pydocstyle]
# Use Google-style docstrings.
convention = "google"
-2
View File
@@ -50,12 +50,10 @@ def get_frontend_configuration(request):
else None,
"default_country": settings.ROOM_TELEPHONY_DEFAULT_COUNTRY,
},
"subtitle": {"enabled": settings.ROOM_SUBTITLE_ENABLED},
"livekit": {
"url": settings.LIVEKIT_CONFIGURATION["url"],
"force_wss_protocol": settings.LIVEKIT_FORCE_WSS_PROTOCOL,
"enable_firefox_proxy_workaround": settings.LIVEKIT_ENABLE_FIREFOX_PROXY_WORKAROUND,
"default_sources": settings.LIVEKIT_DEFAULT_SOURCES,
},
}
frontend_configuration.update(settings.FRONTEND_CONFIGURATION)
-90
View File
@@ -1,90 +0,0 @@
"""Feature flag handler for the Meet core app."""
import logging
import os
from functools import wraps
from logging import getLogger
from django.conf import settings
from django.http import Http404
from posthog import Posthog
logging.basicConfig(level=logging.DEBUG)
logger = getLogger(__name__)
class FeatureFlagError(Exception):
"""Feature flag management error."""
class FeatureFlag:
"""Feature flag management using Django settings and PostHog."""
FLAGS = {
"metadata_agent": {"posthog": "is_metadata_agent_enabled"},
"recording": {"setting": "RECORDING_ENABLE"},
"storage_event": {"setting": "RECORDING_STORAGE_EVENT_ENABLE"},
"subtitle": {"setting": "ROOM_SUBTITLE_ENABLED"},
}
_ph_client = None
@classmethod
def _get_ph_client(cls):
"""Initialize and return PostHog client if configured."""
if cls._ph_client is not None:
return cls._ph_client
api_key = os.getenv("POSTHOG_API_KEY")
host = os.getenv("POSTHOG_API_HOST", "https://eu.i.posthog.com")
logging.info("PostHog config: api_key=%s, host=%s", api_key, host)
if Posthog and api_key and host:
logging.info("Initializing PostHog client")
cls._ph_client = Posthog(project_api_key=api_key, host=host)
logging.info("PostHog client initialized: %s", bool(cls._ph_client))
return cls._ph_client
@classmethod
def flag_is_active(cls, flag_name, *, distinct_id=None, default=False):
"""Check if a feature flag is active."""
cfg = cls.FLAGS.get(flag_name)
if not cfg:
return False
setting_name = cfg.get("setting")
if setting_name is not None:
return bool(getattr(settings, setting_name, False))
posthog_flag = cfg.get("posthog")
if posthog_flag:
ph = cls._get_ph_client()
if ph and distinct_id:
try:
logger.info(
"Checking PostHog flag %s for id=%s", posthog_flag, distinct_id
)
return bool(ph.feature_enabled(posthog_flag, distinct_id))
except FeatureFlagError as e:
logging.error("Error checking feature flag %s: %s", flag_name, e)
return default
return default
return default
@classmethod
def require(cls, flag_name):
"""Decorator to check feature at the beginning of endpoint methods."""
if flag_name not in cls.FLAGS:
raise ValueError(f"Unknown feature flag: {flag_name}")
def decorator(view_func):
@wraps(view_func)
def wrapper(self, request, *args, **kwargs):
if not cls.flag_is_active(flag_name):
raise Http404
return view_func(self, request, *args, **kwargs)
return wrapper
return decorator
+19 -6
View File
@@ -1,5 +1,7 @@
"""Permission handlers for the Meet core app."""
from django.conf import settings
from rest_framework import permissions
from ..models import RoleChoices
@@ -99,10 +101,21 @@ class HasPrivilegesOnRoom(IsAuthenticated):
return obj.is_administrator_or_owner(request.user)
class HasLiveKitRoomAccess(permissions.BasePermission):
"""Check if authenticated user's LiveKit token is for the specific room."""
class IsRecordingEnabled(permissions.BasePermission):
"""Check if the recording feature is enabled."""
def has_object_permission(self, request, view, obj):
if not request.auth or not hasattr(request.auth, "video"):
return False
return request.auth.video.room == str(obj.id)
message = "Access denied, recording is disabled."
def has_permission(self, request, view):
"""Determine if access is allowed based on settings."""
return settings.RECORDING_ENABLE
class IsStorageEventEnabled(permissions.BasePermission):
"""Check if the storage event feature is enabled."""
message = "Access denied, storage event is disabled."
def has_permission(self, request, view):
"""Determine if access is allowed based on settings."""
return settings.RECORDING_STORAGE_EVENT_ENABLE
+53 -90
View File
@@ -1,10 +1,9 @@
"""Client serializers for the Meet core app."""
# pylint: disable=abstract-method,no-name-in-module
import uuid
from django.utils.translation import gettext_lazy as _
from livekit.api import ParticipantPermission
from rest_framework import serializers
from rest_framework.exceptions import PermissionDenied
from timezone_field.rest_framework import TimeZoneSerializerField
@@ -135,8 +134,6 @@ class RoomSerializer(serializers.ModelSerializer):
)
output["accesses"] = access_serializer.data
configuration = output["configuration"]
if not is_admin_or_owner:
del output["configuration"]
@@ -153,11 +150,7 @@ class RoomSerializer(serializers.ModelSerializer):
room_id = f"{instance.id!s}"
username = request.query_params.get("username", None)
output["livekit"] = utils.generate_livekit_config(
room_id=room_id,
user=request.user,
username=username,
configuration=configuration,
is_admin_or_owner=is_admin_or_owner,
room_id=room_id, user=request.user, username=username
)
output["is_administrable"] = is_admin_or_owner
@@ -186,19 +179,7 @@ class RecordingSerializer(serializers.ModelSerializer):
read_only_fields = fields
class BaseValidationOnlySerializer(serializers.Serializer):
"""Base serializer for validation-only operations."""
def create(self, validated_data):
"""Not implemented as this is a validation-only serializer."""
raise NotImplementedError(f"{self.__class__.__name__} is validation-only")
def update(self, instance, validated_data):
"""Not implemented as this is a validation-only serializer."""
raise NotImplementedError(f"{self.__class__.__name__} is validation-only")
class StartRecordingSerializer(BaseValidationOnlySerializer):
class StartRecordingSerializer(serializers.Serializer):
"""Validate start recording requests."""
mode = serializers.ChoiceField(
@@ -211,93 +192,75 @@ class StartRecordingSerializer(BaseValidationOnlySerializer):
},
)
def create(self, validated_data):
"""Not implemented as this is a validation-only serializer."""
raise NotImplementedError("StartRecordingSerializer is validation-only")
class RequestEntrySerializer(BaseValidationOnlySerializer):
def update(self, instance, validated_data):
"""Not implemented as this is a validation-only serializer."""
raise NotImplementedError("StartRecordingSerializer is validation-only")
class RequestEntrySerializer(serializers.Serializer):
"""Validate request entry data."""
username = serializers.CharField(required=True)
def create(self, validated_data):
"""Not implemented as this is a validation-only serializer."""
raise NotImplementedError("RequestEntrySerializer is validation-only")
class ParticipantEntrySerializer(BaseValidationOnlySerializer):
def update(self, instance, validated_data):
"""Not implemented as this is a validation-only serializer."""
raise NotImplementedError("RequestEntrySerializer is validation-only")
class ParticipantEntrySerializer(serializers.Serializer):
"""Validate participant entry decision data."""
participant_id = serializers.UUIDField(required=True)
participant_id = serializers.CharField(required=True)
allow_entry = serializers.BooleanField(required=True)
def validate_participant_id(self, value):
"""Validate that the participant_id is a valid UUID hex string."""
try:
uuid.UUID(hex=value, version=4)
except (ValueError, TypeError) as e:
raise serializers.ValidationError("Invalid UUID hex format") from e
return value
class CreationCallbackSerializer(BaseValidationOnlySerializer):
def create(self, validated_data):
"""Not implemented as this is a validation-only serializer."""
raise NotImplementedError("ParticipantEntrySerializer is validation-only")
def update(self, instance, validated_data):
"""Not implemented as this is a validation-only serializer."""
raise NotImplementedError("ParticipantEntrySerializer is validation-only")
class CreationCallbackSerializer(serializers.Serializer):
"""Validate room creation callback data."""
callback_id = serializers.CharField(required=True)
def create(self, validated_data):
"""Not implemented as this is a validation-only serializer."""
raise NotImplementedError("CreationCallbackSerializer is validation-only")
def update(self, instance, validated_data):
"""Not implemented as this is a validation-only serializer."""
raise NotImplementedError("CreationCallbackSerializer is validation-only")
class RoomInviteSerializer(serializers.Serializer):
"""Validate room invite creation data."""
emails = serializers.ListField(child=serializers.EmailField(), allow_empty=False)
def create(self, validated_data):
"""Not implemented as this is a validation-only serializer."""
raise NotImplementedError("RoomInviteSerializer is validation-only")
class BaseParticipantsManagementSerializer(BaseValidationOnlySerializer):
"""Base serializer for participant management operations."""
participant_identity = serializers.UUIDField(
help_text="LiveKit participant identity (UUID format)"
)
class MuteParticipantSerializer(BaseParticipantsManagementSerializer):
"""Validate participant muting data."""
track_sid = serializers.CharField(
max_length=255, help_text="LiveKit track SID to mute"
)
class UpdateParticipantSerializer(BaseParticipantsManagementSerializer):
"""Validate participant update data."""
metadata = serializers.DictField(
required=False, allow_null=True, help_text="Participant metadata as JSON object"
)
attributes = serializers.DictField(
required=False,
allow_null=True,
help_text="Participant attributes as JSON object",
)
permission = serializers.DictField(
required=False,
allow_null=True,
help_text="Participant permission as JSON object",
)
name = serializers.CharField(
max_length=255,
required=False,
allow_blank=True,
allow_null=True,
help_text="Display name for the participant",
)
def validate(self, attrs):
"""Ensure at least one update field is provided."""
update_fields = ["metadata", "attributes", "permission", "name"]
has_update = any(
field in attrs and attrs[field] is not None and attrs[field] != ""
for field in update_fields
)
if not has_update:
raise serializers.ValidationError(
f"At least one of the following fields must be provided: "
f"{', '.join(update_fields)}."
)
if "permission" in attrs:
try:
ParticipantPermission(**attrs["permission"])
except ValueError as e:
raise serializers.ValidationError(
{"permission": f"Invalid permission: {str(e)}"}
) from e
return attrs
def update(self, instance, validated_data):
"""Not implemented as this is a validation-only serializer."""
raise NotImplementedError("RoomInviteSerializer is validation-only")
+5 -141
View File
@@ -50,16 +50,9 @@ from core.services.lobby import (
LobbyParticipantNotFound,
LobbyService,
)
from core.services.participants_management import (
ParticipantsManagement,
ParticipantsManagementException,
)
from core.services.room_creation import RoomCreation
from core.services.subtitle import SubtitleException, SubtitleService
from ..authentication.livekit import LiveKitTokenAuthentication
from . import permissions, serializers
from .feature_flag import FeatureFlag
# pylint: disable=too-many-ancestors
@@ -294,13 +287,14 @@ class RoomViewSet(
url_path="start-recording",
permission_classes=[
permissions.HasPrivilegesOnRoom,
permissions.IsRecordingEnabled,
],
)
@FeatureFlag.require("recording")
def start_room_recording(self, request, pk=None): # pylint: disable=unused-argument
"""Start recording a room."""
serializer = serializers.StartRecordingSerializer(data=request.data)
if not serializer.is_valid():
return drf_response.Response(
{"detail": "Invalid request."}, status=drf_status.HTTP_400_BAD_REQUEST
@@ -338,9 +332,9 @@ class RoomViewSet(
url_path="stop-recording",
permission_classes=[
permissions.HasPrivilegesOnRoom,
permissions.IsRecordingEnabled,
],
)
@FeatureFlag.require("recording")
def stop_room_recording(self, request, pk=None): # pylint: disable=unused-argument
"""Stop room recording."""
@@ -423,8 +417,7 @@ class RoomViewSet(
try:
lobby_service.handle_participant_entry(
room_id=room.id,
participant_id=str(serializer.validated_data.get("participant_id")),
allow_entry=serializer.validated_data.get("allow_entry"),
**serializer.validated_data,
)
return drf_response.Response({"message": "Participant was updated."})
@@ -537,135 +530,6 @@ class RoomViewSet(
status=drf_status.HTTP_200_OK,
)
@decorators.action(
detail=True,
methods=["post"],
url_path="start-subtitle",
permission_classes=[
permissions.HasLiveKitRoomAccess,
],
authentication_classes=[LiveKitTokenAuthentication],
)
@FeatureFlag.require("subtitle")
def start_subtitle(self, request, pk=None): # pylint: disable=unused-argument
"""Start realtime transcription for the room.
Requires valid LiveKit token for room authorization.
Anonymous users can start subtitles if they have room access tokens.
"""
room = self.get_object()
try:
SubtitleService().start_subtitle(room)
except SubtitleException:
return drf_response.Response(
{"error": f"Subtitles failed to start for room {room.slug}"},
status=drf_status.HTTP_500_INTERNAL_SERVER_ERROR,
)
return drf_response.Response(
{"status": "success"}, status=drf_status.HTTP_200_OK
)
@decorators.action(
detail=True,
methods=["post"],
url_path="mute-participant",
url_name="mute-participant",
permission_classes=[permissions.HasPrivilegesOnRoom],
)
def mute_participant(self, request, pk=None): # pylint: disable=unused-argument
"""Mute a specific track for a participant in the room."""
room = self.get_object()
serializer = serializers.MuteParticipantSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
try:
ParticipantsManagement().mute(
room_name=str(room.pk),
identity=str(serializer.validated_data["participant_identity"]),
track_sid=serializer.validated_data["track_sid"],
)
except ParticipantsManagementException:
return drf_response.Response(
{"error": "Failed to mute participant"},
status=drf_status.HTTP_500_INTERNAL_SERVER_ERROR,
)
return drf_response.Response(
{
"status": "success",
},
status=drf_status.HTTP_200_OK,
)
@decorators.action(
detail=True,
methods=["post"],
url_path="update-participant",
url_name="update-participant",
permission_classes=[permissions.HasPrivilegesOnRoom],
)
def update_participant(self, request, pk=None): # pylint: disable=unused-argument
"""Update participant attributes, permissions, or metadata."""
room = self.get_object()
serializer = serializers.UpdateParticipantSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
try:
ParticipantsManagement().update(
room_name=str(room.pk),
identity=str(serializer.validated_data["participant_identity"]),
metadata=serializer.validated_data.get("metadata"),
attributes=serializer.validated_data.get("attributes"),
permission=serializer.validated_data.get("permission"),
name=serializer.validated_data.get("name"),
)
except ParticipantsManagementException:
return drf_response.Response(
{"error": "Failed to update participant"},
status=drf_status.HTTP_500_INTERNAL_SERVER_ERROR,
)
return drf_response.Response(
{
"status": "success",
},
status=drf_status.HTTP_200_OK,
)
@decorators.action(
detail=True,
methods=["post"],
url_path="remove-participant",
url_name="remove-participant",
permission_classes=[permissions.HasPrivilegesOnRoom],
)
def remove_participant(self, request, pk=None): # pylint: disable=unused-argument
"""Remove a participant from the room."""
room = self.get_object()
serializer = serializers.BaseParticipantsManagementSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
try:
ParticipantsManagement().remove(
room_name=str(room.pk),
identity=str(serializer.validated_data["participant_identity"]),
)
except ParticipantsManagementException:
return drf_response.Response(
{"error": "Failed to remove participant"},
status=drf_status.HTTP_500_INTERNAL_SERVER_ERROR,
)
return drf_response.Response(
{"status": "success"}, status=drf_status.HTTP_200_OK
)
class ResourceAccessViewSet(
mixins.CreateModelMixin,
@@ -732,8 +596,8 @@ class RecordingViewSet(
methods=["post"],
url_path="storage-hook",
authentication_classes=[StorageEventAuthentication],
permission_classes=[permissions.IsStorageEventEnabled],
)
@FeatureFlag.require("storage_event")
def on_storage_event_received(self, request, pk=None): # pylint: disable=unused-argument
"""Handle incoming storage hook events for recordings."""
@@ -1,42 +0,0 @@
"""Authentication using LiveKit token for the Meet core app."""
from django.conf import settings
from django.contrib.auth import get_user_model
from django.contrib.auth.models import AnonymousUser
from livekit.api import TokenVerifier
from rest_framework import authentication, exceptions
UserModel = get_user_model()
class LiveKitTokenAuthentication(authentication.BaseAuthentication):
"""Authenticate using LiveKit token and load the associated Django user."""
def authenticate(self, request):
token = request.data.get("token")
if not token:
return None # No authentication attempted
try:
verifier = TokenVerifier(
api_key=settings.LIVEKIT_CONFIGURATION["api_key"],
api_secret=settings.LIVEKIT_CONFIGURATION["api_secret"],
)
claims = verifier.verify(token)
user_id = claims.identity
if not user_id:
raise exceptions.AuthenticationFailed("Token missing user identity")
try:
user = UserModel.objects.get(id=user_id)
except UserModel.DoesNotExist:
user = AnonymousUser()
return (user, claims)
except Exception as e:
raise exceptions.AuthenticationFailed(
f"Invalid LiveKit token: {str(e)}"
) from e
@@ -1,29 +0,0 @@
# Generated by Django 5.2.6 on 2025-09-25 15:14
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('core', '0014_room_pin_code'),
]
operations = [
migrations.AddField(
model_name='recording',
name='metadata_dispatch_id',
field=models.CharField(blank=True, max_length=100, null=True),
),
migrations.AlterField(
model_name='resource',
name='users',
field=models.ManyToManyField(related_name='resources', through='core.ResourceAccess', through_fields=('resource', 'user'), to=settings.AUTH_USER_MODEL),
),
migrations.AlterField(
model_name='user',
name='language',
field=models.CharField(choices=[('en-us', 'English'), ('fr-fr', 'French'), ('nl-nl', 'Dutch'), ('de-de', 'German')], default='en-us', help_text='The language in which the user wants to see the interface.', max_length=10, verbose_name='language'),
),
]
-1
View File
@@ -573,7 +573,6 @@ class Recording(BaseModel):
verbose_name=_("Recording mode"),
help_text=_("Defines the mode of recording being called."),
)
metadata_dispatch_id = models.CharField(max_length=100, null=True, blank=True)
class Meta:
db_table = "meet_recording"
@@ -4,6 +4,7 @@ import logging
import secrets
from django.conf import settings
from django.utils.translation import gettext_lazy as _
from rest_framework.authentication import BaseAuthentication
from rest_framework.exceptions import AuthenticationFailed
@@ -132,8 +132,6 @@ class NotificationService:
logger.error("No owner found for recording %s", recording.id)
return False
worker_id = recording.worker_id
payload = {
"filename": recording.key,
"email": owner_access.user.email,
@@ -145,7 +143,6 @@ class NotificationService:
"recording_time": recording.created_at.astimezone(
owner_access.user.timezone
).strftime("%H:%M"),
"worker_id": worker_id,
}
headers = {
@@ -1,19 +1,8 @@
"""Recording-related LiveKit Events Service"""
import asyncio
import logging
from logging import getLogger
from django.core.exceptions import ObjectDoesNotExist
from django.db import DatabaseError, transaction
import aiohttp
from core import models, utils
from core.api.feature_flag import FeatureFlag
from core.services.metadata import MetadataService
logging.basicConfig(level=logging.DEBUG)
logger = getLogger(__name__)
@@ -22,21 +11,6 @@ class RecordingEventsError(Exception):
"""Recording event handling fails."""
def get_recording_creator_id(recording: models.Recording) -> str | None:
"""Get the user ID of the recording creator (owner)."""
owner_id = (
models.RecordingAccess.objects.filter(
role=models.RoleChoices.OWNER,
recording_id=recording.id,
user__isnull=False,
)
.order_by("created_at")
.values_list("user_id", flat=True)
.first()
)
return str(owner_id) if owner_id else None
class RecordingEventsService:
"""Handles recording-related Livekit webhook events."""
@@ -73,127 +47,3 @@ class RecordingEventsService:
f"Failed to notify participants in room '{recording.room.id}' about "
f"recording limit reached (recording_id={recording.id})"
) from e
@staticmethod
def handle_egress_started(recording):
"""Start metadata agent after transaction commit."""
rec_id = recording.id
room_id = recording.room_id
creator_id = get_recording_creator_id(recording)
if not FeatureFlag.flag_is_active("metadata_agent", distinct_id=creator_id):
logger.info("Metadata agent disabled by PostHog flag for id=%s", creator_id)
return
service = MetadataService()
logger.info(
"Scheduling metadata start for recording=%s room_id=%s", rec_id, room_id
)
def _start():
"""Start metadata agent after transaction commit."""
try:
rec = (
models.Recording.objects.select_related("room")
.only("id", "status", "room_id", "room__id")
.get(id=rec_id)
)
if rec.status not in (
models.RecordingStatusChoices.INITIATED,
models.RecordingStatusChoices.ACTIVE,
):
logger.info(
"Skip metadata start: status=%s (rec=%s)", rec.status, rec.id
)
return
room = rec.room
if room.pk != room_id:
logger.error(
"Room mismatch at start: rec.room_id=%s event.room_id=%s",
room.pk,
room_id,
)
return
dispatch_id = service.start_metadata(room, rec_id)
rec.metadata_dispatch_id = dispatch_id
rec.save(update_fields=["metadata_dispatch_id"])
logger.info(
"Metadata start dispatched for rec=%s room_id=%s", rec.id, room.pk
)
except (aiohttp.ClientError, asyncio.TimeoutError) as e:
logger.warning(
"Controller unreachable during start (rec=%s, room_id=%s): %s",
rec_id,
room_id,
e,
exc_info=True,
)
transaction.on_commit(_start, robust=True)
@staticmethod
def handle_egress_ended(recording):
"""Stop metadata agent after transaction commit."""
service = MetadataService()
rec_id = recording.id
room_id = recording.room_id
creator_id = get_recording_creator_id(recording)
if not FeatureFlag.flag_is_active("metadata_agent", distinct_id=creator_id):
logger.info("Metadata agent disabled by PostHog flag for id=%s", creator_id)
return
def _stop():
try:
rec = (
models.Recording.objects.select_related("room")
.only("id", "room_id", "room__id")
.get(id=rec_id)
)
room = rec.room
if room.pk != room_id:
logger.error(
"Room mismatch at stop: rec.room_id=%s event.room_id=%s",
room.pk,
room_id,
)
return
try:
service.stop_metadata(room, rec.metadata_dispatch_id)
logger.info(
"Metadata stop dispatched for rec=%s room_id=%s",
rec.id,
room.pk,
)
except (aiohttp.ClientError, asyncio.TimeoutError) as e:
logger.warning(
"Controller unreachable during stop (rec=%s room_id=%s): %s",
rec_id,
room_id,
e,
exc_info=True,
)
except ObjectDoesNotExist as e:
logger.warning(
"Recording or Room not found while stopping (rec=%s room_id=%s): %s",
rec_id,
room_id,
e,
exc_info=True,
)
except DatabaseError as e:
logger.warning(
"DB error while stopping metadata (rec=%s room_id=%s): %s",
rec_id,
room_id,
e,
exc_info=True,
)
transaction.on_commit(_stop, robust=True)
+4 -137
View File
@@ -1,9 +1,7 @@
"""LiveKit Events Service"""
# pylint: disable=no-member
# pylint: disable=E1101
import logging
import re
import uuid
from enum import Enum
from logging import getLogger
@@ -21,8 +19,6 @@ from core.recording.services.recording_events import (
from .lobby import LobbyService
from .telephony import TelephonyException, TelephonyService
logging.basicConfig(level=logging.DEBUG)
logger = getLogger(__name__)
@@ -81,30 +77,6 @@ class LiveKitWebhookEventType(Enum):
INGRESS_ENDED = "ingress_ended"
def _extract_recording_id_from_egress(data) -> str | None:
"""Try to extract recording_id from egress data filenames.
On failure, return None.
"""
ei = getattr(data, "egress_info", None)
if not ei:
return None
fname = getattr(getattr(ei, "file", None), "filename", None)
if not fname:
try:
outs = getattr(getattr(ei, "room_composite", None), "file_outputs", [])
if outs:
fname = getattr(outs[0], "filepath", None)
except (AttributeError, IndexError, TypeError):
fname = None
if not fname:
return None
m = re.search(r"([0-9a-fA-F-]{36})\.ogg$", fname)
return m.group(1) if m else None
class LiveKitEventsService:
"""Service for processing and handling LiveKit webhook events and notifications."""
@@ -122,7 +94,7 @@ class LiveKitEventsService:
def receive(self, request):
"""Process webhook and route to appropriate handler."""
logger.debug("LiveKit webhook received")
auth_token = request.headers.get("Authorization")
if not auth_token:
raise AuthenticationError("Authorization header missing")
@@ -146,17 +118,13 @@ class LiveKitEventsService:
if not handler or not callable(handler):
return
logger.debug("Handling LiveKit webhook event: %s", data)
# pylint: disable=not-callable
handler(data)
def _handle_egress_ended(self, data):
"""Handle 'egress_ended' event."""
logger.debug(
"Egress ended: id=%s status=%s",
getattr(data.egress_info, "egress_id", None),
getattr(data.egress_info, "status", None),
)
try:
recording = models.Recording.objects.get(
worker_id=data.egress_info.egress_id
@@ -166,13 +134,6 @@ class LiveKitEventsService:
f"Recording with worker ID {data.egress_info.egress_id} does not exist"
) from err
try:
self.recording_events.handle_egress_ended(recording)
except RecordingEventsError as e:
raise ActionFailedError(
f"Failed to process egress_ended for recording {recording.id}"
) from e
if (
data.egress_info.status == api.EgressStatus.EGRESS_LIMIT_REACHED
and recording.status == models.RecordingStatusChoices.ACTIVE
@@ -184,100 +145,6 @@ class LiveKitEventsService:
f"Failed to process limit reached event for recording {recording}"
) from e
def _handle_egress_started(self, data):
ei = getattr(data, "egress_info", None) or (
data.get("egress_info") if isinstance(data, dict) else None
)
egress_id = None
room_name = None
status = None
if ei:
egress_id = getattr(ei, "egress_id", None) or getattr(ei, "egressId", None)
room_name = getattr(ei, "room_name", None) or getattr(ei, "roomName", None)
status = getattr(ei, "status", None)
logger.warning(
"egress_started: egress_id=%s status=%s room_name=%s",
egress_id,
status,
room_name,
)
if not egress_id:
logger.error("egress_started without egress_id")
return
rec = models.Recording.objects.filter(worker_id=egress_id).first()
if rec is None:
rec_id = _extract_recording_id_from_egress(data)
if rec_id:
rec = (
models.Recording.objects.filter(id=rec_id)
.select_related("room")
.first()
)
if rec and not rec.worker_id:
rec.worker_id = egress_id
rec.save(update_fields=["worker_id"])
logger.info(
"Attached worker_id=%s to recording=%s via filename",
egress_id,
rec.id,
)
if rec is None and room_name:
rec = (
models.Recording.objects.filter(
room__livekit_name=room_name,
status__in=[
models.RecordingStatusChoices.INITIATED,
models.RecordingStatusChoices.ACTIVE,
],
)
.order_by("-created_at")
.select_related("room")
.first()
)
if rec and not rec.worker_id:
rec.worker_id = egress_id
rec.save(update_fields=["worker_id"])
logger.info(
"Heuristically attached worker_id=%s to recording=%s via livekit room",
egress_id,
rec.id,
)
if rec is None:
logger.warning(
"egress_started: unknown egress_id=%s (room_name=%s)",
egress_id,
room_name,
)
return
lk_name = getattr(rec.room, "livekit_name", None)
if room_name and lk_name and lk_name != room_name:
logger.error(
"Room mismatch: rec[%s].room=%s != event.room=%s",
rec.id,
lk_name,
room_name,
)
return
try:
logger.debug(
"Processing egress_started for recording %s (room=%s)",
rec.id,
lk_name or rec.room_id,
)
self.recording_events.handle_egress_started(rec)
except RecordingEventsError as e:
raise ActionFailedError(
f"Failed to process egress_started for recording {rec.id}"
) from e
def _handle_room_started(self, data):
"""Handle 'room_started' event."""
+3 -17
View File
@@ -89,7 +89,7 @@ class LobbyService:
@staticmethod
def _get_or_create_participant_id(request) -> str:
"""Extract unique participant identifier from the request."""
return request.COOKIES.get(settings.LOBBY_COOKIE_NAME, str(uuid.uuid4()))
return request.COOKIES.get(settings.LOBBY_COOKIE_NAME, uuid.uuid4().hex)
@staticmethod
def prepare_response(response, participant_id):
@@ -143,8 +143,6 @@ class LobbyService:
participant_id = self._get_or_create_participant_id(request)
participant = self._get_participant(room.id, participant_id)
room_id = str(room.id)
if self.can_bypass_lobby(room=room, user=request.user):
if participant is None:
participant = LobbyParticipant(
@@ -157,13 +155,10 @@ class LobbyService:
participant.status = LobbyParticipantStatus.ACCEPTED
livekit_config = utils.generate_livekit_config(
room_id=room_id,
room_id=str(room.id),
user=request.user,
username=username,
color=participant.color,
configuration=room.configuration,
is_admin_or_owner=False,
participant_id=participant_id,
)
return participant, livekit_config
@@ -178,13 +173,10 @@ class LobbyService:
elif participant.status == LobbyParticipantStatus.ACCEPTED:
# wrongly named, contains access token to join a room
livekit_config = utils.generate_livekit_config(
room_id=room_id,
room_id=str(room.id),
user=request.user,
username=username,
color=participant.color,
configuration=room.configuration,
is_admin_or_owner=False,
participant_id=participant_id,
)
return participant, livekit_config
@@ -342,9 +334,3 @@ class LobbyService:
return
cache.delete_many(keys)
def clear_participant_cache(self, room_id: UUID, participant_id: str) -> None:
"""Clear a given participant entry from the cache for a specific room."""
cache_key = self._get_cache_key(room_id, participant_id)
cache.delete(cache_key)
-88
View File
@@ -1,88 +0,0 @@
"""Service for managing metadata agents in LiveKit rooms."""
import json
import logging
from logging import getLogger
from django.conf import settings
from asgiref.sync import async_to_sync
from livekit.protocol.agent_dispatch import (
CreateAgentDispatchRequest,
)
from core import utils
logging.basicConfig(level=logging.DEBUG)
logger = getLogger(__name__)
class MetadataException(Exception):
"""Exception raised when metadata operations fail."""
class MetadataService:
"""Service for managing metadata agents in LiveKit rooms."""
def __init__(self) -> None:
self.agent_name = settings.ROOM_METADATA_AGENT_NAME
@async_to_sync
async def start_metadata(self, room, recording_id) -> None:
"""Start metadata agent in the specified room."""
lkapi = utils.create_livekit_client()
try:
payload = (
json.dumps({"recording_id": str(recording_id)})
if recording_id
else None
)
resp = await lkapi.agent_dispatch.create_dispatch(
CreateAgentDispatchRequest(
agent_name=self.agent_name,
room=str(room.id),
metadata=payload,
)
)
dispatch_id = resp.id
dispatch_id = getattr(resp, "id", None)
if not dispatch_id:
raise MetadataException("LiveKit did not return a dispatch_id")
logger.info(
"Agent dispatch created: room=%s agent=%s dispatch_id=%s",
room.id,
self.agent_name,
dispatch_id,
)
return dispatch_id
except Exception as e:
logger.exception("Failed to create agent dispatch for room %s", room.id)
raise MetadataException("Failed to create metadata agent") from e
finally:
await lkapi.aclose()
@async_to_sync
async def stop_metadata(self, room, dispatch_id) -> None:
"""Stop metadata agent in the specified room."""
logger.info(
"deleting agent dispatch: room=%s agent=%s dispatch_id=%s",
room.id,
self.agent_name,
dispatch_id,
)
lkapi = utils.create_livekit_client()
try:
await lkapi.agent_dispatch.delete_dispatch(
dispatch_id=str(dispatch_id), room_name=str(room.id)
)
logger.info(
"Agent dispatch deleted: room=%s agent=%s", room.id, self.agent_name
)
except Exception as e:
logger.exception("Failed to delete agent dispatch for room %s", room.id)
raise MetadataException("Failed to stop metadata agent") from e
finally:
await lkapi.aclose()
@@ -1,128 +0,0 @@
"""Participants management service for LiveKit rooms."""
# pylint: disable=too-many-arguments,no-name-in-module,too-many-positional-arguments
# ruff: noqa:PLR0913
import json
import uuid
from logging import getLogger
from typing import Dict, Optional
from asgiref.sync import async_to_sync
from livekit.api import (
MuteRoomTrackRequest,
RoomParticipantIdentity,
TwirpError,
UpdateParticipantRequest,
)
from core import utils
from .lobby import LobbyService
logger = getLogger(__name__)
class ParticipantsManagementException(Exception):
"""Exception raised when a participant management operations fail."""
class ParticipantsManagement:
"""Service for managing participants."""
@async_to_sync
async def mute(self, room_name: str, identity: str, track_sid: str):
"""Mute a specific audio or video track for a participant in a room."""
lkapi = utils.create_livekit_client()
try:
await lkapi.room.mute_published_track(
MuteRoomTrackRequest(
room=room_name,
identity=identity,
track_sid=track_sid,
muted=True,
)
)
except TwirpError as e:
logger.exception(
"Unexpected error muting participant %s for room %s",
identity,
room_name,
)
raise ParticipantsManagementException("Could not mute participant") from e
finally:
await lkapi.aclose()
@async_to_sync
async def remove(self, room_name: str, identity: str):
"""Remove a participant from a room and clear their lobby cache."""
try:
LobbyService().clear_participant_cache(
room_id=uuid.UUID(room_name), participant_id=identity
)
except (ValueError, TypeError) as exc:
logger.warning(
"participants_management.remove: room_name '%s' is not a UUID; "
"skipping lobby cache clear",
room_name,
exc_info=exc,
)
lkapi = utils.create_livekit_client()
try:
await lkapi.room.remove_participant(
RoomParticipantIdentity(room=room_name, identity=identity)
)
except TwirpError as e:
logger.exception(
"Unexpected error removing participant %s for room %s",
identity,
room_name,
)
raise ParticipantsManagementException("Could not remove participant") from e
finally:
await lkapi.aclose()
@async_to_sync
async def update(
self,
room_name: str,
identity: str,
metadata: Optional[Dict] = None,
attributes: Optional[Dict] = None,
permission: Optional[Dict] = None,
name: Optional[str] = None,
):
"""Update participant properties such as metadata, attributes, permissions, or name."""
lkapi = utils.create_livekit_client()
try:
await lkapi.room.update_participant(
UpdateParticipantRequest(
room=room_name,
identity=identity,
metadata=json.dumps(metadata),
permission=permission,
attributes=attributes,
name=name,
)
)
except TwirpError as e:
logger.exception(
"Unexpected error updating participant %s for room %s",
identity,
room_name,
)
raise ParticipantsManagementException("Could not update participant") from e
finally:
await lkapi.aclose()
-47
View File
@@ -1,47 +0,0 @@
"""Service for managing subtitle agents in LiveKit rooms."""
from logging import getLogger
from django.conf import settings
from asgiref.sync import async_to_sync
from livekit.protocol.agent_dispatch import CreateAgentDispatchRequest
from core import utils
logger = getLogger(__name__)
class SubtitleException(Exception):
"""Exception raised when subtitle operations fail."""
class SubtitleService:
"""Service for managing subtitle agents in LiveKit rooms."""
@async_to_sync
async def start_subtitle(self, room):
"""Start subtitle agent for the specified room."""
lkapi = utils.create_livekit_client()
try:
# Transcriber agent prevents duplicate subtitle agents per room
# No error is raised if agent already exists
await lkapi.agent_dispatch.create_dispatch(
CreateAgentDispatchRequest(
agent_name=settings.ROOM_SUBTITLE_AGENT_NAME, room=str(room.id)
)
)
except Exception as e:
logger.exception("Failed to create agent dispatch for room %s", room.id)
raise SubtitleException("Failed to create subtitle agent") from e
finally:
await lkapi.aclose()
@async_to_sync
async def stop_subtitle(self, room) -> None:
"""Stop subtitle agent for the specified room."""
raise NotImplementedError("Subtitle agent stopping not yet implemented")
+2 -2
View File
@@ -38,12 +38,12 @@ class TelephonyService:
direct_rule = SIPDispatchRule(
dispatch_rule_direct=SIPDispatchRuleDirect(
room_name=str(room.pk), pin=str(room.pin_code)
room_name=str(room.id), pin=str(room.pin_code)
)
)
request = CreateSIPDispatchRuleRequest(
rule=direct_rule, name=self._rule_name(room.pk)
rule=direct_rule, name=self._rule_name(room.id)
)
lkapi = utils.create_livekit_client()
@@ -2,7 +2,7 @@
Test event authentication.
"""
# pylint: disable=assignment-from-no-return
# pylint: disable=E1128
from django.test import RequestFactory
@@ -2,7 +2,7 @@
Test event notification.
"""
# pylint: disable=assignment-from-no-return,redefined-outer-name,unused-argument,protected-access
# pylint: disable=E1128,W0621,W0613,W0212
import datetime
import smtplib
@@ -2,7 +2,7 @@
Test event parsers.
"""
# pylint: disable=protected-access,redefined-outer-name,unused-argument
# pylint: disable=W0212,W0621,W0613
from unittest import mock
@@ -2,7 +2,7 @@
Test RecordingEventsService service.
"""
# pylint: disable=redefined-outer-name
# pylint: disable=W0621
from unittest import mock
@@ -2,7 +2,7 @@
Test recordings API endpoints in the Meet core app: save recording.
"""
# pylint: disable=redefined-outer-name,unused-argument
# pylint: disable=W0621,W0613
import uuid
from unittest import mock
@@ -77,8 +77,7 @@ def test_save_recording_permission_needed(settings, client):
HTTP_AUTHORIZATION="Bearer testAuthToken",
)
assert response.status_code == 404
assert response.json() == {"detail": "Not found."}
assert response.status_code == 403
def test_save_recording_parsing_error(recording_settings, mock_get_parser, client):
@@ -2,7 +2,7 @@
Test worker service factories.
"""
# pylint: disable=protected-access,redefined-outer-name,unused-argument
# pylint: disable=W0212,W0621,W0613
from dataclasses import FrozenInstanceError
from unittest.mock import Mock
@@ -1,6 +1,6 @@
"""Test WorkerServiceMediator class."""
# pylint: disable=redefined-outer-name,unused-argument
# pylint: disable=W0621,W0613
from unittest.mock import Mock
@@ -2,7 +2,7 @@
Test worker service classes.
"""
# pylint: disable=protected-access,redefined-outer-name,unused-argument,no-member
# pylint: disable=W0212,W0621,W0613,E1101
from unittest.mock import AsyncMock, Mock, patch
@@ -2,7 +2,7 @@
Test rooms API endpoints in the Meet core app: create.
"""
# pylint: disable=redefined-outer-name,unused-argument
# pylint: disable=W0621,W0613
from django.core.cache import cache
import pytest
@@ -2,7 +2,7 @@
Test rooms API endpoints in the Meet core app: creation callback functionality.
"""
# pylint: disable=redefined-outer-name,unused-argument
# pylint: disable=W0621,W0613
from django.core.cache import cache
import pytest
@@ -2,7 +2,7 @@
Test rooms API endpoints in the Meet core app: invite.
"""
# pylint: disable=redefined-outer-name,unused-argument
# pylint: disable=W0621,W0613
import json
import random
@@ -132,9 +132,9 @@ def test_request_entry_with_existing_participants(settings):
# Add two participants already waiting in the lobby
cache.set(
f"mocked-cache-prefix_{room.id}_2f7f162f-e7d1-421b-90e7-02bfbfbf8def",
f"mocked-cache-prefix_{room.id}_2f7f162fe7d1421b90e702bfbfbf8def",
{
"id": "2f7f162f-e7d1-421b-90e7-02bfbfbf8def",
"id": "2f7f162fe7d1421b90e702bfbfbf8def",
"username": "user1",
"status": "waiting",
"color": "#123456",
@@ -259,7 +259,7 @@ def test_request_entry_authenticated_user_public_room(settings):
mock.patch.object(
LobbyService,
"_get_or_create_participant_id",
return_value="2f7f162f-e7d1-421b-90e7-02bfbfbf8def",
return_value="2f7f162fe7d1421b90e702bfbfbf8def",
),
mock.patch.object(
utils, "generate_livekit_config", return_value={"token": "test-token"}
@@ -276,11 +276,11 @@ def test_request_entry_authenticated_user_public_room(settings):
# Verify the lobby cookie was set
cookie = response.cookies.get("mocked-cookie")
assert cookie is not None
assert cookie.value == "2f7f162f-e7d1-421b-90e7-02bfbfbf8def"
assert cookie.value == "2f7f162fe7d1421b90e702bfbfbf8def"
# Verify response content matches expected structure and values
assert response.json() == {
"id": "2f7f162f-e7d1-421b-90e7-02bfbfbf8def",
"id": "2f7f162fe7d1421b90e702bfbfbf8def",
"username": "test_user",
"status": "accepted",
"color": "mocked-color",
@@ -302,9 +302,9 @@ def test_request_entry_waiting_participant_public_room(settings):
# Add a waiting participant to the room's lobby cache
cache.set(
f"mocked-cache-prefix_{room.id}_2f7f162f-e7d1-421b-90e7-02bfbfbf8def",
f"mocked-cache-prefix_{room.id}_2f7f162fe7d1421b90e702bfbfbf8def",
{
"id": "2f7f162f-e7d1-421b-90e7-02bfbfbf8def",
"id": "2f7f162fe7d1421b90e702bfbfbf8def",
"username": "user1",
"status": "waiting",
"color": "#123456",
@@ -312,7 +312,7 @@ def test_request_entry_waiting_participant_public_room(settings):
)
# Simulate a browser with existing participant cookie
client.cookies.load({"mocked-cookie": "2f7f162f-e7d1-421b-90e7-02bfbfbf8def"})
client.cookies.load({"mocked-cookie": "2f7f162fe7d1421b90e702bfbfbf8def"})
with (
mock.patch.object(utils, "notify_participants", return_value=None),
@@ -330,11 +330,11 @@ def test_request_entry_waiting_participant_public_room(settings):
# Verify the lobby cookie was set
cookie = response.cookies.get("mocked-cookie")
assert cookie is not None
assert cookie.value == "2f7f162f-e7d1-421b-90e7-02bfbfbf8def"
assert cookie.value == "2f7f162fe7d1421b90e702bfbfbf8def"
# Verify response content matches expected structure and values
assert response.json() == {
"id": "2f7f162f-e7d1-421b-90e7-02bfbfbf8def",
"id": "2f7f162fe7d1421b90e702bfbfbf8def",
"username": "user1",
"status": "accepted",
"color": "#123456",
@@ -381,7 +381,7 @@ def test_allow_participant_to_enter_anonymous():
response = client.post(
f"/api/v1.0/rooms/{room.id}/enter/",
{"participant_id": "2f7f162f-e7d1-421b-90e7-02bfbfbf8def", "allow_entry": True},
{"participant_id": "2f7f162fe7d1421b90e702bfbfbf8def", "allow_entry": True},
)
assert response.status_code == 401
@@ -396,7 +396,7 @@ def test_allow_participant_to_enter_non_owner():
response = client.post(
f"/api/v1.0/rooms/{room.id}/enter/",
{"participant_id": "2f7f162f-e7d1-421b-90e7-02bfbfbf8def", "allow_entry": True},
{"participant_id": "2f7f162fe7d1421b90e702bfbfbf8def", "allow_entry": True},
)
assert response.status_code == 403
@@ -414,7 +414,7 @@ def test_allow_participant_to_enter_public_room():
response = client.post(
f"/api/v1.0/rooms/{room.id}/enter/",
{"participant_id": "2f7f162f-e7d1-421b-90e7-02bfbfbf8def", "allow_entry": True},
{"participant_id": "2f7f162fe7d1421b90e702bfbfbf8def", "allow_entry": True},
)
assert response.status_code == 404
@@ -437,9 +437,9 @@ def test_allow_participant_to_enter_success(settings, allow_entry, updated_statu
settings.LOBBY_KEY_PREFIX = "mocked-cache-prefix"
cache.set(
f"mocked-cache-prefix_{room.id!s}_2f7f162f-e7d1-421b-90e7-02bfbfbf8def",
f"mocked-cache-prefix_{room.id!s}_2f7f162fe7d1421b90e702bfbfbf8def",
{
"id": "2f7f162f-e7d1-421b-90e7-02bfbfbf8def",
"id": "2f7f162fe7d1421b90e702bfbfbf8def",
"status": "waiting",
"username": "foo",
"color": "123",
@@ -449,7 +449,7 @@ def test_allow_participant_to_enter_success(settings, allow_entry, updated_statu
response = client.post(
f"/api/v1.0/rooms/{room.id}/enter/",
{
"participant_id": "2f7f162f-e7d1-421b-90e7-02bfbfbf8def",
"participant_id": "2f7f162fe7d1421b90e702bfbfbf8def",
"allow_entry": allow_entry,
},
)
@@ -458,7 +458,7 @@ def test_allow_participant_to_enter_success(settings, allow_entry, updated_statu
assert response.json() == {"message": "Participant was updated."}
participant_data = cache.get(
f"mocked-cache-prefix_{room.id!s}_2f7f162f-e7d1-421b-90e7-02bfbfbf8def"
f"mocked-cache-prefix_{room.id!s}_2f7f162fe7d1421b90e702bfbfbf8def"
)
assert participant_data.get("status") == updated_status
@@ -476,13 +476,13 @@ def test_allow_participant_to_enter_participant_not_found(settings):
settings.LOBBY_KEY_PREFIX = "mocked-cache-prefix"
participant_data = cache.get(
f"mocked-cache-prefix_{room.id!s}_2f7f162f-e7d1-421b-90e7-02bfbfbf8def"
f"mocked-cache-prefix_{room.id!s}_2f7f162fe7d1421b90e702bfbfbf8def"
)
assert participant_data is None
response = client.post(
f"/api/v1.0/rooms/{room.id}/enter/",
{"participant_id": "2f7f162f-e7d1-421b-90e7-02bfbfbf8def", "allow_entry": True},
{"participant_id": "2f7f162fe7d1421b90e702bfbfbf8def", "allow_entry": True},
)
assert response.status_code == 404
@@ -572,9 +572,9 @@ def test_list_waiting_participants_success(settings):
# Add participants in the lobby
cache.set(
f"mocked-cache-prefix_{room.id}_2f7f162f-e7d1-421b-90e7-02bfbfbf8def",
f"mocked-cache-prefix_{room.id}_2f7f162fe7d1421b90e702bfbfbf8def",
{
"id": "2f7f162f-e7d1-421b-90e7-02bfbfbf8def",
"id": "2f7f162fe7d1421b90e702bfbfbf8def",
"username": "user1",
"status": "waiting",
"color": "#123456",
@@ -597,7 +597,7 @@ def test_list_waiting_participants_success(settings):
participants = response.json().get("participants")
assert sorted(participants, key=lambda p: p["id"]) == [
{
"id": "2f7f162f-e7d1-421b-90e7-02bfbfbf8def",
"id": "2f7f162fe7d1421b90e702bfbfbf8def",
"username": "user1",
"status": "waiting",
"color": "#123456",
@@ -1,417 +0,0 @@
"""
Test rooms API endpoints in the Meet core app: participants management.
"""
# pylint: disable=redefined-outer-name,unused-argument,protected-access
import random
from unittest import mock
from uuid import uuid4
from django.urls import reverse
import pytest
from livekit.api import TwirpError
from rest_framework import status
from rest_framework.test import APIClient
from core.factories import RoomFactory, UserFactory, UserResourceAccessFactory
from core.services.lobby import LobbyService
pytestmark = pytest.mark.django_db
@pytest.fixture
def mock_livekit_client():
"""Mock LiveKit API client."""
with mock.patch("core.utils.create_livekit_client") as mock_create:
mock_client = mock.AsyncMock()
mock_create.return_value = mock_client
yield mock_client
def test_mute_participant_success(mock_livekit_client):
"""Test successful participant muting."""
client = APIClient()
room = RoomFactory()
user = UserFactory()
UserResourceAccessFactory(
resource=room, user=user, role=random.choice(["administrator", "owner"])
)
client.force_authenticate(user=user)
payload = {"participant_identity": str(uuid4()), "track_sid": "test-track-sid"}
url = reverse("rooms-mute-participant", kwargs={"pk": room.id})
response = client.post(url, payload, format="json")
assert response.status_code == status.HTTP_200_OK
assert response.data == {"status": "success"}
mock_livekit_client.room.mute_published_track.assert_called_once()
mock_livekit_client.aclose.assert_called_once()
def test_mute_participant_forbidden_without_access():
"""Test mute participant returns 403 when user lacks room privileges."""
client = APIClient()
room = RoomFactory()
user = UserFactory() # User without UserResourceAccess
client.force_authenticate(user=user)
payload = {"participant_identity": str(uuid4()), "track_sid": "test-track-sid"}
url = reverse("rooms-mute-participant", kwargs={"pk": room.id})
response = client.post(url, payload, format="json")
assert response.status_code == status.HTTP_403_FORBIDDEN
def test_mute_participant_invalid_payload():
"""Test mute participant with invalid payload."""
client = APIClient()
room = RoomFactory()
user = UserFactory()
UserResourceAccessFactory(
resource=room, user=user, role=random.choice(["administrator", "owner"])
)
client.force_authenticate(user=user)
payload = {"participant_identity": "invalid-uuid", "track_sid": ""}
url = reverse("rooms-mute-participant", kwargs={"pk": room.id})
response = client.post(url, payload, format="json")
assert response.status_code == status.HTTP_400_BAD_REQUEST
def test_mute_participant_unexpected_twirp_error(mock_livekit_client):
"""Test mute participant when LiveKit API raises TwirpError."""
client = APIClient()
mock_livekit_client.room.mute_published_track.side_effect = TwirpError(
msg="Internal server error", code=500, status=500
)
room = RoomFactory()
user = UserFactory()
UserResourceAccessFactory(
resource=room, user=user, role=random.choice(["administrator", "owner"])
)
client.force_authenticate(user=user)
payload = {"participant_identity": str(uuid4()), "track_sid": "test-track-sid"}
url = reverse("rooms-mute-participant", kwargs={"pk": room.id})
response = client.post(url, payload, format="json")
assert response.status_code == status.HTTP_500_INTERNAL_SERVER_ERROR
assert response.data == {"error": "Failed to mute participant"}
mock_livekit_client.aclose.assert_called_once()
def test_update_participant_success(mock_livekit_client):
"""Test successful participant update."""
client = APIClient()
room = RoomFactory()
user = UserFactory()
UserResourceAccessFactory(
resource=room, user=user, role=random.choice(["administrator", "owner"])
)
client.force_authenticate(user=user)
payload = {
"participant_identity": str(uuid4()),
"metadata": {"role": "presenter"},
"permission": {
"can_subscribe": True,
"can_publish": True,
"can_publish_data": True,
"can_publish_sources": [
1,
2,
], # [TrackSource.CAMERA, TrackSource.MICROPHONE]
"hidden": False,
"recorder": False,
"can_update_metadata": True,
"agent": False,
"can_subscribe_metrics": False,
},
"name": "John Doe",
}
url = reverse("rooms-update-participant", kwargs={"pk": room.id})
response = client.post(url, payload, format="json")
assert response.status_code == status.HTTP_200_OK
assert response.data == {"status": "success"}
mock_livekit_client.room.update_participant.assert_called_once()
mock_livekit_client.aclose.assert_called_once()
def test_update_participant_forbidden_without_access():
"""Test update participant returns 403 when user lacks room privileges."""
client = APIClient()
room = RoomFactory()
user = UserFactory() # User without UserResourceAccess
client.force_authenticate(user=user)
payload = {"participant_identity": str(uuid4()), "name": "Test User"}
url = reverse("rooms-update-participant", kwargs={"pk": room.id})
response = client.post(url, payload, format="json")
assert response.status_code == status.HTTP_403_FORBIDDEN
def test_update_participant_invalid_payload():
"""Test update participant with invalid payload."""
client = APIClient()
room = RoomFactory()
user = UserFactory()
UserResourceAccessFactory(
resource=room, user=user, role=random.choice(["administrator", "owner"])
)
client.force_authenticate(user=user)
payload = {"participant_identity": "invalid-uuid"}
url = reverse("rooms-update-participant", kwargs={"pk": room.id})
response = client.post(url, payload, format="json")
assert response.status_code == status.HTTP_400_BAD_REQUEST
assert "Must be a valid UUID." in str(response.data)
def test_update_participant_no_update_fields():
"""Test update participant with no update fields provided."""
client = APIClient()
room = RoomFactory()
user = UserFactory()
UserResourceAccessFactory(
resource=room, user=user, role=random.choice(["administrator", "owner"])
)
client.force_authenticate(user=user)
payload = {
"participant_identity": str(uuid4())
# No metadata, attributes, permission, or name
}
url = reverse("rooms-update-participant", kwargs={"pk": room.id})
response = client.post(url, payload, format="json")
assert response.status_code == status.HTTP_400_BAD_REQUEST
assert "At least one of the following fields must be provided" in str(response.data)
def test_update_participant_invalid_permission():
"""Test update participant with wrong permission object."""
client = APIClient()
room = RoomFactory()
user = UserFactory()
UserResourceAccessFactory(
resource=room, user=user, role=random.choice(["administrator", "owner"])
)
client.force_authenticate(user=user)
payload = {
"participant_identity": str(uuid4()),
"permission": {"invalid-attributes": True},
}
url = reverse("rooms-update-participant", kwargs={"pk": room.id})
response = client.post(url, payload, format="json")
assert response.status_code == status.HTTP_400_BAD_REQUEST
assert "Invalid permission" in str(response.data)
def test_update_participant_wrong_metadata_attributes():
"""Test update participant with wrong metadata or attributes provided."""
client = APIClient()
room = RoomFactory()
user = UserFactory()
UserResourceAccessFactory(
resource=room, user=user, role=random.choice(["administrator", "owner"])
)
client.force_authenticate(user=user)
payload = {
"participant_identity": str(uuid4()),
"metadata": "wrong string",
"attributes": "wrong string",
}
url = reverse("rooms-update-participant", kwargs={"pk": room.id})
response = client.post(url, payload, format="json")
assert response.status_code == status.HTTP_400_BAD_REQUEST
assert "metadata" in response.data or "attributes" in response.data
def test_update_participant_unexpected_twirp_error(mock_livekit_client):
"""Test update participant when LiveKit API raises TwirpError."""
client = APIClient()
mock_livekit_client.room.update_participant.side_effect = TwirpError(
msg="Internal server error", code=500, status=500
)
room = RoomFactory()
user = UserFactory()
UserResourceAccessFactory(
resource=room, user=user, role=random.choice(["administrator", "owner"])
)
client.force_authenticate(user=user)
payload = {"participant_identity": str(uuid4()), "name": "Test User"}
url = reverse("rooms-update-participant", kwargs={"pk": room.id})
response = client.post(url, payload, format="json")
assert response.status_code == status.HTTP_500_INTERNAL_SERVER_ERROR
assert response.data == {"error": "Failed to update participant"}
mock_livekit_client.aclose.assert_called_once()
def test_remove_participant_success_lobby_cache(mock_livekit_client):
"""Test successful participant removal.
The lobby cache cleanup is crucial for security - without it, removed
participants could potentially re-enter the room using their cached
lobby session.
"""
client = APIClient()
room = RoomFactory()
user = UserFactory()
UserResourceAccessFactory(
resource=room, user=user, role=random.choice(["administrator", "owner"])
)
client.force_authenticate(user=user)
participant_identity = str(uuid4())
# Create participant in lobby cache first
LobbyService().enter(room.id, participant_identity, "John doe")
# Accept participant
LobbyService().handle_participant_entry(room.id, participant_identity, True)
payload = {"participant_identity": participant_identity}
url = reverse("rooms-remove-participant", kwargs={"pk": room.id})
response = client.post(url, payload, format="json")
assert response.status_code == status.HTTP_200_OK
assert response.data == {"status": "success"}
mock_livekit_client.room.remove_participant.assert_called_once()
# called twice: once for Lobby, once for ParticipantManagement
mock_livekit_client.aclose.assert_called()
# Verify lobby cache was cleared - participant should no longer exist
participant = LobbyService()._get_participant(room.id, participant_identity)
assert participant is None
def test_remove_participant_success(mock_livekit_client):
"""Test successful participant removal."""
client = APIClient()
room = RoomFactory()
user = UserFactory()
UserResourceAccessFactory(
resource=room, user=user, role=random.choice(["administrator", "owner"])
)
client.force_authenticate(user=user)
payload = {"participant_identity": str(uuid4())}
url = reverse("rooms-remove-participant", kwargs={"pk": room.id})
response = client.post(url, payload, format="json")
assert response.status_code == status.HTTP_200_OK
assert response.data == {"status": "success"}
mock_livekit_client.room.remove_participant.assert_called_once()
mock_livekit_client.aclose.assert_called_once()
def test_remove_participant_forbidden_without_access():
"""Test remove participant returns 403 when user lacks room privileges."""
client = APIClient()
room = RoomFactory()
user = UserFactory() # User without UserResourceAccess
client.force_authenticate(user=user)
payload = {"participant_identity": str(uuid4())}
url = reverse("rooms-remove-participant", kwargs={"pk": room.id})
response = client.post(url, payload, format="json")
assert response.status_code == status.HTTP_403_FORBIDDEN
def test_remove_participant_invalid_payload():
"""Test remove participant with invalid payload."""
client = APIClient()
room = RoomFactory()
user = UserFactory()
UserResourceAccessFactory(
resource=room, user=user, role=random.choice(["administrator", "owner"])
)
client.force_authenticate(user=user)
payload = {"participant_identity": "invalid-uuid"}
url = reverse("rooms-remove-participant", kwargs={"pk": room.id})
response = client.post(url, payload, format="json")
assert response.status_code == status.HTTP_400_BAD_REQUEST
def test_remove_participant_missing_identity():
"""Test remove participant with missing participant_identity."""
client = APIClient()
room = RoomFactory()
user = UserFactory()
UserResourceAccessFactory(
resource=room, user=user, role=random.choice(["administrator", "owner"])
)
client.force_authenticate(user=user)
payload = {} # Missing participant_identity
url = reverse("rooms-remove-participant", kwargs={"pk": room.id})
response = client.post(url, payload, format="json")
assert response.status_code == status.HTTP_400_BAD_REQUEST
def test_remove_participant_unexpected_twirp_error(mock_livekit_client):
"""Test remove participant when LiveKit API raises TwirpError."""
client = APIClient()
mock_livekit_client.room.remove_participant.side_effect = TwirpError(
msg="Internal server error", code=500, status=500
)
room = RoomFactory()
user = UserFactory()
UserResourceAccessFactory(
resource=room, user=user, role=random.choice(["administrator", "owner"])
)
client.force_authenticate(user=user)
payload = {"participant_identity": str(uuid4())}
url = reverse("rooms-remove-participant", kwargs={"pk": room.id})
response = client.post(url, payload, format="json")
assert response.status_code == status.HTTP_500_INTERNAL_SERVER_ERROR
assert response.data == {"error": "Failed to remove participant"}
mock_livekit_client.aclose.assert_called_once()
@@ -235,10 +235,7 @@ def test_api_rooms_retrieve_authenticated_public(mock_token):
which they are not related, provided the room is public.
They should not see related users.
"""
room = RoomFactory(
access_level=RoomAccessLevel.PUBLIC,
configuration={"can_publish_sources": ["mock-source"]},
)
room = RoomFactory(access_level=RoomAccessLevel.PUBLIC)
user = UserFactory()
client = APIClient()
@@ -265,13 +262,7 @@ def test_api_rooms_retrieve_authenticated_public(mock_token):
}
mock_token.assert_called_once_with(
room=expected_name,
user=user,
username=None,
color=None,
sources=["mock-source"],
is_admin_or_owner=False,
participant_id=None,
room=expected_name, user=user, username=None, color=None
)
@@ -316,13 +307,7 @@ def test_api_rooms_retrieve_authenticated_trusted(mock_token):
}
mock_token.assert_called_once_with(
room=expected_name,
user=user,
username=None,
color=None,
sources=None,
is_admin_or_owner=False,
participant_id=None,
room=expected_name, user=user, username=None, color=None
)
@@ -368,9 +353,7 @@ def test_api_rooms_retrieve_members(mock_token, django_assert_num_queries, setti
user = UserFactory()
other_user = UserFactory()
room = RoomFactory(
configuration={"can_publish_sources": ["mock-source"]},
)
room = RoomFactory()
UserResourceAccessFactory(resource=room, user=user, role="member")
UserResourceAccessFactory(resource=room, user=other_user, role="member")
@@ -403,13 +386,7 @@ def test_api_rooms_retrieve_members(mock_token, django_assert_num_queries, setti
}
mock_token.assert_called_once_with(
room=expected_name,
user=user,
username=None,
color=None,
sources=["mock-source"],
is_admin_or_owner=False,
participant_id=None,
room=expected_name, user=user, username=None, color=None
)
@@ -496,11 +473,5 @@ def test_api_rooms_retrieve_administrators(
}
mock_token.assert_called_once_with(
room=expected_name,
user=user,
username=None,
color=None,
sources=None,
is_admin_or_owner=True,
participant_id=None,
room=expected_name, user=user, username=None, color=None
)
@@ -2,7 +2,7 @@
Test rooms API endpoints in the Meet core app: start recording.
"""
# pylint: disable=redefined-outer-name,unused-argument
# pylint: disable=W0621,W0613
from unittest import mock
@@ -55,9 +55,8 @@ def test_start_recording_anonymous():
assert Recording.objects.count() == 0
def test_start_recording_non_owner_and_non_administrator(settings):
def test_start_recording_non_owner_and_non_administrator():
"""Non-owner and Non-Administrator users should not be allowed to start room recordings."""
settings.RECORDING_ENABLE = True
room = RoomFactory()
user = UserFactory()
client = APIClient()
@@ -89,8 +88,8 @@ def test_start_recording_recording_disabled(settings):
{"mode": "screen_recording"},
)
assert response.status_code == 404
assert response.json() == {"detail": "Not found."}
assert response.status_code == 403
assert response.json() == {"detail": "Access denied, recording is disabled."}
assert Recording.objects.count() == 0
@@ -183,10 +182,9 @@ def test_start_recording_success(
mock_worker_service_factory.assert_called_once_with(mode="screen_recording")
assert response.status_code == 201
assert (
response.json()["message"]
== f"Recording successfully started for room {room.slug}"
)
assert response.json() == {
"message": f"Recording successfully started for room {room.slug}"
}
# Verify the mediator was called with the recording
recording = Recording.objects.first()
@@ -2,7 +2,7 @@
Test rooms API endpoints in the Meet core app: stop recording.
"""
# pylint: disable=redefined-outer-name,unused-argument
# pylint: disable=W0621,W0613
from unittest import mock
@@ -54,9 +54,8 @@ def test_stop_recording_anonymous():
assert Recording.objects.filter(status=RecordingStatusChoices.ACTIVE).count() == 1
def test_stop_recording_non_owner_and_non_administrator(settings):
def test_stop_recording_non_owner_and_non_administrator():
"""Non-owner and Non-Administrator users should not be allowed to stop room recordings."""
settings.RECORDING_ENABLE = True
room = RoomFactory()
user = UserFactory()
RecordingFactory(room=room, status=RecordingStatusChoices.ACTIVE)
@@ -85,8 +84,8 @@ def test_stop_recording_recording_disabled(settings):
response = client.post(f"/api/v1.0/rooms/{room.id}/stop-recording/")
assert response.status_code == 404
assert response.json() == {"detail": "Not found."}
assert response.status_code == 403
assert response.json() == {"detail": "Access denied, recording is disabled."}
# Verify no recording exists
assert Recording.objects.count() == 0
@@ -1,221 +0,0 @@
"""
Test rooms API endpoints in the Meet core app: start subtitle.
"""
# pylint: disable=W0621
import uuid
from unittest import mock
from django.conf import settings
import pytest
from livekit.api import AccessToken, TwirpError, VideoGrants
from rest_framework.test import APIClient
from ...factories import RoomFactory, UserFactory
pytestmark = pytest.mark.django_db
@pytest.fixture
def mock_room_id() -> str:
"""Mock room's id."""
return "d2aeb774-1ecd-4d73-a3ac-3d3530cad7ff"
@pytest.fixture
def mock_livekit_token(mock_room_id):
"""Mock LiveKit JWT token."""
video_grants = VideoGrants(
room=mock_room_id,
room_join=True,
room_admin=True,
can_update_own_metadata=True,
can_publish_sources=[
"camera",
"microphone",
"screen_share",
"screen_share_audio",
],
)
token = (
AccessToken(
api_key=settings.LIVEKIT_CONFIGURATION["api_key"],
api_secret=settings.LIVEKIT_CONFIGURATION["api_secret"],
)
.with_grants(video_grants)
.with_identity(str(uuid.uuid4()))
)
return token.to_jwt()
@pytest.fixture
def mock_livekit_client():
"""Mock LiveKit API client."""
with mock.patch("core.utils.create_livekit_client") as mock_create:
mock_client = mock.AsyncMock()
mock_create.return_value = mock_client
yield mock_client
def test_start_subtitle_missing_token_anonymous(settings):
"""Test that anonymous users cannot start subtitles without a valid LiveKit token."""
settings.ROOM_SUBTITLE_ENABLED = True
room = RoomFactory()
client = APIClient()
response = client.post(
f"/api/v1.0/rooms/{room.id}/start-subtitle/",
)
assert response.status_code == 403
assert response.json() == {
"detail": "Authentication credentials were not provided."
}
def test_start_subtitle_missing_token_authenticated(settings):
"""Test that authenticated users still need a valid LiveKit token to start subtitles."""
settings.ROOM_SUBTITLE_ENABLED = True
room = RoomFactory()
user = UserFactory()
client = APIClient()
client.force_login(user)
response = client.post(
f"/api/v1.0/rooms/{room.id}/start-subtitle/",
)
assert response.status_code == 403
assert response.json() == {
"detail": "Authentication credentials were not provided."
}
def test_start_subtitle_invalid_token():
"""Test that malformed or invalid LiveKit tokens are rejected."""
room = RoomFactory()
user = UserFactory()
client = APIClient()
client.force_login(user)
response = client.post(
f"/api/v1.0/rooms/{room.id}/start-subtitle/", {"token": "invalid-token"}
)
assert response.status_code == 403
assert response.json() == {"detail": "Invalid LiveKit token: Not enough segments"}
def test_start_subtitle_disabled_by_default(mock_livekit_token):
"""Test that subtitle functionality is disabled when feature flag is off."""
room = RoomFactory()
user = UserFactory()
client = APIClient()
client.force_login(user)
response = client.post(
f"/api/v1.0/rooms/{room.id}/start-subtitle/",
{"token": mock_livekit_token},
)
assert response.status_code == 404
assert response.json() == {"detail": "Not found."}
def test_start_subtitle_valid_token(
settings, mock_livekit_client, mock_livekit_token, mock_room_id
):
"""Test successful subtitle initiation with valid token and enabled feature."""
settings.ROOM_SUBTITLE_ENABLED = True
room = RoomFactory(id=mock_room_id)
client = APIClient()
response = client.post(
f"/api/v1.0/rooms/{room.id}/start-subtitle/",
{"token": mock_livekit_token},
)
assert response.status_code == 200
assert response.json() == {"status": "success"}
mock_livekit_client.agent_dispatch.create_dispatch.assert_called_once()
call_args = mock_livekit_client.agent_dispatch.create_dispatch.call_args[0][0]
assert call_args.agent_name == "multi-user-transcriber"
assert call_args.room == "d2aeb774-1ecd-4d73-a3ac-3d3530cad7ff"
def test_start_subtitle_twirp_error(
settings, mock_livekit_client, mock_livekit_token, mock_room_id
):
"""Test handling of LiveKit service errors during subtitle initiation."""
settings.ROOM_SUBTITLE_ENABLED = True
room = RoomFactory(id=mock_room_id)
client = APIClient()
mock_livekit_client.agent_dispatch.create_dispatch.side_effect = TwirpError(
msg="Internal server error", code=500, status=500
)
response = client.post(
f"/api/v1.0/rooms/{room.id}/start-subtitle/",
{"token": mock_livekit_token},
)
assert response.status_code == 500
assert response.json() == {
"error": f"Subtitles failed to start for room {room.slug}"
}
def test_start_subtitle_wrong_room(settings, mock_livekit_token):
"""Test that tokens are validated against the correct room ID."""
settings.ROOM_SUBTITLE_ENABLED = True
room = RoomFactory()
client = APIClient()
response = client.post(
f"/api/v1.0/rooms/{room.id}/start-subtitle/",
{"token": mock_livekit_token},
)
assert response.status_code == 403
assert response.json() == {
"detail": "You do not have permission to perform this action."
}
def test_start_subtitle_wrong_signature(settings, mock_livekit_token):
"""Test that tokens signed with incorrect signature are rejected."""
settings.ROOM_SUBTITLE_ENABLED = True
settings.LIVEKIT_CONFIGURATION["api_secret"] = "wrong-secret"
room = RoomFactory()
client = APIClient()
response = client.post(
f"/api/v1.0/rooms/{room.id}/start-subtitle/",
{"token": mock_livekit_token},
)
assert response.status_code == 403
assert response.json() == {
"detail": "Invalid LiveKit token: Signature verification failed"
}
@@ -2,7 +2,7 @@
Test LiveKit webhook endpoint on the rooms API.
"""
# pylint: disable=too-many-arguments,redefined-outer-name,too-many-positional-arguments,unused-argument
# pylint: disable=R0913,W0621,R0917,W0613
import base64
import hashlib
import json
+3 -43
View File
@@ -144,9 +144,10 @@ def test_get_or_create_participant_id_from_cookie(lobby_service):
assert participant_id == "existing-id"
@mock.patch.object(uuid, "uuid4", return_value="generated-id")
@mock.patch("uuid.uuid4")
def test_get_or_create_participant_id_new(mock_uuid4, lobby_service):
"""Test creating new participant ID when cookie is missing."""
mock_uuid4.return_value = mock.Mock(hex="generated-id")
request = mock.Mock()
request.COOKIES = {}
@@ -265,9 +266,6 @@ def test_request_entry_public_room(
user=request.user,
username=username,
color=participant.color,
configuration=room.configuration,
is_admin_or_owner=False,
participant_id="test-participant-id",
)
lobby_service._get_participant.assert_called_once_with(room.id, participant_id)
@@ -304,9 +302,6 @@ def test_request_entry_trusted_room(
user=request.user,
username=username,
color=participant.color,
configuration=room.configuration,
is_admin_or_owner=False,
participant_id="test-participant-id",
)
lobby_service._get_participant.assert_called_once_with(room.id, participant_id)
@@ -399,9 +394,6 @@ def test_request_entry_accepted_participant(
user=request.user,
username=username,
color="#123456",
configuration=room.configuration,
is_admin_or_owner=False,
participant_id="test-participant-id",
)
lobby_service._get_participant.assert_called_once_with(room.id, participant_id)
@@ -450,7 +442,7 @@ def test_enter_success(
timeout=settings.LOBBY_WAITING_TIMEOUT,
)
mock_notify.assert_called_once_with(
room_name=str(room.pk), notification_data={"type": "participantWaiting"}
room_name=str(room.id), notification_data={"type": "participantWaiting"}
)
@@ -839,35 +831,3 @@ def test_clear_room_empty(settings, lobby_service):
assert cache.keys(f"test-lobby_{room_id!s}_*") == []
lobby_service.clear_room_cache(room_id)
assert cache.keys(f"test-lobby_{room_id!s}_*") == []
def test_clear_participant_cache(lobby_service):
"""Test clearing a specific participant entry from cache."""
room_id = uuid.uuid4()
participant_id = "test-participant-id"
cache_key = f"{settings.LOBBY_KEY_PREFIX}_{room_id!s}_{participant_id}"
participant_data = {
"status": "waiting",
"username": "test-username",
"id": participant_id,
"color": "#123456",
}
cache.set(cache_key, participant_data, timeout=settings.LOBBY_WAITING_TIMEOUT)
assert cache.get(cache_key) is not None
lobby_service.clear_participant_cache(room_id, participant_id)
assert cache.get(cache_key) is None
def test_clear_participant_cache_nonexistent(lobby_service):
"""Test clearing a participant that doesn't exist in cache."""
room_id = uuid.uuid4()
participant_id = "nonexistent-participant"
cache_key = f"{settings.LOBBY_KEY_PREFIX}_{room_id!s}_{participant_id}"
assert cache.get(cache_key) is None
lobby_service.clear_participant_cache(room_id, participant_id)
assert cache.get(cache_key) is None
@@ -1,48 +0,0 @@
"""
Test subtitle service.
"""
# pylint: disable=W0621
from unittest import mock
import pytest
from core.factories import RoomFactory
from core.services.subtitle import SubtitleService
pytestmark = pytest.mark.django_db
@pytest.fixture
def mock_livekit_client():
"""Mock LiveKit API client."""
with mock.patch("core.utils.create_livekit_client") as mock_create:
mock_client = mock.AsyncMock()
mock_create.return_value = mock_client
yield mock_client
def test_start_subtitle_settings(mock_livekit_client, settings):
"""Test that start_subtitle uses the configured agent name from Django settings."""
settings.ROOM_SUBTITLE_AGENT_NAME = "fake-subtitle-agent-name"
room = RoomFactory(name="my room")
SubtitleService().start_subtitle(room)
mock_livekit_client.agent_dispatch.create_dispatch.assert_called_once()
call_args = mock_livekit_client.agent_dispatch.create_dispatch.call_args[0][0]
assert call_args.agent_name == "fake-subtitle-agent-name"
assert call_args.room == str(room.id)
def test_stop_subtitle_not_implemented():
"""Test that stop_subtitle raises NotImplementedError."""
room = RoomFactory(name="my room")
with pytest.raises(
NotImplementedError, match="Subtitle agent stopping not yet implemented"
):
SubtitleService().stop_subtitle(room)
+14 -54
View File
@@ -2,13 +2,12 @@
Utils functions used in the core app
"""
# pylint: disable=R0913, R0917
# ruff: noqa:S311, PLR0913
# ruff: noqa:S311
import hashlib
import json
import random
from typing import List, Optional
from typing import Optional
from uuid import uuid4
from django.conf import settings
@@ -50,13 +49,7 @@ def generate_color(identity: str) -> str:
def generate_token(
room: str,
user,
username: Optional[str] = None,
color: Optional[str] = None,
sources: Optional[List[str]] = None,
is_admin_or_owner: bool = False,
participant_id: Optional[str] = None,
room: str, user, username: Optional[str] = None, color: Optional[str] = None
) -> str:
"""Generate a LiveKit access token for a user in a specific room.
@@ -67,34 +60,25 @@ def generate_token(
If none, a default value will be used.
color (Optional[str]): The color to be displayed in the room.
If none, a value will be generated
sources: (Optional[List[str]]): List of media sources the user can publish
If none, defaults to LIVEKIT_DEFAULT_SOURCES.
is_admin_or_owner (bool): Whether user has admin privileges
participant_id (Optional[str]): Stable identifier for anonymous users;
used as identity when user.is_anonymous.
Returns:
str: The LiveKit JWT access token.
"""
if is_admin_or_owner:
sources = settings.LIVEKIT_DEFAULT_SOURCES
if sources is None:
sources = settings.LIVEKIT_DEFAULT_SOURCES
video_grants = VideoGrants(
room=room,
room_join=True,
room_admin=is_admin_or_owner,
room_admin=True,
can_update_own_metadata=True,
can_publish=bool(sources),
can_publish_sources=sources,
can_subscribe=True,
can_publish_sources=[
"camera",
"microphone",
"screen_share",
"screen_share_audio",
],
)
if user.is_anonymous:
identity = participant_id or str(uuid4())
identity = str(uuid4())
default_username = "Anonymous"
else:
identity = str(user.sub)
@@ -111,22 +95,14 @@ def generate_token(
.with_grants(video_grants)
.with_identity(identity)
.with_name(username or default_username)
.with_attributes(
{"color": color, "room_admin": "true" if is_admin_or_owner else "false"}
)
.with_metadata(json.dumps({"color": color}))
)
return token.to_jwt()
def generate_livekit_config(
room_id: str,
user,
username: str,
is_admin_or_owner: bool,
color: Optional[str] = None,
configuration: Optional[dict] = None,
participant_id: Optional[str] = None,
room_id: str, user, username: str, color: Optional[str] = None
) -> dict:
"""Generate LiveKit configuration for room access.
@@ -134,31 +110,15 @@ def generate_livekit_config(
room_id: Room identifier
user: User instance requesting access
username: Display name in room
is_admin_or_owner (bool): Whether the user has admin/owner privileges for this room.
color (Optional[str]): Optional color to associate with the participant.
configuration (Optional[dict]): Room configuration dict that can override default settings.
participant_id (Optional[str]): Stable identifier for anonymous users;
used as identity when user.is_anonymous.
Returns:
dict: LiveKit configuration with URL, room and access token
"""
sources = None
if configuration is not None:
sources = configuration.get("can_publish_sources", None)
return {
"url": settings.LIVEKIT_CONFIGURATION["url"],
"room": room_id,
"token": generate_token(
room=room_id,
user=user,
username=username,
color=color,
sources=sources,
is_admin_or_owner=is_admin_or_owner,
participant_id=participant_id,
room=room_id, user=user, username=username, color=color
),
}
-26
View File
@@ -495,16 +495,6 @@ class Base(Configuration):
LIVEKIT_FORCE_WSS_PROTOCOL = values.BooleanValue(
False, environ_name="LIVEKIT_FORCE_WSS_PROTOCOL", environ_prefix=None
)
LIVEKIT_DEFAULT_SOURCES = values.ListValue(
[
"camera",
"microphone",
"screen_share",
"screen_share_audio",
],
environ_name="LIVEKIT_DEFAULT_SOURCES",
environ_prefix=None,
)
LIVEKIT_ENABLE_FIREFOX_PROXY_WORKAROUND = values.BooleanValue(
environ_name="LIVEKIT_ENABLE_FIREFOX_PROXY_WORKAROUND",
environ_prefix=None,
@@ -654,22 +644,6 @@ class Base(Configuration):
environ_prefix=None,
)
# Subtitles settings
ROOM_SUBTITLE_ENABLED = values.BooleanValue(
False, environ_name="ROOM_SUBTITLE_ENABLED", environ_prefix=None
)
ROOM_SUBTITLE_AGENT_NAME = values.Value(
"multi-user-transcriber",
environ_name="ROOM_SUBTITLE_AGENT_NAME",
environ_prefix=None,
)
ROOM_METADATA_AGENT_NAME = values.Value(
"metadata-extractor",
environ_name="ROOM_METADATA_AGENT_NAME",
environ_prefix=None,
)
# pylint: disable=invalid-name
@property
def ENVIRONMENT(self):
+6 -7
View File
@@ -7,7 +7,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "meet"
version = "0.1.38"
version = "0.1.33"
authors = [{ "name" = "DINUM", "email" = "dev@mail.numerique.gouv.fr" }]
classifiers = [
"Development Status :: 5 - Production/Stable",
@@ -38,7 +38,7 @@ dependencies = [
"django-redis==6.0.0",
"django-storages[s3]==1.14.6",
"django-timezone-field>=5.1",
"django==5.2.6",
"django==5.2.3",
"djangorestframework==3.16.0",
"drf_spectacular==0.28.0",
"dockerflow==2024.4.2",
@@ -48,7 +48,6 @@ dependencies = [
"jsonschema==4.24.0",
"markdown==3.8.2",
"nested-multipart-parser==1.5.0",
"posthog==6.0.3",
"psycopg[binary]==3.2.9",
"PyJWT==2.10.1",
"python-frontmatter==1.1.0",
@@ -61,10 +60,10 @@ dependencies = [
]
[project.urls]
"Bug Tracker" = "https://github.com/suitenumerique/meet/issues/new"
"Changelog" = "https://github.com/suitenumerique/meet/blob/main/CHANGELOG.md"
"Homepage" = "https://github.com/suitenumerique/meet"
"Repository" = "https://github.com/suitenumerique/meet"
"Bug Tracker" = "https://github.com/numerique-gouv/meet/issues/new"
"Changelog" = "https://github.com/numerique-gouv/meet/blob/main/CHANGELOG.md"
"Homepage" = "https://github.com/numerique-gouv/meet"
"Repository" = "https://github.com/numerique-gouv/meet"
[project.optional-dependencies]
dev = [
+1 -4
View File
@@ -2,10 +2,7 @@
<html lang="en" data-lk-theme="visio-light">
<head>
<meta charset="UTF-8" />
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png">
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png">
<link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png">
<link rel="manifest" href="/site.webmanifest">
<link rel="icon" type="image/svg+xml" href="/assets/icon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>%VITE_APP_TITLE%</title>
</head>
+17 -18
View File
@@ -1,19 +1,18 @@
{
"name": "meet",
"version": "0.1.38",
"version": "0.1.33",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "meet",
"version": "0.1.38",
"version": "0.1.33",
"dependencies": {
"@livekit/components-react": "2.9.13",
"@livekit/components-styles": "1.1.6",
"@livekit/track-processors": "0.6.1",
"@livekit/track-processors": "0.5.7",
"@pandacss/preset-panda": "0.54.0",
"@react-aria/toast": "3.0.5",
"@react-types/overlays": "3.9.0",
"@remixicon/react": "4.6.0",
"@tanstack/react-query": "5.81.5",
"@timephy/rnnoise-wasm": "1.0.0",
@@ -26,7 +25,7 @@
"i18next-parser": "9.3.0",
"i18next-resources-to-backend": "1.2.1",
"libphonenumber-js": "1.12.10",
"livekit-client": "2.15.7",
"livekit-client": "2.15.2",
"posthog-js": "1.256.2",
"react": "18.3.1",
"react-aria-components": "1.10.1",
@@ -1281,9 +1280,9 @@
}
},
"node_modules/@livekit/track-processors": {
"version": "0.6.1",
"resolved": "https://registry.npmjs.org/@livekit/track-processors/-/track-processors-0.6.1.tgz",
"integrity": "sha512-t9JMDvMUlaaURDDRZFQEkRYR4q2qROPOOIs3aZXQVL6v/QYgJ0tPg/QfbvHC8b6mYPwcaJgVz3KTk5XQ07fEMg==",
"version": "0.5.7",
"resolved": "https://registry.npmjs.org/@livekit/track-processors/-/track-processors-0.5.7.tgz",
"integrity": "sha512-/2SkuVAF+YiPNtOi9zQJz/yH1WGaK53XZ3PaESpLOiEYUBsYky13BrriXCXUf6kwn5R5+7ZsYWc2k3XSsAuLtg==",
"license": "Apache-2.0",
"dependencies": {
"@mediapipe/tasks-vision": "0.10.14"
@@ -3379,12 +3378,12 @@
}
},
"node_modules/@react-types/overlays": {
"version": "3.9.0",
"resolved": "https://registry.npmjs.org/@react-types/overlays/-/overlays-3.9.0.tgz",
"integrity": "sha512-T2DqMcDN5p8vb4vu2igoLrAtuewaNImLS8jsK7th7OjwQZfIWJn5Y45jSxHtXJUddEg1LkUjXYPSXCMerMcULw==",
"version": "3.8.16",
"resolved": "https://registry.npmjs.org/@react-types/overlays/-/overlays-3.8.16.tgz",
"integrity": "sha512-Aj9jIFwALk9LiOV/s3rVie+vr5qWfaJp/6aGOuc2StSNDTHvj1urSAr3T0bT8wDlkrqnlS4JjEGE40ypfOkbAA==",
"license": "Apache-2.0",
"dependencies": {
"@react-types/shared": "^3.31.0"
"@react-types/shared": "^3.30.0"
},
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1"
@@ -3440,9 +3439,9 @@
}
},
"node_modules/@react-types/shared": {
"version": "3.31.0",
"resolved": "https://registry.npmjs.org/@react-types/shared/-/shared-3.31.0.tgz",
"integrity": "sha512-ua5U6V66gDcbLZe4P2QeyNgPp4YWD1ymGA6j3n+s8CGExtrCPe64v+g4mvpT8Bnb985R96e4zFT61+m0YCwqMg==",
"version": "3.30.0",
"resolved": "https://registry.npmjs.org/@react-types/shared/-/shared-3.30.0.tgz",
"integrity": "sha512-COIazDAx1ncDg046cTJ8SFYsX8aS3lB/08LDnbkH/SkdYrFPWDlXMrO/sUam8j1WWM+PJ+4d1mj7tODIKNiFog==",
"license": "Apache-2.0",
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1"
@@ -7460,9 +7459,9 @@
}
},
"node_modules/livekit-client": {
"version": "2.15.7",
"resolved": "https://registry.npmjs.org/livekit-client/-/livekit-client-2.15.7.tgz",
"integrity": "sha512-19m8Q1cvRl5PslRawDUgWXeP8vL8584tX8kiZEJaPZo83U/L6VPS/O7pP06phfJaBWeeV8sAOVtEPlQiZEHtpg==",
"version": "2.15.2",
"resolved": "https://registry.npmjs.org/livekit-client/-/livekit-client-2.15.2.tgz",
"integrity": "sha512-hf0A0JFN7M0iVGZxMfTk6a3cW7TNTVdqxkykjKBweORlqhQX1ITVloh6aLvplLZOxpkUE5ZVLz1DeS3+ERglog==",
"license": "Apache-2.0",
"dependencies": {
"@livekit/mutex": "1.1.1",
+3 -4
View File
@@ -1,7 +1,7 @@
{
"name": "meet",
"private": true,
"version": "0.1.38",
"version": "0.1.33",
"type": "module",
"scripts": {
"dev": "panda codegen && vite",
@@ -15,10 +15,9 @@
"dependencies": {
"@livekit/components-react": "2.9.13",
"@livekit/components-styles": "1.1.6",
"@livekit/track-processors": "0.6.1",
"@livekit/track-processors": "0.5.7",
"@pandacss/preset-panda": "0.54.0",
"@react-aria/toast": "3.0.5",
"@react-types/overlays": "3.9.0",
"@remixicon/react": "4.6.0",
"@tanstack/react-query": "5.81.5",
"@timephy/rnnoise-wasm": "1.0.0",
@@ -31,7 +30,7 @@
"i18next-parser": "9.3.0",
"i18next-resources-to-backend": "1.2.1",
"libphonenumber-js": "1.12.10",
"livekit-client": "2.15.7",
"livekit-client": "2.15.2",
"posthog-js": "1.256.2",
"react": "18.3.1",
"react-aria-components": "1.10.1",
-1
View File
@@ -124,7 +124,6 @@ const config: Config = {
...pandaPreset.theme.tokens.colors,
primaryDark: {
50: { value: '#161622' },
75: { value: '#222234' },
100: { value: '#2D2D46' },
200: { value: '#43436A' },
300: { value: '#5A5A8F' },
Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.4 KiB

+5
View File
@@ -0,0 +1,5 @@
<svg width="160" height="160" viewBox="0 0 160 160" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M5.61135 59.6951C5 62.832 5 66.6949 5 73.1656V88.7307C5 96.7588 5 100.773 6.16756 104.366C7.20062 107.546 8.89041 110.472 11.1274 112.957C13.6555 115.765 17.1318 117.772 24.0842 121.786L37.564 129.568C43.7169 133.121 47.1472 135.101 50.4165 136.054V88.0288C50.4165 85.6281 49.1052 83.4192 46.9976 82.2696L5.61135 59.6951Z" fill="#C9191E"/>
<path d="M118.313 66.7489V91.7898C118.313 95.2521 119.818 98.5435 122.436 100.809L140.459 116.406C141.513 117.298 142.608 118.028 143.744 118.596C144.92 119.123 146.056 119.387 147.151 119.387C149.503 119.387 151.389 118.616 152.809 117.075C154.269 115.493 154.999 113.445 154.999 110.93V47.6577C154.999 45.1431 154.269 43.1151 152.809 41.5738C151.389 39.992 149.503 39.2011 147.151 39.2011C146.056 39.2011 144.92 39.4647 143.744 39.992C142.608 40.5193 141.513 41.2494 140.459 42.1822L122.45 57.7172C119.823 59.983 118.313 63.28 118.313 66.7489Z" fill="#000091"/>
<path d="M11.6345 50.7522C11.1333 50.4788 10.6078 50.2937 10.0757 50.1915C10.4114 49.7628 10.7622 49.3452 11.1276 48.9394C13.6558 46.1315 17.132 44.1245 24.0845 40.1105L37.5643 32.3279C44.5167 28.3139 47.993 26.3069 51.6887 25.5213C54.9587 24.8262 58.3383 24.8262 61.6083 25.5213C65.304 26.3069 68.7803 28.3139 75.7327 32.3279L89.0535 40.0187C89.1066 40.0492 89.1597 40.0798 89.2129 40.1105C96.1653 44.1245 99.6416 46.1316 102.17 48.9394C104.407 51.4238 106.096 54.3506 107.13 57.5301C108.297 61.1235 108.297 65.1375 108.297 73.1656V88.7307C108.297 96.7588 108.297 100.773 107.13 104.366C106.096 107.546 104.407 110.473 102.17 112.957C99.6416 115.765 96.1655 117.772 89.2133 121.785C89.1537 121.82 89.0936 121.855 89.0341 121.889L75.7327 129.568C68.7803 133.582 65.304 135.589 61.6083 136.375C61.4564 136.407 61.3043 136.438 61.1519 136.467L61.1519 88.0288C61.1519 81.6997 57.6948 75.8761 52.1386 72.8455L11.6345 50.7522Z" fill="#000091"/>
</svg>

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 503 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 KiB

-1
View File
@@ -1 +0,0 @@
{"name":"","short_name":"","icons":[{"src":"/android-chrome-192x192.png","sizes":"192x192","type":"image/png"},{"src":"/android-chrome-512x512.png","sizes":"512x512","type":"image/png"}],"theme_color":"#ffffff","background_color":"#ffffff","display":"standalone"}
-4
View File
@@ -31,9 +31,6 @@ export interface ApiConfig {
expiration_days?: number
max_duration?: number
}
subtitle: {
enabled: boolean
}
telephony: {
enabled: boolean
phone_number?: string
@@ -44,7 +41,6 @@ export interface ApiConfig {
url: string
force_wss_protocol: boolean
enable_firefox_proxy_workaround: boolean
default_sources: string[]
}
}
-6
View File
@@ -16,12 +16,6 @@ const avatar = cva({
},
variants: {
context: {
subtitles: {
width: '40px',
height: '40px',
fontSize: '1.3rem',
lineHeight: '1rem',
},
list: {
width: '32px',
height: '32px',
@@ -3,5 +3,4 @@ export enum FeatureFlags {
ScreenRecording = 'screen-recording',
faceLandmarks = 'face-landmarks',
noiseReduction = 'noise-reduction',
subtitles = 'subtitles',
}
@@ -98,25 +98,11 @@ export const MainNotificationToast = () => {
case NotificationType.ScreenRecordingLimitReached:
toastQueue.add(
{
participant,
type: notification.type,
},
{ timeout: NotificationDuration.ALERT }
)
break
case NotificationType.PermissionsRemoved: {
const removedSources = notification?.data?.removedSources
if (!removedSources?.length) break
toastQueue.add(
{
participant,
type: notification.type,
removedSources: removedSources,
},
{ timeout: NotificationDuration.ALERT }
)
break
}
default:
return
}
@@ -4,6 +4,5 @@ export interface NotificationPayload {
type: NotificationType
data?: {
emoji?: string
removedSources?: string[]
}
}
@@ -13,5 +13,4 @@ export enum NotificationType {
ScreenRecordingStopped = 'screenRecordingStopped',
ScreenRecordingLimitReached = 'screenRecordingLimitReached',
RecordingSaving = 'recordingSaving',
PermissionsRemoved = 'permissionsRemoved',
}
@@ -38,7 +38,7 @@ export interface ToastProps {
state: ToastState<ToastData>
}
export function Toast({ state, ...props }: Readonly<ToastProps>) {
export function Toast({ state, ...props }: ToastProps) {
const ref = useRef(null)
const { toastProps, contentProps, closeButtonProps } = useToast(
props,
@@ -6,7 +6,7 @@ import { HStack } from '@/styled-system/jsx'
import { useTranslation } from 'react-i18next'
import { NotificationType } from '../NotificationType'
export function ToastAnyRecording({ state, ...props }: Readonly<ToastProps>) {
export function ToastAnyRecording({ state, ...props }: ToastProps) {
const { t } = useTranslation('notifications')
const ref = useRef(null)
const { toastProps, contentProps } = useToast(props, state, ref)
@@ -19,7 +19,7 @@ const ClickableToast = styled(RACButton, {
},
})
export function ToastJoined({ state, ...props }: Readonly<ToastProps>) {
export function ToastJoined({ state, ...props }: ToastProps) {
const { t } = useTranslation('notifications')
const ref = useRef(null)
const { toastProps, contentProps, titleProps, closeButtonProps } = useToast(
@@ -7,7 +7,7 @@ import { useTranslation } from 'react-i18next'
import { Button } from '@/primitives'
import { css } from '@/styled-system/css'
export function ToastLowerHand({ state, ...props }: Readonly<ToastProps>) {
export function ToastLowerHand({ state, ...props }: ToastProps) {
const { t } = useTranslation('notifications', { keyPrefix: 'lowerHand' })
const ref = useRef(null)
const { toastProps, contentProps } = useToast(props, state, ref)
@@ -9,10 +9,7 @@ import { Button as RACButton } from 'react-aria-components'
import { css } from '@/styled-system/css'
import { useTranslation } from 'react-i18next'
export function ToastMessageReceived({
state,
...props
}: Readonly<ToastProps>) {
export function ToastMessageReceived({ state, ...props }: ToastProps) {
const { t } = useTranslation('notifications')
const ref = useRef(null)
const { toastProps } = useToast(props, state, ref)
@@ -5,7 +5,7 @@ import { StyledToastContainer, ToastProps } from './Toast'
import { HStack } from '@/styled-system/jsx'
import { useTranslation } from 'react-i18next'
export function ToastMuted({ state, ...props }: Readonly<ToastProps>) {
export function ToastMuted({ state, ...props }: ToastProps) {
const { t } = useTranslation('notifications')
const ref = useRef(null)
const { toastProps, contentProps } = useToast(props, state, ref)
@@ -1,52 +0,0 @@
import { useToast } from '@react-aria/toast'
import { useMemo, useRef } from 'react'
import { StyledToastContainer, ToastProps } from './Toast'
import { HStack } from '@/styled-system/jsx'
import { useTranslation } from 'react-i18next'
export function ToastPermissionsRemoved({
state,
...props
}: Readonly<ToastProps>) {
const { t } = useTranslation('notifications', {
keyPrefix: 'permissionsRemoved',
})
const ref = useRef(null)
const { toastProps, contentProps } = useToast(props, state, ref)
const participant = props.toast.content.participant
const key = useMemo(() => {
const sources = props.toast.content.removedSources
if (!Array.isArray(sources) || sources.length === 0) {
return undefined
}
if (sources.length === 1) {
return sources[0]
}
if (sources.length === 2 && sources.includes('screen_share')) {
return 'screen_share'
}
return undefined
}, [props.toast.content.removedSources])
if (!participant || !key) return null
return (
<StyledToastContainer {...toastProps} ref={ref}>
<HStack
justify="center"
alignItems="center"
{...contentProps}
padding={14}
gap={0}
>
{t(key)}
</HStack>
</StyledToastContainer>
)
}
@@ -9,7 +9,7 @@ import { RiCloseLine, RiHand } from '@remixicon/react'
import { useSidePanel } from '@/features/rooms/livekit/hooks/useSidePanel'
import { css } from '@/styled-system/css'
export function ToastRaised({ state, ...props }: Readonly<ToastProps>) {
export function ToastRaised({ state, ...props }: ToastProps) {
const { t } = useTranslation('notifications')
const ref = useRef(null)
const { toastProps, contentProps, titleProps, closeButtonProps } = useToast(
@@ -9,10 +9,7 @@ import { useUser } from '@/features/auth'
import { css } from '@/styled-system/css'
import { RecordingMode } from '@/features/recording'
export function ToastRecordingSaving({
state,
...props
}: Readonly<ToastProps>) {
export function ToastRecordingSaving({ state, ...props }: ToastProps) {
const { t } = useTranslation('notifications', { keyPrefix: 'recordingSave' })
const ref = useRef(null)
const { toastProps, contentProps } = useToast(props, state, ref)
@@ -11,7 +11,6 @@ import { ToastMessageReceived } from './ToastMessageReceived'
import { ToastLowerHand } from './ToastLowerHand'
import { ToastAnyRecording } from './ToastAnyRecording'
import { ToastRecordingSaving } from './ToastRecordingSaving'
import { ToastPermissionsRemoved } from './ToastPermissionsRemoved'
interface ToastRegionProps extends AriaToastRegionProps {
state: ToastState<ToastData>
@@ -31,11 +30,6 @@ const renderToast = (
case NotificationType.ParticipantMuted:
return <ToastMuted key={toast.key} toast={toast} state={state} />
case NotificationType.PermissionsRemoved:
return (
<ToastPermissionsRemoved key={toast.key} toast={toast} state={state} />
)
case NotificationType.MessageReceived:
return (
<ToastMessageReceived key={toast.key} toast={toast} state={state} />
@@ -23,8 +23,6 @@ import posthog from 'posthog-js'
import { useSnapshot } from 'valtio/index'
import { Spinner } from '@/primitives/Spinner'
import { useConfig } from '@/api/useConfig'
import humanizeDuration from 'humanize-duration'
import i18n from 'i18next'
export const ScreenRecordingSidePanel = () => {
const { data } = useConfig()
@@ -191,7 +189,7 @@ export const ScreenRecordingSidePanel = () => {
</H>
<Text
variant="note"
wrap="balance"
wrap={'pretty'}
centered
className={css({
textStyle: 'sm',
@@ -200,18 +198,7 @@ export const ScreenRecordingSidePanel = () => {
marginTop: '0.25rem',
})}
>
{t('start.body', {
duration_message: data?.recording?.max_duration
? t('durationMessage', {
max_duration: humanizeDuration(
data?.recording?.max_duration,
{
language: i18n.language,
}
),
})
: '',
})}{' '}
{t('start.body')} <br />{' '}
{data?.support?.help_article_recording && (
<A href={data.support.help_article_recording} target="_blank">
{t('start.linkMore')}
@@ -25,8 +25,6 @@ import posthog from 'posthog-js'
import { useSnapshot } from 'valtio/index'
import { Spinner } from '@/primitives/Spinner'
import { useConfig } from '@/api/useConfig'
import humanizeDuration from 'humanize-duration'
import i18n from 'i18next'
export const TranscriptSidePanel = () => {
const { data } = useConfig()
@@ -265,7 +263,7 @@ export const TranscriptSidePanel = () => {
</H>
<Text
variant="note"
wrap="balance"
wrap={'pretty'}
centered
className={css({
textStyle: 'sm',
@@ -274,18 +272,7 @@ export const TranscriptSidePanel = () => {
marginTop: '0.25rem',
})}
>
{t('start.body', {
duration_message: data?.recording?.max_duration
? t('durationMessage', {
max_duration: humanizeDuration(
data?.recording?.max_duration,
{
language: i18n.language,
}
),
})
: '',
})}{' '}
{t('start.body')} <br />{' '}
{data?.support?.help_article_transcript && (
<A
href={data.support.help_article_transcript}
@@ -19,6 +19,6 @@ export type ApiRoom = {
access_level: ApiAccessLevel
livekit?: ApiLiveKit
configuration?: {
[key: string]: string | number | boolean | string[]
[key: string]: string | number | boolean
}
}
@@ -1,27 +0,0 @@
import { Participant } from 'livekit-client'
import { fetchApi } from '@/api/fetchApi.ts'
import { useRoomData } from '@/features/rooms/livekit/hooks/useRoomData'
export const useLowerHandParticipant = () => {
const data = useRoomData()
const lowerHandParticipant = async (participant: Participant) => {
if (!data?.id) {
throw new Error('Room id is not available')
}
const newAttributes = {
...participant.attributes,
handRaisedAt: '',
}
return await fetchApi(`rooms/${data.id}/update-participant/`, {
method: 'POST',
body: JSON.stringify({
participant_identity: participant.identity,
attributes: newAttributes,
}),
})
}
return { lowerHandParticipant }
}
@@ -1,19 +0,0 @@
import { Participant } from 'livekit-client'
import { useMuteParticipant } from './muteParticipant'
export const useMuteParticipants = () => {
const { muteParticipant } = useMuteParticipant()
const muteParticipants = (participants: Array<Participant>) => {
try {
const promises = participants.map((participant) =>
muteParticipant(participant)
)
return Promise.all(promises)
} catch (error) {
console.error('An error occurred while muting participants :', error)
throw new Error('An error occurred while muting participants.')
}
}
return { muteParticipants }
}
@@ -5,7 +5,7 @@ import { ApiError } from '@/api/ApiError'
export type PatchRoomParams = {
roomId: string
room: Partial<Pick<ApiRoom, 'configuration' | 'access_level'>>
room: Pick<ApiRoom, 'configuration' | 'access_level'>
}
export const patchRoom = ({ roomId, room }: PatchRoomParams) => {
@@ -1,21 +0,0 @@
import { Participant } from 'livekit-client'
import { useRoomData } from '../livekit/hooks/useRoomData'
import { fetchApi } from '@/api/fetchApi'
export const useRemoveParticipant = () => {
const data = useRoomData()
const removeParticipant = async (participant: Participant) => {
if (!data?.id) {
throw new Error('Room id is not available')
}
return fetchApi(`rooms/${data.id}/remove-participant/`, {
method: 'POST',
body: JSON.stringify({
participant_identity: participant.identity,
}),
})
}
return { removeParticipant }
}
@@ -1,41 +0,0 @@
import { Participant, Track } from 'livekit-client'
import { fetchApi } from '@/api/fetchApi'
import { useRoomData } from '@/features/rooms/livekit/hooks/useRoomData'
import Source = Track.Source
export const useParticipantPermissions = () => {
const data = useRoomData()
const updateParticipantPermissions = async (
participant: Participant,
sources: Array<Source>
) => {
if (!data?.id) {
throw new Error('Room id is not available')
}
const newPermissions = {
can_subscribe: participant.permissions?.canSubscribe,
can_publish_data: participant.permissions?.canPublishData,
can_update_metadata: participant.permissions?.canUpdateMetadata,
can_subscribe_metrics: participant.permissions?.canSubscribeMetrics,
can_publish: sources.length > 0,
can_publish_sources: sources.map((source) => source.toUpperCase()),
}
try {
return fetchApi(`rooms/${data.id}/update-participant/`, {
method: 'POST',
body: JSON.stringify({
participant_identity: participant.identity,
permission: newPermissions,
}),
})
} catch (error) {
console.error(
`Failed to update participant's permissions ${participant.identity}: ${error instanceof Error ? error.message : 'Unknown error'}`
)
}
}
return { updateParticipantPermissions }
}
@@ -1,23 +0,0 @@
import { Participant, Track } from 'livekit-client'
import { useParticipantPermissions } from './updateParticipantPermissions'
type Source = Track.Source
export const useUpdateParticipantsPermissions = () => {
const { updateParticipantPermissions } = useParticipantPermissions()
const updateParticipantsPermissions = (
participants: Array<Participant>,
sources: Array<Source>
) => {
try {
const promises = participants.map((participant) =>
updateParticipantPermissions(participant, sources)
)
return Promise.all(promises)
} catch (error) {
console.error('An error occurred while updating permissions :', error)
throw new Error('An error occurred while updating permissions.')
}
}
return { updateParticipantsPermissions }
}
@@ -10,6 +10,8 @@ import {
MediaDeviceFailure,
Room,
RoomOptions,
supportsAdaptiveStream,
supportsDynacast,
VideoPresets,
} from 'livekit-client'
import { keys } from '@/api/queryKeys'
@@ -85,10 +87,13 @@ export const Conference = ({
retry: false,
})
const isAdaptiveStreamSupported = supportsAdaptiveStream()
const isDynacastSupported = supportsDynacast()
const roomOptions = useMemo((): RoomOptions => {
return {
adaptiveStream: true,
dynacast: true,
adaptiveStream: isAdaptiveStreamSupported,
dynacast: isDynacastSupported,
publishDefaults: {
videoCodec: 'vp9',
},
@@ -111,6 +116,8 @@ export const Conference = ({
userConfig.videoPublishResolution,
userConfig.audioDeviceId,
userConfig.audioOutputDeviceId,
isAdaptiveStreamSupported,
isDynacastSupported,
])
const room = useMemo(() => new Room(roomOptions), [roomOptions])
@@ -3,13 +3,7 @@ import { usePreviewTracks } from '@livekit/components-react'
import { css } from '@/styled-system/css'
import { Screen } from '@/layout/Screen'
import { useEffect, useMemo, useRef, useState } from 'react'
import {
createLocalVideoTrack,
createLocalAudioTrack,
LocalAudioTrack,
LocalVideoTrack,
Track,
} from 'livekit-client'
import { LocalVideoTrack, Track } from 'livekit-client'
import { H } from '@/primitives/H'
import { Field } from '@/primitives/Field'
import { Button, Dialog, Text, Form } from '@/primitives'
@@ -20,8 +14,6 @@ import {
EffectsConfiguration,
EffectsConfigurationProps,
} from '../livekit/components/effects/EffectsConfiguration'
import { SelectDevice } from '../livekit/components/controls/Device/SelectDevice'
import { ToggleDevice } from '../livekit/components/controls/Device/ToggleDevice'
import { usePersistentUserChoices } from '../livekit/hooks/usePersistentUserChoices'
import { BackgroundProcessorFactory } from '../livekit/components/blur'
import { isMobileBrowser } from '@livekit/components-core'
@@ -34,11 +26,12 @@ import { ApiLobbyStatus, ApiRequestEntry } from '../api/requestEntry'
import { Spinner } from '@/primitives/Spinner'
import { ApiAccessLevel } from '../api/ApiRoom'
import { useLoginHint } from '@/hooks/useLoginHint'
import { openPermissionsDialog } from '@/stores/permissions'
import { useResolveInitiallyDefaultDeviceId } from '../livekit/hooks/useResolveInitiallyDefaultDeviceId'
import { useSnapshot } from 'valtio'
import { openPermissionsDialog, permissionsStore } from '@/stores/permissions'
import { ToggleDevice } from './join/ToggleDevice'
import { SelectDevice } from './join/SelectDevice'
import { useResolveDefaultDeviceId } from '../livekit/hooks/useResolveDefaultDevice'
import { isSafari } from '@/utils/livekit'
import type { LocalUserChoices } from '@/stores/userChoices'
import { useCannotUseDevice } from '../livekit/hooks/useCannotUseDevice'
const onError = (e: Error) => console.error('ERROR', e)
@@ -123,43 +116,19 @@ export const Join = ({
saveProcessorSerialized,
} = usePersistentUserChoices()
const initialUserChoices = useRef<LocalUserChoices | null>(null)
if (initialUserChoices.current === null) {
initialUserChoices.current = {
audioEnabled,
videoEnabled,
audioDeviceId,
audioOutputDeviceId,
videoDeviceId,
processorSerialized,
username,
}
}
const tracks = usePreviewTracks(
{
audio: !!initialUserChoices.current &&
initialUserChoices.current?.audioEnabled && {
deviceId: initialUserChoices.current.audioDeviceId,
},
video: !!initialUserChoices.current &&
initialUserChoices.current?.videoEnabled && {
deviceId: initialUserChoices.current.videoDeviceId,
processor: BackgroundProcessorFactory.deserializeProcessor(
initialUserChoices.current.processorSerialized
),
},
audio: { deviceId: audioDeviceId },
video: {
deviceId: videoDeviceId,
processor:
BackgroundProcessorFactory.deserializeProcessor(processorSerialized),
},
},
onError
)
const [dynamicVideoTrack, setDynamicVideoTrack] =
useState<LocalVideoTrack | null>(null)
const [dynamicAudioTrack, setDynamicAudioTrack] =
useState<LocalAudioTrack | null>(null)
const previewVideoTrack = useMemo(
const videoTrack = useMemo(
() =>
tracks?.filter(
(track) => track.kind === Track.Kind.Video
@@ -167,100 +136,18 @@ export const Join = ({
[tracks]
)
const previewAudioTrack = useMemo(
const audioTrack = useMemo(
() =>
tracks?.filter(
(track) => track.kind === Track.Kind.Audio
)[0] as LocalAudioTrack,
)[0] as LocalVideoTrack,
[tracks]
)
/*
* Dynamic track creation strategy: Only create a dynamic track if the user initially disabled audio/video
* but now wants to enable it. This is a "just-in-time" acquisition pattern where we create the track
* on-demand. We avoid creating tracks when the user explicitly requested them to be disabled.
*/
useEffect(() => {
const createVideoTrack = async () => {
try {
const track = await createLocalVideoTrack({
deviceId: { exact: videoDeviceId },
processor:
BackgroundProcessorFactory.deserializeProcessor(
processorSerialized
),
})
setDynamicVideoTrack(track)
} catch (error) {
onError(error as Error)
}
}
if (
videoEnabled &&
!initialUserChoices.current?.videoEnabled &&
!previewVideoTrack &&
!dynamicVideoTrack
) {
createVideoTrack()
}
}, [
videoEnabled,
videoDeviceId,
processorSerialized,
previewVideoTrack,
dynamicVideoTrack,
])
useEffect(() => {
const createAudioTrack = async () => {
try {
const track = await createLocalAudioTrack({
deviceId: { exact: audioDeviceId },
})
setDynamicAudioTrack(track)
} catch (error) {
onError(error as Error)
}
}
if (
audioEnabled &&
!initialUserChoices.current?.audioEnabled &&
!previewAudioTrack &&
!dynamicAudioTrack
) {
createAudioTrack()
}
}, [audioEnabled, audioDeviceId, previewAudioTrack, dynamicAudioTrack])
// Cleanup dynamic tracks
useEffect(() => {
return () => {
dynamicVideoTrack?.stop()
}
}, [dynamicVideoTrack])
useEffect(() => {
return () => {
dynamicAudioTrack?.stop()
}
}, [dynamicAudioTrack])
// Final tracks (dynamic takes precedence over preview)
const videoTrack = dynamicVideoTrack || previewVideoTrack
const audioTrack = dynamicAudioTrack || previewAudioTrack
// LiveKit by default populates device choices with "default" value.
// Instead, use the current device id used by the preview track as a default
useResolveInitiallyDefaultDeviceId(
audioDeviceId,
audioTrack,
saveAudioInputDeviceId
)
useResolveInitiallyDefaultDeviceId(
videoDeviceId,
videoTrack,
saveVideoInputDeviceId
)
useResolveDefaultDeviceId(audioDeviceId, audioTrack, saveAudioInputDeviceId)
useResolveDefaultDeviceId(videoDeviceId, videoTrack, saveVideoInputDeviceId)
const videoEl = useRef(null)
const isVideoInitiated = useRef(false)
@@ -276,6 +163,7 @@ export const Join = ({
}
if (videoElement && videoTrack && videoEnabled) {
videoTrack.unmute()
videoTrack.attach(videoElement)
videoElement.addEventListener('loadedmetadata', handleVideoLoaded)
}
@@ -348,8 +236,13 @@ export const Join = ({
enterRoom()
}
const isCameraDeniedOrPrompted = useCannotUseDevice('videoinput')
const isMicrophoneDeniedOrPrompted = useCannotUseDevice('audioinput')
const permissions = useSnapshot(permissionsStore)
const isCameraDeniedOrPrompted =
permissions.isCameraDenied || permissions.isCameraPrompted
const isMicrophoneDeniedOrPrompted =
permissions.isMicrophoneDenied || permissions.isMicrophonePrompted
const hintMessage = useMemo(() => {
if (isCameraDeniedOrPrompted) {
@@ -474,7 +367,7 @@ export const Join = ({
width: '100%',
flexDirection: 'column',
flexGrow: 1,
gap: { base: '1rem', sm: '2rem', lg: '2rem' },
gap: { base: '1rem', sm: '2rem', lg: 0 },
lg: {
flexDirection: 'row',
},
@@ -638,7 +531,7 @@ export const Join = ({
<Button
size="sm"
variant="tertiary"
onPress={() => openPermissionsDialog('videoinput')}
onPress={openPermissionsDialog}
>
{t(`permissionsButton.${permissionsButtonLabel}`)}
</Button>
@@ -658,30 +551,18 @@ export const Join = ({
})}
>
<ToggleDevice
kind="audioinput"
context="join"
enabled={audioEnabled}
toggle={async () => {
saveAudioInputEnabled(!audioEnabled)
if (audioEnabled) {
await audioTrack?.mute()
} else {
await audioTrack?.unmute()
}
}}
source={Track.Source.Microphone}
initialState={audioEnabled}
track={audioTrack}
onChange={(enabled) => saveAudioInputEnabled(enabled)}
onDeviceError={(error) => console.error(error)}
/>
<ToggleDevice
kind="videoinput"
context="join"
enabled={videoEnabled}
toggle={async () => {
saveVideoInputEnabled(!videoEnabled)
if (videoEnabled) {
await videoTrack?.mute()
} else {
await videoTrack?.unmute()
}
}}
source={Track.Source.Camera}
initialState={videoEnabled}
track={videoTrack}
onChange={(enabled) => saveVideoInputEnabled(enabled)}
onDeviceError={(error) => console.error(error)}
/>
</div>
<div
@@ -718,16 +599,7 @@ export const Join = ({
<SelectDevice
kind="audioinput"
id={audioDeviceId}
onSubmit={async (id) => {
try {
saveAudioInputDeviceId(id)
if (audioTrack) {
await audioTrack.setDeviceId({ exact: id })
}
} catch (err) {
console.error('Failed to switch microphone device', err)
}
}}
onSubmit={saveAudioInputDeviceId}
/>
</div>
{!isSafari() && (
@@ -751,16 +623,7 @@ export const Join = ({
<SelectDevice
kind="videoinput"
id={videoDeviceId}
onSubmit={async (id) => {
try {
saveVideoInputDeviceId(id)
if (videoTrack) {
await await videoTrack.setDeviceId({ exact: id })
}
} catch (err) {
console.error('Failed to switch camera device', err)
}
}}
onSubmit={saveVideoInputDeviceId}
/>
</div>
</div>
@@ -771,7 +634,7 @@ export const Join = ({
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
flex: '0 0 360px',
flex: '0 0 448px',
position: 'relative',
margin: '1rem 1rem 1rem 0.5rem',
})}
@@ -37,22 +37,6 @@ export const Permissions = () => {
injectIconIntoTranslation(t('body.openMenu.others'))
useEffect(() => {
if (
permissions.isPermissionDialogOpen &&
permissions.isMicrophoneGranted &&
permissions.requestOrigin == 'audioinput'
) {
closePermissionsDialog()
}
if (
permissions.isPermissionDialogOpen &&
permissions.isCameraGranted &&
permissions.requestOrigin == 'videoinput'
) {
closePermissionsDialog()
}
if (
permissions.isPermissionDialogOpen &&
permissions.isCameraGranted &&
@@ -80,17 +64,13 @@ export const Permissions = () => {
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
flexDirection: 'column',
md: {
flexDirection: 'row',
},
})}
>
<img
src="/assets/camera_mic_permission.svg"
alt=""
className={css({
width: '100%',
minWidth: '290px',
minHeight: '290px',
maxWidth: '290px',
})}
@@ -0,0 +1,143 @@
import {
RemixiconComponentType,
RiMicLine,
RiVideoOnLine,
RiVolumeDownLine,
} from '@remixicon/react'
import { useTranslation } from 'react-i18next'
import { useMediaDeviceSelect } from '@livekit/components-react'
import { useEffect, useMemo } from 'react'
import { Select } from '@/primitives/Select'
import { useSnapshot } from 'valtio'
import { permissionsStore } from '@/stores/permissions'
type DeviceItems = Array<{ value: string; label: string }>
type DeviceConfig = {
icon: RemixiconComponentType
}
type SelectDeviceProps = {
id?: string
onSubmit?: (id: string) => void
kind: MediaDeviceKind
}
type SelectDevicePermissionsProps = SelectDeviceProps & {
config: DeviceConfig
}
const SelectDevicePermissions = ({
id,
kind,
config,
onSubmit,
}: SelectDevicePermissionsProps) => {
const { t } = useTranslation('rooms', { keyPrefix: 'join' })
const { devices, activeDeviceId, setActiveMediaDevice } =
useMediaDeviceSelect({ kind: kind, requestPermissions: true })
const items: DeviceItems = devices
.filter((d) => !!d.deviceId)
.map((d) => ({
value: d.deviceId,
label: d.label,
}))
/**
* FALLBACK AUDIO OUTPUT DEVICE SELECTION
* Auto-selects the only available audio output device when currently on 'default'
*/
useEffect(() => {
if (
kind !== 'audiooutput' ||
items.length !== 1 ||
items[0].value === 'default' ||
activeDeviceId !== 'default'
)
return
onSubmit?.(items[0].value)
setActiveMediaDevice(items[0].value)
}, [items, onSubmit, kind, setActiveMediaDevice, activeDeviceId])
return (
<Select
aria-label={t(`${kind}.choose`)}
label=""
isDisabled={items.length === 0}
items={items}
iconComponent={config?.icon}
placeholder={
items.length === 0
? t('selectDevice.loading')
: t('selectDevice.select')
}
selectedKey={id || activeDeviceId}
onSelectionChange={(key) => {
onSubmit?.(key as string)
setActiveMediaDevice(key as string)
}}
/>
)
}
export const SelectDevice = ({ id, onSubmit, kind }: SelectDeviceProps) => {
const { t } = useTranslation('rooms', { keyPrefix: 'join' })
const permissions = useSnapshot(permissionsStore)
const config = useMemo<DeviceConfig | undefined>(() => {
switch (kind) {
case 'audioinput':
return {
icon: RiMicLine,
}
case 'audiooutput':
return {
icon: RiVolumeDownLine,
}
case 'videoinput':
return {
icon: RiVideoOnLine,
}
}
}, [kind])
const isPermissionDeniedOrPrompted = useMemo(() => {
if (kind == 'audioinput') {
return permissions.isMicrophoneDenied || permissions.isMicrophonePrompted
}
if (kind == 'videoinput') {
return permissions.isCameraDenied || permissions.isCameraPrompted
}
if (kind == 'audiooutput') {
return permissions.isMicrophoneDenied || permissions.isMicrophonePrompted
}
return false
}, [kind, permissions])
if (!config) return null
if (isPermissionDeniedOrPrompted || permissions.isLoading) {
return (
<Select
aria-label={t(`${kind}.permissionsNeeded`)}
label=""
isDisabled={true}
items={[]}
iconComponent={config?.icon}
placeholder={t('selectDevice.permissionsNeeded')}
/>
)
}
return (
<SelectDevicePermissions
id={id}
onSubmit={onSubmit}
kind={kind}
config={config}
/>
)
}
@@ -0,0 +1,78 @@
import { UseTrackToggleProps } from '@livekit/components-react'
import { ToggleDevice as BaseToggleDevice } from '../../livekit/components/controls/ToggleDevice'
import {
TOGGLE_DEVICE_CONFIG,
ToggleSource,
} from '../../livekit/config/ToggleDeviceConfig'
import { LocalAudioTrack, LocalVideoTrack } from 'livekit-client'
import { ButtonRecipeProps } from '@/primitives/buttonRecipe'
import { useCallback, useMemo, useState } from 'react'
import { useSnapshot } from 'valtio'
import { permissionsStore } from '@/stores/permissions'
type ToggleDeviceProps<T extends ToggleSource> = UseTrackToggleProps<T> & {
track?: LocalAudioTrack | LocalVideoTrack
source: ToggleSource
variant?: NonNullable<ButtonRecipeProps>['variant']
}
export const ToggleDevice = <T extends ToggleSource>({
track,
onChange,
...props
}: ToggleDeviceProps<T>) => {
const config = TOGGLE_DEVICE_CONFIG[props.source]
if (!config) {
throw new Error('Invalid source')
}
const [isTrackEnabled, setIsTrackEnabled] = useState(
props.initialState ?? false
)
const permissions = useSnapshot(permissionsStore)
const isPermissionDeniedOrPrompted = useMemo(() => {
if (config.kind == 'audioinput') {
return permissions.isMicrophoneDenied || permissions.isMicrophonePrompted
}
if (config.kind == 'videoinput') {
return permissions.isCameraDenied || permissions.isCameraPrompted
}
}, [config, permissions])
const toggle = useCallback(async () => {
if (!track) {
console.error('Track is undefined.')
return
}
try {
if (isTrackEnabled) {
setIsTrackEnabled(false)
onChange?.(false, true)
await track.mute()
} else {
setIsTrackEnabled(true)
onChange?.(true, true)
await track.unmute()
}
} catch (error) {
console.error('Failed to toggle track:', error)
}
}, [track, onChange, isTrackEnabled])
return (
<BaseToggleDevice
enabled={isTrackEnabled}
isPermissionDeniedOrPrompted={isPermissionDeniedOrPrompted}
toggle={toggle}
config={config}
variant="whiteCircle"
errorVariant="errorCircle"
toggleButtonProps={{
groupPosition: undefined,
}}
/>
)
}
@@ -0,0 +1,5 @@
export const buildServerApiUrl = (origin: string, path: string) => {
const sanitizedOrigin = origin.replace(/\/$/, '')
const sanitizedPath = path.replace(/^\//, '')
return `${sanitizedOrigin}/${sanitizedPath}`
}
@@ -0,0 +1,21 @@
import { ApiError } from '@/api/ApiError'
export const fetchServerApi = async <T = Record<string, unknown>>(
url: string,
token: string,
options?: RequestInit
): Promise<T> => {
const response = await fetch(url, {
...options,
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`,
...options?.headers,
},
})
const result = await response.json()
if (!response.ok) {
throw new ApiError(response.status, result)
}
return result
}
@@ -0,0 +1,37 @@
import { Participant } from 'livekit-client'
import { fetchServerApi } from './fetchServerApi'
import { buildServerApiUrl } from './buildServerApiUrl'
import { useRoomData } from '../hooks/useRoomData'
export const useLowerHandParticipant = () => {
const data = useRoomData()
const lowerHandParticipant = (participant: Participant) => {
if (!data || !data?.livekit) {
throw new Error('Room data is not available')
}
const newAttributes = {
...participant.attributes,
handRaisedAt: '',
}
return fetchServerApi(
buildServerApiUrl(
data.livekit.url,
'twirp/livekit.RoomService/UpdateParticipant'
),
data.livekit.token,
{
method: 'POST',
body: JSON.stringify({
room: data.livekit.room,
identity: participant.identity,
attributes: newAttributes,
permission: participant.permissions,
}),
}
)
}
return { lowerHandParticipant }
}

Some files were not shown because too many files have changed in this diff Show More