Compare commits

..

3 Commits

Author SHA1 Message Date
lebaudantoine 3d7f2957f9 wip support experimental calendar api 2026-05-01 18:43:37 +02:00
lebaudantoine d83d3ccc18 insert link in mail 2026-05-01 17:43:18 +02:00
lebaudantoine 52a9a583e8 wip initiate dev environment 2026-05-01 17:12:53 +02:00
106 changed files with 5436 additions and 6109 deletions
+5 -11
View File
@@ -150,14 +150,13 @@ jobs:
uses: actions/setup-python@v6
with:
python-version: "3.13"
- name: Install uv
uses: astral-sh/setup-uv@v7
- name: Install the project
run: uv sync --locked --all-extras
cache: "pip"
- name: Install development dependencies
run: pip install --user .[dev]
- name: Check code formatting with ruff
run: uv run ruff format . --diff
run: ~/.local/bin/ruff format . --diff
- name: Lint code with ruff
run: uv run ruff check .
run: ~/.local/bin/ruff check .
lint-summary:
runs-on: ubuntu-latest
@@ -323,11 +322,6 @@ jobs:
- name: Checkout repository
uses: actions/checkout@v6
- name: Install ffmpeg
run: |
sudo apt-get update
sudo apt-get install -y ffmpeg
- name: Install Python
uses: actions/setup-python@v6
with:
-37
View File
@@ -8,42 +8,6 @@ and this project adheres to
## [Unreleased]
### Added
- ✨(fullstack) allow participants to mute others based on room configuration
- ✨(frontend) add synchronizer for room metadata updates
### Changed
- ♻️(fullstack) simplify source serialization
- ✨(backend) expose room configuration to all API consumers
## [1.16.0] - 2026-05-13
### Added
- 🔒️(backend) add validation of Room.configuration
- ✨(helm) add support multiple transcribe worker / endpoint #1247
- ✨(backend) make LiveKit Egress recording encoding configurable #1288
- ✨(summary) add speaker-to-participant assignment
### Changed
- ♻️(summary) change tasks endpoint signature
- ⬆️(dependencies) update urllib3 to v2.7.0 [SECURITY]
- 🧑‍💻(agents) use `uv` for package management
- ✨(summary) improve speaker-to-participant assignment
### Fixed
- ♻(frontend) standardize role terminology across localizations
- 🐛(backend) make start-recording atomic and fault-tolerant
- 🔒️(frontend) room ids are generated with non-cryptographic rand
- ⬆️(mail) fix dependencies not having resolved or integrity field #1321
- 🐛(summary) complete webm support #1328
- 🐛(backend) add link to "Open" text in recording email
- 🩹(frontend) fix spacing regression in mobile control bar
## [1.15.0] - 2026-04-30
### Added
@@ -81,7 +45,6 @@ and this project adheres to
- ✨(summary) allow more file extensions #1265
- ♿️(frontend) refocus reactions toolbar with ctrl+shift+e is activated #1262
- ♿️(frontend) set an explicit document title on recording download page #1261
- ♿️(frontend) add customizable accessibility fonts #1270
### Fixed
+2 -2
View File
@@ -109,7 +109,7 @@ build-frontend: ## build the frontend container
.PHONY: build-frontend
build-agents: ## build the multi-user-transcriber agent container
@$(COMPOSE) build multi-user-transcriber-dev
@$(COMPOSE) build multi-user-transcriber
.PHONY: build-agents
down: ## stop and remove containers, networks, images, and volumes
@@ -138,7 +138,7 @@ run-agents: ## start the multi-user-transcriber agent
.PHONY: run-agents
run-agent-multi-user-transcriber: ## start the LiveKit agents (multi users transcriber)
@$(COMPOSE) up --force-recreate -d multi-user-transcriber-dev
@$(COMPOSE) up --force-recreate -d multi-user-transcriber
.PHONY: run-agent-multi-user-transcriber
run-agent-metadata-collector: ## start the LiveKit agents (metadata collector)
+1 -1
View File
@@ -109,7 +109,7 @@ k8s_resource('meet-backend', resource_deps=['postgresql', 'minio', 'redis', 'liv
k8s_resource('meet-celery-backend', resource_deps=['redis'])
k8s_resource('meet-celery-summarize', resource_deps=['redis'])
k8s_resource('meet-celery-summary-backend', resource_deps=['redis'])
k8s_resource('meet-celery-transcribe-default', resource_deps=['redis'])
k8s_resource('meet-celery-transcribe', resource_deps=['redis'])
k8s_resource('meet-backend-migrate', resource_deps=['meet-backend'])
k8s_resource('livekit-livekit-server', resource_deps=['redis'])
k8s_resource('livekit-livekit-server-test-connection', resource_deps=['livekit-livekit-server'])
-7
View File
@@ -101,12 +101,6 @@ update_npm_version "mail"
# Update backend pyproject.toml
update_python_version "backend"
# Run uv lock in backend
print_info "Running uv lock in backend..."
cd "src/backend"
uv lock
cd -
# Update summary pyproject.toml
update_python_version "summary"
@@ -155,7 +149,6 @@ echo " - src/frontend/package.json"
echo " - src/sdk/package.json"
echo " - src/mail/package.json"
echo " - src/backend/pyproject.toml"
echo " - src/backend/uv.lock"
echo " - src/summary/pyproject.toml"
echo " - src/agents/pyproject.toml"
echo " - CHANGELOG.md"
+8 -12
View File
@@ -249,7 +249,6 @@ services:
metadata-collector-dev:
build:
context: ./src/agents
target: development
command: ["python", "metadata_collector.py", "dev"]
environment:
- LIVEKIT_URL=ws://livekit:7880
@@ -262,7 +261,6 @@ services:
- AWS_S3_SECURE_ACCESS=False
volumes:
- ./src/agents:/app
- /app/.venv
depends_on:
- livekit
- minio
@@ -271,16 +269,6 @@ services:
- action: rebuild
path: ./src/agents
multi-user-transcriber-dev:
build:
context: ./src/agents
target: development
env_file:
- env.d/development/multi_user_transcriber
volumes:
- ./src/agents:/app
- /app/.venv
redis-summary:
image: redis
ports:
@@ -342,6 +330,14 @@ services:
- action: rebuild
path: ./src/summary
multi-user-transcriber:
build:
context: ./src/agents
env_file:
- env.d/development/multi_user_transcriber
volumes:
- ./src/agents:/app
networks:
default:
resource-server:
-58
View File
@@ -100,13 +100,6 @@ sequenceDiagram
| **RECORDING_STORAGE_EVENT_TOKEN** | Secret/File | `None` | Token used to authenticate storage webhook requests, if `RECORDING_ENABLE_STORAGE_EVENT_AUTH` is enabled. |
| **RECORDING_EXPIRATION_DAYS** | Integer | `None` | Number of days before recordings expire. Should match bucket lifecycle policy. Set to `None` for no expiration. |
| **RECORDING_MAX_DURATION** | Integer | `None` | Maximum duration of a recording in milliseconds. Must be synced with the LiveKit Egress configuration. Set to None for unlimited duration. When the maximum duration is reached, the recording is automatically stopped and saved, and the user is prompted in the frontend with an alert message. |
| **RECORDING_ENCODING_ENABLED** | Boolean | `False` | When `False`, LiveKit Egress uses its built-in `H264_720P_30` preset. When `True`, the `RECORDING_ENCODING_*` values below are sent to LiveKit as advanced `EncodingOptions`. See [Tuning recording encoding](#tuning-recording-encoding). |
| **RECORDING_ENCODING_WIDTH** | Integer | `1280` | Recording video width in pixels. Only applied when `RECORDING_ENCODING_ENABLED` is `True`. |
| **RECORDING_ENCODING_HEIGHT** | Integer | `720` | Recording video height in pixels. Only applied when `RECORDING_ENCODING_ENABLED` is `True`. |
| **RECORDING_ENCODING_FRAMERATE** | Integer | `30` | Recording video framerate (fps). Directly impacts egress worker CPU (roughly linear). Only applied when `RECORDING_ENCODING_ENABLED` is `True`. |
| **RECORDING_ENCODING_VIDEO_BITRATE_KBPS** | Integer | `3000` | H.264 MAIN video bitrate in kbps. Only applied when `RECORDING_ENCODING_ENABLED` is `True`. |
| **RECORDING_ENCODING_AUDIO_BITRATE_KBPS** | Integer | `128` | AAC audio bitrate in kbps. Only applied when `RECORDING_ENCODING_ENABLED` is `True`. |
| **RECORDING_ENCODING_KEY_FRAME_INTERVAL_S** | Float | `4.0` | Keyframe interval in seconds. Drives seek granularity in the recorded MP4 (a player can only seek to keyframe boundaries). Larger values give the encoder slightly more bits for non-keyframe content at a fixed bitrate. `4.0` is a standard VOD value. Only applied when `RECORDING_ENCODING_ENABLED` is `True`. |
### Manual Storage Webhook
@@ -148,54 +141,3 @@ Using default project meet
This allows you to verify which recordings are in progress, troubleshoot egress issues, and confirm that recordings are being processed correctly.
## Tuning recording encoding
By default, LiveKit Egress records with the built-in `H264_720P_30` preset: 1280×720 at 30 fps, 3000 kbps H.264 MAIN video and 128 kbps AAC audio. For a one-hour meeting this produces a file of roughly **1.4 GB**, which is often heavier than necessary for talking-head content and screen sharing.
The `RECORDING_ENCODING_*` settings let operators override this preset without modifying the source. Values are passed straight through LiveKit's `EncodingOptions.advanced` to the GStreamer pipeline (`x264enc` for video, `faac` for audio), so there are no hidden conversions — what you set is what the encoder receives.
### How values map to GStreamer
| Setting | GStreamer element | Property |
| ------------------------------------- | ----------------- | ---------------------------------- |
| `RECORDING_ENCODING_WIDTH/HEIGHT` | capsfilter | `video/x-raw,width=W,height=H` |
| `RECORDING_ENCODING_FRAMERATE` | capsfilter | `framerate=F/1` |
| `RECORDING_ENCODING_VIDEO_BITRATE_KBPS` | `x264enc` | `bitrate=kbps` (kilobits) |
| `RECORDING_ENCODING_KEY_FRAME_INTERVAL_S` | `x264enc` | `key-int-max = interval × fps` |
| `RECORDING_ENCODING_AUDIO_BITRATE_KBPS` | `faac` | `bitrate = kbps × 1000` (bits) |
The H.264 profile is fixed to MAIN and the x264 `speed-preset` to `veryfast` by LiveKit (real-time constraint) — lowering the framerate is therefore the main lever to save CPU, while lowering the bitrate is the main lever to shrink the output file.
### Reference profiles
Rough 30-minute file-size estimates assume video + audio bitrate multiplied by duration. Actual sizes vary with content (static talking heads compress better than heavy screen motion). Egress CPU figures are indicative, measured on a single Ryzen laptop core saturated by the default preset (= 100 %); scaling is roughly linear with `framerate × bitrate` but the absolute numbers depend on the host hardware.
| Profile | Resolution | FPS | Video (kbps) | Audio (kbps) | Keyframe (s) | ~ size / 30 min | Egress CPU (vs. default) | Suitable for |
| ---------------------- | ---------- | --- | ------------ | ------------ | ------------ | --------------- | ------------------------ | --------------------------------------------------- |
| Default (preset) | 1280×720 | 30 | 3000 | 128 | 4 | **~690 MB** | 100 % | Unchanged LiveKit behaviour |
| Balanced | 1280×720 | 20 | 1000 | 96 | 4 | ~240 MB | ~67 % | Mixed content, moderate motion |
| **Low CPU / small file** | 1280×720 | 15 | 600 | 64 | 4 | **~150 MB** | ~50 % | Talking-head dominant meetings + occasional slides ★ |
| Slide-heavy | 1280×720 | 15 | 900 | 64 | 4 | ~210 MB | ~55 % | Frequent dense screen sharing (decks, IDE, docs) |
| Minimum CPU | 960×540 | 15 | 500 | 64 | 4 | ~125 MB | ~30 % | Voice-first meetings, readable text not required |
| Audio-heavy fallback | 1280×720 | 10 | 400 | 96 | 4 | ~110 MB | ~35 % | Long webinars, low motion |
★ Recommended starting point for typical LaSuite Meet usage.
Environment variables for the **Low CPU / small file** profile:
```bash
RECORDING_ENCODING_ENABLED=True
RECORDING_ENCODING_WIDTH=1280
RECORDING_ENCODING_HEIGHT=720
RECORDING_ENCODING_FRAMERATE=15
RECORDING_ENCODING_VIDEO_BITRATE_KBPS=600
RECORDING_ENCODING_AUDIO_BITRATE_KBPS=64
RECORDING_ENCODING_KEY_FRAME_INTERVAL_S=4.0
```
### Caveats
- **Screen-share readability — think bits/frame, not bitrate**: at 720p, text legibility starts to break down below ~40 kbits/frame (= `bitrate ÷ framerate`). The recommended preset (600 kbps × 15 fps) sits at exactly that threshold, comfortable for talking heads with occasional slide sharing. The same 600 kbps at 30 fps would only deliver 20 kbits/frame and visibly blur dense slides — which is why **lowering framerate is a more screen-share-friendly lever than lowering bitrate**. For deck-heavy or IDE-share meetings, prefer the **Slide-heavy** profile (900 kbps × 15 fps ≈ 60 kbits/frame).
- **Motion handling**: the `veryfast` x264 preset is set by LiveKit and cannot be overridden here. Low-bitrate settings will therefore show more artefacts on fast motion than an offline re-encode with a slower preset would. This is the other reason FPS reduction is the safer tuning lever for meeting recordings.
- **Audio**: AAC at 64 kbps stereo is transparent for voice but starts to compress music noticeably. Keep 128 kbps if you expect music playback in meetings.
- **Codec choice**: H.264 MAIN is hardcoded on purpose. Switching to HEVC or VP9 would increase egress CPU cost 2×–5×, defeating the goal of this tuning.
-12
View File
@@ -68,18 +68,6 @@ SUMMARY_SERVICE_ENDPOINT=http://app-summary-dev:8000/api/v1/tasks/
SUMMARY_SERVICE_API_TOKEN=password
RECORDING_DOWNLOAD_BASE_URL=http://localhost:3000/recording
# Recording encoding (LiveKit Egress advanced options).
# When RECORDING_ENCODING_ENABLED is False (default), LiveKit uses its built-in
# H264_720P_30 preset (1280x720, 30fps, 3000 kbps). Enable and tune to reduce
# file size and CPU load on the egress worker.
# RECORDING_ENCODING_ENABLED=False
# RECORDING_ENCODING_WIDTH=1280
# RECORDING_ENCODING_HEIGHT=720
# RECORDING_ENCODING_FRAMERATE=30
# RECORDING_ENCODING_VIDEO_BITRATE_KBPS=3000
# RECORDING_ENCODING_AUDIO_BITRATE_KBPS=128
# RECORDING_ENCODING_KEY_FRAME_INTERVAL_S=4.0
# Telephony
ROOM_TELEPHONY_ENABLED=True
+277 -35
View File
@@ -19,7 +19,7 @@
"@types/office-runtime": "^1.0.35",
"acorn": "^8.11.3",
"babel-loader": "^9.1.3",
"copy-webpack-plugin": "^14.0.0",
"copy-webpack-plugin": "^12.0.2",
"eslint-plugin-office-addins": "^4.0.3",
"file-loader": "^6.2.0",
"html-loader": "^5.0.0",
@@ -36,7 +36,7 @@
"source-map-loader": "^5.0.0",
"webpack": "^5.95.0",
"webpack-cli": "^5.1.4",
"webpack-dev-server": "5.2.1"
"webpack-dev-server": "5.1.0"
}
},
"node_modules/@apidevtools/json-schema-ref-parser": {
@@ -4319,6 +4319,44 @@
"node": ">= 4.0.0"
}
},
"node_modules/@nodelib/fs.scandir": {
"version": "2.1.5",
"resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
"integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==",
"dev": true,
"license": "MIT",
"dependencies": {
"@nodelib/fs.stat": "2.0.5",
"run-parallel": "^1.1.9"
},
"engines": {
"node": ">= 8"
}
},
"node_modules/@nodelib/fs.stat": {
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz",
"integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 8"
}
},
"node_modules/@nodelib/fs.walk": {
"version": "1.2.8",
"resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz",
"integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@nodelib/fs.scandir": "2.1.5",
"fastq": "^1.6.0"
},
"engines": {
"node": ">= 8"
}
},
"node_modules/@pkgr/core": {
"version": "0.2.9",
"resolved": "https://registry.npmjs.org/@pkgr/core/-/core-0.2.9.tgz",
@@ -4332,6 +4370,19 @@
"url": "https://opencollective.com/pkgr"
}
},
"node_modules/@sindresorhus/merge-streams": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/@sindresorhus/merge-streams/-/merge-streams-2.3.0.tgz",
"integrity": "sha512-LtoMMhxAlorcGhmFYI+LhPgbPZCkgP6ra1YL604EeF6U98pLlQ3iWIGMdWSC+vWmPBWBNgmDBAhnAobLROJmwg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/@types/body-parser": {
"version": "1.19.6",
"resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz",
@@ -6635,20 +6686,21 @@
"license": "MIT"
},
"node_modules/copy-webpack-plugin": {
"version": "14.0.0",
"resolved": "https://registry.npmjs.org/copy-webpack-plugin/-/copy-webpack-plugin-14.0.0.tgz",
"integrity": "sha512-3JLW90aBGeaTLpM7mYQKpnVdgsUZRExY55giiZgLuX/xTQRUs1dOCwbBnWnvY6Q6rfZoXMNwzOQJCSZPppfqXA==",
"version": "12.0.2",
"resolved": "https://registry.npmjs.org/copy-webpack-plugin/-/copy-webpack-plugin-12.0.2.tgz",
"integrity": "sha512-SNwdBeHyII+rWvee/bTnAYyO8vfVdcSTud4EIb6jcZ8inLeWucJE0DnxXQBjlQ5zlteuuvooGQy3LIyGxhvlOA==",
"dev": true,
"license": "MIT",
"dependencies": {
"fast-glob": "^3.3.2",
"glob-parent": "^6.0.1",
"globby": "^14.0.0",
"normalize-path": "^3.0.0",
"schema-utils": "^4.2.0",
"serialize-javascript": "^7.0.3",
"tinyglobby": "^0.2.12"
"serialize-javascript": "^6.0.2"
},
"engines": {
"node": ">= 20.9.0"
"node": ">= 18.12.0"
},
"funding": {
"type": "opencollective",
@@ -8010,6 +8062,36 @@
"dev": true,
"license": "Apache-2.0"
},
"node_modules/fast-glob": {
"version": "3.3.3",
"resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz",
"integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@nodelib/fs.stat": "^2.0.2",
"@nodelib/fs.walk": "^1.2.3",
"glob-parent": "^5.1.2",
"merge2": "^1.3.0",
"micromatch": "^4.0.8"
},
"engines": {
"node": ">=8.6.0"
}
},
"node_modules/fast-glob/node_modules/glob-parent": {
"version": "5.1.2",
"resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz",
"integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==",
"dev": true,
"license": "ISC",
"dependencies": {
"is-glob": "^4.0.1"
},
"engines": {
"node": ">= 6"
}
},
"node_modules/fast-json-stable-stringify": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz",
@@ -8098,6 +8180,16 @@
"node": ">= 4.9.1"
}
},
"node_modules/fastq": {
"version": "1.20.1",
"resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz",
"integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==",
"dev": true,
"license": "ISC",
"dependencies": {
"reusify": "^1.0.4"
}
},
"node_modules/faye-websocket": {
"version": "0.11.4",
"resolved": "https://registry.npmjs.org/faye-websocket/-/faye-websocket-0.11.4.tgz",
@@ -8666,6 +8758,37 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/globby": {
"version": "14.1.0",
"resolved": "https://registry.npmjs.org/globby/-/globby-14.1.0.tgz",
"integrity": "sha512-0Ia46fDOaT7k4og1PDW4YbodWWr3scS2vAr2lTbsplOt2WkKp0vQbkI9wKis/T5LV/dqPjO3bpS/z6GTJB82LA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@sindresorhus/merge-streams": "^2.1.0",
"fast-glob": "^3.3.3",
"ignore": "^7.0.3",
"path-type": "^6.0.0",
"slash": "^5.1.0",
"unicorn-magic": "^0.3.0"
},
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/globby/node_modules/ignore": {
"version": "7.0.5",
"resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz",
"integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 4"
}
},
"node_modules/gopd": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
@@ -8873,6 +8996,23 @@
"safe-buffer": "~5.1.0"
}
},
"node_modules/html-entities": {
"version": "2.6.0",
"resolved": "https://registry.npmjs.org/html-entities/-/html-entities-2.6.0.tgz",
"integrity": "sha512-kig+rMn/QOVRvr7c86gQ8lWXq+Hkv6CbAH1hLu+RG338StTpE8Z0b44SDVaqVu7HGKf27frdmUYEs9hTUX/cLQ==",
"dev": true,
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/mdevils"
},
{
"type": "patreon",
"url": "https://patreon.com/mdevils"
}
],
"license": "MIT"
},
"node_modules/html-escaper": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-3.0.3.tgz",
@@ -10921,6 +11061,16 @@
"dev": true,
"license": "MIT"
},
"node_modules/merge2": {
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz",
"integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 8"
}
},
"node_modules/methods": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz",
@@ -12505,6 +12655,19 @@
"dev": true,
"license": "MIT"
},
"node_modules/path-type": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/path-type/-/path-type-6.0.0.tgz",
"integrity": "sha512-Vj7sf++t5pBD637NSfkxpHSMfWaeig5+DKWLhcqIYx6mWQz5hdJTGDVMQiJcw1ZYkhs7AazKDGpRVji1LJCZUQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/pathval": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/pathval/-/pathval-1.1.1.tgz",
@@ -12839,6 +13002,37 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/queue-microtask": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz",
"integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==",
"dev": true,
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT"
},
"node_modules/randombytes": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz",
"integrity": "sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"safe-buffer": "^5.1.0"
}
},
"node_modules/range-parser": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz",
@@ -13325,6 +13519,17 @@
"node": ">= 4"
}
},
"node_modules/reusify": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz",
"integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==",
"dev": true,
"license": "MIT",
"engines": {
"iojs": ">=1.0.0",
"node": ">=0.10.0"
}
},
"node_modules/rfdc": {
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/rfdc/-/rfdc-1.4.1.tgz",
@@ -13357,6 +13562,30 @@
"node": ">=0.12.0"
}
},
"node_modules/run-parallel": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz",
"integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==",
"dev": true,
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT",
"dependencies": {
"queue-microtask": "^1.2.2"
}
},
"node_modules/rxjs": {
"version": "7.8.2",
"resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz",
@@ -13630,13 +13859,13 @@
}
},
"node_modules/serialize-javascript": {
"version": "7.0.5",
"resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-7.0.5.tgz",
"integrity": "sha512-F4LcB0UqUl1zErq+1nYEEzSHJnIwb3AF2XWB94b+afhrekOUijwooAYqFyRbjYkm2PAKBabx6oYv/xDxNi8IBw==",
"version": "6.0.2",
"resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-6.0.2.tgz",
"integrity": "sha512-Saa1xPByTTq2gdeFZYLLo+RFE35NHZkAbqZeWNd3BpzppeVisAqpDjcp8dyf6uIvEqJRd46jemmyA4iFIeVk8g==",
"dev": true,
"license": "BSD-3-Clause",
"engines": {
"node": ">=20.0.0"
"dependencies": {
"randombytes": "^2.1.0"
}
},
"node_modules/serve-index": {
@@ -14048,6 +14277,19 @@
"simple-concat": "^1.0.0"
}
},
"node_modules/slash": {
"version": "5.1.0",
"resolved": "https://registry.npmjs.org/slash/-/slash-5.1.0.tgz",
"integrity": "sha512-ZA6oR3T/pEyuqwMgAKT0/hAv8oAXckzbkmR0UkUosQ+Mc4RxGoJkRmwHgHufaenlyAgE1Mxgpdcrf75y6XcnDg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=14.16"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/sockjs": {
"version": "0.3.24",
"resolved": "https://registry.npmjs.org/sockjs/-/sockjs-0.3.24.tgz",
@@ -15086,6 +15328,19 @@
"node": ">=4"
}
},
"node_modules/unicorn-magic": {
"version": "0.3.0",
"resolved": "https://registry.npmjs.org/unicorn-magic/-/unicorn-magic-0.3.0.tgz",
"integrity": "sha512-+QBBXBCvifc56fsbuxZQ6Sic3wqqc3WWaqxs58gvJrcOuN83HGTCwz3oS5phzU9LthRNE9VrJCFCLUgHeeFnfA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/universalify": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz",
@@ -15416,16 +15671,15 @@
}
},
"node_modules/webpack-dev-server": {
"version": "5.2.1",
"resolved": "https://registry.npmjs.org/webpack-dev-server/-/webpack-dev-server-5.2.1.tgz",
"integrity": "sha512-ml/0HIj9NLpVKOMq+SuBPLHcmbG+TGIjXRHsYfZwocUBIqEvws8NnS/V9AFQ5FKP+tgn5adwVwRrTEpGL33QFQ==",
"version": "5.1.0",
"resolved": "https://registry.npmjs.org/webpack-dev-server/-/webpack-dev-server-5.1.0.tgz",
"integrity": "sha512-aQpaN81X6tXie1FoOB7xlMfCsN19pSvRAeYUHOdFWOlhpQ/LlbfTqYwwmEDFV0h8GGuqmCmKmT+pxcUV/Nt2gQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/bonjour": "^3.5.13",
"@types/connect-history-api-fallback": "^1.5.4",
"@types/express": "^4.17.21",
"@types/express-serve-static-core": "^4.17.21",
"@types/serve-index": "^1.9.4",
"@types/serve-static": "^1.15.5",
"@types/sockjs": "^0.3.36",
@@ -15436,9 +15690,10 @@
"colorette": "^2.0.10",
"compression": "^1.7.4",
"connect-history-api-fallback": "^2.0.0",
"express": "^4.21.2",
"express": "^4.19.2",
"graceful-fs": "^4.2.6",
"http-proxy-middleware": "^2.0.7",
"html-entities": "^2.4.0",
"http-proxy-middleware": "^2.0.3",
"ipaddr.js": "^2.1.0",
"launch-editor": "^2.6.1",
"open": "^10.0.3",
@@ -15473,19 +15728,6 @@
}
}
},
"node_modules/webpack-dev-server/node_modules/@types/express-serve-static-core": {
"version": "4.19.8",
"resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-4.19.8.tgz",
"integrity": "sha512-02S5fmqeoKzVZCHPZid4b8JH2eM5HzQLZWN2FohQEy/0eXTq8VXZfSN6Pcr3F6N9R/vNrj7cpgbhjie6m/1tCA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "*",
"@types/qs": "*",
"@types/range-parser": "*",
"@types/send": "*"
}
},
"node_modules/webpack-dev-server/node_modules/define-lazy-prop": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/define-lazy-prop/-/define-lazy-prop-3.0.0.tgz",
@@ -15529,9 +15771,9 @@
}
},
"node_modules/webpack-dev-server/node_modules/ws": {
"version": "8.20.0",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.20.0.tgz",
"integrity": "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA==",
"version": "8.19.0",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz",
"integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==",
"dev": true,
"license": "MIT",
"engines": {
+2 -2
View File
@@ -36,7 +36,7 @@
"@types/office-runtime": "^1.0.35",
"acorn": "^8.11.3",
"babel-loader": "^9.1.3",
"copy-webpack-plugin": "^14.0.0",
"copy-webpack-plugin": "^12.0.2",
"eslint-plugin-office-addins": "^4.0.3",
"file-loader": "^6.2.0",
"html-loader": "^5.0.0",
@@ -53,7 +53,7 @@
"source-map-loader": "^5.0.0",
"webpack": "^5.95.0",
"webpack-cli": "^5.1.4",
"webpack-dev-server": "5.2.1"
"webpack-dev-server": "5.1.0"
},
"prettier": "office-addin-prettier-config",
"browserslist": [
File diff suppressed because it is too large Load Diff
+17
View File
@@ -0,0 +1,17 @@
{
"name": "thunderbird",
"version": "1.0.0",
"main": "index.js",
"private": true,
"scripts": {
"dev": "web-ext run",
"lint": "web-ext lint --source-dir=./src"
},
"keywords": [],
"author": "",
"license": "ISC",
"description": "",
"devDependencies": {
"web-ext": "^10.1.0"
}
}
+48
View File
@@ -0,0 +1,48 @@
import { buildMeetingMessage } from "./lib/meeting-message.js";
console.log("[meeting-link] background loaded at", new Date().toISOString());
(async () => {
try {
console.log("[meeting-link] browser.calendar exists?", !!browser.calendar);
if (browser.calendar) {
console.log("[meeting-link] calendar namespace keys:",
Object.keys(browser.calendar));
}
// Try the most basic read call — list configured calendars.
// The exact method name varies between experiment versions; we'll
// try the common one first.
if (browser.calendar.calendars?.query) {
const calendars = await browser.calendar.calendars.query({});
console.log("[meeting-link] calendars found:", calendars.length, calendars);
} else {
console.warn("[meeting-link] calendars.query not present — namespace shape:",
browser.calendar);
}
} catch (err) {
console.error("[meeting-link] calendar probe failed:", err);
}
})();
// Hardcoded for Spike 1. Spike 2 replaces this with a fetch() to your API.
const STUB_MEETING_DATA = {
url: "https://meet.example.com/m/abc-123-xyz",
telephony: {
phone_number: "+33123456789",
pin_code: "1234567890",
},
};
browser.runtime.onMessage.addListener(async (msg) => {
if (msg?.type === "GET_MEETING_MESSAGE") {
try {
const built = buildMeetingMessage(STUB_MEETING_DATA);
return { ok: true, ...built };
} catch (err) {
console.error("[meeting-link] build failed", err);
return { ok: false, error: String(err.message || err) };
}
}
});
Binary file not shown.

After

Width:  |  Height:  |  Size: 544 B

+41
View File
@@ -0,0 +1,41 @@
{
"manifest_version": 3,
"name": "Meeting Link (dev)",
"version": "0.1.0",
"description": "Spike 0 — verifying the dev loop",
"browser_specific_settings": {
"gecko": {
"id": "meeting-link-dev@yourcompany.example",
"strict_min_version": "128.0"
}
},
"background": {
"scripts": ["background.js"],
"type": "module"
},
"compose_action": {
"default_title": "Insert meeting link",
"default_popup": "popup/popup.html",
"default_icon": "icons/icon-32.png"
},
"permissions": ["compose"],
"experiment_apis": {
"calendar_provider": {
"schema": "experiments/calendar/schema/calendar-provider.json",
"parent": {
"scopes": ["addon_parent"],
"paths": [["calendar", "provider"]],
"script": "experiments/calendar/parent/ext-calendar-provider.js",
"events": ["startup"]
}
},
"calendar_calendars": {
"schema": "experiments/calendar/schema/calendar-calendars.json",
"parent": {
"scopes": ["addon_parent"],
"paths": [["calendar", "calendars"]],
"script": "experiments/calendar/parent/ext-calendar-calendars.js"
}
}
}
}
@@ -0,0 +1,18 @@
<!doctype html>
<html>
<head>
<meta charset="utf-8">
<title>Meeting link</title>
<style>
body { font: 13px system-ui; padding: 12px; min-width: 240px; margin: 0; }
button { font: inherit; padding: 6px 12px; cursor: pointer; }
#status { margin-top: 8px; color: #555; min-height: 1.2em; white-space: pre-wrap; }
#status.error { color: #b00020; }
</style>
</head>
<body>
<button id="insert">Insert meeting link</button>
<div id="status"></div>
<script src="popup.js"></script>
</body>
</html>
+65
View File
@@ -0,0 +1,65 @@
const insertBtn = document.getElementById("insert");
const statusEl = document.getElementById("status");
function setStatus(text, isError = false) {
statusEl.textContent = text;
statusEl.classList.toggle("error", isError);
}
insertBtn.addEventListener("click", async () => {
insertBtn.disabled = true;
setStatus("Generating link…");
try {
// 1. Find the compose tab this popup belongs to.
// A compose_action popup is anchored to a compose window, so the
// "active tab in the current window" is the compose tab itself.
const [composeTab] = await browser.tabs.query({
active: true,
currentWindow: true,
});
if (!composeTab) throw new Error("No compose tab found");
// 2. Read the current compose state — we need to know if we're
// in HTML mode or plain-text mode, and we need the existing body
// so we can append rather than overwrite.
const details = await browser.compose.getComposeDetails(composeTab.id);
// 3. Ask the background to produce the meeting message.
const reply = await browser.runtime.sendMessage({
type: "GET_MEETING_MESSAGE",
});
if (!reply?.ok) throw new Error(reply?.error || "Background error");
// 4. Append to the existing body in the right format.
if (details.isPlainText) {
const newBody = (details.plainTextBody || "") + "\n\n" + reply.text;
await browser.compose.setComposeDetails(composeTab.id, {
plainTextBody: newBody,
});
} else {
const newBody = appendHtmlBeforeBodyEnd(details.body || "", reply.html);
await browser.compose.setComposeDetails(composeTab.id, {
body: newBody,
});
}
setStatus("Inserted ✓");
setTimeout(() => window.close(), 600);
} catch (err) {
console.error("[meeting-link] popup insert failed", err);
setStatus("Failed: " + (err.message || err), true);
insertBtn.disabled = false;
}
});
/**
* Append HTML right before </body>, or fall back to concatenation if no
* </body> tag is present (Thunderbird's compose body is usually a full
* HTML document, but be defensive).
*/
function appendHtmlBeforeBodyEnd(currentHtml, fragment) {
const idx = currentHtml.toLowerCase().lastIndexOf("</body>");
if (idx === -1) return currentHtml + fragment;
return currentHtml.slice(0, idx) + fragment + currentHtml.slice(idx);
}
+30
View File
@@ -0,0 +1,30 @@
// web-ext-config.cjs
const os = require("os");
const path = require("path");
function thunderbirdBinary() {
if (process.env.WEB_EXT_FIREFOX) return process.env.WEB_EXT_FIREFOX;
switch (process.platform) {
case "darwin":
return "/Applications/Thunderbird Beta.app/Contents/MacOS/thunderbird";
case "linux":
return "/usr/bin/thunderbird"; // adjust to your install
case "win32":
return "C:\\Program Files\\Mozilla Thunderbird\\thunderbird.exe";
default:
throw new Error("Unsupported platform: " + process.platform);
}
}
module.exports = {
sourceDir: "./src",
artifactsDir: "./web-ext-artifacts",
run: {
firefox: thunderbirdBinary(),
firefoxProfile: path.join(os.homedir(), ".thunderbird-dev-profile"),
profileCreateIfMissing: true,
keepProfileChanges: true,
browserConsole: true,
},
ignoreFiles: ["package-lock.json", "web-ext-config.cjs", "*.md"],
};
-38
View File
@@ -1,38 +0,0 @@
# Python
__pycache__
*.pyc
**/__pycache__
**/*.pyc
venv
**/.venv
# System-specific files
.DS_Store
**/.DS_Store
# Docker
compose.*
env.d
# Docs
docs
*.md
*.log
# Development/test cache & configurations
data
.cache
.circleci
.git
.iml
db.sqlite3
.pylint.d
**/.idea
**/.vscode
**/.pytest_cache
**/.mypy_cache
**/.ruff_cache
# Env
.env
+11 -39
View File
@@ -6,61 +6,31 @@ RUN apt-get update && apt-get install -y \
libgobject-2.0-0 \
&& rm -rf /var/lib/apt/lists/*
# ---- Builder image ----
FROM base AS builder
ENV UV_COMPILE_BYTECODE=1 \
UV_LINK_MODE=copy \
UV_PYTHON_DOWNLOADS=0
WORKDIR /builder
# Install uv
COPY --from=ghcr.io/astral-sh/uv:0.10.9 /uv /uvx /bin/
COPY pyproject.toml .
WORKDIR /app
RUN mkdir /install && \
pip install --prefix=/install .
# Install production dependencies without the project itself (cacheable layer)
RUN --mount=type=cache,target=/root/.cache/uv \
--mount=type=bind,source=uv.lock,target=uv.lock \
--mount=type=bind,source=pyproject.toml,target=pyproject.toml \
uv sync --locked --no-install-project --no-dev
# Install the project
COPY . /app
RUN --mount=type=cache,target=/root/.cache/uv \
uv sync --locked --no-dev
# ---- Development image ----
FROM base AS development
ENV UV_COMPILE_BYTECODE=1 \
UV_LINK_MODE=copy \
UV_PYTHON_DOWNLOADS=0
COPY --from=ghcr.io/astral-sh/uv:0.10.9 /uv /uvx /bin/
WORKDIR /app
COPY . /app
COPY pyproject.toml .
RUN pip install --no-cache-dir ".[dev]"
RUN --mount=type=cache,target=/root/.cache/uv \
uv sync --locked --all-extras
COPY . .
ENV PATH="/app/.venv/bin:$PATH"
CMD ["python", "metadata_collector.py", "dev"]
CMD ["python", "multi_user_transcriber.py", "dev"]
# ---- Production image ----
FROM base AS production
WORKDIR /app
# Copy the pre-built virtualenv and application source
COPY --from=builder /app /app
ENV PATH="/app/.venv/bin:$PATH"
COPY --from=builder /install /usr/local
# Remove pip to reduce attack surface in production
RUN pip uninstall -y pip
@@ -69,4 +39,6 @@ RUN pip uninstall -y pip
ARG DOCKER_USER
USER ${DOCKER_USER}
COPY ./*.py /app/
CMD ["python", "multi_user_transcriber.py", "start"]
+2 -2
View File
@@ -177,7 +177,7 @@ class MetadataCollector:
def save(self):
"""Serialize collected events and upload as JSON to S3."""
logger.info("Persisting metadata...")
logger.info("Persisting metadata")
participants = []
for k, v in self.participants.items():
@@ -372,7 +372,7 @@ async def entrypoint(ctx: JobContext):
await ctx.connect(auto_subscribe=AutoSubscribe.AUDIO_ONLY)
async def cleanup():
logger.info("Shutting down metadata collector...")
logger.info("Shutting down metadata collector")
await metadata_collector.aclose()
ctx.add_shutdown_callback(cleanup)
+7 -3
View File
@@ -1,7 +1,7 @@
[project]
name = "agents"
version = "1.16.0"
version = "1.15.0"
requires-python = ">=3.12"
dependencies = [
"livekit-agents==1.4.5",
@@ -18,8 +18,12 @@ dev = [
"ruff==0.15.6",
]
[tool.uv]
package = false
[tool.setuptools]
py-modules = ["multi_user_transcriber", "metadata_collector", "exceptions"]
[build-system]
requires = ["setuptools>=61.0"]
build-backend = "setuptools.build_meta"
[tool.ruff]
target-version = "py313"
-1963
View File
File diff suppressed because it is too large Load Diff
-30
View File
@@ -136,33 +136,3 @@ class FilePermission(IsAuthenticated):
raise Http404
return obj.get_abilities(request.user).get(view.action, False)
class CanMuteParticipant(permissions.BasePermission):
"""
Grant muting rights based on role or room configuration.
- Admins and owners can always mute.
- When `everyone_can_mute` is enabled on the room, any participant
currently in the room (proven by a valid LiveKit token for that room)
can mute.
"""
def has_object_permission(self, request, view, obj):
"""Check if the requesting user is allowed to mute a participant in the given room."""
is_livekit_token_auth = request.auth and hasattr(request.auth, "video")
# Always allow admins/owners when authenticated with session cookie
if not is_livekit_token_auth and obj.is_administrator_or_owner(request.user):
return True
everyone_can_mute = obj.configuration.get("everyone_can_mute", True)
if not everyone_can_mute:
return False
if not is_livekit_token_auth:
return False
# LiveKit token scoped to this room
return request.auth.video.room == str(obj.id)
+8 -30
View File
@@ -13,8 +13,7 @@ from django.core.exceptions import SuspiciousOperation
from django.utils.translation import gettext_lazy as _
from django_pydantic_field.rest_framework import SchemaField
from pydantic import BaseModel, Field, field_serializer
from pydantic import ValidationError as PydanticValidationError
from pydantic import BaseModel, Field
from rest_framework import serializers
from rest_framework.exceptions import PermissionDenied
from timezone_field.rest_framework import TimeZoneSerializerField
@@ -132,16 +131,6 @@ class RoomSerializer(serializers.ModelSerializer):
fields = ["id", "name", "slug", "configuration", "access_level", "pin_code"]
read_only_fields = ["id", "slug", "pin_code"]
def validate_configuration(self, value):
"""Validate room configuration against the RoomConfiguration schema."""
if value is None or value == {}:
return value
try:
RoomConfiguration.model_validate(value)
except PydanticValidationError as e:
raise serializers.ValidationError(e.errors()) from e
return value
def to_representation(self, instance):
"""
Add users only for administrator users.
@@ -166,6 +155,11 @@ class RoomSerializer(serializers.ModelSerializer):
)
output["accesses"] = access_serializer.data
configuration = output["configuration"]
if not is_admin_or_owner:
del output["configuration"]
should_access_room = (
(
instance.access_level == models.RoomAccessLevel.TRUSTED
@@ -182,7 +176,7 @@ class RoomSerializer(serializers.ModelSerializer):
room_id=room_id,
user=request.user,
username=username,
configuration=output["configuration"],
configuration=configuration,
is_admin_or_owner=is_admin_or_owner,
)
else:
@@ -312,19 +306,7 @@ class MuteParticipantSerializer(BaseParticipantsManagementSerializer):
)
TrackSource = Literal["camera", "microphone", "screen_share", "screen_share_audio"]
class RoomConfiguration(BaseModel):
"""Validate room configuration structure.
Unknown fields are rejected.
"""
can_publish_sources: list[TrackSource] | None = None
everyone_can_mute: bool | None = None
model_config = {"extra": "forbid"}
TrackSource = Literal["SCREEN_SHARE", "SCREEN_SHARE_AUDIO", "CAMERA", "MICROPHONE"]
class ParticipantPermission(BaseModel):
@@ -346,10 +328,6 @@ class ParticipantPermission(BaseModel):
model_config = {"extra": "forbid"}
@field_serializer("can_publish_sources")
def _serialize_sources(self, sources: list[str]) -> list[str]:
return [s.upper() for s in sources]
class UpdateParticipantSerializer(BaseParticipantsManagementSerializer):
"""Validate participant update data."""
+11 -93
View File
@@ -6,9 +6,7 @@ from logging import getLogger
from urllib.parse import unquote, urlparse
from django.conf import settings
from django.core.exceptions import ValidationError as DjangoValidationError
from django.core.files.storage import default_storage
from django.db import IntegrityError, transaction
from django.db.models import Q
from django.http import Http404
from django.shortcuts import get_object_or_404
@@ -33,7 +31,6 @@ from rest_framework import (
from rest_framework import (
status as drf_status,
)
from rest_framework.settings import api_settings
from core import enums, models, utils
from core.api.filters import ListFileFilter
@@ -77,11 +74,6 @@ from core.services.participants_management import (
ParticipantsManagementException,
)
from core.services.room_creation import RoomCreation
from core.services.room_management import (
RoomManagement,
RoomManagementException,
RoomNotFoundException,
)
from core.services.subtitle import SubtitleException, SubtitleService
from core.tasks.file import process_file_deletion
@@ -305,41 +297,6 @@ class RoomViewSet(
if callback_id := self.request.data.get("callback_id"):
RoomCreation().persist_callback_state(callback_id, room)
def perform_update(self, serializer):
"""Persist the room update, then sync metadata to LiveKit."""
old_configuration = serializer.instance.configuration
old_access_level = serializer.instance.access_level
room = serializer.save()
if (
room.configuration == old_configuration
and room.access_level == old_access_level
):
return
metadata = {
"configuration": room.configuration,
"access_level": room.access_level,
}
try:
RoomManagement().update_metadata(
room_name=str(room.id),
metadata=metadata,
)
except RoomNotFoundException:
logger.info(
"LiveKit room %s does not exist yet, skipping metadata sync",
room.id,
)
except RoomManagementException:
logger.warning(
"Failed to sync metadata to LiveKit for room %s",
room.id,
)
@decorators.action(
detail=True,
methods=["post"],
@@ -363,27 +320,16 @@ class RoomViewSet(
options = serializer.validated_data.get("options")
room = self.get_object()
try:
with transaction.atomic():
recording = models.Recording.objects.create(
room=room,
mode=mode,
options=options.model_dump(exclude_none=True) if options else {},
)
models.RecordingAccess.objects.create(
user=self.request.user,
role=models.RoleChoices.OWNER,
recording=recording,
)
# May raise exception if an active or initiated recording already exist for the room
recording = models.Recording.objects.create(
room=room,
mode=mode,
options=options.model_dump(exclude_none=True) if options else {},
)
except (DjangoValidationError, IntegrityError):
# DjangoValidationError covers the Python-level check (full_clean);
# IntegrityError covers the race where two concurrent requests both
# pass that check and the DB-level UNIQUE constraint catches the loser.
return drf_response.Response(
{"error": f"A recording is already in progress for room {room.slug}"},
status=drf_status.HTTP_409_CONFLICT,
)
models.RecordingAccess.objects.create(
user=self.request.user, role=models.RoleChoices.OWNER, recording=recording
)
worker_service = get_worker_service(mode=recording.mode)
worker_manager = WorkerServiceMediator(worker_service=worker_service)
@@ -391,12 +337,9 @@ class RoomViewSet(
try:
worker_manager.start(recording)
except RecordingStartError:
models.Recording.objects.filter(pk=recording.pk).update(
status=models.RecordingStatusChoices.FAILED_TO_START
)
return drf_response.Response(
{"error": f"Recording failed to start for room {room.slug}"},
status=drf_status.HTTP_502_BAD_GATEWAY,
status=drf_status.HTTP_500_INTERNAL_SERVER_ERROR,
)
if settings.METADATA_COLLECTOR_ENABLED and (
@@ -404,7 +347,6 @@ class RoomViewSet(
):
try:
MetadataCollectorService().start(recording)
logger.debug("Started MetadataCollectorService")
except MetadataCollectorException:
logger.warning("Failed to start MetadataCollectorService")
@@ -655,11 +597,7 @@ class RoomViewSet(
methods=["post"],
url_path="mute-participant",
url_name="mute-participant",
permission_classes=[permissions.CanMuteParticipant],
authentication_classes=[
LiveKitTokenAuthentication,
*api_settings.DEFAULT_AUTHENTICATION_CLASSES,
],
permission_classes=[permissions.HasPrivilegesOnRoom],
)
def mute_participant(self, request, pk=None): # pylint: disable=unused-argument
"""Mute a specific track for a participant in the room."""
@@ -668,26 +606,6 @@ class RoomViewSet(
serializer = serializers.MuteParticipantSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
# TEMPORARY: a LiveKit token proves access was granted, not that the caller
# joined. Cross-check identity against the live participant list until auth
# is hardened. Skipped for non-LiveKit auth backends.
caller_identity = getattr(request.auth, "identity", None)
if caller_identity is not None:
try:
ParticipantsManagement().check_if_in_meeting(
room_name=str(room.pk),
identity=caller_identity,
)
except (ParticipantNotFoundException, ParticipantsManagementException):
logger.warning(
"Failed to verify caller presence for mute in room %s; denying",
room.pk,
)
return drf_response.Response(
{"error": "Could not verify caller presence"},
status=drf_status.HTTP_403_FORBIDDEN,
)
try:
ParticipantsManagement().mute(
room_name=str(room.pk),
-1
View File
@@ -388,7 +388,6 @@ class Room(Resource):
choices=RoomAccessLevel.choices,
default=settings.RESOURCE_DEFAULT_ACCESS_LEVEL,
)
# Public configuration exposed to any room participant via the API
configuration = models.JSONField(
blank=True,
default=dict,
@@ -1,9 +1,7 @@
"""Service to notify external services when a new recording is ready."""
import asyncio
import logging
import smtplib
from datetime import datetime, timezone
from django.conf import settings
from django.core.mail import send_mail
@@ -11,12 +9,9 @@ from django.template.loader import render_to_string
from django.utils.translation import get_language, override
from django.utils.translation import gettext_lazy as _
import aiohttp
import requests
from asgiref.sync import async_to_sync
from livekit import api as livekit_api
from core import models, utils
from core import models
logger = logging.getLogger(__name__)
@@ -136,50 +131,7 @@ class NotificationService:
return not has_failures
@staticmethod
async def _get_recording_timestamps(worker_id):
"""Fetch FileInfo.started_at and ended_at from LiveKit's egress API.
FileInfo.started_at is more accurate than EgressInfo.started_at because
it reflects when file recording actually began. The started_at value exposed
in the manifest file, as well as in the EgressInfo returned by the API,
corresponds to when the egress service received the request, not the moment
the egress worker effectively joined the room.
Returns:
Tuple of (started_at, ended_at) datetimes, either may be None.
"""
if not worker_id:
return None, None
custom_configuration = {
**settings.LIVEKIT_CONFIGURATION,
"timeout": aiohttp.ClientTimeout(total=10),
}
lkapi = utils.create_livekit_client(custom_configuration=custom_configuration)
try:
egress_list = await lkapi.egress.list_egress(
livekit_api.ListEgressRequest(egress_id=worker_id) # pylint: disable=no-member
)
except (livekit_api.TwirpError, OSError, asyncio.TimeoutError):
logger.exception("Could not fetch egress info for worker %s", worker_id)
return None, None
finally:
await lkapi.aclose()
if not egress_list.items or not egress_list.items[0].file_results:
logger.debug("No file_results for worker %s", worker_id)
return None, None
file_result = egress_list.items[0].file_results[0]
def _ns_to_utc(ns):
return datetime.fromtimestamp(ns / 1e9, tz=timezone.utc) if ns else None
return _ns_to_utc(file_result.started_at), _ns_to_utc(file_result.ended_at)
@staticmethod
def _notify_summary_service(recording: models.Recording):
def _notify_summary_service(recording):
"""Notify summary service about a new recording."""
if (
@@ -198,35 +150,24 @@ class NotificationService:
.first()
)
if settings.METADATA_COLLECTOR_ENABLED and recording.options.get(
"collect_metadata", False
):
output_folder = settings.METADATA_COLLECTOR_OUTPUT_FOLDER
metadata_filename = f"{output_folder}/{recording.id}-metadata.json"
else:
metadata_filename = None
if not owner_access:
logger.error("No owner found for recording %s", recording.id)
return False
started_at, ended_at = async_to_sync(
NotificationService._get_recording_timestamps
)(recording.worker_id)
payload = {
"owner_id": str(owner_access.user.id),
"recording_filename": recording.key,
"metadata_filename": metadata_filename,
"filename": recording.key,
"email": owner_access.user.email,
"sub": owner_access.user.sub,
"room": recording.room.name,
"language": recording.options.get("language"),
"owner_timezone": str(owner_access.user.timezone),
"recording_date": recording.created_at.astimezone(
owner_access.user.timezone
).strftime("%Y-%m-%d"),
"recording_time": recording.created_at.astimezone(
owner_access.user.timezone
).strftime("%H:%M"),
"download_link": f"{get_recording_download_base_url()}/{recording.id}",
"context_language": owner_access.user.language,
"recording_start_at": (started_at.isoformat() if started_at else None),
"recording_end_at": (ended_at.isoformat() if ended_at else None),
}
headers = {
@@ -1,7 +1,5 @@
"""Factory, configurations and Protocol to create worker services"""
# pylint: disable=no-member
import logging
from dataclasses import dataclass
from functools import lru_cache
@@ -10,17 +8,8 @@ from typing import Any, ClassVar, Dict, Optional, Protocol, Type
from django.conf import settings
from django.utils.module_loading import import_string
from livekit import api as livekit_api
logger = logging.getLogger(__name__)
# Codec / frequency constants matching LiveKit's H264_720P_30 preset.
# Kept fixed because changing them would shift the goal-post away from the
# "safe drop-in replacement for the default preset" contract of this feature.
_RECORDING_VIDEO_CODEC = livekit_api.VideoCodec.H264_MAIN
_RECORDING_AUDIO_CODEC = livekit_api.AudioCodec.AAC
_RECORDING_AUDIO_FREQUENCY_HZ = 48000
@dataclass(frozen=True)
class WorkerServiceConfig:
@@ -29,7 +18,6 @@ class WorkerServiceConfig:
output_folder: str
server_configurations: Dict[str, Any]
bucket_args: Optional[dict]
encoding_options: Optional[Dict[str, Any]] = None
@classmethod
@lru_cache
@@ -37,24 +25,6 @@ class WorkerServiceConfig:
"""Load configuration from Django settings with caching for efficiency."""
logger.debug("Loading WorkerServiceConfig from settings.")
encoding_options: Optional[Dict[str, Any]] = None
if settings.RECORDING_ENCODING_ENABLED:
# Single source of truth for the EncodingOptions kwargs:
# operator-tunable values live in Django settings, codec / frequency
# are pinned constants. The services layer only unpacks this dict.
encoding_options = {
"width": settings.RECORDING_ENCODING_WIDTH,
"height": settings.RECORDING_ENCODING_HEIGHT,
"framerate": settings.RECORDING_ENCODING_FRAMERATE,
"video_bitrate": settings.RECORDING_ENCODING_VIDEO_BITRATE_KBPS,
"audio_bitrate": settings.RECORDING_ENCODING_AUDIO_BITRATE_KBPS,
"key_frame_interval": settings.RECORDING_ENCODING_KEY_FRAME_INTERVAL_S,
"video_codec": _RECORDING_VIDEO_CODEC,
"audio_codec": _RECORDING_AUDIO_CODEC,
"audio_frequency": _RECORDING_AUDIO_FREQUENCY_HZ,
}
return cls(
output_folder=settings.RECORDING_OUTPUT_FOLDER,
server_configurations=settings.LIVEKIT_CONFIGURATION,
@@ -66,7 +36,6 @@ class WorkerServiceConfig:
"bucket": settings.AWS_STORAGE_BUCKET_NAME,
"force_path_style": True,
},
encoding_options=encoding_options,
)
+3 -27
View File
@@ -83,22 +83,6 @@ class BaseEgressService:
"""
raise NotImplementedError("Subclass must implement this method.")
def _build_encoding_options(self):
"""Build a LiveKit EncodingOptions from the service config, or None.
When None is returned, the caller should omit the `advanced` field so
LiveKit Egress falls back to its built-in preset (H264_720P_30).
The full EncodingOptions kwargs (operator-tunable values + pinned
codec / frequency constants) are assembled in `WorkerServiceConfig`,
so this method is a thin protobuf adapter.
"""
opts = self._config.encoding_options
if not opts:
return None
return livekit_api.EncodingOptions(**opts)
class VideoCompositeEgressService(BaseEgressService):
"""Record multiple participant video and audio tracks into a single output '.mp4' file."""
@@ -120,17 +104,9 @@ class VideoCompositeEgressService(BaseEgressService):
s3=self._s3,
)
request_kwargs = {
"room_name": room_name,
"file_outputs": [file_output],
"layout": "speaker-light",
}
advanced = self._build_encoding_options()
if advanced is not None:
request_kwargs["advanced"] = advanced
request = livekit_api.RoomCompositeEgressRequest(**request_kwargs)
request = livekit_api.RoomCompositeEgressRequest(
room_name=room_name, file_outputs=[file_output], layout="speaker-light"
)
response = self._handle_request(request, "start_room_composite_egress")
+1 -1
View File
@@ -123,7 +123,7 @@ class LobbyService:
def request_entry(
self,
room: models.Room,
room,
request,
username: str,
) -> Tuple[LobbyParticipant, Optional[Dict]]:
@@ -15,7 +15,6 @@ from livekit.api import (
TwirpError,
UpdateParticipantRequest,
)
from livekit.protocol.models import ParticipantInfo
from core import utils
@@ -155,44 +154,3 @@ class ParticipantsManagement:
finally:
await lkapi.aclose()
@async_to_sync
async def check_if_in_meeting(self, room_name: str, identity: str) -> bool:
"""Check whether `identity` is currently a participant in `room_name`.
Raises ParticipantsManagementException for unexpected LiveKit errors
so callers can fail closed rather than silently allowing the action.
"""
if not room_name or not identity:
return False
lkapi = utils.create_livekit_client()
try:
participant = await lkapi.room.get_participant(
RoomParticipantIdentity(
room=room_name,
identity=identity,
)
)
except TwirpError as e:
if e.code == "not_found":
raise ParticipantNotFoundException("Participant does not exist") from e
logger.exception(
"Unexpected error checking participant %s in room %s",
identity,
room_name,
)
raise ParticipantsManagementException(
"Could not verify participant presence"
) from e
finally:
await lkapi.aclose()
return (
participant is not None
and participant.state != ParticipantInfo.State.DISCONNECTED
)
@@ -1,64 +0,0 @@
"""Room management service for LiveKit rooms."""
# pylint: disable=no-name-in-module
import json
from logging import getLogger
from typing import Dict, Optional
from asgiref.sync import async_to_sync
from livekit.api import (
TwirpError,
UpdateRoomMetadataRequest,
)
from core import utils
logger = getLogger(__name__)
class RoomManagementException(Exception):
"""Exception raised when a room management operation fails."""
class RoomNotFoundException(RoomManagementException):
"""Raised when the target room does not exist in LiveKit."""
class RoomManagement:
"""Service for managing LiveKit rooms."""
@async_to_sync
async def update_metadata(self, room_name: str, metadata: Optional[Dict] = None):
"""Update a LiveKit room's metadata.
The `room_name` corresponds to the LiveKit room identifier
(i.e. the Room model's UUID as a string).
"""
lkapi = utils.create_livekit_client()
try:
await lkapi.room.update_room_metadata(
UpdateRoomMetadataRequest(
room=room_name,
metadata=json.dumps(metadata) if metadata is not None else "",
)
)
except TwirpError as e:
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
logger.exception(
"Unexpected error updating metadata for room %s",
room_name,
)
raise RoomManagementException("Could not update room metadata") from e
finally:
await lkapi.aclose()
@@ -117,7 +117,7 @@ def test_api_files_create_file_authenticated_success():
policy_parsed = urlparse(policy)
assert policy_parsed.scheme == "http"
assert policy_parsed.netloc in ["minio:9000", "localhost:9000"]
assert policy_parsed.netloc == "localhost:9000"
assert policy_parsed.path == f"/meet-media-storage/files/{file.id!s}.png"
query_params = parse_qs(policy_parsed.query)
@@ -2,7 +2,7 @@
Test worker service factories.
"""
# pylint: disable=protected-access,redefined-outer-name,unused-argument,no-member
# pylint: disable=protected-access,redefined-outer-name,unused-argument
from dataclasses import FrozenInstanceError
from unittest.mock import Mock
@@ -10,9 +10,6 @@ from unittest.mock import Mock
from django.test import override_settings
import pytest
from livekit import (
api as livekit_api_codec,
)
from core.recording.worker.factories import (
WorkerService,
@@ -66,8 +63,6 @@ def test_config_initialization(default_config):
"bucket": "test-bucket",
"force_path_style": True,
}
# Encoding override is opt-in; disabled by default.
assert default_config.encoding_options is None
def test_config_immutability(default_config):
@@ -76,45 +71,6 @@ def test_config_immutability(default_config):
default_config.output_folder = "new/path"
@override_settings(
RECORDING_OUTPUT_FOLDER="/test/output",
LIVEKIT_CONFIGURATION={"server": "test.example.com"},
AWS_S3_ENDPOINT_URL="https://s3.test.com",
AWS_S3_ACCESS_KEY_ID="test_key",
AWS_S3_SECRET_ACCESS_KEY="test_secret",
AWS_S3_REGION_NAME="test-region",
AWS_STORAGE_BUCKET_NAME="test-bucket",
RECORDING_ENCODING_ENABLED=True,
RECORDING_ENCODING_WIDTH=1280,
RECORDING_ENCODING_HEIGHT=720,
RECORDING_ENCODING_FRAMERATE=15,
RECORDING_ENCODING_VIDEO_BITRATE_KBPS=600,
RECORDING_ENCODING_AUDIO_BITRATE_KBPS=64,
RECORDING_ENCODING_KEY_FRAME_INTERVAL_S=10.0,
)
def test_config_encoding_options_enabled():
"""When RECORDING_ENCODING_ENABLED is True, encoding options are populated.
The dict mixes operator-tunable values from settings with pinned codec /
frequency constants, so the services layer can simply unpack it.
"""
WorkerServiceConfig.from_settings.cache_clear()
config = WorkerServiceConfig.from_settings()
assert config.encoding_options == {
"width": 1280,
"height": 720,
"framerate": 15,
"video_bitrate": 600,
"audio_bitrate": 64,
"key_frame_interval": 10.0,
"video_codec": livekit_api_codec.VideoCodec.H264_MAIN,
"audio_codec": livekit_api_codec.AudioCodec.AAC,
"audio_frequency": 48000,
}
@override_settings(
RECORDING_OUTPUT_FOLDER="/test/output",
LIVEKIT_CONFIGURATION={"server": "test.example.com"},
@@ -39,31 +39,6 @@ def config():
)
@pytest.fixture
def config_with_encoding(config):
"""Fixture for a config carrying custom encoding options.
Mirrors the dict shape produced by `WorkerServiceConfig.from_settings()`
(operator-tunable values + pinned codec / frequency constants).
"""
return WorkerServiceConfig(
output_folder=config.output_folder,
server_configurations=config.server_configurations,
bucket_args=config.bucket_args,
encoding_options={
"width": 1280,
"height": 720,
"framerate": 15,
"video_bitrate": 600,
"audio_bitrate": 64,
"key_frame_interval": 10.0,
"video_codec": livekit_api.VideoCodec.H264_MAIN,
"audio_codec": livekit_api.AudioCodec.AAC,
"audio_frequency": 48000,
},
)
@pytest.fixture
def mock_s3_upload():
"""Fixture for mocked S3Upload"""
@@ -249,41 +224,6 @@ def test_video_composite_egress_start_missing_egress_id(video_service):
assert "Egress ID not found" in str(exc_info.value)
def test_video_composite_egress_start_without_encoding_options(video_service):
"""When no encoding options are configured, no `advanced` field is set.
LiveKit then falls back to its built-in preset (H264_720P_30).
"""
video_service._handle_request.return_value = Mock(egress_id="eg-1")
video_service.start("test-room", "rec-1")
request = video_service._handle_request.call_args[0][0]
# Proto oneof `options` must be unset when no advanced encoding is provided.
assert request.WhichOneof("options") is None
def test_video_composite_egress_start_with_encoding_options(config_with_encoding):
"""Custom encoding options are forwarded as `advanced` EncodingOptions."""
service = VideoCompositeEgressService(config_with_encoding)
service._handle_request = Mock(return_value=Mock(egress_id="eg-2"))
service.start("test-room", "rec-2")
request = service._handle_request.call_args[0][0]
assert request.WhichOneof("options") == "advanced"
advanced = request.advanced
assert advanced.width == 1280
assert advanced.height == 720
assert advanced.framerate == 15
assert advanced.video_bitrate == 600
assert advanced.audio_bitrate == 64
assert advanced.key_frame_interval == pytest.approx(10.0)
assert advanced.video_codec == livekit_api.VideoCodec.H264_MAIN
assert advanced.audio_codec == livekit_api.AudioCodec.AAC
assert advanced.audio_frequency == 48000
def test_audio_composite_egress_hrid(audio_service):
"""Test HRID is correct"""
assert audio_service.hrid == "audio-recording-composite-livekit-egress"
@@ -2,23 +2,20 @@
Test rooms API endpoints in the Meet core app: participants management.
"""
# pylint: disable=redefined-outer-name,unused-argument,protected-access,no-name-in-module,too-many-lines
# pylint: disable=redefined-outer-name,unused-argument,protected-access
import random
from unittest import mock
from uuid import uuid4
from django.contrib.auth.models import AnonymousUser
from django.core.exceptions import SuspiciousOperation
from django.urls import reverse
import pytest
from livekit.api import TwirpError, UpdateParticipantRequest
from livekit.protocol.models import ParticipantInfo
from livekit.api import TwirpError
from rest_framework import status
from rest_framework.test import APIClient
from core import utils
from core.factories import RoomFactory, UserFactory, UserResourceAccessFactory
from core.services.lobby import LobbyService
@@ -34,8 +31,8 @@ def mock_livekit_client():
yield mock_client
def test_mute_participant_success_as_admin(mock_livekit_client):
"""Admins and owners should be able to mute without a LiveKit token."""
def test_mute_participant_success(mock_livekit_client):
"""Test successful participant muting."""
client = APIClient()
room = RoomFactory()
user = UserFactory()
@@ -44,12 +41,10 @@ def test_mute_participant_success_as_admin(mock_livekit_client):
)
client.force_authenticate(user=user)
payload = {"participant_identity": str(uuid4()), "track_sid": "test-track-sid"}
url = reverse("rooms-mute-participant", kwargs={"pk": room.id})
response = client.post(
url,
{"participant_identity": str(uuid4()), "track_sid": "test-track-sid"},
format="json",
)
response = client.post(url, payload, format="json")
assert response.status_code == status.HTTP_200_OK
assert response.data == {"status": "success"}
@@ -58,131 +53,23 @@ def test_mute_participant_success_as_admin(mock_livekit_client):
mock_livekit_client.aclose.assert_called_once()
def test_mute_participant_anonymous_no_token_forbidden(mock_livekit_client):
"""Should forbid muting when user is anonymous and no LiveKit token."""
def test_mute_participant_forbidden_without_access():
"""Test mute participant returns 403 when user lacks room privileges."""
client = APIClient()
room = RoomFactory()
url = reverse("rooms-mute-participant", kwargs={"pk": room.id})
response = client.post(
url,
{"participant_identity": str(uuid4()), "track_sid": "test-track-sid"},
format="json",
)
assert response.status_code == status.HTTP_403_FORBIDDEN
mock_livekit_client.room.mute_published_track.assert_not_called()
def test_mute_participant_with_livekit_token_for_this_room(mock_livekit_client):
"""Should allow muting when the LiveKit token is scoped to this room."""
client = APIClient()
room = RoomFactory()
user = AnonymousUser()
token = utils.generate_token(str(room.id), user, is_admin_or_owner=False)
url = reverse("rooms-mute-participant", kwargs={"pk": room.id})
response = client.post(
url,
{"participant_identity": str(uuid4()), "track_sid": "test-track-sid"},
format="json",
HTTP_AUTHORIZATION=f"Bearer {token}",
)
assert response.status_code == status.HTTP_200_OK
assert response.data == {"status": "success"}
mock_livekit_client.room.mute_published_track.assert_called_once()
def test_mute_participant_with_livekit_token_for_another_room_forbidden(
mock_livekit_client,
):
"""Should forbid muting when the LiveKit token is scoped to a different room."""
client = APIClient()
target_room = RoomFactory()
other_room = RoomFactory()
user = AnonymousUser()
token = utils.generate_token(str(other_room.id), user, is_admin_or_owner=False)
url = reverse("rooms-mute-participant", kwargs={"pk": target_room.id})
response = client.post(
url,
{"participant_identity": str(uuid4()), "track_sid": "test-track-sid"},
format="json",
HTTP_AUTHORIZATION=f"Bearer {token}",
)
assert response.status_code == status.HTTP_403_FORBIDDEN
mock_livekit_client.room.mute_published_track.assert_not_called()
def test_mute_participant_authenticated_no_role_no_token_forbidden(mock_livekit_client):
"""Should forbid muting when user has no room role and no LiveKit token."""
client = APIClient()
room = RoomFactory() # everyone_can_mute defaults to True
user = UserFactory() # no UserResourceAccess for this room
user = UserFactory() # User without UserResourceAccess
client.force_authenticate(user=user)
payload = {"participant_identity": str(uuid4()), "track_sid": "test-track-sid"}
url = reverse("rooms-mute-participant", kwargs={"pk": room.id})
response = client.post(
url,
{"participant_identity": str(uuid4()), "track_sid": "test-track-sid"},
format="json",
)
response = client.post(url, payload, format="json")
assert response.status_code == status.HTTP_403_FORBIDDEN
mock_livekit_client.room.mute_published_track.assert_not_called()
def test_mute_participant_everyone_can_mute_disabled_blocks_non_admin(
mock_livekit_client,
):
"""Should forbid muting when everyone_can_mute is False, even with a LiveKit token."""
client = APIClient()
room = RoomFactory(configuration={"everyone_can_mute": False})
user = AnonymousUser()
token = utils.generate_token(str(room.id), user, is_admin_or_owner=False)
url = reverse("rooms-mute-participant", kwargs={"pk": room.id})
response = client.post(
url,
{"participant_identity": str(uuid4()), "track_sid": "test-track-sid"},
format="json",
HTTP_AUTHORIZATION=f"Bearer {token}",
)
assert response.status_code == status.HTTP_403_FORBIDDEN
mock_livekit_client.room.mute_published_track.assert_not_called()
def test_mute_participant_everyone_can_mute_disabled_allows_admin(mock_livekit_client):
"""Should allow admins and owners to mute when everyone_can_mute is False."""
client = APIClient()
room = RoomFactory(configuration={"everyone_can_mute": False})
user = UserFactory()
UserResourceAccessFactory(
resource=room, user=user, role=random.choice(["administrator", "owner"])
)
client.force_authenticate(user=user)
url = reverse("rooms-mute-participant", kwargs={"pk": room.id})
response = client.post(
url,
{"participant_identity": str(uuid4()), "track_sid": "test-track-sid"},
format="json",
)
assert response.status_code == status.HTTP_200_OK
mock_livekit_client.room.mute_published_track.assert_called_once()
def test_mute_participant_invalid_payload():
"""Should reject muting when the payload is invalid."""
"""Test mute participant with invalid payload."""
client = APIClient()
room = RoomFactory()
user = UserFactory()
@@ -191,16 +78,16 @@ def test_mute_participant_invalid_payload():
)
client.force_authenticate(user=user)
payload = {"participant_identity": "invalid-uuid", "track_sid": ""}
url = reverse("rooms-mute-participant", kwargs={"pk": room.id})
response = client.post(
url, {"participant_identity": "invalid-uuid", "track_sid": ""}, format="json"
)
response = client.post(url, payload, format="json")
assert response.status_code == status.HTTP_400_BAD_REQUEST
def test_mute_participant_unexpected_twirp_error(mock_livekit_client):
"""Should return 500 when the LiveKit API raises a TwirpError."""
"""Test mute participant when LiveKit API raises TwirpError."""
client = APIClient()
mock_livekit_client.room.mute_published_track.side_effect = TwirpError(
@@ -214,12 +101,10 @@ def test_mute_participant_unexpected_twirp_error(mock_livekit_client):
)
client.force_authenticate(user=user)
payload = {"participant_identity": str(uuid4()), "track_sid": "test-track-sid"}
url = reverse("rooms-mute-participant", kwargs={"pk": room.id})
response = client.post(
url,
{"participant_identity": str(uuid4()), "track_sid": "test-track-sid"},
format="json",
)
response = client.post(url, payload, format="json")
assert response.status_code == status.HTTP_500_INTERNAL_SERVER_ERROR
assert response.data == {"error": "Failed to mute participant"}
@@ -227,282 +112,6 @@ def test_mute_participant_unexpected_twirp_error(mock_livekit_client):
mock_livekit_client.aclose.assert_called_once()
def test_mute_participant_participant_not_found(mock_livekit_client):
"""Should return 404 when the participant does not exist in the room."""
client = APIClient()
mock_livekit_client.room.mute_published_track.side_effect = TwirpError(
msg="participant does not exist", code="not_found", status=404
)
room = RoomFactory()
user = UserFactory()
UserResourceAccessFactory(
resource=room, user=user, role=random.choice(["administrator", "owner"])
)
client.force_authenticate(user=user)
url = reverse("rooms-mute-participant", kwargs={"pk": room.id})
response = client.post(
url,
{"participant_identity": str(uuid4()), "track_sid": "test-track-sid"},
format="json",
)
assert response.status_code == status.HTTP_404_NOT_FOUND
assert response.data == {"error": "Participant not found"}
mock_livekit_client.aclose.assert_called_once()
def test_mute_participant_management_exception(mock_livekit_client):
"""Should return 500 when ParticipantsManagement raises an unexpected error."""
client = APIClient()
mock_livekit_client.room.mute_published_track.side_effect = TwirpError(
msg="boom", code="internal", status=503
)
room = RoomFactory()
user = UserFactory()
UserResourceAccessFactory(
resource=room, user=user, role=random.choice(["administrator", "owner"])
)
client.force_authenticate(user=user)
url = reverse("rooms-mute-participant", kwargs={"pk": room.id})
response = client.post(
url,
{"participant_identity": str(uuid4()), "track_sid": "test-track-sid"},
format="json",
)
assert response.status_code == status.HTTP_500_INTERNAL_SERVER_ERROR
assert response.data == {"error": "Failed to mute participant"}
mock_livekit_client.aclose.assert_called_once()
def test_mute_participant_admin_with_token_for_this_room(mock_livekit_client):
"""Should allow muting when user is admin and LiveKit token is scoped to this room."""
client = APIClient()
room = RoomFactory()
user = UserFactory()
UserResourceAccessFactory(
resource=room, user=user, role=random.choice(["administrator", "owner"])
)
# Token identity matches the admin user so LiveKitTokenAuthentication
# resolves request.user back to the admin.
token = utils.generate_token(str(room.id), user, is_admin_or_owner=True)
url = reverse("rooms-mute-participant", kwargs={"pk": room.id})
response = client.post(
url,
{"participant_identity": str(uuid4()), "track_sid": "test-track-sid"},
format="json",
HTTP_AUTHORIZATION=f"Bearer {token}",
)
assert response.status_code == status.HTTP_200_OK
assert response.data == {"status": "success"}
mock_livekit_client.room.mute_published_track.assert_called_once()
def test_mute_participant_admin_with_token_for_another_room(mock_livekit_client):
"""Should not allow muting when user is admin and the LiveKit token is for another room."""
client = APIClient()
target_room = RoomFactory()
other_room = RoomFactory()
user = UserFactory()
UserResourceAccessFactory(
resource=target_room,
user=user,
role=random.choice(["administrator", "owner"]),
)
# Token is scoped to a DIFFERENT room, and admin status must only be
# honored when established via session, never via a LiveKit
# token, which can be replayed off-host.
token = utils.generate_token(str(other_room.id), user, is_admin_or_owner=True)
url = reverse("rooms-mute-participant", kwargs={"pk": target_room.id})
response = client.post(
url,
{"participant_identity": str(uuid4()), "track_sid": "test-track-sid"},
format="json",
HTTP_AUTHORIZATION=f"Bearer {token}",
)
assert response.status_code == status.HTTP_403_FORBIDDEN
assert response.data == {
"detail": "You do not have permission to perform this action."
}
mock_livekit_client.room.mute_published_track.assert_not_called()
def test_mute_participant_admin_token_replayed_does_not_grant_admin(
mock_livekit_client,
):
"""Should forbid muting when a LiveKit token issued for an admin is passed without a session."""
client = APIClient()
room = RoomFactory(configuration={"everyone_can_mute": False})
admin_user = UserFactory()
UserResourceAccessFactory(
resource=room,
user=admin_user,
role=random.choice(["administrator", "owner"]),
)
# The token is the only credential.
token = utils.generate_token(str(room.id), admin_user, is_admin_or_owner=True)
url = reverse("rooms-mute-participant", kwargs={"pk": room.id})
response = client.post(
url,
{"participant_identity": str(uuid4()), "track_sid": "test-track-sid"},
format="json",
HTTP_AUTHORIZATION=f"Bearer {token}",
)
assert response.status_code == status.HTTP_403_FORBIDDEN
mock_livekit_client.room.mute_published_track.assert_not_called()
def test_mute_participant_livekit_token_triggers_presence_check(mock_livekit_client):
"""Should check participant presence when authenticated via LiveKit token only."""
client = APIClient()
room = RoomFactory()
user = AnonymousUser()
token = utils.generate_token(str(room.id), user, is_admin_or_owner=False)
url = reverse("rooms-mute-participant", kwargs={"pk": room.id})
response = client.post(
url,
{"participant_identity": str(uuid4()), "track_sid": "test-track-sid"},
format="json",
HTTP_AUTHORIZATION=f"Bearer {token}",
)
assert response.status_code == status.HTTP_200_OK
# Presence is verified against LiveKit before the mute is issued.
mock_livekit_client.room.get_participant.assert_called_once()
mock_livekit_client.room.mute_published_track.assert_called_once()
def test_mute_participant_livekit_token_presence_check_returns_participant(
mock_livekit_client,
):
"""Should mute when the authentified participant is currently in the room."""
client = APIClient()
room = RoomFactory()
# Simulate LiveKit confirming the caller is currently in the room.
# State != DISCONNECTED (3) means present.
mock_livekit_client.room.get_participant.return_value = ParticipantInfo(
identity="caller-identity",
state=ParticipantInfo.State.ACTIVE,
)
user = AnonymousUser()
token = utils.generate_token(str(room.id), user, is_admin_or_owner=False)
url = reverse("rooms-mute-participant", kwargs={"pk": room.id})
response = client.post(
url,
{"participant_identity": str(uuid4()), "track_sid": "test-track-sid"},
format="json",
HTTP_AUTHORIZATION=f"Bearer {token}",
)
assert response.status_code == status.HTTP_200_OK
assert response.data == {"status": "success"}
mock_livekit_client.room.get_participant.assert_called_once()
mock_livekit_client.room.mute_published_track.assert_called_once()
def test_mute_participant_livekit_token_presence_check_participant_not_found(
mock_livekit_client,
):
"""Should not mute when the authentified participant is not found."""
client = APIClient()
room = RoomFactory()
mock_livekit_client.room.get_participant.side_effect = TwirpError(
msg="participant does not exist", code="not_found", status=404
)
user = AnonymousUser()
token = utils.generate_token(str(room.id), user, is_admin_or_owner=False)
url = reverse("rooms-mute-participant", kwargs={"pk": room.id})
response = client.post(
url,
{"participant_identity": str(uuid4()), "track_sid": "test-track-sid"},
format="json",
HTTP_AUTHORIZATION=f"Bearer {token}",
)
assert response.status_code == status.HTTP_403_FORBIDDEN
assert response.data == {"error": "Could not verify caller presence"}
mock_livekit_client.room.get_participant.assert_called_once()
# The presence check failed, so we never reach the mute call.
mock_livekit_client.room.mute_published_track.assert_not_called()
def test_mute_participant_livekit_token_presence_check_twirp_error_forbidden(
mock_livekit_client,
):
"""Should not mute when the presence check fail."""
client = APIClient()
room = RoomFactory()
mock_livekit_client.room.get_participant.side_effect = TwirpError(
msg="an error occured", code="not_found", status=500
)
user = AnonymousUser()
token = utils.generate_token(str(room.id), user, is_admin_or_owner=False)
url = reverse("rooms-mute-participant", kwargs={"pk": room.id})
response = client.post(
url,
{"participant_identity": str(uuid4()), "track_sid": "test-track-sid"},
format="json",
HTTP_AUTHORIZATION=f"Bearer {token}",
)
assert response.status_code == status.HTTP_403_FORBIDDEN
assert response.data == {"error": "Could not verify caller presence"}
mock_livekit_client.room.get_participant.assert_called_once()
# The presence check failed, so we never reach the mute call.
mock_livekit_client.room.mute_published_track.assert_not_called()
def test_mute_participant_session_auth_skips_presence_check(mock_livekit_client):
"""Should not check presence of the participant when authentified with a session cookie."""
client = APIClient()
room = RoomFactory()
user = UserFactory()
UserResourceAccessFactory(
resource=room, user=user, role=random.choice(["administrator", "owner"])
)
client.force_authenticate(user=user)
url = reverse("rooms-mute-participant", kwargs={"pk": room.id})
response = client.post(
url,
{"participant_identity": str(uuid4()), "track_sid": "test-track-sid"},
format="json",
)
assert response.status_code == status.HTTP_200_OK
# Session auth has no LiveKit identity to verify against, so the
# stop-gap presence check is skipped.
mock_livekit_client.room.get_participant.assert_not_called()
mock_livekit_client.room.mute_published_track.assert_called_once()
def test_update_participant_success(mock_livekit_client):
"""Test successful participant update."""
client = APIClient()
@@ -521,8 +130,8 @@ def test_update_participant_success(mock_livekit_client):
"can_publish": True,
"can_publish_data": True,
"can_publish_sources": [
"camera",
"microphone",
"CAMERA",
"MICROPHONE",
],
"can_update_metadata": True,
"can_subscribe_metrics": True,
@@ -549,8 +158,8 @@ def test_update_participant_success(mock_livekit_client):
{"can_publish_data": True},
{
"can_publish_sources": [
"camera",
"microphone",
"CAMERA",
"MICROPHONE",
]
},
{"can_update_metadata": True},
@@ -581,41 +190,9 @@ def test_update_participant_permission_fields_are_optional(
assert response.data == {"status": "success"}
mock_livekit_client.room.update_participant.assert_called_once()
(request_arg,), _ = mock_livekit_client.room.update_participant.call_args
assert isinstance(request_arg, UpdateParticipantRequest)
mock_livekit_client.aclose.assert_called_once()
def test_update_participant_permission_fields_invalid_case(mock_livekit_client):
"""Should raise bad request when can_publish_sources is uppercase."""
client = APIClient()
room = RoomFactory()
user = UserFactory()
UserResourceAccessFactory(
resource=room, user=user, role=random.choice(["administrator", "owner"])
)
client.force_authenticate(user=user)
payload = {
"participant_identity": str(uuid4()),
"permission": {
"can_publish_sources": [
"CAMERA",
"microphone",
]
},
}
url = reverse("rooms-update-participant", kwargs={"pk": room.id})
response = client.post(url, payload, format="json")
assert response.status_code == status.HTTP_400_BAD_REQUEST
mock_livekit_client.room.update_participant.assert_not_called()
mock_livekit_client.aclose.assert_not_called()
@pytest.mark.parametrize(
"value,permission_key",
[
@@ -28,7 +28,6 @@ def test_api_rooms_retrieve_anonymous_private_pk():
assert response.status_code == 200
assert response.json() == {
"configuration": {},
"access_level": "restricted",
"id": str(room.id),
"is_administrable": False,
@@ -48,7 +47,6 @@ def test_api_rooms_retrieve_anonymous_trusted_pk():
assert response.status_code == 200
assert response.json() == {
"configuration": {},
"access_level": "trusted",
"id": str(room.id),
"is_administrable": False,
@@ -67,7 +65,6 @@ def test_api_rooms_retrieve_anonymous_private_pk_no_dashes():
assert response.status_code == 200
assert response.json() == {
"configuration": {},
"access_level": "restricted",
"id": str(room.id),
"is_administrable": False,
@@ -84,7 +81,6 @@ def test_api_rooms_retrieve_anonymous_private_slug():
assert response.status_code == 200
assert response.json() == {
"configuration": {},
"access_level": "restricted",
"id": str(room.id),
"is_administrable": False,
@@ -101,7 +97,6 @@ def test_api_rooms_retrieve_anonymous_private_slug_not_normalized():
assert response.status_code == 200
assert response.json() == {
"configuration": {},
"access_level": "restricted",
"id": str(room.id),
"is_administrable": False,
@@ -205,7 +200,6 @@ def test_api_rooms_retrieve_anonymous_public(mock_token):
assert response.status_code == 200
expected_name = f"{room.id!s}"
assert response.json() == {
"configuration": {},
"access_level": str(room.access_level),
"id": str(room.id),
"is_administrable": False,
@@ -238,7 +232,7 @@ def test_api_rooms_retrieve_authenticated_public(mock_token):
"""
room = RoomFactory(
access_level=RoomAccessLevel.PUBLIC,
configuration={"can_publish_sources": ["camera"]},
configuration={"can_publish_sources": ["mock-source"]},
)
user = UserFactory()
@@ -252,7 +246,6 @@ def test_api_rooms_retrieve_authenticated_public(mock_token):
expected_name = f"{room.id!s}"
assert response.json() == {
"configuration": {"can_publish_sources": ["camera"]},
"access_level": str(room.access_level),
"id": str(room.id),
"is_administrable": False,
@@ -271,7 +264,7 @@ def test_api_rooms_retrieve_authenticated_public(mock_token):
user=user,
username=None,
color=None,
sources=["camera"],
sources=["mock-source"],
is_admin_or_owner=False,
participant_id=None,
)
@@ -304,7 +297,6 @@ def test_api_rooms_retrieve_authenticated_trusted(mock_token):
expected_name = f"{room.id!s}"
assert response.json() == {
"configuration": {},
"access_level": str(room.access_level),
"id": str(room.id),
"is_administrable": False,
@@ -346,7 +338,6 @@ def test_api_rooms_retrieve_authenticated():
assert response.status_code == 200
assert response.json() == {
"configuration": {},
"access_level": "restricted",
"id": str(room.id),
"is_administrable": False,
@@ -372,7 +363,7 @@ def test_api_rooms_retrieve_members(mock_token, django_assert_num_queries, setti
other_user = UserFactory()
room = RoomFactory(
configuration={"can_publish_sources": ["camera"]},
configuration={"can_publish_sources": ["mock-source"]},
)
UserResourceAccessFactory(resource=room, user=user, role="member")
UserResourceAccessFactory(resource=room, user=other_user, role="member")
@@ -392,7 +383,6 @@ def test_api_rooms_retrieve_members(mock_token, django_assert_num_queries, setti
expected_name = str(room.id)
assert content_dict == {
"configuration": {"can_publish_sources": ["camera"]},
"access_level": str(room.access_level),
"id": str(room.id),
"is_administrable": False,
@@ -411,7 +401,7 @@ def test_api_rooms_retrieve_members(mock_token, django_assert_num_queries, setti
user=user,
username=None,
color=None,
sources=["camera"],
sources=["mock-source"],
is_admin_or_owner=False,
participant_id=None,
)
@@ -140,18 +140,16 @@ def test_start_recording_worker_error(
mock_worker_service_factory.assert_called_once_with(mode="screen_recording")
assert response.status_code == 502
assert response.status_code == 500
assert response.json() == {
"error": f"Recording failed to start for room {room.slug}"
}
# Recording object should be created even if worker fails, and moved out
# of the unique-constraint window so the room is not locked.
# Recording object should be created even if worker fails
assert Recording.objects.count() == 1
recording = Recording.objects.first()
assert recording.room == room
assert recording.mode == "screen_recording"
assert recording.status == "failed_to_start"
# Verify recording access details
assert recording.accesses.count() == 1
@@ -160,72 +158,6 @@ def test_start_recording_worker_error(
assert access.role == "owner"
@pytest.mark.parametrize(
"status",
["active", "initiated"],
)
def test_start_recording_conflict_when_already_in_progress(
status, mock_worker_service_factory, mock_worker_manager, settings
):
"""Should return 409 when a second start is attempted while a recording is already active."""
settings.RECORDING_ENABLE = True
room = RoomFactory()
user = UserFactory()
room.accesses.create(user=user, role="owner")
# Pre-existing active recording for the same room.
Recording.objects.create(room=room, mode="screen_recording", status="active")
client = APIClient()
client.force_login(user)
response = client.post(
f"/api/v1.0/rooms/{room.id}/start-recording/",
{"mode": "screen_recording"},
)
assert response.status_code == 409
assert response.json() == {
"error": f"A recording is already in progress for room {room.slug}"
}
# No new recording row, no access row leaked from the rolled-back transaction.
assert Recording.objects.count() == 1
assert Recording.objects.first().accesses.count() == 0
mock_worker_manager.start.assert_not_called()
def test_start_recording_after_worker_failure_unblocks_room(
mock_worker_service_factory, mock_worker_manager, settings
):
"""Should allow a new recording when the previous recording failed."""
settings.RECORDING_ENABLE = True
room = RoomFactory()
user = UserFactory()
room.accesses.create(user=user, role="owner")
mock_worker_manager.start = mock.Mock(
side_effect=[RecordingStartError("boom"), None]
)
client = APIClient()
client.force_login(user)
first = client.post(
f"/api/v1.0/rooms/{room.id}/start-recording/",
{"mode": "screen_recording"},
)
assert first.status_code == 502
second = client.post(
f"/api/v1.0/rooms/{room.id}/start-recording/",
{"mode": "screen_recording"},
)
assert second.status_code == 201
assert Recording.objects.count() == 2
def test_start_recording_success(
mock_worker_service_factory, mock_worker_manager, settings
):
@@ -3,18 +3,12 @@ Test rooms API endpoints in the Meet core app: update.
"""
import random
from unittest.mock import patch
import pytest
from rest_framework.test import APIClient
from ...factories import RoomFactory, UserFactory
from ...models import RoomAccessLevel
from ...services.room_management import (
RoomManagement,
RoomManagementException,
RoomNotFoundException,
)
pytestmark = pytest.mark.django_db
@@ -73,7 +67,7 @@ def test_api_rooms_update_members():
"name": "New name",
"slug": "should-be-ignored",
"access_level": RoomAccessLevel.RESTRICTED,
"configuration": {"can_publish_sources": ["camera", "microphone"]},
"configuration": {"the_key": "the_value"},
},
format="json",
)
@@ -85,14 +79,12 @@ def test_api_rooms_update_members():
assert room.configuration == {}
@patch.object(RoomManagement, "update_metadata")
def test_api_rooms_update_administrators(mock_update_metadata):
"""Should sync LiveKit metadata when both configuration and access level change."""
def test_api_rooms_update_administrators():
"""Administrators or owners of a room should be allowed to update it."""
user = UserFactory()
room = RoomFactory(
access_level=RoomAccessLevel.RESTRICTED,
users=[(user, random.choice(["administrator", "owner"]))],
configuration={"can_publish_sources": ["camera"]},
)
client = APIClient()
client.force_login(user)
@@ -103,7 +95,7 @@ def test_api_rooms_update_administrators(mock_update_metadata):
"name": "New name",
"slug": "should-be-ignored",
"access_level": RoomAccessLevel.PUBLIC,
"configuration": {"can_publish_sources": ["camera", "microphone"]},
"configuration": {"the_key": "the_value"},
},
format="json",
)
@@ -112,252 +104,7 @@ def test_api_rooms_update_administrators(mock_update_metadata):
assert room.name == "New name"
assert room.slug == "new-name"
assert room.access_level == RoomAccessLevel.PUBLIC
assert room.configuration == {"can_publish_sources": ["camera", "microphone"]}
mock_update_metadata.assert_called_once_with(
room_name=str(room.id),
metadata={
"access_level": "public",
"configuration": {"can_publish_sources": ["camera", "microphone"]},
},
)
@patch.object(RoomManagement, "update_metadata")
def test_api_rooms_update_administrators_configuration_only(mock_update_metadata):
"""Should sync LiveKit metadata when only configuration changes."""
user = UserFactory()
room = RoomFactory(
access_level=RoomAccessLevel.RESTRICTED,
users=[(user, random.choice(["administrator", "owner"]))],
configuration={},
)
client = APIClient()
client.force_login(user)
response = client.put(
f"/api/v1.0/rooms/{room.id!s}/",
{
"name": "New name",
"slug": "should-be-ignored",
"configuration": {"can_publish_sources": ["camera", "microphone"]},
},
format="json",
)
assert response.status_code == 200
room.refresh_from_db()
assert room.name == "New name"
assert room.slug == "new-name"
assert room.access_level == RoomAccessLevel.RESTRICTED
assert room.configuration == {"can_publish_sources": ["camera", "microphone"]}
mock_update_metadata.assert_called_once_with(
room_name=str(room.id),
metadata={
"access_level": "restricted",
"configuration": {"can_publish_sources": ["camera", "microphone"]},
},
)
@patch.object(RoomManagement, "update_metadata")
def test_api_rooms_update_administrators_access_level_only(mock_update_metadata):
"""Should sync LiveKit metadata when only access level changes."""
user = UserFactory()
room = RoomFactory(
access_level=RoomAccessLevel.RESTRICTED,
users=[(user, random.choice(["administrator", "owner"]))],
configuration={"can_publish_sources": ["camera"]},
)
client = APIClient()
client.force_login(user)
response = client.put(
f"/api/v1.0/rooms/{room.id!s}/",
{
"name": "New name",
"access_level": RoomAccessLevel.PUBLIC,
},
format="json",
)
assert response.status_code == 200
room.refresh_from_db()
assert room.name == "New name"
assert room.slug == "new-name"
assert room.access_level == RoomAccessLevel.PUBLIC
assert room.configuration == {"can_publish_sources": ["camera"]}
mock_update_metadata.assert_called_once_with(
room_name=str(room.id),
metadata={
"access_level": "public",
"configuration": {"can_publish_sources": ["camera"]},
},
)
@patch.object(RoomManagement, "update_metadata")
def test_api_rooms_update_administrators_name_only(mock_update_metadata):
"""Should not sync LiveKit metadata when neither configuration nor access level changes."""
user = UserFactory()
room = RoomFactory(
name="Old name",
access_level=RoomAccessLevel.PUBLIC,
configuration={"can_publish_sources": ["camera"]},
users=[(user, random.choice(["administrator", "owner"]))],
)
client = APIClient()
client.force_login(user)
response = client.patch(
f"/api/v1.0/rooms/{room.id!s}/",
{"name": "New name"},
format="json",
)
assert response.status_code == 200
room.refresh_from_db()
assert room.name == "New name"
assert room.slug == "new-name"
# Unrelated fields untouched
assert room.access_level == RoomAccessLevel.PUBLIC
assert room.configuration == {"can_publish_sources": ["camera"]}
mock_update_metadata.assert_not_called()
@pytest.mark.parametrize(
"configuration",
[
{"can_publish_sources": ["camera", "microphone"]},
{
"can_publish_sources": [
"camera",
"microphone",
"screen_share",
"screen_share_audio",
]
},
{"can_publish_sources": []},
{"can_publish_sources": None},
{"can_publish_sources": None, "everyone_can_mute": True},
{"can_publish_sources": None, "everyone_can_mute": False},
{"can_publish_sources": None, "everyone_can_mute": "yes"},
{"can_publish_sources": None, "everyone_can_mute": "1"},
],
)
@patch.object(RoomManagement, "update_metadata")
def test_api_rooms_update_configuration_valid(mock_update_metadata, configuration):
"""Administrators should be allowed to set valid configurations."""
user = UserFactory()
room = RoomFactory(users=[(user, "owner")], configuration={})
client = APIClient()
client.force_login(user)
response = client.patch(
f"/api/v1.0/rooms/{room.id!s}/",
{"configuration": configuration},
format="json",
)
assert response.status_code == 200
room.refresh_from_db()
assert room.configuration == configuration
mock_update_metadata.assert_called_once()
@patch.object(RoomManagement, "update_metadata")
def test_api_rooms_update_configuration_unchanged_empty(mock_update_metadata):
"""Should not sync LiveKit metadata when patching an already empty configuration."""
user = UserFactory()
room = RoomFactory(users=[(user, "owner")], configuration={})
client = APIClient()
client.force_login(user)
response = client.patch(
f"/api/v1.0/rooms/{room.id!s}/",
{"configuration": {}},
format="json",
)
assert response.status_code == 200
room.refresh_from_db()
assert room.configuration == {}
mock_update_metadata.assert_not_called()
def test_api_rooms_update_configuration_extra_keys_rejected():
"""Extra keys in configuration should be rejected."""
user = UserFactory()
room = RoomFactory(users=[(user, "owner")])
client = APIClient()
client.force_login(user)
response = client.patch(
f"/api/v1.0/rooms/{room.id!s}/",
{
"configuration": {
"can_publish_sources": ["camera"],
"arbitrary_key": "value",
}
},
format="json",
)
assert response.status_code == 400
room.refresh_from_db()
assert room.configuration == {}
@pytest.mark.parametrize("invalid_source", ["invalid_source", "CAMERA"])
def test_api_rooms_update_configuration_invalid_source_value(invalid_source):
"""Invalid source values should be rejected."""
user = UserFactory()
room = RoomFactory(users=[(user, "owner")])
client = APIClient()
client.force_login(user)
response = client.patch(
f"/api/v1.0/rooms/{room.id!s}/",
{"configuration": {"can_publish_sources": [invalid_source]}},
format="json",
)
assert response.status_code == 400
room.refresh_from_db()
assert room.configuration == {}
def test_api_rooms_update_configuration_wrong_type():
"""Configuration values with wrong types should be rejected."""
user = UserFactory()
room = RoomFactory(users=[(user, "owner")])
client = APIClient()
client.force_login(user)
response = client.patch(
f"/api/v1.0/rooms/{room.id!s}/",
{"configuration": {"can_publish_sources": "camera"}},
format="json",
)
assert response.status_code == 400
room.refresh_from_db()
assert room.configuration == {}
@pytest.mark.parametrize("invalid_value", ["test", [], {}])
def test_api_rooms_update_configuration_everyone_can_mute_wrong_type(invalid_value):
"""everyone_can_mute values with wrong types should be rejected."""
user = UserFactory()
room = RoomFactory(users=[(user, "owner")])
client = APIClient()
client.force_login(user)
response = client.patch(
f"/api/v1.0/rooms/{room.id!s}/",
{"configuration": {"everyone_can_mute": invalid_value}},
format="json",
)
assert response.status_code == 400
room.refresh_from_db()
assert room.configuration == {}
assert room.configuration == {"the_key": "the_value"}
def test_api_rooms_update_administrators_of_another():
@@ -379,61 +126,3 @@ def test_api_rooms_update_administrators_of_another():
other_room.refresh_from_db()
assert other_room.name == "Old name"
assert other_room.slug == "old-name"
@patch.object(RoomManagement, "update_metadata", side_effect=RoomNotFoundException)
def test_api_rooms_update_livekit_room_not_found(mock_update_metadata):
"""Should not fail the API request when the LiveKit room does not exist yet."""
user = UserFactory()
room = RoomFactory(
users=[(user, random.choice(["administrator", "owner"]))],
configuration={},
)
client = APIClient()
client.force_login(user)
response = client.patch(
f"/api/v1.0/rooms/{room.id!s}/",
{"configuration": {"can_publish_sources": ["camera"]}},
format="json",
)
assert response.status_code == 200
room.refresh_from_db()
assert room.configuration == {"can_publish_sources": ["camera"]}
mock_update_metadata.assert_called_once_with(
room_name=str(room.id),
metadata={
"access_level": room.access_level,
"configuration": {"can_publish_sources": ["camera"]},
},
)
@patch.object(RoomManagement, "update_metadata", side_effect=RoomManagementException)
def test_api_rooms_update_livekit_sync_failure(mock_update_metadata):
"""Should not fail the API request when the LiveKit metadata sync fails."""
user = UserFactory()
room = RoomFactory(
users=[(user, random.choice(["administrator", "owner"]))],
configuration={},
)
client = APIClient()
client.force_login(user)
response = client.patch(
f"/api/v1.0/rooms/{room.id!s}/",
{"configuration": {"can_publish_sources": ["camera"]}},
format="json",
)
assert response.status_code == 200
room.refresh_from_db()
assert room.configuration == {"can_publish_sources": ["camera"]}
mock_update_metadata.assert_called_once_with(
room_name=str(room.id),
metadata={
"access_level": room.access_level,
"configuration": {"can_publish_sources": ["camera"]},
},
)
+1 -5
View File
@@ -121,11 +121,7 @@ def generate_token(
.with_identity(identity)
.with_name(username or default_username)
.with_attributes(
{
"color": color,
"room_admin": "true" if is_admin_or_owner else "false",
"is_authenticated": not user.is_anonymous,
}
{"color": color, "room_admin": "true" if is_admin_or_owner else "false"}
)
)
@@ -576,8 +576,8 @@ msgstr "So speichern Sie diese Aufzeichnung dauerhaft:"
#: core/templates/mail/html/screen_recording.html:208
#: core/templates/mail/text/screen_recording.txt:13
msgid "Click the \"<a href=\"%(link)s\">Open</a>\" link below "
msgstr "Klicken Sie auf den Link „<a href=\"%(link)s\">Öffnen</a>\" unten "
msgid "Click the \"Open\" button below "
msgstr "Klicken Sie auf den Button „Öffnen“ unten "
#: core/templates/mail/html/screen_recording.html:209
#: core/templates/mail/text/screen_recording.txt:14
@@ -572,8 +572,8 @@ msgstr "To keep this recording permanently:"
#: core/templates/mail/html/screen_recording.html:208
#: core/templates/mail/text/screen_recording.txt:13
msgid "Click the \"<a href=\"%(link)s\">Open</a>\" link below "
msgstr "Click the \"<a href=\"%(link)s\">Open</a>\" link below "
msgid "Click the \"Open\" button below "
msgstr "Click the \"Open\" button below "
#: core/templates/mail/html/screen_recording.html:209
#: core/templates/mail/text/screen_recording.txt:14
@@ -578,8 +578,8 @@ msgstr "Pour conserver cet enregistrement de façon permanente :"
#: core/templates/mail/html/screen_recording.html:208
#: core/templates/mail/text/screen_recording.txt:13
msgid "Click the \"<a href=\"%(link)s\">Open</a>\" link below "
msgstr "Cliquez sur le lien \"<a href=\"%(link)s\">Ouvrir</a>\" ci-dessous "
msgid "Click the \"Open\" button below "
msgstr "Cliquez sur le bouton \"Ouvrir\" ci-dessous "
#: core/templates/mail/html/screen_recording.html:209
#: core/templates/mail/text/screen_recording.txt:14
@@ -571,8 +571,8 @@ msgstr "Om deze opname permanent te bewaren:"
#: core/templates/mail/html/screen_recording.html:208
#: core/templates/mail/text/screen_recording.txt:13
msgid "Click the \"<a href=\"%(link)s\">Open</a>\" link below "
msgstr "Klik op de \"<a href=\"%(link)s\">Openen</a>\"-link hieronder "
msgid "Click the \"Open\" button below "
msgstr "Klik op de \"Openen\"-knop hieronder "
#: core/templates/mail/html/screen_recording.html:209
#: core/templates/mail/text/screen_recording.txt:14
-43
View File
@@ -700,44 +700,6 @@ class Base(Configuration):
RECORDING_MAX_DURATION = values.IntegerValue(
None, environ_name="RECORDING_MAX_DURATION", environ_prefix=None
)
# Recording encoding options for LiveKit Egress (video composite egress only).
# These settings affect screen recordings handled by VideoCompositeEgressService;
# they are silently ignored by AudioCompositeEgressService (audio-only transcript
# recordings), whose request never carries advanced EncodingOptions.
# When disabled, LiveKit falls back to its built-in H264_720P_30 preset
# (1280x720, 30 fps, 3000 kbps H.264 MAIN video, 128 kbps AAC audio).
# When enabled, the values below are passed to LiveKit as EncodingOptions
# (advanced) and replace the preset. Lowering framerate and bitrate reduces
# output file size and CPU load on the egress worker.
RECORDING_ENCODING_ENABLED = values.BooleanValue(
False, environ_name="RECORDING_ENCODING_ENABLED", environ_prefix=None
)
RECORDING_ENCODING_WIDTH = values.PositiveIntegerValue(
1280, environ_name="RECORDING_ENCODING_WIDTH", environ_prefix=None
)
RECORDING_ENCODING_HEIGHT = values.PositiveIntegerValue(
720, environ_name="RECORDING_ENCODING_HEIGHT", environ_prefix=None
)
RECORDING_ENCODING_FRAMERATE = values.PositiveIntegerValue(
30, environ_name="RECORDING_ENCODING_FRAMERATE", environ_prefix=None
)
RECORDING_ENCODING_VIDEO_BITRATE_KBPS = values.PositiveIntegerValue(
3000,
environ_name="RECORDING_ENCODING_VIDEO_BITRATE_KBPS",
environ_prefix=None,
)
RECORDING_ENCODING_AUDIO_BITRATE_KBPS = values.PositiveIntegerValue(
128,
environ_name="RECORDING_ENCODING_AUDIO_BITRATE_KBPS",
environ_prefix=None,
)
RECORDING_ENCODING_KEY_FRAME_INTERVAL_S = values.FloatValue(
4.0,
environ_name="RECORDING_ENCODING_KEY_FRAME_INTERVAL_S",
environ_prefix=None,
)
SUMMARY_SERVICE_ENDPOINT = values.Value(
None, environ_name="SUMMARY_SERVICE_ENDPOINT", environ_prefix=None
)
@@ -855,11 +817,6 @@ class Base(Configuration):
environ_name="METADATA_COLLECTOR_AGENT_NAME",
environ_prefix=None,
)
METADATA_COLLECTOR_OUTPUT_FOLDER = values.Value(
"metadata",
environ_name="METADATA_COLLECTOR_OUTPUT_FOLDER",
environ_prefix=None,
)
# External Applications
APPLICATION_ENABLED = values.BooleanValue(
+2 -3
View File
@@ -7,7 +7,7 @@ build-backend = "uv_build"
[project]
name = "meet"
version = "1.16.0"
version = "1.15.0"
authors = [{ "name" = "DINUM", "email" = "dev@mail.numerique.gouv.fr" }]
classifiers = [
"Development Status :: 5 - Production/Stable",
@@ -40,7 +40,7 @@ dependencies = [
"django-storages[s3]==1.14.6",
"django-timezone-field>=5.1",
"django-pydantic-field==0.5.4",
"django==5.2.14",
"django==5.2.13",
"djangorestframework==3.16.1",
"drf_spectacular==0.29.0",
"dockerflow==2026.3.4",
@@ -61,7 +61,6 @@ dependencies = [
"mozilla-django-oidc==5.0.2",
"livekit-api==1.1.0",
"aiohttp==3.13.4",
"urllib3==2.7.0",
]
[project.urls]
+8 -10
View File
@@ -573,16 +573,16 @@ wheels = [
[[package]]
name = "django"
version = "5.2.14"
version = "5.2.13"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "asgiref" },
{ name = "sqlparse" },
{ name = "tzdata", marker = "sys_platform == 'win32'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/65/95/95f7faa0950867afaa0bef2460c6263afd6a2c78cc9434046ed28160b015/django-5.2.14.tar.gz", hash = "sha256:58a63ba841662e5c686b57ba1fec52ddd68c0b93bd96ac3029d55728f00bf8a2", size = 10895118, upload-time = "2026-05-05T13:57:31.104Z" }
sdist = { url = "https://files.pythonhosted.org/packages/1f/c5/c69e338eb2959f641045802e5ea87ca4bf5ac90c5fd08953ca10742fad51/django-5.2.13.tar.gz", hash = "sha256:a31589db5188d074c63f0945c3888fad104627dfcc236fb2b97f71f89da33bc4", size = 10890368, upload-time = "2026-04-07T14:02:15.072Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/14/44/f172870cf87aa25afef48fb72adba89ee8b77fcab6f3b23d240b923f1528/django-5.2.14-py3-none-any.whl", hash = "sha256:6f712143bd3064310d1f50fac859c3e9a274bdcfc9595339853be7779297fc76", size = 8311320, upload-time = "2026-05-05T13:57:25.795Z" },
{ url = "https://files.pythonhosted.org/packages/59/b1/51ab36b2eefcf8cdb9338c7188668a157e29e30306bfc98a379704c9e10d/django-5.2.13-py3-none-any.whl", hash = "sha256:5788fce61da23788a8ce6f02583765ab060d396720924789f97fa42119d37f7a", size = 8310982, upload-time = "2026-04-07T14:02:08.883Z" },
]
[[package]]
@@ -1173,7 +1173,7 @@ wheels = [
[[package]]
name = "meet"
version = "1.16.0"
version = "1.15.0"
source = { editable = "." }
dependencies = [
{ name = "aiohttp" },
@@ -1212,7 +1212,6 @@ dependencies = [
{ name = "redis" },
{ name = "requests" },
{ name = "sentry-sdk" },
{ name = "urllib3" },
{ name = "whitenoise" },
]
@@ -1244,7 +1243,7 @@ requires-dist = [
{ name = "brotli", specifier = "==1.2.0" },
{ name = "celery", extras = ["redis"], specifier = "==5.6.2" },
{ name = "dj-database-url", specifier = "==3.1.2" },
{ name = "django", specifier = "==5.2.14" },
{ name = "django", specifier = "==5.2.13" },
{ name = "django-configurations", specifier = "==2.5.1" },
{ name = "django-cors-headers", specifier = "==4.9.0" },
{ name = "django-countries", specifier = "==8.2.0" },
@@ -1274,7 +1273,6 @@ requires-dist = [
{ name = "redis", specifier = "==5.2.1" },
{ name = "requests", specifier = "==2.33.0" },
{ name = "sentry-sdk", specifier = "==2.54.0" },
{ name = "urllib3", specifier = "==2.7.0" },
{ name = "whitenoise", specifier = "==6.12.0" },
]
@@ -2262,11 +2260,11 @@ wheels = [
[[package]]
name = "urllib3"
version = "2.7.0"
version = "2.6.3"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/53/0c/06f8b233b8fd13b9e5ee11424ef85419ba0d8ba0b3138bf360be2ff56953/urllib3-2.7.0.tar.gz", hash = "sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c", size = 433602, upload-time = "2026-05-07T16:13:18.596Z" }
sdist = { url = "https://files.pythonhosted.org/packages/c7/24/5f1b3bdffd70275f6661c76461e25f024d5a38a46f04aaca912426a2b1d3/urllib3-2.6.3.tar.gz", hash = "sha256:1b62b6884944a57dbe321509ab94fd4d3b307075e0c2eae991ac71ee15ad38ed", size = 435556, upload-time = "2026-01-07T16:24:43.925Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/7f/3e/5db95bcf282c52709639744ca2a8b149baccf648e39c8cc87553df9eae0c/urllib3-2.7.0-py3-none-any.whl", hash = "sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897", size = 131087, upload-time = "2026-05-07T16:13:17.151Z" },
{ url = "https://files.pythonhosted.org/packages/39/08/aaaad47bc4e9dc8c725e68f9d04865dbcb2052843ff09c97b08904852d84/urllib3-2.6.3-py3-none-any.whl", hash = "sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4", size = 131584, upload-time = "2026-01-07T16:24:42.685Z" },
]
[[package]]
-3
View File
@@ -1,3 +0,0 @@
declare module '@fontsource-variable/lexend' {}
declare module '@fontsource-variable/atkinson-hyperlegible-next' {}
declare module '@fontsource/opendyslexic' {}
+353 -458
View File
File diff suppressed because it is too large Load Diff
+3 -6
View File
@@ -1,7 +1,7 @@
{
"name": "meet",
"private": true,
"version": "1.16.0",
"version": "1.15.0",
"type": "module",
"scripts": {
"dev": "panda codegen && vite",
@@ -13,11 +13,8 @@
"check": "prettier --check ./src"
},
"dependencies": {
"@fontsource-variable/atkinson-hyperlegible-next": "5.2.6",
"@fontsource-variable/lexend": "5.2.11",
"@fontsource-variable/material-symbols-outlined": "5.2.34",
"@fontsource/material-icons-outlined": "5.2.6",
"@fontsource/opendyslexic": "5.2.5",
"@livekit/components-react": "2.9.19",
"@livekit/components-styles": "1.2.0",
"@livekit/track-processors": "0.7.0",
@@ -47,7 +44,7 @@
"wouter": "3.9.0"
},
"devDependencies": {
"@pandacss/dev": "1.11.1",
"@pandacss/dev": "1.8.2",
"@tanstack/eslint-plugin-query": "5.91.4",
"@tanstack/react-query-devtools": "5.91.3",
"@types/humanize-duration": "3.27.4",
@@ -62,7 +59,7 @@
"eslint-plugin-jsx-a11y": "6.10.2",
"eslint-plugin-react-hooks": "5.2.0",
"eslint-plugin-react-refresh": "0.4.20",
"postcss": "8.5.14",
"postcss": "8.5.6",
"prettier": "3.8.1",
"typescript": "5.8.3",
"vite": "7.3.2",
-2
View File
@@ -14,14 +14,12 @@ import './i18n/init'
import { queryClient } from '@/api/queryClient'
import { AppInitialization } from '@/components/AppInitialization'
import { useIsSdkContext } from '@/features/sdk/hooks/useIsSdkContext'
import { useApplyA11yFonts } from '@/hooks/useApplyA11yFonts'
function App() {
const { i18n } = useTranslation()
useTitle(import.meta.env.VITE_APP_TITLE ?? '')
const isSDKContext = useIsSdkContext()
useApplyA11yFonts()
return (
<QueryClientProvider client={queryClient}>
+1 -3
View File
@@ -2,8 +2,6 @@ import { fetchApi } from './fetchApi'
import { keys } from './queryKeys'
import { useQuery } from '@tanstack/react-query'
import { RecordingMode } from '@/features/recording'
import { Track } from 'livekit-client'
import Source = Track.Source
export interface ApiConfig {
analytics?: {
@@ -52,7 +50,7 @@ export interface ApiConfig {
url: string
force_wss_protocol: boolean
enable_firefox_proxy_workaround: boolean
default_sources: Source[]
default_sources: string[]
}
transcription_destination?: string
}
@@ -12,7 +12,7 @@ const controlBarRegion = cva({
variants: {
mobile: {
true: {
justifyContent: 'center',
justifyContent: 'space-between',
width: '330px',
},
},
@@ -1,6 +1,3 @@
import { Track } from 'livekit-client'
import Source = Track.Source
export type ApiLiveKit = {
url: string
room: string
@@ -13,11 +10,6 @@ export enum ApiAccessLevel {
RESTRICTED = 'restricted',
}
export type RoomConfiguration = {
can_publish_sources?: Source[] | null
everyone_can_mute?: boolean | null
}
export type ApiRoom = {
id: string
name: string
@@ -26,5 +18,7 @@ export type ApiRoom = {
is_administrable: boolean
access_level: ApiAccessLevel
livekit?: ApiLiveKit
configuration?: RoomConfiguration
configuration?: {
[key: string]: string | number | boolean | string[]
}
}
@@ -6,74 +6,44 @@ import {
NotificationType,
} from '@/features/notifications'
import { fetchApi } from '@/api/fetchApi'
import { useIsAdminOrOwner } from '../livekit/hooks/useIsAdminOrOwner'
import { useCallback } from 'react'
export const useMuteParticipant = () => {
const apiRoomData = useRoomData()
const data = useRoomData()
const { notifyParticipants } = useNotifyParticipants()
const isAdminOrOwner = useIsAdminOrOwner()
const muteParticipant = useCallback(
async (participant: Participant) => {
if (!apiRoomData?.livekit?.room) {
throw new Error('Room id is not available')
}
const muteParticipant = async (participant: Participant) => {
if (!data?.id) {
throw new Error('Room id is not available')
}
const trackSid = participant.getTrackPublication(
Source.Microphone
)?.trackSid
const trackSid = participant.getTrackPublication(
Source.Microphone
)?.trackSid
if (!trackSid) {
return
}
if (!trackSid) {
return
}
try {
const response = await fetchApi(`rooms/${data.id}/mute-participant/`, {
method: 'POST',
body: JSON.stringify({
participant_identity: participant.identity,
track_sid: trackSid,
}),
})
// Guard against undefined token for non-admin users
if (!isAdminOrOwner && !apiRoomData.livekit.token) {
console.error('Cannot mute participant: missing auth token')
return
}
const headers = !isAdminOrOwner
? { Authorization: `Bearer ${apiRoomData.livekit.token}` }
: undefined
let response
try {
response = await fetchApi(
`rooms/${apiRoomData.livekit.room}/mute-participant/`,
{
method: 'POST',
headers,
body: JSON.stringify({
participant_identity: participant.identity,
track_sid: trackSid,
}),
}
)
} catch (error) {
console.error(
`Failed to mute participant ${participant.identity}: ${error instanceof Error ? error.message : 'Unknown error'}`
)
return
}
try {
await notifyParticipants({
type: NotificationType.ParticipantMuted,
destinationIdentities: [participant.identity],
})
} catch (e) {
console.error(
`Failed to notify muted participant ${participant.identity}: ${e}`
)
}
await notifyParticipants({
type: NotificationType.ParticipantMuted,
destinationIdentities: [participant.identity],
})
return response
},
[apiRoomData, isAdminOrOwner, notifyParticipants]
)
} catch (error) {
console.error(
`Failed to mute participant ${participant.identity}: ${error instanceof Error ? error.message : 'Unknown error'}`
)
}
}
return { muteParticipant }
}
@@ -8,7 +8,7 @@ export const useParticipantPermissions = () => {
const updateParticipantPermissions = async (
participant: Participant,
sources: Source[]
sources: Array<Source>
) => {
if (!data?.id) {
throw new Error('Room id is not available')
@@ -20,7 +20,7 @@ export const useParticipantPermissions = () => {
can_update_metadata: participant.permissions?.canUpdateMetadata,
can_subscribe_metrics: participant.permissions?.canSubscribeMetrics,
can_publish: sources.length > 0,
can_publish_sources: sources,
can_publish_sources: sources.map((source) => source.toUpperCase()),
}
try {
@@ -9,8 +9,7 @@ import { queryClient } from '@/api/queryClient'
import { keys } from '@/api/queryKeys'
import { useQuery } from '@tanstack/react-query'
import { useParams } from 'wouter'
import { usePublishSourcesManager } from '../hooks/usePublishSourcesManager'
import { usePermissionsManager } from '../hooks/usePermissionsManager'
import { usePublishSourcesManager } from '@/features/rooms/livekit/hooks/usePublishSourcesManager'
export const Admin = () => {
const { t } = useTranslation('rooms', { keyPrefix: 'admin' })
@@ -39,8 +38,6 @@ export const Admin = () => {
isScreenShareEnabled,
} = usePublishSourcesManager()
const { toggleMuting, isMutingEnabled } = usePermissionsManager()
return (
<Div
display="flex"
@@ -133,17 +130,6 @@ export const Admin = () => {
fullWidth: true,
}}
/>
<Field
type="switch"
label={t('moderation.mute.label')}
description={t('moderation.mute.description')}
isSelected={isMutingEnabled}
onChange={toggleMuting}
wrapperProps={{
noMargin: true,
fullWidth: true,
}}
/>
</div>
</div>
<div
@@ -1,13 +1,7 @@
import { useIsAdminOrOwner } from './useIsAdminOrOwner'
import { Participant } from 'livekit-client'
import { useRoomData } from '@/features/rooms/livekit/hooks/useRoomData'
export const useCanMute = (participant: Participant) => {
const apiRoomData = useRoomData()
const isAdminOrOwner = useIsAdminOrOwner()
return (
participant.isLocal ||
isAdminOrOwner ||
apiRoomData?.configuration?.everyone_can_mute !== false
)
return participant.isLocal || isAdminOrOwner
}
@@ -1,46 +0,0 @@
import { usePatchRoom } from '@/features/rooms/api/patchRoom'
import { useRoomData } from '@/features/rooms/livekit/hooks/useRoomData'
import { useCallback } from 'react'
import { queryClient } from '@/api/queryClient'
import { keys } from '@/api/queryKeys'
export const usePermissionsManager = () => {
const { mutateAsync: patchRoom } = usePatchRoom()
const data = useRoomData()
const configuration = data?.configuration
const roomId = data?.slug
const isMutingEnabled = configuration?.everyone_can_mute ?? true
const toggleMuting = useCallback(
async (enabled: boolean) => {
if (!roomId) return
try {
const newConfiguration = {
...configuration,
everyone_can_mute: enabled,
}
const room = await patchRoom({
roomId,
room: { configuration: newConfiguration },
})
queryClient.setQueryData([keys.room, roomId], room)
return { configuration: newConfiguration }
} catch (error) {
console.error('Failed to update muting permission:', error)
return { success: false, error }
}
},
[configuration, roomId, patchRoom]
)
return {
toggleMuting,
isMutingEnabled,
}
}
@@ -39,6 +39,10 @@ export const usePublishSourcesManager = () => {
const { notifyParticipants } = useNotifyParticipants()
const defaultSources = configData?.livekit?.default_sources?.map((source) => {
return source as Source
})
// The name can be misleading—use the slug instead to ensure the correct React Query key is updated.
const roomId = data?.slug
@@ -50,16 +54,16 @@ export const usePublishSourcesManager = () => {
)
const currentSources = useMemo(() => {
const defaultSources = configData?.livekit?.default_sources ?? []
if (
configuration?.can_publish_sources == undefined ||
!Array.isArray(configuration?.can_publish_sources)
) {
return defaultSources
}
return configuration.can_publish_sources
}, [configData, configuration?.can_publish_sources])
return configuration.can_publish_sources.map((source) => {
return source as Source
})
}, [defaultSources, configuration?.can_publish_sources])
const updateSource = useCallback(
async (sources: Source[], enabled: boolean) => {
@@ -74,7 +78,7 @@ export const usePublishSourcesManager = () => {
const newConfiguration = {
...configuration,
can_publish_sources: newSources,
can_publish_sources: newSources as string[],
}
const room = await patchRoom({
@@ -1,86 +0,0 @@
// features/rooms/hooks/useSyncLiveKitMetadata.ts
import { useEffect } from 'react'
import { RoomEvent } from 'livekit-client'
import { queryClient } from '@/api/queryClient'
import { keys } from '@/api/queryKeys'
import {
ApiAccessLevel,
ApiRoom,
RoomConfiguration,
} from '@/features/rooms/api/ApiRoom'
import { useRoomContext } from '@livekit/components-react'
import { useRoomData } from './useRoomData'
/**
* Shape of the LiveKit room metadata blob pushed by the backend.
* Matches RoomManagement.update_metadata {"configuration": room.configuration}
*/
type RoomLiveKitMetadata = {
configuration?: RoomConfiguration
access_level?: ApiAccessLevel
}
const parseMetadata = (raw: string | undefined): RoomLiveKitMetadata | null => {
if (!raw) return null
try {
return JSON.parse(raw) as RoomLiveKitMetadata
} catch {
console.warn('useSyncLiveKitMetadata: failed to parse room metadata')
return null
}
}
/**
* Sync LiveKit room metadata into the React Query cache.
*
* The backend pushes room configuration into LiveKit's room metadata
* whenever it changes. This hook listens for those changes and patches
* the ApiRoom cache so every `useRoomData()`
* consumer sees the fresh value automatically.
*
* Mount once, at the level where the LiveKit Room instance lives.
*/
export const useSyncLiveKitMetadata = () => {
const room = useRoomContext()
const roomData = useRoomData()
const roomSlug = roomData?.slug
useEffect(() => {
if (!room || !roomSlug) return
const applyMetadata = (raw: string | undefined) => {
const parsed = parseMetadata(raw)
if (!parsed) return
queryClient.setQueryData<ApiRoom>([keys.room, roomSlug], (prev) => {
if (!prev) return prev
const nextConfiguration = parsed.configuration ?? prev.configuration
const nextAccessLevel = parsed.access_level ?? prev.access_level
if (
nextConfiguration === prev.configuration &&
nextAccessLevel === prev.access_level
) {
return prev
}
return {
...prev,
configuration: nextConfiguration,
access_level: nextAccessLevel,
}
})
}
// Apply whatever metadata is currently set (covers the case where we
// joined the room AFTER the last metadata change, so no event will fire).
applyMetadata(room.metadata)
const handler = (raw: string) => applyMetadata(raw)
room.on(RoomEvent.RoomMetadataChanged, handler)
return () => {
room.off(RoomEvent.RoomMetadataChanged, handler)
}
}, [room, roomSlug])
}
@@ -32,7 +32,6 @@ import { useRegisterKeyboardShortcut } from '@/features/shortcuts/useRegisterKey
import { useSettingsDialog } from '@/features/settings'
import { SettingsDialogExtendedKey } from '@/features/settings/type'
import { useVideoResolutionSubscription } from '../hooks/useVideoResolutionSubscription'
import { useSyncLiveKitMetadata } from '../hooks/useSyncLiveKitMetadata'
import { SettingsDialogProvider } from '@/features/settings/components/SettingsDialogProvider'
import { IsIdleDisconnectModal } from '../components/IsIdleDisconnectModal'
import { getParticipantName } from '@/features/rooms/utils/getParticipantName'
@@ -91,7 +90,6 @@ export function VideoConference({ ...props }: VideoConferenceProps) {
useConnectionObserver()
useRoomPageTitle()
useVideoResolutionSubscription()
useSyncLiveKitMetadata()
useRegisterKeyboardShortcut({
id: 'open-shortcuts',
@@ -1,20 +1,10 @@
// Google Meet uses only letters in a room identifier
const ROOM_ID_ALLOWED_CHARACTERS = 'abcdefghijklmnopqrstuvwxyz'
const getRandomChar = () => {
const maxValue =
Math.floor(0x100000000 / ROOM_ID_ALLOWED_CHARACTERS.length) *
ROOM_ID_ALLOWED_CHARACTERS.length
const randomValue = new Uint32Array(1)
do {
crypto.getRandomValues(randomValue)
} while (randomValue[0] >= maxValue)
return ROOM_ID_ALLOWED_CHARACTERS[
randomValue[0] % ROOM_ID_ALLOWED_CHARACTERS.length
const getRandomChar = () =>
ROOM_ID_ALLOWED_CHARACTERS[
Math.floor(Math.random() * ROOM_ID_ALLOWED_CHARACTERS.length)
]
}
const generateSegment = (length: number): string =>
Array.from(Array(length), getRandomChar).join('')
@@ -1,14 +1,9 @@
import { Field, H, Text } from '@/primitives'
import { Field, H } from '@/primitives'
import { TabPanel, TabPanelProps } from '@/primitives/Tabs'
import { css } from '@/styled-system/css'
import { useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import { useSnapshot } from 'valtio'
import {
accessibilityStore,
UI_FONT_OPTIONS,
type UiFont,
} from '@/stores/accessibility'
import { accessibilityStore } from '@/stores/accessibility'
import { CaptionsSettings } from '@/features/subtitle/component/CaptionsSettings'
export type AccessibilityTabProps = Pick<TabPanelProps, 'id'>
@@ -17,15 +12,6 @@ export const AccessibilityTab = ({ id }: AccessibilityTabProps) => {
const { t } = useTranslation('settings')
const snap = useSnapshot(accessibilityStore)
const fontItems = useMemo(
() =>
UI_FONT_OPTIONS.map((font) => ({
value: font,
label: t(`accessibility.font.options.${font}`),
})),
[t]
)
return (
<TabPanel padding={'md'} flex id={id}>
<H lvl={2}>{t('tabs.accessibility')}</H>
@@ -47,21 +33,6 @@ export const AccessibilityTab = ({ id }: AccessibilityTabProps) => {
wrapperProps={{ noMargin: true, fullWidth: true }}
/>
</li>
<li>
<Field
type="select"
label={t('accessibility.font.label')}
items={fontItems}
selectedKey={snap.uiFont}
onSelectionChange={(key) => {
accessibilityStore.uiFont = key as UiFont
}}
wrapperProps={{ noMargin: true, fullWidth: true }}
/>
<Text variant="smNote" className={css({ marginTop: '0.25rem' })}>
{t('accessibility.font.description')}
</Text>
</li>
<CaptionsSettings />
</ul>
</TabPanel>
@@ -1,38 +0,0 @@
import { useEffect } from 'react'
import { useSnapshot } from 'valtio'
import { accessibilityStore, UiFont } from '@/stores/accessibility'
const fontImports: Partial<Record<UiFont, () => Promise<unknown>>> = {
lexend: () => import('@fontsource-variable/lexend'),
'atkinson-hyperlegible': () =>
import('@fontsource-variable/atkinson-hyperlegible-next'),
opendyslexic: () => import('@fontsource/opendyslexic'),
}
const loadedFonts = new Set<UiFont>()
export function useApplyA11yFonts() {
const { uiFont } = useSnapshot(accessibilityStore)
useEffect(() => {
if (uiFont === 'default') {
return
}
const className = `font-${uiFont}`
const loader = fontImports[uiFont]
if (loader && !loadedFonts.has(uiFont)) {
loader().then(() => {
loadedFonts.add(uiFont)
document.documentElement.classList.add(className)
})
} else {
document.documentElement.classList.add(className)
}
return () => {
document.documentElement.classList.remove(className)
}
}, [uiFont])
}
+2 -2
View File
@@ -1,7 +1,7 @@
{
"error": {
"title": "Aufzeichnung nicht verfügbar",
"body": "Die Aufzeichnung ist nicht verfügbar oder wurde möglicherweise gelöscht. Nur der Host der Besprechung hat Zugriff darauf. Wende dich bei Bedarf gerne an die Person."
"body": "Die Aufzeichnung ist nicht verfügbar oder wurde möglicherweise gelöscht. Nur die*der Organisator:in der Besprechung hat Zugriff darauf. Wende dich bei Bedarf gerne an die Person."
},
"expired": {
"title": "Aufzeichnung abgelaufen",
@@ -22,7 +22,7 @@
"button": "Herunterladen",
"warning": {
"title": "Linkfreigabe deaktiviert",
"body": "Die Freigabe der Aufzeichnung per Link ist noch nicht verfügbar. Nur Hosts können sie herunterladen."
"body": "Die Freigabe der Aufzeichnung per Link ist noch nicht verfügbar. Nur Organisator:innen können sie herunterladen."
}
}
}
+4 -8
View File
@@ -3,7 +3,7 @@
"heading": {
"normal": "Du hast das Meeting verlassen",
"duplicateIdentity": "Du bist dem Meeting von einem anderen Gerät aus beigetreten",
"participantRemoved": "Du wurdest vom Host aus dem Meeting entfernt"
"participantRemoved": "Du wurdest von der*dem Organisator:in aus dem Meeting entfernt"
},
"home": "Zur Startseite zurückkehren",
"back": "Dem Meeting erneut beitreten"
@@ -383,7 +383,7 @@
"body": "Transkribiere bis zu {{max_duration}} Meetingdauer.",
"bodyWithoutMaxDuration": "Transkribieren dein Meeting ohne Zeitbegrenzung.",
"details": {
"receiver": "Das Transkript wird an den Host und die Co-Hosts gesendet.",
"receiver": "Das Transkript wird an die*den Organisator:in und die Mitorganisierenden gesendet.",
"destination": "Ein neues Dokument wird erstellt auf",
"destinationUnknown": "Ein neues Dokument wird erstellt",
"language": "Meeting-Sprache:",
@@ -438,7 +438,7 @@
"linkMore": "Dokumentation öffnen",
"linkAriaLabel": "Dokumentation zur Aufzeichnung öffnen öffnet in neuem Tab",
"details": {
"receiver": "Die Aufzeichnung wird an den Host und die Co-Hosts gesendet.",
"receiver": "Die Aufzeichnung wird an die*den Organisator:in und die Mitorganisierenden gesendet.",
"destination": "Diese Aufzeichnung wird vorübergehend auf unseren Servern gespeichert",
"loading": "Aufzeichnung wird gestartet",
"linkMore": "Mehr erfahren",
@@ -494,7 +494,7 @@
"button": "OK"
},
"admin": {
"description": "Diese Host-Einstellungen ermöglichen dir die Kontrolle über dein Meeting. Nur Hosts haben Zugriff auf diese Optionen.",
"description": "Diese Einstellungen für Organisierende ermöglichen dir die Kontrolle über dein Meeting. Nur Organisierende haben Zugriff auf diese Optionen.",
"access": {
"title": "Raumzugang",
"description": "Diese Einstellungen gelten auch für zukünftige Meetings in diesem Raum.",
@@ -528,10 +528,6 @@
"screenshare": {
"label": "Bildschirm teilen",
"description": "Wenn du diese Option deaktivierst, können Teilnehmende ihren Bildschirm nicht mehr teilen. Laufende Bildschirmfreigaben werden sofort beendet."
},
"mute": {
"label": "Andere stummschalten",
"description": "Wenn deaktiviert, können Teilnehmer andere Teilnehmer nicht mehr stummschalten."
}
}
},
-10
View File
@@ -117,16 +117,6 @@
"announceReactions": {
"label": "Reaktionen vorlesen"
},
"font": {
"label": "Anzeigeschrift",
"description": "Passe die in der Oberfläche verwendete Schrift für ein besseres Leseerlebnis an.",
"options": {
"default": "Standard (System)",
"lexend": "Lexend (verbesserte Lesbarkeit)",
"atkinson-hyperlegible": "Atkinson Hyperlegible (Sehbeeinträchtigung)",
"opendyslexic": "OpenDyslexic (Dyslexie)"
}
},
"captions": {
"heading": "Untertitel",
"textSize": {
+2 -2
View File
@@ -1,7 +1,7 @@
{
"error": {
"title": "Recording unavailable",
"body": "The recording is unavailable or may have been deleted. Only the meeting host can access it. Feel free to contact them if needed."
"body": "The recording is unavailable or may have been deleted. Only the meeting organizer can access it. Feel free to contact them if needed."
},
"expired": {
"title": "Recording expired",
@@ -22,7 +22,7 @@
"button": "Download",
"warning": {
"title": "Link sharing disabled",
"body": "Sharing the recording via link is not yet available. Only hosts can download it."
"body": "Sharing the recording via link is not yet available. Only organizers can download it."
}
}
}
+4 -8
View File
@@ -3,7 +3,7 @@
"heading": {
"normal": "You have left the meeting",
"duplicateIdentity": "You have joined the meeting from another device",
"participantRemoved": "You have been removed from the meeting by a host"
"participantRemoved": "You have been removed from the meeting by an administrator"
},
"home": "Return to home",
"back": "Rejoin the meeting"
@@ -328,7 +328,7 @@
"chat": "Messages in the chat",
"transcript": "Transcribe",
"screenRecording": "Record",
"admin": "Host settings",
"admin": "Admin settings",
"tools": "More tools",
"info": "Meeting information"
},
@@ -338,7 +338,7 @@
"chat": "messages",
"transcript": "transcribe",
"screenRecording": "record",
"admin": "host settings",
"admin": "admin settings",
"tools": "more tools",
"info": "meeting information"
},
@@ -493,7 +493,7 @@
"button": "OK"
},
"admin": {
"description": "These host settings allow you to maintain control of your meeting. Only hosts can access these controls.",
"description": "These organizer settings allow you to maintain control of your meeting. Only organizers can access these controls.",
"access": {
"title": "Room access",
"description": "These settings will also apply to future occurrences of this meeting.",
@@ -527,10 +527,6 @@
"screenshare": {
"label": "Share their screen",
"description": "Disabling this option will prevent participants from sharing their screen, and any ongoing screen sharing will be stopped immediately."
},
"mute": {
"label": "Mute others",
"description": "When disabled, participants will no longer be able to mute other participants."
}
}
},
-10
View File
@@ -117,16 +117,6 @@
"announceReactions": {
"label": "Announce reactions aloud"
},
"font": {
"label": "Display font",
"description": "Customize the font used in the interface to improve your reading comfort.",
"options": {
"default": "Default",
"lexend": "Lexend (improved readability)",
"atkinson-hyperlegible": "Atkinson Hyperlegible (low vision)",
"opendyslexic": "OpenDyslexic (dyslexia)"
}
},
"captions": {
"heading": "Captions",
"textSize": {
-4
View File
@@ -527,10 +527,6 @@
"screenshare": {
"label": "Partager leur écran",
"description": "En désactivant cette option, les participants ne pourront plus partager leur écran et tout partage en cours sera immédiatement interrompu."
},
"mute": {
"label": "Muter les autres",
"description": "En désactivant cette option, les participants ne pourront plus muter d'autres participants."
}
}
},
-10
View File
@@ -117,16 +117,6 @@
"announceReactions": {
"label": "Vocaliser les réactions"
},
"font": {
"label": "Police d'affichage",
"description": "Personnalisez la police utilisée dans l'interface pour améliorer votre confort de lecture.",
"options": {
"default": "Par défaut",
"lexend": "Lexend (lisibilité améliorée)",
"atkinson-hyperlegible": "Atkinson Hyperlegible (basse vision)",
"opendyslexic": "OpenDyslexic (dyslexie)"
}
},
"captions": {
"heading": "Sous-titres",
"textSize": {
+2 -2
View File
@@ -1,7 +1,7 @@
{
"error": {
"title": "Opname niet beschikbaar",
"body": "De opname is niet beschikbaar of is mogelijk verwijderd. Alleen de host van de vergadering heeft toegang. Neem gerust contact met hem of haar op als dat nodig is."
"body": "De opname is niet beschikbaar of is mogelijk verwijderd. Alleen de organisator van de vergadering heeft toegang. Neem gerust contact met hem of haar op als dat nodig is."
},
"expired": {
"title": "Opname verlopen",
@@ -22,7 +22,7 @@
"button": "Downloaden",
"warning": {
"title": "Delen via link uitgeschakeld",
"body": "Het delen van de opname via een link is nog niet beschikbaar. Alleen hosts kunnen deze downloaden."
"body": "Het delen van de opname via een link is nog niet beschikbaar. Alleen organisatoren kunnen deze downloaden."
}
}
}
+2 -6
View File
@@ -437,7 +437,7 @@
"linkMore": "Documentatie openen",
"linkAriaLabel": "Documentatie over opname openen - opent in nieuw venster",
"details": {
"receiver": "De opname wordt verzonden naar de host en co-hosts.",
"receiver": "De opname wordt verzonden naar de organisator en co-organisatoren.",
"destination": "Deze opname wordt tijdelijk op onze servers bewaard",
"loading": "Opname wordt gestart",
"linkMore": "Meer informatie",
@@ -493,7 +493,7 @@
"button": "Opnieuw proberen"
},
"admin": {
"description": "Deze hostinstellingen geven u controle over uw vergadering. Alleen hosts hebben toegang tot deze bedieningselementen.",
"description": "Deze organisatorinstellingen geven u controle over uw vergadering. Alleen organisatoren hebben toegang tot deze bedieningselementen.",
"access": {
"title": "Toegang tot vergadering",
"description": "Deze instellingen zijn ook van toepassing op toekomstige sessies van deze vergadering.",
@@ -527,10 +527,6 @@
"screenshare": {
"label": "Hun scherm delen",
"description": "Als u deze optie uitschakelt, kunnen deelnemers hun scherm niet meer delen en wordt elke lopende schermdeling onmiddellijk gestopt."
},
"mute": {
"label": "Anderen dempen",
"description": "Wanneer uitgeschakeld, kunnen deelnemers andere deelnemers niet meer dempen."
}
}
},
-10
View File
@@ -117,16 +117,6 @@
"announceReactions": {
"label": "Reacties hardop aankondigen"
},
"font": {
"label": "Weergavelettertype",
"description": "Pas het lettertype van de interface aan om uw leescomfort te verbeteren.",
"options": {
"default": "Standaard",
"lexend": "Lexend (verbeterde leesbaarheid)",
"atkinson-hyperlegible": "Atkinson Hyperlegible (slechtziend)",
"opendyslexic": "OpenDyslexic (dyslexie)"
}
},
"captions": {
"heading": "Ondertitels",
"textSize": {
+1 -20
View File
@@ -2,19 +2,6 @@ import { proxy, subscribe } from 'valtio'
import { STORAGE_KEYS } from '@/utils/storageKeys'
import { deserializeToProxyMap } from '@/utils/valtio'
export type UiFont =
| 'default'
| 'lexend'
| 'atkinson-hyperlegible'
| 'opendyslexic'
export const UI_FONT_OPTIONS: UiFont[] = [
'default',
'lexend',
'atkinson-hyperlegible',
'opendyslexic',
]
export type CaptionTextSize = 'small' | 'medium' | 'large'
export const CAPTION_TEXT_SIZE_OPTIONS: CaptionTextSize[] = [
@@ -59,7 +46,7 @@ export const CAPTION_FONT_COLOR_VALUES: Record<CaptionColor, string> = {
}
export const CAPTION_BACKGROUND_COLOR_VALUES: Record<CaptionColor, string> = {
default: 'transparent',
default: 'rgba(0, 0, 0, 0.75)',
black: 'rgba(0, 0, 0, 0.75)',
white: 'rgba(255, 255, 255, 0.75)',
blue: 'rgba(0, 0, 255, 0.75)',
@@ -75,7 +62,6 @@ type AccessibilityState = {
captionTextSize: CaptionTextSize
captionFontColor: CaptionColor
captionBackgroundColor: CaptionColor
uiFont: UiFont
}
const DEFAULT_STATE: AccessibilityState = {
@@ -83,7 +69,6 @@ const DEFAULT_STATE: AccessibilityState = {
captionTextSize: 'medium',
captionFontColor: 'default',
captionBackgroundColor: 'default',
uiFont: 'default',
}
function getAccessibilityState(): AccessibilityState {
@@ -106,9 +91,6 @@ function getAccessibilityState(): AccessibilityState {
)
? parsed.captionBackgroundColor
: DEFAULT_STATE.captionBackgroundColor
const uiFont = UI_FONT_OPTIONS.includes(parsed.uiFont)
? parsed.uiFont
: DEFAULT_STATE.uiFont
return {
...DEFAULT_STATE,
...parsed,
@@ -119,7 +101,6 @@ function getAccessibilityState(): AccessibilityState {
captionTextSize,
captionFontColor,
captionBackgroundColor,
uiFont,
}
}
-13
View File
@@ -6,19 +6,6 @@ body,
height: 100%;
}
html.font-lexend {
--fonts-sans: 'Lexend Variable', ui-sans-serif, system-ui, sans-serif;
}
html.font-atkinson-hyperlegible {
--fonts-sans:
'Atkinson Hyperlegible Next Variable', ui-sans-serif, system-ui, sans-serif;
}
html.font-opendyslexic {
--fonts-sans: OpenDyslexic, ui-sans-serif, system-ui, sans-serif;
}
.sr-only {
position: absolute;
width: 1px;
@@ -1,62 +1,52 @@
{{- $sharedEnvVars := default (dict) .Values.celeryTranscribe.envVars -}}
{{- $envVars := include "meet.common.env" (list . .Values.celeryTranscribe) -}}
{{- $fullName := include "meet.celeryTranscribe.fullname" . -}}
{{- $component := "celery-transcribe" -}}
{{- range $idx, $instance := .Values.celeryTranscribe.instances }}
{{- $instanceValues := merge $instance $.Values.celeryTranscribe }}
{{- $fullName := printf "%s-%s" (include "meet.celeryTranscribe.fullname" $) $instance.name }}
{{- $extraInstanceEnvVars := default (dict) $instance.extraEnvVars -}}
{{- $mergedInstanceEnvVars := merge $extraInstanceEnvVars $sharedEnvVars -}}
{{- $fakeInstanceObjectForEnvHelper := dict "envVars" $mergedInstanceEnvVars -}}
{{- $envVars := include "meet.common.env" (list . $fakeInstanceObjectForEnvHelper) -}}
apiVersion: apps/v1
kind: Deployment
metadata:
name: {{ $fullName }}
annotations:
{{- with $instanceValues.dpAnnotations }}
{{- with .Values.celeryTranscribe.dpAnnotations }}
{{- toYaml . | nindent 4 }}
{{- end }}
namespace: {{ $.Release.Namespace | quote }}
namespace: {{ .Release.Namespace | quote }}
labels:
{{- include "meet.common.labels" (list $ $component) | nindent 4 }}
instance: {{ $instance.name }}
{{- include "meet.common.labels" (list . $component) | nindent 4 }}
spec:
replicas: {{ $instanceValues.replicas }}
replicas: {{ .Values.celeryTranscribe.replicas }}
selector:
matchLabels:
{{- include "meet.common.selectorLabels" (list $ $component) | nindent 6 }}
instance: {{ $instance.name }}
{{- include "meet.common.selectorLabels" (list . $component) | nindent 6 }}
template:
metadata:
annotations:
{{- with $instanceValues.podAnnotations }}
{{- with .Values.celeryTranscribe.podAnnotations }}
{{- toYaml . | nindent 8 }}
{{- end }}
labels:
{{- include "meet.common.selectorLabels" (list $ $component) | nindent 8 }}
instance: {{ $instance.name }}
{{- include "meet.common.selectorLabels" (list . $component) | nindent 8 }}
spec:
{{- if $.Values.image.credentials }}
imagePullSecrets:
- name: {{ include "meet.secret.dockerconfigjson.name" (dict "fullname" (include "meet.fullname" $) "imageCredentials" $.Values.image.credentials) }}
- name: {{ include "meet.secret.dockerconfigjson.name" (dict "fullname" (include "meet.fullname" .) "imageCredentials" $.Values.image.credentials) }}
{{- end }}
shareProcessNamespace: {{ $instanceValues.shareProcessNamespace }}
{{- with $instanceValues.podSecurityContext }}
shareProcessNamespace: {{ .Values.celeryTranscribe.shareProcessNamespace }}
{{- with .Values.celeryTranscribe.podSecurityContext }}
securityContext:
{{- toYaml . | nindent 8 }}
{{- end }}
containers:
{{- with $instanceValues.sidecars }}
{{- with .Values.celeryTranscribe.sidecars }}
{{- toYaml . | nindent 8 }}
{{- end }}
- name: {{ $.Chart.Name }}
image: "{{ ($instanceValues.image | default dict).repository | default $.Values.image.repository }}:{{ ($instanceValues.image | default dict).tag | default $.Values.image.tag }}"
imagePullPolicy: {{ ($instanceValues.image | default dict).pullPolicy | default $.Values.image.pullPolicy }}
{{- with $instanceValues.command }}
- name: {{ .Chart.Name }}
image: "{{ (.Values.celeryTranscribe.image | default dict).repository | default .Values.image.repository }}:{{ (.Values.celeryTranscribe.image | default dict).tag | default .Values.image.tag }}"
imagePullPolicy: {{ (.Values.celeryTranscribe.image | default dict).pullPolicy | default .Values.image.pullPolicy }}
{{- with .Values.celeryTranscribe.command }}
command:
{{- toYaml . | nindent 12 }}
{{- end }}
{{- with $instanceValues.args }}
{{- with .Values.celeryTranscribe.args }}
args:
{{- toYaml . | nindent 12 }}
{{- end }}
@@ -64,65 +54,65 @@ spec:
{{- if $envVars }}
{{- $envVars | indent 12 }}
{{- end }}
{{- with $instanceValues.securityContext }}
{{- with .Values.celeryTranscribe.securityContext }}
securityContext:
{{- toYaml . | nindent 12 }}
{{- end }}
ports:
- name: http
containerPort: {{ $instanceValues.service.targetPort }}
containerPort: {{ .Values.celeryTranscribe.service.targetPort }}
protocol: TCP
{{- if $instanceValues.probes.liveness }}
{{- if .Values.celeryTranscribe.probes.liveness }}
livenessProbe:
{{- include "meet.probes.abstract" (merge $instanceValues.probes.liveness (dict "targetPort" $instanceValues.service.targetPort)) | nindent 12 }}
{{- include "meet.probes.abstract" (merge .Values.celeryTranscribe.probes.liveness (dict "targetPort" .Values.celeryTranscribe.service.targetPort )) | nindent 12 }}
{{- end }}
{{- if $instanceValues.probes.readiness }}
{{- if .Values.celeryTranscribe.probes.readiness }}
readinessProbe:
{{- include "meet.probes.abstract" (merge $instanceValues.probes.readiness (dict "targetPort" $instanceValues.service.targetPort)) | nindent 12 }}
{{- include "meet.probes.abstract" (merge .Values.celeryTranscribe.probes.readiness (dict "targetPort" .Values.celeryTranscribe.service.targetPort )) | nindent 12 }}
{{- end }}
{{- if $instanceValues.probes.startup }}
{{- if .Values.celeryTranscribe.probes.startup }}
startupProbe:
{{- include "meet.probes.abstract" (merge $instanceValues.probes.startup (dict "targetPort" $instanceValues.service.targetPort)) | nindent 12 }}
{{- include "meet.probes.abstract" (merge .Values.celeryTranscribe.probes.startup (dict "targetPort" .Values.celeryTranscribe.service.targetPort )) | nindent 12 }}
{{- end }}
{{- with $instanceValues.resources }}
{{- with .Values.celeryTranscribe.resources }}
resources:
{{- toYaml . | nindent 12 }}
{{- end }}
volumeMounts:
{{- range $index, $value := $.Values.mountFiles }}
{{- range $index, $value := .Values.mountFiles }}
- name: "files-{{ $index }}"
mountPath: {{ $value.path }}
subPath: content
{{- end }}
{{- range $name, $volume := $instanceValues.persistence }}
{{- range $name, $volume := .Values.celeryTranscribe.persistence }}
- name: "{{ $name }}"
mountPath: "{{ $volume.mountPath }}"
{{- end }}
{{- range $instanceValues.extraVolumeMounts }}
{{- range .Values.celeryTranscribe.extraVolumeMounts }}
- name: {{ .name }}
mountPath: {{ .mountPath }}
subPath: {{ .subPath | default "" }}
readOnly: {{ .readOnly }}
{{- end }}
{{- with $instanceValues.nodeSelector }}
{{- with .Values.celeryTranscribe.nodeSelector }}
nodeSelector:
{{- toYaml . | nindent 8 }}
{{- end }}
{{- with $instanceValues.affinity }}
{{- with .Values.celeryTranscribe.affinity }}
affinity:
{{- toYaml . | nindent 8 }}
{{- end }}
{{- with $instanceValues.tolerations }}
{{- with .Values.celeryTranscribe.tolerations }}
tolerations:
{{- toYaml . | nindent 8 }}
{{- end }}
volumes:
{{- range $index, $value := $.Values.mountFiles }}
{{- range $index, $value := .Values.mountFiles }}
- name: "files-{{ $index }}"
configMap:
name: "{{ include "meet.fullname" $ }}-files-{{ $index }}"
{{- end }}
{{- range $name, $volume := $instanceValues.persistence }}
{{- range $name, $volume := .Values.celeryTranscribe.persistence }}
- name: "{{ $name }}"
{{- if eq $volume.type "emptyDir" }}
emptyDir: {}
@@ -131,7 +121,7 @@ spec:
claimName: "{{ $fullName }}-{{ $name }}"
{{- end }}
{{- end }}
{{- range $instanceValues.extraVolumes }}
{{- range .Values.celeryTranscribe.extraVolumes }}
- name: {{ .name }}
{{- if .existingClaim }}
persistentVolumeClaim:
@@ -153,18 +143,15 @@ spec:
{{- end }}
{{- end }}
---
{{ if $instanceValues.pdb.enabled }}
{{ if .Values.celeryTranscribe.pdb.enabled }}
apiVersion: policy/v1
kind: PodDisruptionBudget
metadata:
name: {{ $fullName }}
namespace: {{ $.Release.Namespace | quote }}
namespace: {{ .Release.Namespace | quote }}
spec:
maxUnavailable: 1
selector:
matchLabels:
{{- include "meet.common.selectorLabels" (list $ $component) | nindent 6 }}
instance: {{ $instance.name }}
---
{{- include "meet.common.selectorLabels" (list . $component) | nindent 6 }}
{{ end }}
{{- end }}
-15
View File
@@ -848,21 +848,6 @@ celeryTranscribe:
pdb:
enabled: false
## @param celeryTranscribe.instances List of celeryTranscribe instances to deploy. Each entry creates a dedicated Deployment. Useful when wanting to use multiple instances of WhisperX (configure each endpoint in the extraEnv value specific to that instance).
## @extra celeryTranscribe.instances[].name Unique name suffix for the instance (used in the Deployment name and pod labels)
## @extra celeryTranscribe.instances[].replicas Override the number of replicas for this specific instance
## @extra celeryTranscribe.instances[].extraEnvVars Additional environment variables for this specific instance (same structure as envVars)
## @extra celeryTranscribe.instances[].command Override the container command for this specific instance
## @extra celeryTranscribe.instances[].args Override the container args for this specific instance
## @extra celeryTranscribe.instances[].resources Override resource requirements for this specific instance
## @extra celeryTranscribe.instances[].nodeSelector Override node selector for this specific instance
## @extra celeryTranscribe.instances[].affinity Override affinity for this specific instance
## @extra celeryTranscribe.instances[].tolerations Override tolerations for this specific instance
## @extra celeryTranscribe.instances[].pdb.enabled Enable pdb for this specific instance
instances:
- name: default
extraEnvVars: {}
## @section celerySummarize
celerySummarize:
+1 -1
View File
@@ -37,7 +37,7 @@
<mj-text>
<p>{% trans "To keep this recording permanently:" %}</p>
<ol>
<li>{% blocktrans %}Click the "<a href="{{link}}">Open</a>" link below {% endblocktrans %}</li>
<li>{% blocktrans %}Click the "Open" button below {% endblocktrans %}</li>
<li>{% blocktrans %}Use the "Download" button in the interface {% endblocktrans %}</li>
<li>{% blocktrans %}Save the file to your preferred location{% endblocktrans %}</li>
</ol>
+97 -65
View File
@@ -1,12 +1,12 @@
{
"name": "mail_mjml",
"version": "1.16.0",
"version": "1.15.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "mail_mjml",
"version": "1.16.0",
"version": "1.15.0",
"license": "MIT",
"dependencies": {
"@html-to/text-cli": "0.5.4",
@@ -14,9 +14,9 @@
}
},
"node_modules/@babel/runtime": {
"version": "7.29.2",
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.2.tgz",
"integrity": "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==",
"version": "7.28.6",
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.6.tgz",
"integrity": "sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA==",
"license": "MIT",
"engines": {
"node": ">=6.9.0"
@@ -24,8 +24,6 @@
},
"node_modules/@html-to/text-cli": {
"version": "0.5.4",
"resolved": "https://registry.npmjs.org/@html-to/text-cli/-/text-cli-0.5.4.tgz",
"integrity": "sha512-V7WDfiYjXcibHGD6q61oW8HD68UPvBVkKit0X+9v54nTmLe8KDCc+56STleqqP7CzuEK5f/1jqa652fnr9Pmsw==",
"license": "MIT",
"dependencies": {
"@selderee/plugin-htmlparser2": "^0.11.0",
@@ -76,8 +74,6 @@
},
"node_modules/@selderee/plugin-htmlparser2": {
"version": "0.11.0",
"resolved": "https://registry.npmjs.org/@selderee/plugin-htmlparser2/-/plugin-htmlparser2-0.11.0.tgz",
"integrity": "sha512-P33hHGdldxGabLFjPPpaTxVolMrzrcegejx+0GxjrIb9Zv48D8yAIA/QTDR2dFl7Uz7urX8aX6+5bCZslr+gWQ==",
"license": "MIT",
"dependencies": {
"domhandler": "^5.0.3",
@@ -144,8 +140,6 @@
},
"node_modules/aspargvs": {
"version": "0.6.0",
"resolved": "https://registry.npmjs.org/aspargvs/-/aspargvs-0.6.0.tgz",
"integrity": "sha512-yUrWCd1hkK5UtDOne1gM3O+FoTFGQ+BVlSd4G7FczBz8+JaFn1uzvQzROxwp9hmlhIUtwSwyRuV9mHgd/WbXxg==",
"license": "MIT",
"dependencies": {
"peberminta": "^0.8.0"
@@ -158,10 +152,13 @@
}
},
"node_modules/balanced-match": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
"integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
"license": "MIT"
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz",
"integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==",
"license": "MIT",
"engines": {
"node": "18 || 20 || >=22"
}
},
"node_modules/binary-extensions": {
"version": "2.3.0",
@@ -182,12 +179,15 @@
"license": "ISC"
},
"node_modules/brace-expansion": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.0.tgz",
"integrity": "sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w==",
"version": "5.0.3",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.3.tgz",
"integrity": "sha512-fy6KJm2RawA5RcHkLa1z/ScpBeA762UF9KmZQxwIbDtRJrgLzM10depAiEQ+CXYcoiqW1/m96OAAoke2nE9EeA==",
"license": "MIT",
"dependencies": {
"balanced-match": "^1.0.0"
"balanced-match": "^4.0.2"
},
"engines": {
"node": "18 || 20 || >=22"
}
},
"node_modules/braces": {
@@ -451,8 +451,6 @@
},
"node_modules/deepmerge": {
"version": "4.3.1",
"resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz",
"integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
@@ -466,8 +464,6 @@
},
"node_modules/dom-serializer": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz",
"integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==",
"license": "MIT",
"dependencies": {
"domelementtype": "^2.3.0",
@@ -480,8 +476,6 @@
},
"node_modules/domelementtype": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz",
"integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==",
"funding": [
{
"type": "github",
@@ -492,8 +486,6 @@
},
"node_modules/domhandler": {
"version": "5.0.3",
"resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz",
"integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==",
"license": "BSD-2-Clause",
"dependencies": {
"domelementtype": "^2.3.0"
@@ -506,14 +498,12 @@
}
},
"node_modules/domutils": {
"version": "3.2.2",
"resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz",
"integrity": "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==",
"version": "3.0.1",
"license": "BSD-2-Clause",
"dependencies": {
"dom-serializer": "^2.0.0",
"domelementtype": "^2.3.0",
"domhandler": "^5.0.3"
"domhandler": "^5.0.1"
},
"funding": {
"url": "https://github.com/fb55/domutils?sponsor=1"
@@ -526,14 +516,14 @@
"license": "MIT"
},
"node_modules/editorconfig": {
"version": "1.0.7",
"resolved": "https://registry.npmjs.org/editorconfig/-/editorconfig-1.0.7.tgz",
"integrity": "sha512-e0GOtq/aTQhVdNyDU9e02+wz9oDDM+SIOQxWME2QRjzRX5yyLAuHDE+0aE8vHb9XRC8XD37eO2u57+F09JqFhw==",
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/editorconfig/-/editorconfig-1.0.4.tgz",
"integrity": "sha512-L9Qe08KWTlqYMVvMcTIvMAdl1cDUubzRNYL+WfA4bLDMHe4nemKkpmYzkznE1FwLKu0EEmy6obgQKzMJrg4x9Q==",
"license": "MIT",
"dependencies": {
"@one-ini/wasm": "0.1.1",
"commander": "^10.0.0",
"minimatch": "^9.0.1",
"minimatch": "9.0.1",
"semver": "^7.5.3"
},
"bin": {
@@ -543,6 +533,21 @@
"node": ">=14"
}
},
"node_modules/editorconfig/node_modules/balanced-match": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
"integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
"license": "MIT"
},
"node_modules/editorconfig/node_modules/brace-expansion": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz",
"integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==",
"license": "MIT",
"dependencies": {
"balanced-match": "^1.0.0"
}
},
"node_modules/editorconfig/node_modules/commander": {
"version": "10.0.1",
"resolved": "https://registry.npmjs.org/commander/-/commander-10.0.1.tgz",
@@ -552,6 +557,21 @@
"node": ">=14"
}
},
"node_modules/editorconfig/node_modules/minimatch": {
"version": "9.0.1",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.1.tgz",
"integrity": "sha512-0jWhJpD/MdhPXwPuiRkCbfYfSKp2qnn2eOc279qI7f+osl/l+prKSrvhg157zSYvx/1nmgn2NqdT6k2Z7zSH9w==",
"license": "ISC",
"dependencies": {
"brace-expansion": "^2.0.1"
},
"engines": {
"node": ">=16 || 14 >=14.17"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/emoji-regex": {
"version": "9.2.2",
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz",
@@ -559,9 +579,7 @@
"license": "MIT"
},
"node_modules/entities": {
"version": "4.5.0",
"resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz",
"integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==",
"version": "4.4.0",
"license": "BSD-2-Clause",
"engines": {
"node": ">=0.12"
@@ -707,8 +725,6 @@
},
"node_modules/htmlparser2": {
"version": "8.0.2",
"resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-8.0.2.tgz",
"integrity": "sha512-GYdjWKDkbRLkZ5geuHs5NY1puJ+PXwP7+fHPRz06Eirsb9ugf6d8kkXav6ADhcODhFFPMIXyxkxSuMf3D6NCFA==",
"funding": [
"https://github.com/fb55/htmlparser2?sponsor=1",
{
@@ -862,8 +878,6 @@
},
"node_modules/leac": {
"version": "0.6.0",
"resolved": "https://registry.npmjs.org/leac/-/leac-0.6.0.tgz",
"integrity": "sha512-y+SqErxb8h7nE/fiEX07jsbuhrpO9lL8eca7/Y1nuWV2moNlXhyd59iDGcRf6moVyDMbmTNzL40SUyrFU/yDpg==",
"license": "MIT",
"funding": {
"url": "https://ko-fi.com/killymxi"
@@ -906,12 +920,12 @@
}
},
"node_modules/minimatch": {
"version": "9.0.9",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz",
"integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==",
"version": "9.0.6",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.6.tgz",
"integrity": "sha512-kQAVowdR33euIqeA0+VZTDqU+qo1IeVY+hrKYtZMio3Pg0P0vuh/kwRylLUddJhB6pf3q/botcOvRtx4IN1wqQ==",
"license": "ISC",
"dependencies": {
"brace-expansion": "^2.0.2"
"brace-expansion": "^5.0.2"
},
"engines": {
"node": ">=16 || 14 >=14.17"
@@ -1214,6 +1228,32 @@
"lodash": "^4.17.21"
}
},
"node_modules/mjml-parser-xml/node_modules/domutils": {
"version": "3.2.2",
"resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz",
"integrity": "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==",
"license": "BSD-2-Clause",
"dependencies": {
"dom-serializer": "^2.0.0",
"domelementtype": "^2.3.0",
"domhandler": "^5.0.3"
},
"funding": {
"url": "https://github.com/fb55/domutils?sponsor=1"
}
},
"node_modules/mjml-parser-xml/node_modules/entities": {
"version": "4.5.0",
"resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz",
"integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==",
"license": "BSD-2-Clause",
"engines": {
"node": ">=0.12"
},
"funding": {
"url": "https://github.com/fb55/entities?sponsor=1"
}
},
"node_modules/mjml-parser-xml/node_modules/htmlparser2": {
"version": "9.1.0",
"resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-9.1.0.tgz",
@@ -1472,9 +1512,7 @@
}
},
"node_modules/parseley": {
"version": "0.12.1",
"resolved": "https://registry.npmjs.org/parseley/-/parseley-0.12.1.tgz",
"integrity": "sha512-e6qHKe3a9HWr0oMRVDTRhKce+bRO8VGQR3NyVwcjwrbhMmFCX9KszEV35+rn4AdilFAq9VPxP/Fe1wC9Qjd2lw==",
"version": "0.12.0",
"license": "MIT",
"dependencies": {
"leac": "^0.6.0",
@@ -1486,8 +1524,6 @@
},
"node_modules/parseley/node_modules/peberminta": {
"version": "0.9.0",
"resolved": "https://registry.npmjs.org/peberminta/-/peberminta-0.9.0.tgz",
"integrity": "sha512-XIxfHpEuSJbITd1H3EeQwpcZbTLHc+VVr8ANI9t5sit565tsI4/xK3KWTUFE2e6QiangUkh3B0jihzmGnNrRsQ==",
"license": "MIT",
"funding": {
"url": "https://ko-fi.com/killymxi"
@@ -1520,17 +1556,15 @@
},
"node_modules/peberminta": {
"version": "0.8.0",
"resolved": "https://registry.npmjs.org/peberminta/-/peberminta-0.8.0.tgz",
"integrity": "sha512-YYEs+eauIjDH5nUEGi18EohWE0nV2QbGTqmxQcqgZ/0g+laPCQmuIqq7EBLVi9uim9zMgfJv0QBZEnQ3uHw/Tw==",
"license": "MIT",
"funding": {
"url": "https://ko-fi.com/killymxi"
}
},
"node_modules/picomatch": {
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz",
"integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==",
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",
"integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==",
"license": "MIT",
"engines": {
"node": ">=8.6"
@@ -1577,8 +1611,6 @@
},
"node_modules/selderee": {
"version": "0.11.0",
"resolved": "https://registry.npmjs.org/selderee/-/selderee-0.11.0.tgz",
"integrity": "sha512-5TF+l7p4+OsnP8BCCvSyZiSPc4x4//p5uPwK8TCnVPJYRmU2aYKMpOXvw8zM5a5JvuuCGN1jmsMwuU2W02ukfA==",
"license": "MIT",
"dependencies": {
"parseley": "^0.12.0"
@@ -1588,9 +1620,9 @@
}
},
"node_modules/semver": {
"version": "7.8.0",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.8.0.tgz",
"integrity": "sha512-AcM7dV/5ul4EekoQ29Agm5vri8JNqRyj39o0qpX6vDF2GZrtutZl5RwgD1XnZjiTAfncsJhMI48QQH3sN87YNA==",
"version": "7.7.4",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz",
"integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==",
"license": "ISC",
"bin": {
"semver": "bin/semver.js"
@@ -1710,12 +1742,12 @@
}
},
"node_modules/strip-ansi": {
"version": "7.2.0",
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz",
"integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==",
"version": "7.1.2",
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.2.tgz",
"integrity": "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==",
"license": "MIT",
"dependencies": {
"ansi-regex": "^6.2.2"
"ansi-regex": "^6.0.1"
},
"engines": {
"node": ">=12"
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "mail_mjml",
"version": "1.16.0",
"version": "1.15.0",
"description": "An util to generate html and text django's templates from mjml templates",
"type": "module",
"dependencies": {
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "sdk",
"version": "1.16.0",
"version": "1.15.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "sdk",
"version": "1.16.0",
"version": "1.15.0",
"license": "ISC",
"workspaces": [
"./library",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "sdk",
"version": "1.16.0",
"version": "1.15.0",
"author": "",
"license": "ISC",
"description": "",
+2 -2
View File
@@ -1,7 +1,7 @@
[project]
name = "summary"
version = "1.16.0"
version = "1.15.0"
dependencies = [
"fastapi[standard]>=0.105.0",
"uvicorn>=0.24.0",
@@ -20,7 +20,7 @@ dependencies = [
[project.optional-dependencies]
dev = [
"ruff==0.15.6",
"pytest==9.0.3",
"pytest==9.0.2",
"responses>=0.25.8",
]
+6 -10
View File
@@ -19,18 +19,16 @@ class TranscribeSummarizeTaskCreation(BaseModel):
"""Transcription and summarization parameters."""
owner_id: str
recording_filename: str
metadata_filename: Optional[str] = None
filename: str
email: str
sub: str
version: Optional[int] = 2
room: Optional[str]
owner_timezone: Optional[str]
recording_date: Optional[str]
recording_time: Optional[str]
language: Optional[str]
download_link: Optional[str]
context_language: Optional[str] = None
recording_start_at: Optional[str] = None
recording_end_at: Optional[str] = None
@field_validator("language")
@classmethod
@@ -53,18 +51,16 @@ async def create_transcribe_summarize_task(request: TranscribeSummarizeTaskCreat
task = process_audio_transcribe_summarize_v2.apply_async(
args=[
request.owner_id,
request.recording_filename,
request.metadata_filename,
request.filename,
request.email,
request.sub,
time.time(),
request.room,
request.owner_timezone,
request.recording_date,
request.recording_time,
request.language,
request.download_link,
request.context_language,
request.recording_start_at,
request.recording_end_at,
],
queue=settings.transcribe_queue,
)
+1 -3
View File
@@ -112,9 +112,7 @@ class MetadataManager:
if self._is_disabled or self.has_task_id(task_id):
return
# Positional args mirror process_audio_transcribe_summarize_v2 signature:
# owner_id, recording_filename, metadata_filename, email, sub, received_at, ...
_, filename, _, email, _, received_at, *_ = task_args
_, filename, email, _, received_at, *_ = task_args
start_time = time.time()
initial_metadata = {
+27 -111
View File
@@ -4,7 +4,7 @@
import json
import time
from datetime import datetime
from typing import Optional
import openai
import sentry_sdk
@@ -40,7 +40,6 @@ from summary.core.shared_models import (
webhook_payload_adapter,
)
from summary.core.transcript_formatter import TranscriptFormatter
from summary.core.user_assign import resolve_speaker_identities
from summary.core.webhook_service import (
call_webhook_v2,
submit_content,
@@ -79,7 +78,7 @@ file_service = FileService()
def transcribe_audio(
*,
task_id: str,
recording_filename: str | None = None,
filename: str | None = None,
language: str,
cloud_storage_url=None,
raises: bool = False,
@@ -91,7 +90,7 @@ def transcribe_audio(
Returns the transcription object, or None if the file could not be retrieved.
"""
if bool(recording_filename) == bool(cloud_storage_url):
if bool(filename) == bool(cloud_storage_url):
raise ValueError(
"Either filename or cloud_storage_url must be provided, but not both."
)
@@ -106,12 +105,11 @@ def transcribe_audio(
# Transcription
try:
with file_service.prepare_audio_file(
remote_object_key=recording_filename,
remote_object_key=filename,
cloud_storage_url=cloud_storage_url,
) as (audio_file, metadata):
metadata_manager.track(task_id, {"audio_length": metadata["duration"]})
# Compute language parameter
if language is None:
language = settings.whisperx_default_language
logger.info(
@@ -124,21 +122,18 @@ def transcribe_audio(
language,
)
# Call remote service for transcription
transcription_start_time = time.time()
transcription = whisperx_client.audio.transcriptions.create(
model=settings.whisperx_asr_model, file=audio_file, language=language
)
# Logging
transcription_duration = round(time.time() - transcription_start_time, 2)
transcription_time = round(time.time() - transcription_start_time, 2)
metadata_manager.track(
task_id,
{"transcription_time": transcription_duration},
)
logger.info(
"Transcription received in %.2f seconds.", transcription_duration
{"transcription_time": transcription_time},
)
logger.info("Transcription received in %.2f seconds.", transcription_time)
logger.debug("Transcription: \n %s", transcription)
except FileServiceException as e:
@@ -153,7 +148,7 @@ def transcribe_audio(
"Unexpected error while preparing file | filename: %s "
"| cloud_storage_url: %s"
),
recording_filename,
filename,
redacted_cloud_storage_url,
)
return None
@@ -162,72 +157,13 @@ def transcribe_audio(
return transcription
def resolve_speaker_identities_and_apply_to(
transcription, recording_start_at, recording_end_at, metadata_filename, task_id
):
"""Assign users to detected speakers and rewrite the transcriptions.
Args:
transcription: output of meet-whisperx after transcription and diarization
recording_start_at: sourced from LiveKit FileInfo via the egress_ended webhook
recording_end_at: sourced from LiveKit FileInfo via the egress_ended webhook
metadata_filename: name of metadata file containing VAD information in S3
task_id: current task id, for logging purposes
"""
recording_start_dt = (
datetime.fromisoformat(recording_start_at) if recording_start_at else None
)
recording_end_dt = (
datetime.fromisoformat(recording_end_at) if recording_end_at else None
)
logger.debug(
"recording_start_dt: %s ; recording_end_dt: %s",
recording_start_dt,
recording_end_dt,
)
if (recording_start_dt is None) or (recording_end_dt is None):
logger.debug("Skipping resolve_speaker_identities")
return transcription
logger.debug("Running resolve_speaker_identities")
try:
metadata = file_service.read_json(metadata_filename)
speaker_mapping = resolve_speaker_identities(
metadata,
transcription,
recording_start_dt,
recording_end_dt,
)
new_transcription = speaker_mapping.apply_to(transcription.model_dump())
return new_transcription
except FileServiceException as exc:
logger.error(
"Error reading metadata for task %s; skipping speaker assignment."
" Error: %s",
task_id,
exc,
)
return transcription
except Exception as exc:
logger.exception(
"resolve_speaker_identities failed for task %s; skipping"
" speaker assignment. Error: %s",
task_id,
exc,
)
return transcription
def format_transcript(
transcription,
context_language: str | None,
language: str,
room: str | None,
recording_datetime: str | None,
owner_timezone: str | None,
recording_date: str | None,
recording_time: str | None,
download_link: str | None,
) -> tuple[str, str]:
"""Format a transcription into readable content with a title.
@@ -243,8 +179,8 @@ def format_transcript(
return formatter.format(
transcription,
room=room,
recording_datetime=recording_datetime,
owner_timezone=owner_timezone,
recording_date=recording_date,
recording_time=recording_time,
download_link=download_link,
)
@@ -252,7 +188,7 @@ def format_transcript(
def format_actions(llm_output: dict) -> str:
"""Format the actions from the LLM output into a markdown list.
format:
fomat:
- [ ] Action title Assignée à : assignee1, assignee2, Échéance : due_date
"""
lines = []
@@ -276,18 +212,16 @@ def format_actions(llm_output: dict) -> str:
def process_audio_transcribe_summarize_v2(
self,
owner_id: str,
recording_filename: str,
metadata_filename: str | None,
filename: str,
email: str,
sub: str,
received_at: float,
room: str | None,
owner_timezone: str | None,
language: str | None,
download_link: str | None,
context_language: str | None = None,
recording_start_at: str | None = None,
recording_end_at: str | None = None,
room: Optional[str],
recording_date: Optional[str],
recording_time: Optional[str],
language: Optional[str],
download_link: Optional[str],
context_language: Optional[str] = None,
):
"""Process an audio file by transcribing it and generating a summary.
@@ -300,20 +234,16 @@ def process_audio_transcribe_summarize_v2(
Args:
self: Celery task instance (passed on with bind=True)
owner_id: Unique identifier of the recording owner.
recording_filename: Name of the audio file in MinIO storage.
metadata_filename: Name of the audio file in MinIO storage.
filename: Name of the audio file in MinIO storage.
email: Email address of the recording owner.
sub: OIDC subject identifier of the recording owner.
received_at: Unix timestamp when the recording was received.
room: room name where the recording took place.
owner_timezone: IANA timezone of the recording owner (e.g. "Europe/Paris").
recording_date: Date of the recording (localized display string).
recording_time: Time of the recording (localized display string).
language: ISO 639-1 language code for transcription.
download_link: URL to download the original recording.
context_language: ISO 639-1 language code of the meeting summary context text.
recording_start_at: ISO 8601 timestamp of when file recording actually started
(from LiveKit FileInfo.started_at via the egress_ended webhook).
recording_end_at: ISO 8601 timestamp of when file recording ended
(from LiveKit FileInfo.ended_at via the egress_ended webhook).
"""
logger.info(
"Notification received | Owner: %s | Room: %s",
@@ -323,33 +253,19 @@ def process_audio_transcribe_summarize_v2(
task_id = self.request.id
# Transcribe the audio
transcription = transcribe_audio(
task_id=task_id, recording_filename=recording_filename, language=language
task_id=task_id, filename=filename, language=language
)
if transcription is None:
return
# Assign speakers and rewrite transcription/diarization output
if settings.is_resolve_speaker_identities_enabled and (
metadata_filename is not None
):
transcription = resolve_speaker_identities_and_apply_to(
transcription,
recording_start_at,
recording_end_at,
metadata_filename,
task_id,
)
# Format output
content, title = format_transcript(
transcription,
context_language,
language,
room,
recording_start_at,
owner_timezone,
recording_date,
recording_time,
download_link,
)
-6
View File
@@ -103,12 +103,6 @@ class Settings(BaseSettings):
# Transcription processing
hallucination_patterns: List[str] = ["Vap'n'Roll Thierry"]
# Speaker to user assignment
is_resolve_speaker_identities_enabled: bool = True
resolve_speaker_identities_default_overlap_threshold: float = 0.5
resolve_speaker_identities_enable_split_on_words: bool = True
resolve_speaker_identities_max_word_duration: float = 1 # seconds
# Webhook-related settings
webhook_max_retries: int = 2
webhook_status_forcelist: List[int] = [502, 503, 504]
+19 -114
View File
@@ -23,97 +23,6 @@ settings = get_settings()
logger = logging.getLogger(__name__)
def _get_duration_from_packets(local_path: Path) -> float:
"""Estimate duration from audio packet timestamps."""
# Run ffprobe to inspect the first audio stream in the file.
# ffprobe is part of FFmpeg and can output media metadata as JSON.
#
# ruff: noqa: S607 Hard to know the ffprobe path, it depends on the deployment
result = subprocess.run(
[
"ffprobe",
# Suppress normal ffprobe logging output.
"-v",
"quiet",
# Ask ffprobe to return JSON.
"-print_format",
"json",
# Select only the first audio stream.
"-select_streams",
"a:0",
# Include packet-level information in the output.
"-show_packets",
# Only include each packet's start timestamp and duration.
"-show_entries",
"packet=pts_time,duration_time",
# Read only the last ~10 packets 99999999 is to go to the end of the file
"-read_intervals",
"99999999%+#10",
# Skip non-reference frames for speed
"-skip_frame",
"noref",
local_path,
],
check=False,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True, # Decode stdout/stderr as strings instead of bytes.
)
data = json.loads(result.stdout)
# Build a list containing the end time of each audio packet.
#
# For each packet:
# end time = packet start time + packet duration
#
# pts_time is the packet presentation timestamp, meaning when that packet
# starts during playback.
#
# duration_time may be missing, so it defaults to 0.
packet_ends = [
float(packet["pts_time"]) + float(packet.get("duration_time", 0))
for packet in data.get("packets", [])
if "pts_time" in packet
]
# If no usable packets were found, the duration cannot be estimated.
if not packet_ends:
raise ValueError("Unable to determine recording duration.")
# The recording duration is estimated as the latest packet end time.
return max(packet_ends)
def get_media_duration(local_path: Path):
"""Get media (audio or video) file duration in seconds."""
# ruff: noqa: S607 Hard to know the ffprobe path, it depends on the deployment
result = subprocess.run(
[
"ffprobe",
# Suppress normal ffprobe logging output.
"-v",
"quiet",
# Ask ffprobe to return JSON.
"-print_format",
"json",
"-show_format",
local_path,
],
check=False,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
)
data = json.loads(result.stdout)
duration_value = data.get("format", {}).get("duration")
if duration_value not in (None, "N/A"):
return float(duration_value)
return _get_duration_from_packets(local_path)
class FileServiceException(Exception):
"""Base exception for file service operations."""
@@ -246,7 +155,25 @@ class FileService:
def _validate_duration(self, local_path: Path) -> float:
"""Validate audio file duration against configured maximum."""
duration = get_media_duration(local_path)
# ruff: noqa: S607 Hard to know the ffprobe path, it depends on the deployment
result = subprocess.run(
[
"ffprobe",
"-v",
"quiet",
"-print_format",
"json",
"-show_format",
local_path,
],
check=False,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
)
data = json.loads(result.stdout)
duration = float(data["format"]["duration"])
logger.info(
"Recording file duration: %.2f seconds",
@@ -302,28 +229,6 @@ class FileService:
os.remove(output_path)
raise RuntimeError("Failed to extract audio.") from e
def read_json(self, object_name: str) -> dict:
"""Read and parse a JSON file from MinIO storage."""
logger.info("Reading JSON: %s", object_name)
if not object_name:
raise ValueError("Invalid object_name")
response = None
try:
response = self._minio_client.get_object(self._bucket_name, object_name)
return json.loads(response.read())
except (MinioException, S3Error) as e:
raise FileServiceException(
"Unexpected error while reading JSON object."
) from e
except (json.JSONDecodeError, UnicodeDecodeError) as e:
raise FileServiceException("Invalid JSON content.") from e
finally:
if response:
response.close()
response.release_conn()
@contextmanager
def prepare_audio_file(
self,
@@ -1,9 +1,7 @@
"""Transcript formatting into readable conversation format with speaker labels."""
import logging
from datetime import datetime
from typing import Tuple
from zoneinfo import ZoneInfo
from typing import Optional, Tuple
from summary.core.config import get_settings
from summary.core.locales import LocaleStrings
@@ -41,10 +39,10 @@ class TranscriptFormatter:
def format(
self,
transcription,
room: str | None = None,
recording_datetime: str | None = None,
owner_timezone: str | None = None,
download_link: str | None = None,
room: Optional[str] = None,
recording_date: Optional[str] = None,
recording_time: Optional[str] = None,
download_link: Optional[str] = None,
) -> Tuple[str, str]:
"""Format transcription into the final document and its title."""
segments = self._get_segments(transcription)
@@ -56,7 +54,7 @@ class TranscriptFormatter:
content = self._remove_hallucinations(content)
content = self._add_header(content, download_link)
title = self._generate_title(room, recording_datetime, owner_timezone)
title = self._generate_title(room, recording_date, recording_time)
return content, title
@@ -85,7 +83,7 @@ class TranscriptFormatter:
return formatted_output
def _add_header(self, content, download_link: str | None) -> str:
def _add_header(self, content, download_link: Optional[str]) -> str:
"""Add download link header to the document content."""
if not download_link:
return content
@@ -99,20 +97,16 @@ class TranscriptFormatter:
def _generate_title(
self,
room: str | None = None,
recording_datetime: str | None = None,
owner_timezone: str | None = None,
room: Optional[str] = None,
recording_date: Optional[str] = None,
recording_time: Optional[str] = None,
) -> str:
"""Generate title from context or return default."""
if not room or not recording_datetime:
if not room or not recording_date or not recording_time:
return self._locale.document_default_title
dt = datetime.fromisoformat(recording_datetime)
if owner_timezone:
dt = dt.astimezone(ZoneInfo(owner_timezone))
return self._locale.document_title_template.format(
room=room,
room_recording_date=dt.strftime("%Y-%m-%d"),
room_recording_time=dt.strftime("%H:%M"),
room_recording_date=recording_date,
room_recording_time=recording_time,
)
-434
View File
@@ -1,434 +0,0 @@
"""Assign WhisperX diarization speakers to participant identities.
Uses per-stream VAD events to match generic SPEAKER_XX labels provided
by diarization to real user id's by computing time overlap between
diarization segments and VAD intervals.
Multiple speakers can map to the same participant (e.g. two people sharing
one microphone). A participant with no matching speaker gets no assignment.
"""
import json
import logging
from collections import defaultdict
from dataclasses import asdict, dataclass, field, is_dataclass
from datetime import datetime
from typing import Any
from summary.core.config import get_settings
settings = get_settings()
logger = logging.getLogger(__name__)
@dataclass
class Interval:
"""A time interval in seconds relative to recording start."""
start: float
end: float
@dataclass
class SpeakerAssignment:
"""Maps a diarization speaker label to a participant."""
speaker_label: str
participant_id: str
participant_name: str
score: float
@dataclass
class AssignmentResult:
"""Result of speaker-to-participant assignment."""
assignments: list[SpeakerAssignment] = field(default_factory=list)
unassigned_speakers: list[str] = field(default_factory=list)
def apply_to(self, diarization: dict[str, Any]) -> dict[str, Any]:
"""Return a copy of diarization with speaker labels replaced by names.
Replaces `"speaker"` fields in segments and word_segments with the
assigned participant name. Unassigned speakers are left as-is.
Args:
diarization: WhisperX dict with `segments` and optionally
`word_segments`.
Returns:
New dict with speaker labels replaced.
"""
speaker_to_name = {
a.speaker_label: a.participant_name for a in self.assignments
}
name_to_speaker_count = defaultdict(int)
for name in speaker_to_name.values():
name_to_speaker_count[name] += 1
def _replace_speaker(item: dict[str, Any]) -> dict[str, Any]:
if "speaker" in item and item["speaker"] in speaker_to_name:
name = speaker_to_name[item["speaker"]]
suffix = (
f" ({item['speaker']})" if name_to_speaker_count[name] > 1 else ""
) # Add suffix only if there are multiple detected speakers per user
return {**item, "speaker": f"{name}{suffix}"}
return {**item}
def _process_segment(
item: dict[str, Any], include_words: bool = False
) -> dict[str, Any]:
new_item = _replace_speaker(item)
if include_words and "words" in item:
new_item["words"] = [_replace_speaker(w) for w in item["words"]]
return new_item
result: dict[str, Any] = {}
for key, value in diarization.items():
if key not in ("segments", "word_segments"):
result[key] = value
continue
result[key] = [
_process_segment(item, include_words=(key == "segments"))
for item in value
]
return result
def _merge_intervals(intervals: list[Interval]) -> list[Interval]:
"""Return a list of non-overlapping intervals sorted by start time."""
if not intervals:
return []
sorted_intervals = sorted(intervals, key=lambda interval: interval.start)
merged: list[Interval] = [
Interval(sorted_intervals[0].start, sorted_intervals[0].end)
]
for interval in sorted_intervals[1:]:
if interval.start <= merged[-1].end:
merged[-1].end = max(merged[-1].end, interval.end)
else:
merged.append(Interval(interval.start, interval.end))
return merged
def _total_duration(intervals: list[Interval]) -> float:
"""Return the sum of all interval durations."""
return sum(interval.end - interval.start for interval in intervals)
def _overlap_duration(
a_intervals: list[Interval],
b_intervals: list[Interval],
) -> float:
"""Compute total overlap between two merged interval lists, sorted by start time."""
overlap = 0.0
i = j = 0
while i < len(a_intervals) and j < len(b_intervals):
a = a_intervals[i]
b = b_intervals[j]
lo = max(a.start, b.start)
hi = min(a.end, b.end)
if lo < hi:
overlap += hi - lo
if a.end <= b.end:
i += 1
else:
j += 1
return overlap
def _format_timelines_debug(
participant_timelines: dict[str, list[Interval]],
participant_names: dict[str, str],
speaker_timelines: dict[str, list[Interval]],
) -> str:
"""Render participant and speaker timelines side-by-side for debugging.
Each row is the slice between two consecutive interval boundaries
(drawn from both sides). A filled cell marks an active participant
(left block) or speaker (right block) during that slice, so vertical
alignment makes overlap visually obvious.
"""
participant_ids = sorted(participant_timelines.keys())
speaker_labels = sorted(speaker_timelines.keys())
if not participant_ids and not speaker_labels:
return "(no timelines)"
boundaries: set[float] = set()
for intervals in (*participant_timelines.values(), *speaker_timelines.values()):
for iv in intervals:
boundaries.add(iv.start)
boundaries.add(iv.end)
sorted_boundaries = sorted(boundaries)
if len(sorted_boundaries) < 2:
return "(no intervals)"
p_headers = [participant_names.get(pid, pid) for pid in participant_ids]
s_headers = list(speaker_labels)
p_widths = [max(len(h), 3) for h in p_headers]
s_widths = [max(len(h), 3) for h in s_headers]
def _active(intervals: list[Interval], lo: float, hi: float) -> bool:
mid = (lo + hi) / 2
return any(iv.start <= mid < iv.end for iv in intervals)
def _cells(
intervals_list: list[list[Interval]],
widths: list[int],
lo: float,
hi: float,
) -> str:
return " ".join(
("" * w if _active(iv, lo, hi) else "·" * w)
for iv, w in zip(intervals_list, widths, strict=True)
)
p_iv_list = [participant_timelines[pid] for pid in participant_ids]
s_iv_list = [speaker_timelines[sl] for sl in speaker_labels]
time_col = "[ start → end]"
p_hdr = (
" ".join(h.center(w) for h, w in zip(p_headers, p_widths, strict=True))
or "(none)"
)
s_hdr = (
" ".join(h.center(w) for h, w in zip(s_headers, s_widths, strict=True))
or "(none)"
)
sep = " || "
lines = [
f"{time_col} {p_hdr}{sep}{s_hdr}",
"-" * (len(time_col) + 2 + len(p_hdr) + len(sep) + len(s_hdr)),
]
for lo, hi in zip(sorted_boundaries, sorted_boundaries[1:], strict=False):
time_str = f"[{lo:8.2f}{hi:8.2f}]"
p_row = _cells(p_iv_list, p_widths, lo, hi) or " " * len(p_hdr)
s_row = _cells(s_iv_list, s_widths, lo, hi) or " " * len(s_hdr)
lines.append(f"{time_str} {p_row}{sep}{s_row}")
return "\n".join(lines)
def _build_participant_timelines(
metadata: dict[str, Any],
recording_start_datetime: datetime,
recording_end_datetime: datetime | None = None,
) -> tuple[dict[str, list[Interval]], dict[str, str]]:
"""Build VAD interval timelines for each participant.
Args:
metadata: Dict with `events` and `participants` keys.
recording_start_datetime: UTC datetime used as t=0 reference.
recording_end_datetime: UTC datetime of recording end. When provided,
any open speech_start without a matching speech_end is closed at
this time (the participant is assumed to be speaking until the end).
Returns:
participant_id merged VAD intervals
(seconds relative to recording_start_datetime).
participant_id display name.
Intervals are in seconds relative to recording_start_datetime.
Events before recording start are clamped to 0.
"""
events = metadata.get("events", [])
participants_info = {
p["participantId"]: p.get("name", p["participantId"])
for p in metadata.get("participants", [])
}
ref_epoch = recording_start_datetime.timestamp()
open_starts: dict[str, float] = {}
intervals: dict[str, list[Interval]] = {}
for event in events:
pid = event["participant_id"]
ts = datetime.fromisoformat(event["timestamp"]).timestamp() - ref_epoch
etype = event["type"]
if etype == "speech_start":
open_starts[pid] = max(ts, 0.0)
elif etype == "speech_end":
start = open_starts.pop(pid, None)
if start is not None:
end = max(ts, 0.0)
if end > start:
intervals.setdefault(pid, []).append(Interval(start, end))
# Close any speech_start that was never matched by a speech_end.
# Assume the participant kept speaking until the recording ended.
if recording_end_datetime is not None and open_starts:
recording_end = recording_end_datetime.timestamp() - ref_epoch
for pid, start in open_starts.items():
end = max(recording_end, 0.0)
if end > start:
intervals.setdefault(pid, []).append(Interval(start, end))
for pid, pid_intervals in intervals.items():
intervals[pid] = _merge_intervals(pid_intervals)
return intervals, participants_info
def _build_speaker_timelines(transcription: Any) -> dict[str, list[Interval]]:
"""Build interval timelines from WhisperX transcription segments."""
intervals: dict[str, list[Interval]] = {}
segments = transcription.segments if hasattr(transcription, "segments") else []
max_word_duration = settings.resolve_speaker_identities_max_word_duration
for segment in segments:
speaker = segment.get("speaker")
if speaker is None:
continue
words = [
w
for w in segment.get("words", [])
if w.get("start") is not None and w.get("end") is not None
]
if not words:
intervals.setdefault(speaker, []).append(
Interval(segment["start"], segment["end"])
)
continue
start_time: float | None = segment["start"]
for word in words:
if start_time is None:
start_time = word["start"]
if not settings.resolve_speaker_identities_enable_split_on_words:
continue
if word["end"] - word["start"] > max_word_duration:
end_time = word["start"] + max_word_duration
if end_time > start_time:
intervals.setdefault(speaker, []).append(
Interval(start_time, end_time)
)
start_time = None
if start_time is not None:
last = words[-1]
end_time = min(last["end"], last["start"] + max_word_duration)
if end_time > start_time:
intervals.setdefault(speaker, []).append(Interval(start_time, end_time))
for speaker, speaker_intervals in intervals.items():
intervals[speaker] = _merge_intervals(speaker_intervals)
return intervals
def _json_default(obj: Any) -> Any:
"""Encode datetimes, dataclasses, and pydantic models for `json.dumps`.
Intended to be used for logging of `resolve_speaker_identities` (input
and computed variables)
"""
if isinstance(obj, datetime):
return obj.isoformat()
if is_dataclass(obj) and not isinstance(obj, type):
return asdict(obj)
if hasattr(obj, "segments") and hasattr(obj, "word_segments"):
return {"segments": obj.segments, "word_segments": obj.word_segments}
if hasattr(obj, "model_dump"):
return obj.model_dump(mode="json")
raise TypeError(f"Object of type {type(obj).__name__} is not JSON serializable")
def resolve_speaker_identities(
metadata: dict[str, Any],
transcription: Any,
recording_start_datetime: datetime,
recording_end_datetime: datetime,
overlap_threshold: float = settings.resolve_speaker_identities_default_overlap_threshold, # noqa: E501
) -> AssignmentResult:
"""Match WhisperX speaker labels to participants.
Args:
metadata: User metadata with `events` and `participants`.
transcription: WhisperX Transcription object with a `segments` attribute.
recording_start_datetime: UTC datetime for t=0 reference.
recording_end_datetime: UTC datetime of recording end. Open speech
intervals are closed at this time.
overlap_threshold: Minimum overlap/speaker_duration to accept.
Returns:
AssignmentResult with per-speaker assignments and unassigned
speakers.
"""
participant_timelines, participant_names = _build_participant_timelines(
metadata, recording_start_datetime, recording_end_datetime
)
speaker_timelines = _build_speaker_timelines(transcription)
result = AssignmentResult()
for speaker, speaker_intervals in speaker_timelines.items():
speaker_duration = _total_duration(speaker_intervals)
if speaker_duration == 0:
result.unassigned_speakers.append(speaker)
continue
best_pid: str | None = None
best_score: float = 0.0
for pid, part_intervals in participant_timelines.items():
overlap = _overlap_duration(speaker_intervals, part_intervals)
score = overlap / speaker_duration
if score > best_score:
best_score = score
best_pid = pid
if best_pid is not None and best_score >= overlap_threshold:
result.assignments.append(
SpeakerAssignment(
speaker_label=speaker,
participant_id=best_pid,
participant_name=participant_names.get(best_pid, best_pid),
score=best_score,
)
)
logger.info(
"Assigned %s -> %s (score=%.3f)",
speaker,
participant_names.get(best_pid, best_pid),
best_score,
)
else:
result.unassigned_speakers.append(speaker)
logger.info(
"Speaker %s unassigned (best=%.3f, threshold=%.3f)",
speaker,
best_score,
overlap_threshold,
)
logger.debug(
json.dumps(
{
"input": {
"recording_start_datetime": recording_start_datetime.isoformat(),
"recording_end_datetime": recording_end_datetime.isoformat(),
"metadata": metadata,
"transcription": transcription,
},
"computed": {
"speaker_timelines": speaker_timelines,
"participant_timelines": participant_timelines,
"result": result,
},
},
default=_json_default,
indent=2,
ensure_ascii=False,
),
)
logger.debug(
_format_timelines_debug(
participant_timelines, participant_names, speaker_timelines
),
)
return result
+9 -11
View File
@@ -19,14 +19,14 @@ class TestTasks:
headers={"Authorization": "Bearer test-api-token"},
json={
"owner_id": "owner-123",
"recording_filename": "recording.mp4",
"metadata_filename": "metadata.json",
"filename": "recording.mp4",
"email": "user@example.com",
"sub": "sub-123",
"room": "room-abc",
"owner_timezone": "UTC",
"recording_date": "2026-01-01",
"recording_time": "10:00:00",
"language": None,
"download_link": "https://example.com/file.mp4",
"download_link": "http://example.com/file.mp4",
},
)
@@ -36,18 +36,16 @@ class TestTasks:
args = mock_apply_async.call_args.kwargs["args"]
assert args == [
"owner-123", # owner_id
"recording.mp4", # recording_filename
"metadata.json", # metadata_filename
"recording.mp4", # filename
"user@example.com", # email
"sub-123", # sub
1735725600.0, # received_at
1735725600.0, # frozen time
"room-abc", # room
"UTC", # owner_timezone
"2026-01-01", # recording_date
"10:00:00", # recording_time
None, # language
"https://example.com/file.mp4", # download_link
"http://example.com/file.mp4", # download_link
None, # context_language
None, # recording_start_at
None, # recording_end_at
]
def test_create_task_invalid_language(self, client):
Binary file not shown.
Binary file not shown.
Binary file not shown.

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