Compare commits

...

40 Commits

Author SHA1 Message Date
lebaudantoine de50aeb4fe wip hint if the user is authenticated or not in attributes 2026-05-17 23:44:49 +02:00
lebaudantoine 71f76a81e9 ♻️(backend) refactor caller identity getter
Enhance getting the caller's identity to prevent None.
2026-05-17 23:42:54 +02:00
lebaudantoine 385da86759 🔒️(backend) verify participant presence before mute operations
Ensure the participant requesting a mute action is still present
in the room before processing the request.

This mitigates scenarios where a previously issued token could be
reused after the meeting has ended.

Current token lifetime is intentionally long-lived and will be
refactored in the future to better align with LiveKit session
constraints. In the meantime, add this extra validation step to
reduce the attack surface.
2026-05-17 23:39:53 +02:00
lebaudantoine 81e3483f28 📝(changelog) update the changelog 2026-05-17 23:39:53 +02:00
lebaudantoine 5e030c2a07 ♻️(frontend) refactor useMuteParticipant hook
Fix unreachable code when notifying participants that they were
muted.

Prevent unnecessary function re-creations when props remain
unchanged.

Also guard against missing tokens by logging an error and
returning early when the token is undefined.
2026-05-17 23:39:53 +02:00
lebaudantoine 32fbedd358 (backend) extend live synchronization to lobby access level updates
Extend the existing live synchronization mechanism beyond room
configuration to also include lobby access level changes.

This ensures that all owners and admins sharing a room maintain a
consistent and up-to-date view of room state in the frontend,
including configuration and access control updates.
2026-05-17 23:39:53 +02:00
lebaudantoine aab90650f1 (frontend) add synchroniser for room metadata updates
Listen to room metadata change events and synchronize the React
Query cache with the latest room data fetched from the API.

This ensures clients react to live configuration updates, such as
showing or hiding mute controls when `everyone_can_mute` changes.
2026-05-17 23:39:53 +02:00
lebaudantoine 534cf000b2 (backend) expose room configuration to all API consumers
Update room serialization to include room configuration for all
users fetching the API response, not only room owners.
This behavior was inherited from the original upstream project.

At the moment, exposing this configuration does not appear to
introduce meaningful security concerns or provide attackers with
additional capabilities.

The decision will continue to be reviewed from a security
perspective, but sharing the configuration improves frontend
consistency and synchronization.
2026-05-17 23:39:53 +02:00
lebaudantoine 5bac1668fe ♻️(fullstack) simplify source serialization
Simplify source serialization and validation logic while improving
type safety around room configuration handling.

Introduce a dedicated TypeScript type matching the backend
Pydantic model more precisely.

Also harmonize track source casing between frontend and backend to
remove redundant conversion logic and resolve #1282.
2026-05-17 23:39:53 +02:00
lebaudantoine 5a7a0da923 (backend) add synchronization mechanism for room configuration updates
Introduce synchronization of room configuration changes across
active participants.

When a room configuration is updated through a PUT operation, the
backend now performs an additional LiveKit API call to notify room
participants through a room metadata update event.

This ensures admins and owners quickly see up-to-date settings in
their administration panel. It also prepares the frontend for
automatic updates of unprivileged participants room’s data without
refetching it from the API.

An event-driven design was chosen instead of storing the full room
configuration in LiveKit metadata. While embedding the state
directly in metadata would provide immediate synchronization, it
would also require initializing and maintaining configuration
state during room creation or webhook handling, increasing the
risk of operational failures and regressions.

Instead, the backend emits lightweight synchronization events and
active clients update their React Query cache, which remains the
single source of truth for room configuration data.
2026-05-17 23:39:53 +02:00
lebaudantoine c20daafd81 (fullstack) support everyone_can_mute room configuration
Introduce a new room setting controlling whether all participants,
including non-privileged users, can mute others.

Update API validation accordingly and add the frontend controls
allowing administrators to toggle the option and persist the
configuration through the API.
2026-05-17 23:39:53 +02:00
lebaudantoine 9846a61bd0 (frontend) update useCanMute hook to reflect room muting behavior
Allow non-privileged users to mute others when the
everyone_can_mute configuration is unset or true.

This setting is not yet customizable by room owners and will be
introduced in a future update.
2026-05-17 23:39:53 +02:00
lebaudantoine 388b7d172d (frontend) allow unauthenticated participants to mute via LiveKit token
Pass the LiveKit token when calling the mute-participant endpoint
to authenticate the request.

This enables non-authenticated participants to mute others through
the API while preserving proper authorization checks.
2026-05-17 23:39:53 +02:00
lebaudantoine 288562cc0e 🛂(backend) allow participants to mute others based on room configuration
Enable any participant to mute others when the room configuration
allows it. This is enabled by default for all meetings unless
explicitly disabled by an administrator.

Privileged users retain the ability to mute any participant
regardless of the room configuration.
2026-05-17 23:39:53 +02:00
leo 79400188d8 🔊(summary) improve logging of speaker assign
Structure logging of speaker assignment in json format to help
assess its performance.
2026-05-14 15:39:02 +02:00
lebaudantoine dcaa45ccfe 🩹(frontend) fix subtitle background regression
Restore transparent background as the default subtitle background
to match previous behavior.
2026-05-14 15:04:48 +02:00
lebaudantoine 35951ba2a6 🔖(minor) bump release to 1.16.0 2026-05-13 22:30:32 +02:00
lebaudantoine 72184e1370 🩹(frontend) fix spacing regression in mobile control bar
Correct excessive spacing between action buttons in the mobile
control bar introduced by a recent layout change.
2026-05-13 20:15:32 +02:00
leo 1b4a8fbac2 🔧(agents) fix Docker setup
Fix two issues. 1: Missmatch between commands in dev and production in
Dockerfile, leading to unexpected behaviors. 2: Naming of
multi-user-transcriber -> multi-user-transcriber-dev for coherence.
2026-05-13 20:07:45 +02:00
lebaudantoine 1e2fad5444 ️(mail) revert mail upgrade due to unhandled breaking changes
Rollback the mail package upgrade after identifying multiple
breaking changes introduced in v5 that were not fully accounted
for.

Local testing initially missed the issue because the mail Docker
image had not been rebuilt automatically, causing broken emails to
go unnoticed.
2026-05-13 19:55:54 +02:00
leo 96f97ed2d0 (summary) improve speaker assignment
Speaker-to-participant assignment relie on WhisperX word timings, but
incorrect word durations in the output can lead to inaccurate overlap
scoring and wrong user attribution. Add a custom heuristic to trim
overly long word durations before computing assignments.
2026-05-12 16:58:07 +02:00
lebaudantoine 02d16cb55c ⬆️(addons) update dependencies 2026-05-12 16:26:16 +02:00
lebaudantoine 7268ff6777 ⬆️(mail) update dependencies 2026-05-12 16:26:16 +02:00
lebaudantoine cca5bc2186 ⬆️(frontend) update dependencies 2026-05-12 16:26:16 +02:00
leo ec67a12fe4 (agents) use uv for dependency management
Change from pip to uv for dependancy management in src/agents.
2026-05-12 13:47:19 +02:00
leo 05f32d008a ⬆️ (dependencies) Bump urllib3 from 2.6.3 to 2.7.0 [SECURITY]
Fix CVE-2026-44431 and CVE-2026-44432.
2026-05-12 11:23:00 +02:00
UGilfoyle 964b3cd452 🐛(backend) add link to "Open" text in recording email
Added a hyperlink to the "Open" text in step 1 of the recording
notification email instructions. Previously, "Open" was plain text
and users could only access their recording via the button below.
Now the text itself is a clickable link, improving accessibility
for email clients that may not render the button properly.

Updated MJML source template and all 4 locale files (en, fr, de, nl).
2026-05-11 23:04:27 +02:00
Florent Chehab c7ca5a621f 🐛(ci) install ffmpeg for summary tests
Add ffmpeg for summary tests
2026-05-11 23:00:55 +02:00
Florent Chehab 90ebe231ef 🐛(summary) complete webm support
When duration is not reported in the files metadata,
we directly infer the duration from the audio packets.
This prevents errors on webm files.

Very simple audio & video test files have been added
that cover relevant usecases to prevent regressions.
2026-05-11 23:00:54 +02:00
soyouzpanda 04f2a9ebdc ⬆️(mail) fix dependencies not having resolved or integrity field
Update dependencies to the latest minor versions fixed that
by re-resolving the fields.
This is needed for packaging as many distribution retrieve
node modules into the npm cache and then tries to install
node modules into the project without any internet connection.
Since there is no resolved/integrity field, it fails to
get packages from the cache.
2026-05-11 12:45:02 +02:00
renovate[bot] 6a8eb79b41 ⬆️(dependencies) update django to v5.2.14 [SECURITY] 2026-05-11 12:03:18 +02:00
leo bc35046b3a 🩹(summary) fix bug in assign_user
Fix bug in speaker assignment which occurs when LIVEKIT_VERIFY_SSL
is True.
2026-05-07 18:17:15 +02:00
leo 1612d8b2d4 (audio) assign users to diarization speaker results using VAD
Introduce a new user assignment mechanism to for more friendly output
than the current (SPEAKER_0, SPEAKER_1, ...). Use the VAD metadata to
compare speech intervals with those returned by WhisperX. User with the
highest overlap score above a defined threshold is assigned to each segment.
This method allows for multi-speaker scenarios for a single account.
2026-05-07 12:45:00 +02:00
lebaudantoine f8937fc0a1 ♻️(frontend) improve and simplify accessibility font override logic
Fix compatibility issues with the DINUM frontend image, which
overrides the default `font-sans` value.

