mirror of
https://github.com/suitenumerique/meet.git
synced 2026-09-30 14:38:33 +00:00
Compare commits
68 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 387269d1d2 | |||
| 9a2ad63524 | |||
| 67f9e54784 | |||
| 5d3255ddbc | |||
| d89b01b681 | |||
| 660c0ed684 | |||
| 8d980192c8 | |||
| de1f7158f5 | |||
| 6e17c6533c | |||
| 27e32c0370 | |||
| 6d4403d4fa | |||
| 37ae308825 | |||
| 16fd2dc4e8 | |||
| 9791a8a3b2 | |||
| 5b0dece79b | |||
| 96135a0263 | |||
| ce2e2a4d64 | |||
| e5dc9c2f15 | |||
| e97eab9b5e | |||
| 436b3dc9df | |||
| 6ea2a85810 | |||
| 3d1ea88e8f | |||
| beb74af574 | |||
| c785b4a627 | |||
| a9a4246abb | |||
| 3cf7f60eaa | |||
| bf215f1513 | |||
| 75836fc817 | |||
| 1affe65b4a | |||
| c34ecbd3ef | |||
| 78960d9769 | |||
| 41d07d36ee | |||
| f7386e1741 | |||
| f1da04eb27 | |||
| 2970420b84 | |||
| 771f58c0aa | |||
| b159b20695 | |||
| 226d004838 | |||
| b723b7bb62 | |||
| cb36df9104 | |||
| d85123d0c5 | |||
| b2abf814fa | |||
| cfdf1c2f92 | |||
| 4139f542d3 | |||
| eda66640df | |||
| 3fa05ea785 | |||
| 30b68052e1 | |||
| 04fd79b56b | |||
| e336122cfa | |||
| 172dc70649 | |||
| e0ab7f191f | |||
| 455b315dbb | |||
| 3089b03062 | |||
| 60febb3b57 | |||
| bf76ab1ddf | |||
| 7565ede0a7 | |||
| 1a15e9f44e | |||
| 7838d8acfe | |||
| 3bb388b937 | |||
| e1cc8105db | |||
| 7844dfcc12 | |||
| ef71003721 | |||
| f74d23c57e | |||
| acedb21045 | |||
| 67e7d382e3 | |||
| 164ac8d948 | |||
| e3deb37fbe | |||
| 33929324d0 |
@@ -252,13 +252,13 @@ jobs:
|
|||||||
|
|
||||||
- name: Start MinIO
|
- name: Start MinIO
|
||||||
run: |
|
run: |
|
||||||
docker pull minio/minio
|
docker pull quay.io/minio/minio
|
||||||
docker run -d --name minio \
|
docker run -d --name minio \
|
||||||
-p 9000:9000 \
|
-p 9000:9000 \
|
||||||
-e "MINIO_ACCESS_KEY=meet" \
|
-e "MINIO_ACCESS_KEY=meet" \
|
||||||
-e "MINIO_SECRET_KEY=password" \
|
-e "MINIO_SECRET_KEY=password" \
|
||||||
-v /data/media:/data \
|
-v /data/media:/data \
|
||||||
minio/minio server --console-address :9001 /data
|
quay.io/minio/minio server --console-address :9001 /data
|
||||||
|
|
||||||
# Tool to wait for a service to be ready
|
# Tool to wait for a service to be ready
|
||||||
- name: Install Dockerize
|
- name: Install Dockerize
|
||||||
|
|||||||
@@ -86,3 +86,6 @@ docker/livekit/rootCA.pem
|
|||||||
|
|
||||||
# Frontend rollup-plugin-visualizer
|
# Frontend rollup-plugin-visualizer
|
||||||
/src/frontend/rollup-plugin-visualizer/*
|
/src/frontend/rollup-plugin-visualizer/*
|
||||||
|
|
||||||
|
# NixOS
|
||||||
|
.devenv
|
||||||
|
|||||||
@@ -10,13 +10,60 @@ and this project adheres to
|
|||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|
||||||
|
- ✨(backend) make the LiveKit default video codec configurable
|
||||||
|
- 🔧(dev) add support for Bureautix workstations
|
||||||
|
- ✨(frontend) add screen share zoom controls #1498
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- 🔥(backend) remove unused API viewset and permission helpers
|
||||||
|
- 🔊(backend) pin the dockerflow logger level to WARNING
|
||||||
|
- 🚑️(summary) serve health endpoints with the dockerflow router
|
||||||
|
- ♻️(backend) serve the dockerflow views early in the middleware stack
|
||||||
|
- 📈(frontend) include LiveKit SIDs in the connection analytics event
|
||||||
|
- 🔇(backend) silence expected 401 warnings on /me
|
||||||
|
- 🔇(backend) silence noisy request summary info logs
|
||||||
|
- ⚡️(frontend) defer loading the Crisp script until idle
|
||||||
|
- ⬆️(frontend) upgrade react-i18next from 17.0.10 to 17.0.12
|
||||||
|
- ⬆️(frontend) upgrade posthog-js from 1.414.0 to 1.418.10
|
||||||
|
- ⬆️(addons) upgrade i18next from 26.3.6 to 26.4.0
|
||||||
|
- ⬆️(frontend) upgrade humanize-duration from 3.33.2 to 3.34.1
|
||||||
|
- ⬆️(addons) upgrade i18next from 26.4.0 to 26.4.1
|
||||||
|
- 🔖(helm) release chart 0.0.28
|
||||||
|
- ♻️(backend) decouple recording event handling from LiveKit egress statuses
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- 🐛(helm) probe liveness on __lbheartbeat__ and readiness on __heartbeat__
|
||||||
|
- 🐛(helm) render periodSeconds and failureThreshold on probes
|
||||||
|
- 🐛(backend) report the app release to Sentry instead of "NA"
|
||||||
|
- 🐛(frontend) play the waiting room notification sound on every arrival
|
||||||
|
- 🐛(frontend) apply saved reception resolution when joining a meeting #1714
|
||||||
|
- 🐛(backend) acknowledge unknown LiveKit webhook events instead of 422
|
||||||
|
- 🔒️(backend) enforce display name setting on rename API
|
||||||
|
- 🔒️(backend) reject inactive users in resource server backend
|
||||||
|
- 🐛(frontend) fix file permissions in the Docker image
|
||||||
|
- 🚸(frontend) inform user that recording waits until a track is published
|
||||||
|
- 🔒(backend) upgrade base image to python:3.13.5-alpine3.24
|
||||||
|
- 🐛(backend) handle failed and aborted egresses
|
||||||
|
- 🩹(frontend) notify participants when a recording fails or is aborted
|
||||||
|
- 🔒️(frontend) fix HIGH CVE-2026-93990 in libexpat
|
||||||
|
|
||||||
|
## [1.31.0] - 2026-09-08
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
- ✨(frontend) add 1080p sending resolution option #1660
|
- ✨(frontend) add 1080p sending resolution option #1660
|
||||||
- ✨(backend) add Traefik support via configurable media-auth url header #1649
|
- ✨(backend) add Traefik support via configurable media-auth url header #1649
|
||||||
- ✨(backend) update a room's attributes from the external API
|
- ✨(backend) update a room's attributes from the external API
|
||||||
|
- 🔊(backend) log request duration in Gunicorn workers
|
||||||
|
- 📈(frontend) track missing lobby participant on accept/reject
|
||||||
|
- ✨(backend) sort waiting participants by their arrival time
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
|
|
||||||
- ⬆️(dev) pin LiveKit server to v1.13.6
|
- ⬆️(dev) pin LiveKit server to v1.13.6
|
||||||
|
- 🔒(frontend) upgrade base image to 1.30.4-alpine3.24
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|
||||||
@@ -24,6 +71,12 @@ and this project adheres to
|
|||||||
- 🐛(frontend) keep the sending resolution picked while the camera is off #1667
|
- 🐛(frontend) keep the sending resolution picked while the camera is off #1667
|
||||||
- 🐛(frontend) restore automatic lower-hand on speaking
|
- 🐛(frontend) restore automatic lower-hand on speaking
|
||||||
- 🐛(frontend) center Avatar initials with a font-aware cap-height ratio
|
- 🐛(frontend) center Avatar initials with a font-aware cap-height ratio
|
||||||
|
- 🐛(frontend) keep feedback buttons on one line for fr/es/en
|
||||||
|
- ⚡️(frontend) increase lobby polling interval on both sides
|
||||||
|
- ⚡️(frontend) add trailing slash on the /me endpoint call
|
||||||
|
- ⚡️(backend) refactor lobby storage to bound key lookups per room
|
||||||
|
- ⚡️(backend) refactor presence cache to bound key lookups per room
|
||||||
|
- 💄(frontend) position the login hint dynamically next to the button
|
||||||
|
|
||||||
## [1.30.0] - 2026-09-01
|
## [1.30.0] - 2026-09-01
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
# Django Meet
|
# Django Meet
|
||||||
|
|
||||||
# ---- base image to inherit from ----
|
# ---- base image to inherit from ----
|
||||||
FROM python:3.13.5-alpine3.21 AS base
|
FROM python:3.13.15-alpine3.24 AS base
|
||||||
|
|
||||||
# Upgrade pip to its latest release to speed up dependencies installation
|
# Upgrade pip to its latest release to speed up dependencies installation
|
||||||
RUN python -m pip install --upgrade pip
|
RUN python -m pip install --upgrade pip
|
||||||
|
|||||||
@@ -292,7 +292,7 @@ shell: ## connect to database shell
|
|||||||
# -- Database
|
# -- Database
|
||||||
|
|
||||||
dbshell: ## connect to database shell
|
dbshell: ## connect to database shell
|
||||||
docker compose exec app-dev python manage.py dbshell
|
@$(COMPOSE_EXEC_APP) python manage.py dbshell
|
||||||
.PHONY: dbshell
|
.PHONY: dbshell
|
||||||
|
|
||||||
resetdb: FLUSH_ARGS ?=
|
resetdb: FLUSH_ARGS ?=
|
||||||
|
|||||||
+1
-2
@@ -5,7 +5,7 @@ set -eo pipefail
|
|||||||
REPO_DIR="$(cd "$( dirname "${BASH_SOURCE[0]}" )/.." && pwd)"
|
REPO_DIR="$(cd "$( dirname "${BASH_SOURCE[0]}" )/.." && pwd)"
|
||||||
UNSET_USER=0
|
UNSET_USER=0
|
||||||
|
|
||||||
COMPOSE_FILE="${REPO_DIR}/compose.yml"
|
export COMPOSE_FILE="${COMPOSE_FILE:-${REPO_DIR}/compose.yml}"
|
||||||
COMPOSE_PROJECT="meet"
|
COMPOSE_PROJECT="meet"
|
||||||
|
|
||||||
|
|
||||||
@@ -42,7 +42,6 @@ function _docker_compose() {
|
|||||||
echo "🐳(compose) project: '${COMPOSE_PROJECT}' file: '${COMPOSE_FILE}'"
|
echo "🐳(compose) project: '${COMPOSE_PROJECT}' file: '${COMPOSE_FILE}'"
|
||||||
docker compose \
|
docker compose \
|
||||||
-p "${COMPOSE_PROJECT}" \
|
-p "${COMPOSE_PROJECT}" \
|
||||||
-f "${COMPOSE_FILE}" \
|
|
||||||
--project-directory "${REPO_DIR}" \
|
--project-directory "${REPO_DIR}" \
|
||||||
"$@"
|
"$@"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
|
|
||||||
# shellcheck source=bin/_config.sh
|
|
||||||
source "$(dirname "${BASH_SOURCE[0]}")/_config.sh"
|
|
||||||
|
|
||||||
_docker_compose "$@"
|
|
||||||
+2
-2
@@ -17,7 +17,7 @@ services:
|
|||||||
|
|
||||||
minio:
|
minio:
|
||||||
user: ${DOCKER_USER:-1000}
|
user: ${DOCKER_USER:-1000}
|
||||||
image: minio/minio
|
image: quay.io/minio/minio
|
||||||
environment:
|
environment:
|
||||||
- MINIO_ROOT_USER=meet
|
- MINIO_ROOT_USER=meet
|
||||||
- MINIO_ROOT_PASSWORD=password
|
- MINIO_ROOT_PASSWORD=password
|
||||||
@@ -35,7 +35,7 @@ services:
|
|||||||
- ./data/media:/data
|
- ./data/media:/data
|
||||||
|
|
||||||
createbuckets:
|
createbuckets:
|
||||||
image: minio/mc
|
image: quay.io/minio/mc
|
||||||
depends_on:
|
depends_on:
|
||||||
minio:
|
minio:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
|
|||||||
+47
@@ -0,0 +1,47 @@
|
|||||||
|
{
|
||||||
|
"nodes": {
|
||||||
|
"devenv": {
|
||||||
|
"locked": {
|
||||||
|
"dir": "src/modules",
|
||||||
|
"lastModified": 1778705847,
|
||||||
|
"narHash": "sha256-EQnZCy7r4VMO6KDoytxHBa0mFbM1D9g1kaDfs/s0YZA=",
|
||||||
|
"ref": "refs/tags/v2.1.2",
|
||||||
|
"rev": "ea3d94ac9d6bf6a1313773170122ca4e2ef5a0be",
|
||||||
|
"revCount": 6569,
|
||||||
|
"type": "git",
|
||||||
|
"url": "https://github.com/cachix/devenv"
|
||||||
|
},
|
||||||
|
"original": {
|
||||||
|
"dir": "src/modules",
|
||||||
|
"ref": "refs/tags/v2.1.2",
|
||||||
|
"type": "git",
|
||||||
|
"url": "https://github.com/cachix/devenv"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"nixpkgs": {
|
||||||
|
"locked": {
|
||||||
|
"lastModified": 1789542786,
|
||||||
|
"narHash": "sha256-ajQuqcxnj6RYnwCjA/9FTCnrdR2+BDzstE8UembXf60=",
|
||||||
|
"ref": "nixos-26.05",
|
||||||
|
"rev": "4c7870105e7f1fdf9c48688c8d7efc21abf0688a",
|
||||||
|
"shallow": true,
|
||||||
|
"type": "git",
|
||||||
|
"url": "https://github.com/NixOS/nixpkgs"
|
||||||
|
},
|
||||||
|
"original": {
|
||||||
|
"ref": "nixos-26.05",
|
||||||
|
"shallow": true,
|
||||||
|
"type": "git",
|
||||||
|
"url": "https://github.com/NixOS/nixpkgs"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"root": {
|
||||||
|
"inputs": {
|
||||||
|
"devenv": "devenv",
|
||||||
|
"nixpkgs": "nixpkgs"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"root": "root",
|
||||||
|
"version": 7
|
||||||
|
}
|
||||||
+265
@@ -0,0 +1,265 @@
|
|||||||
|
# =============================================================================
|
||||||
|
# devenv.nix — La Suite Meet ("Visio") developer environment
|
||||||
|
# =============================================================================
|
||||||
|
{
|
||||||
|
pkgs,
|
||||||
|
lib,
|
||||||
|
config,
|
||||||
|
...
|
||||||
|
}:
|
||||||
|
|
||||||
|
let
|
||||||
|
python = pkgs.python313;
|
||||||
|
nodejs = pkgs.nodejs_22;
|
||||||
|
|
||||||
|
backendDir = "src/backend";
|
||||||
|
agentsDir = "src/agents";
|
||||||
|
summaryDir = "src/summary";
|
||||||
|
frontendDir = "src/frontend";
|
||||||
|
|
||||||
|
readDotEnv =
|
||||||
|
file:
|
||||||
|
let
|
||||||
|
lines = lib.splitString "\n" (builtins.readFile file);
|
||||||
|
unquote =
|
||||||
|
v:
|
||||||
|
let
|
||||||
|
len = builtins.stringLength v;
|
||||||
|
in
|
||||||
|
if len >= 2 && lib.hasPrefix "\"" v && lib.hasSuffix "\"" v then
|
||||||
|
builtins.substring 1 (len - 2) v
|
||||||
|
else if len >= 2 && lib.hasPrefix "'" v && lib.hasSuffix "'" v then
|
||||||
|
builtins.substring 1 (len - 2) v
|
||||||
|
else
|
||||||
|
v;
|
||||||
|
parseLine =
|
||||||
|
line:
|
||||||
|
let
|
||||||
|
m = builtins.match "[ \t]*([A-Za-z_][A-Za-z0-9_]*)[ \t]*=[ \t]*(.*)" line;
|
||||||
|
in
|
||||||
|
if m == null then null else { name = builtins.elemAt m 0; value = unquote (builtins.elemAt m 1); };
|
||||||
|
in
|
||||||
|
builtins.listToAttrs (builtins.filter (x: x != null) (map parseLine lines));
|
||||||
|
|
||||||
|
# Reuse existing .env
|
||||||
|
dotEnv =
|
||||||
|
(readDotEnv ./env.d/development/common.dist)
|
||||||
|
// (readDotEnv ./env.d/development/postgresql.dist);
|
||||||
|
|
||||||
|
sharedEnv = builtins.removeAttrs dotEnv [ "PYTHONPATH" ]; # only makes sense inside the backend container.
|
||||||
|
in
|
||||||
|
{
|
||||||
|
options.meet = {
|
||||||
|
agents.enable = lib.mkEnableOption "tooling for the LiveKit agents in src/agents";
|
||||||
|
summary.enable = lib.mkEnableOption "tooling for the summary service in src/summary";
|
||||||
|
k8s.enable = lib.mkEnableOption "Kubernetes dev utilities";
|
||||||
|
};
|
||||||
|
|
||||||
|
config = {
|
||||||
|
# Profile can be activated with devenv --profile <profile> shell
|
||||||
|
profiles = {
|
||||||
|
agents.module = {
|
||||||
|
meet.agents.enable = true;
|
||||||
|
};
|
||||||
|
summary.module = {
|
||||||
|
meet.summary.enable = true;
|
||||||
|
};
|
||||||
|
k8s.module = {
|
||||||
|
meet.k8s.enable = true;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
languages.python = {
|
||||||
|
enable = true;
|
||||||
|
package = python;
|
||||||
|
directory = backendDir;
|
||||||
|
manylinux.enable = pkgs.stdenv.hostPlatform.isLinux;
|
||||||
|
|
||||||
|
libraries = [
|
||||||
|
"${config.devenv.dotfile}/profile"
|
||||||
|
pkgs.file
|
||||||
|
pkgs.zlib
|
||||||
|
pkgs.libffi
|
||||||
|
pkgs.openssl
|
||||||
|
];
|
||||||
|
|
||||||
|
uv.enable = true;
|
||||||
|
uv.sync.enable = false;
|
||||||
|
venv.enable = false;
|
||||||
|
lsp.enable = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
languages.javascript = {
|
||||||
|
enable = true;
|
||||||
|
package = nodejs;
|
||||||
|
directory = frontendDir;
|
||||||
|
|
||||||
|
npm.enable = true;
|
||||||
|
yarn.enable = true;
|
||||||
|
corepack.enable = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
languages.typescript.enable = false;
|
||||||
|
languages.nix.enable = true;
|
||||||
|
|
||||||
|
packages =
|
||||||
|
with pkgs;
|
||||||
|
[
|
||||||
|
gnumake
|
||||||
|
file
|
||||||
|
shared-mime-info
|
||||||
|
gettext
|
||||||
|
postgresql_16
|
||||||
|
git
|
||||||
|
curl
|
||||||
|
jq
|
||||||
|
podman
|
||||||
|
podman-compose
|
||||||
|
docker-client
|
||||||
|
]
|
||||||
|
|
||||||
|
# -- LiveKit agents
|
||||||
|
++ lib.optionals config.meet.agents.enable [
|
||||||
|
glib
|
||||||
|
portaudio
|
||||||
|
livekit-cli
|
||||||
|
]
|
||||||
|
|
||||||
|
# -- summary service
|
||||||
|
++ lib.optionals config.meet.summary.enable [
|
||||||
|
redis
|
||||||
|
]
|
||||||
|
|
||||||
|
# -- Kubernetes tools
|
||||||
|
++ lib.optionals config.meet.k8s.enable [
|
||||||
|
kubectl
|
||||||
|
kubernetes-helm
|
||||||
|
helmfile
|
||||||
|
tilt
|
||||||
|
kind
|
||||||
|
mkcert
|
||||||
|
];
|
||||||
|
|
||||||
|
env = sharedEnv // {
|
||||||
|
UV_LINK_MODE = "copy";
|
||||||
|
|
||||||
|
PYTHONDONTWRITEBYTECODE = "1";
|
||||||
|
PYTHONUNBUFFERED = "1";
|
||||||
|
|
||||||
|
UV_PROJECT_ENVIRONMENT = lib.mkForce ".venv";
|
||||||
|
|
||||||
|
COMPOSE_PROJECT_NAME = "meet";
|
||||||
|
|
||||||
|
DJANGO_DATA_DIR = "${config.devenv.root}/data";
|
||||||
|
|
||||||
|
# Database / Pgsql
|
||||||
|
DB_HOST = "127.0.0.1";
|
||||||
|
DB_PORT = "15432";
|
||||||
|
PGHOST = "127.0.0.1";
|
||||||
|
PGPORT = "15432";
|
||||||
|
PGDATABASE = sharedEnv.DB_NAME;
|
||||||
|
PGUSER = sharedEnv.DB_USER;
|
||||||
|
PGPASSWORD = sharedEnv.DB_PASSWORD;
|
||||||
|
|
||||||
|
REDIS_URL = "redis://127.0.0.1:6379/1";
|
||||||
|
CELERY_BROKER_URL = "redis://127.0.0.1:6379/0";
|
||||||
|
|
||||||
|
# S3 / MinIO
|
||||||
|
AWS_S3_ENDPOINT_URL = "http://127.0.0.1:9000";
|
||||||
|
|
||||||
|
# OIDC
|
||||||
|
OIDC_OP_JWKS_ENDPOINT = "http://localhost:8083/realms/meet/protocol/openid-connect/certs";
|
||||||
|
OIDC_OP_TOKEN_ENDPOINT = "http://localhost:8083/realms/meet/protocol/openid-connect/token";
|
||||||
|
OIDC_OP_USER_ENDPOINT = "http://localhost:8083/realms/meet/protocol/openid-connect/userinfo";
|
||||||
|
OIDC_OP_INTROSPECTION_ENDPOINT = "http://localhost:8083/realms/meet/protocol/openid-connect/token/introspect";
|
||||||
|
|
||||||
|
# summary service
|
||||||
|
SUMMARY_SERVICE_ENDPOINT = "http://127.0.0.1:8001/api/v2/async-jobs/transcribe/";
|
||||||
|
SUMMARY_SERVICE_VERSION = "2";
|
||||||
|
|
||||||
|
# Mail
|
||||||
|
DJANGO_EMAIL_HOST = "127.0.0.1";
|
||||||
|
};
|
||||||
|
|
||||||
|
scripts = {
|
||||||
|
|
||||||
|
meet-venv = {
|
||||||
|
description = "Create/refresh meet uv virtualenvs for backend, agents and summary";
|
||||||
|
exec = ''
|
||||||
|
set -euo pipefail
|
||||||
|
cd "$DEVENV_ROOT"
|
||||||
|
|
||||||
|
echo "==> ${backendDir} (uv sync --locked, dependency-groups)"
|
||||||
|
( cd "${backendDir}" && uv sync --locked --all-groups )
|
||||||
|
|
||||||
|
echo "==> ${agentsDir} (uv sync --locked --all-extras)"
|
||||||
|
( cd "${agentsDir}" && uv sync --locked --all-extras )
|
||||||
|
|
||||||
|
echo "==> ${summaryDir} (uv sync --locked --all-extras)"
|
||||||
|
( cd "${summaryDir}" && uv sync --locked --all-extras )
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "Synced the following virtualenvs successfully:"
|
||||||
|
echo " ${backendDir}/.venv"
|
||||||
|
echo " ${agentsDir}/.venv"
|
||||||
|
echo " ${summaryDir}/.venv"
|
||||||
|
'';
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
enterShell = ''
|
||||||
|
# Make podman socket accessible in order to launch regular docker commands.
|
||||||
|
# Set MEET_PODMAN_SOCKET=0 to keep the DOCKER_HOST of the calling environment.
|
||||||
|
case "''${MEET_PODMAN_SOCKET:-1}" in
|
||||||
|
0|false|no|off) ;;
|
||||||
|
*)
|
||||||
|
_rundir="''${XDG_RUNTIME_DIR:-/run/user/$(id -u)}"
|
||||||
|
export DOCKER_HOST="unix://$_rundir/podman/podman.sock"
|
||||||
|
unset _rundir
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
# Compose files to merge
|
||||||
|
_compose_dir="${config.devenv.root}/docker/compose.d"
|
||||||
|
_compose_files="${config.devenv.root}/compose.yml"
|
||||||
|
|
||||||
|
export DOCKER_USER="$(id -u):$(id -g)"
|
||||||
|
|
||||||
|
case "''${DOCKER_HOST:-}" in
|
||||||
|
*podman*)
|
||||||
|
_compose_files="$_compose_files:$_compose_dir/compose.podman.yml"
|
||||||
|
|
||||||
|
# Build images with Podman/Buildah rather than BuildKit. `docker
|
||||||
|
# compose build` otherwise has buildx boot a moby/buildkit container,
|
||||||
|
# and that container lands in its own network namespace with neither
|
||||||
|
# the proxy in its environment nor any route to it.
|
||||||
|
# Buildah has neither problem: base images are resolved by the Podman systemd
|
||||||
|
# service, which inherits the proxy from its systemd socket activated unit, and
|
||||||
|
# RUN steps execute in the *host* network namespace
|
||||||
|
|
||||||
|
export DOCKER_BUILDKIT=0
|
||||||
|
export COMPOSE_BAKE=false
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
|
||||||
|
# Apply Bureautix override
|
||||||
|
if [ -n "''${http_proxy:-}" ]; then
|
||||||
|
_compose_files="$_compose_files:$_compose_dir/compose.bureautix.yml"
|
||||||
|
fi
|
||||||
|
|
||||||
|
export COMPOSE_FILE="$_compose_files"
|
||||||
|
unset _compose_dir _compose_files
|
||||||
|
|
||||||
|
# Make binaries accessible
|
||||||
|
for _d in \
|
||||||
|
"$DEVENV_ROOT/${backendDir}/.venv/bin" \
|
||||||
|
"$DEVENV_ROOT/${agentsDir}/.venv/bin" \
|
||||||
|
"$DEVENV_ROOT/${summaryDir}/.venv/bin" \
|
||||||
|
"$DEVENV_ROOT/${frontendDir}/node_modules/.bin"
|
||||||
|
do
|
||||||
|
[ -d "$_d" ] && export PATH="$_d:$PATH"
|
||||||
|
done
|
||||||
|
unset _d
|
||||||
|
'';
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
inputs:
|
||||||
|
nixpkgs:
|
||||||
|
url: git+https://github.com/NixOS/nixpkgs?ref=nixos-26.05&shallow=1
|
||||||
|
devenv:
|
||||||
|
url: git+https://github.com/cachix/devenv?ref=refs/tags/v2.1.2&dir=src/modules
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
# Bureautix proxy overrides
|
||||||
|
#
|
||||||
|
# Builds submitted through the Docker API of the Podman service get none of
|
||||||
|
# the proxy variables in their RUN steps. We need to pass the http_proxy explicitely
|
||||||
|
# otherwise all connections fail during the build.
|
||||||
|
|
||||||
|
x-proxy-vars: &proxy-vars
|
||||||
|
http_proxy: ${http_proxy:-}
|
||||||
|
https_proxy: ${https_proxy:-}
|
||||||
|
no_proxy: ${no_proxy:-}
|
||||||
|
|
||||||
|
services:
|
||||||
|
app:
|
||||||
|
build:
|
||||||
|
args:
|
||||||
|
<<: *proxy-vars
|
||||||
|
app-dev:
|
||||||
|
build:
|
||||||
|
args:
|
||||||
|
<<: *proxy-vars
|
||||||
|
frontend:
|
||||||
|
build:
|
||||||
|
args:
|
||||||
|
<<: *proxy-vars
|
||||||
|
metadata-collector-dev:
|
||||||
|
build:
|
||||||
|
args:
|
||||||
|
<<: *proxy-vars
|
||||||
|
multi-user-transcriber-dev:
|
||||||
|
build:
|
||||||
|
args:
|
||||||
|
<<: *proxy-vars
|
||||||
|
app-summary-dev:
|
||||||
|
build:
|
||||||
|
args:
|
||||||
|
<<: *proxy-vars
|
||||||
|
celery-summary-transcribe:
|
||||||
|
build:
|
||||||
|
args:
|
||||||
|
<<: *proxy-vars
|
||||||
|
celery-summary-summarize:
|
||||||
|
build:
|
||||||
|
args:
|
||||||
|
<<: *proxy-vars
|
||||||
|
# The local proxy listens on 8080 and collides with Keycloak's published admin port.
|
||||||
|
keycloak:
|
||||||
|
ports: !override
|
||||||
|
- "8081:8080"
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
# Rootless Podman override for compose.yml.
|
||||||
|
#
|
||||||
|
# Rootless Podman maps container UID 0 to the host user and every other
|
||||||
|
# container UID to a subuid that owns nothing in the worktree. Meet compose.ymlruns
|
||||||
|
# its containers as DOCKER_USER=$(id -u):$(id -g), which would land on such a
|
||||||
|
# subuid and make every bind mount effectively read-only.
|
||||||
|
#
|
||||||
|
# `userns_mode: keep-id` maps the host user to the same UID and GID inside the
|
||||||
|
# container instead, so DOCKER_USER keeps its Docker value and files written
|
||||||
|
# through a bind mount are owned by the host user on both sides.
|
||||||
|
#
|
||||||
|
# Only the services that mount the worktree and run as DOCKER_USER are listed.
|
||||||
|
|
||||||
|
x-keep-id: &keep-id
|
||||||
|
userns_mode: keep-id
|
||||||
|
|
||||||
|
services:
|
||||||
|
app-dev:
|
||||||
|
<<: *keep-id
|
||||||
|
celery-dev:
|
||||||
|
<<: *keep-id
|
||||||
|
minio:
|
||||||
|
<<: *keep-id
|
||||||
|
node:
|
||||||
|
<<: *keep-id
|
||||||
|
crowdin:
|
||||||
|
<<: *keep-id
|
||||||
|
metadata-collector-dev:
|
||||||
|
<<: *keep-id
|
||||||
|
multi-user-transcriber-dev:
|
||||||
|
<<: *keep-id
|
||||||
|
app-summary-dev:
|
||||||
|
<<: *keep-id
|
||||||
@@ -54,19 +54,12 @@ RUN npx webpack --mode production
|
|||||||
|
|
||||||
|
|
||||||
# ---- Front-end image ----
|
# ---- Front-end image ----
|
||||||
FROM nginxinc/nginx-unprivileged:1.30.3-alpine3.23 AS frontend-production
|
FROM nginxinc/nginx-unprivileged:1.30.4-alpine3.24 AS frontend-production
|
||||||
|
|
||||||
USER root
|
USER root
|
||||||
|
RUN apk upgrade --no-cache libexpat && \
|
||||||
# Security patches for known CVEs
|
apk del curl
|
||||||
RUN apk update && apk upgrade \
|
USER nginx
|
||||||
libcrypto3>=3.5.7-r0 \
|
|
||||||
libssl3>=3.5.7-r0 \
|
|
||||||
musl \
|
|
||||||
musl-utils \
|
|
||||||
zlib>=1.3.2-r0 \
|
|
||||||
libexpat>=2.8.4-r0 \
|
|
||||||
&& apk del curl
|
|
||||||
|
|
||||||
USER nginx
|
USER nginx
|
||||||
|
|
||||||
|
|||||||
@@ -14,3 +14,4 @@ accesslog = "-"
|
|||||||
# Using '-' for the error log file makes gunicorn log errors to stderr
|
# Using '-' for the error log file makes gunicorn log errors to stderr
|
||||||
errorlog = "-"
|
errorlog = "-"
|
||||||
loglevel = "info"
|
loglevel = "info"
|
||||||
|
access_log_format = '%(h)s %(l)s %(u)s %(t)s "%(r)s" %(s)s %(b)s "%(f)s" "%(a)s" %(M)s'
|
||||||
|
|||||||
@@ -21,3 +21,10 @@ turn:
|
|||||||
- 192.168.0.0/16
|
- 192.168.0.0/16
|
||||||
- 172.16.0.0/12
|
- 172.16.0.0/12
|
||||||
|
|
||||||
|
rtc:
|
||||||
|
node_ip: 127.0.0.1
|
||||||
|
advertise_internal_ip: true
|
||||||
|
udp_port: 7882
|
||||||
|
tcp_port: 7881
|
||||||
|
use_external_ip: false
|
||||||
|
|
||||||
|
|||||||
Generated
+4
-4
@@ -10,7 +10,7 @@
|
|||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"core-js": "3.50.0",
|
"core-js": "3.50.0",
|
||||||
"i18next": "26.3.6",
|
"i18next": "26.4.1",
|
||||||
"i18next-browser-languagedetector": "8.2.1",
|
"i18next-browser-languagedetector": "8.2.1",
|
||||||
"regenerator-runtime": "0.14.1"
|
"regenerator-runtime": "0.14.1"
|
||||||
},
|
},
|
||||||
@@ -9367,9 +9367,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/i18next": {
|
"node_modules/i18next": {
|
||||||
"version": "26.3.6",
|
"version": "26.4.1",
|
||||||
"resolved": "https://registry.npmjs.org/i18next/-/i18next-26.3.6.tgz",
|
"resolved": "https://registry.npmjs.org/i18next/-/i18next-26.4.1.tgz",
|
||||||
"integrity": "sha512-Bu5Z2nAXgfVyM8xvW3jk9EKRIuX37PudsrBViThNFx7CR7aaYTpP01cxNB/E4c4UUzTDiAZRstEhsRfPOL/8xA==",
|
"integrity": "sha512-9YbX5E6gd1H+yaOSX3izCsPj5iWXyH7X4oC+iuHHJJw8AeHglzOK5SJf+1CHxaYKYigcea+8jvzSxqYx46YvyA==",
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
"type": "individual",
|
"type": "individual",
|
||||||
|
|||||||
@@ -27,7 +27,7 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"core-js": "3.50.0",
|
"core-js": "3.50.0",
|
||||||
"i18next": "26.3.6",
|
"i18next": "26.4.1",
|
||||||
"i18next-browser-languagedetector": "8.2.1",
|
"i18next-browser-languagedetector": "8.2.1",
|
||||||
"regenerator-runtime": "0.14.1"
|
"regenerator-runtime": "0.14.1"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
FROM python:3.14.6-slim AS base
|
FROM python:3.14.6-slim AS base
|
||||||
|
|
||||||
# Install system dependencies required by LiveKit
|
# Install system dependencies required by LiveKit, fetching packages over HTTPS only for Bureautix proxy
|
||||||
RUN apt-get update && apt-get install -y \
|
RUN sed -i "s|^URIs: http://|URIs: https://|" /etc/apt/sources.list.d/debian.sources \
|
||||||
|
&& apt-get update && apt-get install -y --no-install-recommends \
|
||||||
libglib2.0-0 \
|
libglib2.0-0 \
|
||||||
libgobject-2.0-0 \
|
libgobject-2.0-0 \
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "agents"
|
name = "agents"
|
||||||
version = "1.29.0"
|
version = "1.31.0"
|
||||||
requires-python = ">=3.12"
|
requires-python = ">=3.12"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"livekit-agents==1.6.7",
|
"livekit-agents==1.6.7",
|
||||||
|
|||||||
Generated
+1
-1
@@ -9,7 +9,7 @@ resolution-markers = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "agents"
|
name = "agents"
|
||||||
version = "1.29.0"
|
version = "1.31.0"
|
||||||
source = { virtual = "." }
|
source = { virtual = "." }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "httpx" },
|
{ name = "httpx" },
|
||||||
|
|||||||
@@ -71,10 +71,14 @@ def get_frontend_configuration(request):
|
|||||||
"force_wss_protocol": settings.LIVEKIT_FORCE_WSS_PROTOCOL,
|
"force_wss_protocol": settings.LIVEKIT_FORCE_WSS_PROTOCOL,
|
||||||
"enable_firefox_proxy_workaround": settings.LIVEKIT_ENABLE_FIREFOX_PROXY_WORKAROUND,
|
"enable_firefox_proxy_workaround": settings.LIVEKIT_ENABLE_FIREFOX_PROXY_WORKAROUND,
|
||||||
"default_sources": settings.LIVEKIT_DEFAULT_SOURCES,
|
"default_sources": settings.LIVEKIT_DEFAULT_SOURCES,
|
||||||
|
"default_video_codec": settings.LIVEKIT_DEFAULT_VIDEO_CODEC,
|
||||||
},
|
},
|
||||||
"authenticated_users_can_edit_display_name": (
|
"authenticated_users_can_edit_display_name": (
|
||||||
settings.AUTHENTICATED_PARTICIPANTS_CAN_EDIT_DISPLAY_NAME
|
settings.AUTHENTICATED_PARTICIPANTS_CAN_EDIT_DISPLAY_NAME
|
||||||
),
|
),
|
||||||
|
"encryption": {
|
||||||
|
"is_enabled": settings.ENCRYPTION_ENABLED,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
frontend_configuration.update(settings.FRONTEND_CONFIGURATION)
|
frontend_configuration.update(settings.FRONTEND_CONFIGURATION)
|
||||||
return Response(frontend_configuration)
|
return Response(frontend_configuration)
|
||||||
|
|||||||
@@ -12,10 +12,6 @@ from ..services.participants_management import (
|
|||||||
ParticipantsManagementException,
|
ParticipantsManagementException,
|
||||||
)
|
)
|
||||||
|
|
||||||
ACTION_FOR_METHOD_TO_PERMISSION = {
|
|
||||||
"versions_detail": {"DELETE": "versions_destroy", "GET": "versions_retrieve"}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
class IsAuthenticated(permissions.BasePermission):
|
class IsAuthenticated(permissions.BasePermission):
|
||||||
"""
|
"""
|
||||||
@@ -27,15 +23,6 @@ class IsAuthenticated(permissions.BasePermission):
|
|||||||
return bool(request.auth) or request.user.is_authenticated
|
return bool(request.auth) or request.user.is_authenticated
|
||||||
|
|
||||||
|
|
||||||
class IsAuthenticatedOrSafe(IsAuthenticated):
|
|
||||||
"""Allows access to authenticated users (or anonymous users but only on safe methods)."""
|
|
||||||
|
|
||||||
def has_permission(self, request, view):
|
|
||||||
if request.method in permissions.SAFE_METHODS:
|
|
||||||
return True
|
|
||||||
return super().has_permission(request, view)
|
|
||||||
|
|
||||||
|
|
||||||
class IsSelf(IsAuthenticated):
|
class IsSelf(IsAuthenticated):
|
||||||
"""
|
"""
|
||||||
Allows access only to authenticated users. Alternative method checking the presence
|
Allows access only to authenticated users. Alternative method checking the presence
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ class UserSerializer(serializers.ModelSerializer):
|
|||||||
"short_name",
|
"short_name",
|
||||||
"timezone",
|
"timezone",
|
||||||
"language",
|
"language",
|
||||||
|
"default_encryption_mode",
|
||||||
"default_room_access_level",
|
"default_room_access_level",
|
||||||
"default_room_configuration",
|
"default_room_configuration",
|
||||||
]
|
]
|
||||||
@@ -53,6 +54,14 @@ class UserSerializer(serializers.ModelSerializer):
|
|||||||
raise serializers.ValidationError(e.errors()) from e
|
raise serializers.ValidationError(e.errors()) from e
|
||||||
return value
|
return value
|
||||||
|
|
||||||
|
def validate_default_encryption_mode(self, value):
|
||||||
|
"""Reject a non-none default when the server has encryption disabled."""
|
||||||
|
if value != models.EncryptionMode.NONE and not settings.ENCRYPTION_ENABLED:
|
||||||
|
raise serializers.ValidationError(
|
||||||
|
_("End-to-end encryption is disabled on this server.")
|
||||||
|
)
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
class UserLightSerializer(serializers.ModelSerializer):
|
class UserLightSerializer(serializers.ModelSerializer):
|
||||||
"""Serialize users with limited fields."""
|
"""Serialize users with limited fields."""
|
||||||
@@ -94,6 +103,7 @@ class ResourceAccessSerializerMixin:
|
|||||||
raise PermissionDenied(
|
raise PermissionDenied(
|
||||||
"Only owners of a room can assign other users as owners."
|
"Only owners of a room can assign other users as owners."
|
||||||
)
|
)
|
||||||
|
|
||||||
return data
|
return data
|
||||||
|
|
||||||
def validate_resource(self, resource):
|
def validate_resource(self, resource):
|
||||||
@@ -148,7 +158,15 @@ class RoomSerializer(serializers.ModelSerializer):
|
|||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = models.Room
|
model = models.Room
|
||||||
fields = ["id", "name", "slug", "configuration", "access_level", "pin_code"]
|
fields = [
|
||||||
|
"id",
|
||||||
|
"name",
|
||||||
|
"slug",
|
||||||
|
"configuration",
|
||||||
|
"access_level",
|
||||||
|
"pin_code",
|
||||||
|
"encryption_mode",
|
||||||
|
]
|
||||||
read_only_fields = ["id", "slug", "pin_code"]
|
read_only_fields = ["id", "slug", "pin_code"]
|
||||||
|
|
||||||
def validate_configuration(self, value):
|
def validate_configuration(self, value):
|
||||||
@@ -161,6 +179,32 @@ class RoomSerializer(serializers.ModelSerializer):
|
|||||||
raise serializers.ValidationError(e.errors()) from e
|
raise serializers.ValidationError(e.errors()) from e
|
||||||
return value
|
return value
|
||||||
|
|
||||||
|
def validate_encryption_mode(self, value):
|
||||||
|
"""Encryption mode is part of the link's semantics (the passphrase
|
||||||
|
lives in the URL hash for `basic` rooms) so it cannot be changed once
|
||||||
|
the room exists."""
|
||||||
|
instance = self.instance
|
||||||
|
if instance and instance.encryption_mode != value:
|
||||||
|
raise serializers.ValidationError(
|
||||||
|
"Encryption mode cannot be changed after room creation."
|
||||||
|
)
|
||||||
|
return value
|
||||||
|
|
||||||
|
def validate_access_level(self, value):
|
||||||
|
"""Encrypted rooms must stay restricted — the lobby is the only way
|
||||||
|
to enforce per-participant admission, and basic encryption relies on
|
||||||
|
the host vetting each joiner before they receive the in-URL key."""
|
||||||
|
instance = self.instance
|
||||||
|
if (
|
||||||
|
instance
|
||||||
|
and instance.encryption_mode != models.EncryptionMode.NONE
|
||||||
|
and value != models.RoomAccessLevel.RESTRICTED
|
||||||
|
):
|
||||||
|
raise serializers.ValidationError(
|
||||||
|
"Encrypted rooms require restricted access level."
|
||||||
|
)
|
||||||
|
return value
|
||||||
|
|
||||||
def to_representation(self, instance):
|
def to_representation(self, instance):
|
||||||
"""
|
"""
|
||||||
Add users only for administrator users.
|
Add users only for administrator users.
|
||||||
@@ -197,12 +241,14 @@ class RoomSerializer(serializers.ModelSerializer):
|
|||||||
if should_access_room:
|
if should_access_room:
|
||||||
room_id = f"{instance.id!s}"
|
room_id = f"{instance.id!s}"
|
||||||
username = request.query_params.get("username", None)
|
username = request.query_params.get("username", None)
|
||||||
|
|
||||||
output["livekit"] = utils.generate_livekit_config(
|
output["livekit"] = utils.generate_livekit_config(
|
||||||
room_id=room_id,
|
room_id=room_id,
|
||||||
user=request.user,
|
user=request.user,
|
||||||
username=username,
|
username=username,
|
||||||
configuration=output["configuration"],
|
configuration=output["configuration"],
|
||||||
role=role,
|
role=role,
|
||||||
|
encryption_mode=instance.encryption_mode,
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
del output["pin_code"]
|
del output["pin_code"]
|
||||||
|
|||||||
@@ -95,60 +95,6 @@ from .feature_flag import FeatureFlag
|
|||||||
logger = getLogger(__name__)
|
logger = getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
class NestedGenericViewSet(viewsets.GenericViewSet):
|
|
||||||
"""
|
|
||||||
A generic Viewset aims to be used in a nested route context.
|
|
||||||
e.g: `/api/v1.0/resource_1/<resource_1_pk>/resource_2/<resource_2_pk>/`
|
|
||||||
|
|
||||||
It allows to define all url kwargs and lookup fields to perform the lookup.
|
|
||||||
"""
|
|
||||||
|
|
||||||
lookup_fields: list[str] = ["pk"]
|
|
||||||
lookup_url_kwargs: list[str] = []
|
|
||||||
|
|
||||||
def __getattribute__(self, file):
|
|
||||||
"""
|
|
||||||
This method is overridden to allow to get the last lookup field or lookup url kwarg
|
|
||||||
when accessing the `lookup_field` or `lookup_url_kwarg` attribute. This is useful
|
|
||||||
to keep compatibility with all methods used by the parent class `GenericViewSet`.
|
|
||||||
"""
|
|
||||||
if file in ["lookup_field", "lookup_url_kwarg"]:
|
|
||||||
return getattr(self, file + "s", [None])[-1]
|
|
||||||
|
|
||||||
return super().__getattribute__(file)
|
|
||||||
|
|
||||||
def get_queryset(self):
|
|
||||||
"""
|
|
||||||
Get the list of files for this view.
|
|
||||||
|
|
||||||
`lookup_fields` attribute is enumerated here to perform the nested lookup.
|
|
||||||
"""
|
|
||||||
queryset = super().get_queryset()
|
|
||||||
|
|
||||||
# The last lookup field is removed to perform the nested lookup as it corresponds
|
|
||||||
# to the object pk, it is used within get_object method.
|
|
||||||
lookup_url_kwargs = (
|
|
||||||
self.lookup_url_kwargs[:-1]
|
|
||||||
if self.lookup_url_kwargs
|
|
||||||
else self.lookup_fields[:-1]
|
|
||||||
)
|
|
||||||
|
|
||||||
filter_kwargs = {}
|
|
||||||
for index, lookup_url_kwarg in enumerate(lookup_url_kwargs):
|
|
||||||
if lookup_url_kwarg not in self.kwargs:
|
|
||||||
raise KeyError(
|
|
||||||
f"Expected view {self.__class__.__name__} to be called with a URL "
|
|
||||||
f'keyword argument named "{lookup_url_kwarg}". Fix your URL conf, or '
|
|
||||||
"set the `.lookup_fields` attribute on the view correctly."
|
|
||||||
)
|
|
||||||
|
|
||||||
filter_kwargs.update(
|
|
||||||
{self.lookup_fields[index]: self.kwargs[lookup_url_kwarg]}
|
|
||||||
)
|
|
||||||
|
|
||||||
return queryset.filter(**filter_kwargs)
|
|
||||||
|
|
||||||
|
|
||||||
class SerializerPerActionMixin:
|
class SerializerPerActionMixin:
|
||||||
"""
|
"""
|
||||||
A mixin to allow to define serializer classes for each action.
|
A mixin to allow to define serializer classes for each action.
|
||||||
@@ -303,6 +249,18 @@ class RoomViewSet(
|
|||||||
Apply the user's default room preferences (access level and configuration)
|
Apply the user's default room preferences (access level and configuration)
|
||||||
unless the request explicitly provides its own values.
|
unless the request explicitly provides its own values.
|
||||||
"""
|
"""
|
||||||
|
encryption_mode = serializer.validated_data.get(
|
||||||
|
"encryption_mode", models.EncryptionMode.NONE
|
||||||
|
)
|
||||||
|
|
||||||
|
if (
|
||||||
|
encryption_mode != models.EncryptionMode.NONE
|
||||||
|
and not settings.ENCRYPTION_ENABLED
|
||||||
|
):
|
||||||
|
raise drf_exceptions.ValidationError(
|
||||||
|
{"encryption_mode": "Encryption is not enabled on this server."}
|
||||||
|
)
|
||||||
|
|
||||||
user = self.request.user
|
user = self.request.user
|
||||||
save_kwargs = {}
|
save_kwargs = {}
|
||||||
|
|
||||||
@@ -367,16 +325,21 @@ class RoomViewSet(
|
|||||||
"""Start recording a room."""
|
"""Start recording a room."""
|
||||||
|
|
||||||
serializer = serializers.StartRecordingSerializer(data=request.data)
|
serializer = serializers.StartRecordingSerializer(data=request.data)
|
||||||
|
|
||||||
if not serializer.is_valid():
|
if not serializer.is_valid():
|
||||||
return drf_response.Response(
|
return drf_response.Response(
|
||||||
{"detail": "Invalid request."}, status=drf_status.HTTP_400_BAD_REQUEST
|
{"detail": "Invalid request."},
|
||||||
|
status=drf_status.HTTP_400_BAD_REQUEST,
|
||||||
)
|
)
|
||||||
|
|
||||||
mode = serializer.validated_data["mode"]
|
mode = serializer.validated_data["mode"]
|
||||||
options = serializer.validated_data.get("options")
|
options = serializer.validated_data.get("options")
|
||||||
room = self.get_object()
|
room = self.get_object()
|
||||||
|
|
||||||
|
if room.is_encrypted:
|
||||||
|
raise drf_exceptions.ValidationError(
|
||||||
|
{"detail": "Recording is unavailable in encrypted rooms."}
|
||||||
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
with transaction.atomic():
|
with transaction.atomic():
|
||||||
recording = models.Recording.objects.create(
|
recording = models.Recording.objects.create(
|
||||||
@@ -699,6 +662,11 @@ class RoomViewSet(
|
|||||||
|
|
||||||
room = self.get_object()
|
room = self.get_object()
|
||||||
|
|
||||||
|
if room.is_encrypted:
|
||||||
|
raise drf_exceptions.ValidationError(
|
||||||
|
{"detail": "Subtitles are unavailable in encrypted rooms."}
|
||||||
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
SubtitleService().start_subtitle(room)
|
SubtitleService().start_subtitle(room)
|
||||||
except SubtitleException:
|
except SubtitleException:
|
||||||
@@ -909,6 +877,15 @@ class RoomViewSet(
|
|||||||
"""Rename the current participant in the room."""
|
"""Rename the current participant in the room."""
|
||||||
room = self.get_object()
|
room = self.get_object()
|
||||||
|
|
||||||
|
if (
|
||||||
|
not settings.AUTHENTICATED_PARTICIPANTS_CAN_EDIT_DISPLAY_NAME
|
||||||
|
and request.user.is_authenticated
|
||||||
|
):
|
||||||
|
return drf_response.Response(
|
||||||
|
{"error": "Authenticated participants cannot edit their display name"},
|
||||||
|
status=drf_status.HTTP_403_FORBIDDEN,
|
||||||
|
)
|
||||||
|
|
||||||
serializer = serializers.RenameParticipantSerializer(data=request.data)
|
serializer = serializers.RenameParticipantSerializer(data=request.data)
|
||||||
serializer.is_valid(raise_exception=True)
|
serializer.is_valid(raise_exception=True)
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ Core application enums declaration
|
|||||||
import re
|
import re
|
||||||
|
|
||||||
from django.conf import global_settings, settings
|
from django.conf import global_settings, settings
|
||||||
|
from django.db import models
|
||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
|
|
||||||
UUID_REGEX = (
|
UUID_REGEX = (
|
||||||
@@ -32,3 +33,14 @@ ALL_LANGUAGES = getattr(
|
|||||||
"ALL_LANGUAGES",
|
"ALL_LANGUAGES",
|
||||||
[(language, _(name)) for language, name in global_settings.LANGUAGES],
|
[(language, _(name)) for language, name in global_settings.LANGUAGES],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class EncryptionMode(models.TextChoices):
|
||||||
|
"""Encryption mode for a room.
|
||||||
|
|
||||||
|
Kept as an enum (not a boolean) so future modes — e.g. a vault-managed
|
||||||
|
per-user key flow — can be added without another schema migration.
|
||||||
|
"""
|
||||||
|
|
||||||
|
NONE = "none", _("No encryption")
|
||||||
|
BASIC = "basic", _("Passphrase-in-URL encryption")
|
||||||
|
|||||||
@@ -286,6 +286,10 @@ class ResourceServerBackend(LaSuiteBackend):
|
|||||||
if user is None and settings.OIDC_CREATE_USER:
|
if user is None and settings.OIDC_CREATE_USER:
|
||||||
user = self.create_user(sub)
|
user = self.create_user(sub)
|
||||||
|
|
||||||
|
if user is not None and not user.is_active:
|
||||||
|
logger.warning("Inactive user attempted authentication: %s", user.pk)
|
||||||
|
raise SuspiciousOperation("User account is disabled.")
|
||||||
|
|
||||||
return user
|
return user
|
||||||
|
|
||||||
def create_user(self, sub):
|
def create_user(self, sub):
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
"""Logging filters for the core application."""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from django.conf import settings
|
||||||
|
|
||||||
|
|
||||||
|
class SilenceExpected401(logging.Filter):
|
||||||
|
"""Drop the expected 401 from anonymous hits on the /me endpoint.
|
||||||
|
|
||||||
|
The frontend probes `/users/me/` to check authentication; a 401 for
|
||||||
|
anonymous users is normal, not a warning worth logging.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def filter(self, record):
|
||||||
|
"""Return False for a 401 on a silenced path, True otherwise."""
|
||||||
|
if getattr(record, "status_code", None) != 401:
|
||||||
|
return True
|
||||||
|
|
||||||
|
request = getattr(record, "request", None)
|
||||||
|
path = getattr(request, "path", None)
|
||||||
|
if not path:
|
||||||
|
return True
|
||||||
|
|
||||||
|
return path not in settings.LOGGING_SILENCED_401_PATHS
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
# Generated by Django 5.2.16 on 2026-09-23 16:49
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('core', '0022_user_default_room_access_level_and_more'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='recording',
|
||||||
|
name='status',
|
||||||
|
field=models.CharField(choices=[('initiated', 'Initiated'), ('active', 'Active'), ('stopped', 'Stopped'), ('saved', 'Saved'), ('aborted', 'Aborted'), ('failed', 'Failed'), ('failed_to_start', 'Failed to Start'), ('failed_to_stop', 'Failed to Stop'), ('notification_succeeded', 'Notification succeeded'), ('external_process_successful', 'External process successful'), ('external_process_failed', 'External process failed')], default='initiated', max_length=50),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
"""Add Room.encryption_mode and User.default_encryption_mode (enum-based).
|
||||||
|
|
||||||
|
We store the mode as an enum (CharField with choices) rather than a boolean
|
||||||
|
so a future "advanced" mode (per-user vault keys, etc.) can be added without
|
||||||
|
a schema migration.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("core", "0023_alter_recording_status"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="room",
|
||||||
|
name="encryption_mode",
|
||||||
|
field=models.CharField(
|
||||||
|
choices=[
|
||||||
|
("none", "No encryption"),
|
||||||
|
("basic", "Passphrase-in-URL encryption"),
|
||||||
|
],
|
||||||
|
default="none",
|
||||||
|
help_text="End-to-end encryption mode for this room.",
|
||||||
|
max_length=20,
|
||||||
|
verbose_name="Encryption mode",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="user",
|
||||||
|
name="default_encryption_mode",
|
||||||
|
field=models.CharField(
|
||||||
|
choices=[
|
||||||
|
("none", "No encryption"),
|
||||||
|
("basic", "Passphrase-in-URL encryption"),
|
||||||
|
],
|
||||||
|
default="none",
|
||||||
|
help_text="Encryption mode pre-selected when this user creates a new meeting.",
|
||||||
|
max_length=20,
|
||||||
|
verbose_name="Default encryption mode",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -26,6 +26,7 @@ from lasuite.tools.email import get_domain_from_email
|
|||||||
from timezone_field import TimeZoneField
|
from timezone_field import TimeZoneField
|
||||||
|
|
||||||
from . import fields, utils
|
from . import fields, utils
|
||||||
|
from .enums import EncryptionMode
|
||||||
from .recording.enums import FileExtension
|
from .recording.enums import FileExtension
|
||||||
from .validators import sub_validator
|
from .validators import sub_validator
|
||||||
|
|
||||||
@@ -58,6 +59,7 @@ class RecordingStatusChoices(models.TextChoices):
|
|||||||
STOPPED = "stopped", _("Stopped")
|
STOPPED = "stopped", _("Stopped")
|
||||||
SAVED = "saved", _("Saved")
|
SAVED = "saved", _("Saved")
|
||||||
ABORTED = "aborted", _("Aborted")
|
ABORTED = "aborted", _("Aborted")
|
||||||
|
FAILED = "failed", _("Failed")
|
||||||
FAILED_TO_START = "failed_to_start", _("Failed to Start")
|
FAILED_TO_START = "failed_to_start", _("Failed to Start")
|
||||||
FAILED_TO_STOP = "failed_to_stop", _("Failed to Stop")
|
FAILED_TO_STOP = "failed_to_stop", _("Failed to Stop")
|
||||||
NOTIFICATION_SUCCEEDED = "notification_succeeded", _("Notification succeeded")
|
NOTIFICATION_SUCCEEDED = "notification_succeeded", _("Notification succeeded")
|
||||||
@@ -79,17 +81,13 @@ class RecordingStatusChoices(models.TextChoices):
|
|||||||
cls.STOPPED,
|
cls.STOPPED,
|
||||||
cls.SAVED,
|
cls.SAVED,
|
||||||
cls.ABORTED,
|
cls.ABORTED,
|
||||||
|
cls.FAILED,
|
||||||
cls.EXTERNAL_PROCESS_SUCCESSFUL,
|
cls.EXTERNAL_PROCESS_SUCCESSFUL,
|
||||||
cls.EXTERNAL_PROCESS_FAILED,
|
cls.EXTERNAL_PROCESS_FAILED,
|
||||||
cls.FAILED_TO_START,
|
cls.FAILED_TO_START,
|
||||||
cls.FAILED_TO_STOP,
|
cls.FAILED_TO_STOP,
|
||||||
}
|
}
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def is_unsuccessful(cls, status):
|
|
||||||
"""Determine if the recording status represents an unsuccessful state."""
|
|
||||||
return status in {cls.ABORTED, cls.FAILED_TO_START, cls.FAILED_TO_STOP}
|
|
||||||
|
|
||||||
|
|
||||||
class RecordingModeChoices(models.TextChoices):
|
class RecordingModeChoices(models.TextChoices):
|
||||||
"""Recording mode choices."""
|
"""Recording mode choices."""
|
||||||
@@ -219,6 +217,15 @@ class User(AbstractBaseUser, BaseModel, auth_models.PermissionsMixin):
|
|||||||
"Unselect this instead of deleting accounts."
|
"Unselect this instead of deleting accounts."
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
default_encryption_mode = models.CharField(
|
||||||
|
_("Default encryption mode"),
|
||||||
|
max_length=20,
|
||||||
|
choices=EncryptionMode.choices,
|
||||||
|
default=EncryptionMode.NONE,
|
||||||
|
help_text=_(
|
||||||
|
"Encryption mode pre-selected when this user creates a new meeting."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
objects = auth_models.UserManager()
|
objects = auth_models.UserManager()
|
||||||
|
|
||||||
@@ -414,6 +421,13 @@ class Room(Resource):
|
|||||||
choices=RoomAccessLevel.choices,
|
choices=RoomAccessLevel.choices,
|
||||||
default=settings.RESOURCE_DEFAULT_ACCESS_LEVEL,
|
default=settings.RESOURCE_DEFAULT_ACCESS_LEVEL,
|
||||||
)
|
)
|
||||||
|
encryption_mode = models.CharField(
|
||||||
|
max_length=20,
|
||||||
|
choices=EncryptionMode.choices,
|
||||||
|
default=EncryptionMode.NONE,
|
||||||
|
verbose_name=_("Encryption mode"),
|
||||||
|
help_text=_("End-to-end encryption mode for this room."),
|
||||||
|
)
|
||||||
# Public configuration exposed to any room participant via the API
|
# Public configuration exposed to any room participant via the API
|
||||||
configuration = models.JSONField(
|
configuration = models.JSONField(
|
||||||
blank=True,
|
blank=True,
|
||||||
@@ -440,20 +454,68 @@ class Room(Resource):
|
|||||||
return capfirst(self.name)
|
return capfirst(self.name)
|
||||||
|
|
||||||
def save(self, *args, **kwargs):
|
def save(self, *args, **kwargs):
|
||||||
"""Generate a unique n-digit pin code for new rooms."""
|
"""Restrict new encrypted rooms and allocate PINs for unencrypted rooms.
|
||||||
|
|
||||||
# Roomkit devices also join by PIN, so a PIN is needed as soon as
|
Skip PIN allocation for encrypted rooms — the SIP gateway will
|
||||||
# either integration is enabled.
|
always reject calls to them (no way to derive the key), and the
|
||||||
|
PIN namespace is finite (10**length): no point burning slots that
|
||||||
|
can never be dialed.
|
||||||
|
|
||||||
|
Also run `clean()` so the encryption invariants are enforced on
|
||||||
|
every save path (ORM, admin, shell), not only via the DRF
|
||||||
|
serializer.
|
||||||
|
"""
|
||||||
|
# Override both explicit access levels and user defaults on creation.
|
||||||
|
# Updates remain subject to clean() instead of being silently normalized.
|
||||||
|
if self._state.adding and self.is_encrypted:
|
||||||
|
self.access_level = RoomAccessLevel.RESTRICTED
|
||||||
|
self.clean()
|
||||||
if (
|
if (
|
||||||
(settings.ROOM_TELEPHONY_ENABLED or settings.ROOMKIT_ENABLED)
|
(settings.ROOM_TELEPHONY_ENABLED or settings.ROOMKIT_ENABLED)
|
||||||
and not self.pk
|
and not self.pk
|
||||||
and not self.pin_code
|
and not self.pin_code
|
||||||
|
and self.encryption_mode == EncryptionMode.NONE
|
||||||
):
|
):
|
||||||
self.pin_code = self.generate_unique_pin_code(
|
self.pin_code = self.generate_unique_pin_code(
|
||||||
length=settings.ROOM_TELEPHONY_PIN_LENGTH
|
length=settings.ROOM_TELEPHONY_PIN_LENGTH
|
||||||
)
|
)
|
||||||
super().save(*args, **kwargs)
|
super().save(*args, **kwargs)
|
||||||
|
|
||||||
|
def clean(self):
|
||||||
|
"""Enforce encryption-mode invariants outside DRF.
|
||||||
|
|
||||||
|
Two rules:
|
||||||
|
- `encryption_mode` is set at creation and never mutated afterwards
|
||||||
|
(the URL-hash passphrase encodes assumptions about it).
|
||||||
|
- An encrypted room must be at the RESTRICTED access level so the
|
||||||
|
host vets joiners before they ever see the in-URL key.
|
||||||
|
"""
|
||||||
|
super().clean()
|
||||||
|
if self.pk is not None:
|
||||||
|
previous = Room.objects.filter(pk=self.pk).only("encryption_mode").first()
|
||||||
|
if (
|
||||||
|
previous is not None
|
||||||
|
and previous.encryption_mode != self.encryption_mode
|
||||||
|
):
|
||||||
|
raise ValidationError(
|
||||||
|
{
|
||||||
|
"encryption_mode": _(
|
||||||
|
"Encryption mode cannot be changed after room creation."
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
if (
|
||||||
|
self.encryption_mode != EncryptionMode.NONE
|
||||||
|
and self.access_level != RoomAccessLevel.RESTRICTED
|
||||||
|
):
|
||||||
|
raise ValidationError(
|
||||||
|
{
|
||||||
|
"access_level": _(
|
||||||
|
"Encrypted rooms must use the 'restricted' access level."
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
def clean_fields(self, exclude=None):
|
def clean_fields(self, exclude=None):
|
||||||
"""
|
"""
|
||||||
Automatically generate the slug from the name and make sure it does not look like a UUID.
|
Automatically generate the slug from the name and make sure it does not look like a UUID.
|
||||||
@@ -476,6 +538,11 @@ class Room(Resource):
|
|||||||
"""Check if a room is public"""
|
"""Check if a room is public"""
|
||||||
return self.access_level == RoomAccessLevel.PUBLIC
|
return self.access_level == RoomAccessLevel.PUBLIC
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_encrypted(self):
|
||||||
|
"""Convenience: any non-none encryption mode counts as encrypted."""
|
||||||
|
return self.encryption_mode != EncryptionMode.NONE
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def generate_unique_pin_code(length):
|
def generate_unique_pin_code(length):
|
||||||
"""Generate a unique n-digit PIN code"""
|
"""Generate a unique n-digit PIN code"""
|
||||||
@@ -582,6 +649,7 @@ class Recording(BaseModel):
|
|||||||
4. NOTIFICATION_SUCCEEDED: External service has been notified of this recording
|
4. NOTIFICATION_SUCCEEDED: External service has been notified of this recording
|
||||||
|
|
||||||
Error States:
|
Error States:
|
||||||
|
- FAILED: Egress failed mid-recording
|
||||||
- FAILED_TO_START: Worker failed to initialize recording
|
- FAILED_TO_START: Worker failed to initialize recording
|
||||||
- FAILED_TO_STOP: Worker failed during stop operation
|
- FAILED_TO_STOP: Worker failed during stop operation
|
||||||
- ABORTED: Recording was terminated before completion
|
- ABORTED: Recording was terminated before completion
|
||||||
|
|||||||
@@ -8,3 +8,50 @@ class FileExtension(Enum):
|
|||||||
|
|
||||||
OGG = "ogg"
|
OGG = "ogg"
|
||||||
MP4 = "mp4"
|
MP4 = "mp4"
|
||||||
|
|
||||||
|
|
||||||
|
class RecordingWorkerEvent(Enum):
|
||||||
|
"""Lifecycle events a recording worker reports about a recording.
|
||||||
|
|
||||||
|
It is intended to be free of SFU-specific vocabulary.
|
||||||
|
"""
|
||||||
|
|
||||||
|
# The worker accepted the request but is not recording yet.
|
||||||
|
STARTING = "starting"
|
||||||
|
# The worker is recording.
|
||||||
|
STARTED = "started"
|
||||||
|
# The worker stopped recording and is flushing the media file.
|
||||||
|
SAVING = "saving"
|
||||||
|
|
||||||
|
# The recording ended, its media file is available.
|
||||||
|
COMPLETED = "completed"
|
||||||
|
# The recording ended on its configured limit, its media file is available.
|
||||||
|
LIMIT_REACHED = "limit reached"
|
||||||
|
# The worker stopped before it ever started recording, there is no media file.
|
||||||
|
ABORTED = "aborted"
|
||||||
|
# The worker hit a runtime error once recording had started; its media file
|
||||||
|
# may be available.
|
||||||
|
FAILED = "failed"
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def is_terminal(cls, event):
|
||||||
|
"""Determine if the event ends the recording's lifecycle (successful or not)."""
|
||||||
|
|
||||||
|
return event in TERMINAL_EVENTS
|
||||||
|
|
||||||
|
|
||||||
|
SUCCESSFUL_EVENTS = frozenset(
|
||||||
|
{
|
||||||
|
RecordingWorkerEvent.COMPLETED,
|
||||||
|
RecordingWorkerEvent.LIMIT_REACHED,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
UNSUCCESSFUL_EVENTS = frozenset(
|
||||||
|
{
|
||||||
|
RecordingWorkerEvent.ABORTED,
|
||||||
|
RecordingWorkerEvent.FAILED,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
TERMINAL_EVENTS = SUCCESSFUL_EVENTS | UNSUCCESSFUL_EVENTS
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
"""Recording-related LiveKit Events Service"""
|
"""Recording-related Events Service"""
|
||||||
|
|
||||||
# pylint: disable=no-member
|
|
||||||
|
|
||||||
from logging import getLogger
|
from logging import getLogger
|
||||||
|
|
||||||
from livekit import api
|
|
||||||
|
|
||||||
from core import models, utils
|
from core import models, utils
|
||||||
from core.models import Recording
|
from core.models import Recording
|
||||||
|
from core.recording.enums import (
|
||||||
|
UNSUCCESSFUL_EVENTS,
|
||||||
|
RecordingWorkerEvent,
|
||||||
|
)
|
||||||
from core.recording.event.notification import notification_service
|
from core.recording.event.notification import notification_service
|
||||||
from core.services.room_management import (
|
from core.services.room_management import (
|
||||||
RoomManagement,
|
RoomManagement,
|
||||||
@@ -26,22 +26,110 @@ class RecordingNotSavableError(Exception):
|
|||||||
"""Recording cannot be saved because it is either in an error state or has already been saved"""
|
"""Recording cannot be saved because it is either in an error state or has already been saved"""
|
||||||
|
|
||||||
|
|
||||||
|
# Notification sent to the room's participants, per event and recording mode.
|
||||||
|
NOTIFICATION_PREFIXES = {
|
||||||
|
models.RecordingModeChoices.SCREEN_RECORDING: "screenRecording",
|
||||||
|
models.RecordingModeChoices.TRANSCRIPT: "transcription",
|
||||||
|
}
|
||||||
|
NOTIFICATION_SUFFIXES = {
|
||||||
|
RecordingWorkerEvent.LIMIT_REACHED: "LimitReached",
|
||||||
|
RecordingWorkerEvent.FAILED: "Failed",
|
||||||
|
RecordingWorkerEvent.ABORTED: "Aborted",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def get_notification_type(recording_mode, event):
|
||||||
|
"""Generate corresponding notification type string."""
|
||||||
|
try:
|
||||||
|
return f"{NOTIFICATION_PREFIXES[recording_mode]}{NOTIFICATION_SUFFIXES[event]}"
|
||||||
|
except KeyError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
# Recording status in the room's metadata, per event.
|
||||||
|
ROOM_METADATA_RECORDING_STATUSES = {
|
||||||
|
RecordingWorkerEvent.STARTED: "started",
|
||||||
|
RecordingWorkerEvent.SAVING: "saving",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
class RecordingEventsService:
|
class RecordingEventsService:
|
||||||
"""Handles recording-related LiveKit webhook events."""
|
"""Handles recording-related worker events.
|
||||||
|
|
||||||
|
Two entry points: `handle_update` for the events a running recording
|
||||||
|
reports, and `handle_terminal_event` for the one ending it.
|
||||||
|
"""
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def handle_update(recording: Recording, egress_status):
|
def log_worker_error(recording, event, error=None, error_code=None):
|
||||||
"""Handle egress status updates and sync recording state to room metadata."""
|
"""Log FAILED at error level and expected ABORTED outcomes at info level."""
|
||||||
|
|
||||||
|
if event == RecordingWorkerEvent.FAILED:
|
||||||
|
log = logger.error
|
||||||
|
elif event == RecordingWorkerEvent.ABORTED:
|
||||||
|
log = logger.info
|
||||||
|
else:
|
||||||
|
return
|
||||||
|
|
||||||
|
log(
|
||||||
|
"Recording worker reported %s for recording %s (room=%s, mode=%s): %s (error_code=%s)",
|
||||||
|
event.value,
|
||||||
|
recording.id,
|
||||||
|
recording.room.id,
|
||||||
|
recording.mode,
|
||||||
|
error or "no error reported",
|
||||||
|
error_code or "no error_code reported",
|
||||||
|
)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _notify_participants(recording: Recording, event: RecordingWorkerEvent):
|
||||||
|
"""Notify the room's participants that a recording ended on the given event."""
|
||||||
|
recording_mode = recording.options.get("original_mode", None) or recording.mode
|
||||||
|
|
||||||
|
notification_type = get_notification_type(recording_mode, event)
|
||||||
|
if not notification_type:
|
||||||
|
logger.warning(
|
||||||
|
"Could not find notification type for: "
|
||||||
|
"room=%s, recording_id=%s, mode=%s, event=%s",
|
||||||
|
recording.room.id,
|
||||||
|
recording.id,
|
||||||
|
recording_mode,
|
||||||
|
event.value,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
utils.notify_participants(
|
||||||
|
room_name=str(recording.room.id),
|
||||||
|
notification_data={"type": notification_type},
|
||||||
|
)
|
||||||
|
except utils.NotificationError as e:
|
||||||
|
raise RecordingEventsError(
|
||||||
|
f"Failed to notify participants in room '{recording.room.id}' about "
|
||||||
|
f"recording {event.value} (recording_id={recording.id})"
|
||||||
|
) from e
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _log_notification_failure(recording, event: RecordingWorkerEvent):
|
||||||
|
"""Log a participant notification error on an unsuccessful recording."""
|
||||||
|
|
||||||
|
logger.exception(
|
||||||
|
"Failed to notify participants that recording %s %s (room=%s)",
|
||||||
|
recording.id,
|
||||||
|
event.value,
|
||||||
|
recording.room.id,
|
||||||
|
)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def handle_update(recording: Recording, event: RecordingWorkerEvent):
|
||||||
|
"""Handle non-terminal worker events and sync recording state to room metadata.
|
||||||
|
|
||||||
|
Terminal events are dispatched through `handle_terminal_event` instead.
|
||||||
|
"""
|
||||||
|
|
||||||
room_name = str(recording.room.id)
|
room_name = str(recording.room.id)
|
||||||
|
|
||||||
status_mapping = {
|
recording_status = ROOM_METADATA_RECORDING_STATUSES.get(event)
|
||||||
api.EgressStatus.EGRESS_ACTIVE: "started",
|
|
||||||
api.EgressStatus.EGRESS_ENDING: "saving",
|
|
||||||
api.EgressStatus.EGRESS_ABORTED: "aborted",
|
|
||||||
}
|
|
||||||
|
|
||||||
recording_status = status_mapping.get(egress_status)
|
|
||||||
if recording_status:
|
if recording_status:
|
||||||
try:
|
try:
|
||||||
RoomManagement.update_metadata(
|
RoomManagement.update_metadata(
|
||||||
@@ -55,42 +143,113 @@ class RecordingEventsService:
|
|||||||
except RoomManagementException as e:
|
except RoomManagementException as e:
|
||||||
logger.exception("Failed to update room's metadata: %s", e)
|
logger.exception("Failed to update room's metadata: %s", e)
|
||||||
|
|
||||||
@staticmethod
|
def handle_terminal_event(self, recording: Recording, event: RecordingWorkerEvent):
|
||||||
def handle_limit_reached(recording: Recording):
|
"""Run the appropriate handlers for a terminal event, given the recording's state."""
|
||||||
|
|
||||||
|
if not RecordingWorkerEvent.is_terminal(event):
|
||||||
|
logger.warning(
|
||||||
|
"Ignoring non-terminal event %s dispatched as a terminal event "
|
||||||
|
"for recording %s.",
|
||||||
|
event.value,
|
||||||
|
recording.id,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
if event in UNSUCCESSFUL_EVENTS:
|
||||||
|
self._flag_unsuccessful_recording(recording, event)
|
||||||
|
else:
|
||||||
|
self._save_successful_recording(recording, event)
|
||||||
|
|
||||||
|
def _flag_unsuccessful_recording(
|
||||||
|
self, recording: Recording, event: RecordingWorkerEvent
|
||||||
|
):
|
||||||
|
"""Persist the outcome of a recording the worker announced as unsuccessful."""
|
||||||
|
|
||||||
|
# Aborted
|
||||||
|
if event == RecordingWorkerEvent.ABORTED:
|
||||||
|
if recording.status == models.RecordingStatusChoices.ACTIVE:
|
||||||
|
self._apply_outcome(recording, event, self._handle_aborted)
|
||||||
|
return
|
||||||
|
|
||||||
|
# Failed
|
||||||
|
if event == RecordingWorkerEvent.FAILED:
|
||||||
|
if recording.is_savable():
|
||||||
|
self._apply_outcome(recording, event, self._handle_failed)
|
||||||
|
return
|
||||||
|
|
||||||
|
logger.error(
|
||||||
|
"Unsuccessful event %s has no handler; recording %s keeps status '%s'.",
|
||||||
|
event.value,
|
||||||
|
recording.id,
|
||||||
|
recording.status,
|
||||||
|
)
|
||||||
|
|
||||||
|
def _save_successful_recording(
|
||||||
|
self, recording: Recording, event: RecordingWorkerEvent
|
||||||
|
):
|
||||||
|
"""Save a recording whose media file the worker made available."""
|
||||||
|
|
||||||
|
# Limit reached
|
||||||
|
if (
|
||||||
|
event == RecordingWorkerEvent.LIMIT_REACHED
|
||||||
|
and recording.status == models.RecordingStatusChoices.ACTIVE
|
||||||
|
):
|
||||||
|
self._apply_outcome(recording, event, self._handle_limit_reached)
|
||||||
|
|
||||||
|
try:
|
||||||
|
self._handle_successful(recording)
|
||||||
|
except RecordingNotSavableError:
|
||||||
|
logger.warning(
|
||||||
|
"Recording %s is not savable on a completed recording "
|
||||||
|
"(already saved or in an error state); ignoring.",
|
||||||
|
recording.id,
|
||||||
|
)
|
||||||
|
|
||||||
|
def _apply_outcome(
|
||||||
|
self, recording: Recording, event: RecordingWorkerEvent, handler
|
||||||
|
):
|
||||||
|
"""Keep notification failure non-fatal."""
|
||||||
|
|
||||||
|
try:
|
||||||
|
handler(recording)
|
||||||
|
except RecordingEventsError:
|
||||||
|
self._log_notification_failure(recording, event)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _handle_limit_reached(cls, recording: Recording):
|
||||||
"""Stop recording and notify participants when limit is reached."""
|
"""Stop recording and notify participants when limit is reached."""
|
||||||
|
|
||||||
recording.status = models.RecordingStatusChoices.STOPPED
|
recording.status = models.RecordingStatusChoices.STOPPED
|
||||||
recording.save()
|
recording.save()
|
||||||
|
|
||||||
notification_mapping = {
|
cls._notify_participants(recording, RecordingWorkerEvent.LIMIT_REACHED)
|
||||||
models.RecordingModeChoices.SCREEN_RECORDING: "screenRecordingLimitReached",
|
|
||||||
models.RecordingModeChoices.TRANSCRIPT: "transcriptionLimitReached",
|
|
||||||
}
|
|
||||||
|
|
||||||
notification_type = notification_mapping.get(recording.mode)
|
@classmethod
|
||||||
if not notification_type:
|
def _handle_failed(cls, recording: Recording):
|
||||||
return
|
"""Set recording status to failed, matching the worker event, and notify participants.
|
||||||
|
|
||||||
try:
|
FAILED: used when an actual runtime/pipeline error occurs after the
|
||||||
utils.notify_participants(
|
recording has started
|
||||||
room_name=str(recording.room.id),
|
"""
|
||||||
notification_data={"type": notification_type},
|
recording.status = models.RecordingStatusChoices.FAILED
|
||||||
)
|
recording.save()
|
||||||
except utils.NotificationError as e:
|
|
||||||
logger.exception(
|
cls._notify_participants(recording, RecordingWorkerEvent.FAILED)
|
||||||
"Failed to notify participants about recording limit reached: "
|
|
||||||
"room=%s, recording_id=%s, mode=%s",
|
@classmethod
|
||||||
recording.room.id,
|
def _handle_aborted(cls, recording: Recording):
|
||||||
recording.id,
|
"""Set recording status to aborted, matching the worker event, and notify participants.
|
||||||
recording.mode,
|
|
||||||
)
|
ABORTED: used when the worker stops before it ever became
|
||||||
raise RecordingEventsError(
|
active/recording
|
||||||
f"Failed to notify participants in room '{recording.room.id}' about "
|
"""
|
||||||
f"recording limit reached (recording_id={recording.id})"
|
recording.status = models.RecordingStatusChoices.ABORTED
|
||||||
) from e
|
recording.save()
|
||||||
|
|
||||||
|
cls._notify_participants(recording, RecordingWorkerEvent.ABORTED)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def handle_complete(recording: Recording):
|
def _handle_successful(recording: Recording):
|
||||||
"""Notify external services and save recording."""
|
"""Notify external services and save recording."""
|
||||||
|
|
||||||
if not recording.is_savable():
|
if not recording.is_savable():
|
||||||
|
|||||||
@@ -2,6 +2,8 @@
|
|||||||
|
|
||||||
# pylint: disable=no-member
|
# pylint: disable=no-member
|
||||||
|
|
||||||
|
import logging
|
||||||
|
|
||||||
from asgiref.sync import async_to_sync
|
from asgiref.sync import async_to_sync
|
||||||
from livekit import api as livekit_api
|
from livekit import api as livekit_api
|
||||||
|
|
||||||
@@ -10,6 +12,8 @@ from ..enums import FileExtension
|
|||||||
from .exceptions import WorkerConnectionError, WorkerResponseError
|
from .exceptions import WorkerConnectionError, WorkerResponseError
|
||||||
from .factories import WorkerServiceConfig
|
from .factories import WorkerServiceConfig
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
class BaseEgressService:
|
class BaseEgressService:
|
||||||
"""Base egress defining common methods to manage and interact with LiveKit egress processes."""
|
"""Base egress defining common methods to manage and interact with LiveKit egress processes."""
|
||||||
@@ -49,6 +53,22 @@ class BaseEgressService:
|
|||||||
finally:
|
finally:
|
||||||
await lkapi.aclose()
|
await lkapi.aclose()
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _log_egress_error(response, event: str):
|
||||||
|
"""Log the reason LiveKit reported an unsuccessful egress on stop.
|
||||||
|
|
||||||
|
Mirrors the logging done in the 'egress_ended' webhook. The
|
||||||
|
StopEgress response carries the same error fields.
|
||||||
|
"""
|
||||||
|
logger.error(
|
||||||
|
"Egress %s on stop (egress_id=%s, status=%s): %s (error_code=%s)",
|
||||||
|
event,
|
||||||
|
response.egress_id,
|
||||||
|
livekit_api.EgressStatus.Name(response.status),
|
||||||
|
response.error or "no error reported",
|
||||||
|
response.error_code or "no error_code reported",
|
||||||
|
)
|
||||||
|
|
||||||
def stop(self, worker_id: str) -> str:
|
def stop(self, worker_id: str) -> str:
|
||||||
"""Stop an ongoing egress worker.
|
"""Stop an ongoing egress worker.
|
||||||
The StopEgressRequest is shared among all types of egress,
|
The StopEgressRequest is shared among all types of egress,
|
||||||
@@ -66,14 +86,26 @@ class BaseEgressService:
|
|||||||
"LiveKit response is missing the recording status."
|
"LiveKit response is missing the recording status."
|
||||||
)
|
)
|
||||||
|
|
||||||
# To avoid exposing EgressStatus values and coupling with LiveKit outside of this class,
|
|
||||||
# the response status is mapped to simpler "ABORTED", "STOPPED" or "FAILED_TO_STOP" strings.
|
|
||||||
if response.status == livekit_api.EgressStatus.EGRESS_ABORTED:
|
|
||||||
return "ABORTED"
|
|
||||||
|
|
||||||
if response.status == livekit_api.EgressStatus.EGRESS_ENDING:
|
if response.status == livekit_api.EgressStatus.EGRESS_ENDING:
|
||||||
return "STOPPED"
|
return "STOPPED"
|
||||||
|
|
||||||
|
if response.status == livekit_api.EgressStatus.EGRESS_LIMIT_REACHED:
|
||||||
|
return "STOPPED"
|
||||||
|
|
||||||
|
# Cases below should be very infrequent as status changes should be
|
||||||
|
# received and processed by `handle_ended`, thus `stop` would not
|
||||||
|
# be called (unless failure and stop are very close in time).
|
||||||
|
# We therefore accept not to notify the user in this code branch.
|
||||||
|
# This could be fixed in a future refactoring.
|
||||||
|
if response.status == livekit_api.EgressStatus.EGRESS_ABORTED:
|
||||||
|
self._log_egress_error(response, "aborted")
|
||||||
|
return "ABORTED"
|
||||||
|
|
||||||
|
if response.status == livekit_api.EgressStatus.EGRESS_FAILED:
|
||||||
|
self._log_egress_error(response, "failed")
|
||||||
|
return "FAILED"
|
||||||
|
|
||||||
|
self._log_egress_error(response, "failed to stop")
|
||||||
return "FAILED_TO_STOP"
|
return "FAILED_TO_STOP"
|
||||||
|
|
||||||
def start(self, room_name, recording_id):
|
def start(self, room_name, recording_id):
|
||||||
|
|||||||
@@ -12,15 +12,12 @@ from django.conf import settings
|
|||||||
from livekit import api
|
from livekit import api
|
||||||
|
|
||||||
from core import models
|
from core import models
|
||||||
|
from core.recording.enums import RecordingWorkerEvent
|
||||||
from core.recording.services.metadata_collector import (
|
from core.recording.services.metadata_collector import (
|
||||||
MetadataCollectorException,
|
MetadataCollectorException,
|
||||||
MetadataCollectorService,
|
MetadataCollectorService,
|
||||||
)
|
)
|
||||||
from core.recording.services.recording_events import (
|
from core.recording.services.recording_events import RecordingEventsService
|
||||||
RecordingEventsError,
|
|
||||||
RecordingEventsService,
|
|
||||||
RecordingNotSavableError,
|
|
||||||
)
|
|
||||||
|
|
||||||
from .lobby import LobbyService
|
from .lobby import LobbyService
|
||||||
from .presence import PresenceCache
|
from .presence import PresenceCache
|
||||||
@@ -52,12 +49,6 @@ class InvalidPayloadError(LiveKitWebhookError):
|
|||||||
status_code = 400
|
status_code = 400
|
||||||
|
|
||||||
|
|
||||||
class UnsupportedEventTypeError(LiveKitWebhookError):
|
|
||||||
"""Unsupported event type."""
|
|
||||||
|
|
||||||
status_code = 422
|
|
||||||
|
|
||||||
|
|
||||||
class ActionFailedError(LiveKitWebhookError):
|
class ActionFailedError(LiveKitWebhookError):
|
||||||
"""Webhook action fails to process or complete."""
|
"""Webhook action fails to process or complete."""
|
||||||
|
|
||||||
@@ -74,6 +65,7 @@ class LiveKitWebhookEventType(Enum):
|
|||||||
# Participant events
|
# Participant events
|
||||||
PARTICIPANT_JOINED = "participant_joined"
|
PARTICIPANT_JOINED = "participant_joined"
|
||||||
PARTICIPANT_LEFT = "participant_left"
|
PARTICIPANT_LEFT = "participant_left"
|
||||||
|
PARTICIPANT_CONNECTION_ABORTED = "participant_connection_aborted"
|
||||||
|
|
||||||
# Track events
|
# Track events
|
||||||
TRACK_PUBLISHED = "track_published"
|
TRACK_PUBLISHED = "track_published"
|
||||||
@@ -89,6 +81,30 @@ class LiveKitWebhookEventType(Enum):
|
|||||||
INGRESS_ENDED = "ingress_ended"
|
INGRESS_ENDED = "ingress_ended"
|
||||||
|
|
||||||
|
|
||||||
|
# LiveKit egress statuses mapped to recording worker event statuses
|
||||||
|
EGRESS_STATUS_TO_RECORDING_EVENT = {
|
||||||
|
api.EgressStatus.EGRESS_STARTING: RecordingWorkerEvent.STARTING,
|
||||||
|
api.EgressStatus.EGRESS_ACTIVE: RecordingWorkerEvent.STARTED,
|
||||||
|
api.EgressStatus.EGRESS_ENDING: RecordingWorkerEvent.SAVING,
|
||||||
|
api.EgressStatus.EGRESS_COMPLETE: RecordingWorkerEvent.COMPLETED,
|
||||||
|
api.EgressStatus.EGRESS_LIMIT_REACHED: RecordingWorkerEvent.LIMIT_REACHED,
|
||||||
|
api.EgressStatus.EGRESS_ABORTED: RecordingWorkerEvent.ABORTED,
|
||||||
|
api.EgressStatus.EGRESS_FAILED: RecordingWorkerEvent.FAILED,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def to_recording_event(egress_status):
|
||||||
|
"""Translate a LiveKit egress status into a recording worker event."""
|
||||||
|
|
||||||
|
event = EGRESS_STATUS_TO_RECORDING_EVENT.get(egress_status)
|
||||||
|
if event is None:
|
||||||
|
logger.warning(
|
||||||
|
"Unmapped LiveKit egress status '%s', ignoring the event.",
|
||||||
|
egress_status,
|
||||||
|
)
|
||||||
|
return event
|
||||||
|
|
||||||
|
|
||||||
class LiveKitEventsService:
|
class LiveKitEventsService:
|
||||||
"""Service for processing and handling LiveKit webhook events and notifications."""
|
"""Service for processing and handling LiveKit webhook events and notifications."""
|
||||||
|
|
||||||
@@ -153,10 +169,13 @@ class LiveKitEventsService:
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
webhook_type = LiveKitWebhookEventType(data.event)
|
webhook_type = LiveKitWebhookEventType(data.event)
|
||||||
except ValueError as e:
|
except ValueError:
|
||||||
raise UnsupportedEventTypeError(
|
logger.warning(
|
||||||
f"Unknown webhook type: {data.event}"
|
"Ignoring unknown LiveKit webhook event type '%s' for room '%s'",
|
||||||
) from e
|
data.event,
|
||||||
|
room_name,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
# Handle according to received webhook type
|
# Handle according to received webhook type
|
||||||
handler = self._webhook_handlers.get(webhook_type.value)
|
handler = self._webhook_handlers.get(webhook_type.value)
|
||||||
@@ -175,12 +194,20 @@ class LiveKitEventsService:
|
|||||||
f"Recording with worker ID {egress_id} does not exist"
|
f"Recording with worker ID {egress_id} does not exist"
|
||||||
) from err
|
) from err
|
||||||
|
|
||||||
egress_status = data.egress_info.status
|
event = to_recording_event(data.egress_info.status)
|
||||||
self.recording_events.handle_update(recording, egress_status)
|
if event is None:
|
||||||
|
return
|
||||||
|
|
||||||
|
self.recording_events.handle_update(recording, event)
|
||||||
|
|
||||||
def _handle_egress_ended(self, data):
|
def _handle_egress_ended(self, data):
|
||||||
"""Handle 'egress_ended' event."""
|
"""Handle 'egress_ended' event.
|
||||||
|
|
||||||
|
Egress ended is sent with one of these statuses:
|
||||||
|
EGRESS_COMPLETE, EGRESS_FAILED, EGRESS_ABORTED, EGRESS_LIMIT_REACHED
|
||||||
|
"""
|
||||||
|
|
||||||
|
# Fetch recording
|
||||||
try:
|
try:
|
||||||
recording = models.Recording.objects.select_related("room").get(
|
recording = models.Recording.objects.select_related("room").get(
|
||||||
worker_id=data.egress_info.egress_id
|
worker_id=data.egress_info.egress_id
|
||||||
@@ -190,6 +217,17 @@ class LiveKitEventsService:
|
|||||||
f"Recording with worker ID {data.egress_info.egress_id} does not exist"
|
f"Recording with worker ID {data.egress_info.egress_id} does not exist"
|
||||||
) from err
|
) from err
|
||||||
|
|
||||||
|
event = to_recording_event(data.egress_info.status)
|
||||||
|
|
||||||
|
# Log if/why the recording failed
|
||||||
|
self.recording_events.log_worker_error(
|
||||||
|
recording,
|
||||||
|
event,
|
||||||
|
error=data.egress_info.error,
|
||||||
|
error_code=data.egress_info.error_code,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Update room
|
||||||
try:
|
try:
|
||||||
room_name = str(recording.room.id)
|
room_name = str(recording.room.id)
|
||||||
RoomManagement.update_metadata(
|
RoomManagement.update_metadata(
|
||||||
@@ -203,38 +241,17 @@ class LiveKitEventsService:
|
|||||||
except RoomManagementException as e:
|
except RoomManagementException as e:
|
||||||
logger.exception("Failed to update room's metadata: %s", e)
|
logger.exception("Failed to update room's metadata: %s", e)
|
||||||
|
|
||||||
|
# Stop metadata collector
|
||||||
if recording.options.get("metadata_collector_dispatch_id", None) is not None:
|
if recording.options.get("metadata_collector_dispatch_id", None) is not None:
|
||||||
try:
|
try:
|
||||||
MetadataCollectorService().stop(recording)
|
MetadataCollectorService().stop(recording)
|
||||||
except MetadataCollectorException:
|
except MetadataCollectorException:
|
||||||
logger.warning("Failed to stop the MetadataCollectorService")
|
logger.warning("Failed to stop the MetadataCollectorService")
|
||||||
|
|
||||||
if (
|
if event is None:
|
||||||
data.egress_info.status == api.EgressStatus.EGRESS_LIMIT_REACHED
|
return
|
||||||
and recording.status == models.RecordingStatusChoices.ACTIVE
|
|
||||||
):
|
|
||||||
try:
|
|
||||||
self.recording_events.handle_limit_reached(recording)
|
|
||||||
except RecordingEventsError as e:
|
|
||||||
raise ActionFailedError(
|
|
||||||
f"Failed to process limit reached event for recording {recording}"
|
|
||||||
) from e
|
|
||||||
|
|
||||||
# Finalize the recording, the egress has uploaded the file to the storage
|
self.recording_events.handle_terminal_event(recording, event)
|
||||||
if data.egress_info.status in [
|
|
||||||
api.EgressStatus.EGRESS_COMPLETE,
|
|
||||||
api.EgressStatus.EGRESS_LIMIT_REACHED,
|
|
||||||
]:
|
|
||||||
try:
|
|
||||||
self.recording_events.handle_complete(recording)
|
|
||||||
except RecordingNotSavableError:
|
|
||||||
logger.warning(
|
|
||||||
"Recording %s is not savable on egress complete "
|
|
||||||
"(already saved or in an error state); ignoring.",
|
|
||||||
recording.id,
|
|
||||||
)
|
|
||||||
|
|
||||||
# Silently ignoring EGRESS_ABORTED, EGRESS_FAILED
|
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _is_connection_test_room(room_name: str) -> bool:
|
def _is_connection_test_room(room_name: str) -> bool:
|
||||||
@@ -258,7 +275,9 @@ class LiveKitEventsService:
|
|||||||
except models.Room.DoesNotExist as err:
|
except models.Room.DoesNotExist as err:
|
||||||
raise ActionFailedError(f"Room with ID {room_id} does not exist") from err
|
raise ActionFailedError(f"Room with ID {room_id} does not exist") from err
|
||||||
|
|
||||||
if settings.ROOM_TELEPHONY_ENABLED or settings.ROOMKIT_ENABLED:
|
if (
|
||||||
|
settings.ROOM_TELEPHONY_ENABLED or settings.ROOMKIT_ENABLED
|
||||||
|
) and not room.is_encrypted:
|
||||||
try:
|
try:
|
||||||
self.sip_management.ensure_dispatch_rule(room)
|
self.sip_management.ensure_dispatch_rule(room)
|
||||||
except SIPException as e:
|
except SIPException as e:
|
||||||
|
|||||||
@@ -4,11 +4,12 @@ import logging
|
|||||||
import uuid
|
import uuid
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from enum import Enum
|
from enum import Enum
|
||||||
from typing import Dict, List, Optional, Tuple
|
from typing import Dict, FrozenSet, Optional, Sequence, Tuple
|
||||||
from uuid import UUID
|
from uuid import UUID
|
||||||
|
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.core.cache import cache
|
from django.core.cache import cache
|
||||||
|
from django.utils import timezone
|
||||||
|
|
||||||
from core import models, utils
|
from core import models, utils
|
||||||
|
|
||||||
@@ -46,14 +47,20 @@ class LobbyParticipant:
|
|||||||
username: str
|
username: str
|
||||||
color: str
|
color: str
|
||||||
id: str
|
id: str
|
||||||
|
entered_at: str
|
||||||
|
# Whether the user signed in (e.g. via ProConnect). Surfaced to admins so
|
||||||
|
# they can decide whether to accept self-declared identities.
|
||||||
|
is_authenticated: bool = False
|
||||||
|
|
||||||
def to_dict(self) -> Dict[str, str]:
|
def to_dict(self) -> Dict[str, object]:
|
||||||
"""Serialize the participant object to a dict representation."""
|
"""Serialize the participant object to a dict representation."""
|
||||||
return {
|
return {
|
||||||
"status": self.status.value,
|
"status": self.status.value,
|
||||||
"username": self.username,
|
"username": self.username,
|
||||||
"id": self.id,
|
"id": self.id,
|
||||||
"color": self.color,
|
"color": self.color,
|
||||||
|
"entered_at": self.entered_at,
|
||||||
|
"is_authenticated": self.is_authenticated,
|
||||||
}
|
}
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
@@ -68,6 +75,8 @@ class LobbyParticipant:
|
|||||||
username=data["username"],
|
username=data["username"],
|
||||||
id=data["id"],
|
id=data["id"],
|
||||||
color=data["color"],
|
color=data["color"],
|
||||||
|
entered_at=data["entered_at"],
|
||||||
|
is_authenticated=bool(data.get("is_authenticated", False)),
|
||||||
)
|
)
|
||||||
except (KeyError, ValueError) as e:
|
except (KeyError, ValueError) as e:
|
||||||
logger.exception("Error creating Participant from dict:")
|
logger.exception("Error creating Participant from dict:")
|
||||||
@@ -86,6 +95,47 @@ class LobbyService:
|
|||||||
"""Generate cache key for participant(s) data."""
|
"""Generate cache key for participant(s) data."""
|
||||||
return f"{settings.LOBBY_KEY_PREFIX}_{room_id!s}_{participant_id}"
|
return f"{settings.LOBBY_KEY_PREFIX}_{room_id!s}_{participant_id}"
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _get_index_key(room_id: UUID) -> str:
|
||||||
|
"""Raw Redis key of the per-room participant index (a native SET)."""
|
||||||
|
return cache.client.make_key(f"{settings.LOBBY_KEY_PREFIX}-index_{room_id!s}")
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _redis(write: bool = True):
|
||||||
|
"""Raw redis-py client.
|
||||||
|
|
||||||
|
SADD/SREM/SMEMBERS are not exposed by the Django cache API; this is
|
||||||
|
the documented django-redis escape hatch.
|
||||||
|
"""
|
||||||
|
return cache.client.get_client(write=write)
|
||||||
|
|
||||||
|
def _index_add(self, room_id: UUID, participant_id: str) -> None:
|
||||||
|
"""Record a participant id in the room index."""
|
||||||
|
index_key = self._get_index_key(room_id)
|
||||||
|
pipe = self._redis().pipeline(transaction=False)
|
||||||
|
pipe.sadd(index_key, participant_id)
|
||||||
|
pipe.expire(index_key, settings.LOBBY_ACCEPTED_TIMEOUT)
|
||||||
|
pipe.execute()
|
||||||
|
|
||||||
|
def _index_members(self, room_id: UUID) -> FrozenSet[str]:
|
||||||
|
"""All participant ids currently indexed for the room."""
|
||||||
|
members = self._redis(write=False).smembers(self._get_index_key(room_id))
|
||||||
|
return frozenset(
|
||||||
|
member.decode() if isinstance(member, bytes) else member
|
||||||
|
for member in members
|
||||||
|
)
|
||||||
|
|
||||||
|
def _index_touch(self, room_id: UUID) -> None:
|
||||||
|
"""Re-arm the room index backstop TTL."""
|
||||||
|
self._redis().expire(
|
||||||
|
self._get_index_key(room_id), settings.LOBBY_ACCEPTED_TIMEOUT
|
||||||
|
)
|
||||||
|
|
||||||
|
def _index_remove(self, room_id: UUID, *participant_ids: str) -> None:
|
||||||
|
"""Drop participant ids from the room index."""
|
||||||
|
if participant_ids:
|
||||||
|
self._redis().srem(self._get_index_key(room_id), *participant_ids)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _get_or_create_participant_id(request) -> str:
|
def _get_or_create_participant_id(request) -> str:
|
||||||
"""Extract unique participant identifier from the request."""
|
"""Extract unique participant identifier from the request."""
|
||||||
@@ -99,7 +149,7 @@ class LobbyService:
|
|||||||
key=settings.LOBBY_COOKIE_NAME,
|
key=settings.LOBBY_COOKIE_NAME,
|
||||||
value=participant_id,
|
value=participant_id,
|
||||||
httponly=True,
|
httponly=True,
|
||||||
secure=True,
|
secure=not settings.DEBUG,
|
||||||
samesite="Lax",
|
samesite="Lax",
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -162,6 +212,8 @@ class LobbyService:
|
|||||||
username=username,
|
username=username,
|
||||||
id=participant_id,
|
id=participant_id,
|
||||||
color=utils.generate_color(participant_id),
|
color=utils.generate_color(participant_id),
|
||||||
|
entered_at=timezone.now().isoformat(),
|
||||||
|
is_authenticated=request.user.is_authenticated,
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
participant.status = LobbyParticipantStatus.ACCEPTED
|
participant.status = LobbyParticipantStatus.ACCEPTED
|
||||||
@@ -174,19 +226,24 @@ class LobbyService:
|
|||||||
configuration=room.configuration,
|
configuration=room.configuration,
|
||||||
participant_id=participant_id,
|
participant_id=participant_id,
|
||||||
role=user_role,
|
role=user_role,
|
||||||
|
encryption_mode=room.encryption_mode,
|
||||||
)
|
)
|
||||||
return participant, livekit_config
|
return participant, livekit_config
|
||||||
|
|
||||||
livekit_config = None
|
livekit_config = None
|
||||||
|
|
||||||
if participant is None:
|
if participant is None:
|
||||||
participant = self.enter(room.id, participant_id, username)
|
participant = self.enter(
|
||||||
|
room.id,
|
||||||
|
participant_id,
|
||||||
|
username,
|
||||||
|
is_authenticated=request.user.is_authenticated,
|
||||||
|
)
|
||||||
|
|
||||||
elif participant.status == LobbyParticipantStatus.WAITING:
|
elif participant.status == LobbyParticipantStatus.WAITING:
|
||||||
self.refresh_waiting_status(room.id, participant_id)
|
self.refresh_waiting_status(room.id, participant_id)
|
||||||
|
|
||||||
elif participant.status == LobbyParticipantStatus.ACCEPTED:
|
elif participant.status == LobbyParticipantStatus.ACCEPTED:
|
||||||
# wrongly named, contains access token to join a room
|
|
||||||
livekit_config = utils.generate_livekit_config(
|
livekit_config = utils.generate_livekit_config(
|
||||||
room_id=room_id,
|
room_id=room_id,
|
||||||
user=request.user,
|
user=request.user,
|
||||||
@@ -195,6 +252,7 @@ class LobbyService:
|
|||||||
configuration=room.configuration,
|
configuration=room.configuration,
|
||||||
participant_id=participant_id,
|
participant_id=participant_id,
|
||||||
role=user_role,
|
role=user_role,
|
||||||
|
encryption_mode=room.encryption_mode,
|
||||||
)
|
)
|
||||||
|
|
||||||
return participant, livekit_config
|
return participant, livekit_config
|
||||||
@@ -209,15 +267,16 @@ class LobbyService:
|
|||||||
cache.touch(
|
cache.touch(
|
||||||
self._get_cache_key(room_id, participant_id), settings.LOBBY_WAITING_TIMEOUT
|
self._get_cache_key(room_id, participant_id), settings.LOBBY_WAITING_TIMEOUT
|
||||||
)
|
)
|
||||||
|
self._index_touch(room_id)
|
||||||
|
|
||||||
def enter(
|
def enter(
|
||||||
self, room_id: UUID, participant_id: str, username: str
|
self,
|
||||||
|
room_id: UUID,
|
||||||
|
participant_id: str,
|
||||||
|
username: str,
|
||||||
|
is_authenticated: bool = False,
|
||||||
) -> LobbyParticipant:
|
) -> LobbyParticipant:
|
||||||
"""Add participant to waiting lobby.
|
"""Add participant to waiting lobby."""
|
||||||
|
|
||||||
Create a new participant entry in waiting status and notify room
|
|
||||||
participants of the new entry request.
|
|
||||||
"""
|
|
||||||
|
|
||||||
color = utils.generate_color(participant_id)
|
color = utils.generate_color(participant_id)
|
||||||
|
|
||||||
@@ -226,6 +285,8 @@ class LobbyService:
|
|||||||
username=username,
|
username=username,
|
||||||
id=participant_id,
|
id=participant_id,
|
||||||
color=color,
|
color=color,
|
||||||
|
entered_at=timezone.now().isoformat(),
|
||||||
|
is_authenticated=is_authenticated,
|
||||||
)
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -245,6 +306,7 @@ class LobbyService:
|
|||||||
participant.to_dict(),
|
participant.to_dict(),
|
||||||
timeout=settings.LOBBY_WAITING_TIMEOUT,
|
timeout=settings.LOBBY_WAITING_TIMEOUT,
|
||||||
)
|
)
|
||||||
|
self._index_add(room_id, participant_id)
|
||||||
|
|
||||||
return participant
|
return participant
|
||||||
|
|
||||||
@@ -266,28 +328,42 @@ class LobbyService:
|
|||||||
cache.delete(cache_key)
|
cache.delete(cache_key)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def list_waiting_participants(self, room_id: UUID) -> List[dict]:
|
def list_waiting_participants(self, room_id: UUID) -> Sequence[dict]:
|
||||||
"""List all waiting participants for a room."""
|
"""List all waiting participants for a room."""
|
||||||
|
|
||||||
pattern = self._get_cache_key(room_id, "*")
|
member_ids = self._index_members(room_id)
|
||||||
keys = list(cache.iter_keys(pattern, itersize=utils.CACHE_SCAN_ITERSIZE))
|
|
||||||
|
|
||||||
if not keys:
|
if not member_ids:
|
||||||
return []
|
return ()
|
||||||
|
|
||||||
data = cache.get_many(keys)
|
keys_by_id = {
|
||||||
|
participant_id: self._get_cache_key(room_id, participant_id)
|
||||||
|
for participant_id in member_ids
|
||||||
|
}
|
||||||
|
data = cache.get_many(list(keys_by_id.values()))
|
||||||
|
|
||||||
|
dead_ids = []
|
||||||
waiting_participants = []
|
waiting_participants = []
|
||||||
for cache_key, raw_participant in data.items():
|
|
||||||
|
for participant_id, cache_key in keys_by_id.items():
|
||||||
|
raw_participant = data.get(cache_key)
|
||||||
|
if raw_participant is None:
|
||||||
|
dead_ids.append(participant_id)
|
||||||
|
continue
|
||||||
try:
|
try:
|
||||||
participant = LobbyParticipant.from_dict(raw_participant)
|
participant = LobbyParticipant.from_dict(raw_participant)
|
||||||
except LobbyParticipantParsingError:
|
except LobbyParticipantParsingError:
|
||||||
cache.delete(cache_key)
|
cache.delete(cache_key)
|
||||||
|
dead_ids.append(participant_id)
|
||||||
continue
|
continue
|
||||||
if participant.status == LobbyParticipantStatus.WAITING:
|
if participant.status == LobbyParticipantStatus.WAITING:
|
||||||
waiting_participants.append(participant.to_dict())
|
waiting_participants.append(participant.to_dict())
|
||||||
|
|
||||||
return waiting_participants
|
self._index_remove(room_id, *dead_ids)
|
||||||
|
|
||||||
|
waiting_participants.sort(key=lambda p: p["entered_at"], reverse=True)
|
||||||
|
|
||||||
|
return tuple(waiting_participants)
|
||||||
|
|
||||||
def handle_participant_entry(
|
def handle_participant_entry(
|
||||||
self,
|
self,
|
||||||
@@ -341,16 +417,24 @@ class LobbyService:
|
|||||||
|
|
||||||
participant.status = status
|
participant.status = status
|
||||||
cache.set(cache_key, participant.to_dict(), timeout=timeout)
|
cache.set(cache_key, participant.to_dict(), timeout=timeout)
|
||||||
|
self._index_touch(room_id)
|
||||||
|
|
||||||
def clear_room_cache(self, room_id: UUID) -> None:
|
def clear_room_cache(self, room_id: UUID) -> None:
|
||||||
"""Clear all participant entries from the cache for a specific room."""
|
"""Clear all participant entries from the cache for a specific room."""
|
||||||
|
|
||||||
cache.delete_pattern(
|
member_ids = self._index_members(room_id)
|
||||||
self._get_cache_key(room_id, "*"), itersize=utils.CACHE_SCAN_ITERSIZE
|
if member_ids:
|
||||||
)
|
cache.delete_many(
|
||||||
|
[
|
||||||
|
self._get_cache_key(room_id, participant_id)
|
||||||
|
for participant_id in member_ids
|
||||||
|
]
|
||||||
|
)
|
||||||
|
self._redis().delete(self._get_index_key(room_id))
|
||||||
|
|
||||||
def clear_participant_cache(self, room_id: UUID, participant_id: str) -> None:
|
def clear_participant_cache(self, room_id: UUID, participant_id: str) -> None:
|
||||||
"""Clear a given participant entry from the cache for a specific room."""
|
"""Clear a given participant entry from the cache for a specific room."""
|
||||||
|
|
||||||
cache_key = self._get_cache_key(room_id, participant_id)
|
cache_key = self._get_cache_key(room_id, participant_id)
|
||||||
cache.delete(cache_key)
|
cache.delete(cache_key)
|
||||||
|
self._index_remove(room_id, participant_id)
|
||||||
|
|||||||
@@ -1,25 +1,11 @@
|
|||||||
"""Presence cache.
|
"""Presence cache."""
|
||||||
|
|
||||||
Redis-backed memo of "this identity is currently connected to this room".
|
|
||||||
|
|
||||||
This module is intentionally a *pure cache store* with no dependency on other
|
|
||||||
services, so that `participants_management` (which talks to LiveKit) can
|
|
||||||
import it without creating an import cycle. The composition of "check cache,
|
|
||||||
fall back to LiveKit" lives in
|
|
||||||
`ParticipantsManagement.check_if_in_meeting_cached`.
|
|
||||||
|
|
||||||
Only positive answers are stored: a sticky negative would lock out someone
|
|
||||||
who joins right after a miss for the whole TTL. The TTL is a safety net in
|
|
||||||
case an invalidation webhook is lost.
|
|
||||||
"""
|
|
||||||
|
|
||||||
|
from typing import FrozenSet
|
||||||
from uuid import UUID
|
from uuid import UUID
|
||||||
|
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.core.cache import cache
|
from django.core.cache import cache
|
||||||
|
|
||||||
from core.utils import CACHE_SCAN_ITERSIZE
|
|
||||||
|
|
||||||
|
|
||||||
class PresenceCache:
|
class PresenceCache:
|
||||||
"""Store and invalidate (room, identity) presence entries."""
|
"""Store and invalidate (room, identity) presence entries."""
|
||||||
@@ -29,24 +15,65 @@ class PresenceCache:
|
|||||||
"""Cache key for a (room, identity) presence entry."""
|
"""Cache key for a (room, identity) presence entry."""
|
||||||
return f"{settings.PRESENCE_KEY_PREFIX}_{room_id!s}_{identity}"
|
return f"{settings.PRESENCE_KEY_PREFIX}_{room_id!s}_{identity}"
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _get_index_key(room_id: UUID | str) -> str:
|
||||||
|
"""Raw Redis key of the per-room identity index (a native SET).
|
||||||
|
|
||||||
|
Built through django-redis' make_key so it lives under the same
|
||||||
|
KEY_PREFIX/version namespace as the presence entries.
|
||||||
|
"""
|
||||||
|
return cache.client.make_key(
|
||||||
|
f"{settings.PRESENCE_KEY_PREFIX}-index_{room_id!s}"
|
||||||
|
)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _redis(write: bool = True):
|
||||||
|
"""Raw redis-py client.
|
||||||
|
|
||||||
|
SADD/SREM/SMEMBERS are not exposed by the Django cache API; this is
|
||||||
|
the documented django-redis escape hatch.
|
||||||
|
"""
|
||||||
|
return cache.client.get_client(write=write)
|
||||||
|
|
||||||
|
def _index_members(self, room_id: UUID | str) -> FrozenSet[str]:
|
||||||
|
"""All identities currently indexed for the room."""
|
||||||
|
members = self._redis(write=False).smembers(self._get_index_key(room_id))
|
||||||
|
return frozenset(
|
||||||
|
member.decode() if isinstance(member, bytes) else member
|
||||||
|
for member in members
|
||||||
|
)
|
||||||
|
|
||||||
def is_marked_present(self, room_id: UUID | str, identity: str) -> bool:
|
def is_marked_present(self, room_id: UUID | str, identity: str) -> bool:
|
||||||
"""Return True if a positive presence entry exists in cache."""
|
"""Return True if a positive presence entry exists in cache."""
|
||||||
return bool(cache.get(self._get_cache_key(room_id, identity)))
|
return bool(cache.get(self._get_cache_key(room_id, identity)))
|
||||||
|
|
||||||
def mark_present(self, room_id: UUID | str, identity: str) -> None:
|
def mark_present(self, room_id: UUID | str, identity: str) -> None:
|
||||||
"""Record that `identity` is in `room_id`."""
|
"""Record that `identity` is in `room_id` and index it for the room."""
|
||||||
cache.set(
|
cache.set(
|
||||||
self._get_cache_key(room_id, identity),
|
self._get_cache_key(room_id, identity),
|
||||||
True,
|
True,
|
||||||
timeout=settings.PRESENCE_CACHE_TIMEOUT,
|
timeout=settings.PRESENCE_CACHE_TIMEOUT,
|
||||||
)
|
)
|
||||||
|
index_key = self._get_index_key(room_id)
|
||||||
|
pipe = self._redis().pipeline(transaction=False)
|
||||||
|
pipe.sadd(index_key, identity)
|
||||||
|
pipe.expire(index_key, settings.PRESENCE_CACHE_TIMEOUT)
|
||||||
|
pipe.execute()
|
||||||
|
|
||||||
def clear(self, room_id: UUID | str, identity: str) -> None:
|
def clear(self, room_id: UUID | str, identity: str) -> None:
|
||||||
"""Forget presence for one participant (e.g. on participant_left)."""
|
"""Forget presence for one participant (e.g. on participant_left)."""
|
||||||
cache.delete(self._get_cache_key(room_id, identity))
|
cache.delete(self._get_cache_key(room_id, identity))
|
||||||
|
self._redis().srem(self._get_index_key(room_id), identity)
|
||||||
|
|
||||||
def clear_room(self, room_id: UUID | str) -> None:
|
def clear_room(self, room_id: UUID | str) -> None:
|
||||||
"""Forget presence for every participant of a room (on room_finished)."""
|
"""Forget presence for every participant of a room (on room_finished).
|
||||||
cache.delete_pattern(
|
|
||||||
self._get_cache_key(room_id, "*"), itersize=CACHE_SCAN_ITERSIZE
|
Deletes the indexed entries and the index itself with targeted
|
||||||
)
|
commands instead of a full-keyspace pattern scan.
|
||||||
|
"""
|
||||||
|
identities = self._index_members(room_id)
|
||||||
|
if identities:
|
||||||
|
cache.delete_many(
|
||||||
|
[self._get_cache_key(room_id, identity) for identity in identities]
|
||||||
|
)
|
||||||
|
self._redis().delete(self._get_index_key(room_id))
|
||||||
|
|||||||
@@ -76,10 +76,6 @@ class RoomManagement:
|
|||||||
|
|
||||||
except TwirpError as e:
|
except TwirpError as e:
|
||||||
if e.code == "not_found":
|
if e.code == "not_found":
|
||||||
logger.warning(
|
|
||||||
"Room %s not found in LiveKit, skipping metadata update",
|
|
||||||
room_name,
|
|
||||||
)
|
|
||||||
raise RoomNotFoundException("Room does not exist") from e
|
raise RoomNotFoundException("Room does not exist") from e
|
||||||
|
|
||||||
logger.exception(
|
logger.exception(
|
||||||
|
|||||||
@@ -2,18 +2,23 @@
|
|||||||
Test RecordingEventsService service.
|
Test RecordingEventsService service.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
# pylint: disable=redefined-outer-name
|
# pylint: disable=redefined-outer-name,protected-access
|
||||||
|
|
||||||
|
import logging
|
||||||
from unittest import mock
|
from unittest import mock
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from core.factories import RecordingFactory
|
from core.factories import RecordingFactory
|
||||||
|
from core.recording.enums import RecordingWorkerEvent
|
||||||
from core.recording.services.recording_events import (
|
from core.recording.services.recording_events import (
|
||||||
RecordingEventsError,
|
RecordingEventsError,
|
||||||
RecordingEventsService,
|
RecordingEventsService,
|
||||||
RecordingNotSavableError,
|
RecordingNotSavableError,
|
||||||
)
|
)
|
||||||
|
from core.services.room_management import (
|
||||||
|
RoomManagementException,
|
||||||
|
)
|
||||||
from core.utils import NotificationError
|
from core.utils import NotificationError
|
||||||
|
|
||||||
pytestmark = pytest.mark.django_db
|
pytestmark = pytest.mark.django_db
|
||||||
@@ -34,10 +39,10 @@ def service():
|
|||||||
)
|
)
|
||||||
@mock.patch("core.utils.notify_participants")
|
@mock.patch("core.utils.notify_participants")
|
||||||
def test_handle_limit_reached_success(mock_notify, mode, notification_type, service):
|
def test_handle_limit_reached_success(mock_notify, mode, notification_type, service):
|
||||||
"""Test handle_limit_reached stops recording and notifies participants."""
|
"""Test _handle_limit_reached stops recording and notifies participants."""
|
||||||
|
|
||||||
recording = RecordingFactory(status="active", mode=mode)
|
recording = RecordingFactory(status="active", mode=mode)
|
||||||
service.handle_limit_reached(recording)
|
service._handle_limit_reached(recording)
|
||||||
|
|
||||||
assert recording.status == "stopped"
|
assert recording.status == "stopped"
|
||||||
mock_notify.assert_called_once_with(
|
mock_notify.assert_called_once_with(
|
||||||
@@ -48,13 +53,69 @@ def test_handle_limit_reached_success(mock_notify, mode, notification_type, serv
|
|||||||
@pytest.mark.parametrize(
|
@pytest.mark.parametrize(
|
||||||
("mode", "notification_type"),
|
("mode", "notification_type"),
|
||||||
(
|
(
|
||||||
("screen_recording", "screenRecordingLimitReached"),
|
("screen_recording", "screenRecordingFailed"),
|
||||||
("transcript", "transcriptionLimitReached"),
|
("transcript", "transcriptionFailed"),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
@mock.patch("core.utils.notify_participants")
|
@mock.patch("core.utils.notify_participants")
|
||||||
def test_handle_limit_reached_error(mock_notify, mode, notification_type, service):
|
def test_handle_failed_success(mock_notify, mode, notification_type, service):
|
||||||
"""Test handle_limit_reached raises RecordingEventsError when notification fails."""
|
"""Test _handle_failed marks recording as failed and notifies participants."""
|
||||||
|
|
||||||
|
recording = RecordingFactory(status="active", mode=mode)
|
||||||
|
service._handle_failed(recording)
|
||||||
|
|
||||||
|
assert recording.status == "failed"
|
||||||
|
mock_notify.assert_called_once_with(
|
||||||
|
room_name=str(recording.room.id), notification_data={"type": notification_type}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("mode", "notification_type"),
|
||||||
|
(
|
||||||
|
("screen_recording", "screenRecordingAborted"),
|
||||||
|
("transcript", "transcriptionAborted"),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
@mock.patch("core.utils.notify_participants")
|
||||||
|
def test_handle_aborted_success(mock_notify, mode, notification_type, service):
|
||||||
|
"""Test _handle_aborted marks recording as aborted and notifies participants."""
|
||||||
|
|
||||||
|
recording = RecordingFactory(status="active", mode=mode)
|
||||||
|
service._handle_aborted(recording)
|
||||||
|
|
||||||
|
assert recording.status == "aborted"
|
||||||
|
mock_notify.assert_called_once_with(
|
||||||
|
room_name=str(recording.room.id), notification_data={"type": notification_type}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("mode", "notification_prefix"),
|
||||||
|
(("screen_recording", "screenRecording"), ("transcript", "transcription")),
|
||||||
|
)
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("handler", "expected_status", "event", "notification_suffix"),
|
||||||
|
(
|
||||||
|
("_handle_limit_reached", "stopped", "limit reached", "LimitReached"),
|
||||||
|
("_handle_failed", "failed", "failed", "Failed"),
|
||||||
|
("_handle_aborted", "aborted", "aborted", "Aborted"),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
@mock.patch("core.utils.notify_participants")
|
||||||
|
def test_handle_event_notification_error( # noqa: PLR0913, PLR0917
|
||||||
|
mock_notify,
|
||||||
|
handler,
|
||||||
|
expected_status,
|
||||||
|
event,
|
||||||
|
notification_suffix,
|
||||||
|
mode,
|
||||||
|
notification_prefix,
|
||||||
|
service,
|
||||||
|
): # pylint: disable=too-many-arguments,too-many-positional-arguments
|
||||||
|
"""Test handlers raise RecordingEventsError when notifying participants fails,
|
||||||
|
while still applying the recording status of their event.
|
||||||
|
"""
|
||||||
|
|
||||||
mock_notify.side_effect = NotificationError("Error notifying")
|
mock_notify.side_effect = NotificationError("Error notifying")
|
||||||
|
|
||||||
@@ -62,14 +123,15 @@ def test_handle_limit_reached_error(mock_notify, mode, notification_type, servic
|
|||||||
|
|
||||||
with pytest.raises(
|
with pytest.raises(
|
||||||
RecordingEventsError,
|
RecordingEventsError,
|
||||||
match=r"Failed to notify participants in room '.+' "
|
match=rf"Failed to notify participants in room '.+' "
|
||||||
r"about recording limit reached \(recording_id=.+\)",
|
rf"about recording {event} \(recording_id=.+\)",
|
||||||
):
|
):
|
||||||
service.handle_limit_reached(recording)
|
getattr(service, handler)(recording)
|
||||||
|
|
||||||
assert recording.status == "stopped"
|
assert recording.status == expected_status
|
||||||
mock_notify.assert_called_once_with(
|
mock_notify.assert_called_once_with(
|
||||||
room_name=str(recording.room.id), notification_data={"type": notification_type}
|
room_name=str(recording.room.id),
|
||||||
|
notification_data={"type": f"{notification_prefix}{notification_suffix}"},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -82,19 +144,19 @@ def test_handle_limit_reached_error(mock_notify, mode, notification_type, servic
|
|||||||
"core.recording.services.recording_events.notification_service."
|
"core.recording.services.recording_events.notification_service."
|
||||||
"notify_external_services"
|
"notify_external_services"
|
||||||
)
|
)
|
||||||
def test_handle_complete_saves_recording( # pylint: disable=too-many-arguments, too-many-positional-arguments
|
def test_handle_successful_saves_recording( # pylint: disable=too-many-arguments, too-many-positional-arguments
|
||||||
mock_notify_external_services,
|
mock_notify_external_services,
|
||||||
notify_return_value,
|
notify_return_value,
|
||||||
expected_status,
|
expected_status,
|
||||||
status,
|
status,
|
||||||
service,
|
service,
|
||||||
):
|
):
|
||||||
"""Test handle_complete notifies external services and saves a savable recording."""
|
"""Test _handle_successful notifies external services and saves a savable recording."""
|
||||||
|
|
||||||
mock_notify_external_services.return_value = notify_return_value
|
mock_notify_external_services.return_value = notify_return_value
|
||||||
|
|
||||||
recording = RecordingFactory(status=status)
|
recording = RecordingFactory(status=status)
|
||||||
service.handle_complete(recording)
|
service._handle_successful(recording)
|
||||||
|
|
||||||
mock_notify_external_services.assert_called_once_with(recording)
|
mock_notify_external_services.assert_called_once_with(recording)
|
||||||
|
|
||||||
@@ -104,23 +166,293 @@ def test_handle_complete_saves_recording( # pylint: disable=too-many-arguments,
|
|||||||
|
|
||||||
@pytest.mark.parametrize(
|
@pytest.mark.parametrize(
|
||||||
"status",
|
"status",
|
||||||
["initiated", "saved", "notification_succeeded", "aborted", "failed_to_start"],
|
[
|
||||||
|
"initiated",
|
||||||
|
"saved",
|
||||||
|
"notification_succeeded",
|
||||||
|
"aborted",
|
||||||
|
"failed",
|
||||||
|
"failed_to_start",
|
||||||
|
],
|
||||||
)
|
)
|
||||||
@mock.patch(
|
@mock.patch(
|
||||||
"core.recording.services.recording_events.notification_service."
|
"core.recording.services.recording_events.notification_service."
|
||||||
"notify_external_services"
|
"notify_external_services"
|
||||||
)
|
)
|
||||||
def test_handle_complete_non_savable_recording(
|
def test_handle_successful_non_savable_recording(
|
||||||
mock_notify_external_services, status, service
|
mock_notify_external_services, status, service
|
||||||
):
|
):
|
||||||
"""Test handle_complete refuses recordings that are already saved or in error."""
|
"""Test _handle_successful refuses recordings that are already saved or in error."""
|
||||||
|
|
||||||
recording = RecordingFactory(status=status)
|
recording = RecordingFactory(status=status)
|
||||||
|
|
||||||
with pytest.raises(RecordingNotSavableError):
|
with pytest.raises(RecordingNotSavableError):
|
||||||
service.handle_complete(recording)
|
service._handle_successful(recording)
|
||||||
|
|
||||||
mock_notify_external_services.assert_not_called()
|
mock_notify_external_services.assert_not_called()
|
||||||
|
|
||||||
recording.refresh_from_db()
|
recording.refresh_from_db()
|
||||||
assert recording.status == status
|
assert recording.status == status
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("event", "recording_status"),
|
||||||
|
(
|
||||||
|
(RecordingWorkerEvent.STARTED, "started"),
|
||||||
|
(RecordingWorkerEvent.SAVING, "saving"),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
@mock.patch("core.services.room_management.RoomManagement.update_metadata")
|
||||||
|
def test_handle_update_syncs_room_metadata(
|
||||||
|
mock_update_metadata, event, recording_status, service
|
||||||
|
):
|
||||||
|
"""Test handle_update updates the room's metadata."""
|
||||||
|
|
||||||
|
recording = RecordingFactory(status="active")
|
||||||
|
service.handle_update(recording, event)
|
||||||
|
|
||||||
|
mock_update_metadata.assert_called_once_with(
|
||||||
|
str(recording.room.id), {"recording_status": recording_status}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"event",
|
||||||
|
(
|
||||||
|
RecordingWorkerEvent.STARTING,
|
||||||
|
RecordingWorkerEvent.COMPLETED,
|
||||||
|
RecordingWorkerEvent.LIMIT_REACHED,
|
||||||
|
RecordingWorkerEvent.ABORTED,
|
||||||
|
RecordingWorkerEvent.FAILED,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
@mock.patch("core.services.room_management.RoomManagement.update_metadata")
|
||||||
|
def test_handle_update_ignores_events_without_a_metadata_status(
|
||||||
|
mock_update_metadata, event, service
|
||||||
|
):
|
||||||
|
"""Test handle_update doesn't update metadata for events it doesn't match."""
|
||||||
|
|
||||||
|
recording = RecordingFactory(status="active")
|
||||||
|
service.handle_update(recording, event)
|
||||||
|
|
||||||
|
mock_update_metadata.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("event", "initial_status", "expected_status", "notification_type"),
|
||||||
|
(
|
||||||
|
(RecordingWorkerEvent.LIMIT_REACHED, "active", "saved", "LimitReached"),
|
||||||
|
(RecordingWorkerEvent.LIMIT_REACHED, "stopped", "saved", None),
|
||||||
|
(RecordingWorkerEvent.LIMIT_REACHED, "saved", "saved", None),
|
||||||
|
(RecordingWorkerEvent.ABORTED, "active", "aborted", "Aborted"),
|
||||||
|
(RecordingWorkerEvent.ABORTED, "failed_to_stop", "failed_to_stop", None),
|
||||||
|
(RecordingWorkerEvent.FAILED, "active", "failed", "Failed"),
|
||||||
|
(RecordingWorkerEvent.FAILED, "stopped", "failed", "Failed"),
|
||||||
|
(RecordingWorkerEvent.FAILED, "aborted", "aborted", None),
|
||||||
|
(RecordingWorkerEvent.COMPLETED, "active", "saved", None),
|
||||||
|
(RecordingWorkerEvent.COMPLETED, "saved", "saved", None),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
@mock.patch(
|
||||||
|
"core.recording.services.recording_events.notification_service."
|
||||||
|
"notify_external_services"
|
||||||
|
)
|
||||||
|
@mock.patch("core.utils.notify_participants")
|
||||||
|
def test_handle_terminal_event_dispatches_on_event_and_status( # noqa: PLR0913, PLR0917
|
||||||
|
mock_notify,
|
||||||
|
mock_notify_external_services,
|
||||||
|
event,
|
||||||
|
initial_status,
|
||||||
|
expected_status,
|
||||||
|
notification_type,
|
||||||
|
service,
|
||||||
|
): # pylint: disable=too-many-arguments,too-many-positional-arguments
|
||||||
|
"""Test handle_terminal_event chooses the right handler from the event and status."""
|
||||||
|
|
||||||
|
mock_notify_external_services.return_value = False
|
||||||
|
|
||||||
|
recording = RecordingFactory(status=initial_status, mode="screen_recording")
|
||||||
|
service.handle_terminal_event(recording, event)
|
||||||
|
|
||||||
|
recording.refresh_from_db()
|
||||||
|
assert recording.status == expected_status
|
||||||
|
|
||||||
|
if notification_type is None:
|
||||||
|
mock_notify.assert_not_called()
|
||||||
|
else:
|
||||||
|
mock_notify.assert_called_once_with(
|
||||||
|
room_name=str(recording.room.id),
|
||||||
|
notification_data={"type": f"screenRecording{notification_type}"},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"event",
|
||||||
|
(
|
||||||
|
RecordingWorkerEvent.STARTING,
|
||||||
|
RecordingWorkerEvent.STARTED,
|
||||||
|
RecordingWorkerEvent.SAVING,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
@mock.patch(
|
||||||
|
"core.recording.services.recording_events.notification_service."
|
||||||
|
"notify_external_services"
|
||||||
|
)
|
||||||
|
@mock.patch("core.utils.notify_participants")
|
||||||
|
def test_handle_terminal_event_ignores_non_terminal_events(
|
||||||
|
mock_notify, mock_notify_external_services, event, service, caplog
|
||||||
|
):
|
||||||
|
"""Test handle_terminal_event refuses non-terminal events."""
|
||||||
|
|
||||||
|
recording = RecordingFactory(status="active")
|
||||||
|
|
||||||
|
with caplog.at_level(logging.WARNING):
|
||||||
|
service.handle_terminal_event(recording, event)
|
||||||
|
|
||||||
|
assert f"Ignoring non-terminal event {event.value}" in caplog.text
|
||||||
|
mock_notify.assert_not_called()
|
||||||
|
mock_notify_external_services.assert_not_called()
|
||||||
|
|
||||||
|
recording.refresh_from_db()
|
||||||
|
assert recording.status == "active"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("event", "expected_status"),
|
||||||
|
(
|
||||||
|
(RecordingWorkerEvent.LIMIT_REACHED, "saved"),
|
||||||
|
(RecordingWorkerEvent.ABORTED, "aborted"),
|
||||||
|
(RecordingWorkerEvent.FAILED, "failed"),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
@mock.patch(
|
||||||
|
"core.recording.services.recording_events.notification_service."
|
||||||
|
"notify_external_services"
|
||||||
|
)
|
||||||
|
@mock.patch("core.utils.notify_participants")
|
||||||
|
def test_handle_terminal_event_survives_a_notification_failure( # noqa: PLR0913, PLR0917
|
||||||
|
mock_notify,
|
||||||
|
mock_notify_external_services,
|
||||||
|
event,
|
||||||
|
expected_status,
|
||||||
|
service,
|
||||||
|
caplog,
|
||||||
|
): # pylint: disable=too-many-arguments,too-many-positional-arguments
|
||||||
|
"""Test handle_terminal_event logs a notification failure instead of raising.
|
||||||
|
|
||||||
|
The recording status must still be persisted: participants missing their
|
||||||
|
notification should not disturb recording.
|
||||||
|
"""
|
||||||
|
|
||||||
|
mock_notify_external_services.return_value = False
|
||||||
|
mock_notify.side_effect = NotificationError("Error notifying")
|
||||||
|
|
||||||
|
recording = RecordingFactory(status="active")
|
||||||
|
|
||||||
|
with caplog.at_level(logging.ERROR):
|
||||||
|
service.handle_terminal_event(recording, event)
|
||||||
|
|
||||||
|
assert f"Failed to notify participants that recording {recording.id}" in caplog.text
|
||||||
|
|
||||||
|
recording.refresh_from_db()
|
||||||
|
assert recording.status == expected_status
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"status",
|
||||||
|
["failed_to_start", "aborted", "failed", "failed_to_stop", "saved", "initiated"],
|
||||||
|
)
|
||||||
|
@mock.patch(
|
||||||
|
"core.recording.services.recording_events.notification_service."
|
||||||
|
"notify_external_services"
|
||||||
|
)
|
||||||
|
def test_handle_terminal_event_ignores_a_non_savable_recording(
|
||||||
|
mock_notify_external_services, status, service, caplog
|
||||||
|
):
|
||||||
|
"""Test handle_terminal_event handles a redelivered event idempotently.
|
||||||
|
|
||||||
|
A terminal event may be redelivered for an already finalized recording;
|
||||||
|
this must not raise, otherwise the webhook would 500 and be retried.
|
||||||
|
"""
|
||||||
|
|
||||||
|
recording = RecordingFactory(status=status)
|
||||||
|
|
||||||
|
with caplog.at_level(logging.WARNING):
|
||||||
|
service.handle_terminal_event(recording, RecordingWorkerEvent.COMPLETED)
|
||||||
|
|
||||||
|
assert f"Recording {recording.id} is not savable" in caplog.text
|
||||||
|
mock_notify_external_services.assert_not_called()
|
||||||
|
|
||||||
|
recording.refresh_from_db()
|
||||||
|
assert recording.status == status
|
||||||
|
|
||||||
|
|
||||||
|
@mock.patch("core.services.room_management.RoomManagement.update_metadata")
|
||||||
|
def test_handle_update_survives_a_metadata_failure(
|
||||||
|
mock_update_metadata, service, caplog
|
||||||
|
):
|
||||||
|
"""Test handle_update logs a metadata failure instead of raising."""
|
||||||
|
|
||||||
|
mock_update_metadata.side_effect = RoomManagementException("Error updating")
|
||||||
|
|
||||||
|
recording = RecordingFactory(status="active")
|
||||||
|
|
||||||
|
with caplog.at_level(logging.ERROR):
|
||||||
|
service.handle_update(recording, RecordingWorkerEvent.SAVING)
|
||||||
|
|
||||||
|
assert "Failed to update room's metadata" in caplog.text
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("event", "expected_level"),
|
||||||
|
(
|
||||||
|
(RecordingWorkerEvent.ABORTED, logging.INFO),
|
||||||
|
(RecordingWorkerEvent.FAILED, logging.ERROR),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
def test_log_worker_error_reports_an_unsuccessful_event(
|
||||||
|
event, expected_level, service, caplog
|
||||||
|
):
|
||||||
|
"""Test log_worker_error records the reason the recording did not succeed."""
|
||||||
|
|
||||||
|
recording = RecordingFactory(status="active", mode="screen_recording")
|
||||||
|
|
||||||
|
with caplog.at_level(logging.INFO):
|
||||||
|
service.log_worker_error(
|
||||||
|
recording, event, error="could not connect to the room", error_code=500
|
||||||
|
)
|
||||||
|
|
||||||
|
assert (
|
||||||
|
f"Recording worker reported {event.value} for recording {recording.id}"
|
||||||
|
in caplog.text
|
||||||
|
)
|
||||||
|
assert "could not connect to the room" in caplog.text
|
||||||
|
assert "error_code=500" in caplog.text
|
||||||
|
worker_logs = [
|
||||||
|
record
|
||||||
|
for record in caplog.records
|
||||||
|
if record.name == "core.recording.services.recording_events"
|
||||||
|
]
|
||||||
|
assert [record.levelno for record in worker_logs] == [expected_level]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"event",
|
||||||
|
(
|
||||||
|
RecordingWorkerEvent.STARTING,
|
||||||
|
RecordingWorkerEvent.STARTED,
|
||||||
|
RecordingWorkerEvent.SAVING,
|
||||||
|
RecordingWorkerEvent.COMPLETED,
|
||||||
|
RecordingWorkerEvent.LIMIT_REACHED,
|
||||||
|
None,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
def test_log_worker_error_stays_quiet_on_anything_else(event, service, caplog):
|
||||||
|
"""Test log_worker_error ignores events other than FAILED and ABORTED."""
|
||||||
|
|
||||||
|
recording = RecordingFactory(status="active")
|
||||||
|
|
||||||
|
with caplog.at_level(logging.INFO):
|
||||||
|
service.log_worker_error(recording, event, error="some error", error_code=500)
|
||||||
|
|
||||||
|
assert "Recording worker reported" not in caplog.text
|
||||||
|
|||||||
@@ -224,6 +224,7 @@ def test_api_recording_retrieve_expired(settings):
|
|||||||
RecordingStatusChoices.INITIATED,
|
RecordingStatusChoices.INITIATED,
|
||||||
RecordingStatusChoices.ACTIVE,
|
RecordingStatusChoices.ACTIVE,
|
||||||
RecordingStatusChoices.SAVED,
|
RecordingStatusChoices.SAVED,
|
||||||
|
RecordingStatusChoices.FAILED,
|
||||||
RecordingStatusChoices.FAILED_TO_START,
|
RecordingStatusChoices.FAILED_TO_START,
|
||||||
RecordingStatusChoices.FAILED_TO_STOP,
|
RecordingStatusChoices.FAILED_TO_STOP,
|
||||||
RecordingStatusChoices.ABORTED,
|
RecordingStatusChoices.ABORTED,
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ Test worker service classes.
|
|||||||
|
|
||||||
# pylint: disable=protected-access,redefined-outer-name,unused-argument,no-member
|
# pylint: disable=protected-access,redefined-outer-name,unused-argument,no-member
|
||||||
|
|
||||||
|
import logging
|
||||||
from unittest.mock import AsyncMock, Mock, patch
|
from unittest.mock import AsyncMock, Mock, patch
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
@@ -154,9 +155,9 @@ def test_base_egress_filepath_construction(service, filename, extension, expecte
|
|||||||
"response_status,expected_result",
|
"response_status,expected_result",
|
||||||
[
|
[
|
||||||
(livekit_api.EgressStatus.EGRESS_ABORTED, "ABORTED"),
|
(livekit_api.EgressStatus.EGRESS_ABORTED, "ABORTED"),
|
||||||
|
(livekit_api.EgressStatus.EGRESS_FAILED, "FAILED"),
|
||||||
(livekit_api.EgressStatus.EGRESS_COMPLETE, "FAILED_TO_STOP"),
|
(livekit_api.EgressStatus.EGRESS_COMPLETE, "FAILED_TO_STOP"),
|
||||||
(livekit_api.EgressStatus.EGRESS_ENDING, "STOPPED"),
|
(livekit_api.EgressStatus.EGRESS_ENDING, "STOPPED"),
|
||||||
(livekit_api.EgressStatus.EGRESS_FAILED, "FAILED_TO_STOP"),
|
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
def test_base_egress_stop_with_status(service, response_status, expected_result):
|
def test_base_egress_stop_with_status(service, response_status, expected_result):
|
||||||
@@ -175,6 +176,32 @@ def test_base_egress_stop_with_status(service, response_status, expected_result)
|
|||||||
assert result == expected_result
|
assert result == expected_result
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"response_status,event",
|
||||||
|
[
|
||||||
|
(livekit_api.EgressStatus.EGRESS_ABORTED, "aborted"),
|
||||||
|
(livekit_api.EgressStatus.EGRESS_FAILED, "failed"),
|
||||||
|
(livekit_api.EgressStatus.EGRESS_COMPLETE, "failed to stop"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_base_egress_stop_logs_livekit_error(service, response_status, event, caplog):
|
||||||
|
"""Should log the reason LiveKit reported for an unsuccessful stop."""
|
||||||
|
mock_response = Mock(
|
||||||
|
status=response_status,
|
||||||
|
egress_id="test_worker_id",
|
||||||
|
error="could not connect to the room",
|
||||||
|
error_code=500,
|
||||||
|
)
|
||||||
|
service._handle_request = Mock(return_value=mock_response)
|
||||||
|
|
||||||
|
with caplog.at_level(logging.ERROR):
|
||||||
|
service.stop("test_worker_id")
|
||||||
|
|
||||||
|
assert f"Egress {event} on stop (egress_id=test_worker_id" in caplog.text
|
||||||
|
assert "could not connect to the room" in caplog.text
|
||||||
|
assert "error_code=500" in caplog.text
|
||||||
|
|
||||||
|
|
||||||
def test_base_egress_stop_missing_status(service):
|
def test_base_egress_stop_missing_status(service):
|
||||||
"""Test stop method when response is missing status"""
|
"""Test stop method when response is missing status"""
|
||||||
# Mock _handle_request with missing status
|
# Mock _handle_request with missing status
|
||||||
|
|||||||
@@ -312,3 +312,34 @@ def test_api_rooms_create_authenticated_blank_user_default_access_level():
|
|||||||
assert response.status_code == 201
|
assert response.status_code == 201
|
||||||
room = Room.objects.get()
|
room = Room.objects.get()
|
||||||
assert room.access_level == settings.RESOURCE_DEFAULT_ACCESS_LEVEL
|
assert room.access_level == settings.RESOURCE_DEFAULT_ACCESS_LEVEL
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("encryption_mode", ["none", "basic"])
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"user_default", [None, RoomAccessLevel.PUBLIC, RoomAccessLevel.TRUSTED]
|
||||||
|
)
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"requested_access", [None, RoomAccessLevel.PUBLIC, RoomAccessLevel.TRUSTED]
|
||||||
|
)
|
||||||
|
def test_api_rooms_create_encryption_access_precedence(
|
||||||
|
settings, encryption_mode, user_default, requested_access
|
||||||
|
):
|
||||||
|
"""Encryption overrides request and user access defaults only for encrypted rooms."""
|
||||||
|
settings.ENCRYPTION_ENABLED = True
|
||||||
|
user = UserFactory(default_room_access_level=user_default)
|
||||||
|
client = APIClient()
|
||||||
|
client.force_login(user)
|
||||||
|
data = {"name": "New room", "encryption_mode": encryption_mode}
|
||||||
|
if requested_access is not None:
|
||||||
|
data["access_level"] = requested_access
|
||||||
|
|
||||||
|
response = client.post("/api/v1.0/rooms/", data)
|
||||||
|
|
||||||
|
assert response.status_code == 201
|
||||||
|
expected_access = (
|
||||||
|
RoomAccessLevel.RESTRICTED
|
||||||
|
if encryption_mode == "basic"
|
||||||
|
else requested_access or user_default or settings.RESOURCE_DEFAULT_ACCESS_LEVEL
|
||||||
|
)
|
||||||
|
assert response.json()["access_level"] == expected_access
|
||||||
|
assert Room.objects.get().access_level == expected_access
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ from unittest import mock
|
|||||||
from django.core.cache import cache
|
from django.core.cache import cache
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
from freezegun import freeze_time
|
||||||
from rest_framework.test import APIClient
|
from rest_framework.test import APIClient
|
||||||
|
|
||||||
from ... import utils
|
from ... import utils
|
||||||
@@ -24,6 +25,7 @@ pytestmark = pytest.mark.django_db
|
|||||||
# Tests for request_entry endpoint
|
# Tests for request_entry endpoint
|
||||||
|
|
||||||
|
|
||||||
|
@freeze_time("2025-01-01 10:00:00")
|
||||||
def test_request_entry_anonymous(settings):
|
def test_request_entry_anonymous(settings):
|
||||||
"""Anonymous users should be allowed to request entry to a room."""
|
"""Anonymous users should be allowed to request entry to a room."""
|
||||||
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
|
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
|
||||||
@@ -59,6 +61,8 @@ def test_request_entry_anonymous(settings):
|
|||||||
"username": "test_user",
|
"username": "test_user",
|
||||||
"status": "waiting",
|
"status": "waiting",
|
||||||
"color": "mocked-color",
|
"color": "mocked-color",
|
||||||
|
"entered_at": "2025-01-01T10:00:00+00:00",
|
||||||
|
"is_authenticated": False,
|
||||||
"livekit": None,
|
"livekit": None,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -71,9 +75,13 @@ def test_request_entry_anonymous(settings):
|
|||||||
assert participant_data.get("username") == "test_user"
|
assert participant_data.get("username") == "test_user"
|
||||||
|
|
||||||
|
|
||||||
def test_request_entry_authenticated_user(settings):
|
@freeze_time("2025-01-01 10:00:00")
|
||||||
|
@pytest.mark.parametrize("encryption_mode", ["none", "basic"])
|
||||||
|
def test_request_entry_authenticated_user(settings, encryption_mode):
|
||||||
"""Authenticated users should be allowed to request entry."""
|
"""Authenticated users should be allowed to request entry."""
|
||||||
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
|
room = RoomFactory(
|
||||||
|
access_level=RoomAccessLevel.RESTRICTED, encryption_mode=encryption_mode
|
||||||
|
)
|
||||||
user = UserFactory()
|
user = UserFactory()
|
||||||
client = APIClient()
|
client = APIClient()
|
||||||
client.force_login(user)
|
client.force_login(user)
|
||||||
@@ -108,6 +116,8 @@ def test_request_entry_authenticated_user(settings):
|
|||||||
"username": "test_user",
|
"username": "test_user",
|
||||||
"status": "waiting",
|
"status": "waiting",
|
||||||
"color": "mocked-color",
|
"color": "mocked-color",
|
||||||
|
"entered_at": "2025-01-01T10:00:00+00:00",
|
||||||
|
"is_authenticated": True,
|
||||||
"livekit": None,
|
"livekit": None,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -120,6 +130,7 @@ def test_request_entry_authenticated_user(settings):
|
|||||||
assert participant_data.get("username") == "test_user"
|
assert participant_data.get("username") == "test_user"
|
||||||
|
|
||||||
|
|
||||||
|
@freeze_time("2025-01-01 10:00:00")
|
||||||
def test_request_entry_with_existing_participants(settings):
|
def test_request_entry_with_existing_participants(settings):
|
||||||
"""Anonymous users should be allowed to request entry to a room with existing participants."""
|
"""Anonymous users should be allowed to request entry to a room with existing participants."""
|
||||||
# Create a restricted access room
|
# Create a restricted access room
|
||||||
@@ -138,6 +149,7 @@ def test_request_entry_with_existing_participants(settings):
|
|||||||
"username": "user1",
|
"username": "user1",
|
||||||
"status": "waiting",
|
"status": "waiting",
|
||||||
"color": "#123456",
|
"color": "#123456",
|
||||||
|
"entered_at": "2025-01-01T10:00:00+00:00",
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
cache.set(
|
cache.set(
|
||||||
@@ -147,6 +159,7 @@ def test_request_entry_with_existing_participants(settings):
|
|||||||
"username": "user2",
|
"username": "user2",
|
||||||
"status": "accepted",
|
"status": "accepted",
|
||||||
"color": "#654321",
|
"color": "#654321",
|
||||||
|
"entered_at": "2025-01-01T10:00:00+00:00",
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -178,8 +191,10 @@ def test_request_entry_with_existing_participants(settings):
|
|||||||
assert response.json() == {
|
assert response.json() == {
|
||||||
"id": participant_id,
|
"id": participant_id,
|
||||||
"username": "test_user",
|
"username": "test_user",
|
||||||
|
"entered_at": "2025-01-01T10:00:00+00:00",
|
||||||
"status": "waiting",
|
"status": "waiting",
|
||||||
"color": "mocked-color",
|
"color": "mocked-color",
|
||||||
|
"is_authenticated": False,
|
||||||
"livekit": None,
|
"livekit": None,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -192,6 +207,7 @@ def test_request_entry_with_existing_participants(settings):
|
|||||||
assert participant_data.get("username") == "test_user"
|
assert participant_data.get("username") == "test_user"
|
||||||
|
|
||||||
|
|
||||||
|
@freeze_time("2025-01-01 10:00:00")
|
||||||
def test_request_entry_public_room(settings):
|
def test_request_entry_public_room(settings):
|
||||||
"""Entry requests to public rooms should return ACCEPTED status with LiveKit config."""
|
"""Entry requests to public rooms should return ACCEPTED status with LiveKit config."""
|
||||||
room = RoomFactory(access_level=RoomAccessLevel.PUBLIC)
|
room = RoomFactory(access_level=RoomAccessLevel.PUBLIC)
|
||||||
@@ -230,8 +246,10 @@ def test_request_entry_public_room(settings):
|
|||||||
assert response.json() == {
|
assert response.json() == {
|
||||||
"id": "123",
|
"id": "123",
|
||||||
"username": "test_user",
|
"username": "test_user",
|
||||||
|
"entered_at": "2025-01-01T10:00:00+00:00",
|
||||||
"status": "accepted",
|
"status": "accepted",
|
||||||
"color": "mocked-color",
|
"color": "mocked-color",
|
||||||
|
"is_authenticated": False,
|
||||||
"livekit": {"token": "test-token"},
|
"livekit": {"token": "test-token"},
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -240,6 +258,7 @@ def test_request_entry_public_room(settings):
|
|||||||
assert not lobby_keys
|
assert not lobby_keys
|
||||||
|
|
||||||
|
|
||||||
|
@freeze_time("2025-01-01 10:00:00")
|
||||||
def test_request_entry_authenticated_user_public_room(settings):
|
def test_request_entry_authenticated_user_public_room(settings):
|
||||||
"""While authenticated, entry request to public rooms should get accepted."""
|
"""While authenticated, entry request to public rooms should get accepted."""
|
||||||
room = RoomFactory(access_level=RoomAccessLevel.PUBLIC)
|
room = RoomFactory(access_level=RoomAccessLevel.PUBLIC)
|
||||||
@@ -282,8 +301,10 @@ def test_request_entry_authenticated_user_public_room(settings):
|
|||||||
assert response.json() == {
|
assert response.json() == {
|
||||||
"id": "2f7f162f-e7d1-421b-90e7-02bfbfbf8def",
|
"id": "2f7f162f-e7d1-421b-90e7-02bfbfbf8def",
|
||||||
"username": "test_user",
|
"username": "test_user",
|
||||||
|
"entered_at": "2025-01-01T10:00:00+00:00",
|
||||||
"status": "accepted",
|
"status": "accepted",
|
||||||
"color": "mocked-color",
|
"color": "mocked-color",
|
||||||
|
"is_authenticated": True,
|
||||||
"livekit": {"token": "test-token"},
|
"livekit": {"token": "test-token"},
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -292,6 +313,7 @@ def test_request_entry_authenticated_user_public_room(settings):
|
|||||||
assert not lobby_keys
|
assert not lobby_keys
|
||||||
|
|
||||||
|
|
||||||
|
@freeze_time("2025-01-01 10:00:00")
|
||||||
def test_request_entry_waiting_participant_public_room(settings):
|
def test_request_entry_waiting_participant_public_room(settings):
|
||||||
"""While waiting, entry request to public rooms should get accepted."""
|
"""While waiting, entry request to public rooms should get accepted."""
|
||||||
room = RoomFactory(access_level=RoomAccessLevel.PUBLIC)
|
room = RoomFactory(access_level=RoomAccessLevel.PUBLIC)
|
||||||
@@ -308,6 +330,7 @@ def test_request_entry_waiting_participant_public_room(settings):
|
|||||||
"username": "user1",
|
"username": "user1",
|
||||||
"status": "waiting",
|
"status": "waiting",
|
||||||
"color": "#123456",
|
"color": "#123456",
|
||||||
|
"entered_at": "2025-01-01T10:00:00+00:00",
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -338,6 +361,8 @@ def test_request_entry_waiting_participant_public_room(settings):
|
|||||||
"username": "user1",
|
"username": "user1",
|
||||||
"status": "accepted",
|
"status": "accepted",
|
||||||
"color": "#123456",
|
"color": "#123456",
|
||||||
|
"entered_at": "2025-01-01T10:00:00+00:00",
|
||||||
|
"is_authenticated": False,
|
||||||
"livekit": {"token": "test-token"},
|
"livekit": {"token": "test-token"},
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -443,6 +468,7 @@ def test_allow_participant_to_enter_success(settings, allow_entry, updated_statu
|
|||||||
"status": "waiting",
|
"status": "waiting",
|
||||||
"username": "foo",
|
"username": "foo",
|
||||||
"color": "123",
|
"color": "123",
|
||||||
|
"entered_at": "2025-01-01T10:00:00+00:00",
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -578,6 +604,7 @@ def test_list_waiting_participants_success(settings):
|
|||||||
"username": "user1",
|
"username": "user1",
|
||||||
"status": "waiting",
|
"status": "waiting",
|
||||||
"color": "#123456",
|
"color": "#123456",
|
||||||
|
"entered_at": "2025-01-01T10:00:00+00:00",
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
cache.set(
|
cache.set(
|
||||||
@@ -587,28 +614,37 @@ def test_list_waiting_participants_success(settings):
|
|||||||
"username": "user2",
|
"username": "user2",
|
||||||
"status": "waiting",
|
"status": "waiting",
|
||||||
"color": "#654321",
|
"color": "#654321",
|
||||||
|
"entered_at": "2025-01-01T10:05:00+00:00",
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
lobby_service = LobbyService()
|
||||||
|
lobby_service._index_add(room.id, "2f7f162f-e7d1-421b-90e7-02bfbfbf8def")
|
||||||
|
lobby_service._index_add(room.id, "f4ca3ab8a6c04ad88097b8da33f60f10")
|
||||||
|
|
||||||
response = client.get(f"/api/v1.0/rooms/{room.id}/waiting-participants/")
|
response = client.get(f"/api/v1.0/rooms/{room.id}/waiting-participants/")
|
||||||
|
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
|
|
||||||
participants = response.json().get("participants")
|
assert response.json() == {
|
||||||
assert sorted(participants, key=lambda p: p["id"]) == [
|
"participants": [
|
||||||
{
|
{
|
||||||
"id": "2f7f162f-e7d1-421b-90e7-02bfbfbf8def",
|
"id": "f4ca3ab8a6c04ad88097b8da33f60f10",
|
||||||
"username": "user1",
|
"username": "user2",
|
||||||
"status": "waiting",
|
"status": "waiting",
|
||||||
"color": "#123456",
|
"color": "#654321",
|
||||||
},
|
"is_authenticated": False,
|
||||||
{
|
"entered_at": "2025-01-01T10:05:00+00:00",
|
||||||
"id": "f4ca3ab8a6c04ad88097b8da33f60f10",
|
},
|
||||||
"username": "user2",
|
{
|
||||||
"status": "waiting",
|
"id": "2f7f162f-e7d1-421b-90e7-02bfbfbf8def",
|
||||||
"color": "#654321",
|
"username": "user1",
|
||||||
},
|
"status": "waiting",
|
||||||
]
|
"color": "#123456",
|
||||||
|
"is_authenticated": False,
|
||||||
|
"entered_at": "2025-01-01T10:00:00+00:00",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def test_list_waiting_participants_empty(settings):
|
def test_list_waiting_participants_empty(settings):
|
||||||
|
|||||||
@@ -372,6 +372,67 @@ def test_rename_participant_unexpected_twirp_error(mock_livekit_client, room, to
|
|||||||
mock_livekit_client.aclose.assert_called_once()
|
mock_livekit_client.aclose.assert_called_once()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("name", ["John Doe", "Admin", "Room Owner"])
|
||||||
|
def test_rename_participant_forbidden_when_display_name_edit_disabled(
|
||||||
|
mock_livekit_client, settings, room, token, name
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Test rename is rejected for authenticated users when the self-hoster
|
||||||
|
disables AUTHENTICATED_PARTICIPANTS_CAN_EDIT_DISPLAY_NAME.
|
||||||
|
"""
|
||||||
|
settings.AUTHENTICATED_PARTICIPANTS_CAN_EDIT_DISPLAY_NAME = False
|
||||||
|
|
||||||
|
client = APIClient()
|
||||||
|
url = reverse("rooms-rename", kwargs={"pk": room.id})
|
||||||
|
response = client.post(
|
||||||
|
url, {"name": name}, format="json", HTTP_AUTHORIZATION=f"Bearer {token}"
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == status.HTTP_403_FORBIDDEN
|
||||||
|
assert response.data == {
|
||||||
|
"error": "Authenticated participants cannot edit their display name"
|
||||||
|
}
|
||||||
|
mock_livekit_client.room.update_participant.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
def test_rename_participant_allowed_when_display_name_edit_enabled(
|
||||||
|
mock_livekit_client, settings, room, token
|
||||||
|
):
|
||||||
|
"""Test rename still works for authenticated users when the setting is enabled."""
|
||||||
|
settings.AUTHENTICATED_PARTICIPANTS_CAN_EDIT_DISPLAY_NAME = True
|
||||||
|
|
||||||
|
client = APIClient()
|
||||||
|
url = reverse("rooms-rename", kwargs={"pk": room.id})
|
||||||
|
response = client.post(
|
||||||
|
url, {"name": "John Doe"}, format="json", HTTP_AUTHORIZATION=f"Bearer {token}"
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == status.HTTP_200_OK
|
||||||
|
mock_livekit_client.room.update_participant.assert_called_once()
|
||||||
|
|
||||||
|
|
||||||
|
def test_rename_participant_anonymous_allowed_when_display_name_edit_disabled(
|
||||||
|
mock_livekit_client, settings, room, anonymous_token
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Test the setting only restricts authenticated users: anonymous participants
|
||||||
|
have no account name to fall back on and can still rename themselves.
|
||||||
|
"""
|
||||||
|
settings.AUTHENTICATED_PARTICIPANTS_CAN_EDIT_DISPLAY_NAME = False
|
||||||
|
|
||||||
|
client = APIClient()
|
||||||
|
url = reverse("rooms-rename", kwargs={"pk": room.id})
|
||||||
|
response = client.post(
|
||||||
|
url,
|
||||||
|
{"name": "Guest User"},
|
||||||
|
format="json",
|
||||||
|
HTTP_AUTHORIZATION=f"Bearer {anonymous_token}",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == status.HTTP_200_OK
|
||||||
|
mock_livekit_client.room.update_participant.assert_called_once()
|
||||||
|
|
||||||
|
|
||||||
def test_rename_participant_success_anonymous(
|
def test_rename_participant_success_anonymous(
|
||||||
mock_livekit_client, room, anonymous_token
|
mock_livekit_client, room, anonymous_token
|
||||||
):
|
):
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ def test_api_rooms_retrieve_anonymous_private_pk():
|
|||||||
"id": str(room.id),
|
"id": str(room.id),
|
||||||
"name": room.name,
|
"name": room.name,
|
||||||
"slug": room.slug,
|
"slug": room.slug,
|
||||||
|
"encryption_mode": room.encryption_mode,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -52,6 +53,7 @@ def test_api_rooms_retrieve_anonymous_trusted_pk():
|
|||||||
"id": str(room.id),
|
"id": str(room.id),
|
||||||
"name": room.name,
|
"name": room.name,
|
||||||
"slug": room.slug,
|
"slug": room.slug,
|
||||||
|
"encryption_mode": room.encryption_mode,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -70,6 +72,7 @@ def test_api_rooms_retrieve_anonymous_private_pk_no_dashes():
|
|||||||
"id": str(room.id),
|
"id": str(room.id),
|
||||||
"name": room.name,
|
"name": room.name,
|
||||||
"slug": room.slug,
|
"slug": room.slug,
|
||||||
|
"encryption_mode": room.encryption_mode,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -86,6 +89,7 @@ def test_api_rooms_retrieve_anonymous_private_slug():
|
|||||||
"id": str(room.id),
|
"id": str(room.id),
|
||||||
"name": room.name,
|
"name": room.name,
|
||||||
"slug": room.slug,
|
"slug": room.slug,
|
||||||
|
"encryption_mode": room.encryption_mode,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -102,6 +106,7 @@ def test_api_rooms_retrieve_anonymous_private_slug_not_normalized():
|
|||||||
"id": str(room.id),
|
"id": str(room.id),
|
||||||
"name": room.name,
|
"name": room.name,
|
||||||
"slug": room.slug,
|
"slug": room.slug,
|
||||||
|
"encryption_mode": room.encryption_mode,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -217,6 +222,7 @@ def test_api_rooms_retrieve_anonymous_public(mock_token):
|
|||||||
"name": room.name,
|
"name": room.name,
|
||||||
"pin_code": room.pin_code,
|
"pin_code": room.pin_code,
|
||||||
"slug": room.slug,
|
"slug": room.slug,
|
||||||
|
"encryption_mode": room.encryption_mode,
|
||||||
}
|
}
|
||||||
|
|
||||||
mock_token.assert_called_once()
|
mock_token.assert_called_once()
|
||||||
@@ -263,6 +269,7 @@ def test_api_rooms_retrieve_authenticated_public(mock_token):
|
|||||||
"name": room.name,
|
"name": room.name,
|
||||||
"pin_code": room.pin_code,
|
"pin_code": room.pin_code,
|
||||||
"slug": room.slug,
|
"slug": room.slug,
|
||||||
|
"encryption_mode": room.encryption_mode,
|
||||||
}
|
}
|
||||||
|
|
||||||
mock_token.assert_called_once_with(
|
mock_token.assert_called_once_with(
|
||||||
@@ -273,6 +280,7 @@ def test_api_rooms_retrieve_authenticated_public(mock_token):
|
|||||||
sources=["camera"],
|
sources=["camera"],
|
||||||
role=None,
|
role=None,
|
||||||
participant_id=None,
|
participant_id=None,
|
||||||
|
encryption_mode="none",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -314,6 +322,7 @@ def test_api_rooms_retrieve_authenticated_trusted(mock_token):
|
|||||||
"name": room.name,
|
"name": room.name,
|
||||||
"pin_code": room.pin_code,
|
"pin_code": room.pin_code,
|
||||||
"slug": room.slug,
|
"slug": room.slug,
|
||||||
|
"encryption_mode": room.encryption_mode,
|
||||||
}
|
}
|
||||||
|
|
||||||
mock_token.assert_called_once_with(
|
mock_token.assert_called_once_with(
|
||||||
@@ -324,6 +333,7 @@ def test_api_rooms_retrieve_authenticated_trusted(mock_token):
|
|||||||
sources=None,
|
sources=None,
|
||||||
role=None,
|
role=None,
|
||||||
participant_id=None,
|
participant_id=None,
|
||||||
|
encryption_mode="none",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -349,6 +359,7 @@ def test_api_rooms_retrieve_authenticated():
|
|||||||
"id": str(room.id),
|
"id": str(room.id),
|
||||||
"name": room.name,
|
"name": room.name,
|
||||||
"slug": room.slug,
|
"slug": room.slug,
|
||||||
|
"encryption_mode": room.encryption_mode,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -400,6 +411,7 @@ def test_api_rooms_retrieve_members(mock_token, django_assert_num_queries, setti
|
|||||||
"name": room.name,
|
"name": room.name,
|
||||||
"pin_code": room.pin_code,
|
"pin_code": room.pin_code,
|
||||||
"slug": room.slug,
|
"slug": room.slug,
|
||||||
|
"encryption_mode": room.encryption_mode,
|
||||||
}
|
}
|
||||||
|
|
||||||
mock_token.assert_called_once_with(
|
mock_token.assert_called_once_with(
|
||||||
@@ -410,6 +422,7 @@ def test_api_rooms_retrieve_members(mock_token, django_assert_num_queries, setti
|
|||||||
sources=["camera"],
|
sources=["camera"],
|
||||||
role=str(RoleChoices.MEMBER),
|
role=str(RoleChoices.MEMBER),
|
||||||
participant_id=None,
|
participant_id=None,
|
||||||
|
encryption_mode="none",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -461,6 +474,7 @@ def test_api_rooms_retrieve_administrators(
|
|||||||
"short_name": other_user_access.user.short_name,
|
"short_name": other_user_access.user.short_name,
|
||||||
"timezone": "UTC",
|
"timezone": "UTC",
|
||||||
"language": other_user_access.user.language,
|
"language": other_user_access.user.language,
|
||||||
|
"default_encryption_mode": "none",
|
||||||
},
|
},
|
||||||
"resource": str(room.id),
|
"resource": str(room.id),
|
||||||
"role": other_user_access.role,
|
"role": other_user_access.role,
|
||||||
@@ -476,6 +490,7 @@ def test_api_rooms_retrieve_administrators(
|
|||||||
"short_name": user_access.user.short_name,
|
"short_name": user_access.user.short_name,
|
||||||
"timezone": "UTC",
|
"timezone": "UTC",
|
||||||
"language": user_access.user.language,
|
"language": user_access.user.language,
|
||||||
|
"default_encryption_mode": "none",
|
||||||
},
|
},
|
||||||
"resource": str(room.id),
|
"resource": str(room.id),
|
||||||
"role": user_access.role,
|
"role": user_access.role,
|
||||||
@@ -496,6 +511,7 @@ def test_api_rooms_retrieve_administrators(
|
|||||||
"name": room.name,
|
"name": room.name,
|
||||||
"pin_code": room.pin_code,
|
"pin_code": room.pin_code,
|
||||||
"slug": room.slug,
|
"slug": room.slug,
|
||||||
|
"encryption_mode": room.encryption_mode,
|
||||||
}
|
}
|
||||||
|
|
||||||
mock_token.assert_called_once_with(
|
mock_token.assert_called_once_with(
|
||||||
@@ -506,4 +522,25 @@ def test_api_rooms_retrieve_administrators(
|
|||||||
sources=None,
|
sources=None,
|
||||||
role=str(user_access.role),
|
role=str(user_access.role),
|
||||||
participant_id=None,
|
participant_id=None,
|
||||||
|
encryption_mode="none",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("encryption_mode", ["none", "basic"])
|
||||||
|
@mock.patch("core.utils.generate_token", return_value="test-token")
|
||||||
|
def test_api_rooms_retrieve_custom_username(mock_token, encryption_mode, settings):
|
||||||
|
"""Encryption does not override the participant's requested display name."""
|
||||||
|
settings.AUTHENTICATED_PARTICIPANTS_CAN_EDIT_DISPLAY_NAME = True
|
||||||
|
user = UserFactory(full_name="Profile Name")
|
||||||
|
room = RoomFactory(
|
||||||
|
access_level=RoomAccessLevel.RESTRICTED, encryption_mode=encryption_mode
|
||||||
|
)
|
||||||
|
UserResourceAccessFactory(resource=room, user=user, role="owner")
|
||||||
|
client = APIClient()
|
||||||
|
client.force_login(user)
|
||||||
|
|
||||||
|
response = client.get(f"/api/v1.0/rooms/{room.id}/", {"username": "Custom Name"})
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert mock_token.call_args.kwargs["username"] == "Custom Name"
|
||||||
|
assert mock_token.call_args.kwargs["encryption_mode"] == encryption_mode
|
||||||
|
|||||||
@@ -117,9 +117,11 @@ def test_start_subtitle_invalid_token():
|
|||||||
assert response.json() == {"detail": "Invalid LiveKit token: Not enough segments"}
|
assert response.json() == {"detail": "Invalid LiveKit token: Not enough segments"}
|
||||||
|
|
||||||
|
|
||||||
def test_start_subtitle_disabled_by_default(mock_livekit_token):
|
def test_start_subtitle_disabled_by_default(mock_livekit_token, settings):
|
||||||
"""Test that subtitle functionality is disabled when feature flag is off."""
|
"""Test that subtitle functionality is disabled when feature flag is off."""
|
||||||
|
|
||||||
|
settings.ROOM_SUBTITLE_ENABLED = False
|
||||||
|
|
||||||
room = RoomFactory()
|
room = RoomFactory()
|
||||||
user = UserFactory()
|
user = UserFactory()
|
||||||
client = APIClient()
|
client = APIClient()
|
||||||
|
|||||||
@@ -410,3 +410,24 @@ def test_api_rooms_update_livekit_sync_failure(mock_update_metadata, exception):
|
|||||||
"configuration": {"can_publish_sources": ["camera"]},
|
"configuration": {"can_publish_sources": ["camera"]},
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"access_level", [RoomAccessLevel.PUBLIC, RoomAccessLevel.TRUSTED]
|
||||||
|
)
|
||||||
|
def test_api_rooms_update_encrypted_access_rejected(access_level):
|
||||||
|
"""API updates cannot change an encrypted room away from restricted access."""
|
||||||
|
room = RoomFactory(encryption_mode="basic")
|
||||||
|
user = UserFactory()
|
||||||
|
room.accesses.create(user=user, role="owner")
|
||||||
|
client = APIClient()
|
||||||
|
client.force_login(user)
|
||||||
|
|
||||||
|
response = client.patch(
|
||||||
|
f"/api/v1.0/rooms/{room.id}/", {"access_level": access_level}
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 400
|
||||||
|
assert "access_level" in response.json()
|
||||||
|
room.refresh_from_db()
|
||||||
|
assert room.access_level == RoomAccessLevel.RESTRICTED
|
||||||
|
|||||||
@@ -94,7 +94,7 @@ def test_invalid_payload(client, auth_token, mock_livekit_config):
|
|||||||
|
|
||||||
|
|
||||||
def test_unknown_event_type(client, mock_livekit_config):
|
def test_unknown_event_type(client, mock_livekit_config):
|
||||||
"""Should return 422 for unknown event type."""
|
"""Should acknowledge (200) an unknown event type rather than reject it."""
|
||||||
event_data = json.dumps({"event": "unknown_event_type"})
|
event_data = json.dumps({"event": "unknown_event_type"})
|
||||||
|
|
||||||
# Generate auth token for this specific payload
|
# Generate auth token for this specific payload
|
||||||
@@ -112,10 +112,8 @@ def test_unknown_event_type(client, mock_livekit_config):
|
|||||||
HTTP_AUTHORIZATION=auth_token,
|
HTTP_AUTHORIZATION=auth_token,
|
||||||
)
|
)
|
||||||
|
|
||||||
assert response.status_code == 422
|
assert response.status_code == 200
|
||||||
assert response.json() == {
|
assert response.json() == {"status": "success"}
|
||||||
"status": "error",
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@mock.patch.object(LiveKitEventsService, "_handle_room_finished")
|
@mock.patch.object(LiveKitEventsService, "_handle_room_finished")
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ Test LiveKitEvents service.
|
|||||||
"""
|
"""
|
||||||
# pylint: disable=W0621,W0613, W0212, E0611
|
# pylint: disable=W0621,W0613, W0212, E0611
|
||||||
|
|
||||||
|
import logging
|
||||||
import uuid
|
import uuid
|
||||||
from unittest import mock
|
from unittest import mock
|
||||||
|
|
||||||
@@ -10,14 +11,16 @@ import pytest
|
|||||||
from livekit.api import EgressStatus
|
from livekit.api import EgressStatus
|
||||||
|
|
||||||
from core.factories import RecordingFactory, RoomFactory
|
from core.factories import RecordingFactory, RoomFactory
|
||||||
|
from core.recording.enums import RecordingWorkerEvent
|
||||||
from core.recording.services.recording_events import RecordingEventsService
|
from core.recording.services.recording_events import RecordingEventsService
|
||||||
from core.services.livekit_events import (
|
from core.services.livekit_events import (
|
||||||
|
EGRESS_STATUS_TO_RECORDING_EVENT,
|
||||||
ActionFailedError,
|
ActionFailedError,
|
||||||
AuthenticationError,
|
AuthenticationError,
|
||||||
InvalidPayloadError,
|
InvalidPayloadError,
|
||||||
LiveKitEventsService,
|
LiveKitEventsService,
|
||||||
UnsupportedEventTypeError,
|
|
||||||
api,
|
api,
|
||||||
|
to_recording_event,
|
||||||
)
|
)
|
||||||
from core.services.lobby import LobbyService
|
from core.services.lobby import LobbyService
|
||||||
from core.services.room_management import RoomManagementException
|
from core.services.room_management import RoomManagementException
|
||||||
@@ -78,7 +81,7 @@ def test_initialization(
|
|||||||
def test_handle_egress_ended_success( # pylint: disable=too-many-arguments, too-many-positional-arguments
|
def test_handle_egress_ended_success( # pylint: disable=too-many-arguments, too-many-positional-arguments
|
||||||
mock_update_metadata, mock_notify, mode, notification_type, service
|
mock_update_metadata, mock_notify, mode, notification_type, service
|
||||||
):
|
):
|
||||||
"""Should successfully stop recording and notifies all participant."""
|
"""Should successfully stop recording and notify all participant."""
|
||||||
|
|
||||||
recording = RecordingFactory(worker_id="worker-1", mode=mode, status="active")
|
recording = RecordingFactory(worker_id="worker-1", mode=mode, status="active")
|
||||||
mock_data = mock.MagicMock()
|
mock_data = mock.MagicMock()
|
||||||
@@ -105,7 +108,6 @@ def test_handle_egress_ended_success( # pylint: disable=too-many-arguments, too
|
|||||||
(
|
(
|
||||||
(EgressStatus.EGRESS_ACTIVE, "started"),
|
(EgressStatus.EGRESS_ACTIVE, "started"),
|
||||||
(EgressStatus.EGRESS_ENDING, "saving"),
|
(EgressStatus.EGRESS_ENDING, "saving"),
|
||||||
(EgressStatus.EGRESS_ABORTED, "aborted"),
|
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
@mock.patch("core.services.room_management.RoomManagement.update_metadata")
|
@mock.patch("core.services.room_management.RoomManagement.update_metadata")
|
||||||
@@ -126,29 +128,6 @@ def test_handle_egress_updated_success(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
|
||||||
"egress_status",
|
|
||||||
(
|
|
||||||
EgressStatus.EGRESS_FAILED,
|
|
||||||
EgressStatus.EGRESS_LIMIT_REACHED,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
@mock.patch("core.services.room_management.RoomManagement.update_metadata")
|
|
||||||
def test_handle_egress_updated_non_handled(
|
|
||||||
mock_update_metadata, egress_status, service
|
|
||||||
):
|
|
||||||
"""Should ignore certain egress status and don't trigger metadata updates."""
|
|
||||||
|
|
||||||
recording = RecordingFactory(worker_id="worker-1", status="initiated")
|
|
||||||
mock_data = mock.MagicMock()
|
|
||||||
mock_data.egress_info.egress_id = recording.worker_id
|
|
||||||
mock_data.egress_info.status = egress_status
|
|
||||||
|
|
||||||
service._handle_egress_updated(mock_data)
|
|
||||||
|
|
||||||
mock_update_metadata.assert_not_called()
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
@pytest.mark.parametrize(
|
||||||
("mode", "notification_type"),
|
("mode", "notification_type"),
|
||||||
(
|
(
|
||||||
@@ -181,33 +160,38 @@ def test_handle_egress_ended_metadata_update_fails( # pylint: disable=too-many-
|
|||||||
assert recording.status == "saved"
|
assert recording.status == "saved"
|
||||||
|
|
||||||
|
|
||||||
|
@mock.patch(
|
||||||
|
"core.recording.services.recording_events.notification_service."
|
||||||
|
"notify_external_services"
|
||||||
|
)
|
||||||
@mock.patch("core.utils.notify_participants")
|
@mock.patch("core.utils.notify_participants")
|
||||||
@mock.patch("core.services.room_management.RoomManagement.update_metadata")
|
@mock.patch("core.services.room_management.RoomManagement.update_metadata")
|
||||||
def test_handle_egress_ended_notification_fails(
|
def test_handle_egress_ended_notification_fails(
|
||||||
mock_update_metadata, mock_notify, service
|
mock_update_metadata, mock_notify, mock_notify_external_services, service
|
||||||
):
|
):
|
||||||
"""Should raise ActionFailedError when notification fails but still stop recording."""
|
"""Should still stop and save the recording when notifying participants fails."""
|
||||||
|
|
||||||
|
mock_notify_external_services.return_value = False
|
||||||
|
mock_notify.side_effect = NotificationError("Error notifying")
|
||||||
|
|
||||||
recording = RecordingFactory(worker_id="worker-1", status="active")
|
recording = RecordingFactory(worker_id="worker-1", status="active")
|
||||||
mock_data = mock.MagicMock()
|
mock_data = mock.MagicMock()
|
||||||
mock_data.egress_info.egress_id = recording.worker_id
|
mock_data.egress_info.egress_id = recording.worker_id
|
||||||
mock_data.egress_info.status = EgressStatus.EGRESS_LIMIT_REACHED
|
mock_data.egress_info.status = EgressStatus.EGRESS_LIMIT_REACHED
|
||||||
|
|
||||||
mock_notify.side_effect = NotificationError("Error notifying")
|
service._handle_egress_ended(mock_data)
|
||||||
|
|
||||||
with pytest.raises(
|
|
||||||
ActionFailedError,
|
|
||||||
match=r"Failed to process limit reached event for recording .+",
|
|
||||||
):
|
|
||||||
service._handle_egress_ended(mock_data)
|
|
||||||
|
|
||||||
recording.refresh_from_db()
|
|
||||||
assert recording.status == "stopped"
|
|
||||||
|
|
||||||
|
mock_notify.assert_called_once_with(
|
||||||
|
room_name=str(recording.room.id),
|
||||||
|
notification_data={"type": "screenRecordingLimitReached"},
|
||||||
|
)
|
||||||
mock_update_metadata.assert_called_once_with(
|
mock_update_metadata.assert_called_once_with(
|
||||||
str(recording.room.id), remove_keys=["recording_mode", "recording_status"]
|
str(recording.room.id), remove_keys=["recording_mode", "recording_status"]
|
||||||
)
|
)
|
||||||
|
|
||||||
|
recording.refresh_from_db()
|
||||||
|
assert recording.status == "saved"
|
||||||
|
|
||||||
|
|
||||||
@mock.patch("core.utils.notify_participants")
|
@mock.patch("core.utils.notify_participants")
|
||||||
@mock.patch("core.services.room_management.RoomManagement.update_metadata")
|
@mock.patch("core.services.room_management.RoomManagement.update_metadata")
|
||||||
@@ -233,17 +217,25 @@ def test_handle_egress_ended_recording_not_found(
|
|||||||
assert recording.status == "active"
|
assert recording.status == "active"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"egress_status",
|
||||||
|
(
|
||||||
|
EgressStatus.EGRESS_FAILED,
|
||||||
|
EgressStatus.EGRESS_ABORTED,
|
||||||
|
EgressStatus.EGRESS_LIMIT_REACHED,
|
||||||
|
),
|
||||||
|
)
|
||||||
@mock.patch("core.utils.notify_participants")
|
@mock.patch("core.utils.notify_participants")
|
||||||
@mock.patch("core.services.room_management.RoomManagement.update_metadata")
|
@mock.patch("core.services.room_management.RoomManagement.update_metadata")
|
||||||
def test_handle_egress_ended_recording_not_active(
|
def test_handle_egress_ended_recording_should_not_be_saved(
|
||||||
mock_update_metadata, mock_notify, service
|
mock_update_metadata, mock_notify, egress_status, service
|
||||||
):
|
):
|
||||||
"""Should ignore non-active recordings."""
|
"""Don't update status for recordings that must not be saved."""
|
||||||
|
|
||||||
recording = RecordingFactory(worker_id="worker-1", status="failed_to_stop")
|
recording = RecordingFactory(worker_id="worker-1", status="failed_to_stop")
|
||||||
mock_data = mock.MagicMock()
|
mock_data = mock.MagicMock()
|
||||||
mock_data.egress_info.egress_id = "worker-1"
|
mock_data.egress_info.egress_id = "worker-1"
|
||||||
mock_data.egress_info.status = EgressStatus.EGRESS_LIMIT_REACHED
|
mock_data.egress_info.status = egress_status
|
||||||
|
|
||||||
service._handle_egress_ended(mock_data)
|
service._handle_egress_ended(mock_data)
|
||||||
|
|
||||||
@@ -256,12 +248,24 @@ def test_handle_egress_ended_recording_not_active(
|
|||||||
assert recording.status == "failed_to_stop"
|
assert recording.status == "failed_to_stop"
|
||||||
|
|
||||||
|
|
||||||
|
@mock.patch(
|
||||||
|
"core.recording.services.recording_events.notification_service."
|
||||||
|
"notify_external_services"
|
||||||
|
)
|
||||||
@mock.patch("core.utils.notify_participants")
|
@mock.patch("core.utils.notify_participants")
|
||||||
@mock.patch("core.services.room_management.RoomManagement.update_metadata")
|
@mock.patch("core.services.room_management.RoomManagement.update_metadata")
|
||||||
def test_handle_egress_ended_recording_not_limit_reached(
|
def test_handle_egress_ended_complete_does_not_notify_participants(
|
||||||
mock_update_metadata, mock_notify, service
|
mock_update_metadata, mock_notify, mock_notify_external_services, service
|
||||||
):
|
):
|
||||||
"""Should ignore egress non-limit-reached statuses."""
|
"""Shouldn't notify participants on a successful egress.
|
||||||
|
|
||||||
|
EGRESS_COMPLETE is the only ended status that notifies no one: limit
|
||||||
|
reached, aborted and failed egresses each send their own notification.
|
||||||
|
A stopped recording is simply finalized, which is the nominal flow once
|
||||||
|
the egress uploaded the file of a user-initiated stop.
|
||||||
|
"""
|
||||||
|
|
||||||
|
mock_notify_external_services.return_value = False
|
||||||
|
|
||||||
recording = RecordingFactory(worker_id="worker-1", status="stopped")
|
recording = RecordingFactory(worker_id="worker-1", status="stopped")
|
||||||
mock_data = mock.MagicMock()
|
mock_data = mock.MagicMock()
|
||||||
@@ -274,7 +278,10 @@ def test_handle_egress_ended_recording_not_limit_reached(
|
|||||||
mock_update_metadata.assert_called_once_with(
|
mock_update_metadata.assert_called_once_with(
|
||||||
str(recording.room.id), remove_keys=["recording_mode", "recording_status"]
|
str(recording.room.id), remove_keys=["recording_mode", "recording_status"]
|
||||||
)
|
)
|
||||||
assert recording.status == "stopped"
|
mock_notify_external_services.assert_called_once_with(recording)
|
||||||
|
|
||||||
|
recording.refresh_from_db()
|
||||||
|
assert recording.status == "saved"
|
||||||
|
|
||||||
|
|
||||||
@mock.patch("core.services.livekit_events.MetadataCollectorService")
|
@mock.patch("core.services.livekit_events.MetadataCollectorService")
|
||||||
@@ -334,96 +341,163 @@ def test_handle_egress_ended_does_not_call_metadata_collector_stop_when_conditio
|
|||||||
mock_collector.stop.assert_not_called()
|
mock_collector.stop.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("egress_status", "recording_status", "event", "expected_level"),
|
||||||
|
(
|
||||||
|
(EgressStatus.EGRESS_ABORTED, "active", "aborted", logging.INFO),
|
||||||
|
(EgressStatus.EGRESS_FAILED, "active", "failed", logging.ERROR),
|
||||||
|
# The synchronous stop may already have persisted the terminal status,
|
||||||
|
# and the error details exist only in the webhook payload.
|
||||||
|
(EgressStatus.EGRESS_ABORTED, "aborted", "aborted", logging.INFO),
|
||||||
|
(EgressStatus.EGRESS_FAILED, "failed", "failed", logging.ERROR),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
@mock.patch("core.utils.notify_participants")
|
||||||
|
@mock.patch("core.services.room_management.RoomManagement.update_metadata")
|
||||||
|
def test_handle_egress_ended_logs_livekit_error( # noqa: PLR0913, PLR0917
|
||||||
|
mock_update_metadata,
|
||||||
|
mock_notify,
|
||||||
|
egress_status,
|
||||||
|
recording_status,
|
||||||
|
event,
|
||||||
|
expected_level,
|
||||||
|
service,
|
||||||
|
caplog,
|
||||||
|
): # pylint: disable=too-many-arguments,too-many-positional-arguments
|
||||||
|
"""Should log the reason LiveKit reported an unsuccessful egress."""
|
||||||
|
|
||||||
|
recording = RecordingFactory(worker_id="worker-1", status=recording_status)
|
||||||
|
mock_data = mock.MagicMock()
|
||||||
|
mock_data.egress_info.egress_id = recording.worker_id
|
||||||
|
mock_data.egress_info.status = egress_status
|
||||||
|
mock_data.egress_info.error = "could not connect to the room"
|
||||||
|
mock_data.egress_info.error_code = 500
|
||||||
|
|
||||||
|
with caplog.at_level(logging.INFO):
|
||||||
|
service._handle_egress_ended(mock_data)
|
||||||
|
|
||||||
|
assert (
|
||||||
|
f"Recording worker reported {event} for recording {recording.id}" in caplog.text
|
||||||
|
)
|
||||||
|
assert "could not connect to the room" in caplog.text
|
||||||
|
assert "error_code=500" in caplog.text
|
||||||
|
|
||||||
|
worker_logs = [
|
||||||
|
record
|
||||||
|
for record in caplog.records
|
||||||
|
if record.name == "core.recording.services.recording_events"
|
||||||
|
]
|
||||||
|
assert [record.levelno for record in worker_logs] == [expected_level]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"egress_status",
|
||||||
|
(EgressStatus.EGRESS_COMPLETE, EgressStatus.EGRESS_LIMIT_REACHED),
|
||||||
|
)
|
||||||
@mock.patch(
|
@mock.patch(
|
||||||
"core.recording.services.recording_events.notification_service."
|
"core.recording.services.recording_events.notification_service."
|
||||||
"notify_external_services"
|
"notify_external_services"
|
||||||
)
|
)
|
||||||
@mock.patch("core.utils.notify_participants")
|
@mock.patch("core.utils.notify_participants")
|
||||||
@mock.patch("core.services.room_management.RoomManagement.update_metadata")
|
@mock.patch("core.services.room_management.RoomManagement.update_metadata")
|
||||||
@pytest.mark.parametrize(
|
def test_handle_egress_ended_does_not_log_error_on_successful_egress( # noqa: PLR0913, PLR0917
|
||||||
"egress_status",
|
|
||||||
[EgressStatus.EGRESS_COMPLETE, EgressStatus.EGRESS_LIMIT_REACHED],
|
|
||||||
)
|
|
||||||
@pytest.mark.parametrize(
|
|
||||||
"notify_return_value, recording_status",
|
|
||||||
[(True, "notification_succeeded"), (False, "saved")],
|
|
||||||
)
|
|
||||||
def test_handle_egress_ended_finalizes_recording( # noqa: PLR0913, PLR0917
|
|
||||||
mock_update_metadata,
|
mock_update_metadata,
|
||||||
mock_notify,
|
mock_notify,
|
||||||
mock_notify_external_services,
|
mock_notify_external_services,
|
||||||
notify_return_value,
|
|
||||||
recording_status,
|
|
||||||
egress_status,
|
egress_status,
|
||||||
service,
|
service,
|
||||||
|
caplog,
|
||||||
): # pylint: disable=too-many-arguments,too-many-positional-arguments
|
): # pylint: disable=too-many-arguments,too-many-positional-arguments
|
||||||
"""Should notify external services and save the recording on egress completion
|
"""Shouldn't log an egress error when LiveKit reports a successful egress."""
|
||||||
(EGRESS_COMPLETE or EGRESS_LIMIT_REACHED).
|
|
||||||
"""
|
|
||||||
mock_notify_external_services.return_value = notify_return_value
|
|
||||||
|
|
||||||
recording = RecordingFactory(worker_id="worker-1", status="active")
|
recording = RecordingFactory(worker_id="worker-1", status="active")
|
||||||
mock_data = mock.MagicMock()
|
mock_data = mock.MagicMock()
|
||||||
mock_data.egress_info.egress_id = recording.worker_id
|
mock_data.egress_info.egress_id = recording.worker_id
|
||||||
mock_data.egress_info.status = egress_status
|
mock_data.egress_info.status = egress_status
|
||||||
|
|
||||||
service._handle_egress_ended(mock_data)
|
with caplog.at_level(logging.ERROR):
|
||||||
|
service._handle_egress_ended(mock_data)
|
||||||
|
|
||||||
mock_notify_external_services.assert_called_once_with(recording)
|
assert "Recording worker reported" not in caplog.text
|
||||||
|
|
||||||
recording.refresh_from_db()
|
|
||||||
assert recording.status == recording_status
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
@mock.patch("core.utils.notify_participants")
|
||||||
"egress_status",
|
|
||||||
[
|
|
||||||
EgressStatus.EGRESS_STARTING,
|
|
||||||
EgressStatus.EGRESS_ACTIVE,
|
|
||||||
EgressStatus.EGRESS_ENDING,
|
|
||||||
EgressStatus.EGRESS_FAILED,
|
|
||||||
EgressStatus.EGRESS_ABORTED,
|
|
||||||
],
|
|
||||||
)
|
|
||||||
@mock.patch("core.services.room_management.RoomManagement.update_metadata")
|
@mock.patch("core.services.room_management.RoomManagement.update_metadata")
|
||||||
def test_handle_egress_ended_does_not_save_on_wrong_status(
|
def test_handle_egress_ended_logs_livekit_error_before_cleaning_up(
|
||||||
mock_update_metadata, egress_status, service
|
mock_update_metadata, mock_notify, service, caplog
|
||||||
):
|
):
|
||||||
"""Shouldn't save on invalid status."""
|
"""Should log the failure reason even when the cleanup fails afterwards."""
|
||||||
|
|
||||||
|
mock_update_metadata.side_effect = RuntimeError("LiveKit is unreachable")
|
||||||
|
|
||||||
recording = RecordingFactory(worker_id="worker-1", status="active")
|
recording = RecordingFactory(worker_id="worker-1", status="active")
|
||||||
mock_data = mock.MagicMock()
|
mock_data = mock.MagicMock()
|
||||||
mock_data.egress_info.egress_id = recording.worker_id
|
mock_data.egress_info.egress_id = recording.worker_id
|
||||||
mock_data.egress_info.status = egress_status
|
mock_data.egress_info.status = EgressStatus.EGRESS_FAILED
|
||||||
|
mock_data.egress_info.error = "could not connect to the room"
|
||||||
|
mock_data.egress_info.error_code = 500
|
||||||
|
|
||||||
service._handle_egress_ended(mock_data)
|
with caplog.at_level(logging.ERROR), pytest.raises(RuntimeError):
|
||||||
|
service._handle_egress_ended(mock_data)
|
||||||
|
|
||||||
recording.refresh_from_db()
|
assert (
|
||||||
assert recording.status == "active"
|
f"Recording worker reported failed for recording {recording.id}" in caplog.text
|
||||||
|
)
|
||||||
|
assert "could not connect to the room" in caplog.text
|
||||||
|
|
||||||
|
|
||||||
|
def test_egress_status_mapping_covers_every_livekit_status():
|
||||||
|
"""Every egress status LiveKit can report must translate to a recording event."""
|
||||||
|
|
||||||
|
unmapped = [
|
||||||
|
name
|
||||||
|
for name in EgressStatus.keys()
|
||||||
|
if getattr(EgressStatus, name) not in EGRESS_STATUS_TO_RECORDING_EVENT
|
||||||
|
]
|
||||||
|
|
||||||
|
assert not unmapped
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
@pytest.mark.parametrize(
|
||||||
"status", ["failed_to_start", "aborted", "failed_to_stop", "saved", "initiated"]
|
("egress_status", "expected_event"),
|
||||||
|
(
|
||||||
|
(EgressStatus.EGRESS_STARTING, RecordingWorkerEvent.STARTING),
|
||||||
|
(EgressStatus.EGRESS_ACTIVE, RecordingWorkerEvent.STARTED),
|
||||||
|
(EgressStatus.EGRESS_ENDING, RecordingWorkerEvent.SAVING),
|
||||||
|
(EgressStatus.EGRESS_COMPLETE, RecordingWorkerEvent.COMPLETED),
|
||||||
|
(EgressStatus.EGRESS_LIMIT_REACHED, RecordingWorkerEvent.LIMIT_REACHED),
|
||||||
|
(EgressStatus.EGRESS_ABORTED, RecordingWorkerEvent.ABORTED),
|
||||||
|
(EgressStatus.EGRESS_FAILED, RecordingWorkerEvent.FAILED),
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
def test_to_recording_event_translates_egress_status(egress_status, expected_event):
|
||||||
|
"""Should translate a LiveKit egress status into a recording worker event."""
|
||||||
|
|
||||||
|
assert to_recording_event(egress_status) == expected_event
|
||||||
|
|
||||||
|
|
||||||
|
def test_to_recording_event_returns_none_on_unmapped_status(caplog):
|
||||||
|
"""Should warn and return None when LiveKit reports an unknown status."""
|
||||||
|
|
||||||
|
with caplog.at_level(logging.WARNING):
|
||||||
|
event = to_recording_event(999)
|
||||||
|
|
||||||
|
assert event is None
|
||||||
|
assert "Unmapped LiveKit egress status" in caplog.text
|
||||||
|
|
||||||
|
|
||||||
@mock.patch("core.services.room_management.RoomManagement.update_metadata")
|
@mock.patch("core.services.room_management.RoomManagement.update_metadata")
|
||||||
def test_handle_egress_ended_ignores_non_savable_recording(
|
def test_handle_egress_updated_ignores_unmapped_status(mock_update_metadata, service):
|
||||||
mock_update_metadata, status, service
|
"""Shouldn't touch the room's metadata when the egress status is unknown."""
|
||||||
):
|
|
||||||
"""Should handle non-savable recordings idempotently without raising.
|
|
||||||
|
|
||||||
'egress_ended' may be redelivered (e.g. for an already-saved recording);
|
RecordingFactory(worker_id="worker-1", status="active")
|
||||||
this must not raise, otherwise the webhook would 500 and LiveKit would retry.
|
|
||||||
"""
|
|
||||||
|
|
||||||
recording = RecordingFactory(worker_id="worker-1", status=status)
|
|
||||||
mock_data = mock.MagicMock()
|
mock_data = mock.MagicMock()
|
||||||
mock_data.egress_info.egress_id = recording.worker_id
|
mock_data.egress_info.egress_id = "worker-1"
|
||||||
mock_data.egress_info.status = EgressStatus.EGRESS_COMPLETE
|
mock_data.egress_info.status = 999
|
||||||
|
|
||||||
service._handle_egress_ended(mock_data)
|
service._handle_egress_updated(mock_data)
|
||||||
|
|
||||||
recording.refresh_from_db()
|
mock_update_metadata.assert_not_called()
|
||||||
assert recording.status == status
|
|
||||||
|
|
||||||
|
|
||||||
@mock.patch.object(LobbyService, "clear_room_cache")
|
@mock.patch.object(LobbyService, "clear_room_cache")
|
||||||
@@ -665,22 +739,27 @@ def test_receive_missing_auth(service):
|
|||||||
|
|
||||||
|
|
||||||
@mock.patch.object(api.WebhookReceiver, "receive")
|
@mock.patch.object(api.WebhookReceiver, "receive")
|
||||||
def test_receive_unsupported_event(mock_receive, service):
|
def test_receive_unknown_event_is_acknowledged(mock_receive, service, caplog):
|
||||||
"""Should raise LiveKitWebhookError for unsupported events."""
|
"""Unknown event types are logged and ignored, not rejected.
|
||||||
|
|
||||||
|
LiveKit adds event types over time and does not retry 4xx responses, so
|
||||||
|
raising here would silently drop the event.
|
||||||
|
"""
|
||||||
mock_request = mock.MagicMock()
|
mock_request = mock.MagicMock()
|
||||||
mock_request.headers = {"Authorization": "test_token"}
|
mock_request.headers = {"Authorization": "test_token"}
|
||||||
mock_request.body = b"{}"
|
mock_request.body = b"{}"
|
||||||
|
|
||||||
# Mock returned data with unsupported event type
|
|
||||||
mock_data = mock.MagicMock()
|
mock_data = mock.MagicMock()
|
||||||
mock_data.room.name = str(uuid.uuid4())
|
mock_data.room.name = str(uuid.uuid4())
|
||||||
mock_data.event = "unsupported_event"
|
mock_data.event = "some_future_event"
|
||||||
mock_receive.return_value = mock_data
|
mock_receive.return_value = mock_data
|
||||||
|
|
||||||
with pytest.raises(
|
with caplog.at_level("WARNING", logger="core.services.livekit_events"):
|
||||||
UnsupportedEventTypeError, match="Unknown webhook type: unsupported_event"
|
service.receive(mock_request) # must not raise
|
||||||
):
|
|
||||||
service.receive(mock_request)
|
assert "Ignoring unknown LiveKit webhook event type 'some_future_event'" in (
|
||||||
|
caplog.text
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@mock.patch.object(api.WebhookReceiver, "receive")
|
@mock.patch.object(api.WebhookReceiver, "receive")
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
Test lobby service.
|
Test lobby service.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
# pylint: disable=W0621,W0613, W0212, R0913
|
# pylint: disable=W0621,W0613, W0212, R0913, C0302
|
||||||
# ruff: noqa: PLR0913, PLR0917
|
# ruff: noqa: PLR0913, PLR0917
|
||||||
|
|
||||||
import uuid
|
import uuid
|
||||||
@@ -14,6 +14,7 @@ from django.core.cache import cache
|
|||||||
from django.http import HttpResponse
|
from django.http import HttpResponse
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
from freezegun import freeze_time
|
||||||
|
|
||||||
from core.factories import RoomFactory, UserFactory, UserResourceAccessFactory
|
from core.factories import RoomFactory, UserFactory, UserResourceAccessFactory
|
||||||
from core.models import RoleChoices, RoomAccessLevel
|
from core.models import RoleChoices, RoomAccessLevel
|
||||||
@@ -24,7 +25,6 @@ from core.services.lobby import (
|
|||||||
LobbyParticipantStatus,
|
LobbyParticipantStatus,
|
||||||
LobbyService,
|
LobbyService,
|
||||||
)
|
)
|
||||||
from core.services.presence import CACHE_SCAN_ITERSIZE
|
|
||||||
from core.utils import NotificationError
|
from core.utils import NotificationError
|
||||||
|
|
||||||
pytestmark = pytest.mark.django_db
|
pytestmark = pytest.mark.django_db
|
||||||
@@ -56,6 +56,7 @@ def participant_dict():
|
|||||||
"username": "test-username",
|
"username": "test-username",
|
||||||
"id": "test-participant-id",
|
"id": "test-participant-id",
|
||||||
"color": "#123456",
|
"color": "#123456",
|
||||||
|
"entered_at": "2025-01-01T10:00:00+00:00",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -67,6 +68,7 @@ def participant_data():
|
|||||||
username="test-username",
|
username="test-username",
|
||||||
id="test-participant-id",
|
id="test-participant-id",
|
||||||
color="#123456",
|
color="#123456",
|
||||||
|
entered_at="2025-01-01T10:00:00+00:00",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -78,6 +80,7 @@ def test_lobby_participant_to_dict(participant_data):
|
|||||||
assert result["username"] == "test-username"
|
assert result["username"] == "test-username"
|
||||||
assert result["id"] == "test-participant-id"
|
assert result["id"] == "test-participant-id"
|
||||||
assert result["color"] == "#123456"
|
assert result["color"] == "#123456"
|
||||||
|
assert result["entered_at"] == "2025-01-01T10:00:00+00:00"
|
||||||
|
|
||||||
|
|
||||||
def test_lobby_participant_from_dict_success(participant_dict):
|
def test_lobby_participant_from_dict_success(participant_dict):
|
||||||
@@ -88,6 +91,20 @@ def test_lobby_participant_from_dict_success(participant_dict):
|
|||||||
assert participant.username == "test-username"
|
assert participant.username == "test-username"
|
||||||
assert participant.id == "test-participant-id"
|
assert participant.id == "test-participant-id"
|
||||||
assert participant.color == "#123456"
|
assert participant.color == "#123456"
|
||||||
|
assert participant.entered_at == "2025-01-01T10:00:00+00:00"
|
||||||
|
|
||||||
|
|
||||||
|
def test_lobby_participant_from_dict_missing_entered_at():
|
||||||
|
"""`entered_at` is mandatory; data without it is rejected."""
|
||||||
|
data = {
|
||||||
|
"status": "waiting",
|
||||||
|
"username": "test-username",
|
||||||
|
"id": "test-participant-id",
|
||||||
|
"color": "#123456",
|
||||||
|
}
|
||||||
|
|
||||||
|
with pytest.raises(LobbyParticipantParsingError, match="Invalid participant data"):
|
||||||
|
LobbyParticipant.from_dict(data)
|
||||||
|
|
||||||
|
|
||||||
def test_lobby_participant_from_dict_default_status():
|
def test_lobby_participant_from_dict_default_status():
|
||||||
@@ -96,6 +113,7 @@ def test_lobby_participant_from_dict_default_status():
|
|||||||
"username": "test-username",
|
"username": "test-username",
|
||||||
"id": "test-participant-id",
|
"id": "test-participant-id",
|
||||||
"color": "#123456",
|
"color": "#123456",
|
||||||
|
"entered_at": "2025-01-01T10:00:00+00:00",
|
||||||
}
|
}
|
||||||
|
|
||||||
participant = LobbyParticipant.from_dict(data_without_status)
|
participant = LobbyParticipant.from_dict(data_without_status)
|
||||||
@@ -121,6 +139,7 @@ def test_lobby_participant_from_dict_invalid_status():
|
|||||||
"username": "test-username",
|
"username": "test-username",
|
||||||
"id": "test-participant-id",
|
"id": "test-participant-id",
|
||||||
"color": "#123456",
|
"color": "#123456",
|
||||||
|
"entered_at": "2025-01-01T10:00:00+00:00",
|
||||||
}
|
}
|
||||||
|
|
||||||
with pytest.raises(LobbyParticipantParsingError, match="Invalid participant data"):
|
with pytest.raises(LobbyParticipantParsingError, match="Invalid participant data"):
|
||||||
@@ -265,6 +284,7 @@ def test_request_entry_public_room(
|
|||||||
username=username,
|
username=username,
|
||||||
id=participant_id,
|
id=participant_id,
|
||||||
color="#123456",
|
color="#123456",
|
||||||
|
entered_at="2025-01-01T10:00:00+00:00",
|
||||||
)
|
)
|
||||||
|
|
||||||
lobby_service._get_or_create_participant_id = mock.Mock(return_value=participant_id)
|
lobby_service._get_or_create_participant_id = mock.Mock(return_value=participant_id)
|
||||||
@@ -283,6 +303,7 @@ def test_request_entry_public_room(
|
|||||||
configuration=room.configuration,
|
configuration=room.configuration,
|
||||||
participant_id="test-participant-id",
|
participant_id="test-participant-id",
|
||||||
role=None,
|
role=None,
|
||||||
|
encryption_mode="none",
|
||||||
)
|
)
|
||||||
|
|
||||||
lobby_service._get_participant.assert_called_once_with(room.id, participant_id)
|
lobby_service._get_participant.assert_called_once_with(room.id, participant_id)
|
||||||
@@ -303,6 +324,7 @@ def test_request_entry_trusted_room(
|
|||||||
username=username,
|
username=username,
|
||||||
id=participant_id,
|
id=participant_id,
|
||||||
color="#123456",
|
color="#123456",
|
||||||
|
entered_at="2025-01-01T10:00:00+00:00",
|
||||||
)
|
)
|
||||||
|
|
||||||
lobby_service._get_or_create_participant_id = mock.Mock(return_value=participant_id)
|
lobby_service._get_or_create_participant_id = mock.Mock(return_value=participant_id)
|
||||||
@@ -321,6 +343,7 @@ def test_request_entry_trusted_room(
|
|||||||
configuration=room.configuration,
|
configuration=room.configuration,
|
||||||
participant_id="test-participant-id",
|
participant_id="test-participant-id",
|
||||||
role=None,
|
role=None,
|
||||||
|
encryption_mode="none",
|
||||||
)
|
)
|
||||||
|
|
||||||
lobby_service._get_participant.assert_called_once_with(room.id, participant_id)
|
lobby_service._get_participant.assert_called_once_with(room.id, participant_id)
|
||||||
@@ -345,6 +368,7 @@ def test_request_entry_new_participant(
|
|||||||
username=username,
|
username=username,
|
||||||
id=participant_id,
|
id=participant_id,
|
||||||
color="#123456",
|
color="#123456",
|
||||||
|
entered_at="2025-01-01T10:00:00+00:00",
|
||||||
)
|
)
|
||||||
mock_enter.return_value = participant_data
|
mock_enter.return_value = participant_data
|
||||||
|
|
||||||
@@ -352,7 +376,12 @@ def test_request_entry_new_participant(
|
|||||||
|
|
||||||
assert participant == participant_data
|
assert participant == participant_data
|
||||||
assert livekit_config is None
|
assert livekit_config is None
|
||||||
mock_enter.assert_called_once_with(room.id, participant_id, username)
|
mock_enter.assert_called_once_with(
|
||||||
|
room.id,
|
||||||
|
participant_id,
|
||||||
|
username,
|
||||||
|
is_authenticated=request.user.is_authenticated,
|
||||||
|
)
|
||||||
lobby_service._get_participant.assert_called_once_with(room.id, participant_id)
|
lobby_service._get_participant.assert_called_once_with(room.id, participant_id)
|
||||||
|
|
||||||
|
|
||||||
@@ -372,6 +401,7 @@ def test_request_entry_waiting_participant(
|
|||||||
username=username,
|
username=username,
|
||||||
id=participant_id,
|
id=participant_id,
|
||||||
color="#123456",
|
color="#123456",
|
||||||
|
entered_at="2025-01-01T10:00:00+00:00",
|
||||||
)
|
)
|
||||||
lobby_service._get_or_create_participant_id = mock.Mock(return_value=participant_id)
|
lobby_service._get_or_create_participant_id = mock.Mock(return_value=participant_id)
|
||||||
lobby_service._get_participant = mock.Mock(return_value=mocked_participant)
|
lobby_service._get_participant = mock.Mock(return_value=mocked_participant)
|
||||||
@@ -400,6 +430,7 @@ def test_request_entry_accepted_participant(
|
|||||||
username=username,
|
username=username,
|
||||||
id=participant_id,
|
id=participant_id,
|
||||||
color="#123456",
|
color="#123456",
|
||||||
|
entered_at="2025-01-01T10:00:00+00:00",
|
||||||
)
|
)
|
||||||
lobby_service._get_or_create_participant_id = mock.Mock(return_value=participant_id)
|
lobby_service._get_or_create_participant_id = mock.Mock(return_value=participant_id)
|
||||||
lobby_service._get_participant = mock.Mock(return_value=mocked_participant)
|
lobby_service._get_participant = mock.Mock(return_value=mocked_participant)
|
||||||
@@ -418,6 +449,7 @@ def test_request_entry_accepted_participant(
|
|||||||
configuration=room.configuration,
|
configuration=room.configuration,
|
||||||
participant_id="test-participant-id",
|
participant_id="test-participant-id",
|
||||||
role=None,
|
role=None,
|
||||||
|
encryption_mode="none",
|
||||||
)
|
)
|
||||||
lobby_service._get_participant.assert_called_once_with(room.id, participant_id)
|
lobby_service._get_participant.assert_called_once_with(room.id, participant_id)
|
||||||
|
|
||||||
@@ -440,6 +472,7 @@ def test_request_entry_participant_with_role(
|
|||||||
username=username,
|
username=username,
|
||||||
id=participant_id,
|
id=participant_id,
|
||||||
color="#123456",
|
color="#123456",
|
||||||
|
entered_at="2025-01-01T10:00:00+00:00",
|
||||||
)
|
)
|
||||||
lobby_service._get_or_create_participant_id = mock.Mock(return_value=participant_id)
|
lobby_service._get_or_create_participant_id = mock.Mock(return_value=participant_id)
|
||||||
lobby_service._get_participant = mock.Mock(return_value=mocked_participant)
|
lobby_service._get_participant = mock.Mock(return_value=mocked_participant)
|
||||||
@@ -458,6 +491,7 @@ def test_request_entry_participant_with_role(
|
|||||||
configuration=room.configuration,
|
configuration=room.configuration,
|
||||||
participant_id="test-participant-id",
|
participant_id="test-participant-id",
|
||||||
role="administrator",
|
role="administrator",
|
||||||
|
encryption_mode="none",
|
||||||
)
|
)
|
||||||
lobby_service._get_participant.assert_called_once_with(room.id, participant_id)
|
lobby_service._get_participant.assert_called_once_with(room.id, participant_id)
|
||||||
|
|
||||||
@@ -466,18 +500,23 @@ def test_request_entry_participant_with_role(
|
|||||||
def test_refresh_waiting_status(mock_cache, lobby_service, participant_id):
|
def test_refresh_waiting_status(mock_cache, lobby_service, participant_id):
|
||||||
"""Test refreshing waiting status for a participant."""
|
"""Test refreshing waiting status for a participant."""
|
||||||
lobby_service._get_cache_key = mock.Mock(return_value="mocked_cache_key")
|
lobby_service._get_cache_key = mock.Mock(return_value="mocked_cache_key")
|
||||||
|
lobby_service._index_touch = mock.Mock()
|
||||||
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
|
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
|
||||||
lobby_service.refresh_waiting_status(room.id, participant_id)
|
lobby_service.refresh_waiting_status(room.id, participant_id)
|
||||||
mock_cache.touch.assert_called_once_with(
|
mock_cache.touch.assert_called_once_with(
|
||||||
"mocked_cache_key", settings.LOBBY_WAITING_TIMEOUT
|
"mocked_cache_key", settings.LOBBY_WAITING_TIMEOUT
|
||||||
)
|
)
|
||||||
|
lobby_service._index_touch.assert_called_once_with(room.id)
|
||||||
|
|
||||||
|
|
||||||
# pylint: disable=R0917
|
# pylint: disable=R0917
|
||||||
@mock.patch("core.services.lobby.cache")
|
@mock.patch("core.services.lobby.cache")
|
||||||
@mock.patch("core.utils.generate_color")
|
@mock.patch("core.utils.generate_color")
|
||||||
@mock.patch("core.utils.notify_participants")
|
@mock.patch("core.utils.notify_participants")
|
||||||
|
@mock.patch("core.services.lobby.LobbyService._index_add")
|
||||||
|
@freeze_time("2025-01-01 10:00:00")
|
||||||
def test_enter_success(
|
def test_enter_success(
|
||||||
|
mock_index_add,
|
||||||
mock_notify,
|
mock_notify,
|
||||||
mock_generate_color,
|
mock_generate_color,
|
||||||
mock_cache,
|
mock_cache,
|
||||||
@@ -497,6 +536,7 @@ def test_enter_success(
|
|||||||
assert participant.username == username
|
assert participant.username == username
|
||||||
assert participant.id == participant_id
|
assert participant.id == participant_id
|
||||||
assert participant.color == "#123456"
|
assert participant.color == "#123456"
|
||||||
|
assert participant.entered_at == "2025-01-01T10:00:00+00:00"
|
||||||
|
|
||||||
lobby_service._get_cache_key.assert_called_once_with(room.id, participant_id)
|
lobby_service._get_cache_key.assert_called_once_with(room.id, participant_id)
|
||||||
|
|
||||||
@@ -508,13 +548,16 @@ def test_enter_success(
|
|||||||
mock_notify.assert_called_once_with(
|
mock_notify.assert_called_once_with(
|
||||||
room_name=str(room.pk), notification_data={"type": "participantWaiting"}
|
room_name=str(room.pk), notification_data={"type": "participantWaiting"}
|
||||||
)
|
)
|
||||||
|
mock_index_add.assert_called_once_with(room.id, participant_id)
|
||||||
|
|
||||||
|
|
||||||
# pylint: disable=R0917
|
# pylint: disable=R0917
|
||||||
@mock.patch("core.services.lobby.cache")
|
@mock.patch("core.services.lobby.cache")
|
||||||
@mock.patch("core.utils.generate_color")
|
@mock.patch("core.utils.generate_color")
|
||||||
@mock.patch("core.utils.notify_participants")
|
@mock.patch("core.utils.notify_participants")
|
||||||
|
@mock.patch("core.services.lobby.LobbyService._index_add")
|
||||||
def test_enter_with_notification_error(
|
def test_enter_with_notification_error(
|
||||||
|
mock_index_add,
|
||||||
mock_notify,
|
mock_notify,
|
||||||
mock_generate_color,
|
mock_generate_color,
|
||||||
mock_cache,
|
mock_cache,
|
||||||
@@ -541,6 +584,7 @@ def test_enter_with_notification_error(
|
|||||||
participant.to_dict(),
|
participant.to_dict(),
|
||||||
timeout=settings.LOBBY_WAITING_TIMEOUT,
|
timeout=settings.LOBBY_WAITING_TIMEOUT,
|
||||||
)
|
)
|
||||||
|
mock_index_add.assert_called_once_with(room.id, participant_id)
|
||||||
|
|
||||||
|
|
||||||
@mock.patch("core.services.lobby.cache")
|
@mock.patch("core.services.lobby.cache")
|
||||||
@@ -579,14 +623,15 @@ def test_get_participant_parsing_error(
|
|||||||
@mock.patch("core.services.lobby.cache")
|
@mock.patch("core.services.lobby.cache")
|
||||||
def test_list_waiting_participants_empty(mock_cache, lobby_service):
|
def test_list_waiting_participants_empty(mock_cache, lobby_service):
|
||||||
"""Test listing waiting participants when none exist."""
|
"""Test listing waiting participants when none exist."""
|
||||||
mock_cache.iter_keys.return_value = []
|
|
||||||
|
|
||||||
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
|
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
|
||||||
|
lobby_service._index_members = mock.Mock(return_value=[])
|
||||||
|
lobby_service._index_remove = mock.Mock()
|
||||||
|
|
||||||
result = lobby_service.list_waiting_participants(room.id)
|
result = lobby_service.list_waiting_participants(room.id)
|
||||||
|
|
||||||
assert result == []
|
assert result == ()
|
||||||
pattern = f"{settings.LOBBY_KEY_PREFIX}_{room.id!s}_*"
|
lobby_service._index_members.assert_called_once_with(room.id)
|
||||||
mock_cache.iter_keys.assert_called_once_with(pattern, itersize=CACHE_SCAN_ITERSIZE)
|
lobby_service._index_remove.assert_not_called()
|
||||||
mock_cache.get_many.assert_not_called()
|
mock_cache.get_many.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
@@ -595,7 +640,8 @@ def test_list_waiting_participants(mock_cache, lobby_service, participant_dict):
|
|||||||
"""Test listing waiting participants with valid data."""
|
"""Test listing waiting participants with valid data."""
|
||||||
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
|
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
|
||||||
cache_key = f"{settings.LOBBY_KEY_PREFIX}_{room.id!s}_participant1"
|
cache_key = f"{settings.LOBBY_KEY_PREFIX}_{room.id!s}_participant1"
|
||||||
mock_cache.iter_keys.return_value = [cache_key]
|
lobby_service._index_members = mock.Mock(return_value=["participant1"])
|
||||||
|
lobby_service._index_remove = mock.Mock()
|
||||||
mock_cache.get_many.return_value = {cache_key: participant_dict}
|
mock_cache.get_many.return_value = {cache_key: participant_dict}
|
||||||
|
|
||||||
result = lobby_service.list_waiting_participants(room.id)
|
result = lobby_service.list_waiting_participants(room.id)
|
||||||
@@ -603,8 +649,8 @@ def test_list_waiting_participants(mock_cache, lobby_service, participant_dict):
|
|||||||
assert len(result) == 1
|
assert len(result) == 1
|
||||||
assert result[0]["status"] == "waiting"
|
assert result[0]["status"] == "waiting"
|
||||||
assert result[0]["username"] == "test-username"
|
assert result[0]["username"] == "test-username"
|
||||||
pattern = f"{settings.LOBBY_KEY_PREFIX}_{room.id!s}_*"
|
lobby_service._index_members.assert_called_once_with(room.id)
|
||||||
mock_cache.iter_keys.assert_called_once_with(pattern, itersize=CACHE_SCAN_ITERSIZE)
|
lobby_service._index_remove.assert_called_once_with(room.id)
|
||||||
mock_cache.get_many.assert_called_once_with([cache_key])
|
mock_cache.get_many.assert_called_once_with([cache_key])
|
||||||
|
|
||||||
|
|
||||||
@@ -620,6 +666,7 @@ def test_list_waiting_participants_multiple(mock_cache, lobby_service):
|
|||||||
"username": "user1",
|
"username": "user1",
|
||||||
"id": "participant1",
|
"id": "participant1",
|
||||||
"color": "#123456",
|
"color": "#123456",
|
||||||
|
"entered_at": "2025-01-01T10:00:00+00:00",
|
||||||
}
|
}
|
||||||
|
|
||||||
participant2 = {
|
participant2 = {
|
||||||
@@ -627,9 +674,13 @@ def test_list_waiting_participants_multiple(mock_cache, lobby_service):
|
|||||||
"username": "user2",
|
"username": "user2",
|
||||||
"id": "participant2",
|
"id": "participant2",
|
||||||
"color": "#654321",
|
"color": "#654321",
|
||||||
|
"entered_at": "2025-01-01T10:05:00+00:00",
|
||||||
}
|
}
|
||||||
|
|
||||||
mock_cache.iter_keys.return_value = [cache_key1, cache_key2]
|
lobby_service._index_members = mock.Mock(
|
||||||
|
return_value=["participant1", "participant2"]
|
||||||
|
)
|
||||||
|
lobby_service._index_remove = mock.Mock()
|
||||||
mock_cache.get_many.return_value = {
|
mock_cache.get_many.return_value = {
|
||||||
cache_key1: participant1,
|
cache_key1: participant1,
|
||||||
cache_key2: participant2,
|
cache_key2: participant2,
|
||||||
@@ -639,15 +690,15 @@ def test_list_waiting_participants_multiple(mock_cache, lobby_service):
|
|||||||
|
|
||||||
assert len(result) == 2
|
assert len(result) == 2
|
||||||
|
|
||||||
# Verify both participants are in the result
|
# Most recent entry comes first
|
||||||
assert any(p["id"] == "participant1" and p["username"] == "user1" for p in result)
|
assert [p["id"] for p in result] == ["participant2", "participant1"]
|
||||||
assert any(p["id"] == "participant2" and p["username"] == "user2" for p in result)
|
assert result[0]["username"] == "user2"
|
||||||
|
assert result[1]["username"] == "user1"
|
||||||
|
|
||||||
# Verify all participants have waiting status
|
# Verify all participants have waiting status
|
||||||
assert all(p["status"] == "waiting" for p in result)
|
assert all(p["status"] == "waiting" for p in result)
|
||||||
|
|
||||||
pattern = f"{settings.LOBBY_KEY_PREFIX}_{room.id!s}_*"
|
lobby_service._index_members.assert_called_once_with(room.id)
|
||||||
mock_cache.iter_keys.assert_called_once_with(pattern, itersize=CACHE_SCAN_ITERSIZE)
|
|
||||||
mock_cache.get_many.assert_called_once_with([cache_key1, cache_key2])
|
mock_cache.get_many.assert_called_once_with([cache_key1, cache_key2])
|
||||||
|
|
||||||
|
|
||||||
@@ -656,12 +707,13 @@ def test_list_waiting_participants_corrupted_data(mock_cache, lobby_service):
|
|||||||
"""Test listing waiting participants with corrupted data."""
|
"""Test listing waiting participants with corrupted data."""
|
||||||
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
|
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
|
||||||
cache_key = f"{settings.LOBBY_KEY_PREFIX}_{room.id!s}_participant1"
|
cache_key = f"{settings.LOBBY_KEY_PREFIX}_{room.id!s}_participant1"
|
||||||
mock_cache.iter_keys.return_value = [cache_key]
|
lobby_service._index_members = mock.Mock(return_value=["participant1"])
|
||||||
|
lobby_service._index_remove = mock.Mock()
|
||||||
mock_cache.get_many.return_value = {cache_key: {"invalid": "data"}}
|
mock_cache.get_many.return_value = {cache_key: {"invalid": "data"}}
|
||||||
|
|
||||||
result = lobby_service.list_waiting_participants(room.id)
|
result = lobby_service.list_waiting_participants(room.id)
|
||||||
|
|
||||||
assert result == []
|
assert result == ()
|
||||||
mock_cache.delete.assert_called_once_with(cache_key)
|
mock_cache.delete.assert_called_once_with(cache_key)
|
||||||
|
|
||||||
|
|
||||||
@@ -677,11 +729,15 @@ def test_list_waiting_participants_partially_corrupted(mock_cache, lobby_service
|
|||||||
"username": "user2",
|
"username": "user2",
|
||||||
"id": "participant2",
|
"id": "participant2",
|
||||||
"color": "#654321",
|
"color": "#654321",
|
||||||
|
"entered_at": "2025-01-01T10:00:00+00:00",
|
||||||
}
|
}
|
||||||
|
|
||||||
corrupted_participant = {"invalid": "data"}
|
corrupted_participant = {"invalid": "data"}
|
||||||
|
|
||||||
mock_cache.iter_keys.return_value = [cache_key1, cache_key2]
|
lobby_service._index_members = mock.Mock(
|
||||||
|
return_value=["participant1", "participant2"]
|
||||||
|
)
|
||||||
|
lobby_service._index_remove = mock.Mock()
|
||||||
mock_cache.get_many.return_value = {
|
mock_cache.get_many.return_value = {
|
||||||
cache_key1: corrupted_participant,
|
cache_key1: corrupted_participant,
|
||||||
cache_key2: valid_participant,
|
cache_key2: valid_participant,
|
||||||
@@ -699,8 +755,6 @@ def test_list_waiting_participants_partially_corrupted(mock_cache, lobby_service
|
|||||||
mock_cache.delete.assert_called_once_with(cache_key1)
|
mock_cache.delete.assert_called_once_with(cache_key1)
|
||||||
|
|
||||||
# Verify both cache keys were queried
|
# Verify both cache keys were queried
|
||||||
pattern = f"{settings.LOBBY_KEY_PREFIX}_{room.id!s}_*"
|
|
||||||
mock_cache.iter_keys.assert_called_once_with(pattern, itersize=CACHE_SCAN_ITERSIZE)
|
|
||||||
mock_cache.get_many.assert_called_once_with([cache_key1, cache_key2])
|
mock_cache.get_many.assert_called_once_with([cache_key1, cache_key2])
|
||||||
|
|
||||||
|
|
||||||
@@ -716,15 +770,20 @@ def test_list_waiting_participants_non_waiting(mock_cache, lobby_service):
|
|||||||
"username": "user1",
|
"username": "user1",
|
||||||
"id": "participant1",
|
"id": "participant1",
|
||||||
"color": "#123456",
|
"color": "#123456",
|
||||||
|
"entered_at": "2025-01-01T10:00:00+00:00",
|
||||||
}
|
}
|
||||||
participant2 = {
|
participant2 = {
|
||||||
"status": "accepted",
|
"status": "accepted",
|
||||||
"username": "user2",
|
"username": "user2",
|
||||||
"id": "participant2",
|
"id": "participant2",
|
||||||
"color": "#654321",
|
"color": "#654321",
|
||||||
|
"entered_at": "2025-01-01T10:00:00+00:00",
|
||||||
}
|
}
|
||||||
|
|
||||||
mock_cache.iter_keys.return_value = [cache_key1, cache_key2]
|
lobby_service._index_members = mock.Mock(
|
||||||
|
return_value=["participant1", "participant2"]
|
||||||
|
)
|
||||||
|
lobby_service._index_remove = mock.Mock()
|
||||||
mock_cache.get_many.return_value = {
|
mock_cache.get_many.return_value = {
|
||||||
cache_key1: participant1,
|
cache_key1: participant1,
|
||||||
cache_key2: participant2,
|
cache_key2: participant2,
|
||||||
@@ -816,10 +875,12 @@ def test_update_participant_status_success(mock_cache, lobby_service, participan
|
|||||||
"username": "test-username",
|
"username": "test-username",
|
||||||
"id": participant_id,
|
"id": participant_id,
|
||||||
"color": "#123456",
|
"color": "#123456",
|
||||||
|
"entered_at": "2025-01-01T10:00:00+00:00",
|
||||||
}
|
}
|
||||||
|
|
||||||
mock_cache.get.return_value = participant_dict
|
mock_cache.get.return_value = participant_dict
|
||||||
lobby_service._get_cache_key = mock.Mock(return_value="mocked_cache_key")
|
lobby_service._get_cache_key = mock.Mock(return_value="mocked_cache_key")
|
||||||
|
lobby_service._index_touch = mock.Mock()
|
||||||
|
|
||||||
lobby_service._update_participant_status(
|
lobby_service._update_participant_status(
|
||||||
room.id,
|
room.id,
|
||||||
@@ -833,10 +894,13 @@ def test_update_participant_status_success(mock_cache, lobby_service, participan
|
|||||||
"username": "test-username",
|
"username": "test-username",
|
||||||
"id": participant_id,
|
"id": participant_id,
|
||||||
"color": "#123456",
|
"color": "#123456",
|
||||||
|
"entered_at": "2025-01-01T10:00:00+00:00",
|
||||||
|
"is_authenticated": False,
|
||||||
}
|
}
|
||||||
mock_cache.set.assert_called_once_with(
|
mock_cache.set.assert_called_once_with(
|
||||||
"mocked_cache_key", expected_data, timeout=60
|
"mocked_cache_key", expected_data, timeout=60
|
||||||
)
|
)
|
||||||
|
lobby_service._index_touch.assert_called_once_with(room.id)
|
||||||
lobby_service._get_cache_key.assert_called_once_with(room.id, participant_id)
|
lobby_service._get_cache_key.assert_called_once_with(room.id, participant_id)
|
||||||
|
|
||||||
|
|
||||||
@@ -857,6 +921,7 @@ def test_clear_room_cache(settings, lobby_service):
|
|||||||
username="participant1",
|
username="participant1",
|
||||||
id="participant1",
|
id="participant1",
|
||||||
color="#123456",
|
color="#123456",
|
||||||
|
entered_at="2025-01-01T10:00:00+00:00",
|
||||||
),
|
),
|
||||||
timeout=settings.LOBBY_WAITING_TIMEOUT,
|
timeout=settings.LOBBY_WAITING_TIMEOUT,
|
||||||
)
|
)
|
||||||
@@ -867,6 +932,7 @@ def test_clear_room_cache(settings, lobby_service):
|
|||||||
username="participant2",
|
username="participant2",
|
||||||
id="participant2",
|
id="participant2",
|
||||||
color="#123456",
|
color="#123456",
|
||||||
|
entered_at="2025-01-01T10:00:00+00:00",
|
||||||
),
|
),
|
||||||
timeout=settings.LOBBY_ACCEPTED_TIMEOUT,
|
timeout=settings.LOBBY_ACCEPTED_TIMEOUT,
|
||||||
)
|
)
|
||||||
@@ -877,13 +943,18 @@ def test_clear_room_cache(settings, lobby_service):
|
|||||||
username="participant3",
|
username="participant3",
|
||||||
id="participant3",
|
id="participant3",
|
||||||
color="#123456",
|
color="#123456",
|
||||||
|
entered_at="2025-01-01T10:00:00+00:00",
|
||||||
),
|
),
|
||||||
timeout=settings.LOBBY_DENIED_TIMEOUT,
|
timeout=settings.LOBBY_DENIED_TIMEOUT,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
for participant_id in ("participant1", "participant2", "participant3"):
|
||||||
|
lobby_service._index_add(room_id, participant_id)
|
||||||
|
|
||||||
lobby_service.clear_room_cache(room_id)
|
lobby_service.clear_room_cache(room_id)
|
||||||
|
|
||||||
assert cache.keys(f"test-lobby_{room_id!s}_*") == []
|
assert cache.keys(f"test-lobby_{room_id!s}_*") == []
|
||||||
|
assert lobby_service._index_members(room_id) == frozenset()
|
||||||
|
|
||||||
|
|
||||||
def test_clear_room_empty(settings, lobby_service):
|
def test_clear_room_empty(settings, lobby_service):
|
||||||
@@ -908,12 +979,16 @@ def test_clear_participant_cache(lobby_service):
|
|||||||
"username": "test-username",
|
"username": "test-username",
|
||||||
"id": participant_id,
|
"id": participant_id,
|
||||||
"color": "#123456",
|
"color": "#123456",
|
||||||
|
"entered_at": "2025-01-01T10:00:00+00:00",
|
||||||
}
|
}
|
||||||
cache.set(cache_key, participant_data, timeout=settings.LOBBY_WAITING_TIMEOUT)
|
cache.set(cache_key, participant_data, timeout=settings.LOBBY_WAITING_TIMEOUT)
|
||||||
|
lobby_service._index_add(room_id, participant_id)
|
||||||
assert cache.get(cache_key) is not None
|
assert cache.get(cache_key) is not None
|
||||||
|
assert participant_id in lobby_service._index_members(room_id)
|
||||||
|
|
||||||
lobby_service.clear_participant_cache(room_id, participant_id)
|
lobby_service.clear_participant_cache(room_id, participant_id)
|
||||||
assert cache.get(cache_key) is None
|
assert cache.get(cache_key) is None
|
||||||
|
assert participant_id not in lobby_service._index_members(room_id)
|
||||||
|
|
||||||
|
|
||||||
def test_clear_participant_cache_nonexistent(lobby_service):
|
def test_clear_participant_cache_nonexistent(lobby_service):
|
||||||
@@ -927,3 +1002,85 @@ def test_clear_participant_cache_nonexistent(lobby_service):
|
|||||||
lobby_service.clear_participant_cache(room_id, participant_id)
|
lobby_service.clear_participant_cache(room_id, participant_id)
|
||||||
|
|
||||||
assert cache.get(cache_key) is None
|
assert cache.get(cache_key) is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_index_add_members_remove_roundtrip(lobby_service):
|
||||||
|
"""The room index records, lists and forgets participant ids."""
|
||||||
|
room_id = uuid.uuid4()
|
||||||
|
|
||||||
|
assert lobby_service._index_members(room_id) == frozenset()
|
||||||
|
|
||||||
|
lobby_service._index_add(room_id, "participant1")
|
||||||
|
lobby_service._index_add(room_id, "participant2")
|
||||||
|
|
||||||
|
assert sorted(lobby_service._index_members(room_id)) == [
|
||||||
|
"participant1",
|
||||||
|
"participant2",
|
||||||
|
]
|
||||||
|
|
||||||
|
# The index carries a backstop TTL so abandoned rooms cannot leak it.
|
||||||
|
ttl = lobby_service._redis().ttl(lobby_service._get_index_key(room_id))
|
||||||
|
assert 0 < ttl <= settings.LOBBY_ACCEPTED_TIMEOUT
|
||||||
|
|
||||||
|
lobby_service._index_remove(room_id, "participant1")
|
||||||
|
assert lobby_service._index_members(room_id) == frozenset(["participant2"])
|
||||||
|
|
||||||
|
|
||||||
|
@mock.patch("core.utils.notify_participants")
|
||||||
|
def test_enter_registers_participant_in_room_index(
|
||||||
|
mock_notify, lobby_service, participant_id, username
|
||||||
|
):
|
||||||
|
"""Entering the lobby must index the participant id for the room."""
|
||||||
|
room_id = uuid.uuid4()
|
||||||
|
|
||||||
|
lobby_service.enter(room_id, participant_id, username)
|
||||||
|
|
||||||
|
assert lobby_service._index_members(room_id) == frozenset([participant_id])
|
||||||
|
|
||||||
|
|
||||||
|
def test_list_waiting_participants_prunes_stale_index_ids(settings, lobby_service):
|
||||||
|
"""Indexed ids whose cache entry expired are pruned and not listed."""
|
||||||
|
settings.LOBBY_KEY_PREFIX = "test-lobby-prune"
|
||||||
|
room_id = uuid.uuid4()
|
||||||
|
|
||||||
|
cache.set(
|
||||||
|
f"test-lobby-prune_{room_id!s}_participant1",
|
||||||
|
{
|
||||||
|
"id": "participant1",
|
||||||
|
"username": "user1",
|
||||||
|
"status": "waiting",
|
||||||
|
"color": "#123456",
|
||||||
|
"entered_at": "2025-01-01T10:00:00+00:00",
|
||||||
|
},
|
||||||
|
timeout=100,
|
||||||
|
)
|
||||||
|
lobby_service._index_add(room_id, "participant1")
|
||||||
|
# participant2 is indexed but its cache entry has expired.
|
||||||
|
lobby_service._index_add(room_id, "participant2")
|
||||||
|
|
||||||
|
result = lobby_service.list_waiting_participants(room_id)
|
||||||
|
|
||||||
|
assert [participant["id"] for participant in result] == ["participant1"]
|
||||||
|
assert lobby_service._index_members(room_id) == frozenset(["participant1"])
|
||||||
|
|
||||||
|
|
||||||
|
def test_refresh_waiting_status_rearms_room_index_ttl(lobby_service, participant_id):
|
||||||
|
"""A lone waiter's polling must keep the room index alive.
|
||||||
|
|
||||||
|
Regression test: the index backstop TTL is only armed at enter() time,
|
||||||
|
so a participant whose rolling WAITING refreshes outlast it would keep
|
||||||
|
their entry alive while silently vanishing from the moderator list.
|
||||||
|
Refreshing the waiting status must therefore re-arm the index TTL.
|
||||||
|
"""
|
||||||
|
room_id = uuid.uuid4()
|
||||||
|
lobby_service._index_add(room_id, participant_id)
|
||||||
|
|
||||||
|
index_key = lobby_service._get_index_key(room_id)
|
||||||
|
redis_client = lobby_service._redis()
|
||||||
|
redis_client.expire(index_key, 10)
|
||||||
|
assert redis_client.ttl(index_key) <= 10
|
||||||
|
|
||||||
|
lobby_service.refresh_waiting_status(room_id, participant_id)
|
||||||
|
|
||||||
|
assert redis_client.ttl(index_key) > 10
|
||||||
|
assert lobby_service._index_members(room_id) == frozenset([participant_id])
|
||||||
|
|||||||
@@ -90,19 +90,18 @@ def test_presence_clear_and_clear_room():
|
|||||||
assert presence.is_marked_present(other_room, "a") is True
|
assert presence.is_marked_present(other_room, "a") is True
|
||||||
|
|
||||||
|
|
||||||
def test_presence_clear_room_scans_in_pages():
|
def test_presence_clear_room_removes_many_entries_and_the_index():
|
||||||
"""clear_room removes every match, even across several SCAN pages,
|
"""clear_room removes every entry of the room through the index — never
|
||||||
and only within the room."""
|
a keyspace scan — and leaves other rooms untouched."""
|
||||||
room_id, other_room = str(uuid4()), str(uuid4())
|
room_id, other_room = str(uuid4()), str(uuid4())
|
||||||
presence = PresenceCache()
|
presence = PresenceCache()
|
||||||
for i in range(7):
|
for i in range(7):
|
||||||
presence.mark_present(room_id, f"user-{i}")
|
presence.mark_present(room_id, f"user-{i}")
|
||||||
presence.mark_present(other_room, "user-0")
|
presence.mark_present(other_room, "user-0")
|
||||||
|
|
||||||
# An itersize smaller than the match count forces delete_pattern to
|
presence.clear_room(room_id)
|
||||||
# page through several SCAN cursors rather than finish in one pass.
|
|
||||||
with mock.patch("core.utils.CACHE_SCAN_ITERSIZE", 3):
|
|
||||||
presence.clear_room(room_id)
|
|
||||||
|
|
||||||
assert all(not presence.is_marked_present(room_id, f"user-{i}") for i in range(7))
|
assert all(not presence.is_marked_present(room_id, f"user-{i}") for i in range(7))
|
||||||
assert presence.is_marked_present(other_room, "user-0") is True
|
assert presence.is_marked_present(other_room, "user-0") is True
|
||||||
|
assert presence._index_members(room_id) == frozenset([])
|
||||||
|
assert presence._index_members(other_room) == frozenset(["user-0"])
|
||||||
|
|||||||
@@ -127,6 +127,7 @@ def test_api_users_retrieve_me_authenticated(settings):
|
|||||||
"short_name": user.short_name,
|
"short_name": user.short_name,
|
||||||
"language": user.language,
|
"language": user.language,
|
||||||
"timezone": "UTC",
|
"timezone": "UTC",
|
||||||
|
"default_encryption_mode": "none",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,78 @@
|
|||||||
|
"""Tests for the Dockerflow health endpoints backing the Kubernetes probes."""
|
||||||
|
|
||||||
|
from unittest import mock
|
||||||
|
|
||||||
|
from django.core import checks
|
||||||
|
from django.test.utils import override_settings
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from dockerflow.django.views import django_check_registry
|
||||||
|
|
||||||
|
pytestmark = pytest.mark.django_db
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("path", ["/__lbheartbeat__", "/__heartbeat__"])
|
||||||
|
def test_dockerflow_endpoints_are_anonymous(client, path):
|
||||||
|
"""Both endpoints answer without authentication."""
|
||||||
|
response = client.get(path)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
|
||||||
|
|
||||||
|
def test_dockerflow_lbheartbeat_is_a_liveness_signal(client, django_assert_num_queries):
|
||||||
|
"""The load balancer heartbeat answers 200 without touching the database."""
|
||||||
|
with django_assert_num_queries(0):
|
||||||
|
response = client.get("/__lbheartbeat__")
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
|
||||||
|
|
||||||
|
def test_dockerflow_lbheartbeat_ignores_allowed_hosts(client):
|
||||||
|
"""The probes are served before ALLOWED_HOSTS is enforced."""
|
||||||
|
response = client.get("/__lbheartbeat__", headers={"host": "1.2.3.4:8000"})
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
|
||||||
|
|
||||||
|
def test_dockerflow_heartbeat_reports_the_configured_checks(client):
|
||||||
|
"""The heartbeat runs the database, migrations and redis checks."""
|
||||||
|
with override_settings(DEBUG=True):
|
||||||
|
response = client.get("/__heartbeat__")
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
|
||||||
|
payload = response.json()
|
||||||
|
assert payload["status"] == "ok"
|
||||||
|
assert payload["details"] == {}
|
||||||
|
assert payload["checks"]["check_database_connected"] == "ok"
|
||||||
|
assert payload["checks"]["check_migrations_applied"] == "ok"
|
||||||
|
|
||||||
|
|
||||||
|
def test_dockerflow_heartbeat_is_a_readiness_signal(client):
|
||||||
|
"""A check reporting an error takes the heartbeat down with a 500."""
|
||||||
|
|
||||||
|
def failing_check(**kwargs):
|
||||||
|
return [checks.Error("Could not connect to database", id="health.E001")]
|
||||||
|
|
||||||
|
with mock.patch.object(
|
||||||
|
django_check_registry, "get_checks", return_value=[failing_check]
|
||||||
|
):
|
||||||
|
response = client.get("/__heartbeat__")
|
||||||
|
|
||||||
|
assert response.status_code == 500
|
||||||
|
assert response.json()["status"] == "error"
|
||||||
|
|
||||||
|
|
||||||
|
def test_dockerflow_heartbeat_tolerates_warnings(client):
|
||||||
|
"""A warning in the check should not affect the probes."""
|
||||||
|
|
||||||
|
def warning_check(**kwargs):
|
||||||
|
return [checks.Warning("Unapplied migration", id="health.W001")]
|
||||||
|
|
||||||
|
with mock.patch.object(
|
||||||
|
django_check_registry, "get_checks", return_value=[warning_check]
|
||||||
|
):
|
||||||
|
response = client.get("/__heartbeat__")
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.json()["status"] == "warning"
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
"""Tests for the external API ResourceServerBackend."""
|
||||||
|
|
||||||
|
from django.core.exceptions import SuspiciousOperation
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
import responses
|
||||||
|
from rest_framework.test import APIClient
|
||||||
|
|
||||||
|
from core.external_api.authentication import ResourceServerBackend
|
||||||
|
from core.factories import UserFactory
|
||||||
|
from core.models import User
|
||||||
|
|
||||||
|
pytestmark = pytest.mark.django_db
|
||||||
|
|
||||||
|
|
||||||
|
def _payload(sub):
|
||||||
|
return {"sub": sub, "active": True, "scope": "lasuite_meet", "client_id": "app"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_resource_server_backend_get_or_create_user_active():
|
||||||
|
"""An existing active user matching the sub should be returned."""
|
||||||
|
|
||||||
|
user = UserFactory()
|
||||||
|
|
||||||
|
result = ResourceServerBackend().get_or_create_user(
|
||||||
|
access_token="token", id_token=None, payload=_payload(user.sub)
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result == user
|
||||||
|
|
||||||
|
|
||||||
|
def test_resource_server_backend_get_or_create_user_inactive():
|
||||||
|
"""An inactive user should be rejected even with a valid token."""
|
||||||
|
|
||||||
|
user = UserFactory(is_active=False)
|
||||||
|
|
||||||
|
with pytest.raises(SuspiciousOperation, match="User account is disabled."):
|
||||||
|
ResourceServerBackend().get_or_create_user(
|
||||||
|
access_token="token", id_token=None, payload=_payload(user.sub)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_resource_server_backend_get_or_create_user_creates(settings):
|
||||||
|
"""An unknown sub should create an active user when OIDC_CREATE_USER is set."""
|
||||||
|
|
||||||
|
settings.OIDC_CREATE_USER = True
|
||||||
|
|
||||||
|
result = ResourceServerBackend().get_or_create_user(
|
||||||
|
access_token="token", id_token=None, payload=_payload("new-sub")
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result.sub == "new-sub"
|
||||||
|
assert result.is_active is True
|
||||||
|
assert User.objects.filter(sub="new-sub").exists()
|
||||||
|
|
||||||
|
|
||||||
|
def test_resource_server_backend_get_or_create_user_no_creation(settings):
|
||||||
|
"""An unknown sub should return None when OIDC_CREATE_USER is unset."""
|
||||||
|
|
||||||
|
settings.OIDC_CREATE_USER = False
|
||||||
|
|
||||||
|
result = ResourceServerBackend().get_or_create_user(
|
||||||
|
access_token="token", id_token=None, payload=_payload("new-sub")
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result is None
|
||||||
|
assert not User.objects.filter(sub="new-sub").exists()
|
||||||
|
|
||||||
|
|
||||||
|
@responses.activate
|
||||||
|
def test_api_rooms_list_resource_server_inactive_user(settings):
|
||||||
|
"""End to end: a valid introspected token for an inactive user should get 401."""
|
||||||
|
|
||||||
|
settings.OIDC_OP_INTROSPECTION_ENDPOINT = "https://oidc.example.com/introspect"
|
||||||
|
settings.OIDC_OP_URL = "https://oidc.example.com"
|
||||||
|
|
||||||
|
user = UserFactory(is_active=False)
|
||||||
|
|
||||||
|
responses.add(
|
||||||
|
responses.POST,
|
||||||
|
"https://oidc.example.com/introspect",
|
||||||
|
json={
|
||||||
|
"iss": "https://oidc.example.com",
|
||||||
|
"active": True,
|
||||||
|
"sub": user.sub,
|
||||||
|
"scope": "openid lasuite_meet rooms:list",
|
||||||
|
"client_id": "app",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
client = APIClient()
|
||||||
|
client.credentials(HTTP_AUTHORIZATION="Bearer rs-token")
|
||||||
|
response = client.get("/external-api/v1.0/rooms/")
|
||||||
|
|
||||||
|
assert response.status_code == 401
|
||||||
|
assert "login failed" in str(response.data).lower()
|
||||||
@@ -157,6 +157,7 @@ def test_models_recording_is_savable_normal():
|
|||||||
@pytest.mark.parametrize(
|
@pytest.mark.parametrize(
|
||||||
"status",
|
"status",
|
||||||
[
|
[
|
||||||
|
RecordingStatusChoices.FAILED,
|
||||||
RecordingStatusChoices.FAILED_TO_STOP,
|
RecordingStatusChoices.FAILED_TO_STOP,
|
||||||
RecordingStatusChoices.FAILED_TO_START,
|
RecordingStatusChoices.FAILED_TO_START,
|
||||||
RecordingStatusChoices.ABORTED,
|
RecordingStatusChoices.ABORTED,
|
||||||
@@ -279,6 +280,7 @@ def test_models_recording_is_saved_false_initiated():
|
|||||||
@pytest.mark.parametrize(
|
@pytest.mark.parametrize(
|
||||||
"status",
|
"status",
|
||||||
[
|
[
|
||||||
|
RecordingStatusChoices.FAILED,
|
||||||
RecordingStatusChoices.FAILED_TO_STOP,
|
RecordingStatusChoices.FAILED_TO_STOP,
|
||||||
RecordingStatusChoices.FAILED_TO_START,
|
RecordingStatusChoices.FAILED_TO_START,
|
||||||
RecordingStatusChoices.ABORTED,
|
RecordingStatusChoices.ABORTED,
|
||||||
|
|||||||
@@ -360,3 +360,35 @@ def test_pin_generation_upper_bound(mock_randbelow, settings):
|
|||||||
|
|
||||||
# Assert called with the right exclusive upper bound, 10^5
|
# Assert called with the right exclusive upper bound, 10^5
|
||||||
mock_randbelow.assert_called_with(100000)
|
mock_randbelow.assert_called_with(100000)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("access_level", [None, *RoomAccessLevel.values])
|
||||||
|
@pytest.mark.parametrize("use_manager", [False, True])
|
||||||
|
def test_models_encrypted_room_creation_is_restricted(access_level, use_manager):
|
||||||
|
"""Both save and manager creation normalize encrypted rooms before validation."""
|
||||||
|
fields = {"name": "Encrypted room", "encryption_mode": "basic"}
|
||||||
|
if access_level is not None:
|
||||||
|
fields["access_level"] = access_level
|
||||||
|
if use_manager:
|
||||||
|
room = Room.objects.create(**fields)
|
||||||
|
else:
|
||||||
|
room = Room(**fields)
|
||||||
|
# A caller may assign the primary key before the first save.
|
||||||
|
room.pk = room.id
|
||||||
|
room.save()
|
||||||
|
room.refresh_from_db()
|
||||||
|
assert room.access_level == RoomAccessLevel.RESTRICTED
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"access_level", [RoomAccessLevel.PUBLIC, RoomAccessLevel.TRUSTED]
|
||||||
|
)
|
||||||
|
def test_models_encrypted_room_access_update_rejected(access_level):
|
||||||
|
"""Existing encrypted rooms reject incompatible access instead of normalizing it."""
|
||||||
|
room = Room.objects.create(name="Encrypted room", encryption_mode="basic")
|
||||||
|
room.access_level = access_level
|
||||||
|
with pytest.raises(ValidationError) as excinfo:
|
||||||
|
room.save()
|
||||||
|
assert "access_level" in excinfo.value.message_dict
|
||||||
|
room.refresh_from_db()
|
||||||
|
assert room.access_level == RoomAccessLevel.RESTRICTED
|
||||||
|
|||||||
@@ -0,0 +1,47 @@
|
|||||||
|
"""Unit tests for the get_release settings helper."""
|
||||||
|
|
||||||
|
import re
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from meet.settings import get_release
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(name="base_dir")
|
||||||
|
def fixture_empty_base_dir(tmp_path, monkeypatch):
|
||||||
|
"""Point get_release at an empty directory."""
|
||||||
|
monkeypatch.setattr("meet.settings.BASE_DIR", str(tmp_path))
|
||||||
|
return tmp_path
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_release_reads_project_pyproject():
|
||||||
|
"""Should return the semantic version of the backend's pyproject.toml."""
|
||||||
|
assert re.fullmatch(r"\d+\.\d+\.\d+", get_release())
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_release_reads_pyproject_version(base_dir):
|
||||||
|
"""Should return the version declared in the [project] table."""
|
||||||
|
(base_dir / "pyproject.toml").write_text(
|
||||||
|
'[project]\nname = "meet"\nversion = "1.2.3"\n', encoding="utf-8"
|
||||||
|
)
|
||||||
|
assert get_release() == "1.2.3"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.usefixtures("base_dir")
|
||||||
|
def test_get_release_missing_pyproject():
|
||||||
|
"""Should fall back to "NA" without a pyproject.toml."""
|
||||||
|
assert get_release() == "NA"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"content",
|
||||||
|
[
|
||||||
|
'[project]\nname = "meet"\n', # no version
|
||||||
|
"[tool.uv]\npackage = true\n", # no [project] table
|
||||||
|
"[project\nversion = ", # malformed TOML
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_get_release_unreadable_version(base_dir, content):
|
||||||
|
"""Should fall back to "NA" without a readable version in pyproject.toml."""
|
||||||
|
(base_dir / "pyproject.toml").write_text(content, encoding="utf-8")
|
||||||
|
assert get_release() == "NA"
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
"""Unit tests for the LIVEKIT_DEFAULT_VIDEO_CODEC setting value."""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from meet.settings import VideoCodecValue
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"raw,expected",
|
||||||
|
[("vp9", "vp9"), ("AV1", "av1"), (" h264 ", "h264")],
|
||||||
|
)
|
||||||
|
def test_video_codec_value_normalizes(raw, expected):
|
||||||
|
"""Whitespace is trimmed and the name is lowercased before it is checked."""
|
||||||
|
# environ=False keeps __new__ from resolving the value, so the instance survives.
|
||||||
|
assert VideoCodecValue(environ=False).to_python(raw) == expected
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("raw", ["vp10", "", "h.264"])
|
||||||
|
def test_video_codec_value_rejects_unsupported(raw):
|
||||||
|
"""A name outside the accepted list stops the settings module loading."""
|
||||||
|
with pytest.raises(ValueError, match="Unsupported video codec"):
|
||||||
|
VideoCodecValue(environ=False).to_python(raw)
|
||||||
@@ -57,21 +57,35 @@ def test_generate_token_authenticated_fallback_user_representation():
|
|||||||
assert claims["name"] == str(user)
|
assert claims["name"] == str(user)
|
||||||
|
|
||||||
|
|
||||||
def test_generate_token_explicit_username_overrides_default():
|
@pytest.mark.parametrize("encryption_mode", ["none", "basic"])
|
||||||
|
def test_generate_token_explicit_username_overrides_default(encryption_mode):
|
||||||
"""An explicitly provided username should take precedence over the full name."""
|
"""An explicitly provided username should take precedence over the full name."""
|
||||||
user = UserFactory(full_name="Jane Doe")
|
user = UserFactory(full_name="Jane Doe")
|
||||||
|
|
||||||
token = generate_token(room="my-room", user=user, username="Custom Name")
|
token = generate_token(
|
||||||
|
room="my-room",
|
||||||
|
user=user,
|
||||||
|
username="Custom Name",
|
||||||
|
encryption_mode=encryption_mode,
|
||||||
|
)
|
||||||
|
|
||||||
claims = decode_token(token)
|
claims = decode_token(token)
|
||||||
assert claims["name"] == "Custom Name"
|
assert claims["name"] == "Custom Name"
|
||||||
|
|
||||||
|
|
||||||
def test_authenticated_username_ignored_when_editing_disabled(settings):
|
@pytest.mark.parametrize("encryption_mode", ["none", "basic"])
|
||||||
|
def test_authenticated_username_ignored_when_editing_disabled(
|
||||||
|
settings, encryption_mode
|
||||||
|
):
|
||||||
"""With editing disabled, an authenticated user's username is ignored."""
|
"""With editing disabled, an authenticated user's username is ignored."""
|
||||||
settings.AUTHENTICATED_PARTICIPANTS_CAN_EDIT_DISPLAY_NAME = False
|
settings.AUTHENTICATED_PARTICIPANTS_CAN_EDIT_DISPLAY_NAME = False
|
||||||
user = UserFactory(full_name="Jane Doe")
|
user = UserFactory(full_name="Jane Doe")
|
||||||
token = generate_token(room="my-room", user=user, username="Custom Name")
|
token = generate_token(
|
||||||
|
room="my-room",
|
||||||
|
user=user,
|
||||||
|
username="Custom Name",
|
||||||
|
encryption_mode=encryption_mode,
|
||||||
|
)
|
||||||
claims = decode_token(token)
|
claims = decode_token(token)
|
||||||
assert claims["name"] == "Jane Doe"
|
assert claims["name"] == "Jane Doe"
|
||||||
|
|
||||||
|
|||||||
@@ -34,6 +34,9 @@ from livekit.api import ( # pylint: disable=E0611
|
|||||||
TwirpError,
|
TwirpError,
|
||||||
VideoGrants,
|
VideoGrants,
|
||||||
)
|
)
|
||||||
|
from livekit.protocol.room import RoomConfiguration # pylint: disable=E0611
|
||||||
|
|
||||||
|
from core.enums import EncryptionMode
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -69,6 +72,7 @@ def generate_token( # noqa: PLR0917
|
|||||||
role: Optional[str] = None,
|
role: Optional[str] = None,
|
||||||
participant_id: Optional[str] = None,
|
participant_id: Optional[str] = None,
|
||||||
ttl: Optional[timedelta] = None,
|
ttl: Optional[timedelta] = None,
|
||||||
|
encryption_mode: str = "none",
|
||||||
) -> str:
|
) -> str:
|
||||||
"""Generate a LiveKit access token for a user in a specific room.
|
"""Generate a LiveKit access token for a user in a specific room.
|
||||||
|
|
||||||
@@ -91,10 +95,8 @@ def generate_token( # noqa: PLR0917
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
is_admin_or_owner = role in ("owner", "administrator")
|
is_admin_or_owner = role in ("owner", "administrator")
|
||||||
if is_admin_or_owner:
|
|
||||||
sources = settings.LIVEKIT_DEFAULT_SOURCES
|
|
||||||
|
|
||||||
if sources is None:
|
if is_admin_or_owner or sources is None:
|
||||||
sources = settings.LIVEKIT_DEFAULT_SOURCES
|
sources = settings.LIVEKIT_DEFAULT_SOURCES
|
||||||
|
|
||||||
video_grants = VideoGrants(
|
video_grants = VideoGrants(
|
||||||
@@ -141,6 +143,14 @@ def generate_token( # noqa: PLR0917
|
|||||||
if ttl is not None:
|
if ttl is not None:
|
||||||
token = token.with_ttl(ttl)
|
token = token.with_ttl(ttl)
|
||||||
|
|
||||||
|
if encryption_mode != EncryptionMode.NONE:
|
||||||
|
token = token.with_room_config(
|
||||||
|
RoomConfiguration(
|
||||||
|
name=room,
|
||||||
|
metadata=json.dumps({"encryption_mode": encryption_mode}),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
return token.to_jwt()
|
return token.to_jwt()
|
||||||
|
|
||||||
|
|
||||||
@@ -152,6 +162,7 @@ def generate_livekit_config( # noqa: PLR0917
|
|||||||
color: Optional[str] = None,
|
color: Optional[str] = None,
|
||||||
configuration: Optional[dict] = None,
|
configuration: Optional[dict] = None,
|
||||||
participant_id: Optional[str] = None,
|
participant_id: Optional[str] = None,
|
||||||
|
encryption_mode: str = "none",
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""Generate LiveKit configuration for room access.
|
"""Generate LiveKit configuration for room access.
|
||||||
|
|
||||||
@@ -184,6 +195,7 @@ def generate_livekit_config( # noqa: PLR0917
|
|||||||
sources=sources,
|
sources=sources,
|
||||||
role=role,
|
role=role,
|
||||||
participant_id=participant_id,
|
participant_id=participant_id,
|
||||||
|
encryption_mode=encryption_mode,
|
||||||
),
|
),
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -499,6 +511,3 @@ def build_telephony_config():
|
|||||||
"default_country": country,
|
"default_country": country,
|
||||||
"international_phone_number": international,
|
"international_phone_number": international,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
CACHE_SCAN_ITERSIZE = 500
|
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ https://docs.djangoproject.com/en/3.1/ref/settings/
|
|||||||
|
|
||||||
# pylint: disable=too-many-lines
|
# pylint: disable=too-many-lines
|
||||||
|
|
||||||
import json
|
import tomllib
|
||||||
import warnings
|
import warnings
|
||||||
from os import path
|
from os import path
|
||||||
from socket import gethostbyname, gethostname
|
from socket import gethostbyname, gethostname
|
||||||
@@ -37,22 +37,34 @@ GB = 1024 * MB
|
|||||||
def get_release():
|
def get_release():
|
||||||
"""
|
"""
|
||||||
Get the current release of the application
|
Get the current release of the application
|
||||||
|
|
||||||
By release, we mean the release from the version.json file à la Mozilla [1]
|
|
||||||
(if any). If this file has not been found, it defaults to "NA".
|
|
||||||
|
|
||||||
[1]
|
|
||||||
https://github.com/mozilla-services/Dockerflow/blob/master/docs/version_object.md
|
|
||||||
"""
|
"""
|
||||||
# Try to get the current release from the version.json file generated by the
|
|
||||||
# CI during the Docker image build
|
|
||||||
try:
|
try:
|
||||||
with open(path.join(BASE_DIR, "version.json"), encoding="utf8") as version:
|
with open(path.join(BASE_DIR, "pyproject.toml"), "rb") as pyproject:
|
||||||
return json.load(version)["version"]
|
return tomllib.load(pyproject)["project"]["version"]
|
||||||
except FileNotFoundError:
|
except (FileNotFoundError, KeyError, tomllib.TOMLDecodeError):
|
||||||
return "NA" # Default: not available
|
return "NA" # Default: not available
|
||||||
|
|
||||||
|
|
||||||
|
class VideoCodecValue(values.Value):
|
||||||
|
"""
|
||||||
|
A video codec name, normalized to lowercase and validated against the codecs
|
||||||
|
supported by the LiveKit client, so that a typo fails at startup instead of
|
||||||
|
silently downgrading every publisher to another codec.
|
||||||
|
"""
|
||||||
|
|
||||||
|
codecs = frozenset(("vp8", "h264", "vp9", "av1"))
|
||||||
|
|
||||||
|
def to_python(self, value):
|
||||||
|
"""Normalize the codec name and ensure it is a supported one."""
|
||||||
|
codec = super().to_python(value).strip().lower()
|
||||||
|
if codec not in self.codecs:
|
||||||
|
raise ValueError(
|
||||||
|
f"Unsupported video codec {value!r}, "
|
||||||
|
f"expected one of: {', '.join(sorted(self.codecs))}."
|
||||||
|
)
|
||||||
|
return codec
|
||||||
|
|
||||||
|
|
||||||
class Base(Configuration):
|
class Base(Configuration):
|
||||||
"""
|
"""
|
||||||
This is the base configuration every configuration (aka environment) should inherit from. It
|
This is the base configuration every configuration (aka environment) should inherit from. It
|
||||||
@@ -275,6 +287,7 @@ class Base(Configuration):
|
|||||||
|
|
||||||
MIDDLEWARE = [
|
MIDDLEWARE = [
|
||||||
"django.middleware.security.SecurityMiddleware",
|
"django.middleware.security.SecurityMiddleware",
|
||||||
|
"dockerflow.django.middleware.DockerflowMiddleware",
|
||||||
"whitenoise.middleware.WhiteNoiseMiddleware",
|
"whitenoise.middleware.WhiteNoiseMiddleware",
|
||||||
"django.contrib.sessions.middleware.SessionMiddleware",
|
"django.contrib.sessions.middleware.SessionMiddleware",
|
||||||
"django.middleware.locale.LocaleMiddleware",
|
"django.middleware.locale.LocaleMiddleware",
|
||||||
@@ -284,7 +297,6 @@ class Base(Configuration):
|
|||||||
"django.middleware.csrf.CsrfViewMiddleware",
|
"django.middleware.csrf.CsrfViewMiddleware",
|
||||||
"django.contrib.auth.middleware.AuthenticationMiddleware",
|
"django.contrib.auth.middleware.AuthenticationMiddleware",
|
||||||
"django.contrib.messages.middleware.MessageMiddleware",
|
"django.contrib.messages.middleware.MessageMiddleware",
|
||||||
"dockerflow.django.middleware.DockerflowMiddleware",
|
|
||||||
]
|
]
|
||||||
|
|
||||||
AUTHENTICATION_BACKENDS = [
|
AUTHENTICATION_BACKENDS = [
|
||||||
@@ -469,6 +481,9 @@ class Base(Configuration):
|
|||||||
|
|
||||||
# Sentry
|
# Sentry
|
||||||
SENTRY_DSN = values.Value(None, environ_name="SENTRY_DSN")
|
SENTRY_DSN = values.Value(None, environ_name="SENTRY_DSN")
|
||||||
|
SENTRY_TRACES_SAMPLE_RATE = values.FloatValue(
|
||||||
|
0.0, environ_name="SENTRY_TRACES_SAMPLE_RATE", environ_prefix=None
|
||||||
|
)
|
||||||
|
|
||||||
# Easy thumbnails
|
# Easy thumbnails
|
||||||
THUMBNAIL_EXTENSION = "webp"
|
THUMBNAIL_EXTENSION = "webp"
|
||||||
@@ -675,6 +690,9 @@ class Base(Configuration):
|
|||||||
environ_prefix=None,
|
environ_prefix=None,
|
||||||
default=False,
|
default=False,
|
||||||
)
|
)
|
||||||
|
LIVEKIT_DEFAULT_VIDEO_CODEC = VideoCodecValue(
|
||||||
|
"vp9", environ_name="LIVEKIT_DEFAULT_VIDEO_CODEC", environ_prefix=None
|
||||||
|
)
|
||||||
CONNECTION_TEST_ENABLED = values.BooleanValue(
|
CONNECTION_TEST_ENABLED = values.BooleanValue(
|
||||||
environ_name="CONNECTION_TEST_ENABLED",
|
environ_name="CONNECTION_TEST_ENABLED",
|
||||||
environ_prefix=None,
|
environ_prefix=None,
|
||||||
@@ -865,7 +883,7 @@ class Base(Configuration):
|
|||||||
"room_lobby", environ_name="LOBBY_KEY_PREFIX", environ_prefix=None
|
"room_lobby", environ_name="LOBBY_KEY_PREFIX", environ_prefix=None
|
||||||
)
|
)
|
||||||
LOBBY_WAITING_TIMEOUT = values.PositiveIntegerValue(
|
LOBBY_WAITING_TIMEOUT = values.PositiveIntegerValue(
|
||||||
3, environ_name="LOBBY_WAITING_TIMEOUT", environ_prefix=None
|
6, environ_name="LOBBY_WAITING_TIMEOUT", environ_prefix=None
|
||||||
)
|
)
|
||||||
LOBBY_DENIED_TIMEOUT = values.PositiveIntegerValue(
|
LOBBY_DENIED_TIMEOUT = values.PositiveIntegerValue(
|
||||||
5, environ_name="LOBBY_DENIED_TIMEOUT", environ_prefix=None
|
5, environ_name="LOBBY_DENIED_TIMEOUT", environ_prefix=None
|
||||||
@@ -960,6 +978,10 @@ class Base(Configuration):
|
|||||||
environ_prefix=None,
|
environ_prefix=None,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
ENCRYPTION_ENABLED = values.BooleanValue(
|
||||||
|
False, environ_name="ENCRYPTION_ENABLED", environ_prefix=None
|
||||||
|
)
|
||||||
|
|
||||||
# External Applications
|
# External Applications
|
||||||
APPLICATION_ENABLED = values.BooleanValue(
|
APPLICATION_ENABLED = values.BooleanValue(
|
||||||
False, environ_name="APPLICATION_ENABLED", environ_prefix=None
|
False, environ_name="APPLICATION_ENABLED", environ_prefix=None
|
||||||
@@ -1110,6 +1132,12 @@ class Base(Configuration):
|
|||||||
environ_prefix=None,
|
environ_prefix=None,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
LOGGING_SILENCED_401_PATHS = values.ListValue(
|
||||||
|
default=["/api/v1.0/users/me/"],
|
||||||
|
environ_name="LOGGING_SILENCED_401_PATHS",
|
||||||
|
environ_prefix=None,
|
||||||
|
)
|
||||||
|
|
||||||
# Logging
|
# Logging
|
||||||
# We want to make it easy to log to console but by default we log production
|
# We want to make it easy to log to console but by default we log production
|
||||||
# to Sentry and don't want to log to console.
|
# to Sentry and don't want to log to console.
|
||||||
@@ -1122,10 +1150,16 @@ class Base(Configuration):
|
|||||||
"style": "{",
|
"style": "{",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
"filters": {
|
||||||
|
"silence_expected_401": {
|
||||||
|
"()": "core.logging_filters.SilenceExpected401",
|
||||||
|
},
|
||||||
|
},
|
||||||
"handlers": {
|
"handlers": {
|
||||||
"console": {
|
"console": {
|
||||||
"class": "logging.StreamHandler",
|
"class": "logging.StreamHandler",
|
||||||
"formatter": "simple",
|
"formatter": "simple",
|
||||||
|
"filters": ["silence_expected_401"],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
# Override root logger to send it to console
|
# Override root logger to send it to console
|
||||||
@@ -1136,6 +1170,20 @@ class Base(Configuration):
|
|||||||
),
|
),
|
||||||
},
|
},
|
||||||
"loggers": {
|
"loggers": {
|
||||||
|
"request.summary": {
|
||||||
|
"level": values.Value(
|
||||||
|
"WARNING",
|
||||||
|
environ_name="LOGGING_LEVEL_REQUEST_SUMMARY",
|
||||||
|
environ_prefix="",
|
||||||
|
)
|
||||||
|
},
|
||||||
|
"dockerflow": {
|
||||||
|
"level": values.Value(
|
||||||
|
"WARNING",
|
||||||
|
environ_name="LOGGING_LEVEL_DOCKERFLOW",
|
||||||
|
environ_prefix="",
|
||||||
|
)
|
||||||
|
},
|
||||||
"core": {
|
"core": {
|
||||||
"handlers": ["console"],
|
"handlers": ["console"],
|
||||||
"level": values.Value(
|
"level": values.Value(
|
||||||
@@ -1211,7 +1259,14 @@ class Base(Configuration):
|
|||||||
dsn=cls.SENTRY_DSN,
|
dsn=cls.SENTRY_DSN,
|
||||||
environment=cls.__name__.lower(), # build, test, development, production
|
environment=cls.__name__.lower(), # build, test, development, production
|
||||||
release=get_release(),
|
release=get_release(),
|
||||||
integrations=[DjangoIntegration()],
|
traces_sample_rate=cls.SENTRY_TRACES_SAMPLE_RATE,
|
||||||
|
integrations=[
|
||||||
|
DjangoIntegration(
|
||||||
|
transaction_style="url",
|
||||||
|
middleware_spans=True,
|
||||||
|
cache_spans=True,
|
||||||
|
)
|
||||||
|
],
|
||||||
)
|
)
|
||||||
sentry_sdk.set_tag("application", "backend")
|
sentry_sdk.set_tag("application", "backend")
|
||||||
|
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ build-backend = "uv_build"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "meet"
|
name = "meet"
|
||||||
version = "1.30.0"
|
version = "1.31.0"
|
||||||
authors = [{ "name" = "DINUM", "email" = "dev@mail.numerique.gouv.fr" }]
|
authors = [{ "name" = "DINUM", "email" = "dev@mail.numerique.gouv.fr" }]
|
||||||
classifiers = [
|
classifiers = [
|
||||||
"Development Status :: 5 - Production/Stable",
|
"Development Status :: 5 - Production/Stable",
|
||||||
|
|||||||
Generated
+1
-1
@@ -1187,7 +1187,7 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "meet"
|
name = "meet"
|
||||||
version = "1.30.0"
|
version = "1.31.0"
|
||||||
source = { editable = "." }
|
source = { editable = "." }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "aiohttp" },
|
{ name = "aiohttp" },
|
||||||
|
|||||||
+6
-15
@@ -4,12 +4,12 @@ USER node
|
|||||||
|
|
||||||
WORKDIR /home/frontend/
|
WORKDIR /home/frontend/
|
||||||
|
|
||||||
COPY ./src/frontend/package.json ./package.json
|
COPY --chown=node:node ./src/frontend/package.json ./package.json
|
||||||
COPY ./src/frontend/package-lock.json ./package-lock.json
|
COPY --chown=node:node ./src/frontend/package-lock.json ./package-lock.json
|
||||||
|
|
||||||
RUN npm ci
|
RUN npm ci
|
||||||
|
|
||||||
COPY .dockerignore ./.dockerignore
|
COPY --chown=node:node .dockerignore ./.dockerignore
|
||||||
COPY --chown=node:node ./src/frontend/ .
|
COPY --chown=node:node ./src/frontend/ .
|
||||||
|
|
||||||
### ---- Front-end builder image ----
|
### ---- Front-end builder image ----
|
||||||
@@ -42,20 +42,11 @@ ENV VITE_APP_TITLE=${VITE_APP_TITLE}
|
|||||||
RUN npm run build
|
RUN npm run build
|
||||||
|
|
||||||
# ---- Front-end image ----
|
# ---- Front-end image ----
|
||||||
FROM nginxinc/nginx-unprivileged:1.30.3-alpine3.23 AS frontend-production
|
FROM nginxinc/nginx-unprivileged:1.30.4-alpine3.24 AS frontend-production
|
||||||
|
|
||||||
USER root
|
USER root
|
||||||
|
RUN apk upgrade --no-cache libexpat && \
|
||||||
# Security patches for known CVEs
|
apk del curl
|
||||||
RUN apk update && apk upgrade \
|
|
||||||
libcrypto3>=3.5.7-r0 \
|
|
||||||
libssl3>=3.5.7-r0 \
|
|
||||||
musl \
|
|
||||||
musl-utils \
|
|
||||||
zlib>=1.3.2-r0 \
|
|
||||||
libexpat>=2.8.4-r0 \
|
|
||||||
&& apk del curl
|
|
||||||
|
|
||||||
USER nginx
|
USER nginx
|
||||||
|
|
||||||
# Un-privileged user running the application
|
# Un-privileged user running the application
|
||||||
|
|||||||
Generated
+38
-45
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "meet",
|
"name": "meet",
|
||||||
"version": "1.30.0",
|
"version": "1.31.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "meet",
|
"name": "meet",
|
||||||
"version": "1.30.0",
|
"version": "1.31.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@fontsource-variable/atkinson-hyperlegible-next": "5.3.0",
|
"@fontsource-variable/atkinson-hyperlegible-next": "5.3.0",
|
||||||
"@fontsource-variable/lexend": "5.3.0",
|
"@fontsource-variable/lexend": "5.3.0",
|
||||||
@@ -23,18 +23,18 @@
|
|||||||
"@timephy/rnnoise-wasm": "1.0.0",
|
"@timephy/rnnoise-wasm": "1.0.0",
|
||||||
"crisp-sdk-web": "1.1.2",
|
"crisp-sdk-web": "1.1.2",
|
||||||
"hoofd": "1.7.3",
|
"hoofd": "1.7.3",
|
||||||
"humanize-duration": "3.33.2",
|
"humanize-duration": "3.34.1",
|
||||||
"i18next": "26.3.6",
|
"i18next": "26.3.6",
|
||||||
"i18next-browser-languagedetector": "8.2.1",
|
"i18next-browser-languagedetector": "8.2.1",
|
||||||
"i18next-parser": "9.4.0",
|
"i18next-parser": "9.4.0",
|
||||||
"i18next-resources-to-backend": "1.2.3",
|
"i18next-resources-to-backend": "1.2.3",
|
||||||
"livekit-client": "2.21.0",
|
"livekit-client": "2.21.0",
|
||||||
"posthog-js": "1.414.0",
|
"posthog-js": "1.418.10",
|
||||||
"react": "18.3.1",
|
"react": "18.3.1",
|
||||||
"react-aria": "3.50.0",
|
"react-aria": "3.50.0",
|
||||||
"react-aria-components": "1.19.0",
|
"react-aria-components": "1.19.0",
|
||||||
"react-dom": "18.3.1",
|
"react-dom": "18.3.1",
|
||||||
"react-i18next": "17.0.10",
|
"react-i18next": "17.0.12",
|
||||||
"react-stately": "3.48.0",
|
"react-stately": "3.48.0",
|
||||||
"use-sound": "5.0.0",
|
"use-sound": "5.0.0",
|
||||||
"valtio": "2.3.2",
|
"valtio": "2.3.2",
|
||||||
@@ -1720,28 +1720,28 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/@posthog/browser-common": {
|
"node_modules/@posthog/browser-common": {
|
||||||
"version": "0.4.0",
|
"version": "0.5.2",
|
||||||
"resolved": "https://registry.npmjs.org/@posthog/browser-common/-/browser-common-0.4.0.tgz",
|
"resolved": "https://registry.npmjs.org/@posthog/browser-common/-/browser-common-0.5.2.tgz",
|
||||||
"integrity": "sha512-W9DCGVks15docUMPvJ2nd8NS16Gn74bsGWuaeg31beEKFSjdW8wvnQ1ETY6WSql5pYxZb3GdJmEUZVVstKSrBQ==",
|
"integrity": "sha512-8GvfEshFdeKIccuy3kpp6mDBxawQtRamMYRCwzy1r1ixLKQVLAGs3afhOf6r75yp59ZQBi5mtXQgUJ2Jz8eHuw==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@posthog/core": "^1.46.8",
|
"@posthog/core": "^1.48.11",
|
||||||
"@posthog/types": "^1.402.0"
|
"@posthog/types": "^1.405.3"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@posthog/core": {
|
"node_modules/@posthog/core": {
|
||||||
"version": "1.49.1",
|
"version": "1.53.2",
|
||||||
"resolved": "https://registry.npmjs.org/@posthog/core/-/core-1.49.1.tgz",
|
"resolved": "https://registry.npmjs.org/@posthog/core/-/core-1.53.2.tgz",
|
||||||
"integrity": "sha512-jdZh85tG56OXLH881CVwBZyiXCPPaZasfYeWwm9kVUvxC/Rb+lz7wYN9GuqSEmNPJgVnK4v8wS0bCaFc3OmVEA==",
|
"integrity": "sha512-Knx8442G2LyPVIzLvhcBX/TIdeHpZhrYrtZou3Uw8FWfbr9gQtQeOZDCog3MWcDxJ+4vAnMAAOzf5cYpc7Gxyw==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@posthog/types": "^1.407.0"
|
"@posthog/types": "^1.411.1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@posthog/types": {
|
"node_modules/@posthog/types": {
|
||||||
"version": "1.407.1",
|
"version": "1.411.1",
|
||||||
"resolved": "https://registry.npmjs.org/@posthog/types/-/types-1.407.1.tgz",
|
"resolved": "https://registry.npmjs.org/@posthog/types/-/types-1.411.1.tgz",
|
||||||
"integrity": "sha512-WhbkXPC2rgylXqmxHqv70ffI3k+KxyR6s7DBIfr5NvIqHkxp6v0pk31D/jbz0DNVbzwkLjyll2pxr4FNbJiYzg==",
|
"integrity": "sha512-Gd7tnSYctcSXup3naVlAgavvenByRao6rsSYdMgWgP35KE9jFn+rMHNRJeI5LNjJuLzMXHEVcgzAN7xcATefdw==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/@react-aria/overlays": {
|
"node_modules/@react-aria/overlays": {
|
||||||
@@ -6354,11 +6354,12 @@
|
|||||||
"integrity": "sha512-iARIBPgcQrwtEr+tALF+rapJ8qSc+Set2GJQl7xT1MQzWaVkFebdJhR3alVlSiUf5U7nAANKuj3aWpwerocD5w=="
|
"integrity": "sha512-iARIBPgcQrwtEr+tALF+rapJ8qSc+Set2GJQl7xT1MQzWaVkFebdJhR3alVlSiUf5U7nAANKuj3aWpwerocD5w=="
|
||||||
},
|
},
|
||||||
"node_modules/html-parse-stringify": {
|
"node_modules/html-parse-stringify": {
|
||||||
"version": "3.0.1",
|
"version": "4.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/html-parse-stringify/-/html-parse-stringify-3.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/html-parse-stringify/-/html-parse-stringify-4.0.1.tgz",
|
||||||
"integrity": "sha512-KknJ50kTInJ7qIScF3jeaFRpMpE8/lfiTdzf/twXyPBLAGrLRTmkz3AdTnKeh40X8k9L2fdYwEp/42WGXIRGcg==",
|
"integrity": "sha512-0zHsZJrK7S3K2aucXWL6ycoYJ/iNtIcFHC/nYQgFklPtrv5LpJctIiSCroWZWeuoXvuyFdzp6KzjJQ+OT5MfFw==",
|
||||||
"dependencies": {
|
"license": "MIT",
|
||||||
"void-elements": "3.1.0"
|
"funding": {
|
||||||
|
"url": "https://locize.com"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/htmlparser2": {
|
"node_modules/htmlparser2": {
|
||||||
@@ -6401,9 +6402,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/humanize-duration": {
|
"node_modules/humanize-duration": {
|
||||||
"version": "3.33.2",
|
"version": "3.34.1",
|
||||||
"resolved": "https://registry.npmjs.org/humanize-duration/-/humanize-duration-3.33.2.tgz",
|
"resolved": "https://registry.npmjs.org/humanize-duration/-/humanize-duration-3.34.1.tgz",
|
||||||
"integrity": "sha512-K7Ny/ULO1hDm2nnhvAY+SJV1skxFb61fd073SG1IWJl+D44ULrruCuTyjHKjBVVcSuTlnY99DKtgEG39CM5QOQ==",
|
"integrity": "sha512-YIiigjQ+O31rvcyDJPK1ptTZtvSErjBtmHy93VhbwxB/VwMG0FszKs2IB667tFxmOhNapePQQxa24luQRDkKFQ==",
|
||||||
"license": "Unlicense",
|
"license": "Unlicense",
|
||||||
"funding": {
|
"funding": {
|
||||||
"url": "https://github.com/sponsors/EvanHahn"
|
"url": "https://github.com/sponsors/EvanHahn"
|
||||||
@@ -9131,16 +9132,16 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/posthog-js": {
|
"node_modules/posthog-js": {
|
||||||
"version": "1.414.0",
|
"version": "1.418.10",
|
||||||
"resolved": "https://registry.npmjs.org/posthog-js/-/posthog-js-1.414.0.tgz",
|
"resolved": "https://registry.npmjs.org/posthog-js/-/posthog-js-1.418.10.tgz",
|
||||||
"integrity": "sha512-dtZd4asdskr8lNyltAEX6zyn48uO1pO0EMvx6AXJU65PFhu6yn2LPbKtQcyLysjcN57FJPNT9QYL6St5SBJHqw==",
|
"integrity": "sha512-XMvmmnuFoesSPjFo+wvzXkvuzYqIho8CVqxugG1Wt768offFARYNZDd1/xWfm9vk/pJTJ4RhUEHRzLpggmGx9Q==",
|
||||||
"license": "(Apache-2.0 AND MIT)",
|
"license": "(Apache-2.0 AND MIT)",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@posthog/browser-common": "^0.4.0",
|
"@posthog/browser-common": "^0.5.0",
|
||||||
"@posthog/core": "^1.46.9",
|
"@posthog/core": "^1.48.8",
|
||||||
"@posthog/types": "^1.402.2",
|
"@posthog/types": "^1.405.1",
|
||||||
"core-js": "^3.49.0",
|
"core-js": "^3.49.0",
|
||||||
"dompurify": "^3.4.12",
|
"dompurify": "^3.4.13",
|
||||||
"fflate": "^0.4.8",
|
"fflate": "^0.4.8",
|
||||||
"preact": "^10.29.3",
|
"preact": "^10.29.3",
|
||||||
"query-selector-shadow-dom": "^1.0.1",
|
"query-selector-shadow-dom": "^1.0.1",
|
||||||
@@ -9435,13 +9436,13 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/react-i18next": {
|
"node_modules/react-i18next": {
|
||||||
"version": "17.0.10",
|
"version": "17.0.12",
|
||||||
"resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-17.0.10.tgz",
|
"resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-17.0.12.tgz",
|
||||||
"integrity": "sha512-XneHftyYA774MJkkccSkZ5oKrUpCnXIPmxio3wemqrVzCRLWiGXOMbIzObrer03fNDEnm8g8R5yYls4HcE+esg==",
|
"integrity": "sha512-lFWPEGkxQ6RhusdUkysFBD58VHfSSzvHBzqMgN0SvfVpdQGfwtNkStTqdy08/sJd7s807qqutgx93fRpD0DJ3Q==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/runtime": "^7.29.2",
|
"@babel/runtime": "^7.29.7",
|
||||||
"html-parse-stringify": "^3.0.1",
|
"html-parse-stringify": "^4.0.1",
|
||||||
"use-sync-external-store": "^1.6.0"
|
"use-sync-external-store": "^1.6.0"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
@@ -11608,14 +11609,6 @@
|
|||||||
"url": "https://github.com/sponsors/jonschlinkert"
|
"url": "https://github.com/sponsors/jonschlinkert"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/void-elements": {
|
|
||||||
"version": "3.1.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/void-elements/-/void-elements-3.1.0.tgz",
|
|
||||||
"integrity": "sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w==",
|
|
||||||
"engines": {
|
|
||||||
"node": ">=0.10.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/walk-sync": {
|
"node_modules/walk-sync": {
|
||||||
"version": "2.2.0",
|
"version": "2.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/walk-sync/-/walk-sync-2.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/walk-sync/-/walk-sync-2.2.0.tgz",
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "meet",
|
"name": "meet",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "1.30.0",
|
"version": "1.31.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "panda codegen && vite",
|
"dev": "panda codegen && vite",
|
||||||
@@ -30,18 +30,18 @@
|
|||||||
"@timephy/rnnoise-wasm": "1.0.0",
|
"@timephy/rnnoise-wasm": "1.0.0",
|
||||||
"crisp-sdk-web": "1.1.2",
|
"crisp-sdk-web": "1.1.2",
|
||||||
"hoofd": "1.7.3",
|
"hoofd": "1.7.3",
|
||||||
"humanize-duration": "3.33.2",
|
"humanize-duration": "3.34.1",
|
||||||
"i18next": "26.3.6",
|
"i18next": "26.3.6",
|
||||||
"i18next-browser-languagedetector": "8.2.1",
|
"i18next-browser-languagedetector": "8.2.1",
|
||||||
"i18next-parser": "9.4.0",
|
"i18next-parser": "9.4.0",
|
||||||
"i18next-resources-to-backend": "1.2.3",
|
"i18next-resources-to-backend": "1.2.3",
|
||||||
"livekit-client": "2.21.0",
|
"livekit-client": "2.21.0",
|
||||||
"posthog-js": "1.414.0",
|
"posthog-js": "1.418.10",
|
||||||
"react": "18.3.1",
|
"react": "18.3.1",
|
||||||
"react-aria": "3.50.0",
|
"react-aria": "3.50.0",
|
||||||
"react-aria-components": "1.19.0",
|
"react-aria-components": "1.19.0",
|
||||||
"react-dom": "18.3.1",
|
"react-dom": "18.3.1",
|
||||||
"react-i18next": "17.0.10",
|
"react-i18next": "17.0.12",
|
||||||
"react-stately": "3.48.0",
|
"react-stately": "3.48.0",
|
||||||
"use-sound": "5.0.0",
|
"use-sound": "5.0.0",
|
||||||
"valtio": "2.3.2",
|
"valtio": "2.3.2",
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { keys } from './queryKeys'
|
|||||||
import { useQuery } from '@tanstack/react-query'
|
import { useQuery } from '@tanstack/react-query'
|
||||||
import { RecordingMode } from '@/features/recording'
|
import { RecordingMode } from '@/features/recording'
|
||||||
import type { ApiAccessLevel } from '@/features/rooms/api/ApiRoom'
|
import type { ApiAccessLevel } from '@/features/rooms/api/ApiRoom'
|
||||||
import type { Track } from 'livekit-client'
|
import type { Track, VideoCodec } from 'livekit-client'
|
||||||
type Source = Track.Source
|
type Source = Track.Source
|
||||||
|
|
||||||
export interface ApiConfig {
|
export interface ApiConfig {
|
||||||
@@ -62,6 +62,7 @@ export interface ApiConfig {
|
|||||||
force_wss_protocol: boolean
|
force_wss_protocol: boolean
|
||||||
enable_firefox_proxy_workaround: boolean
|
enable_firefox_proxy_workaround: boolean
|
||||||
default_sources: Source[]
|
default_sources: Source[]
|
||||||
|
default_video_codec: VideoCodec
|
||||||
}
|
}
|
||||||
transcription_destination?: string
|
transcription_destination?: string
|
||||||
max_participants_for_sound: number
|
max_participants_for_sound: number
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ export const fetchUser = (
|
|||||||
}
|
}
|
||||||
): Promise<ApiUser | false> => {
|
): Promise<ApiUser | false> => {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
fetchApi<ApiUser>('/users/me')
|
fetchApi<ApiUser>('/users/me/')
|
||||||
.then(resolve)
|
.then(resolve)
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
// we assume that a 401 means the user is not logged in
|
// we assume that a 401 means the user is not logged in
|
||||||
|
|||||||
@@ -96,6 +96,10 @@ export const MainNotificationToast = () => {
|
|||||||
case NotificationType.ScreenRecordingStopped:
|
case NotificationType.ScreenRecordingStopped:
|
||||||
case NotificationType.TranscriptionLimitReached:
|
case NotificationType.TranscriptionLimitReached:
|
||||||
case NotificationType.ScreenRecordingLimitReached:
|
case NotificationType.ScreenRecordingLimitReached:
|
||||||
|
case NotificationType.TranscriptionFailed:
|
||||||
|
case NotificationType.ScreenRecordingFailed:
|
||||||
|
case NotificationType.TranscriptionAborted:
|
||||||
|
case NotificationType.ScreenRecordingAborted:
|
||||||
toastQueue.add(
|
toastQueue.add(
|
||||||
{
|
{
|
||||||
participant,
|
participant,
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { Div } from '@/primitives'
|
import { Div } from '@/primitives'
|
||||||
import { ToastProvider } from './components/ToastProvider'
|
import { ToastProvider } from './components/ToastProvider'
|
||||||
|
import { RecordingWaitingForTracksNotification } from './components/RecordingWaitingForTracksNotification'
|
||||||
import { WaitingParticipantNotification } from './components/WaitingParticipantNotification'
|
import { WaitingParticipantNotification } from './components/WaitingParticipantNotification'
|
||||||
|
|
||||||
export const NotificationProvider = ({
|
export const NotificationProvider = ({
|
||||||
@@ -12,5 +13,6 @@ export const NotificationProvider = ({
|
|||||||
<Div position="absolute" bottom={bottom} right={right} zIndex={1000}>
|
<Div position="absolute" bottom={bottom} right={right} zIndex={1000}>
|
||||||
<ToastProvider />
|
<ToastProvider />
|
||||||
<WaitingParticipantNotification />
|
<WaitingParticipantNotification />
|
||||||
|
<RecordingWaitingForTracksNotification />
|
||||||
</Div>
|
</Div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -10,11 +10,15 @@ export enum NotificationType {
|
|||||||
TranscriptionStarted = 'transcriptionStarted',
|
TranscriptionStarted = 'transcriptionStarted',
|
||||||
TranscriptionStopped = 'transcriptionStopped',
|
TranscriptionStopped = 'transcriptionStopped',
|
||||||
TranscriptionLimitReached = 'transcriptionLimitReached',
|
TranscriptionLimitReached = 'transcriptionLimitReached',
|
||||||
|
TranscriptionFailed = 'transcriptionFailed',
|
||||||
|
TranscriptionAborted = 'transcriptionAborted',
|
||||||
TranscriptionRequested = 'transcriptionRequested',
|
TranscriptionRequested = 'transcriptionRequested',
|
||||||
ScreenRecordingStarted = 'screenRecordingStarted',
|
ScreenRecordingStarted = 'screenRecordingStarted',
|
||||||
ScreenRecordingStopped = 'screenRecordingStopped',
|
ScreenRecordingStopped = 'screenRecordingStopped',
|
||||||
ScreenRecordingRequested = 'screenRecordingRequested',
|
ScreenRecordingRequested = 'screenRecordingRequested',
|
||||||
ScreenRecordingLimitReached = 'screenRecordingLimitReached',
|
ScreenRecordingLimitReached = 'screenRecordingLimitReached',
|
||||||
|
ScreenRecordingFailed = 'screenRecordingFailed',
|
||||||
|
ScreenRecordingAborted = 'screenRecordingAborted',
|
||||||
RecordingSaving = 'recordingSaving',
|
RecordingSaving = 'recordingSaving',
|
||||||
PermissionsRemoved = 'permissionsRemoved',
|
PermissionsRemoved = 'permissionsRemoved',
|
||||||
RoleChanged = 'roleChanged',
|
RoleChanged = 'roleChanged',
|
||||||
|
|||||||
+41
@@ -0,0 +1,41 @@
|
|||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import { Text } from '@/primitives'
|
||||||
|
import { css } from '@/styled-system/css'
|
||||||
|
import { HStack } from '@/styled-system/jsx'
|
||||||
|
import { RecordingMode } from '@/features/recording'
|
||||||
|
import { useRecordingWaitingForTracks } from '@/features/recording/hooks/useRecordingWaitingForTracks'
|
||||||
|
import { StyledToastContainer } from './StyledToastContainer'
|
||||||
|
|
||||||
|
export const RecordingWaitingForTracksNotification = () => {
|
||||||
|
const { t } = useTranslation('notifications')
|
||||||
|
const isTranscriptWaiting = useRecordingWaitingForTracks(
|
||||||
|
RecordingMode.Transcript
|
||||||
|
)
|
||||||
|
const isScreenRecordingWaiting = useRecordingWaitingForTracks(
|
||||||
|
RecordingMode.ScreenRecording
|
||||||
|
)
|
||||||
|
|
||||||
|
if (!isTranscriptWaiting && !isScreenRecordingWaiting) return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<StyledToastContainer role="status">
|
||||||
|
<HStack padding={14}>
|
||||||
|
<Text
|
||||||
|
margin={false}
|
||||||
|
className={css({
|
||||||
|
maxWidth: '22rem',
|
||||||
|
wordBreak: 'break-word',
|
||||||
|
overflowWrap: 'break-word',
|
||||||
|
whiteSpace: 'normal',
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
{t(
|
||||||
|
isTranscriptWaiting
|
||||||
|
? 'transcript.waitingForTracks'
|
||||||
|
: 'screenRecording.waitingForTracks'
|
||||||
|
)}
|
||||||
|
</Text>
|
||||||
|
</HStack>
|
||||||
|
</StyledToastContainer>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -22,12 +22,20 @@ export function ToastAnyRecording({ state, ...props }: Readonly<ToastProps>) {
|
|||||||
return 'transcript.stopped'
|
return 'transcript.stopped'
|
||||||
case NotificationType.TranscriptionLimitReached:
|
case NotificationType.TranscriptionLimitReached:
|
||||||
return 'transcript.limitReached'
|
return 'transcript.limitReached'
|
||||||
|
case NotificationType.TranscriptionFailed:
|
||||||
|
return 'transcript.failed'
|
||||||
|
case NotificationType.TranscriptionAborted:
|
||||||
|
return 'transcript.aborted'
|
||||||
case NotificationType.ScreenRecordingStarted:
|
case NotificationType.ScreenRecordingStarted:
|
||||||
return 'screenRecording.started'
|
return 'screenRecording.started'
|
||||||
case NotificationType.ScreenRecordingStopped:
|
case NotificationType.ScreenRecordingStopped:
|
||||||
return 'screenRecording.stopped'
|
return 'screenRecording.stopped'
|
||||||
case NotificationType.ScreenRecordingLimitReached:
|
case NotificationType.ScreenRecordingLimitReached:
|
||||||
return 'screenRecording.limitReached'
|
return 'screenRecording.limitReached'
|
||||||
|
case NotificationType.ScreenRecordingFailed:
|
||||||
|
return 'screenRecording.failed'
|
||||||
|
case NotificationType.ScreenRecordingAborted:
|
||||||
|
return 'screenRecording.aborted'
|
||||||
default:
|
default:
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -58,6 +58,10 @@ const renderToast = (
|
|||||||
case NotificationType.ScreenRecordingStarted:
|
case NotificationType.ScreenRecordingStarted:
|
||||||
case NotificationType.ScreenRecordingStopped:
|
case NotificationType.ScreenRecordingStopped:
|
||||||
case NotificationType.ScreenRecordingLimitReached:
|
case NotificationType.ScreenRecordingLimitReached:
|
||||||
|
case NotificationType.TranscriptionFailed:
|
||||||
|
case NotificationType.ScreenRecordingFailed:
|
||||||
|
case NotificationType.TranscriptionAborted:
|
||||||
|
case NotificationType.ScreenRecordingAborted:
|
||||||
return <ToastAnyRecording key={toast.key} toast={toast} state={state} />
|
return <ToastAnyRecording key={toast.key} toast={toast} state={state} />
|
||||||
|
|
||||||
case NotificationType.TranscriptionRequested:
|
case NotificationType.TranscriptionRequested:
|
||||||
|
|||||||
+9
-2
@@ -34,6 +34,15 @@ export const WaitingParticipantNotification = () => {
|
|||||||
const isParticipantListEmpty = (p?: WaitingParticipant[]) => p?.length == 0
|
const isParticipantListEmpty = (p?: WaitingParticipant[]) => p?.length == 0
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
const previousIds = new Set(prevWaitingParticipant?.map(({ id }) => id))
|
||||||
|
const hasNewWaitingParticipant = waitingParticipants.some(
|
||||||
|
({ id }) => !previousIds.has(id)
|
||||||
|
)
|
||||||
|
|
||||||
|
if (hasNewWaitingParticipant) {
|
||||||
|
triggerNotificationSound(NotificationType.ParticipantWaiting)
|
||||||
|
}
|
||||||
|
|
||||||
// Show notification when the first participant enters the waiting room
|
// Show notification when the first participant enters the waiting room
|
||||||
if (
|
if (
|
||||||
!isParticipantListEmpty(waitingParticipants) &&
|
!isParticipantListEmpty(waitingParticipants) &&
|
||||||
@@ -42,8 +51,6 @@ export const WaitingParticipantNotification = () => {
|
|||||||
) {
|
) {
|
||||||
setShowQuickActionsMessage(true)
|
setShowQuickActionsMessage(true)
|
||||||
|
|
||||||
triggerNotificationSound(NotificationType.ParticipantJoined)
|
|
||||||
|
|
||||||
if (timerRef.current !== null) {
|
if (timerRef.current !== null) {
|
||||||
clearTimeout(timerRef.current)
|
clearTimeout(timerRef.current)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ export const useNotificationSound = () => {
|
|||||||
participantJoined: [0, 1150],
|
participantJoined: [0, 1150],
|
||||||
handRaised: [1400, 180],
|
handRaised: [1400, 180],
|
||||||
messageReceived: [1580, 300],
|
messageReceived: [1580, 300],
|
||||||
waiting: [2039, 710],
|
participantWaiting: [2039, 710],
|
||||||
success: [2740, 1304],
|
success: [2740, 1304],
|
||||||
},
|
},
|
||||||
volume: notificationsSnap.soundNotificationVolume,
|
volume: notificationsSnap.soundNotificationVolume,
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import { Track } from 'livekit-client'
|
|||||||
import { ParticipantPlaceholder } from './ParticipantPlaceholder'
|
import { ParticipantPlaceholder } from './ParticipantPlaceholder'
|
||||||
import { ParticipantTileFocus } from './participantTileFocus/ParticipantTileFocus'
|
import { ParticipantTileFocus } from './participantTileFocus/ParticipantTileFocus'
|
||||||
import { FullScreenShareWarning } from './FullScreenShareWarning'
|
import { FullScreenShareWarning } from './FullScreenShareWarning'
|
||||||
|
import { ScreenShareZoomableVideo } from '@/features/rooms/livekit/components/ScreenShareZoomableVideo'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { getShortcutDescriptorById } from '@/features/shortcuts/catalog'
|
import { getShortcutDescriptorById } from '@/features/shortcuts/catalog'
|
||||||
import { formatShortcutLabel } from '@/features/shortcuts/formatLabels'
|
import { formatShortcutLabel } from '@/features/shortcuts/formatLabels'
|
||||||
@@ -89,6 +90,8 @@ export const ParticipantTile: (
|
|||||||
)
|
)
|
||||||
|
|
||||||
const isScreenShare = trackReference.source != Track.Source.Camera
|
const isScreenShare = trackReference.source != Track.Source.Camera
|
||||||
|
const isRemoteScreenShare =
|
||||||
|
isScreenShare && !trackReference.participant.isLocal
|
||||||
const [hasKeyboardFocus, setHasKeyboardFocus] = React.useState(false)
|
const [hasKeyboardFocus, setHasKeyboardFocus] = React.useState(false)
|
||||||
|
|
||||||
const participantColor = getParticipantColor(trackReference.participant)
|
const participantColor = getParticipantColor(trackReference.participant)
|
||||||
@@ -98,11 +101,23 @@ export const ParticipantTile: (
|
|||||||
})
|
})
|
||||||
const participantName = name || identity || 'Unknown'
|
const participantName = name || identity || 'Unknown'
|
||||||
|
|
||||||
|
// tileRef: fullscreen target, and the node the focus overlay listens on.
|
||||||
|
// setRefs merges it with the forwarded ref on the same node.
|
||||||
|
const tileRef = React.useRef<HTMLDivElement>(null)
|
||||||
|
const setRefs = React.useCallback(
|
||||||
|
(node: HTMLDivElement | null) => {
|
||||||
|
;(tileRef as React.MutableRefObject<HTMLDivElement | null>).current = node
|
||||||
|
if (typeof ref === 'function') ref(node)
|
||||||
|
else if (ref)
|
||||||
|
(ref as React.MutableRefObject<HTMLDivElement | null>).current = node
|
||||||
|
},
|
||||||
|
[ref]
|
||||||
|
)
|
||||||
|
|
||||||
const { t } = useTranslation('rooms', { keyPrefix: 'participantTileFocus' })
|
const { t } = useTranslation('rooms', { keyPrefix: 'participantTileFocus' })
|
||||||
|
|
||||||
const interactiveProps = {
|
const interactiveProps = {
|
||||||
...elementProps,
|
...elementProps,
|
||||||
// Ensure the tile is focusable to expose contextual controls to keyboard users.
|
|
||||||
tabIndex: 0,
|
tabIndex: 0,
|
||||||
'aria-label': t('containerLabel', { name: participantName }),
|
'aria-label': t('containerLabel', { name: participantName }),
|
||||||
onFocus: (event: React.FocusEvent<HTMLDivElement>) => {
|
onFocus: (event: React.FocusEvent<HTMLDivElement>) => {
|
||||||
@@ -120,8 +135,41 @@ export const ParticipantTile: (
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const isVideoTrack =
|
||||||
|
isTrackReference(trackReference) &&
|
||||||
|
trackReference.publication.kind === 'video'
|
||||||
|
|
||||||
|
let trackMedia: React.ReactNode = null
|
||||||
|
if (isVideoTrack) {
|
||||||
|
const videoTrack = (
|
||||||
|
<VideoTrack
|
||||||
|
trackRef={trackReference}
|
||||||
|
onSubscriptionStatusChanged={handleSubscribe}
|
||||||
|
manageSubscription={autoManageSubscription}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
// Zoom toolbar stays out of picture-in-picture: that window has its own
|
||||||
|
// document and the fullscreen API is off. Follow-up PR can restore zoom
|
||||||
|
// there without the dead fullscreen button.
|
||||||
|
trackMedia =
|
||||||
|
isRemoteScreenShare && !disableTileControls ? (
|
||||||
|
<ScreenShareZoomableVideo tileRef={tileRef}>
|
||||||
|
{videoTrack}
|
||||||
|
</ScreenShareZoomableVideo>
|
||||||
|
) : (
|
||||||
|
videoTrack
|
||||||
|
)
|
||||||
|
} else if (isTrackReference(trackReference)) {
|
||||||
|
trackMedia = (
|
||||||
|
<AudioTrack
|
||||||
|
trackRef={trackReference}
|
||||||
|
onSubscriptionStatusChanged={handleSubscribe}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div ref={ref} style={{ position: 'relative' }} {...interactiveProps}>
|
<div ref={setRefs} style={{ position: 'relative' }} {...interactiveProps}>
|
||||||
<TrackRefContextIfNeeded trackRef={trackReference}>
|
<TrackRefContextIfNeeded trackRef={trackReference}>
|
||||||
<ParticipantContextIfNeeded participant={trackReference.participant}>
|
<ParticipantContextIfNeeded participant={trackReference.participant}>
|
||||||
{trackReference.participant.isLocal && (
|
{trackReference.participant.isLocal && (
|
||||||
@@ -129,23 +177,7 @@ export const ParticipantTile: (
|
|||||||
)}
|
)}
|
||||||
{children ?? (
|
{children ?? (
|
||||||
<>
|
<>
|
||||||
{isTrackReference(trackReference) &&
|
{trackMedia}
|
||||||
(trackReference.publication?.kind === 'video' ||
|
|
||||||
trackReference.source === Track.Source.Camera ||
|
|
||||||
trackReference.source === Track.Source.ScreenShare) ? (
|
|
||||||
<VideoTrack
|
|
||||||
trackRef={trackReference}
|
|
||||||
onSubscriptionStatusChanged={handleSubscribe}
|
|
||||||
manageSubscription={autoManageSubscription}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
isTrackReference(trackReference) && (
|
|
||||||
<AudioTrack
|
|
||||||
trackRef={trackReference}
|
|
||||||
onSubscriptionStatusChanged={handleSubscribe}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
)}
|
|
||||||
<div className="lk-participant-placeholder">
|
<div className="lk-participant-placeholder">
|
||||||
<ParticipantPlaceholder
|
<ParticipantPlaceholder
|
||||||
color={participantColor}
|
color={participantColor}
|
||||||
@@ -164,6 +196,7 @@ export const ParticipantTile: (
|
|||||||
{!disableMetadata && !disableTileControls && (
|
{!disableMetadata && !disableTileControls && (
|
||||||
<ParticipantTileFocus
|
<ParticipantTileFocus
|
||||||
trackRef={trackReference}
|
trackRef={trackReference}
|
||||||
|
tileRef={tileRef}
|
||||||
hasKeyboardFocus={hasKeyboardFocus}
|
hasKeyboardFocus={hasKeyboardFocus}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|||||||
+47
-25
@@ -7,38 +7,64 @@ import { useCanMute } from '@/features/rooms/livekit/hooks/useCanMute'
|
|||||||
import { FocusButton } from './FocusButton'
|
import { FocusButton } from './FocusButton'
|
||||||
import { EffectsButton } from './EffectsButton'
|
import { EffectsButton } from './EffectsButton'
|
||||||
import { MuteButton } from './MuteButton'
|
import { MuteButton } from './MuteButton'
|
||||||
import { ZoomButton } from './ZoomButton'
|
|
||||||
|
|
||||||
const MOUSE_IDLE_TIME = 3000
|
const MOUSE_IDLE_TIME = 3000
|
||||||
|
|
||||||
type FadeOverlayProps = {
|
type FadeOverlayProps = {
|
||||||
children: ReactNode
|
children: ReactNode
|
||||||
hasKeyboardFocus: boolean
|
hasKeyboardFocus: boolean
|
||||||
|
tileRef: React.RefObject<HTMLDivElement | null>
|
||||||
}
|
}
|
||||||
|
|
||||||
const FadeOverlay = ({ children, hasKeyboardFocus }: FadeOverlayProps) => {
|
// Pointer-events none so this overlay doesn't block the zoom surface below.
|
||||||
|
// The tile node still gets the mouse events, so we listen on it directly
|
||||||
|
// rather than lifting the state up: this keeps mouse moves from re-rendering
|
||||||
|
// the tile and the video it contains.
|
||||||
|
const FadeOverlay = ({
|
||||||
|
children,
|
||||||
|
hasKeyboardFocus,
|
||||||
|
tileRef,
|
||||||
|
}: FadeOverlayProps) => {
|
||||||
const [active, setActive] = useState(false)
|
const [active, setActive] = useState(false)
|
||||||
const idleTimerRef = useRef<number | null>(null)
|
const idleTimerRef = useRef<number | null>(null)
|
||||||
|
|
||||||
const clearIdleTimer = () => {
|
useEffect(() => {
|
||||||
if (idleTimerRef.current) window.clearTimeout(idleTimerRef.current)
|
const tile = tileRef.current
|
||||||
}
|
if (!tile) return
|
||||||
|
|
||||||
const armIdleTimer = () => {
|
const clearIdleTimer = () => {
|
||||||
clearIdleTimer()
|
if (idleTimerRef.current) window.clearTimeout(idleTimerRef.current)
|
||||||
idleTimerRef.current = window.setTimeout(() => {
|
idleTimerRef.current = null
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleActivity = () => {
|
||||||
|
setActive(true)
|
||||||
|
clearIdleTimer()
|
||||||
|
idleTimerRef.current = window.setTimeout(
|
||||||
|
() => setActive(false),
|
||||||
|
MOUSE_IDLE_TIME
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleLeave = () => {
|
||||||
|
clearIdleTimer()
|
||||||
setActive(false)
|
setActive(false)
|
||||||
}, MOUSE_IDLE_TIME)
|
}
|
||||||
}
|
|
||||||
|
|
||||||
const handleActivity = () => {
|
tile.addEventListener('mouseenter', handleActivity)
|
||||||
setActive(true)
|
tile.addEventListener('mousemove', handleActivity)
|
||||||
armIdleTimer()
|
tile.addEventListener('mouseleave', handleLeave)
|
||||||
}
|
|
||||||
|
|
||||||
useEffect(() => clearIdleTimer, [])
|
return () => {
|
||||||
|
clearIdleTimer()
|
||||||
|
tile.removeEventListener('mouseenter', handleActivity)
|
||||||
|
tile.removeEventListener('mousemove', handleActivity)
|
||||||
|
tile.removeEventListener('mouseleave', handleLeave)
|
||||||
|
}
|
||||||
|
}, [tileRef])
|
||||||
|
|
||||||
const isVisible = hasKeyboardFocus || active
|
const isVisible = hasKeyboardFocus || active
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={css({
|
className={css({
|
||||||
@@ -50,15 +76,10 @@ const FadeOverlay = ({ children, hasKeyboardFocus }: FadeOverlayProps) => {
|
|||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
width: '100%',
|
width: '100%',
|
||||||
height: '100%',
|
height: '100%',
|
||||||
|
pointerEvents: 'none',
|
||||||
})}
|
})}
|
||||||
data-visible={isVisible || undefined}
|
data-visible={isVisible || undefined}
|
||||||
aria-hidden={!isVisible}
|
aria-hidden={!isVisible}
|
||||||
onMouseEnter={handleActivity}
|
|
||||||
onMouseMove={handleActivity}
|
|
||||||
onMouseLeave={() => {
|
|
||||||
clearIdleTimer()
|
|
||||||
setActive(false)
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
{isVisible && children}
|
{isVisible && children}
|
||||||
</div>
|
</div>
|
||||||
@@ -67,9 +88,11 @@ const FadeOverlay = ({ children, hasKeyboardFocus }: FadeOverlayProps) => {
|
|||||||
|
|
||||||
export const ParticipantTileFocus = ({
|
export const ParticipantTileFocus = ({
|
||||||
trackRef,
|
trackRef,
|
||||||
|
tileRef,
|
||||||
hasKeyboardFocus,
|
hasKeyboardFocus,
|
||||||
}: {
|
}: {
|
||||||
trackRef: TrackReferenceOrPlaceholder
|
trackRef: TrackReferenceOrPlaceholder
|
||||||
|
tileRef: React.RefObject<HTMLDivElement | null>
|
||||||
hasKeyboardFocus: boolean
|
hasKeyboardFocus: boolean
|
||||||
}) => {
|
}) => {
|
||||||
const participant = trackRef.participant
|
const participant = trackRef.participant
|
||||||
@@ -78,7 +101,7 @@ export const ParticipantTileFocus = ({
|
|||||||
const canMute = useCanMute(participant)
|
const canMute = useCanMute(participant)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<FadeOverlay hasKeyboardFocus={hasKeyboardFocus}>
|
<FadeOverlay hasKeyboardFocus={hasKeyboardFocus} tileRef={tileRef}>
|
||||||
<div
|
<div
|
||||||
className={css({
|
className={css({
|
||||||
backgroundColor: 'primaryDark.50',
|
backgroundColor: 'primaryDark.50',
|
||||||
@@ -87,6 +110,7 @@ export const ParticipantTileFocus = ({
|
|||||||
display: 'flex',
|
display: 'flex',
|
||||||
opacity: 0.6,
|
opacity: 0.6,
|
||||||
animation: 'overlayIn 200ms linear 300ms backwards',
|
animation: 'overlayIn 200ms linear 300ms backwards',
|
||||||
|
pointerEvents: 'auto',
|
||||||
_hover: {
|
_hover: {
|
||||||
opacity: 0.95,
|
opacity: 0.95,
|
||||||
},
|
},
|
||||||
@@ -94,7 +118,7 @@ export const ParticipantTileFocus = ({
|
|||||||
>
|
>
|
||||||
<HStack gap={0.5} padding={0.5}>
|
<HStack gap={0.5} padding={0.5}>
|
||||||
<FocusButton trackRef={trackRef} />
|
<FocusButton trackRef={trackRef} />
|
||||||
{!isScreenShare ? (
|
{!isScreenShare && (
|
||||||
<>
|
<>
|
||||||
{isLocal ? (
|
{isLocal ? (
|
||||||
<EffectsButton />
|
<EffectsButton />
|
||||||
@@ -102,8 +126,6 @@ export const ParticipantTileFocus = ({
|
|||||||
canMute && <MuteButton participant={participant} />
|
canMute && <MuteButton participant={participant} />
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
) : (
|
|
||||||
!isLocal && <ZoomButton trackRef={trackRef} />
|
|
||||||
)}
|
)}
|
||||||
</HStack>
|
</HStack>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
-32
@@ -1,32 +0,0 @@
|
|||||||
import { TrackReferenceOrPlaceholder } from '@livekit/components-core'
|
|
||||||
import { useTranslation } from 'react-i18next'
|
|
||||||
import { useFullScreen } from '@/features/rooms/livekit/hooks/useFullScreen'
|
|
||||||
import { Button } from '@/primitives'
|
|
||||||
import { RiFullscreenLine } from '@remixicon/react'
|
|
||||||
|
|
||||||
export const ZoomButton = ({
|
|
||||||
trackRef,
|
|
||||||
}: {
|
|
||||||
trackRef: TrackReferenceOrPlaceholder
|
|
||||||
}) => {
|
|
||||||
const { t } = useTranslation('rooms', { keyPrefix: 'participantTileFocus' })
|
|
||||||
const { toggleFullScreen, isFullscreenAvailable } = useFullScreen({
|
|
||||||
trackRef,
|
|
||||||
})
|
|
||||||
|
|
||||||
if (!isFullscreenAvailable) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
variant="primaryTextDark"
|
|
||||||
square
|
|
||||||
tooltip={t('fullScreen')}
|
|
||||||
onPress={() => toggleFullScreen()}
|
|
||||||
>
|
|
||||||
<RiFullscreenLine />
|
|
||||||
</Button>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import { ApiError } from '@/api/ApiError'
|
import { ApiError } from '@/api/ApiError'
|
||||||
import { fetchApi } from '@/api/fetchApi'
|
import { fetchApi } from '@/api/fetchApi'
|
||||||
|
import { captureEvent } from '@/features/analytics/telemetry'
|
||||||
import { useMutation, type UseMutationOptions } from '@tanstack/react-query'
|
import { useMutation, type UseMutationOptions } from '@tanstack/react-query'
|
||||||
|
|
||||||
export interface EnterRoomParams {
|
export interface EnterRoomParams {
|
||||||
@@ -17,13 +18,23 @@ export const enterRoom = async ({
|
|||||||
allowEntry,
|
allowEntry,
|
||||||
participantId,
|
participantId,
|
||||||
}: EnterRoomParams): Promise<EnterRoomResponse> => {
|
}: EnterRoomParams): Promise<EnterRoomResponse> => {
|
||||||
return await fetchApi<EnterRoomResponse>(`/rooms/${roomId}/enter/`, {
|
try {
|
||||||
method: 'POST',
|
return await fetchApi<EnterRoomResponse>(`/rooms/${roomId}/enter/`, {
|
||||||
body: JSON.stringify({
|
method: 'POST',
|
||||||
participant_id: participantId,
|
body: JSON.stringify({
|
||||||
allow_entry: allowEntry,
|
participant_id: participantId,
|
||||||
}),
|
allow_entry: allowEntry,
|
||||||
})
|
}),
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof ApiError && error.statusCode === 404) {
|
||||||
|
captureEvent('lobby_entry_participant_gone', {
|
||||||
|
room_id: roomId,
|
||||||
|
allow_entry: allowEntry,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
throw error
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useEnterRoom(
|
export function useEnterRoom(
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ export type WaitingParticipant = {
|
|||||||
status: string
|
status: string
|
||||||
username: string
|
username: string
|
||||||
color: string
|
color: string
|
||||||
|
entered_at: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export type WaitingParticipantsResponse = {
|
export type WaitingParticipantsResponse = {
|
||||||
|
|||||||
@@ -9,6 +9,14 @@ import {
|
|||||||
} from '../../participants/api/listWaitingParticipants'
|
} from '../../participants/api/listWaitingParticipants'
|
||||||
import { reportError } from '@/features/analytics/telemetry'
|
import { reportError } from '@/features/analytics/telemetry'
|
||||||
|
|
||||||
|
const toTimestamp = (participant: WaitingParticipant): number =>
|
||||||
|
Date.parse(participant.entered_at)
|
||||||
|
|
||||||
|
export const sortWaitingParticipants = (
|
||||||
|
participants: WaitingParticipant[]
|
||||||
|
): WaitingParticipant[] =>
|
||||||
|
[...participants].sort((a, b) => toTimestamp(a) - toTimestamp(b))
|
||||||
|
|
||||||
export const useWaitingParticipants = () => {
|
export const useWaitingParticipants = () => {
|
||||||
const roomData = useRoomData()
|
const roomData = useRoomData()
|
||||||
const roomId = roomData?.id || '' // FIXME - bad practice
|
const roomId = roomData?.id || '' // FIXME - bad practice
|
||||||
@@ -22,7 +30,10 @@ export const useWaitingParticipants = () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const waitingParticipants = useMemo(
|
const waitingParticipants = useMemo(
|
||||||
() => (canManageLobby ? waitingData?.participants || [] : []),
|
() =>
|
||||||
|
canManageLobby
|
||||||
|
? sortWaitingParticipants(waitingData?.participants || [])
|
||||||
|
: [],
|
||||||
[waitingData, canManageLobby]
|
[waitingData, canManageLobby]
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import { useTracks } from '@livekit/components-react'
|
||||||
|
import { Track } from 'livekit-client'
|
||||||
|
import { RecordingMode } from '@/features/recording'
|
||||||
|
import { useRecordingStatuses } from './useRecordingStatuses'
|
||||||
|
|
||||||
|
const STARTING_NOTIFICATION_DELAY = 15_000
|
||||||
|
|
||||||
|
export const useRecordingWaitingForTracks = (mode: RecordingMode) => {
|
||||||
|
const { isStarting } = useRecordingStatuses(mode)
|
||||||
|
const tracks = useTracks(
|
||||||
|
mode === RecordingMode.Transcript ? [Track.Source.Microphone] : undefined,
|
||||||
|
{ onlySubscribed: false }
|
||||||
|
)
|
||||||
|
const [delayElapsed, setDelayElapsed] = useState(false)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setDelayElapsed(false)
|
||||||
|
if (!isStarting) return
|
||||||
|
|
||||||
|
const timeout = window.setTimeout(
|
||||||
|
() => setDelayElapsed(true),
|
||||||
|
STARTING_NOTIFICATION_DELAY
|
||||||
|
)
|
||||||
|
return () => window.clearTimeout(timeout)
|
||||||
|
}, [isStarting, mode])
|
||||||
|
|
||||||
|
return isStarting && delayElapsed && tracks.length === 0
|
||||||
|
}
|
||||||
@@ -9,8 +9,9 @@ export enum RecordingStatus {
|
|||||||
Stopped = 'stopped',
|
Stopped = 'stopped',
|
||||||
Saved = 'saved',
|
Saved = 'saved',
|
||||||
Aborted = 'aborted',
|
Aborted = 'aborted',
|
||||||
FailedToStart = 'failedToStart',
|
Failed = 'failed',
|
||||||
FailedToStop = 'failedToStop',
|
FailedToStart = 'failed_to_start',
|
||||||
|
FailedToStop = 'failed_to_stop',
|
||||||
NotificationSucceed = 'notification_succeeded',
|
NotificationSucceed = 'notification_succeeded',
|
||||||
ExternalProcessSuccessful = 'external_process_successful',
|
ExternalProcessSuccessful = 'external_process_successful',
|
||||||
ExternalProcessFailed = 'external_process_failed',
|
ExternalProcessFailed = 'external_process_failed',
|
||||||
|
|||||||
@@ -107,7 +107,7 @@ export const Conference = ({
|
|||||||
adaptiveStream: true,
|
adaptiveStream: true,
|
||||||
dynacast: true,
|
dynacast: true,
|
||||||
publishDefaults: {
|
publishDefaults: {
|
||||||
videoCodec: 'vp9',
|
videoCodec: apiConfig?.livekit.default_video_codec ?? 'vp9',
|
||||||
},
|
},
|
||||||
videoCaptureDefaults: {
|
videoCaptureDefaults: {
|
||||||
deviceId: userConfig.videoDeviceId ?? undefined,
|
deviceId: userConfig.videoDeviceId ?? undefined,
|
||||||
@@ -129,6 +129,7 @@ export const Conference = ({
|
|||||||
userConfig.videoPublishResolution,
|
userConfig.videoPublishResolution,
|
||||||
userConfig.audioDeviceId,
|
userConfig.audioDeviceId,
|
||||||
userConfig.audioOutputDeviceId,
|
userConfig.audioOutputDeviceId,
|
||||||
|
apiConfig?.livekit.default_video_codec,
|
||||||
])
|
])
|
||||||
|
|
||||||
const room = useMemo(() => new Room(roomOptions), [roomOptions])
|
const room = useMemo(() => new Room(roomOptions), [roomOptions])
|
||||||
|
|||||||
@@ -12,8 +12,8 @@ import { keys } from '@/api/queryKeys'
|
|||||||
import { queryClient } from '@/api/queryClient'
|
import { queryClient } from '@/api/queryClient'
|
||||||
import { ApiError } from '@/api/ApiError'
|
import { ApiError } from '@/api/ApiError'
|
||||||
|
|
||||||
export const POLL_INTERVAL_MS = 1000
|
export const POLL_INTERVAL_MS = 4_000
|
||||||
export const LAZY_POLL_INTERVAL_MS = 10_000
|
export const LAZY_POLL_INTERVAL_MS = 15_000
|
||||||
|
|
||||||
export const LobbyProvider = () => {
|
export const LobbyProvider = () => {
|
||||||
const room = useRoomContext()
|
const room = useRoomContext()
|
||||||
@@ -79,7 +79,7 @@ export const LobbyProvider = () => {
|
|||||||
// 3. Rights regained.
|
// 3. Rights regained.
|
||||||
const prevCanManageLobby = usePrevious(canManageLobby)
|
const prevCanManageLobby = usePrevious(canManageLobby)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!prevCanManageLobby && canManageLobby && isConnected) {
|
if (prevCanManageLobby != canManageLobby && isConnected) {
|
||||||
fetchIfManager()
|
fetchIfManager()
|
||||||
}
|
}
|
||||||
}, [
|
}, [
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ const Card = styled('div', {
|
|||||||
borderRadius: '0.25rem',
|
borderRadius: '0.25rem',
|
||||||
boxShadow: '',
|
boxShadow: '',
|
||||||
width: '100%',
|
width: '100%',
|
||||||
maxWidth: '380px',
|
maxWidth: '410px',
|
||||||
minHeight: '196px',
|
minHeight: '196px',
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
@@ -229,7 +229,7 @@ const ConfirmationMessage = ({ onNext }: { onNext: () => void }) => {
|
|||||||
return (
|
return (
|
||||||
<Card
|
<Card
|
||||||
style={{
|
style={{
|
||||||
maxWidth: '380px',
|
maxWidth: '410px',
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
flexDirection: 'column',
|
flexDirection: 'column',
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import {
|
|||||||
} from '../api/requestEntry'
|
} from '../api/requestEntry'
|
||||||
|
|
||||||
export const WAIT_TIMEOUT_MS = 600000 // 10 minutes
|
export const WAIT_TIMEOUT_MS = 600000 // 10 minutes
|
||||||
export const POLL_INTERVAL_MS = 1000
|
export const POLL_INTERVAL_MS = 3_000
|
||||||
|
|
||||||
export const useLobby = ({
|
export const useLobby = ({
|
||||||
roomId,
|
roomId,
|
||||||
|
|||||||
@@ -71,20 +71,30 @@ export const ConnectionObserver = () => {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isAnalyticsEnabled) return
|
if (!isAnalyticsEnabled) return
|
||||||
|
|
||||||
const handleConnection = () => {
|
const handleConnection = async () => {
|
||||||
// Preserve original connection timestamp across reconnections to measure
|
// Preserve original connection timestamp across reconnections to measure
|
||||||
// total session duration from first connect to final disconnect.
|
// total session duration from first connect to final disconnect.
|
||||||
if (connectionStartTimeRef.current != null) return
|
if (connectionStartTimeRef.current != null) return
|
||||||
connectionStartTimeRef.current = Date.now()
|
connectionStartTimeRef.current = Date.now()
|
||||||
void captureMediaEvent('connection-event', {})
|
const participantSid = room.localParticipant.sid
|
||||||
|
const roomSid = await room.getSid().catch(() => undefined)
|
||||||
|
void captureMediaEvent('connection-event', {
|
||||||
|
livekit_room_sid: roomSid,
|
||||||
|
livekit_participant_sid: participantSid,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleReconnect = () => {
|
const handleReconnect = () => {
|
||||||
captureEvent('reconnect-event')
|
captureEvent('reconnect-event')
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleReconnected = () => {
|
const handleReconnected = async () => {
|
||||||
captureEvent('reconnected-event')
|
const participantSid = room.localParticipant.sid
|
||||||
|
const roomSid = await room.getSid().catch(() => undefined)
|
||||||
|
captureEvent('reconnected-event', {
|
||||||
|
livekit_room_sid: roomSid,
|
||||||
|
livekit_participant_sid: participantSid,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleSignalingConnect = () => {
|
const handleSignalingConnect = () => {
|
||||||
|
|||||||
@@ -0,0 +1,76 @@
|
|||||||
|
import { Button } from '@/primitives'
|
||||||
|
import { RiCollapseDiagonalLine, RiExpandDiagonalLine } from '@remixicon/react'
|
||||||
|
import { memo, useCallback, useEffect, useRef, useState } from 'react'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import { useScreenReaderAnnounce } from '@/hooks/useScreenReaderAnnounce'
|
||||||
|
|
||||||
|
// Keeps the fullscreen state here rather than on the toolbar, so entering or
|
||||||
|
// leaving fullscreen does not re-render the zoom controls.
|
||||||
|
export const ScreenShareFullscreenButton = memo(
|
||||||
|
({
|
||||||
|
containerRef,
|
||||||
|
}: {
|
||||||
|
containerRef: React.RefObject<HTMLDivElement | null>
|
||||||
|
}) => {
|
||||||
|
const { t } = useTranslation('rooms', { keyPrefix: 'screenShareZoom' })
|
||||||
|
const announce = useScreenReaderAnnounce()
|
||||||
|
|
||||||
|
const [isFullscreen, setIsFullscreen] = useState(false)
|
||||||
|
// Tracks whether this tile's container triggered fullscreen (vs another share's).
|
||||||
|
const wasThisTileFullscreen = useRef(false)
|
||||||
|
|
||||||
|
// Covers Esc and browser UI exits, not just this button.
|
||||||
|
// Only this tile's instance announces to avoid duplicates with multiple shares.
|
||||||
|
useEffect(() => {
|
||||||
|
const onChange = () => {
|
||||||
|
const isThisTileFullscreen =
|
||||||
|
document.fullscreenElement === containerRef.current
|
||||||
|
setIsFullscreen(isThisTileFullscreen)
|
||||||
|
|
||||||
|
if (isThisTileFullscreen) {
|
||||||
|
wasThisTileFullscreen.current = true
|
||||||
|
announce(t('fullScreenEntered'), 'assertive')
|
||||||
|
} else if (wasThisTileFullscreen.current) {
|
||||||
|
wasThisTileFullscreen.current = false
|
||||||
|
announce(t('fullScreenExited'), 'assertive')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
document.addEventListener('fullscreenchange', onChange)
|
||||||
|
return () => document.removeEventListener('fullscreenchange', onChange)
|
||||||
|
}, [announce, t, containerRef])
|
||||||
|
|
||||||
|
const toggleFullScreen = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
if (document.fullscreenElement === containerRef.current) {
|
||||||
|
await document.exitFullscreen()
|
||||||
|
} else {
|
||||||
|
// Tile container so zoom controls stay visible in fullscreen.
|
||||||
|
await containerRef.current?.requestFullscreen()
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error toggling fullscreen:', error)
|
||||||
|
}
|
||||||
|
}, [containerRef])
|
||||||
|
|
||||||
|
if (!document.fullscreenEnabled) return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="primaryTextDark"
|
||||||
|
square
|
||||||
|
tooltip={isFullscreen ? t('exitFullScreen') : t('fullScreen')}
|
||||||
|
aria-label={isFullscreen ? t('exitFullScreen') : t('fullScreen')}
|
||||||
|
onPress={toggleFullScreen}
|
||||||
|
>
|
||||||
|
{isFullscreen ? (
|
||||||
|
<RiCollapseDiagonalLine size={20} />
|
||||||
|
) : (
|
||||||
|
<RiExpandDiagonalLine size={20} />
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
ScreenShareFullscreenButton.displayName = 'ScreenShareFullscreenButton'
|
||||||
@@ -0,0 +1,165 @@
|
|||||||
|
import { css } from '@/styled-system/css'
|
||||||
|
import { Button } from '@/primitives'
|
||||||
|
import {
|
||||||
|
RiFullscreenExitLine,
|
||||||
|
RiZoomInLine,
|
||||||
|
RiZoomOutLine,
|
||||||
|
} from '@remixicon/react'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import { Toolbar } from 'react-aria-components'
|
||||||
|
import { useEffect, useRef } from 'react'
|
||||||
|
import { isMacintosh } from '@/utils/livekit'
|
||||||
|
import { srOnly } from '@/styles/a11y'
|
||||||
|
import { ScreenShareFullscreenButton } from './ScreenShareFullscreenButton'
|
||||||
|
|
||||||
|
interface ScreenShareZoomControlsProps {
|
||||||
|
containerRef: React.RefObject<HTMLDivElement | null>
|
||||||
|
isZoomed: boolean
|
||||||
|
zoomPercentage: number
|
||||||
|
canZoomIn: boolean
|
||||||
|
canZoomOut: boolean
|
||||||
|
onZoomIn: () => void
|
||||||
|
onZoomOut: () => void
|
||||||
|
onResetZoom: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ScreenShareZoomControls = ({
|
||||||
|
containerRef,
|
||||||
|
isZoomed,
|
||||||
|
zoomPercentage,
|
||||||
|
canZoomIn,
|
||||||
|
canZoomOut,
|
||||||
|
onZoomIn,
|
||||||
|
onZoomOut,
|
||||||
|
onResetZoom,
|
||||||
|
}: ScreenShareZoomControlsProps) => {
|
||||||
|
const { t } = useTranslation('rooms', { keyPrefix: 'screenShareZoom' })
|
||||||
|
|
||||||
|
const zoomInButtonRef = useRef<HTMLButtonElement>(null)
|
||||||
|
const hadFocusInCollapsibleRef = useRef(false)
|
||||||
|
|
||||||
|
// Back at 100 % the collapsible controls are disabled and hidden, which drops
|
||||||
|
// keyboard focus on the body. Hand it to the zoom in button instead, the only
|
||||||
|
// control of that group still reachable.
|
||||||
|
useEffect(() => {
|
||||||
|
if (isZoomed || !hadFocusInCollapsibleRef.current) return
|
||||||
|
hadFocusInCollapsibleRef.current = false
|
||||||
|
zoomInButtonRef.current?.focus()
|
||||||
|
}, [isZoomed])
|
||||||
|
|
||||||
|
const wheelShortcut = t(isMacintosh() ? 'wheelShortcutMac' : 'wheelShortcut')
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={css({
|
||||||
|
position: 'absolute',
|
||||||
|
bottom: '12px',
|
||||||
|
right: '12px',
|
||||||
|
zIndex: 2,
|
||||||
|
pointerEvents: 'auto',
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<Toolbar
|
||||||
|
aria-label={t('toolbarLabel')}
|
||||||
|
className={css({
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
backgroundColor: 'primaryDark.50',
|
||||||
|
borderRadius: '2rem',
|
||||||
|
padding: '0.5rem',
|
||||||
|
opacity: 0.7,
|
||||||
|
transition: 'opacity 200ms linear',
|
||||||
|
_hover: {
|
||||||
|
opacity: 0.95,
|
||||||
|
},
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<span className={srOnly}>
|
||||||
|
{t(isMacintosh() ? 'wheelShortcutHintMac' : 'wheelShortcutHint')}
|
||||||
|
</span>
|
||||||
|
{/* Animated wrapper: collapses to 0 when not zoomed. padding/margin
|
||||||
|
trick keeps overflow:hidden from clipping focus rings. */}
|
||||||
|
<div
|
||||||
|
className={css({
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
overflow: 'hidden',
|
||||||
|
transition: 'max-width 200ms ease-out, opacity 200ms ease-out',
|
||||||
|
padding: '3px',
|
||||||
|
margin: '-3px',
|
||||||
|
})}
|
||||||
|
style={{
|
||||||
|
maxWidth: isZoomed ? '12rem' : '0',
|
||||||
|
opacity: isZoomed ? 1 : 0,
|
||||||
|
}}
|
||||||
|
aria-hidden={!isZoomed}
|
||||||
|
onFocus={() => {
|
||||||
|
hadFocusInCollapsibleRef.current = true
|
||||||
|
}}
|
||||||
|
onBlur={(e) => {
|
||||||
|
// Disabling a focused button blurs it with no relatedTarget, so the
|
||||||
|
// flag must survive that case for the effect above to rescue focus.
|
||||||
|
if (e.relatedTarget) hadFocusInCollapsibleRef.current = false
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="primaryTextDark"
|
||||||
|
square
|
||||||
|
tooltip={t('fitToWindow')}
|
||||||
|
aria-label={t('fitToWindow')}
|
||||||
|
isDisabled={!isZoomed}
|
||||||
|
onPress={onResetZoom}
|
||||||
|
>
|
||||||
|
<RiFullscreenExitLine size={20} />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="primaryTextDark"
|
||||||
|
square
|
||||||
|
tooltip={t('zoomOutWithShortcut', {
|
||||||
|
shortcut: wheelShortcut,
|
||||||
|
})}
|
||||||
|
aria-label={t('zoomOut')}
|
||||||
|
isDisabled={!isZoomed || !canZoomOut}
|
||||||
|
onPress={onZoomOut}
|
||||||
|
>
|
||||||
|
<RiZoomOutLine size={20} />
|
||||||
|
</Button>
|
||||||
|
{/* Visual only - zoom level is announced via useScreenReaderAnnounce. */}
|
||||||
|
<span
|
||||||
|
aria-hidden="true"
|
||||||
|
className={css({
|
||||||
|
color: 'white',
|
||||||
|
fontSize: '0.8125rem',
|
||||||
|
fontWeight: 500,
|
||||||
|
minWidth: '3.25rem',
|
||||||
|
textAlign: 'center',
|
||||||
|
userSelect: 'none',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
padding: '0 0.25rem',
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
{zoomPercentage} %
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
ref={zoomInButtonRef}
|
||||||
|
size="sm"
|
||||||
|
variant="primaryTextDark"
|
||||||
|
square
|
||||||
|
tooltip={t('zoomInWithShortcut', { shortcut: wheelShortcut })}
|
||||||
|
aria-label={t('zoomIn')}
|
||||||
|
isDisabled={!canZoomIn}
|
||||||
|
onPress={onZoomIn}
|
||||||
|
>
|
||||||
|
<RiZoomInLine size={20} />
|
||||||
|
</Button>
|
||||||
|
<ScreenShareFullscreenButton containerRef={containerRef} />
|
||||||
|
</Toolbar>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
import { css } from '@/styled-system/css'
|
||||||
|
import { useEffect, useRef, type ReactNode } from 'react'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import { useScreenShareZoom } from '../hooks/useScreenShareZoom'
|
||||||
|
import { useScreenReaderAnnounce } from '@/hooks/useScreenReaderAnnounce'
|
||||||
|
import { ScreenShareZoomControls } from './ScreenShareZoomControls'
|
||||||
|
|
||||||
|
interface ScreenShareZoomableVideoProps {
|
||||||
|
tileRef: React.RefObject<HTMLDivElement | null>
|
||||||
|
children: ReactNode
|
||||||
|
}
|
||||||
|
|
||||||
|
// The video comes in as children so that a zoom change, which only re-renders
|
||||||
|
// this wrapper, leaves the video subtree untouched.
|
||||||
|
export const ScreenShareZoomableVideo = ({
|
||||||
|
tileRef,
|
||||||
|
children,
|
||||||
|
}: ScreenShareZoomableVideoProps) => {
|
||||||
|
const zoom = useScreenShareZoom()
|
||||||
|
const { t } = useTranslation('rooms', { keyPrefix: 'screenShareZoom' })
|
||||||
|
const announce = useScreenReaderAnnounce()
|
||||||
|
|
||||||
|
// SR announcement: announce zoom level on change, with a one-time pan hint
|
||||||
|
// on the first zoom above 100 % per session.
|
||||||
|
const prevZoomRef = useRef(zoom.zoomPercentage)
|
||||||
|
const hasAnnouncedPanHint = useRef(false)
|
||||||
|
useEffect(() => {
|
||||||
|
if (prevZoomRef.current === zoom.zoomPercentage) return
|
||||||
|
const wasAtDefault = prevZoomRef.current <= 100
|
||||||
|
prevZoomRef.current = zoom.zoomPercentage
|
||||||
|
|
||||||
|
if (wasAtDefault && zoom.isZoomed && !hasAnnouncedPanHint.current) {
|
||||||
|
hasAnnouncedPanHint.current = true
|
||||||
|
announce(t('panHint', { level: zoom.zoomPercentage }), 'polite')
|
||||||
|
} else {
|
||||||
|
announce(t('currentZoomLevel', { level: zoom.zoomPercentage }), 'polite')
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!zoom.isZoomed) hasAnnouncedPanHint.current = false
|
||||||
|
}, [zoom.zoomPercentage, zoom.isZoomed, announce, t])
|
||||||
|
|
||||||
|
// Attach keyboard listener on the tile container (has tabIndex=0).
|
||||||
|
useEffect(() => {
|
||||||
|
const el = tileRef.current
|
||||||
|
if (!el) return
|
||||||
|
el.addEventListener('keydown', zoom.handleKeyDown)
|
||||||
|
return () => el.removeEventListener('keydown', zoom.handleKeyDown)
|
||||||
|
}, [tileRef, zoom.handleKeyDown])
|
||||||
|
|
||||||
|
// Native wheel listener with { passive: false } so preventDefault works.
|
||||||
|
useEffect(() => {
|
||||||
|
const el = zoom.surfaceElRef.current
|
||||||
|
if (!el) return
|
||||||
|
el.addEventListener('wheel', zoom.handleWheel, { passive: false })
|
||||||
|
return () => el.removeEventListener('wheel', zoom.handleWheel)
|
||||||
|
}, [zoom.handleWheel, zoom.surfaceElRef])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div
|
||||||
|
ref={zoom.surfaceElRef}
|
||||||
|
className={css({
|
||||||
|
width: '100%',
|
||||||
|
height: '100%',
|
||||||
|
overflow: 'hidden',
|
||||||
|
position: 'relative',
|
||||||
|
userSelect: 'none',
|
||||||
|
// Leaves the browser's native pinch-zoom available on touch devices
|
||||||
|
// while still routing single-pointer drags to useMove for panning.
|
||||||
|
touchAction: 'pinch-zoom',
|
||||||
|
})}
|
||||||
|
{...zoom.moveProps}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
ref={zoom.transformElRef}
|
||||||
|
style={{
|
||||||
|
width: '100%',
|
||||||
|
height: '100%',
|
||||||
|
pointerEvents: 'none',
|
||||||
|
transformOrigin: 'center center',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<ScreenShareZoomControls
|
||||||
|
containerRef={tileRef}
|
||||||
|
isZoomed={zoom.isZoomed}
|
||||||
|
zoomPercentage={zoom.zoomPercentage}
|
||||||
|
canZoomIn={zoom.canZoomIn}
|
||||||
|
canZoomOut={zoom.canZoomOut}
|
||||||
|
onZoomIn={zoom.zoomIn}
|
||||||
|
onZoomOut={zoom.zoomOut}
|
||||||
|
onResetZoom={zoom.resetZoom}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
+27
-18
@@ -1,17 +1,16 @@
|
|||||||
import { useEffect } from 'react'
|
import { useEffect } from 'react'
|
||||||
import { useRoomContext } from '@livekit/components-react'
|
import { useRoomContext } from '@livekit/components-react'
|
||||||
import {
|
import {
|
||||||
type RemoteParticipant,
|
type RemoteTrack,
|
||||||
type RemoteTrackPublication,
|
type RemoteTrackPublication,
|
||||||
RoomEvent,
|
RoomEvent,
|
||||||
Track,
|
Track,
|
||||||
VideoQuality,
|
|
||||||
} from 'livekit-client'
|
} from 'livekit-client'
|
||||||
import { useSnapshot } from 'valtio'
|
import { useSnapshot } from 'valtio'
|
||||||
import { userChoicesStore } from '@/stores/userChoices'
|
import { userChoicesStore } from '@/stores/userChoices'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Sets initial video quality for new participants as they join.
|
* Applies the saved reception quality to every remote camera.
|
||||||
* LiveKit doesn't allow handling video quality preferences at the room level.
|
* LiveKit doesn't allow handling video quality preferences at the room level.
|
||||||
*/
|
*/
|
||||||
export const VideoResolutionSubscription = () => {
|
export const VideoResolutionSubscription = () => {
|
||||||
@@ -19,30 +18,40 @@ export const VideoResolutionSubscription = () => {
|
|||||||
const room = useRoomContext()
|
const room = useRoomContext()
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!room) return
|
if (!room || videoSubscribeQuality === undefined) return
|
||||||
|
|
||||||
const handleTrackPublished = (
|
const applyQuality = (publication: RemoteTrackPublication) => {
|
||||||
publication: RemoteTrackPublication,
|
|
||||||
_participant: RemoteParticipant
|
|
||||||
) => {
|
|
||||||
// By default, the maximum quality is set to high
|
|
||||||
if (
|
if (
|
||||||
videoSubscribeQuality === undefined ||
|
publication.kind !== Track.Kind.Video ||
|
||||||
videoSubscribeQuality === VideoQuality.HIGH
|
publication.source === Track.Source.ScreenShare ||
|
||||||
)
|
publication.videoQuality === videoSubscribeQuality
|
||||||
return
|
|
||||||
|
|
||||||
if (
|
|
||||||
publication.kind === Track.Kind.Video &&
|
|
||||||
publication.source !== Track.Source.ScreenShare
|
|
||||||
) {
|
) {
|
||||||
publication.setVideoQuality(videoSubscribeQuality)
|
return
|
||||||
}
|
}
|
||||||
|
publication.setVideoQuality(videoSubscribeQuality)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Cameras we are already receiving: those published before this effect ran,
|
||||||
|
// and all of them again whenever the preference changes mid-call.
|
||||||
|
room.remoteParticipants.forEach((participant) =>
|
||||||
|
participant.videoTrackPublications.forEach(applyQuality)
|
||||||
|
)
|
||||||
|
|
||||||
|
const handleTrackPublished = (publication: RemoteTrackPublication) =>
|
||||||
|
applyQuality(publication)
|
||||||
|
|
||||||
|
// TrackPublished is not raised for cameras that were already sending when we
|
||||||
|
// joined, but it is the earliest point for the ones that start after us.
|
||||||
|
const handleTrackSubscribed = (
|
||||||
|
_track: RemoteTrack,
|
||||||
|
publication: RemoteTrackPublication
|
||||||
|
) => applyQuality(publication)
|
||||||
|
|
||||||
room.on(RoomEvent.TrackPublished, handleTrackPublished)
|
room.on(RoomEvent.TrackPublished, handleTrackPublished)
|
||||||
|
room.on(RoomEvent.TrackSubscribed, handleTrackSubscribed)
|
||||||
return () => {
|
return () => {
|
||||||
room.off(RoomEvent.TrackPublished, handleTrackPublished)
|
room.off(RoomEvent.TrackPublished, handleTrackPublished)
|
||||||
|
room.off(RoomEvent.TrackSubscribed, handleTrackSubscribed)
|
||||||
}
|
}
|
||||||
}, [room, videoSubscribeQuality])
|
}, [room, videoSubscribeQuality])
|
||||||
|
|
||||||
|
|||||||
+6
-6
@@ -2,23 +2,23 @@ import { RiQuestionLine } from '@remixicon/react'
|
|||||||
import { MenuItem } from 'react-aria-components'
|
import { MenuItem } from 'react-aria-components'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { menuRecipe } from '@/primitives/menuRecipe'
|
import { menuRecipe } from '@/primitives/menuRecipe'
|
||||||
import { Crisp } from 'crisp-sdk-web'
|
import {
|
||||||
import { useIsSupportEnabled } from '@/features/support/hooks/useSupport'
|
useIsSupportEnabled,
|
||||||
|
openSupportChat,
|
||||||
|
} from '@/features/support/hooks/useSupport'
|
||||||
|
|
||||||
export const SupportMenuItem = () => {
|
export const SupportMenuItem = () => {
|
||||||
const { t } = useTranslation('rooms', { keyPrefix: 'options.items' })
|
const { t } = useTranslation('rooms', { keyPrefix: 'options.items' })
|
||||||
const isSupportEnabled = useIsSupportEnabled()
|
const isSupportEnabled = useIsSupportEnabled()
|
||||||
|
|
||||||
if (!isSupportEnabled || !Crisp) {
|
if (!isSupportEnabled) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<MenuItem
|
<MenuItem
|
||||||
className={menuRecipe({ icon: true, variant: 'dark' }).item}
|
className={menuRecipe({ icon: true, variant: 'dark' }).item}
|
||||||
onAction={() => {
|
onAction={openSupportChat}
|
||||||
Crisp?.chat.open()
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<RiQuestionLine size={20} />
|
<RiQuestionLine size={20} />
|
||||||
{t('support')}
|
{t('support')}
|
||||||
|
|||||||
@@ -0,0 +1,270 @@
|
|||||||
|
import { useCallback, useRef, useState } from 'react'
|
||||||
|
import { useMove } from 'react-aria'
|
||||||
|
import type { MoveMoveEvent } from '@react-types/shared'
|
||||||
|
import {
|
||||||
|
FULL_PICTURE_RATIO,
|
||||||
|
MAX_ZOOM,
|
||||||
|
MIN_ZOOM,
|
||||||
|
PAN_STEP,
|
||||||
|
WHEEL_ZOOM_SPEED,
|
||||||
|
ZOOM_STEP,
|
||||||
|
type PanOffset,
|
||||||
|
clampPan,
|
||||||
|
clampZoom,
|
||||||
|
getCursorFromZoomState,
|
||||||
|
getCursorPercentsFromWheelEvent,
|
||||||
|
getPanDeltaPercentsFromMove,
|
||||||
|
getPictureRatio,
|
||||||
|
getWheelPanOffset,
|
||||||
|
getZoomTransform,
|
||||||
|
} from '../utils/screenShareZoom'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Manages zoom and pan state for a remote screen share.
|
||||||
|
*
|
||||||
|
* Performance: zoom/pan live in refs and are applied imperatively to the DOM
|
||||||
|
* (via transformElRef / surfaceElRef) so dragging and panning never re-render.
|
||||||
|
* React state only tracks what the toolbar displays, and only updates when
|
||||||
|
* the zoom level actually changes.
|
||||||
|
*
|
||||||
|
* Drag/touch panning is handled by react-aria's useMove (moveProps).
|
||||||
|
* The wheel listener (non-passive) zooms on Ctrl/Cmd+scroll and pans on a
|
||||||
|
* two-finger trackpad scroll once zoomed.
|
||||||
|
* Arrow key panning and +/-/0 zoom are on a keydown listener attached to the
|
||||||
|
* tile container (which has tabIndex=0 and focus).
|
||||||
|
*/
|
||||||
|
export const useScreenShareZoom = () => {
|
||||||
|
const zoomRef = useRef(MIN_ZOOM)
|
||||||
|
const panRef = useRef<PanOffset>({ x: 0, y: 0 })
|
||||||
|
const draggingRef = useRef(false)
|
||||||
|
|
||||||
|
// The consumer binds these to the inner transform div and the outer drag surface.
|
||||||
|
const transformElRef = useRef<HTMLDivElement | null>(null)
|
||||||
|
const surfaceElRef = useRef<HTMLDivElement | null>(null)
|
||||||
|
|
||||||
|
// Mirrors zoomRef for the toolbar. Panning never publishes: the toolbar
|
||||||
|
// shows the zoom level, not the position.
|
||||||
|
const [zoomLevel, setZoomLevel] = useState(MIN_ZOOM)
|
||||||
|
|
||||||
|
const syncToolbar = useCallback(() => setZoomLevel(zoomRef.current), [])
|
||||||
|
|
||||||
|
const applyTransform = useCallback(() => {
|
||||||
|
const el = transformElRef.current
|
||||||
|
if (!el) return
|
||||||
|
el.style.transform = getZoomTransform(zoomRef.current, panRef.current)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// The video is letterboxed inside the surface by object-fit: contain, so the
|
||||||
|
// pan bounds depend on how much of the surface the picture actually covers.
|
||||||
|
// Read live rather than cached: both the tile and the shared resolution can
|
||||||
|
// change at any time.
|
||||||
|
const readPictureRatio = useCallback(() => {
|
||||||
|
const surface = surfaceElRef.current
|
||||||
|
const video = transformElRef.current?.querySelector('video')
|
||||||
|
if (!surface || !video) return FULL_PICTURE_RATIO
|
||||||
|
return getPictureRatio(
|
||||||
|
surface.clientWidth,
|
||||||
|
surface.clientHeight,
|
||||||
|
video.videoWidth,
|
||||||
|
video.videoHeight
|
||||||
|
)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const applyCursor = useCallback(() => {
|
||||||
|
const el = surfaceElRef.current
|
||||||
|
if (!el) return
|
||||||
|
el.style.cursor = getCursorFromZoomState(
|
||||||
|
zoomRef.current,
|
||||||
|
draggingRef.current
|
||||||
|
)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const setZoom = useCallback(
|
||||||
|
(next: number) => {
|
||||||
|
zoomRef.current = next
|
||||||
|
panRef.current =
|
||||||
|
next <= MIN_ZOOM
|
||||||
|
? { x: 0, y: 0 }
|
||||||
|
: clampPan(panRef.current, next, readPictureRatio())
|
||||||
|
applyTransform()
|
||||||
|
applyCursor()
|
||||||
|
syncToolbar()
|
||||||
|
},
|
||||||
|
[applyTransform, applyCursor, syncToolbar, readPictureRatio]
|
||||||
|
)
|
||||||
|
|
||||||
|
const zoomIn = useCallback(
|
||||||
|
() => setZoom(clampZoom(zoomRef.current + ZOOM_STEP)),
|
||||||
|
[setZoom]
|
||||||
|
)
|
||||||
|
|
||||||
|
const zoomOut = useCallback(
|
||||||
|
() => setZoom(clampZoom(zoomRef.current - ZOOM_STEP)),
|
||||||
|
[setZoom]
|
||||||
|
)
|
||||||
|
|
||||||
|
const resetZoom = useCallback(() => setZoom(MIN_ZOOM), [setZoom])
|
||||||
|
|
||||||
|
// Cancels the scale() that multiplies translate(), so the picture follows
|
||||||
|
// the pointer 1:1 and keyboard steps keep the same visual size.
|
||||||
|
const panBy = useCallback(
|
||||||
|
(dx: number, dy: number) => {
|
||||||
|
const zoom = zoomRef.current
|
||||||
|
panRef.current = clampPan(
|
||||||
|
{
|
||||||
|
x: panRef.current.x + dx / zoom,
|
||||||
|
y: panRef.current.y + dy / zoom,
|
||||||
|
},
|
||||||
|
zoom,
|
||||||
|
readPictureRatio()
|
||||||
|
)
|
||||||
|
applyTransform()
|
||||||
|
},
|
||||||
|
[applyTransform, readPictureRatio]
|
||||||
|
)
|
||||||
|
|
||||||
|
// Must be attached with { passive: false } so preventDefault() blocks
|
||||||
|
// the browser's native Ctrl+scroll page zoom. Trackpad pinch arrives
|
||||||
|
// here as a wheel event with ctrl/cmd already set.
|
||||||
|
const handleWheel = useCallback(
|
||||||
|
(e: WheelEvent) => {
|
||||||
|
if (e.ctrlKey || e.metaKey) {
|
||||||
|
e.preventDefault()
|
||||||
|
e.stopPropagation()
|
||||||
|
|
||||||
|
const target = e.currentTarget as HTMLElement
|
||||||
|
const prev = zoomRef.current
|
||||||
|
const delta = -e.deltaY * WHEEL_ZOOM_SPEED
|
||||||
|
const next = clampZoom(prev + delta)
|
||||||
|
|
||||||
|
if (next <= MIN_ZOOM) {
|
||||||
|
zoomRef.current = MIN_ZOOM
|
||||||
|
panRef.current = { x: 0, y: 0 }
|
||||||
|
} else {
|
||||||
|
const { cursorXPercent, cursorYPercent } =
|
||||||
|
getCursorPercentsFromWheelEvent(e, target)
|
||||||
|
zoomRef.current = next
|
||||||
|
panRef.current = getWheelPanOffset({
|
||||||
|
pan: panRef.current,
|
||||||
|
prevZoom: prev,
|
||||||
|
nextZoom: next,
|
||||||
|
cursorXPercent,
|
||||||
|
cursorYPercent,
|
||||||
|
ratio: readPictureRatio(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
applyTransform()
|
||||||
|
applyCursor()
|
||||||
|
syncToolbar()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Two-finger trackpad scroll: pan only once zoomed, otherwise leave
|
||||||
|
// the event alone so the page can still scroll.
|
||||||
|
if (zoomRef.current <= MIN_ZOOM) return
|
||||||
|
|
||||||
|
const el = surfaceElRef.current
|
||||||
|
if (!el) return
|
||||||
|
|
||||||
|
e.preventDefault()
|
||||||
|
e.stopPropagation()
|
||||||
|
|
||||||
|
const { deltaXPercent, deltaYPercent } = getPanDeltaPercentsFromMove(
|
||||||
|
-e.deltaX,
|
||||||
|
-e.deltaY,
|
||||||
|
el
|
||||||
|
)
|
||||||
|
panBy(deltaXPercent, deltaYPercent)
|
||||||
|
},
|
||||||
|
[applyTransform, applyCursor, syncToolbar, readPictureRatio, panBy]
|
||||||
|
)
|
||||||
|
|
||||||
|
// useMove handles mouse drag + touch pan. Keyboard arrows are not handled
|
||||||
|
// here because moveProps is on the zoom surface, while focus is on the tile
|
||||||
|
// container, see handleKeyDown below.
|
||||||
|
const { moveProps } = useMove({
|
||||||
|
onMoveStart() {
|
||||||
|
if (zoomRef.current <= MIN_ZOOM) return
|
||||||
|
draggingRef.current = true
|
||||||
|
applyCursor()
|
||||||
|
},
|
||||||
|
onMove(e: MoveMoveEvent) {
|
||||||
|
if (zoomRef.current <= MIN_ZOOM) return
|
||||||
|
|
||||||
|
const el = surfaceElRef.current
|
||||||
|
if (!el) return
|
||||||
|
|
||||||
|
const { deltaXPercent, deltaYPercent } = getPanDeltaPercentsFromMove(
|
||||||
|
e.deltaX,
|
||||||
|
e.deltaY,
|
||||||
|
el
|
||||||
|
)
|
||||||
|
panBy(deltaXPercent, deltaYPercent)
|
||||||
|
},
|
||||||
|
onMoveEnd() {
|
||||||
|
draggingRef.current = false
|
||||||
|
applyTransform()
|
||||||
|
applyCursor()
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
// Attached to the tile container (not the zoom surface) where keyboard
|
||||||
|
// focus lives. Arrows pan, +/-/0 zoom.
|
||||||
|
const handleKeyDown = useCallback(
|
||||||
|
(e: KeyboardEvent) => {
|
||||||
|
const isZoomed = zoomRef.current > MIN_ZOOM
|
||||||
|
if (!isZoomed && e.key !== '+' && e.key !== '=') return
|
||||||
|
|
||||||
|
if (e.key.startsWith('Arrow') && e.target !== e.currentTarget) return
|
||||||
|
|
||||||
|
switch (e.key) {
|
||||||
|
case 'ArrowLeft':
|
||||||
|
e.preventDefault()
|
||||||
|
panBy(PAN_STEP, 0)
|
||||||
|
break
|
||||||
|
case 'ArrowRight':
|
||||||
|
e.preventDefault()
|
||||||
|
panBy(-PAN_STEP, 0)
|
||||||
|
break
|
||||||
|
case 'ArrowUp':
|
||||||
|
e.preventDefault()
|
||||||
|
panBy(0, PAN_STEP)
|
||||||
|
break
|
||||||
|
case 'ArrowDown':
|
||||||
|
e.preventDefault()
|
||||||
|
panBy(0, -PAN_STEP)
|
||||||
|
break
|
||||||
|
case '+':
|
||||||
|
case '=':
|
||||||
|
e.preventDefault()
|
||||||
|
zoomIn()
|
||||||
|
break
|
||||||
|
case '-':
|
||||||
|
e.preventDefault()
|
||||||
|
zoomOut()
|
||||||
|
break
|
||||||
|
case '0':
|
||||||
|
e.preventDefault()
|
||||||
|
resetZoom()
|
||||||
|
break
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[panBy, zoomIn, zoomOut, resetZoom]
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
zoomPercentage: Math.round(zoomLevel * 100),
|
||||||
|
isZoomed: zoomLevel > MIN_ZOOM,
|
||||||
|
canZoomIn: zoomLevel < MAX_ZOOM,
|
||||||
|
canZoomOut: zoomLevel > MIN_ZOOM,
|
||||||
|
transformElRef,
|
||||||
|
surfaceElRef,
|
||||||
|
moveProps,
|
||||||
|
zoomIn,
|
||||||
|
zoomOut,
|
||||||
|
resetZoom,
|
||||||
|
handleWheel,
|
||||||
|
handleKeyDown,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,128 @@
|
|||||||
|
export const MIN_ZOOM = 1
|
||||||
|
export const MAX_ZOOM = 4
|
||||||
|
export const ZOOM_STEP = 0.1
|
||||||
|
export const WHEEL_ZOOM_SPEED = 0.002
|
||||||
|
export const PAN_STEP = 5
|
||||||
|
|
||||||
|
// Half of a 100 % axis. Geometry, not a tunable: it is both the centre the
|
||||||
|
// cursor offset is measured from and the half extent the pan is clamped
|
||||||
|
// against, so the two stay consistent by construction.
|
||||||
|
export const HALF_EXTENT_PERCENT = 50
|
||||||
|
|
||||||
|
export interface PanOffset {
|
||||||
|
x: number
|
||||||
|
y: number
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fraction of the surface each axis of the picture covers, in [0, 1].
|
||||||
|
export interface PictureRatio {
|
||||||
|
x: number
|
||||||
|
y: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export const FULL_PICTURE_RATIO: PictureRatio = { x: 1, y: 1 }
|
||||||
|
|
||||||
|
export const clampZoom = (value: number) => {
|
||||||
|
return Math.max(MIN_ZOOM, Math.min(MAX_ZOOM, value))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Restrict pan so the picture always covers the view. Pan is a % of the
|
||||||
|
// surface, in which object-fit: contain letterboxes the picture: its half
|
||||||
|
// extent is `ratio * 50` against a view half extent of 50, and scaling by
|
||||||
|
// `zoom` must keep `zoom * (ratio * 50 - |pan|) >= 50`. An axis whose picture
|
||||||
|
// is still smaller than the view is pinned to 0, keeping the bars symmetric.
|
||||||
|
export const clampPan = (
|
||||||
|
pan: PanOffset,
|
||||||
|
zoom: number,
|
||||||
|
ratio: PictureRatio
|
||||||
|
): PanOffset => {
|
||||||
|
const maxPanX = Math.max(0, (ratio.x - 1 / zoom) * HALF_EXTENT_PERCENT)
|
||||||
|
const maxPanY = Math.max(0, (ratio.y - 1 / zoom) * HALF_EXTENT_PERCENT)
|
||||||
|
return {
|
||||||
|
x: Math.max(-maxPanX, Math.min(maxPanX, pan.x)),
|
||||||
|
y: Math.max(-maxPanY, Math.min(maxPanY, pan.y)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Per-axis fraction of the surface covered by an object-fit: contain picture.
|
||||||
|
export const getPictureRatio = (
|
||||||
|
surfaceWidth: number,
|
||||||
|
surfaceHeight: number,
|
||||||
|
videoWidth: number,
|
||||||
|
videoHeight: number
|
||||||
|
): PictureRatio => {
|
||||||
|
if (!surfaceWidth || !surfaceHeight || !videoWidth || !videoHeight) {
|
||||||
|
return FULL_PICTURE_RATIO
|
||||||
|
}
|
||||||
|
const surfaceRatio = surfaceWidth / surfaceHeight
|
||||||
|
const videoRatio = videoWidth / videoHeight
|
||||||
|
return surfaceRatio > videoRatio
|
||||||
|
? { x: videoRatio / surfaceRatio, y: 1 }
|
||||||
|
: { x: 1, y: surfaceRatio / videoRatio }
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getZoomTransform = (zoom: number, pan: PanOffset) => {
|
||||||
|
return `scale(${zoom}) translate(${pan.x}%, ${pan.y}%)`
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getCursorFromZoomState = (zoom: number, dragging: boolean) => {
|
||||||
|
if (zoom <= MIN_ZOOM) return 'default'
|
||||||
|
return dragging ? 'grabbing' : 'grab'
|
||||||
|
}
|
||||||
|
|
||||||
|
// Keep the content point under the cursor anchored while zooming. With
|
||||||
|
// `scale(z) translate(pan%)`, a point at `offset` from the center renders at
|
||||||
|
// `z * (offset + pan)`, so holding it still gives:
|
||||||
|
// pan' = pan + cursor * (1 / zoom' - 1 / zoom).
|
||||||
|
export const getWheelPanOffset = ({
|
||||||
|
pan,
|
||||||
|
prevZoom,
|
||||||
|
nextZoom,
|
||||||
|
cursorXPercent,
|
||||||
|
cursorYPercent,
|
||||||
|
ratio,
|
||||||
|
}: {
|
||||||
|
pan: PanOffset
|
||||||
|
prevZoom: number
|
||||||
|
nextZoom: number
|
||||||
|
cursorXPercent: number
|
||||||
|
cursorYPercent: number
|
||||||
|
ratio: PictureRatio
|
||||||
|
}): PanOffset => {
|
||||||
|
const panShift = 1 / nextZoom - 1 / prevZoom
|
||||||
|
return clampPan(
|
||||||
|
{
|
||||||
|
x: pan.x + cursorXPercent * panShift,
|
||||||
|
y: pan.y + cursorYPercent * panShift,
|
||||||
|
},
|
||||||
|
nextZoom,
|
||||||
|
ratio
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert cursor pixel position to a % offset from the surface center.
|
||||||
|
export const getCursorPercentsFromWheelEvent = (
|
||||||
|
e: WheelEvent,
|
||||||
|
target: HTMLElement
|
||||||
|
) => {
|
||||||
|
const rect = target.getBoundingClientRect()
|
||||||
|
return {
|
||||||
|
cursorXPercent:
|
||||||
|
((e.clientX - rect.left) / rect.width) * 100 - HALF_EXTENT_PERCENT,
|
||||||
|
cursorYPercent:
|
||||||
|
((e.clientY - rect.top) / rect.height) * 100 - HALF_EXTENT_PERCENT,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert useMove pixel deltas to % of the surface dimensions.
|
||||||
|
export const getPanDeltaPercentsFromMove = (
|
||||||
|
deltaX: number,
|
||||||
|
deltaY: number,
|
||||||
|
surface: HTMLElement
|
||||||
|
) => {
|
||||||
|
const rect = surface.getBoundingClientRect()
|
||||||
|
return {
|
||||||
|
deltaXPercent: (deltaX / rect.width) * 100,
|
||||||
|
deltaYPercent: (deltaY / rect.height) * 100,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -24,7 +24,8 @@ const Heading = styled('h1', {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const buttonClass = css({
|
const buttonClass = css({
|
||||||
width: { base: '100%', xsm: 'auto' },
|
width: '100%',
|
||||||
|
flex: 1,
|
||||||
})
|
})
|
||||||
|
|
||||||
enum DisconnectReasonKey {
|
enum DisconnectReasonKey {
|
||||||
@@ -64,8 +65,8 @@ const FeedbackRoute = () => {
|
|||||||
<Heading>{t(`feedback.heading.${reasonKey || 'normal'}`)}</Heading>
|
<Heading>{t(`feedback.heading.${reasonKey || 'normal'}`)}</Heading>
|
||||||
<Stack
|
<Stack
|
||||||
direction={{ base: 'column', xsm: 'row' }}
|
direction={{ base: 'column', xsm: 'row' }}
|
||||||
width={{ base: '100%', xsm: 'auto' }}
|
width="100%"
|
||||||
maxWidth="380px"
|
maxWidth="410px"
|
||||||
>
|
>
|
||||||
{showBackButton && (
|
{showBackButton && (
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ const EMPTY_PROPS = {}
|
|||||||
export const VideoTab = ({ id }: VideoTabProps) => {
|
export const VideoTab = ({ id }: VideoTabProps) => {
|
||||||
const { t } = useTranslation('settings', { keyPrefix: 'video' })
|
const { t } = useTranslation('settings', { keyPrefix: 'video' })
|
||||||
const room = useRoomContext()
|
const room = useRoomContext()
|
||||||
const { localParticipant, remoteParticipants } = room
|
const { localParticipant } = room
|
||||||
|
|
||||||
const {
|
const {
|
||||||
videoDeviceId,
|
videoDeviceId,
|
||||||
@@ -88,22 +88,6 @@ export const VideoTab = ({ id }: VideoTabProps) => {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Updates video quality for all existing remote video tracks when user preference changes.
|
|
||||||
* LiveKit doesn't support setting video quality preferences at the room level for remote participants,
|
|
||||||
* so this function applies the selected quality to all existing remote video tracks.
|
|
||||||
* Hook useVideoResolutionSubscription updates quality preferences of new participants joining.
|
|
||||||
*/
|
|
||||||
const updateExistingRemoteVideoQuality = (selectedQuality: VideoQuality) => {
|
|
||||||
remoteParticipants.forEach((participant) => {
|
|
||||||
participant.videoTrackPublications.forEach((publication) => {
|
|
||||||
if (publication.videoQuality !== selectedQuality) {
|
|
||||||
publication.setVideoQuality(selectedQuality)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let videoTrack: LocalVideoTrack | null = null
|
let videoTrack: LocalVideoTrack | null = null
|
||||||
|
|
||||||
@@ -231,9 +215,7 @@ export const VideoTab = ({ id }: VideoTabProps) => {
|
|||||||
selectedKey={videoSubscribeQuality?.toString()}
|
selectedKey={videoSubscribeQuality?.toString()}
|
||||||
onSelectionChange={(key) => {
|
onSelectionChange={(key) => {
|
||||||
if (key == undefined) return
|
if (key == undefined) return
|
||||||
const selectedQuality = Number(String(key))
|
saveVideoSubscribeQuality(Number(String(key)))
|
||||||
saveVideoSubscribeQuality(selectedQuality)
|
|
||||||
updateExistingRemoteVideoQuality(selectedQuality)
|
|
||||||
}}
|
}}
|
||||||
style={{
|
style={{
|
||||||
width: '100%',
|
width: '100%',
|
||||||
|
|||||||
@@ -16,6 +16,10 @@ export type ShortcutId =
|
|||||||
| 'recording'
|
| 'recording'
|
||||||
| 'reaction'
|
| 'reaction'
|
||||||
| 'fullscreen'
|
| 'fullscreen'
|
||||||
|
| 'zoom-in'
|
||||||
|
| 'zoom-out'
|
||||||
|
| 'zoom-reset'
|
||||||
|
| 'zoom-pan'
|
||||||
|
|
||||||
export const getShortcutDescriptorById = (id: ShortcutId) =>
|
export const getShortcutDescriptorById = (id: ShortcutId) =>
|
||||||
shortcutCatalog.find((item) => item.id === id)
|
shortcutCatalog.find((item) => item.id === id)
|
||||||
@@ -24,7 +28,7 @@ export type ShortcutDescriptor = {
|
|||||||
id: ShortcutId
|
id: ShortcutId
|
||||||
category: ShortcutCategory
|
category: ShortcutCategory
|
||||||
shortcut?: Shortcut
|
shortcut?: Shortcut
|
||||||
kind?: 'press' | 'longPress'
|
kind?: 'press' | 'longPress' | 'arrows'
|
||||||
code?: string // used when kind === 'longPress' (KeyboardEvent.code)
|
code?: string // used when kind === 'longPress' (KeyboardEvent.code)
|
||||||
description?: string
|
description?: string
|
||||||
}
|
}
|
||||||
@@ -86,4 +90,27 @@ export const shortcutCatalog: ShortcutDescriptor[] = [
|
|||||||
category: 'interaction',
|
category: 'interaction',
|
||||||
shortcut: { key: 'P', ctrlKey: true, shiftKey: true },
|
shortcut: { key: 'P', ctrlKey: true, shiftKey: true },
|
||||||
},
|
},
|
||||||
|
// Screen share zoom keys are unmodified, so they are bound on the focused
|
||||||
|
// tile instead of being registered globally. They are listed here so the
|
||||||
|
// shortcuts panel stays exhaustive.
|
||||||
|
{
|
||||||
|
id: 'zoom-in',
|
||||||
|
category: 'interaction',
|
||||||
|
shortcut: { key: '+' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'zoom-out',
|
||||||
|
category: 'interaction',
|
||||||
|
shortcut: { key: '-' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'zoom-reset',
|
||||||
|
category: 'interaction',
|
||||||
|
shortcut: { key: '0' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'zoom-pan',
|
||||||
|
category: 'interaction',
|
||||||
|
kind: 'arrows',
|
||||||
|
},
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ export const formatShortcutLabelForSR = (
|
|||||||
shiftLabel,
|
shiftLabel,
|
||||||
plusLabel,
|
plusLabel,
|
||||||
noShortcutLabel,
|
noShortcutLabel,
|
||||||
|
keyLabels,
|
||||||
}: {
|
}: {
|
||||||
controlLabel: string
|
controlLabel: string
|
||||||
commandLabel: string
|
commandLabel: string
|
||||||
@@ -33,10 +34,12 @@ export const formatShortcutLabelForSR = (
|
|||||||
shiftLabel: string
|
shiftLabel: string
|
||||||
plusLabel: string
|
plusLabel: string
|
||||||
noShortcutLabel: string
|
noShortcutLabel: string
|
||||||
|
// Spelled-out names for keys screen readers may skip or mispronounce.
|
||||||
|
keyLabels?: Record<string, string>
|
||||||
}
|
}
|
||||||
) => {
|
) => {
|
||||||
if (!shortcut) return noShortcutLabel
|
if (!shortcut) return noShortcutLabel
|
||||||
const key = shortcut.key?.toUpperCase()
|
const key = keyLabels?.[shortcut.key] ?? shortcut.key?.toUpperCase()
|
||||||
if (!key) return noShortcutLabel
|
if (!key) return noShortcutLabel
|
||||||
const ctrlWord = isMacintosh() ? commandLabel : controlLabel
|
const ctrlWord = isMacintosh() ? commandLabel : controlLabel
|
||||||
const altWord = isMacintosh() ? optionLabel : altLabel
|
const altWord = isMacintosh() ? optionLabel : altLabel
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user