Simplify the implementation by having the JavaScript layer only
toggle well-scoped CSS classes responsible for accessibility font
overrides. This makes the behavior more predictable and restoring
default styles straightforward.

Also clarify the intent of the hook by making its accessibility
purpose explicit and moving its usage to the App component, where
it better fits the application lifecycle.
2026-05-07 11:20:15 +02:00
Cyril 97b5e3e65c (frontend) add font selector in accessibility settings
Dropdown with description and FR/EN/NL translations.
2026-05-07 11:20:15 +02:00
Cyril b917d82f7e (frontend) apply font preference to app layout
Hook, CSS variable and LiveKit integration for custom fonts.
2026-05-07 11:20:15 +02:00
Cyril 82d146cdf5 (frontend) install accessibility font packages
Lexend, Atkinson Hyperlegible Next and OpenDyslexic via fontsource.
2026-05-07 11:20:15 +02:00
Cyril cbfeea0a4e (frontend) add uiFont preference to accessibility store
Add UiFont type with four options and Extend AccessibilityState.
2026-05-07 11:20:15 +02:00
leo a695758da4 ♻️(summary) refactor tasks signature and make transcription tz-aware
The tasks endpoint used non-timezone-aware date and time values and split
them into separate variables, which is unconventional. Refactor the
implementation to use timezone-aware datetime objects and align transcription
formatting with the user-declared timezone. Update the source of truth for
recording start time to FileInfo.started_at for improved precision. Adjust
the task signature in preparation for upcoming user assignment work, which
will require `started_at`, `ended_at`, and `metadata_filename`.
2026-05-06 18:33:03 +02:00
Damien Laine 4c5b6de8f3 (backend) make LiveKit Egress recording encoding configurable
Expose RECORDING_ENCODING_* settings to override the default LiveKit
Egress preset (H264_720P_30). When RECORDING_ENCODING_ENABLED is True,
the provided width/height/framerate/bitrate/keyframe values are passed
as advanced EncodingOptions. Lowering framerate and bitrate reduces
recording file size and egress worker CPU load.

Disabled by default, preserving current behaviour.
2026-05-05 18:26:49 +02:00
87 changed files with 5752 additions and 1061 deletions
+11 -5
View File
@@ -150,13 +150,14 @@ jobs:
uses: actions/setup-python@v6
with:
python-version: "3.13"
cache: "pip"
- name: Install development dependencies
run: pip install --user .[dev]
- name: Install uv
uses: astral-sh/setup-uv@v7
- name: Install the project
run: uv sync --locked --all-extras
- name: Check code formatting with ruff
run: ~/.local/bin/ruff format . --diff
run: uv run ruff format . --diff
- name: Lint code with ruff
run: ~/.local/bin/ruff check .
run: uv run ruff check .
lint-summary:
runs-on: ubuntu-latest
@@ -322,6 +323,11 @@ 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:
+26
View File
@@ -10,14 +10,39 @@ and this project adheres to
### 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
@@ -56,6 +81,7 @@ 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
@$(COMPOSE) build multi-user-transcriber-dev
.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
@$(COMPOSE) up --force-recreate -d multi-user-transcriber-dev
.PHONY: run-agent-multi-user-transcriber
run-agent-metadata-collector: ## start the LiveKit agents (metadata collector)
+12 -8
View File
@@ -249,6 +249,7 @@ services:
metadata-collector-dev:
build:
context: ./src/agents
target: development
command: ["python", "metadata_collector.py", "dev"]
environment:
- LIVEKIT_URL=ws://livekit:7880
@@ -261,6 +262,7 @@ services:
- AWS_S3_SECURE_ACCESS=False
volumes:
- ./src/agents:/app
- /app/.venv
depends_on:
- livekit
- minio
@@ -269,6 +271,16 @@ 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:
@@ -330,14 +342,6 @@ 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,6 +100,13 @@ 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
@@ -141,3 +148,54 @@ 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,6 +68,18 @@ 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
+12 -250
View File
@@ -19,7 +19,7 @@
"@types/office-runtime": "^1.0.35",
"acorn": "^8.11.3",
"babel-loader": "^9.1.3",
"copy-webpack-plugin": "^12.0.2",
"copy-webpack-plugin": "^14.0.0",
"eslint-plugin-office-addins": "^4.0.3",
"file-loader": "^6.2.0",
"html-loader": "^5.0.0",
@@ -4319,44 +4319,6 @@
"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",
@@ -4370,19 +4332,6 @@
"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",
@@ -6686,21 +6635,20 @@
"license": "MIT"
},
"node_modules/copy-webpack-plugin": {
"version": "12.0.2",
"resolved": "https://registry.npmjs.org/copy-webpack-plugin/-/copy-webpack-plugin-12.0.2.tgz",
"integrity": "sha512-SNwdBeHyII+rWvee/bTnAYyO8vfVdcSTud4EIb6jcZ8inLeWucJE0DnxXQBjlQ5zlteuuvooGQy3LIyGxhvlOA==",
"version": "14.0.0",
"resolved": "https://registry.npmjs.org/copy-webpack-plugin/-/copy-webpack-plugin-14.0.0.tgz",
"integrity": "sha512-3JLW90aBGeaTLpM7mYQKpnVdgsUZRExY55giiZgLuX/xTQRUs1dOCwbBnWnvY6Q6rfZoXMNwzOQJCSZPppfqXA==",
"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": "^6.0.2"
"serialize-javascript": "^7.0.3",
"tinyglobby": "^0.2.12"
},
"engines": {
"node": ">= 18.12.0"
"node": ">= 20.9.0"
},
"funding": {
"type": "opencollective",
@@ -8062,36 +8010,6 @@
"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",
@@ -8180,16 +8098,6 @@
"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",
@@ -8758,37 +8666,6 @@
"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",
@@ -11044,16 +10921,6 @@
"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",
@@ -12638,19 +12505,6 @@
"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",
@@ -12985,37 +12839,6 @@
"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",
@@ -13502,17 +13325,6 @@
"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",
@@ -13545,30 +13357,6 @@
"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",
@@ -13842,13 +13630,13 @@
}
},
"node_modules/serialize-javascript": {
"version": "6.0.2",
"resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-6.0.2.tgz",
"integrity": "sha512-Saa1xPByTTq2gdeFZYLLo+RFE35NHZkAbqZeWNd3BpzppeVisAqpDjcp8dyf6uIvEqJRd46jemmyA4iFIeVk8g==",
"version": "7.0.5",
"resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-7.0.5.tgz",
"integrity": "sha512-F4LcB0UqUl1zErq+1nYEEzSHJnIwb3AF2XWB94b+afhrekOUijwooAYqFyRbjYkm2PAKBabx6oYv/xDxNi8IBw==",
"dev": true,
"license": "BSD-3-Clause",
"dependencies": {
"randombytes": "^2.1.0"
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/serve-index": {
@@ -14260,19 +14048,6 @@
"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",
@@ -15311,19 +15086,6 @@
"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",
+1 -1
View File
@@ -36,7 +36,7 @@
"@types/office-runtime": "^1.0.35",
"acorn": "^8.11.3",
"babel-loader": "^9.1.3",
"copy-webpack-plugin": "^12.0.2",
"copy-webpack-plugin": "^14.0.0",
"eslint-plugin-office-addins": "^4.0.3",
"file-loader": "^6.2.0",
"html-loader": "^5.0.0",
+38
View File
@@ -0,0 +1,38 @@
# 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
+42 -14
View File
@@ -6,31 +6,61 @@ RUN apt-get update && apt-get install -y \
libgobject-2.0-0 \
&& rm -rf /var/lib/apt/lists/*
# ---- Builder image ----
FROM base AS builder
WORKDIR /builder
ENV UV_COMPILE_BYTECODE=1 \
UV_LINK_MODE=copy \
UV_PYTHON_DOWNLOADS=0
COPY pyproject.toml .
RUN mkdir /install && \
pip install --prefix=/install .
FROM base AS development
# Install uv
COPY --from=ghcr.io/astral-sh/uv:0.10.9 /uv /uvx /bin/
WORKDIR /app
COPY pyproject.toml .
RUN pip install --no-cache-dir ".[dev]"
# 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
COPY . .
# Install the project
COPY . /app
RUN --mount=type=cache,target=/root/.cache/uv \
uv sync --locked --no-dev
CMD ["python", "metadata_collector.py", "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
RUN --mount=type=cache,target=/root/.cache/uv \
uv sync --locked --all-extras
ENV PATH="/app/.venv/bin:$PATH"
CMD ["python", "multi_user_transcriber.py", "dev"]
# ---- Production image ----
FROM base AS production
WORKDIR /app
COPY --from=builder /install /usr/local
# Copy the pre-built virtualenv and application source
COPY --from=builder /app /app
ENV PATH="/app/.venv/bin:$PATH"
# Remove pip to reduce attack surface in production
RUN pip uninstall -y pip
@@ -39,6 +69,4 @@ 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)
+3 -7
View File
@@ -1,7 +1,7 @@
[project]
name = "agents"
version = "1.15.0"
version = "1.16.0"
requires-python = ">=3.12"
dependencies = [
"livekit-agents==1.4.5",
@@ -18,12 +18,8 @@ dev = [
"ruff==0.15.6",
]
[tool.setuptools]
py-modules = ["multi_user_transcriber", "metadata_collector", "exceptions"]
[build-system]
requires = ["setuptools>=61.0"]
build-backend = "setuptools.build_meta"
[tool.uv]
package = false
[tool.ruff]
target-version = "py313"
+1963
View File
File diff suppressed because it is too large Load Diff
+30
View File
@@ -136,3 +136,33 @@ 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)
+9 -14
View File
@@ -13,7 +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
from pydantic import BaseModel, Field, field_serializer
from pydantic import ValidationError as PydanticValidationError
from rest_framework import serializers
from rest_framework.exceptions import PermissionDenied
@@ -166,11 +166,6 @@ 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
@@ -187,7 +182,7 @@ class RoomSerializer(serializers.ModelSerializer):
room_id=room_id,
user=request.user,
username=username,
configuration=configuration,
configuration=output["configuration"],
is_admin_or_owner=is_admin_or_owner,
)
else:
@@ -317,9 +312,7 @@ class MuteParticipantSerializer(BaseParticipantsManagementSerializer):
)
RoomConfigurationTrackSource = Literal[
"camera", "microphone", "screen_share", "screen_share_audio"
]
TrackSource = Literal["camera", "microphone", "screen_share", "screen_share_audio"]
class RoomConfiguration(BaseModel):
@@ -328,14 +321,12 @@ class RoomConfiguration(BaseModel):
Unknown fields are rejected.
"""
can_publish_sources: list[RoomConfigurationTrackSource] | None = None
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):
"""Mirror the LiveKit ParticipantPermission protobuf.
@@ -355,6 +346,10 @@ 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."""
+67 -1
View File
@@ -33,6 +33,7 @@ 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
@@ -76,6 +77,11 @@ 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
@@ -299,6 +305,41 @@ 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,6 +404,7 @@ class RoomViewSet(
):
try:
MetadataCollectorService().start(recording)
logger.debug("Started MetadataCollectorService")
except MetadataCollectorException:
logger.warning("Failed to start MetadataCollectorService")
@@ -613,7 +655,11 @@ class RoomViewSet(
methods=["post"],
url_path="mute-participant",
url_name="mute-participant",
permission_classes=[permissions.HasPrivilegesOnRoom],
permission_classes=[permissions.CanMuteParticipant],
authentication_classes=[
LiveKitTokenAuthentication,
*api_settings.DEFAULT_AUTHENTICATION_CLASSES,
],
)
def mute_participant(self, request, pk=None): # pylint: disable=unused-argument
"""Mute a specific track for a participant in the room."""
@@ -622,6 +668,26 @@ 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,6 +388,7 @@ 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,7 +1,9 @@
"""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
@@ -9,9 +11,12 @@ 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
from core import models, utils
logger = logging.getLogger(__name__)
@@ -131,7 +136,50 @@ class NotificationService:
return not has_failures
@staticmethod
def _notify_summary_service(recording):
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):
"""Notify summary service about a new recording."""
if (
@@ -150,24 +198,35 @@ 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),
"filename": recording.key,
"recording_filename": recording.key,
"metadata_filename": metadata_filename,
"email": owner_access.user.email,
"sub": owner_access.user.sub,
"room": recording.room.name,
"language": recording.options.get("language"),
"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"),
"owner_timezone": str(owner_access.user.timezone),
"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,5 +1,7 @@
"""Factory, configurations and Protocol to create worker services"""
# pylint: disable=no-member
import logging
from dataclasses import dataclass
from functools import lru_cache
@@ -8,8 +10,17 @@ 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:
@@ -18,6 +29,7 @@ class WorkerServiceConfig:
output_folder: str
server_configurations: Dict[str, Any]
bucket_args: Optional[dict]
encoding_options: Optional[Dict[str, Any]] = None
@classmethod
@lru_cache
@@ -25,6 +37,24 @@ 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,
@@ -36,6 +66,7 @@ class WorkerServiceConfig:
"bucket": settings.AWS_STORAGE_BUCKET_NAME,
"force_path_style": True,
},
encoding_options=encoding_options,
)
+27 -3
View File
@@ -83,6 +83,22 @@ 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."""
@@ -104,9 +120,17 @@ class VideoCompositeEgressService(BaseEgressService):
s3=self._s3,
)
request = livekit_api.RoomCompositeEgressRequest(
room_name=room_name, file_outputs=[file_output], layout="speaker-light"
)
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)
response = self._handle_request(request, "start_room_composite_egress")
@@ -15,6 +15,7 @@ from livekit.api import (
TwirpError,
UpdateParticipantRequest,
)
from livekit.protocol.models import ParticipantInfo
from core import utils
@@ -154,3 +155,44 @@ 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
)
@@ -0,0 +1,64 @@
"""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()
@@ -2,7 +2,7 @@
Test worker service factories.
"""
# pylint: disable=protected-access,redefined-outer-name,unused-argument
# pylint: disable=protected-access,redefined-outer-name,unused-argument,no-member
from dataclasses import FrozenInstanceError
from unittest.mock import Mock
@@ -10,6 +10,9 @@ 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,
@@ -63,6 +66,8 @@ 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):
@@ -71,6 +76,45 @@ 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,6 +39,31 @@ 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"""
@@ -224,6 +249,41 @@ 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,20 +2,23 @@
Test rooms API endpoints in the Meet core app: participants management.
"""
# pylint: disable=redefined-outer-name,unused-argument,protected-access
# pylint: disable=redefined-outer-name,unused-argument,protected-access,no-name-in-module,too-many-lines
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
from livekit.api import TwirpError, UpdateParticipantRequest
from livekit.protocol.models import ParticipantInfo
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
@@ -31,8 +34,8 @@ def mock_livekit_client():
yield mock_client
def test_mute_participant_success(mock_livekit_client):
"""Test successful participant muting."""
def test_mute_participant_success_as_admin(mock_livekit_client):
"""Admins and owners should be able to mute without a LiveKit token."""
client = APIClient()
room = RoomFactory()
user = UserFactory()
@@ -41,10 +44,12 @@ def test_mute_participant_success(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, payload, format="json")
response = client.post(
url,
{"participant_identity": str(uuid4()), "track_sid": "test-track-sid"},
format="json",
)
assert response.status_code == status.HTTP_200_OK
assert response.data == {"status": "success"}
@@ -53,23 +58,131 @@ def test_mute_participant_success(mock_livekit_client):
mock_livekit_client.aclose.assert_called_once()
def test_mute_participant_forbidden_without_access():
"""Test mute participant returns 403 when user lacks room privileges."""
def test_mute_participant_anonymous_no_token_forbidden(mock_livekit_client):
"""Should forbid muting when user is anonymous and no LiveKit token."""
client = APIClient()
room = RoomFactory()
user = UserFactory() # User without UserResourceAccess
client.force_authenticate(user=user)
payload = {"participant_identity": str(uuid4()), "track_sid": "test-track-sid"}
url = reverse("rooms-mute-participant", kwargs={"pk": room.id})
response = client.post(url, payload, format="json")
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
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_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():
"""Test mute participant with invalid payload."""
"""Should reject muting when the payload is invalid."""
client = APIClient()
room = RoomFactory()
user = UserFactory()
@@ -78,16 +191,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, payload, format="json")
response = client.post(
url, {"participant_identity": "invalid-uuid", "track_sid": ""}, format="json"
)
assert response.status_code == status.HTTP_400_BAD_REQUEST
def test_mute_participant_unexpected_twirp_error(mock_livekit_client):
"""Test mute participant when LiveKit API raises TwirpError."""
"""Should return 500 when the LiveKit API raises a TwirpError."""
client = APIClient()
mock_livekit_client.room.mute_published_track.side_effect = TwirpError(
@@ -101,10 +214,12 @@ 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, payload, format="json")
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"}
@@ -112,6 +227,282 @@ 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()
@@ -130,8 +521,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,
@@ -158,8 +549,8 @@ def test_update_participant_success(mock_livekit_client):
{"can_publish_data": True},
{
"can_publish_sources": [
"CAMERA",
"MICROPHONE",
"camera",
"microphone",
]
},
{"can_update_metadata": True},
@@ -190,9 +581,41 @@ 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,6 +28,7 @@ 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,
@@ -47,6 +48,7 @@ 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,
@@ -65,6 +67,7 @@ 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,
@@ -81,6 +84,7 @@ 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,
@@ -97,6 +101,7 @@ 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,
@@ -200,6 +205,7 @@ 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,
@@ -246,6 +252,7 @@ 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,
@@ -297,6 +304,7 @@ 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,
@@ -338,6 +346,7 @@ 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,
@@ -383,6 +392,7 @@ 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,
@@ -3,12 +3,18 @@ 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
@@ -79,12 +85,14 @@ def test_api_rooms_update_members():
assert room.configuration == {}
def test_api_rooms_update_administrators():
"""Administrators or owners of a room should be allowed to update it."""
@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."""
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)
@@ -106,11 +114,120 @@ def test_api_rooms_update_administrators():
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": [
@@ -122,12 +239,17 @@ def test_api_rooms_update_administrators():
},
{"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"},
],
)
def test_api_rooms_update_configuration_valid(configuration):
@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")])
room = RoomFactory(users=[(user, "owner")], configuration={})
client = APIClient()
client.force_login(user)
@@ -140,6 +262,28 @@ def test_api_rooms_update_configuration_valid(configuration):
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."""
@@ -198,6 +342,24 @@ def test_api_rooms_update_configuration_wrong_type():
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 == {}
def test_api_rooms_update_administrators_of_another():
"""
Being administrator or owner of a room should not grant authorization to update
@@ -217,3 +379,61 @@ 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"]},
},
)
+5 -1
View File
@@ -121,7 +121,11 @@ 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"}
{
"color": color,
"room_admin": "true" if is_admin_or_owner else "false",
"is_authenticated": not user.is_anonymous,
}
)
)
@@ -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 \"Open\" button below "
msgstr "Klicken Sie auf den Button „Öffnen“ unten "
msgid "Click the \"<a href=\"%(link)s\">Open</a>\" link below "
msgstr "Klicken Sie auf den Link „<a href=\"%(link)s\">Öffnen</a>\" 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 \"Open\" button below "
msgstr "Click the \"Open\" button below "
msgid "Click the \"<a href=\"%(link)s\">Open</a>\" link below "
msgstr "Click the \"<a href=\"%(link)s\">Open</a>\" link 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 \"Open\" button below "
msgstr "Cliquez sur le bouton \"Ouvrir\" ci-dessous "
msgid "Click the \"<a href=\"%(link)s\">Open</a>\" link below "
msgstr "Cliquez sur le lien \"<a href=\"%(link)s\">Ouvrir</a>\" 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 \"Open\" button below "
msgstr "Klik op de \"Openen\"-knop hieronder "
msgid "Click the \"<a href=\"%(link)s\">Open</a>\" link below "
msgstr "Klik op de \"<a href=\"%(link)s\">Openen</a>\"-link hieronder "
#: core/templates/mail/html/screen_recording.html:209
#: core/templates/mail/text/screen_recording.txt:14
+43
View File
@@ -700,6 +700,44 @@ 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
)
@@ -817,6 +855,11 @@ 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(
+3 -2
View File
@@ -7,7 +7,7 @@ build-backend = "uv_build"
[project]
name = "meet"
version = "1.15.0"
version = "1.16.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.13",
"django==5.2.14",
"djangorestframework==3.16.1",
"drf_spectacular==0.29.0",
"dockerflow==2026.3.4",
@@ -61,6 +61,7 @@ dependencies = [
"mozilla-django-oidc==5.0.2",
"livekit-api==1.1.0",
"aiohttp==3.13.4",
"urllib3==2.7.0",
]
[project.urls]
+10 -8
View File
@@ -573,16 +573,16 @@ wheels = [
[[package]]
name = "django"
version = "5.2.13"
version = "5.2.14"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "asgiref" },
{ name = "sqlparse" },
{ name = "tzdata", marker = "sys_platform == 'win32'" },
]
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" }
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" }
wheels = [
{ 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" },
{ 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" },
]
[[package]]
@@ -1173,7 +1173,7 @@ wheels = [
[[package]]
name = "meet"
version = "1.15.0"
version = "1.16.0"
source = { editable = "." }
dependencies = [
{ name = "aiohttp" },
@@ -1212,6 +1212,7 @@ dependencies = [
{ name = "redis" },
{ name = "requests" },
{ name = "sentry-sdk" },
{ name = "urllib3" },
{ name = "whitenoise" },
]
@@ -1243,7 +1244,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.13" },
{ name = "django", specifier = "==5.2.14" },
{ name = "django-configurations", specifier = "==2.5.1" },
{ name = "django-cors-headers", specifier = "==4.9.0" },
{ name = "django-countries", specifier = "==8.2.0" },
@@ -1273,6 +1274,7 @@ 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" },
]
@@ -2260,11 +2262,11 @@ wheels = [
[[package]]
name = "urllib3"
version = "2.6.3"
version = "2.7.0"
source = { registry = "https://pypi.org/simple" }
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" }
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" }
wheels = [
{ 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" },
{ 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" },
]
[[package]]
+3
View File
@@ -0,0 +1,3 @@
declare module '@fontsource-variable/lexend' {}
declare module '@fontsource-variable/atkinson-hyperlegible-next' {}
declare module '@fontsource/opendyslexic' {}
+451 -462
View File
File diff suppressed because it is too large Load Diff
+6 -3
View File
@@ -1,7 +1,7 @@
{
"name": "meet",
"private": true,
"version": "1.15.0",
"version": "1.16.0",
"type": "module",
"scripts": {
"dev": "panda codegen && vite",
@@ -13,8 +13,11 @@
"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",
@@ -44,7 +47,7 @@
"wouter": "3.9.0"
},
"devDependencies": {
"@pandacss/dev": "1.8.2",
"@pandacss/dev": "1.11.1",
"@tanstack/eslint-plugin-query": "5.91.4",
"@tanstack/react-query-devtools": "5.91.3",
"@types/humanize-duration": "3.27.4",
@@ -59,7 +62,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.10",
"postcss": "8.5.14",
"prettier": "3.8.1",
"typescript": "5.8.3",
"vite": "7.3.2",
+2
View File
@@ -14,12 +14,14 @@ 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}>
+3 -1
View File
@@ -2,6 +2,8 @@ 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?: {
@@ -50,7 +52,7 @@ export interface ApiConfig {
url: string
force_wss_protocol: boolean
enable_firefox_proxy_workaround: boolean
default_sources: string[]
default_sources: Source[]
}
transcription_destination?: string
}
@@ -12,7 +12,7 @@ const controlBarRegion = cva({
variants: {
mobile: {
true: {
justifyContent: 'space-between',
justifyContent: 'center',
width: '330px',
},
},
@@ -1,3 +1,6 @@
import { Track } from 'livekit-client'
import Source = Track.Source
export type ApiLiveKit = {
url: string
room: string
@@ -10,6 +13,11 @@ 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
@@ -18,7 +26,5 @@ export type ApiRoom = {
is_administrable: boolean
access_level: ApiAccessLevel
livekit?: ApiLiveKit
configuration?: {
[key: string]: string | number | boolean | string[]
}
configuration?: RoomConfiguration
}
@@ -6,44 +6,74 @@ import {
NotificationType,
} from '@/features/notifications'
import { fetchApi } from '@/api/fetchApi'
import { useIsAdminOrOwner } from '../livekit/hooks/useIsAdminOrOwner'
import { useCallback } from 'react'
export const useMuteParticipant = () => {
const data = useRoomData()
const apiRoomData = useRoomData()
const { notifyParticipants } = useNotifyParticipants()
const isAdminOrOwner = useIsAdminOrOwner()
const muteParticipant = async (participant: Participant) => {
if (!data?.id) {
throw new Error('Room id is not available')
}
const trackSid = participant.getTrackPublication(
Source.Microphone
)?.trackSid
const muteParticipant = useCallback(
async (participant: Participant) => {
if (!apiRoomData?.livekit?.room) {
throw new Error('Room id is not available')
}
if (!trackSid) {
return
}
const trackSid = participant.getTrackPublication(
Source.Microphone
)?.trackSid
try {
const response = await fetchApi(`rooms/${data.id}/mute-participant/`, {
method: 'POST',
body: JSON.stringify({
participant_identity: participant.identity,
track_sid: trackSid,
}),
})
if (!trackSid) {
return
}
await notifyParticipants({
type: NotificationType.ParticipantMuted,
destinationIdentities: [participant.identity],
})
// 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}`
)
}
return response
} catch (error) {
console.error(
`Failed to mute participant ${participant.identity}: ${error instanceof Error ? error.message : 'Unknown error'}`
)
}
}
},
[apiRoomData, isAdminOrOwner, notifyParticipants]
)
return { muteParticipant }
}
@@ -8,7 +8,7 @@ export const useParticipantPermissions = () => {
const updateParticipantPermissions = async (
participant: Participant,
sources: Array<Source>
sources: 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.map((source) => source.toUpperCase()),
can_publish_sources: sources,
}
try {
@@ -9,7 +9,8 @@ import { queryClient } from '@/api/queryClient'
import { keys } from '@/api/queryKeys'
import { useQuery } from '@tanstack/react-query'
import { useParams } from 'wouter'
import { usePublishSourcesManager } from '@/features/rooms/livekit/hooks/usePublishSourcesManager'
import { usePublishSourcesManager } from '../hooks/usePublishSourcesManager'
import { usePermissionsManager } from '../hooks/usePermissionsManager'
export const Admin = () => {
const { t } = useTranslation('rooms', { keyPrefix: 'admin' })
@@ -38,6 +39,8 @@ export const Admin = () => {
isScreenShareEnabled,
} = usePublishSourcesManager()
const { toggleMuting, isMutingEnabled } = usePermissionsManager()
return (
<Div
display="flex"
@@ -130,6 +133,17 @@ 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,7 +1,13 @@
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
return (
participant.isLocal ||
isAdminOrOwner ||
apiRoomData?.configuration?.everyone_can_mute !== false
)
}
@@ -0,0 +1,46 @@
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,10 +39,6 @@ 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
@@ -54,16 +50,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.map((source) => {
return source as Source
})
}, [defaultSources, configuration?.can_publish_sources])
return configuration.can_publish_sources
}, [configData, configuration?.can_publish_sources])
const updateSource = useCallback(
async (sources: Source[], enabled: boolean) => {
@@ -78,7 +74,7 @@ export const usePublishSourcesManager = () => {
const newConfiguration = {
...configuration,
can_publish_sources: newSources as string[],
can_publish_sources: newSources,
}
const room = await patchRoom({
@@ -0,0 +1,86 @@
// 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,6 +32,7 @@ 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'
@@ -90,6 +91,7 @@ export function VideoConference({ ...props }: VideoConferenceProps) {
useConnectionObserver()
useRoomPageTitle()
useVideoResolutionSubscription()
useSyncLiveKitMetadata()
useRegisterKeyboardShortcut({
id: 'open-shortcuts',
@@ -1,9 +1,14 @@
import { Field, H } from '@/primitives'
import { Field, H, Text } 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 } from '@/stores/accessibility'
import {
accessibilityStore,
UI_FONT_OPTIONS,
type UiFont,
} from '@/stores/accessibility'
import { CaptionsSettings } from '@/features/subtitle/component/CaptionsSettings'
export type AccessibilityTabProps = Pick<TabPanelProps, 'id'>
@@ -12,6 +17,15 @@ 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>
@@ -33,6 +47,21 @@ 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>
@@ -0,0 +1,38 @@
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])
}
+4
View File
@@ -528,6 +528,10 @@
"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,6 +117,16 @@
"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": {
+4
View File
@@ -527,6 +527,10 @@
"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,6 +117,16 @@
"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,6 +527,10 @@
"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,6 +117,16 @@
"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": {
+4
View File
@@ -527,6 +527,10 @@
"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,6 +117,16 @@
"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": {
+20 -1
View File
@@ -2,6 +2,19 @@ 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[] = [
@@ -46,7 +59,7 @@ export const CAPTION_FONT_COLOR_VALUES: Record<CaptionColor, string> = {
}
export const CAPTION_BACKGROUND_COLOR_VALUES: Record<CaptionColor, string> = {
default: 'rgba(0, 0, 0, 0.75)',
default: 'transparent',
black: 'rgba(0, 0, 0, 0.75)',
white: 'rgba(255, 255, 255, 0.75)',
blue: 'rgba(0, 0, 255, 0.75)',
@@ -62,6 +75,7 @@ type AccessibilityState = {
captionTextSize: CaptionTextSize
captionFontColor: CaptionColor
captionBackgroundColor: CaptionColor
uiFont: UiFont
}
const DEFAULT_STATE: AccessibilityState = {
@@ -69,6 +83,7 @@ const DEFAULT_STATE: AccessibilityState = {
captionTextSize: 'medium',
captionFontColor: 'default',
captionBackgroundColor: 'default',
uiFont: 'default',
}
function getAccessibilityState(): AccessibilityState {
@@ -91,6 +106,9 @@ 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,
@@ -101,6 +119,7 @@ function getAccessibilityState(): AccessibilityState {
captionTextSize,
captionFontColor,
captionBackgroundColor,
uiFont,
}
}
+13
View File
@@ -6,6 +6,19 @@ 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 -1
View File
@@ -37,7 +37,7 @@
<mj-text>
<p>{% trans "To keep this recording permanently:" %}</p>
<ol>
<li>{% blocktrans %}Click the "Open" button below {% endblocktrans %}</li>
<li>{% blocktrans %}Click the "<a href="{{link}}">Open</a>" link 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>
+65 -97
View File
@@ -1,12 +1,12 @@
{
"name": "mail_mjml",
"version": "1.15.0",
"version": "1.16.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "mail_mjml",
"version": "1.15.0",
"version": "1.16.0",
"license": "MIT",
"dependencies": {
"@html-to/text-cli": "0.5.4",
@@ -14,9 +14,9 @@
}
},
"node_modules/@babel/runtime": {
"version": "7.28.6",
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.6.tgz",
"integrity": "sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA==",
"version": "7.29.2",
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.2.tgz",
"integrity": "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==",
"license": "MIT",
"engines": {
"node": ">=6.9.0"
@@ -24,6 +24,8 @@
},
"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",
@@ -74,6 +76,8 @@
},
"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",
@@ -140,6 +144,8 @@
},
"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"
@@ -152,13 +158,10 @@
}
},
"node_modules/balanced-match": {
"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"
}
"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/binary-extensions": {
"version": "2.3.0",
@@ -179,15 +182,12 @@
"license": "ISC"
},
"node_modules/brace-expansion": {
"version": "5.0.3",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.3.tgz",
"integrity": "sha512-fy6KJm2RawA5RcHkLa1z/ScpBeA762UF9KmZQxwIbDtRJrgLzM10depAiEQ+CXYcoiqW1/m96OAAoke2nE9EeA==",
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.0.tgz",
"integrity": "sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w==",
"license": "MIT",
"dependencies": {
"balanced-match": "^4.0.2"
},
"engines": {
"node": "18 || 20 || >=22"
"balanced-match": "^1.0.0"
}
},
"node_modules/braces": {
@@ -451,6 +451,8 @@
},
"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"
@@ -464,6 +466,8 @@
},
"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",
@@ -476,6 +480,8 @@
},
"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",
@@ -486,6 +492,8 @@
},
"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"
@@ -498,12 +506,14 @@
}
},
"node_modules/domutils": {
"version": "3.0.1",
"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.1"
"domhandler": "^5.0.3"
},
"funding": {
"url": "https://github.com/fb55/domutils?sponsor=1"
@@ -516,14 +526,14 @@
"license": "MIT"
},
"node_modules/editorconfig": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/editorconfig/-/editorconfig-1.0.4.tgz",
"integrity": "sha512-L9Qe08KWTlqYMVvMcTIvMAdl1cDUubzRNYL+WfA4bLDMHe4nemKkpmYzkznE1FwLKu0EEmy6obgQKzMJrg4x9Q==",
"version": "1.0.7",
"resolved": "https://registry.npmjs.org/editorconfig/-/editorconfig-1.0.7.tgz",
"integrity": "sha512-e0GOtq/aTQhVdNyDU9e02+wz9oDDM+SIOQxWME2QRjzRX5yyLAuHDE+0aE8vHb9XRC8XD37eO2u57+F09JqFhw==",
"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": {
@@ -533,21 +543,6 @@
"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",
@@ -557,21 +552,6 @@
"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",
@@ -579,7 +559,9 @@
"license": "MIT"
},
"node_modules/entities": {
"version": "4.4.0",
"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"
@@ -725,6 +707,8 @@
},
"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",
{
@@ -878,6 +862,8 @@
},
"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"
@@ -920,12 +906,12 @@
}
},
"node_modules/minimatch": {
"version": "9.0.6",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.6.tgz",
"integrity": "sha512-kQAVowdR33euIqeA0+VZTDqU+qo1IeVY+hrKYtZMio3Pg0P0vuh/kwRylLUddJhB6pf3q/botcOvRtx4IN1wqQ==",
"version": "9.0.9",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz",
"integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==",
"license": "ISC",
"dependencies": {
"brace-expansion": "^5.0.2"
"brace-expansion": "^2.0.2"
},
"engines": {
"node": ">=16 || 14 >=14.17"
@@ -1228,32 +1214,6 @@
"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",
@@ -1512,7 +1472,9 @@
}
},
"node_modules/parseley": {
"version": "0.12.0",
"version": "0.12.1",
"resolved": "https://registry.npmjs.org/parseley/-/parseley-0.12.1.tgz",
"integrity": "sha512-e6qHKe3a9HWr0oMRVDTRhKce+bRO8VGQR3NyVwcjwrbhMmFCX9KszEV35+rn4AdilFAq9VPxP/Fe1wC9Qjd2lw==",
"license": "MIT",
"dependencies": {
"leac": "^0.6.0",
@@ -1524,6 +1486,8 @@
},
"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"
@@ -1556,15 +1520,17 @@
},
"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.1",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",
"integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==",
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz",
"integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==",
"license": "MIT",
"engines": {
"node": ">=8.6"
@@ -1611,6 +1577,8 @@
},
"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"
@@ -1620,9 +1588,9 @@
}
},
"node_modules/semver": {
"version": "7.7.4",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz",
"integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==",
"version": "7.8.0",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.8.0.tgz",
"integrity": "sha512-AcM7dV/5ul4EekoQ29Agm5vri8JNqRyj39o0qpX6vDF2GZrtutZl5RwgD1XnZjiTAfncsJhMI48QQH3sN87YNA==",
"license": "ISC",
"bin": {
"semver": "bin/semver.js"
@@ -1742,12 +1710,12 @@
}
},
"node_modules/strip-ansi": {
"version": "7.1.2",
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.2.tgz",
"integrity": "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==",
"version": "7.2.0",
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz",
"integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==",
"license": "MIT",
"dependencies": {
"ansi-regex": "^6.0.1"
"ansi-regex": "^6.2.2"
},
"engines": {
"node": ">=12"
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "mail_mjml",
"version": "1.15.0",
"version": "1.16.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.15.0",
"version": "1.16.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "sdk",
"version": "1.15.0",
"version": "1.16.0",
"license": "ISC",
"workspaces": [
"./library",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "sdk",
"version": "1.15.0",
"version": "1.16.0",
"author": "",
"license": "ISC",
"description": "",
+1 -1
View File
@@ -1,7 +1,7 @@
[project]
name = "summary"
version = "1.15.0"
version = "1.16.0"
dependencies = [
"fastapi[standard]>=0.105.0",
"uvicorn>=0.24.0",
+10 -6
View File
@@ -19,16 +19,18 @@ class TranscribeSummarizeTaskCreation(BaseModel):
"""Transcription and summarization parameters."""
owner_id: str
filename: str
recording_filename: str
metadata_filename: Optional[str] = None
email: str
sub: str
version: Optional[int] = 2
room: Optional[str]
recording_date: Optional[str]
recording_time: Optional[str]
owner_timezone: 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
@@ -51,16 +53,18 @@ async def create_transcribe_summarize_task(request: TranscribeSummarizeTaskCreat
task = process_audio_transcribe_summarize_v2.apply_async(
args=[
request.owner_id,
request.filename,
request.recording_filename,
request.metadata_filename,
request.email,
request.sub,
time.time(),
request.room,
request.recording_date,
request.recording_time,
request.owner_timezone,
request.language,
request.download_link,
request.context_language,
request.recording_start_at,
request.recording_end_at,
],
queue=settings.transcribe_queue,
)
+3 -1
View File
@@ -112,7 +112,9 @@ class MetadataManager:
if self._is_disabled or self.has_task_id(task_id):
return
_, filename, email, _, received_at, *_ = task_args
# 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
start_time = time.time()
initial_metadata = {
+111 -27
View File
@@ -4,7 +4,7 @@
import json
import time
from typing import Optional
from datetime import datetime
import openai
import sentry_sdk
@@ -40,6 +40,7 @@ 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,
@@ -78,7 +79,7 @@ file_service = FileService()
def transcribe_audio(
*,
task_id: str,
filename: str | None = None,
recording_filename: str | None = None,
language: str,
cloud_storage_url=None,
raises: bool = False,
@@ -90,7 +91,7 @@ def transcribe_audio(
Returns the transcription object, or None if the file could not be retrieved.
"""
if bool(filename) == bool(cloud_storage_url):
if bool(recording_filename) == bool(cloud_storage_url):
raise ValueError(
"Either filename or cloud_storage_url must be provided, but not both."
)
@@ -105,11 +106,12 @@ def transcribe_audio(
# Transcription
try:
with file_service.prepare_audio_file(
remote_object_key=filename,
remote_object_key=recording_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(
@@ -122,18 +124,21 @@ 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
)
transcription_time = round(time.time() - transcription_start_time, 2)
# Logging
transcription_duration = round(time.time() - transcription_start_time, 2)
metadata_manager.track(
task_id,
{"transcription_time": transcription_time},
{"transcription_time": transcription_duration},
)
logger.info(
"Transcription received in %.2f seconds.", transcription_duration
)
logger.info("Transcription received in %.2f seconds.", transcription_time)
logger.debug("Transcription: \n %s", transcription)
except FileServiceException as e:
@@ -148,7 +153,7 @@ def transcribe_audio(
"Unexpected error while preparing file | filename: %s "
"| cloud_storage_url: %s"
),
filename,
recording_filename,
redacted_cloud_storage_url,
)
return None
@@ -157,13 +162,72 @@ 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_date: str | None,
recording_time: str | None,
recording_datetime: str | None,
owner_timezone: str | None,
download_link: str | None,
) -> tuple[str, str]:
"""Format a transcription into readable content with a title.
@@ -179,8 +243,8 @@ def format_transcript(
return formatter.format(
transcription,
room=room,
recording_date=recording_date,
recording_time=recording_time,
recording_datetime=recording_datetime,
owner_timezone=owner_timezone,
download_link=download_link,
)
@@ -188,7 +252,7 @@ def format_transcript(
def format_actions(llm_output: dict) -> str:
"""Format the actions from the LLM output into a markdown list.
fomat:
format:
- [ ] Action title Assignée à : assignee1, assignee2, Échéance : due_date
"""
lines = []
@@ -212,16 +276,18 @@ def format_actions(llm_output: dict) -> str:
def process_audio_transcribe_summarize_v2(
self,
owner_id: str,
filename: str,
recording_filename: str,
metadata_filename: str | None,
email: str,
sub: str,
received_at: float,
room: Optional[str],
recording_date: Optional[str],
recording_time: Optional[str],
language: Optional[str],
download_link: Optional[str],
context_language: Optional[str] = None,
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,
):
"""Process an audio file by transcribing it and generating a summary.
@@ -234,16 +300,20 @@ def process_audio_transcribe_summarize_v2(
Args:
self: Celery task instance (passed on with bind=True)
owner_id: Unique identifier of the recording owner.
filename: Name of the audio file in MinIO storage.
recording_filename: Name of the audio file in MinIO storage.
metadata_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.
recording_date: Date of the recording (localized display string).
recording_time: Time of the recording (localized display string).
owner_timezone: IANA timezone of the recording owner (e.g. "Europe/Paris").
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",
@@ -253,19 +323,33 @@ def process_audio_transcribe_summarize_v2(
task_id = self.request.id
# Transcribe the audio
transcription = transcribe_audio(
task_id=task_id, filename=filename, language=language
task_id=task_id, recording_filename=recording_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_date,
recording_time,
recording_start_at,
owner_timezone,
download_link,
)
+6
View File
@@ -103,6 +103,12 @@ 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]
+114 -19
View File
@@ -23,6 +23,97 @@ 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."""
@@ -155,25 +246,7 @@ class FileService:
def _validate_duration(self, local_path: Path) -> float:
"""Validate audio file duration against configured maximum."""
# 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"])
duration = get_media_duration(local_path)
logger.info(
"Recording file duration: %.2f seconds",
@@ -229,6 +302,28 @@ 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,7 +1,9 @@
"""Transcript formatting into readable conversation format with speaker labels."""
import logging
from typing import Optional, Tuple
from datetime import datetime
from typing import Tuple
from zoneinfo import ZoneInfo
from summary.core.config import get_settings
from summary.core.locales import LocaleStrings
@@ -39,10 +41,10 @@ class TranscriptFormatter:
def format(
self,
transcription,
room: Optional[str] = None,
recording_date: Optional[str] = None,
recording_time: Optional[str] = None,
download_link: Optional[str] = None,
room: str | None = None,
recording_datetime: str | None = None,
owner_timezone: str | None = None,
download_link: str | None = None,
) -> Tuple[str, str]:
"""Format transcription into the final document and its title."""
segments = self._get_segments(transcription)
@@ -54,7 +56,7 @@ class TranscriptFormatter:
content = self._remove_hallucinations(content)
content = self._add_header(content, download_link)
title = self._generate_title(room, recording_date, recording_time)
title = self._generate_title(room, recording_datetime, owner_timezone)
return content, title
@@ -83,7 +85,7 @@ class TranscriptFormatter:
return formatted_output
def _add_header(self, content, download_link: Optional[str]) -> str:
def _add_header(self, content, download_link: str | None) -> str:
"""Add download link header to the document content."""
if not download_link:
return content
@@ -97,16 +99,20 @@ class TranscriptFormatter:
def _generate_title(
self,
room: Optional[str] = None,
recording_date: Optional[str] = None,
recording_time: Optional[str] = None,
room: str | None = None,
recording_datetime: str | None = None,
owner_timezone: str | None = None,
) -> str:
"""Generate title from context or return default."""
if not room or not recording_date or not recording_time:
if not room or not recording_datetime:
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=recording_date,
room_recording_time=recording_time,
room_recording_date=dt.strftime("%Y-%m-%d"),
room_recording_time=dt.strftime("%H:%M"),
)
+434
View File
@@ -0,0 +1,434 @@
"""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
+11 -9
View File
@@ -19,14 +19,14 @@ class TestTasks:
headers={"Authorization": "Bearer test-api-token"},
json={
"owner_id": "owner-123",
"filename": "recording.mp4",
"recording_filename": "recording.mp4",
"metadata_filename": "metadata.json",
"email": "user@example.com",
"sub": "sub-123",
"room": "room-abc",
"recording_date": "2026-01-01",
"recording_time": "10:00:00",
"owner_timezone": "UTC",
"language": None,
"download_link": "http://example.com/file.mp4",
"download_link": "https://example.com/file.mp4",
},
)
@@ -36,16 +36,18 @@ class TestTasks:
args = mock_apply_async.call_args.kwargs["args"]
assert args == [
"owner-123", # owner_id
"recording.mp4", # filename
"recording.mp4", # recording_filename
"metadata.json", # metadata_filename
"user@example.com", # email
"sub-123", # sub
1735725600.0, # frozen time
1735725600.0, # received_at
"room-abc", # room
"2026-01-01", # recording_date
"10:00:00", # recording_time
"UTC", # owner_timezone
None, # language
"http://example.com/file.mp4", # download_link
"https://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.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,30 @@
"""Unit tests for the file service."""
from pathlib import Path
import pytest
from summary.core.file_service import get_media_duration
@pytest.mark.parametrize(
"filename, duration",
[
("audio-sample-android-chrome.webm", 2.2795),
("audio-sample-android-firefox.ogg", 2.3025),
("audio-sample-android.m4a", 1.38),
("audio-sample-chromium.webm", 2.65),
("audio-sample-firefox.ogg", 2.0865),
("audio-sample-ios-browser.webm", 2.6229),
("audio-sample-ios.m4a", 1.408),
("audio-sample-mac-os-safari.webm", 2.3049),
("video-sample-visio.mp4", 5.34059),
],
)
def test_validate_duration_supports_all_used_file_formats(
filename: str, duration: float
) -> None:
"""Validate duration for Safari iPhone WebM files without format duration."""
audio_path = Path(__file__).parent.parent / "assets" / filename
assert get_media_duration(audio_path) == pytest.approx(duration, 1e-3)
+708
View File
@@ -0,0 +1,708 @@
"""Tests for the speaker-to-user assignment service."""
import math
from dataclasses import dataclass, field
from datetime import datetime
from summary.core import user_assign
from summary.core.user_assign import (
AssignmentResult,
Interval,
SpeakerAssignment,
_build_speaker_timelines,
_merge_intervals,
_overlap_duration,
_total_duration,
resolve_speaker_identities,
)
@dataclass
class FakeTranscription:
"""Mimics the OpenAI Transcription pydantic model for testing."""
segments: list = field(default_factory=list)
RECORDING_START = datetime.fromisoformat("2026-03-17T15:30:33.000001")
RECORDING_END = datetime.fromisoformat("2026-03-17T15:31:33.000001")
METADATA_SINGLE_USER = {
"events": [
{
"participant_id": "da8d39ff-3b1c-4e8d-9a70-c630c9871bcb",
"type": "participant_connected",
"timestamp": "2026-03-17T15:30:33.000001",
},
{
"participant_id": "da8d39ff-3b1c-4e8d-9a70-c630c9871bcb",
"type": "speech_start",
"timestamp": "2026-03-17T15:30:36.039456",
},
{
"participant_id": "da8d39ff-3b1c-4e8d-9a70-c630c9871bcb",
"type": "speech_end",
"timestamp": "2026-03-17T15:30:36.589114",
},
{
"participant_id": "da8d39ff-3b1c-4e8d-9a70-c630c9871bcb",
"type": "speech_start",
"timestamp": "2026-03-17T15:30:38.887518",
},
{
"participant_id": "da8d39ff-3b1c-4e8d-9a70-c630c9871bcb",
"type": "speech_end",
"timestamp": "2026-03-17T15:30:39.438141",
},
{
"participant_id": "da8d39ff-3b1c-4e8d-9a70-c630c9871bcb",
"type": "participant_disconnected",
"timestamp": "2026-03-17T15:30:43.223255",
},
],
"participants": [
{
"participantId": "da8d39ff-3b1c-4e8d-9a70-c630c9871bcb",
"name": "cameledev",
}
],
}
DIARIZATION_SINGLE_SPEAKER = FakeTranscription(
segments=[
{
"start": 1.363,
"end": 3.545,
"text": " The stale smell.",
"speaker": "SPEAKER_00",
"words": [
{"word": "The", "start": 1.363, "end": 1.8},
{"word": "stale", "start": 1.8, "end": 2.7},
{"word": "smell.", "start": 2.7, "end": 3.545},
],
},
{
"start": 4.466,
"end": 6.247,
"text": "It takes heat.",
"speaker": "SPEAKER_00",
"words": [
{"word": "It", "start": 4.466, "end": 4.7},
{"word": "takes", "start": 4.7, "end": 5.5},
{"word": "heat.", "start": 5.5, "end": 6.247},
],
},
],
)
USER_ID = "da8d39ff-3b1c-4e8d-9a70-c630c9871bcb"
class TestMergeIntervals:
"""Tests for _merge_intervals."""
def test_empty(self):
"""Empty input returns empty list."""
assert _merge_intervals([]) == []
def test_no_overlap(self):
"""Non-overlapping intervals stay separate."""
result = _merge_intervals([Interval(1, 2), Interval(3, 4)])
assert len(result) == 2
def test_overlap(self):
"""Overlapping intervals are merged."""
result = _merge_intervals([Interval(1, 3), Interval(2, 4)])
assert len(result) == 1
assert result[0].start == 1
assert result[0].end == 4
def test_adjacent(self):
"""Adjacent intervals are merged."""
result = _merge_intervals([Interval(1, 2), Interval(2, 3)])
assert len(result) == 1
assert result[0].start == 1
assert result[0].end == 3
def test_unsorted(self):
"""Unsorted input is sorted before merging."""
result = _merge_intervals([Interval(5, 6), Interval(1, 2)])
assert len(result) == 2
assert result[0].start == 1
class TestOverlapDuration:
"""Tests for _overlap_duration."""
def test_no_overlap(self):
"""Disjoint intervals have zero overlap."""
a = [Interval(1, 2)]
b = [Interval(3, 4)]
assert math.isclose(_overlap_duration(a, b), 0.0)
def test_full_overlap(self):
"""Identical intervals have full overlap."""
a = [Interval(1, 3)]
b = [Interval(1, 3)]
assert math.isclose(_overlap_duration(a, b), 2.0)
def test_partial_overlap(self):
"""Partially overlapping intervals."""
a = [Interval(1, 3)]
b = [Interval(2, 4)]
assert math.isclose(_overlap_duration(a, b), 1.0)
def test_multiple_intervals(self):
"""Multiple intervals with a spanning interval."""
a = [Interval(1, 3), Interval(5, 7)]
b = [Interval(2, 6)]
assert math.isclose(_overlap_duration(a, b), 2.0)
def test_empty(self):
"""Empty input yields zero overlap."""
assert math.isclose(_overlap_duration([], [Interval(1, 2)]), 0.0)
assert math.isclose(_overlap_duration([Interval(1, 2)], []), 0.0)
class TestTotalDuration:
"""Tests for _total_duration."""
def test_basic(self):
"""Sum of durations for multiple intervals."""
ivs = [Interval(0, 1), Interval(2, 5)]
assert math.isclose(_total_duration(ivs), 4.0)
def test_empty(self):
"""Empty input returns zero."""
assert math.isclose(_total_duration([]), 0.0)
class TestBuildSpeakerTimelines:
"""Tests for _build_speaker_timelines."""
def test_segment_without_words_falls_back_to_segment_bounds(self):
"""Segments missing a `words` key use the segment start/end as one interval."""
transcription = FakeTranscription(
segments=[{"start": 1.5, "end": 3.5, "speaker": "SPEAKER_00"}],
)
result = _build_speaker_timelines(transcription)
assert result == {"SPEAKER_00": [Interval(1.5, 3.5)]}
def test_segment_with_only_none_word_timestamps_falls_back(self):
"""If every word has None start/end, fall back to segment bounds."""
transcription = FakeTranscription(
segments=[
{
"start": 1.0,
"end": 4.0,
"speaker": "SPEAKER_00",
"words": [
{"word": "hi", "start": None, "end": None},
{"word": "there", "start": None, "end": None},
],
},
],
)
result = _build_speaker_timelines(transcription)
assert result == {"SPEAKER_00": [Interval(1.0, 4.0)]}
def test_short_words_only_uses_segment_start_and_last_word_end(self):
"""With no overly long words, the interval runs segment start to end."""
transcription = FakeTranscription(
segments=[
{
"start": 1.0,
"end": 5.0,
"speaker": "SPEAKER_00",
"words": [
{"word": "a", "start": 1.0, "end": 1.3},
{"word": "b", "start": 1.4, "end": 1.7},
{"word": "c", "start": 1.8, "end": 2.1},
],
},
],
)
result = _build_speaker_timelines(transcription)
# Tail: min(2.1, 1.8 + 1.0) = 2.1
assert result == {"SPEAKER_00": [Interval(1.0, 2.1)]}
def test_long_word_caps_interval_at_max_duration(self):
"""A word longer than the max-word-duration cap truncates the segment."""
max_word_duration = (
user_assign.settings.resolve_speaker_identities_max_word_duration
)
transcription = FakeTranscription(
segments=[
{
"start": 0.0,
"end": max_word_duration + 7,
"speaker": "SPEAKER_00",
"words": [
{
"word": "pause",
"start": 0.0,
"end": max_word_duration + 7,
},
],
},
],
)
result = _build_speaker_timelines(transcription)
assert result == {"SPEAKER_00": [Interval(0.0, max_word_duration)]}
def test_long_word_in_middle_splits_segment(self):
"""Short words around a long word produce two intervals (before-cap + after)."""
transcription = FakeTranscription(
segments=[
{
"start": 0.0,
"end": 20.0,
"speaker": "SPEAKER_00",
"words": [
{"word": "a", "start": 0.0, "end": 0.5},
{"word": "long", "start": 1.0, "end": 15.0},
{"word": "z", "start": 18.0, "end": 18.4},
],
},
],
)
result = _build_speaker_timelines(transcription)
# First emit: (0.0, 1.0 + 1.0). Then start_time resets, picks up at "z" (18.0).
# Tail: min(18.4, 18.0 + 1.0) = 18.4. So second interval is (18.0, 18.4).
assert result == {
"SPEAKER_00": [Interval(0.0, 2.0), Interval(18.0, 18.4)],
}
def test_tail_word_is_capped_at_max_duration(self):
"""The trailing word's end is capped at word.start + max_word_duration."""
transcription = FakeTranscription(
segments=[
{
"start": 0.0,
"end": 50.0,
"speaker": "SPEAKER_00",
"words": [
{"word": "a", "start": 0.0, "end": 0.4},
# Last word ends inside the cap, so the cap doesn't apply.
{"word": "b", "start": 1.0, "end": 1.5},
],
},
],
)
result = _build_speaker_timelines(transcription)
# Tail: min(1.5, 1.0 + 1.0) = 1.5
assert result == {"SPEAKER_00": [Interval(0.0, 1.5)]}
def test_split_on_words_disabled_keeps_segment_as_one_interval(self, monkeypatch):
"""With splitting disabled, long words don't split the interval."""
monkeypatch.setattr(
user_assign,
"settings",
user_assign.settings.model_copy(
update={"resolve_speaker_identities_enable_split_on_words": False},
),
)
transcription = FakeTranscription(
segments=[
{
"start": 0.0,
"end": 20.0,
"speaker": "SPEAKER_00",
"words": [
{"word": "a", "start": 0.0, "end": 0.5},
{"word": "long", "start": 1.0, "end": 15.0},
{"word": "z", "start": 18.0, "end": 18.4},
],
},
],
)
result = _build_speaker_timelines(transcription)
# No mid-segment split; tail caps at min(18.4, 18.0 + 1.0) = 18.4.
assert result == {"SPEAKER_00": [Interval(0.0, 18.4)]}
def test_multiple_speakers_keep_separate_timelines(self):
"""Segments from different speakers populate independent timeline entries."""
transcription = FakeTranscription(
segments=[
{
"start": 0.0,
"end": 1.0,
"speaker": "SPEAKER_00",
"words": [{"word": "hi", "start": 0.0, "end": 0.5}],
},
{
"start": 2.0,
"end": 3.0,
"speaker": "SPEAKER_01",
"words": [{"word": "yo", "start": 2.0, "end": 2.5}],
},
],
)
result = _build_speaker_timelines(transcription)
assert result == {
"SPEAKER_00": [Interval(0.0, 0.5)],
"SPEAKER_01": [Interval(2.0, 2.5)],
}
class TestResolveSpeakerIdentities:
"""Tests for resolve_speaker_identities."""
def test_single_speaker_single_user(self):
"""Single speaker assigned to single user with low threshold."""
result = resolve_speaker_identities(
METADATA_SINGLE_USER,
DIARIZATION_SINGLE_SPEAKER,
RECORDING_START,
RECORDING_END,
overlap_threshold=0.2,
)
assert len(result.assignments) == 1
assert result.assignments[0].speaker_label == "SPEAKER_00"
assert result.assignments[0].participant_id == USER_ID
assert result.assignments[0].participant_name == "cameledev"
assert result.assignments[0].score > 0
assert result.unassigned_speakers == []
def test_no_vad_events(self):
"""Participant with no speech leaves speakers unassigned."""
metadata = {
"events": [
{
"participant_id": "user-a",
"type": "participant_connected",
"timestamp": "2026-03-17T15:30:33.000000",
},
],
"participants": [{"participantId": "user-a", "name": "Silent User"}],
}
result = resolve_speaker_identities(
metadata,
DIARIZATION_SINGLE_SPEAKER,
RECORDING_START,
RECORDING_END,
)
assert len(result.assignments) == 0
assert "SPEAKER_00" in result.unassigned_speakers
def test_multiple_speakers_same_user(self):
"""Two speakers from same mic both assigned to same user."""
metadata = {
"events": [
{
"participant_id": "user-a",
"type": "speech_start",
"timestamp": "2026-03-17T15:30:35.000000",
},
{
"participant_id": "user-a",
"type": "speech_end",
"timestamp": "2026-03-17T15:30:50.000000",
},
],
"participants": [{"participantId": "user-a", "name": "Shared Mic"}],
}
transcription = FakeTranscription(
segments=[
{"start": 1.0, "end": 3.0, "speaker": "SPEAKER_00"},
{"start": 5.0, "end": 7.0, "speaker": "SPEAKER_01"},
],
)
result = resolve_speaker_identities(
metadata, transcription, RECORDING_START, RECORDING_END
)
assert len(result.assignments) == 2
pids = {a.participant_id for a in result.assignments}
assert pids == {"user-a"}
def test_two_users_two_speakers(self):
"""Each speaker maps to correct user by VAD overlap."""
metadata = {
"events": [
{
"participant_id": "user-a",
"type": "speech_start",
"timestamp": "2026-03-17T15:30:34.000001",
},
{
"participant_id": "user-a",
"type": "speech_end",
"timestamp": "2026-03-17T15:30:37.000001",
},
{
"participant_id": "user-b",
"type": "speech_start",
"timestamp": "2026-03-17T15:30:38.000001",
},
{
"participant_id": "user-b",
"type": "speech_end",
"timestamp": "2026-03-17T15:30:41.000001",
},
],
"participants": [
{"participantId": "user-a", "name": "Alice"},
{"participantId": "user-b", "name": "Bob"},
],
}
transcription = FakeTranscription(
segments=[
{"start": 1.5, "end": 3.5, "speaker": "SPEAKER_00"},
{"start": 5.5, "end": 7.5, "speaker": "SPEAKER_01"},
],
)
result = resolve_speaker_identities(
metadata, transcription, RECORDING_START, RECORDING_END
)
assert len(result.assignments) == 2
by_speaker = {a.speaker_label: a for a in result.assignments}
assert by_speaker["SPEAKER_00"].participant_name == "Alice"
assert by_speaker["SPEAKER_01"].participant_name == "Bob"
def test_overlapping_speech_two_users(self):
"""Simultaneous speech from two users still assigns each speaker correctly."""
# user-a speaks from t=1s to t=6s, user-b speaks from t=3s to t=8s
# (3s overlap where both are speaking)
# SPEAKER_00 diarization covers t=1.55.5 (mostly user-a)
# SPEAKER_01 diarization covers t=4.07.5 (mostly user-b)
metadata = {
"events": [
{
"participant_id": "user-a",
"type": "speech_start",
"timestamp": "2026-03-17T15:30:34.000001",
},
{
"participant_id": "user-b",
"type": "speech_start",
"timestamp": "2026-03-17T15:30:36.000001",
},
{
"participant_id": "user-a",
"type": "speech_end",
"timestamp": "2026-03-17T15:30:39.000001",
},
{
"participant_id": "user-b",
"type": "speech_end",
"timestamp": "2026-03-17T15:30:41.000001",
},
],
"participants": [
{"participantId": "user-a", "name": "Alice"},
{"participantId": "user-b", "name": "Bob"},
],
}
transcription = FakeTranscription(
segments=[
{"start": 1.5, "end": 5.5, "speaker": "SPEAKER_00"},
{"start": 4.0, "end": 7.5, "speaker": "SPEAKER_01"},
],
)
result = resolve_speaker_identities(
metadata,
transcription,
RECORDING_START,
RECORDING_END,
overlap_threshold=0.3,
)
assert len(result.assignments) == 2
by_speaker = {a.speaker_label: a for a in result.assignments}
assert by_speaker["SPEAKER_00"].participant_name == "Alice"
assert by_speaker["SPEAKER_01"].participant_name == "Bob"
assert result.unassigned_speakers == []
def test_below_threshold(self):
"""Speaker with minimal overlap stays unassigned."""
metadata = {
"events": [
{
"participant_id": "user-a",
"type": "speech_start",
"timestamp": "2026-03-17T15:30:34.000001",
},
{
"participant_id": "user-a",
"type": "speech_end",
"timestamp": "2026-03-17T15:30:35.169950",
},
],
"participants": [{"participantId": "user-a", "name": "Brief User"}],
}
transcription = FakeTranscription(
segments=[
{"start": 1.0, "end": 10.0, "speaker": "SPEAKER_00"},
],
)
result = resolve_speaker_identities(
metadata,
transcription,
RECORDING_START,
RECORDING_END,
overlap_threshold=0.5,
)
assert len(result.assignments) == 0
assert "SPEAKER_00" in result.unassigned_speakers
def test_events_before_recording_start_clamped(self):
"""Speech events before recording start are clamped to t=0."""
metadata = {
"events": [
{
"participant_id": "user-a",
"type": "speech_start",
"timestamp": "2026-03-17T15:30:31.000001", # before RECORDING_START
},
{
"participant_id": "user-a",
"type": "speech_end",
"timestamp": "2026-03-17T15:30:36.000001", # after RECORDING_START
},
],
"participants": [{"participantId": "user-a", "name": "Early User"}],
}
transcription = FakeTranscription(
segments=[
{"start": 0.0, "end": 3.0, "speaker": "SPEAKER_00"},
],
)
result = resolve_speaker_identities(
metadata, transcription, RECORDING_START, RECORDING_END
)
assert len(result.assignments) == 1
assert result.assignments[0].participant_name == "Early User"
def test_empty_diarization(self):
"""No segments produces empty result."""
result = resolve_speaker_identities(
METADATA_SINGLE_USER,
FakeTranscription(segments=[]),
RECORDING_START,
RECORDING_END,
)
assert result == AssignmentResult()
def test_segment_without_speaker_ignored(self):
"""Segments missing speaker key are skipped."""
transcription = FakeTranscription(
segments=[
{"start": 1.0, "end": 3.0, "text": "no speaker"},
],
)
result = resolve_speaker_identities(
METADATA_SINGLE_USER, transcription, RECORDING_START, RECORDING_END
)
assert result == AssignmentResult()
def test_unclosed_speech_closed_at_recording_end(self):
"""Open speech_start without speech_end is closed at recording end."""
recording_end = datetime.fromisoformat("2026-03-17T15:30:43.000001")
metadata = {
"events": [
{
"participant_id": "user-a",
"type": "speech_start",
"timestamp": "2026-03-17T15:30:35.000001",
},
# No speech_end — participant kept speaking until recording stopped
],
"participants": [{"participantId": "user-a", "name": "Still Talking"}],
}
transcription = FakeTranscription(
segments=[
{"start": 2.0, "end": 9.0, "speaker": "SPEAKER_00"},
],
)
result = resolve_speaker_identities(
metadata,
transcription,
RECORDING_START,
recording_end,
overlap_threshold=0.5,
)
assert len(result.assignments) == 1
assert result.assignments[0].participant_name == "Still Talking"
assert result.unassigned_speakers == []
class TestApply:
"""Tests for AssignmentResult.apply_to."""
def test_replaces_speakers_in_segments_and_words(self):
"""Speaker labels replaced in segments, words, and word_segments."""
diarization = {
"segments": [
{
"start": 1.0,
"end": 3.0,
"text": "Hello",
"speaker": "SPEAKER_00",
"words": [
{
"word": "Hello",
"start": 1.0,
"end": 1.5,
"speaker": "SPEAKER_00",
},
],
},
{
"start": 4.0,
"end": 6.0,
"text": "World",
"speaker": "SPEAKER_01",
"words": [
{
"word": "World",
"start": 4.0,
"end": 4.5,
"speaker": "SPEAKER_01",
},
],
},
],
"word_segments": [
{"word": "Hello", "start": 1.0, "end": 1.5, "speaker": "SPEAKER_00"},
{"word": "World", "start": 4.0, "end": 4.5, "speaker": "SPEAKER_01"},
],
}
assignment = AssignmentResult(
assignments=[
SpeakerAssignment("SPEAKER_00", "id-a", "Alice", 0.9),
SpeakerAssignment("SPEAKER_01", "id-b", "Bob", 0.8),
],
)
result = assignment.apply_to(diarization)
assert result["segments"][0]["speaker"] == "Alice"
assert result["segments"][0]["words"][0]["speaker"] == "Alice"
assert result["segments"][1]["speaker"] == "Bob"
assert result["word_segments"][0]["speaker"] == "Alice"
assert result["word_segments"][1]["speaker"] == "Bob"
def test_unassigned_speakers_unchanged(self):
"""Unassigned speaker labels are left as-is."""
diarization = {
"segments": [
{"start": 1.0, "end": 3.0, "speaker": "SPEAKER_02"},
],
}
assignment = AssignmentResult(
assignments=[
SpeakerAssignment("SPEAKER_00", "id-a", "Alice", 0.9),
],
unassigned_speakers=["SPEAKER_02"],
)
result = assignment.apply_to(diarization)
assert result["segments"][0]["speaker"] == "SPEAKER_02"
def test_preserves_extra_keys(self):
"""Non-segment keys in diarization are preserved."""
diarization = {
"segments": [],
"language": "en",
"custom_field": 42,
}
result = AssignmentResult().apply_to(diarization)
assert result["language"] == "en"
assert result["custom_field"] == 42