mirror of
https://github.com/suitenumerique/meet.git
synced 2026-09-03 14:17:59 +00:00
Compare commits
102 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| fc5ef39eee | |||
| ab707d866f | |||
| dac0b9c000 | |||
| 53cc8642eb | |||
| c8d9d2fea8 | |||
| e910b1f0b7 | |||
| 581e115c03 | |||
| 8118ef0612 | |||
| 85f886d9c5 | |||
| b66f82e4b7 | |||
| aabb7d629c | |||
| 1e5f5c4fe9 | |||
| f55fa0c42b | |||
| 2bc5e47c75 | |||
| 0d5136206f | |||
| 05a67320b1 | |||
| 2fbaa49089 | |||
| 76542b2235 | |||
| 10231b0333 | |||
| 9b9e6578ee | |||
| 0c81d29ee7 | |||
| 839a8f1c71 | |||
| 40ef420cda | |||
| d995bd9041 | |||
| 8ac4c2409d | |||
| e47ad42656 | |||
| 2509452c52 | |||
| 1022780027 | |||
| 4a1a04f86e | |||
| 8702638cf3 | |||
| f88c0307ea | |||
| e7f15b50ff | |||
| 75ba2ff146 | |||
| bbe2a32efc | |||
| 86fff16eed | |||
| 195d2b5006 | |||
| 30cf264276 | |||
| 945d779b45 | |||
| d43b335546 | |||
| e6f0ff5112 | |||
| 224d6ce358 | |||
| 20718f2d5a | |||
| 8edcb6e973 | |||
| b00c4e5d6d | |||
| 3c0ba03976 | |||
| 7fa172d100 | |||
| ecf8f0fe3f | |||
| 27da57aff5 | |||
| e327a5e35f | |||
| c3abd0441f | |||
| 79245389ce | |||
| 3b3f992834 | |||
| a98dc1484a | |||
| dca24a1b25 | |||
| 74b791e207 | |||
| df1495c97b | |||
| c95e1c67bd | |||
| f115c83752 | |||
| ebfcb42a7d | |||
| c86a47f736 | |||
| dd6b4512c8 | |||
| f82fd4bece | |||
| edab18d94a | |||
| 636c2168be | |||
| d54e9c2ad0 | |||
| 657712d7cb | |||
| d2bfbee389 | |||
| 9ba97fd14f | |||
| be0d0927d4 | |||
| 27dce44d40 | |||
| 78acaf395e | |||
| 4e5e648730 | |||
| 924fe95d94 | |||
| e3e33c7d0a | |||
| 16ee575ff8 | |||
| e42b083f20 | |||
| 6d2c31eb0a | |||
| e9bdf173de | |||
| dd23ce817a | |||
| 1ca8f6e5ea | |||
| f98e884067 | |||
| b498926353 | |||
| 3d4dc2d631 | |||
| 1523e6aec9 | |||
| 99510c9c6a | |||
| 9f003e95f3 | |||
| 46c30b6fcd | |||
| 1533ae8a3c | |||
| 526e96797e | |||
| 8903a55008 | |||
| 2a60b49086 | |||
| aee1847303 | |||
| 6b7cd8ab2e | |||
| cc2a0ad322 | |||
| 3300226bbe | |||
| 8efb11b3ae | |||
| 8cf15cabaf | |||
| 9b7c449ca5 | |||
| 5c27aba00f | |||
| 7256b87511 | |||
| 7b7951a0e0 | |||
| 5c15140783 |
@@ -46,7 +46,7 @@ jobs:
|
|||||||
images: '${{ env.DOCKER_CONTAINER_REGISTRY_NAMESPACE }}/meet-backend'
|
images: '${{ env.DOCKER_CONTAINER_REGISTRY_NAMESPACE }}/meet-backend'
|
||||||
-
|
-
|
||||||
name: Login to DockerHub
|
name: Login to DockerHub
|
||||||
if: github.event_name != 'pull_request'
|
if: github.event_name != 'pull_request' || startsWith(github.head_ref, 'integration/')
|
||||||
uses: docker/login-action@v3
|
uses: docker/login-action@v3
|
||||||
with:
|
with:
|
||||||
username: ${{ secrets.DOCKER_HUB_USER }}
|
username: ${{ secrets.DOCKER_HUB_USER }}
|
||||||
@@ -65,7 +65,7 @@ jobs:
|
|||||||
target: backend-production
|
target: backend-production
|
||||||
platforms: ${{ env.BUILD_PLATFORMS }}
|
platforms: ${{ env.BUILD_PLATFORMS }}
|
||||||
build-args: DOCKER_USER=${{ env.DOCKER_USER }}:-1000
|
build-args: DOCKER_USER=${{ env.DOCKER_USER }}:-1000
|
||||||
push: ${{ github.event_name != 'pull_request' }}
|
push: ${{ github.event_name != 'pull_request' || startsWith(github.head_ref, 'integration/') }}
|
||||||
tags: ${{ steps.meta.outputs.tags }}
|
tags: ${{ steps.meta.outputs.tags }}
|
||||||
labels: ${{ steps.meta.outputs.labels }}
|
labels: ${{ steps.meta.outputs.labels }}
|
||||||
|
|
||||||
@@ -92,7 +92,7 @@ jobs:
|
|||||||
images: '${{ env.DOCKER_CONTAINER_REGISTRY_NAMESPACE }}/meet-frontend'
|
images: '${{ env.DOCKER_CONTAINER_REGISTRY_NAMESPACE }}/meet-frontend'
|
||||||
-
|
-
|
||||||
name: Login to DockerHub
|
name: Login to DockerHub
|
||||||
if: github.event_name != 'pull_request'
|
if: github.event_name != 'pull_request' || startsWith(github.head_ref, 'integration/')
|
||||||
uses: docker/login-action@v3
|
uses: docker/login-action@v3
|
||||||
with:
|
with:
|
||||||
username: ${{ secrets.DOCKER_HUB_USER }}
|
username: ${{ secrets.DOCKER_HUB_USER }}
|
||||||
@@ -112,7 +112,7 @@ jobs:
|
|||||||
target: frontend-production
|
target: frontend-production
|
||||||
platforms: ${{ env.BUILD_PLATFORMS }}
|
platforms: ${{ env.BUILD_PLATFORMS }}
|
||||||
build-args: DOCKER_USER=${{ env.DOCKER_USER }}:-1000
|
build-args: DOCKER_USER=${{ env.DOCKER_USER }}:-1000
|
||||||
push: ${{ github.event_name != 'pull_request' }}
|
push: ${{ github.event_name != 'pull_request' || startsWith(github.head_ref, 'integration/') }}
|
||||||
tags: ${{ steps.meta.outputs.tags }}
|
tags: ${{ steps.meta.outputs.tags }}
|
||||||
labels: ${{ steps.meta.outputs.labels }}
|
labels: ${{ steps.meta.outputs.labels }}
|
||||||
|
|
||||||
@@ -139,7 +139,7 @@ jobs:
|
|||||||
images: '${{ env.DOCKER_CONTAINER_REGISTRY_NAMESPACE }}/meet-frontend-dinum'
|
images: '${{ env.DOCKER_CONTAINER_REGISTRY_NAMESPACE }}/meet-frontend-dinum'
|
||||||
-
|
-
|
||||||
name: Login to DockerHub
|
name: Login to DockerHub
|
||||||
if: github.event_name != 'pull_request'
|
if: github.event_name != 'pull_request' || startsWith(github.head_ref, 'integration/')
|
||||||
uses: docker/login-action@v3
|
uses: docker/login-action@v3
|
||||||
with:
|
with:
|
||||||
username: ${{ secrets.DOCKER_HUB_USER }}
|
username: ${{ secrets.DOCKER_HUB_USER }}
|
||||||
@@ -159,7 +159,7 @@ jobs:
|
|||||||
target: frontend-production
|
target: frontend-production
|
||||||
platforms: ${{ env.BUILD_PLATFORMS }}
|
platforms: ${{ env.BUILD_PLATFORMS }}
|
||||||
build-args: DOCKER_USER=${{ env.DOCKER_USER }}:-1000
|
build-args: DOCKER_USER=${{ env.DOCKER_USER }}:-1000
|
||||||
push: ${{ github.event_name != 'pull_request' }}
|
push: ${{ github.event_name != 'pull_request' || startsWith(github.head_ref, 'integration/') }}
|
||||||
tags: ${{ steps.meta.outputs.tags }}
|
tags: ${{ steps.meta.outputs.tags }}
|
||||||
labels: ${{ steps.meta.outputs.labels }}
|
labels: ${{ steps.meta.outputs.labels }}
|
||||||
|
|
||||||
@@ -186,7 +186,7 @@ jobs:
|
|||||||
images: '${{ env.DOCKER_CONTAINER_REGISTRY_NAMESPACE }}/meet-summary'
|
images: '${{ env.DOCKER_CONTAINER_REGISTRY_NAMESPACE }}/meet-summary'
|
||||||
-
|
-
|
||||||
name: Login to DockerHub
|
name: Login to DockerHub
|
||||||
if: github.event_name != 'pull_request'
|
if: github.event_name != 'pull_request' || startsWith(github.head_ref, 'integration/')
|
||||||
uses: docker/login-action@v3
|
uses: docker/login-action@v3
|
||||||
with:
|
with:
|
||||||
username: ${{ secrets.DOCKER_HUB_USER }}
|
username: ${{ secrets.DOCKER_HUB_USER }}
|
||||||
@@ -208,7 +208,7 @@ jobs:
|
|||||||
target: production
|
target: production
|
||||||
platforms: ${{ env.BUILD_PLATFORMS }}
|
platforms: ${{ env.BUILD_PLATFORMS }}
|
||||||
build-args: DOCKER_USER=${{ env.DOCKER_USER }}:-1000
|
build-args: DOCKER_USER=${{ env.DOCKER_USER }}:-1000
|
||||||
push: ${{ github.event_name != 'pull_request' }}
|
push: ${{ github.event_name != 'pull_request' || startsWith(github.head_ref, 'integration/') }}
|
||||||
tags: ${{ steps.meta.outputs.tags }}
|
tags: ${{ steps.meta.outputs.tags }}
|
||||||
labels: ${{ steps.meta.outputs.labels }}
|
labels: ${{ steps.meta.outputs.labels }}
|
||||||
|
|
||||||
@@ -235,7 +235,7 @@ jobs:
|
|||||||
images: lasuite/meet-agents
|
images: lasuite/meet-agents
|
||||||
-
|
-
|
||||||
name: Login to DockerHub
|
name: Login to DockerHub
|
||||||
if: github.event_name != 'pull_request'
|
if: github.event_name != 'pull_request' || startsWith(github.head_ref, 'integration/')
|
||||||
uses: docker/login-action@v3
|
uses: docker/login-action@v3
|
||||||
with:
|
with:
|
||||||
username: ${{ secrets.DOCKER_HUB_USER }}
|
username: ${{ secrets.DOCKER_HUB_USER }}
|
||||||
@@ -257,7 +257,7 @@ jobs:
|
|||||||
target: production
|
target: production
|
||||||
platforms: ${{ env.BUILD_PLATFORMS }}
|
platforms: ${{ env.BUILD_PLATFORMS }}
|
||||||
build-args: DOCKER_USER=${{ env.DOCKER_USER }}:-1000
|
build-args: DOCKER_USER=${{ env.DOCKER_USER }}:-1000
|
||||||
push: ${{ github.event_name != 'pull_request' }}
|
push: ${{ github.event_name != 'pull_request' || startsWith(github.head_ref, 'integration/') }}
|
||||||
tags: ${{ steps.meta.outputs.tags }}
|
tags: ${{ steps.meta.outputs.tags }}
|
||||||
labels: ${{ steps.meta.outputs.labels }}
|
labels: ${{ steps.meta.outputs.labels }}
|
||||||
|
|
||||||
|
|||||||
@@ -82,7 +82,7 @@ jobs:
|
|||||||
- name: Install Node.js
|
- name: Install Node.js
|
||||||
uses: actions/setup-node@v6
|
uses: actions/setup-node@v6
|
||||||
with:
|
with:
|
||||||
node-version: "18"
|
node-version: "22"
|
||||||
|
|
||||||
- name: Restore the mail templates
|
- name: Restore the mail templates
|
||||||
uses: actions/cache@v5
|
uses: actions/cache@v5
|
||||||
@@ -305,7 +305,6 @@ jobs:
|
|||||||
working-directory: src/summary
|
working-directory: src/summary
|
||||||
|
|
||||||
env:
|
env:
|
||||||
V1_TENANT_ID: 'test-tenant'
|
|
||||||
AUTHORIZED_TENANTS: '[{"id": "test-tenant", "api_key": "test-api-token", "webhook_url": "https://example.com/webhook", "webhook_api_key": "test-webhook-api-key"}]'
|
AUTHORIZED_TENANTS: '[{"id": "test-tenant", "api_key": "test-api-token", "webhook_url": "https://example.com/webhook", "webhook_api_key": "test-webhook-api-key"}]'
|
||||||
AWS_STORAGE_BUCKET_NAME: "http://meet-media-storage"
|
AWS_STORAGE_BUCKET_NAME: "http://meet-media-storage"
|
||||||
AWS_S3_ENDPOINT_URL: "minio:9000"
|
AWS_S3_ENDPOINT_URL: "minio:9000"
|
||||||
|
|||||||
@@ -8,6 +8,91 @@ and this project adheres to
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- ✨(backend) allow searching the recording admin table by owner email
|
||||||
|
- ✨(frontend) add participant color gradient when camera is off #1490
|
||||||
|
- ✨(all) allow forcing SSO display name for authenticated users
|
||||||
|
- ➕(frontend) install vite-plugin-static-copy for MediaPipe WASM assets
|
||||||
|
- ✨(backend) add per-recording encoding quality presets to start-recording API
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- 🗑️(settings) deprecate SUMMARY_SERVICE_VERSION=1
|
||||||
|
- ⬆️(mail) update mjml to v5 and @html-to/text-cli
|
||||||
|
- 🚸(frontend) initialize the join input name with the persisted full name
|
||||||
|
- ♻️(frontend) refactor background processors to use the new API
|
||||||
|
- ♻️(frontend) inline model weights to avoid loading them from remote
|
||||||
|
- ♻️(frontend) inline MediaPipe WASM modules to avoid loading from remote
|
||||||
|
- ⬆️(frontend) upgrade posthog-js from 1.387.0 to 1.391.2
|
||||||
|
- ⬆️(frontend) upgrade react-stately from 3.47.0 to 3.48.0
|
||||||
|
- ⬆️(frontend) upgrade react-aria from 3.49.0 to 3.50.0
|
||||||
|
- ⬆️(frontend) upgrade react-aria-components from 1.18.0 to 1.19.0
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- 🩹(backend) identify externally provisioned users to PostHog
|
||||||
|
- 🐛(backend) fix info panel crash for unregistered rooms
|
||||||
|
- ♿️(frontend) focus side panel container on open #1452
|
||||||
|
- 💥(backend) replace recording encoding options with a profile model
|
||||||
|
|
||||||
|
## [1.23.0] - 2026-07-08
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- ✨(backend) extend analytics module to support feature flags
|
||||||
|
- ✨(backend) implement feature flags in Posthog analytics backend
|
||||||
|
- ✨(agents) report errors to Sentry for all LiveKit agents
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- ⬆️(agents) upgrade to python 3.14 slim
|
||||||
|
- ⬆️(dependencies) update python dependencies
|
||||||
|
- 💥(summary) remove v1 related code #1362
|
||||||
|
- ✨(meet) use compatible with summary v2 #1362
|
||||||
|
- ♻️(backend) refactor analytics backend from Protocol to abstract class
|
||||||
|
- 🔥(summary) remove call to summary enabled feature flag
|
||||||
|
- ♻️(frontend) wrap MuteEveryoneButton with AdminOrOwnerOnly
|
||||||
|
- ⬆️(frontend) upgrade livekit-client from 2.19.0 to 2.19.2
|
||||||
|
- ⬆️(frontend) upgrade posthog-js from 1.386.5 to 1.387.0
|
||||||
|
- ⬆️(frontend) upgrade @tanstack/react-query from 5.100.14 to 5.101.0
|
||||||
|
- ⬆️(frontend) update the frontend build image to Node 22
|
||||||
|
- 🔒️(frontend) update docker image to nginx-unprivileged:1.30.3-alpine3.23
|
||||||
|
- ✨(summary) more precise analytics events
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- 🚀(front) fix frontend build failure
|
||||||
|
- 🐛(makefile) fix args in make test
|
||||||
|
- 🩹(backend) fix case-insensitive email deduplication in merge command
|
||||||
|
- 🐛(summary) support media files with bad streams #1478
|
||||||
|
|
||||||
|
## [1.22.0] - 2026-07-03
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- ✨(frontend) cap and paginate tiles in picture-in-picture #1383
|
||||||
|
- 📝(docs) document rebranding the favicon via a volume mount #1443
|
||||||
|
- ✨(backend) add command to clean pending and deleted files
|
||||||
|
- 🧱(helm) run clean files command as cronjob
|
||||||
|
- ✨(backend) add fallback to save recordings without S3/MinIO webhooks
|
||||||
|
- 🩹(frontend) enable screen share button in PiP #1458
|
||||||
|
- 🐛(backend) support unencoded S3 notification object keys #1455
|
||||||
|
- ✨(frontend) prioritize screen share in picture-in-picture layout #1467
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- ✨(summary) generalized stt api call #1420
|
||||||
|
- ♻️(env) refactor env variables handling
|
||||||
|
- 🚸(frontend) use "Advanced" instead of "Premium" in the sidepanel
|
||||||
|
- ♿️(frontend) make fullscreen share warning keyboard accessible #1459
|
||||||
|
- ⬆️(summary) update docker alpine to 3.24 & ffmpeg to 8.1.2 #1471
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- 🛂(backend) reject user access tokens on the API
|
||||||
|
- 🩹(helm) fix Helm ingress rendering when passing multiple hosts
|
||||||
|
|
||||||
## [1.21.0] - 2026-06-15
|
## [1.21.0] - 2026-06-15
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
+1
-1
@@ -37,7 +37,7 @@ RUN --mount=type=cache,target=/root/.cache/uv \
|
|||||||
uv sync --locked --no-dev
|
uv sync --locked --no-dev
|
||||||
|
|
||||||
# ---- mails ----
|
# ---- mails ----
|
||||||
FROM node:20 AS mail-builder
|
FROM node:22 AS mail-builder
|
||||||
|
|
||||||
COPY ./src/mail /mail/app
|
COPY ./src/mail /mail/app
|
||||||
|
|
||||||
|
|||||||
@@ -75,7 +75,8 @@ create-env-files: \
|
|||||||
env.d/development/kc_postgresql \
|
env.d/development/kc_postgresql \
|
||||||
env.d/development/summary \
|
env.d/development/summary \
|
||||||
env.d/development/kube-secret \
|
env.d/development/kube-secret \
|
||||||
env.d/development/multi_user_transcriber
|
env.d/development/multi_user_transcriber \
|
||||||
|
env.d/development/metadata_collector
|
||||||
.PHONY: create-env-files
|
.PHONY: create-env-files
|
||||||
|
|
||||||
bootstrap: ## Prepare Docker images for the project
|
bootstrap: ## Prepare Docker images for the project
|
||||||
@@ -210,24 +211,25 @@ lint-pylint: ## lint back-end python sources with pylint only on changed files f
|
|||||||
@$(COMPOSE_RUN_APP) pylint meet demo core
|
@$(COMPOSE_RUN_APP) pylint meet demo core
|
||||||
.PHONY: lint-pylint
|
.PHONY: lint-pylint
|
||||||
|
|
||||||
test: ## run project tests
|
test: ## run project tests; pass extra pytest args via ARGS, e.g. `make test ARGS="-vv"`
|
||||||
@$(MAKE) test-back-parallel
|
@args="$(ARGS) $(filter-out $@,$(MAKECMDGOALS))" && \
|
||||||
@$(MAKE) test-summary
|
$(MAKE) test-back-parallel ARGS="$${args}" && \
|
||||||
|
$(MAKE) test-summary ARGS="$${args}"
|
||||||
.PHONY: test
|
.PHONY: test
|
||||||
|
|
||||||
test-back: ## run back-end tests
|
test-back: ## run back-end tests (pass extra pytest args via ARGS)
|
||||||
@args="$(filter-out $@,$(MAKECMDGOALS))" && \
|
@args="$(ARGS) $(filter-out $@,$(MAKECMDGOALS))" && \
|
||||||
bin/pytest $${args:-${1}}
|
bin/pytest $${args}
|
||||||
.PHONY: test-back
|
.PHONY: test-back
|
||||||
|
|
||||||
test-back-parallel: ## run all back-end tests in parallel
|
test-back-parallel: ## run all back-end tests in parallel (pass extra pytest args via ARGS)
|
||||||
@args="$(filter-out $@,$(MAKECMDGOALS))" && \
|
@args="$(ARGS) $(filter-out $@,$(MAKECMDGOALS))" && \
|
||||||
bin/pytest -n auto $${args:-${1}}
|
bin/pytest -n auto $${args}
|
||||||
.PHONY: test-back-parallel
|
.PHONY: test-back-parallel
|
||||||
|
|
||||||
test-summary: ## run summary tests
|
test-summary: ## run summary tests (pass extra pytest args via ARGS)
|
||||||
@args="$(filter-out $@,$(MAKECMDGOALS))" && \
|
@args="$(ARGS) $(filter-out $@,$(MAKECMDGOALS))" && \
|
||||||
bin/pytest-summary $${args:-${1}}
|
bin/pytest-summary $${args}
|
||||||
.PHONY: test-summary
|
.PHONY: test-summary
|
||||||
|
|
||||||
makemigrations: ## run django makemigrations for the Meet project.
|
makemigrations: ## run django makemigrations for the Meet project.
|
||||||
@@ -292,6 +294,9 @@ env.d/development/kube-secret:
|
|||||||
env.d/development/multi_user_transcriber:
|
env.d/development/multi_user_transcriber:
|
||||||
cp -n env.d/development/multi_user_transcriber.dist env.d/development/multi_user_transcriber
|
cp -n env.d/development/multi_user_transcriber.dist env.d/development/multi_user_transcriber
|
||||||
|
|
||||||
|
env.d/development/metadata_collector:
|
||||||
|
cp -n env.d/development/metadata_collector.dist env.d/development/metadata_collector
|
||||||
|
|
||||||
# -- Internationalization
|
# -- Internationalization
|
||||||
|
|
||||||
env.d/development/crowdin:
|
env.d/development/crowdin:
|
||||||
|
|||||||
@@ -13,6 +13,9 @@
|
|||||||
<a href="https://github.com/suitenumerique/meet/blob/main/LICENSE">
|
<a href="https://github.com/suitenumerique/meet/blob/main/LICENSE">
|
||||||
<img alt="GitHub closed issues" src="https://img.shields.io/github/license/suitenumerique/meet"/>
|
<img alt="GitHub closed issues" src="https://img.shields.io/github/license/suitenumerique/meet"/>
|
||||||
</a>
|
</a>
|
||||||
|
<a href="https://digitalpublicgoods.net/r/la-suite-meet-simple-video-conferencing">
|
||||||
|
<img src="https://img.shields.io/badge/Verified-DPG-3333AB?logo=data:image/svg%2bxml;base64,PHN2ZyB3aWR0aD0iMzEiIGhlaWdodD0iMzMiIHZpZXdCb3g9IjAgMCAzMSAzMyIgZmlsbD0ibm9uZSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj4KPHBhdGggZD0iTTE0LjIwMDggMjEuMzY3OEwxMC4xNzM2IDE4LjAxMjRMMTEuNTIxOSAxNi40MDAzTDEzLjk5MjggMTguNDU5TDE5LjYyNjkgMTIuMjExMUwyMS4xOTA5IDEzLjYxNkwxNC4yMDA4IDIxLjM2NzhaTTI0LjYyNDEgOS4zNTEyN0wyNC44MDcxIDMuMDcyOTdMMTguODgxIDUuMTg2NjJMMTUuMzMxNCAtMi4zMzA4MmUtMDVMMTEuNzgyMSA1LjE4NjYyTDUuODU2MDEgMy4wNzI5N0w2LjAzOTA2IDkuMzUxMjdMMCAxMS4xMTc3TDMuODQ1MjEgMTYuMDg5NUwwIDIxLjA2MTJMNi4wMzkwNiAyMi44Mjc3TDUuODU2MDEgMjkuMTA2TDExLjc4MjEgMjYuOTkyM0wxNS4zMzE0IDMyLjE3OUwxOC44ODEgMjYuOTkyM0wyNC44MDcxIDI5LjEwNkwyNC42MjQxIDIyLjgyNzdMMzAuNjYzMSAyMS4wNjEyTDI2LjgxNzYgMTYuMDg5NUwzMC42NjMxIDExLjExNzdMMjQuNjI0MSA5LjM1MTI3WiIgZmlsbD0id2hpdGUiLz4KPC9zdmc+Cg==" alt="DPG Badge"/>
|
||||||
|
</a>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
@@ -28,6 +31,14 @@
|
|||||||
## La Suite Meet: Simple Video Conferencing
|
## La Suite Meet: Simple Video Conferencing
|
||||||
|
|
||||||
Powered by [LiveKit](https://livekit.io/), La Suite Meet offers Zoom-level performance with high-quality video and audio. No installation required—simply join calls directly from your browser. Check out LiveKit's impressive optimizations in their [blog post](https://blog.livekit.io/livekit-one-dot-zero/).
|
Powered by [LiveKit](https://livekit.io/), La Suite Meet offers Zoom-level performance with high-quality video and audio. No installation required—simply join calls directly from your browser. Check out LiveKit's impressive optimizations in their [blog post](https://blog.livekit.io/livekit-one-dot-zero/).
|
||||||
|
|
||||||
|
> [!TIP]
|
||||||
|
> New here? Start by introducing yourself in our Matrix channel:
|
||||||
|
> **https://matrix.to/#/#meet-official:matrix.org**
|
||||||
|
>
|
||||||
|
> We’re happy to discuss ideas, answer questions, and help to deploy LaSuite Meet.
|
||||||
|
|
||||||
|
|
||||||
### Features
|
### Features
|
||||||
- Optimized for stability in large meetings (+100 p.)
|
- Optimized for stability in large meetings (+100 p.)
|
||||||
- Support for multiple screen sharing streams
|
- Support for multiple screen sharing streams
|
||||||
@@ -52,7 +63,7 @@ We’re continuously adding new features to enhance your experience, with the la
|
|||||||
|
|
||||||
### 🚀 Major roll out to all French public servants
|
### 🚀 Major roll out to all French public servants
|
||||||
|
|
||||||
On the 25th of January 2026, David Amiel, France’s Minister for Civil Service and State Reform, announced the full deployment of Visio—the French government’s dedicated Meet platform—to all public servants. ([Source in French](https://www.latribune.fr/article/la-tribune-dimanche/politique/73157688099661/david-amiel-ministre-delegue-de-la-fonction-publique-nous-allons-sortir-de-la-dependance-aux-outils-americains))
|
On the 29th of January 2026, Prime Minister Sébastien Lecornu, announced the full deployment of Visio—the French government’s dedicated Meet platform—to all public servants. ([Source in English](https://www.nytimes.com/2026/01/29/world/europe/france-zoom-alternative-visio.html))
|
||||||
|
|
||||||
## Table of Contents
|
## Table of Contents
|
||||||
|
|
||||||
@@ -84,22 +95,57 @@ We use Kubernetes for our [production instance](https://visio.numerique.gouv.fr/
|
|||||||
#### Known instances
|
#### Known instances
|
||||||
We hope to see many more, here is an incomplete list of public La Suite Meet instances. Feel free to make a PR to add ones that are not listed below🙏
|
We hope to see many more, here is an incomplete list of public La Suite Meet instances. Feel free to make a PR to add ones that are not listed below🙏
|
||||||
|
|
||||||
| Url | Org | Access |
|
| Url | Org | Access |
|
||||||
|---------------------------------------------------------------| --- | ------- |
|
|---------------------------------------------------------------|--------------|-----------------------------------------------------------------------------------------------------------------------------------------------|
|
||||||
| [visio.numerique.gouv.fr](https://visio.numerique.gouv.fr/) | DINUM | French public agents working for the central administration and the extended public sphere. ProConnect is required to login in or sign up|
|
| [visio.numerique.gouv.fr](https://visio.numerique.gouv.fr/) | DINUM | French public agents working for the central administration and the extended public sphere. ProConnect is required to login in or sign up |
|
||||||
| [visio.suite.anct.gouv.fr](https://visio.suite.anct.gouv.fr/) | ANCT | French public agents working for the territorial administration and the extended public sphere. ProConnect is required to login in or sign up|
|
| [visio.suite.anct.gouv.fr](https://visio.suite.anct.gouv.fr/) | ANCT | French public agents working for the territorial administration and the extended public sphere. ProConnect is required to login in or sign up |
|
||||||
| [visio.lasuite.coop](https://visio.lasuite.coop/) | lasuite.coop | Free and open demo to all. Content and accounts are reset after one month |
|
| [visio.lasuite.coop](https://visio.lasuite.coop/) | lasuite.coop | Free and open demo to all. Content and accounts are reset after one month |
|
||||||
| [mosacloud.cloud](https://mosa.cloud/) | mosa.cloud | Demo instance of mosa.cloud, a dutch company providing services around La Suite apps. |
|
| [mosacloud.cloud](https://mosa.cloud/) | mosa.cloud | Demo instance of mosa.cloud, a dutch company providing services around La Suite apps. |
|
||||||
|
| [Clever Cloud](https://www.clever.cloud/product/visio/) | clever cloud | Openvisio is a sovereign video conferencing solution based on LaSuite Meet offered by [Clever Cloud](https://www.clever.cloud/). |
|
||||||
|
| [Email.eu](https://email.eu/) | Email.eu | Sovereign business workspace. |
|
||||||
|
|
||||||
|
|
||||||
## Contributing
|
# Contributing
|
||||||
|
|
||||||
We <3 contributions of any kind, big and small:
|
We <3 contributions of all kinds **big or small** and we’re genuinely glad you’re here. 🌱
|
||||||
|
|
||||||
- Vote on features or get early access to beta functionality in our [roadmap](https://github.com/orgs/suitenumerique/projects/11/views/4)
|
### Start by saying hi
|
||||||
- Open a PR (see our instructions on [developing La Suite Meet locally](https://github.com/suitenumerique/meet/blob/main/docs/developping_locally.md))
|
|
||||||
- Submit a [feature request](https://github.com/suitenumerique/meet/issues/new?assignees=&labels=enhancement&template=Feature_request.md) or [bug report](https://github.com/suitenumerique/meet/issues/new?assignees=&labels=bug&template=Bug_report.md)
|
|
||||||
|
|
||||||
|
**The best first contribution is simply to come say hi.**
|
||||||
|
|
||||||
|
Before opening a PR, especially a larger one, or one written with the help of AI, we encourage you to reach out to a maintainer on our [Matrix channel](https://matrix.to/#/#meet-official:matrix.org) (@antoine.lebaud:matrix.org).
|
||||||
|
|
||||||
|
Getting in touch early helps us align on goals, avoid duplicated or wasted effort, and build a community that stays active, welcoming, and fun to be part of. There are no silly questions here: whether you’ve shipped hundreds of PRs or you’re just getting started, you’re welcome.
|
||||||
|
|
||||||
|
### AI contributions
|
||||||
|
|
||||||
|
AI-assisted contributions are welcome. But code is never the end goal. What matters most is building relationships, sharing knowledge, and growing a sustainable community over time.
|
||||||
|
|
||||||
|
If your contribution has been heavily generated with AI, please be transparent about it. This helps maintainers review it with the right context and respects the time they invest in the project.
|
||||||
|
|
||||||
|
Using AI does not transfer ownership of the contribution: you should still fully understand the code, the problem it solves, and the reasoning behind the approach you propose. In short, even if AI helped write it, the why should still be yours.
|
||||||
|
|
||||||
|
### Contributions beyond code
|
||||||
|
|
||||||
|
**Not technical? We need you too.**
|
||||||
|
Open source is much more than code. Writing documentation, improving onboarding, translating content, answering questions, reporting bugs, or simply helping others feel welcome all make a huge difference.
|
||||||
|
|
||||||
|
### Ways to contribute
|
||||||
|
|
||||||
|
When you’re ready, here are a few ways to get involved:
|
||||||
|
|
||||||
|
* 👋 **Say hello** and share your ideas with the community and maintainers on our [Matrix channel](https://matrix.to/#/#meet-official:matrix.org)
|
||||||
|
* 🛠️ **Open a PR** by following our guide to [develop La Suite Meet locally](https://github.com/suitenumerique/meet/blob/main/docs/developping_locally.md)
|
||||||
|
* 💡 **Suggest an idea** by opening a [feature request](https://github.com/suitenumerique/meet/issues/new?assignees=&labels=enhancement&template=Feature_request.md)
|
||||||
|
* 🐛 **Report a bug** by opening a [bug report](https://github.com/suitenumerique/meet/issues/new?assignees=&labels=bug&template=Bug_report.md)
|
||||||
|
|
||||||
|
Thank you for helping build something open, useful, and human. 💙
|
||||||
|
|
||||||
|
### Community call
|
||||||
|
|
||||||
|
We host a community call on the first Friday of every month to share updates, discuss ideas, and connect with contributors.
|
||||||
|
|
||||||
|
Whether you’re actively contributing or just curious about the project, you’re welcome to join. More details are shared on the [Matrix channel](https://matrix.to/#/#meet-official:matrix.org).
|
||||||
|
|
||||||
## Philosophy
|
## Philosophy
|
||||||
|
|
||||||
|
|||||||
+98
@@ -15,3 +15,101 @@ the following command inside your docker container:
|
|||||||
(Note : in your development environment, you can `make migrate`.)
|
(Note : in your development environment, you can `make migrate`.)
|
||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
### Recording encoding settings replaced by a resolution/profile model
|
||||||
|
|
||||||
|
The `RECORDING_ENCODING_*` settings introduced in v1.16.0 exposed raw encoder
|
||||||
|
values (width, height, framerate, bitrate). They are replaced by two named and configurable sets of
|
||||||
|
dimensions, a **resolution** (default: `540p`, `720p`, `1080p`) and a **profile**
|
||||||
|
(default: `talking_heads`, `text`, `mixed`, `full`), which are resolved to the width, height,
|
||||||
|
fps and video bitrate.
|
||||||
|
|
||||||
|
**The following environment variables are no longer read. If they are still set in
|
||||||
|
your deployment they are silently ignored, and your recordings will be encoded with
|
||||||
|
the new defaults instead of your tuned values.**
|
||||||
|
|
||||||
|
| Removed variable | Replaced by |
|
||||||
|
| --------------------------------------- | ------------------------------------------------------------------------------------------------------------- |
|
||||||
|
| `RECORDING_ENCODING_ENABLED` | Nothing. A default encoding is now always built (see below). **Not** `RECORDING_CUSTOM_ENCODING_ENABLED`, which gates a different feature. |
|
||||||
|
| `RECORDING_ENCODING_WIDTH` | The `width` of the entry selected by `RECORDING_ENCODING_DEFAULT_RESOLUTION` in `RECORDING_ENCODING_AVAILABLE_RESOLUTIONS`. |
|
||||||
|
| `RECORDING_ENCODING_HEIGHT` | The `height` of that same entry. |
|
||||||
|
| `RECORDING_ENCODING_FRAMERATE` | The `fps` of the profile selected by `RECORDING_ENCODING_DEFAULT_PROFILE` in `RECORDING_ENCODING_AVAILABLE_PROFILES`. |
|
||||||
|
| `RECORDING_ENCODING_VIDEO_BITRATE_KBPS` | That profile's `kbps`. |
|
||||||
|
|
||||||
|
`RECORDING_ENCODING_AUDIO_BITRATE_KBPS` and `RECORDING_ENCODING_KEY_FRAME_INTERVAL_S`
|
||||||
|
are unchanged and keep their values.
|
||||||
|
|
||||||
|
#### If you never set `RECORDING_ENCODING_ENABLED=True`
|
||||||
|
|
||||||
|
No action is required. The shipped defaults (`RECORDING_ENCODING_DEFAULT_PROFILE=full`,
|
||||||
|
`RECORDING_ENCODING_DEFAULT_RESOLUTION=720p`) match LiveKit's built-in
|
||||||
|
`H264_720P_30` preset: 1280×720, 30 fps, 3000 kbps H.264 MAIN, 128 kbps AAC.
|
||||||
|
|
||||||
|
Note that these values are now sent explicitly as advanced `EncodingOptions`
|
||||||
|
rather than relying on LiveKit's preset, so `RECORDING_ENCODING_AUDIO_BITRATE_KBPS`
|
||||||
|
and `RECORDING_ENCODING_KEY_FRAME_INTERVAL_S` now apply to every recording. They
|
||||||
|
previously applied only when `RECORDING_ENCODING_ENABLED` was `True`.
|
||||||
|
|
||||||
|
To keep letting LiveKit pick the encoding instead, set either default to an empty
|
||||||
|
value:
|
||||||
|
|
||||||
|
```
|
||||||
|
RECORDING_ENCODING_DEFAULT_RESOLUTION=
|
||||||
|
RECORDING_ENCODING_DEFAULT_PROFILE=
|
||||||
|
```
|
||||||
|
|
||||||
|
#### If you had tuned `RECORDING_ENCODING_*` values
|
||||||
|
|
||||||
|
Translate your old values into a default resolution and a default profile. Declare your own resolution and/or profile. Both maps are read from the
|
||||||
|
environment as a single-line Python/JSON dict literal (parsed with
|
||||||
|
`ast.literal_eval`, so use double-quoted keys and no trailing commas, and do not
|
||||||
|
add outer quotes in `.env`-style files):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
RECORDING_ENCODING_AVAILABLE_RESOLUTIONS={"540p": {"width": 960, "height": 540}, "720p": {"width": 1280, "height": 720}, "1080p": {"width": 1920, "height": 1080}}
|
||||||
|
RECORDING_ENCODING_AVAILABLE_PROFILES={"my_old_profile": {"fps": 15, "kbps": {"540p": 350, "720p": 600, "1080p": 1100}}}
|
||||||
|
RECORDING_ENCODING_DEFAULT_RESOLUTION=720p
|
||||||
|
RECORDING_ENCODING_DEFAULT_PROFILE=my_old_profile
|
||||||
|
```
|
||||||
|
|
||||||
|
Two constraints are validated at startup and may raise a `ValueError`:
|
||||||
|
|
||||||
|
- every profile in `RECORDING_ENCODING_AVAILABLE_PROFILES` must define a `kbps`
|
||||||
|
entry for **exactly** the keys of `RECORDING_ENCODING_AVAILABLE_RESOLUTIONS`;
|
||||||
|
overriding one of the two maps usually means overriding both;
|
||||||
|
- `RECORDING_ENCODING_DEFAULT_RESOLUTION` and `RECORDING_ENCODING_DEFAULT_PROFILE`,
|
||||||
|
when non-empty, must be keys of their respective map.
|
||||||
|
|
||||||
|
#### Optional: per-recording encoding
|
||||||
|
|
||||||
|
`RECORDING_CUSTOM_ENCODING_ENABLED` (default `False`) toggles whether the
|
||||||
|
start-recording API accepts an `encoding` object
|
||||||
|
(`{"resolution": "720p", "profile": "talking_heads"}`, `profile` optional) that
|
||||||
|
overrides the default for a single recording. It does not enable or disable the
|
||||||
|
default encoding, which is built from the two `RECORDING_ENCODING_DEFAULT_*`
|
||||||
|
settings either way. Leaving it at `False` preserves the previous behaviour, where
|
||||||
|
every recording uses the server-side encoding: requests carrying
|
||||||
|
`options.encoding` are rejected with a `400` before the recording is created, so
|
||||||
|
nothing is persisted and no egress is started.
|
||||||
|
|
||||||
|
Before enabling it:
|
||||||
|
|
||||||
|
- clients can only pick keys you declared; there is no way to send a raw width or bitrate
|
||||||
|
- as of this implementation, the frontend never sends `encoding`
|
||||||
|
- `encoding` is accepted but ignored for `transcript` recordings, whose audio-only
|
||||||
|
egress has no video encoding to configure.
|
||||||
|
|
||||||
|
See [docs/features/recording.md](docs/features/recording.md#tuning-recording-encoding)
|
||||||
|
for the full setting reference, the shipped profile table and the tuning caveats.
|
||||||
|
|
||||||
|
## v1.23.0
|
||||||
|
|
||||||
|
As part of the 1.23.0 release, the legacy `api/v1` implementation has been removed from the _experimental_ Summary service and Meet has been migrated to the new `api/v2`.
|
||||||
|
|
||||||
|
**To avoid a breaking change, the Meet backend continues to use the Summary service's v1-compatible API format by default (`SUMMARY_SERVICE_VERSION` setting defaults to `1`).**
|
||||||
|
|
||||||
|
If you are deploying both Meet and Summary from this repository, you must configure the Meet backend to use the v2 API by setting the following environment variable `SUMMARY_SERVICE_VERSION=2`.
|
||||||
|
|
||||||
|
If you are upgrading only the Meet deployment while keeping an older Summary v1 compatible deployment, no action is required, as the v1-compatible API remains the default.
|
||||||
|
|
||||||
|
Note that we plan on removing the legacy `v1` summary compatibility in a future major version. If you have your own implementation for the summary service, we recommend updating its API contract and setting `SUMMARY_SERVICE_VERSION=2`.
|
||||||
|
|||||||
@@ -113,6 +113,12 @@ update_python_version "summary"
|
|||||||
# Update agents pyproject.toml
|
# Update agents pyproject.toml
|
||||||
update_python_version "agents"
|
update_python_version "agents"
|
||||||
|
|
||||||
|
# Run uv lock in agents
|
||||||
|
print_info "Running uv lock in agents..."
|
||||||
|
cd "src/agents"
|
||||||
|
uv lock
|
||||||
|
cd -
|
||||||
|
|
||||||
# Update CHANGELOG
|
# Update CHANGELOG
|
||||||
print_info "Updating CHANGELOG..."
|
print_info "Updating CHANGELOG..."
|
||||||
|
|
||||||
|
|||||||
+12
-19
@@ -237,29 +237,22 @@ services:
|
|||||||
- livekit-egress
|
- livekit-egress
|
||||||
|
|
||||||
livekit-egress:
|
livekit-egress:
|
||||||
image: livekit/egress:v1.11.0
|
image: livekit/egress:v1.11.0
|
||||||
environment:
|
environment:
|
||||||
EGRESS_CONFIG_FILE: ./livekit-egress.yaml
|
EGRESS_CONFIG_FILE: ./livekit-egress.yaml
|
||||||
volumes:
|
volumes:
|
||||||
- ./docker/livekit/config/livekit-egress.yaml:/livekit-egress.yaml
|
- ./docker/livekit/config/livekit-egress.yaml:/livekit-egress.yaml
|
||||||
- ./docker/livekit/out:/out
|
- ./docker/livekit/out:/out
|
||||||
depends_on:
|
depends_on:
|
||||||
- redis
|
- redis
|
||||||
|
|
||||||
metadata-collector-dev:
|
metadata-collector-dev:
|
||||||
build:
|
build:
|
||||||
context: ./src/agents
|
context: ./src/agents
|
||||||
target: development
|
target: development
|
||||||
command: ["python", "metadata_collector.py", "dev"]
|
command: ["python", "metadata_collector.py", "dev"]
|
||||||
environment:
|
env_file:
|
||||||
- LIVEKIT_URL=ws://livekit:7880
|
- env.d/development/metadata_collector
|
||||||
- LIVEKIT_API_KEY=devkey
|
|
||||||
- LIVEKIT_API_SECRET=secret
|
|
||||||
- AWS_S3_ENDPOINT_URL=minio:9000
|
|
||||||
- AWS_S3_ACCESS_KEY_ID=meet
|
|
||||||
- AWS_S3_SECRET_ACCESS_KEY=password
|
|
||||||
- AWS_STORAGE_BUCKET_NAME=meet-media-storage
|
|
||||||
- AWS_S3_SECURE_ACCESS=False
|
|
||||||
volumes:
|
volumes:
|
||||||
- ./src/agents:/app
|
- ./src/agents:/app
|
||||||
- /app/.venv
|
- /app/.venv
|
||||||
@@ -308,7 +301,7 @@ services:
|
|||||||
context: ./src/summary
|
context: ./src/summary
|
||||||
dockerfile: Dockerfile
|
dockerfile: Dockerfile
|
||||||
target: production
|
target: production
|
||||||
command: celery -A summary.core.celery_worker worker --pool=solo --loglevel=debug -Q transcribe-queue
|
command: celery -A summary.core.celery_worker worker --pool=solo --loglevel=debug -Q transcribe-queue-v2
|
||||||
env_file:
|
env_file:
|
||||||
- env.d/development/summary
|
- env.d/development/summary
|
||||||
volumes:
|
volumes:
|
||||||
@@ -328,7 +321,7 @@ services:
|
|||||||
context: ./src/summary
|
context: ./src/summary
|
||||||
dockerfile: Dockerfile
|
dockerfile: Dockerfile
|
||||||
target: production
|
target: production
|
||||||
command: celery -A summary.core.celery_worker worker --pool=solo --loglevel=debug -Q summarize-queue
|
command: celery -A summary.core.celery_worker worker --pool=solo --loglevel=debug -Q summarize-queue-v2
|
||||||
env_file:
|
env_file:
|
||||||
- env.d/development/summary
|
- env.d/development/summary
|
||||||
volumes:
|
volumes:
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
# ---- Front-end image ----
|
# ---- Front-end image ----
|
||||||
FROM node:20-alpine AS frontend-deps
|
FROM node:22-alpine AS frontend-deps
|
||||||
|
|
||||||
WORKDIR /home/frontend/
|
WORKDIR /home/frontend/
|
||||||
|
|
||||||
@@ -54,7 +54,7 @@ RUN npx webpack --mode production
|
|||||||
|
|
||||||
|
|
||||||
# ---- Front-end image ----
|
# ---- Front-end image ----
|
||||||
FROM nginxinc/nginx-unprivileged:alpine3.23 AS frontend-production
|
FROM nginxinc/nginx-unprivileged:1.30.3-alpine3.23 AS frontend-production
|
||||||
|
|
||||||
USER root
|
USER root
|
||||||
|
|
||||||
|
|||||||
+42
-35
@@ -96,17 +96,17 @@ sequenceDiagram
|
|||||||
| **RECORDING_WORKER_CLASSES** | Dict | `{ "screen_recording": "core.recording.worker.services.VideoCompositeEgressService", "transcript": "core.recording.worker.services.AudioCompositeEgressService" }` | Maps recording types to their worker service classes. |
|
| **RECORDING_WORKER_CLASSES** | Dict | `{ "screen_recording": "core.recording.worker.services.VideoCompositeEgressService", "transcript": "core.recording.worker.services.AudioCompositeEgressService" }` | Maps recording types to their worker service classes. |
|
||||||
| **RECORDING_EVENT_PARSER_CLASS** | String | `"core.recording.event.parsers.MinioParser"` | Class responsible for parsing storage events and updating the backend. |
|
| **RECORDING_EVENT_PARSER_CLASS** | String | `"core.recording.event.parsers.MinioParser"` | Class responsible for parsing storage events and updating the backend. |
|
||||||
| **RECORDING_ENABLE_STORAGE_EVENT_AUTH** | Boolean | `True` | Enable authentication for storage event webhook requests. |
|
| **RECORDING_ENABLE_STORAGE_EVENT_AUTH** | Boolean | `True` | Enable authentication for storage event webhook requests. |
|
||||||
| **RECORDING_STORAGE_EVENT_ENABLE** | Boolean | `False` | Enable handling of storage events (must configure webhook in storage). |
|
| **RECORDING_STORAGE_EVENT_ENABLE** | Boolean | `False` | Enable handling of storage events (must configure webhook in storage). If `False`, fallback to LiveKit egress complete webhook. |
|
||||||
| **RECORDING_STORAGE_EVENT_TOKEN** | Secret/File | `None` | Token used to authenticate storage webhook requests, if `RECORDING_ENABLE_STORAGE_EVENT_AUTH` is enabled. |
|
| **RECORDING_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_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_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_CUSTOM_ENCODING_ENABLED** | Boolean | `False` | Whether the start-recording API accepts a per-recording `encoding` object (resolution/profile) that overrides the default. When `False`, the API rejects per-recording `encoding`; when `True`, clients may pick from the available resolutions/profiles. The default encoding below is applied regardless of this flag. 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_AVAILABLE_RESOLUTIONS** | Dict | `{"540p": {"width": 960, "height": 540}, "720p": {"width": 1280, "height": 720}, "1080p": {"width": 1920, "height": 1080}}` | Maps a resolution name to its `{"width", "height"}` in pixels. Both the default encoding and the per-recording start-recording API pick from these keys. |
|
||||||
| **RECORDING_ENCODING_HEIGHT** | Integer | `720` | Recording video height in pixels. Only applied when `RECORDING_ENCODING_ENABLED` is `True`. |
|
| **RECORDING_ENCODING_AVAILABLE_PROFILES** | Dict | `{"full": {"fps": 30, "kbps": {…}}, …}` | Maps a profile name to `{"fps", "kbps": {resolution: video_bitrate_kbps}}`. Every profile must define a bitrate for each available resolution (validated at startup). |
|
||||||
| **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_DEFAULT_RESOLUTION** | String | `"720p"` | Resolution used by the default encoding. When set, must be a key of `RECORDING_ENCODING_AVAILABLE_RESOLUTIONS`. Leave unset (together with, or instead of, the default profile) to disable the custom default encoding and fall back to LiveKit's built-in preset (a startup warning is emitted). |
|
||||||
| **RECORDING_ENCODING_VIDEO_BITRATE_KBPS** | Integer | `3000` | H.264 MAIN video bitrate in kbps. Only applied when `RECORDING_ENCODING_ENABLED` is `True`. |
|
| **RECORDING_ENCODING_DEFAULT_PROFILE** | String | `"full"` | Profile used by the default encoding. When set, must be a key of `RECORDING_ENCODING_AVAILABLE_PROFILES`. Leave unset (together with, or instead of, the default resolution) to disable the custom default encoding and fall back to LiveKit's built-in preset (a startup warning is emitted). |
|
||||||
| **RECORDING_ENCODING_AUDIO_BITRATE_KBPS** | Integer | `128` | AAC audio bitrate in kbps. Only applied when `RECORDING_ENCODING_ENABLED` is `True`. |
|
| **RECORDING_ENCODING_AUDIO_BITRATE_KBPS** | Integer | `128` | AAC audio bitrate in kbps used in the default encoding. |
|
||||||
| **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`. |
|
| **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. |
|
||||||
|
|
||||||
|
|
||||||
### Manual Storage Webhook
|
### Manual Storage Webhook
|
||||||
@@ -150,52 +150,59 @@ This allows you to verify which recordings are in progress, troubleshoot egress
|
|||||||
|
|
||||||
## Tuning recording encoding
|
## 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.
|
Every video recording is encoded from a default resolved from `RECORDING_ENCODING_DEFAULT_PROFILE` + `RECORDING_ENCODING_DEFAULT_RESOLUTION` and passed to LiveKit as advanced `EncodingOptions`. The shipped defaults (`full` profile) match LiveKit's built-in `H264_720P_30` preset. For a one-hour meeting that produces a file of roughly **1.4 GB**, which is often heavier than necessary for talking-head content and screen sharing; lowering the default profile/resolution shrinks it. If either default is left unset, no custom default encoding is built: a warning is logged at startup and LiveKit's built-in preset is used instead.
|
||||||
|
|
||||||
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.
|
Encoding is chosen from two maps: `RECORDING_ENCODING_AVAILABLE_RESOLUTIONS` (`resolution → {"width", "height"}`) and `RECORDING_ENCODING_AVAILABLE_PROFILES` (`profile → {"fps", "kbps": {resolution: video_bitrate_kbps}}`):
|
||||||
|
|
||||||
|
- **Default**: `RECORDING_ENCODING_DEFAULT_PROFILE` + `RECORDING_ENCODING_DEFAULT_RESOLUTION` set the encoding used by every recording that doesn't override it. Leave either unset to fall back to LiveKit's built-in preset (a startup warning is emitted).
|
||||||
|
- **Per recording (opt-in)**: set `RECORDING_CUSTOM_ENCODING_ENABLED=True` to let clients override the default per recording. The start-recording API then accepts an `encoding` object selecting a `resolution` (required) and `profile` (optional): a resolution-only request sets the frame size but leaves LiveKit's default framerate/bitrate; adding a profile pins fps and bitrate too. When `RECORDING_CUSTOM_ENCODING_ENABLED=False`, the API rejects any per-recording `encoding` and the default is used.
|
||||||
|
|
||||||
|
The resolved 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 the profile/resolution resolve to is what the encoder receives.
|
||||||
|
|
||||||
### How values map to GStreamer
|
### How values map to GStreamer
|
||||||
|
|
||||||
| Setting | GStreamer element | Property |
|
| Resolved value | GStreamer element | Property |
|
||||||
| ------------------------------------- | ----------------- | ---------------------------------- |
|
| ----------------------------------------- | ----------------- | ---------------------------------- |
|
||||||
| `RECORDING_ENCODING_WIDTH/HEIGHT` | capsfilter | `video/x-raw,width=W,height=H` |
|
| resolution `width` / `height` | capsfilter | `video/x-raw,width=W,height=H` |
|
||||||
| `RECORDING_ENCODING_FRAMERATE` | capsfilter | `framerate=F/1` |
|
| profile `fps` | capsfilter | `framerate=F/1` |
|
||||||
| `RECORDING_ENCODING_VIDEO_BITRATE_KBPS` | `x264enc` | `bitrate=kbps` (kilobits) |
|
| profile `kbps[resolution]` | `x264enc` | `bitrate=kbps` (kilobits) |
|
||||||
| `RECORDING_ENCODING_KEY_FRAME_INTERVAL_S` | `x264enc` | `key-int-max = interval × fps` |
|
| `RECORDING_ENCODING_KEY_FRAME_INTERVAL_S` | `x264enc` | `key-int-max = interval × fps` |
|
||||||
| `RECORDING_ENCODING_AUDIO_BITRATE_KBPS` | `faac` | `bitrate = kbps × 1000` (bits) |
|
| `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.
|
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
|
### Built-in 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.
|
The default `RECORDING_ENCODING_AVAILABLE_PROFILES` ship four profiles. Framerate is fixed per profile; video bitrate (kbps) scales with resolution so quality stays consistent across sizes. File size scales roughly with `framerate × bitrate`, and so does egress CPU cost.
|
||||||
|
|
||||||
| Profile | Resolution | FPS | Video (kbps) | Audio (kbps) | Keyframe (s) | ~ size / 30 min | Egress CPU (vs. default) | Suitable for |
|
| Profile | FPS | 540p (kbps) | 720p (kbps) | 1080p (kbps) | Suitable for |
|
||||||
| ---------------------- | ---------- | --- | ------------ | ------------ | ------------ | --------------- | ------------------------ | --------------------------------------------------- |
|
| --------------- | --- | ----------- | ----------- | ------------ | -------------------------------------------------- |
|
||||||
| Default (preset) | 1280×720 | 30 | 3000 | 128 | 4 | **~690 MB** | 100 % | Unchanged LiveKit behaviour |
|
| `talking_heads` | 15 | 400 | 700 | 1200 | Talking-head dominant meetings + occasional slides |
|
||||||
| Balanced | 1280×720 | 20 | 1000 | 96 | 4 | ~240 MB | ~67 % | Mixed content, moderate motion |
|
| `text` | 15 | 600 | 1000 | 1800 | Frequent dense screen sharing (decks, IDE, docs) |
|
||||||
| **Low CPU / small file** | 1280×720 | 15 | 600 | 64 | 4 | **~150 MB** | ~50 % | Talking-head dominant meetings + occasional slides ★ |
|
| `mixed` | 20 | 900 | 1500 | 2500 | Mixed content, moderate motion |
|
||||||
| Slide-heavy | 1280×720 | 15 | 900 | 64 | 4 | ~210 MB | ~55 % | Frequent dense screen sharing (decks, IDE, docs) |
|
| `full` | 30 | 2000 | 3000 | 4500 | Highest fidelity; closest to the LiveKit default preset |
|
||||||
| 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.
|
To pick a profile per recording (requires `RECORDING_CUSTOM_ENCODING_ENABLED=True`), the client sends it in the start-recording request:
|
||||||
|
|
||||||
Environment variables for the **Low CPU / small file** profile:
|
```json
|
||||||
|
{
|
||||||
|
"mode": "screen_recording",
|
||||||
|
"options": {"encoding": {"resolution": "720p", "profile": "talking_heads"}}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
To change the default encoding applied to every recording:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
RECORDING_ENCODING_ENABLED=True
|
RECORDING_ENCODING_DEFAULT_RESOLUTION=720p
|
||||||
RECORDING_ENCODING_WIDTH=1280
|
RECORDING_ENCODING_DEFAULT_PROFILE=talking_heads
|
||||||
RECORDING_ENCODING_HEIGHT=720
|
|
||||||
RECORDING_ENCODING_FRAMERATE=15
|
|
||||||
RECORDING_ENCODING_VIDEO_BITRATE_KBPS=600
|
|
||||||
RECORDING_ENCODING_AUDIO_BITRATE_KBPS=64
|
RECORDING_ENCODING_AUDIO_BITRATE_KBPS=64
|
||||||
RECORDING_ENCODING_KEY_FRAME_INTERVAL_S=4.0
|
RECORDING_ENCODING_KEY_FRAME_INTERVAL_S=4.0
|
||||||
```
|
```
|
||||||
|
|
||||||
### Caveats
|
### 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).
|
- **Screen-share readability — think bits/frame, not bitrate**: at 720p, text legibility starts to break down below ~40 kbits/frame (= `bitrate ÷ framerate`). The `talking_heads` profile (700 kbps × 15 fps) sits just above that threshold, comfortable for talking heads with occasional slide sharing. The same bitrate at 30 fps would only deliver ~23 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 **`text`** profile (1000 kbps × 15 fps ≈ 67 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.
|
- **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.
|
- **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.
|
- **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.
|
||||||
|
|||||||
@@ -80,7 +80,6 @@ sequenceDiagram
|
|||||||
| whisperx_api_key | Secret | — | API key for accessing WhisperX. |
|
| whisperx_api_key | Secret | — | API key for accessing WhisperX. |
|
||||||
| whisperx_base_url | String | `"https://api.whisperx.com/v1"` | Base URL for the WhisperX API. |
|
| whisperx_base_url | String | `"https://api.whisperx.com/v1"` | Base URL for the WhisperX API. |
|
||||||
| whisperx_asr_model | String | `"whisper-1"` | ASR model used for transcription. |
|
| whisperx_asr_model | String | `"whisper-1"` | ASR model used for transcription. |
|
||||||
| whisperx_max_retries | Integer | `0` | Maximum number of retries for WhisperX API requests. |
|
|
||||||
| webhook_max_retries | Integer | `2` | Maximum retries for webhook requests. |
|
| webhook_max_retries | Integer | `2` | Maximum retries for webhook requests. |
|
||||||
| webhook_status_forcelist | List[Int] | `[502, 503, 504]` | HTTP status codes triggering webhook retry. |
|
| webhook_status_forcelist | List[Int] | `[502, 503, 504]` | HTTP status codes triggering webhook retry. |
|
||||||
| webhook_backoff_factor | Float | `0.1` | Exponential backoff factor for webhook retries. |
|
| webhook_backoff_factor | Float | `0.1` | Exponential backoff factor for webhook retries. |
|
||||||
|
|||||||
@@ -244,6 +244,69 @@ meet-admin <none> meet.127.0.0.1.nip.io localhost 80, 44
|
|||||||
|
|
||||||
You can use LaSuite Meet on https://meet.127.0.0.1.nip.io from the local device. The provisioning user in keycloak is meet/meet.
|
You can use LaSuite Meet on https://meet.127.0.0.1.nip.io from the local device. The provisioning user in keycloak is meet/meet.
|
||||||
|
|
||||||
|
## Rebranding the favicon
|
||||||
|
|
||||||
|
The favicon is bundled into the frontend image and served as a set of static
|
||||||
|
files from `/usr/share/nginx/html` (`favicon.ico`, `favicon-16x16.png`,
|
||||||
|
`favicon-32x32.png`, `apple-touch-icon.png`, `android-chrome-192x192.png`,
|
||||||
|
`android-chrome-512x512.png`, `icon.png`). To rebrand without forking and
|
||||||
|
rebuilding the image, overlay your own icons onto those paths with a volume —
|
||||||
|
this serves the right icon from the first byte (no rebuild, no flash) and
|
||||||
|
covers every variant, including the iOS home-screen and Android/PWA icons.
|
||||||
|
|
||||||
|
Put your icons in a `ConfigMap` (`binaryData` keeps the PNGs intact)…
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
apiVersion: v1
|
||||||
|
kind: ConfigMap
|
||||||
|
metadata:
|
||||||
|
name: meet-favicon
|
||||||
|
binaryData:
|
||||||
|
# base64 of each replacement icon
|
||||||
|
favicon.ico: <base64…>
|
||||||
|
favicon-16x16.png: <base64…>
|
||||||
|
favicon-32x32.png: <base64…>
|
||||||
|
apple-touch-icon.png: <base64…>
|
||||||
|
android-chrome-192x192.png: <base64…>
|
||||||
|
android-chrome-512x512.png: <base64…>
|
||||||
|
```
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# e.g. build the ConfigMap straight from a directory of icons
|
||||||
|
$ kubectl create configmap meet-favicon --from-file=./my-icons/
|
||||||
|
```
|
||||||
|
|
||||||
|
…then mount each file over the bundled one via the chart's
|
||||||
|
`frontend.extraVolumes` / `frontend.extraVolumeMounts` (the `subPath` mounts
|
||||||
|
the single file without hiding the rest of `html/`):
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
frontend:
|
||||||
|
extraVolumes:
|
||||||
|
- name: favicon
|
||||||
|
configMap:
|
||||||
|
name: meet-favicon
|
||||||
|
extraVolumeMounts:
|
||||||
|
- name: favicon
|
||||||
|
mountPath: /usr/share/nginx/html/favicon.ico
|
||||||
|
subPath: favicon.ico
|
||||||
|
- name: favicon
|
||||||
|
mountPath: /usr/share/nginx/html/favicon-16x16.png
|
||||||
|
subPath: favicon-16x16.png
|
||||||
|
- name: favicon
|
||||||
|
mountPath: /usr/share/nginx/html/favicon-32x32.png
|
||||||
|
subPath: favicon-32x32.png
|
||||||
|
- name: favicon
|
||||||
|
mountPath: /usr/share/nginx/html/apple-touch-icon.png
|
||||||
|
subPath: apple-touch-icon.png
|
||||||
|
- name: favicon
|
||||||
|
mountPath: /usr/share/nginx/html/android-chrome-192x192.png
|
||||||
|
subPath: android-chrome-192x192.png
|
||||||
|
- name: favicon
|
||||||
|
mountPath: /usr/share/nginx/html/android-chrome-512x512.png
|
||||||
|
subPath: android-chrome-512x512.png
|
||||||
|
```
|
||||||
|
|
||||||
## All options
|
## All options
|
||||||
|
|
||||||
These are the environmental options available on meet backend.
|
These are the environmental options available on meet backend.
|
||||||
@@ -344,7 +407,7 @@ These are the environmental options available on meet backend.
|
|||||||
| RECORDING_WORKER_CLASSES | Worker classes for recording | {"screen_recording": "core.recording.worker.services.VideoCompositeEgressService","transcript": "core.recording.worker.services.AudioCompositeEgressService"} |
|
| RECORDING_WORKER_CLASSES | Worker classes for recording | {"screen_recording": "core.recording.worker.services.VideoCompositeEgressService","transcript": "core.recording.worker.services.AudioCompositeEgressService"} |
|
||||||
| RECORDING_EVENT_PARSER_CLASS | Storage event engine for recording | core.recording.event.parsers.MinioParser |
|
| RECORDING_EVENT_PARSER_CLASS | Storage event engine for recording | core.recording.event.parsers.MinioParser |
|
||||||
| RECORDING_ENABLE_STORAGE_EVENT_AUTH | Enable storage event authorization | true |
|
| RECORDING_ENABLE_STORAGE_EVENT_AUTH | Enable storage event authorization | true |
|
||||||
| RECORDING_STORAGE_EVENT_ENABLE | Enable recording storage events | false |
|
| RECORDING_STORAGE_EVENT_ENABLE | Enable recording storage events. If false, fallback to egress webhook. | false |
|
||||||
| RECORDING_STORAGE_EVENT_TOKEN | Recording storage event token | |
|
| RECORDING_STORAGE_EVENT_TOKEN | Recording storage event token | |
|
||||||
| RECORDING_EXPIRATION_DAYS | Recording expiration in days | |
|
| RECORDING_EXPIRATION_DAYS | Recording expiration in days | |
|
||||||
| RECORDING_MAX_DURATION | Maximum recording duration in milliseconds. Must match LiveKit Egress configuration exactly. | |
|
| RECORDING_MAX_DURATION | Maximum recording duration in milliseconds. Must match LiveKit Egress configuration exactly. | |
|
||||||
|
|||||||
@@ -57,6 +57,7 @@ OIDC_RS_CLIENT_SECRET=ThisIsAnExampleKeyForDevPurposeOnly
|
|||||||
LIVEKIT_API_SECRET=secret
|
LIVEKIT_API_SECRET=secret
|
||||||
LIVEKIT_API_KEY=devkey
|
LIVEKIT_API_KEY=devkey
|
||||||
LIVEKIT_API_URL=http://127.0.0.1.nip.io:7880
|
LIVEKIT_API_URL=http://127.0.0.1.nip.io:7880
|
||||||
|
LIVEKIT_INTERNAL_URL=http://livekit:7880
|
||||||
LIVEKIT_VERIFY_SSL=False
|
LIVEKIT_VERIFY_SSL=False
|
||||||
ALLOW_UNREGISTERED_ROOMS=False
|
ALLOW_UNREGISTERED_ROOMS=False
|
||||||
|
|
||||||
@@ -64,8 +65,9 @@ ALLOW_UNREGISTERED_ROOMS=False
|
|||||||
RECORDING_ENABLE=True
|
RECORDING_ENABLE=True
|
||||||
RECORDING_STORAGE_EVENT_ENABLE=True
|
RECORDING_STORAGE_EVENT_ENABLE=True
|
||||||
RECORDING_STORAGE_EVENT_TOKEN=password
|
RECORDING_STORAGE_EVENT_TOKEN=password
|
||||||
SUMMARY_SERVICE_ENDPOINT=http://app-summary-dev:8000/api/v1/tasks/
|
SUMMARY_SERVICE_ENDPOINT=http://app-summary-dev:8000/api/v2/async-jobs/transcribe/
|
||||||
SUMMARY_SERVICE_API_TOKEN=password
|
SUMMARY_SERVICE_API_TOKEN=password
|
||||||
|
SUMMARY_SERVICE_WEBHOOK_API_TOKEN=webhook-password
|
||||||
RECORDING_DOWNLOAD_BASE_URL=http://localhost:3000/recording
|
RECORDING_DOWNLOAD_BASE_URL=http://localhost:3000/recording
|
||||||
|
|
||||||
# Recording encoding (LiveKit Egress advanced options).
|
# Recording encoding (LiveKit Egress advanced options).
|
||||||
@@ -86,6 +88,9 @@ ROOM_TELEPHONY_ENABLED=True
|
|||||||
# Metadata
|
# Metadata
|
||||||
METADATA_COLLECTOR_ENABLED=True
|
METADATA_COLLECTOR_ENABLED=True
|
||||||
|
|
||||||
|
# Subtitle
|
||||||
|
ROOM_SUBTITLE_ENABLED=False
|
||||||
|
|
||||||
FRONTEND_USE_FRENCH_GOV_FOOTER=False
|
FRONTEND_USE_FRENCH_GOV_FOOTER=False
|
||||||
FRONTEND_USE_PROCONNECT_BUTTON=False
|
FRONTEND_USE_PROCONNECT_BUTTON=False
|
||||||
|
|
||||||
@@ -94,3 +99,4 @@ EXTERNAL_API_ENABLED=True
|
|||||||
APPLICATION_JWT_AUDIENCE=http://localhost:8071/external-api/v1.0/
|
APPLICATION_JWT_AUDIENCE=http://localhost:8071/external-api/v1.0/
|
||||||
APPLICATION_JWT_SECRET_KEY=devKey
|
APPLICATION_JWT_SECRET_KEY=devKey
|
||||||
APPLICATION_BASE_URL=http://localhost:3000
|
APPLICATION_BASE_URL=http://localhost:3000
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
LIVEKIT_URL=ws://livekit:7880
|
||||||
|
LIVEKIT_API_KEY=devkey
|
||||||
|
LIVEKIT_API_SECRET=secret
|
||||||
|
|
||||||
|
AWS_S3_ENDPOINT_URL=minio:9000
|
||||||
|
AWS_S3_ACCESS_KEY_ID=meet
|
||||||
|
AWS_S3_SECRET_ACCESS_KEY=password
|
||||||
|
AWS_STORAGE_BUCKET_NAME=meet-media-storage
|
||||||
|
AWS_S3_SECURE_ACCESS=False
|
||||||
@@ -2,8 +2,13 @@ LIVEKIT_URL=ws://livekit:7880
|
|||||||
LIVEKIT_API_KEY=devkey
|
LIVEKIT_API_KEY=devkey
|
||||||
LIVEKIT_API_SECRET=secret
|
LIVEKIT_API_SECRET=secret
|
||||||
|
|
||||||
STT_PROVIDER=kyutai
|
STT_PROVIDER=kyutai # kyutai, deepgram
|
||||||
ENABLE_SILERO_VAD=False
|
ENABLE_SILERO_VAD=False
|
||||||
|
|
||||||
|
DEEPGRAM_API_KEY=
|
||||||
|
|
||||||
KYUTAI_STT_BASE_URL=
|
KYUTAI_STT_BASE_URL=
|
||||||
KYUTAI_API_KEY=
|
KYUTAI_API_KEY=
|
||||||
|
|
||||||
|
SENTRY_DSN=
|
||||||
|
SENTRY_ENVIRONMENT=
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
APP_NAME="meet-app-summary-dev"
|
APP_NAME="meet-app-summary-dev"
|
||||||
APP_API_TOKEN="password"
|
APP_API_TOKEN="password"
|
||||||
|
|
||||||
AWS_STORAGE_BUCKET_NAME="http://meet-media-storage"
|
AWS_STORAGE_BUCKET_NAME="meet-media-storage"
|
||||||
AWS_S3_ENDPOINT_URL="minio:9000"
|
AWS_S3_ENDPOINT_URL="minio:9000"
|
||||||
AWS_S3_SECURE_ACCESS=false
|
AWS_S3_SECURE_ACCESS=false
|
||||||
|
|
||||||
@@ -20,8 +20,14 @@ LLM_MODEL="albert-large"
|
|||||||
WEBHOOK_API_TOKEN="secret"
|
WEBHOOK_API_TOKEN="secret"
|
||||||
WEBHOOK_URL="https://configure-your-url.com"
|
WEBHOOK_URL="https://configure-your-url.com"
|
||||||
|
|
||||||
|
IS_RESOLVE_SPEAKER_IDENTITIES_ENABLED=true
|
||||||
|
RESOLVE_SPEAKER_IDENTITIES_DEFAULT_OVERLAP=0.5
|
||||||
|
RESOLVE_SPEAKER_ENABLE_SPLIT_ON_WORDS=true
|
||||||
|
RESOLVE_SPEAKER_MAX_WORD_DURATION=1
|
||||||
|
|
||||||
POSTHOG_API_KEY="your-posthog-key"
|
POSTHOG_API_KEY="your-posthog-key"
|
||||||
POSTHOG_ENABLED="False"
|
POSTHOG_ENABLED="False"
|
||||||
|
|
||||||
# Transcription
|
# Transcription
|
||||||
TRANSCRIPTION_SATISFACTION_FORM_BASE_URL=
|
TRANSCRIPTION_SATISFACTION_FORM_BASE_URL=
|
||||||
|
AUTHORIZED_TENANTS='[{"id": "meet","api_key": "password","webhook_url": "https://configure-your-url.com/api/v1.0/recordings/external-process-hook/","webhook_api_key": "webhook-password","allowed_push_to_docs": true}]'
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
FROM python:3.13.13-slim AS base
|
FROM python:3.14.6-slim AS base
|
||||||
|
|
||||||
# Install system dependencies required by LiveKit
|
# Install system dependencies required by LiveKit
|
||||||
RUN apt-get update && apt-get install -y \
|
RUN apt-get update && apt-get install -y \
|
||||||
|
|||||||
@@ -32,6 +32,8 @@ from minio import Minio
|
|||||||
from minio.error import S3Error
|
from minio.error import S3Error
|
||||||
|
|
||||||
from exceptions import MissingConfigError
|
from exceptions import MissingConfigError
|
||||||
|
from observability import configure_sentry, set_job_context
|
||||||
|
from tasks import done_callback
|
||||||
|
|
||||||
load_dotenv()
|
load_dotenv()
|
||||||
|
|
||||||
@@ -42,6 +44,7 @@ AGENT_NAME = os.getenv("METADATA_COLLECTOR_AGENT_NAME", "metadata-collector")
|
|||||||
|
|
||||||
def prewarm(proc: JobProcess):
|
def prewarm(proc: JobProcess):
|
||||||
"""Preload voice activity detection model."""
|
"""Preload voice activity detection model."""
|
||||||
|
configure_sentry(AGENT_NAME)
|
||||||
proc.userdata["vad"] = silero.VAD.load()
|
proc.userdata["vad"] = silero.VAD.load()
|
||||||
|
|
||||||
|
|
||||||
@@ -174,7 +177,13 @@ class MetadataCollector:
|
|||||||
self.on_chat_message_received(reader, participant_identity)
|
self.on_chat_message_received(reader, participant_identity)
|
||||||
)
|
)
|
||||||
self._tasks.add(task)
|
self._tasks.add(task)
|
||||||
task.add_done_callback(lambda _: self._tasks.remove(task))
|
task.add_done_callback(
|
||||||
|
done_callback(
|
||||||
|
logger,
|
||||||
|
self._tasks,
|
||||||
|
f"process chat stream from {participant_identity}",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
def save(self):
|
def save(self):
|
||||||
"""Serialize collected events and upload as JSON to S3."""
|
"""Serialize collected events and upload as JSON to S3."""
|
||||||
@@ -270,16 +279,18 @@ class MetadataCollector:
|
|||||||
logger.info("Participant disconnected: %s", participant.identity)
|
logger.info("Participant disconnected: %s", participant.identity)
|
||||||
task = asyncio.create_task(self._close_session(session))
|
task = asyncio.create_task(self._close_session(session))
|
||||||
self._tasks.add(task)
|
self._tasks.add(task)
|
||||||
|
task.add_done_callback(
|
||||||
def on_close_done(_):
|
done_callback(
|
||||||
self._tasks.discard(task)
|
logger,
|
||||||
logger.info(
|
self._tasks,
|
||||||
"VAD session closed for %s (remaining sessions: %d)",
|
f"close VAD session for {participant.identity}",
|
||||||
participant.identity,
|
on_success=lambda _: logger.info(
|
||||||
len(self._sessions),
|
"VAD session closed for %s (remaining sessions: %d)",
|
||||||
|
participant.identity,
|
||||||
|
len(self._sessions),
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
)
|
||||||
task.add_done_callback(on_close_done)
|
|
||||||
|
|
||||||
def on_participant_name_changed(self, participant: rtc.RemoteParticipant):
|
def on_participant_name_changed(self, participant: rtc.RemoteParticipant):
|
||||||
"""Update stored participant name when it changes."""
|
"""Update stored participant name when it changes."""
|
||||||
@@ -360,6 +371,8 @@ async def handle_job_request(job_req: JobRequest) -> None:
|
|||||||
@server.rtc_session(agent_name=AGENT_NAME, on_request=handle_job_request)
|
@server.rtc_session(agent_name=AGENT_NAME, on_request=handle_job_request)
|
||||||
async def entrypoint(ctx: JobContext):
|
async def entrypoint(ctx: JobContext):
|
||||||
"""Initialize and run the metadata collector."""
|
"""Initialize and run the metadata collector."""
|
||||||
|
set_job_context(room=ctx.room.name, job_id=ctx.job.id)
|
||||||
|
|
||||||
logger.info("Starting metadata agent in room: %s", ctx.room.name)
|
logger.info("Starting metadata agent in room: %s", ctx.room.name)
|
||||||
recording_id = ctx.job.metadata
|
recording_id = ctx.job.metadata
|
||||||
metadata_collector = MetadataCollector(ctx, recording_id)
|
metadata_collector = MetadataCollector(ctx, recording_id)
|
||||||
@@ -377,4 +390,7 @@ async def entrypoint(ctx: JobContext):
|
|||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
# Initialize Sentry for the worker process. Each job runs in its own
|
||||||
|
# (forked) process and re-initializes Sentry via prewarm().
|
||||||
|
configure_sentry(AGENT_NAME)
|
||||||
cli.run_app(server)
|
cli.run_app(server)
|
||||||
|
|||||||
@@ -25,6 +25,9 @@ from livekit.agents import (
|
|||||||
)
|
)
|
||||||
from livekit.plugins import deepgram, silero
|
from livekit.plugins import deepgram, silero
|
||||||
|
|
||||||
|
from observability import configure_sentry, set_job_context
|
||||||
|
from tasks import done_callback
|
||||||
|
|
||||||
load_dotenv()
|
load_dotenv()
|
||||||
|
|
||||||
logger = logging.getLogger("transcriber")
|
logger = logging.getLogger("transcriber")
|
||||||
@@ -99,24 +102,29 @@ class MultiUserTranscriber:
|
|||||||
logger.info(f"starting session for {participant.identity}")
|
logger.info(f"starting session for {participant.identity}")
|
||||||
task = asyncio.create_task(self._start_session(participant))
|
task = asyncio.create_task(self._start_session(participant))
|
||||||
self._tasks.add(task)
|
self._tasks.add(task)
|
||||||
|
task.add_done_callback(
|
||||||
def on_task_done(task: asyncio.Task):
|
done_callback(
|
||||||
try:
|
logger,
|
||||||
self._sessions[participant.identity] = task.result()
|
self._tasks,
|
||||||
finally:
|
f"start transcription session for {participant.identity}",
|
||||||
self._tasks.discard(task)
|
)
|
||||||
|
)
|
||||||
task.add_done_callback(on_task_done)
|
|
||||||
|
|
||||||
def on_participant_disconnected(self, participant: rtc.RemoteParticipant):
|
def on_participant_disconnected(self, participant: rtc.RemoteParticipant):
|
||||||
"""Handle participant disconnection by closing transcription session."""
|
"""Handle participant disconnection by closing transcription session."""
|
||||||
if (session := self._sessions.pop(participant.identity)) is None:
|
if (session := self._sessions.pop(participant.identity, None)) is None:
|
||||||
return
|
return
|
||||||
|
|
||||||
logger.info(f"closing session for {participant.identity}")
|
logger.info(f"closing session for {participant.identity}")
|
||||||
task = asyncio.create_task(self._close_session(session))
|
task = asyncio.create_task(self._close_session(session))
|
||||||
self._tasks.add(task)
|
self._tasks.add(task)
|
||||||
task.add_done_callback(lambda _: self._tasks.discard(task))
|
task.add_done_callback(
|
||||||
|
done_callback(
|
||||||
|
logger,
|
||||||
|
self._tasks,
|
||||||
|
f"close transcription session for {participant.identity}",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
async def _start_session(self, participant: rtc.RemoteParticipant) -> AgentSession:
|
async def _start_session(self, participant: rtc.RemoteParticipant) -> AgentSession:
|
||||||
"""Create and start transcription session for participant."""
|
"""Create and start transcription session for participant."""
|
||||||
@@ -139,6 +147,7 @@ class MultiUserTranscriber:
|
|||||||
participant_identity=participant.identity,
|
participant_identity=participant.identity,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
self._sessions[participant.identity] = session
|
||||||
return session
|
return session
|
||||||
|
|
||||||
async def _close_session(self, sess: AgentSession) -> None:
|
async def _close_session(self, sess: AgentSession) -> None:
|
||||||
@@ -149,6 +158,8 @@ class MultiUserTranscriber:
|
|||||||
|
|
||||||
async def entrypoint(ctx: JobContext):
|
async def entrypoint(ctx: JobContext):
|
||||||
"""Initialize and run the multi-user transcriber."""
|
"""Initialize and run the multi-user transcriber."""
|
||||||
|
set_job_context(room=ctx.room.name, job_id=ctx.job.id)
|
||||||
|
|
||||||
transcriber = MultiUserTranscriber(ctx)
|
transcriber = MultiUserTranscriber(ctx)
|
||||||
transcriber.start()
|
transcriber.start()
|
||||||
|
|
||||||
@@ -193,11 +204,15 @@ async def handle_transcriber_job_request(job_req: JobRequest) -> None:
|
|||||||
|
|
||||||
def prewarm(proc: JobProcess):
|
def prewarm(proc: JobProcess):
|
||||||
"""Preload voice activity detection model."""
|
"""Preload voice activity detection model."""
|
||||||
|
configure_sentry(TRANSCRIBER_AGENT_NAME)
|
||||||
if ENABLE_SILERO_VAD:
|
if ENABLE_SILERO_VAD:
|
||||||
proc.userdata["vad"] = silero.VAD.load()
|
proc.userdata["vad"] = silero.VAD.load()
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
# Initialize Sentry for the worker process. Each job runs in its own
|
||||||
|
# (forked) process and re-initializes Sentry via prewarm().
|
||||||
|
configure_sentry(TRANSCRIBER_AGENT_NAME)
|
||||||
cli.run_app(
|
cli.run_app(
|
||||||
WorkerOptions(
|
WorkerOptions(
|
||||||
entrypoint_fnc=entrypoint,
|
entrypoint_fnc=entrypoint,
|
||||||
|
|||||||
@@ -0,0 +1,83 @@
|
|||||||
|
"""Sentry helpers for the LiveKit agents."""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import tomllib
|
||||||
|
from os import path
|
||||||
|
|
||||||
|
import sentry_sdk
|
||||||
|
from sentry_sdk.integrations.logging import LoggingIntegration
|
||||||
|
|
||||||
|
logger = logging.getLogger("observability")
|
||||||
|
|
||||||
|
BASE_DIR = path.dirname(path.abspath(__file__))
|
||||||
|
|
||||||
|
|
||||||
|
def get_release():
|
||||||
|
"""Get the current release of the application.
|
||||||
|
|
||||||
|
By release, we mean the ``version`` declared in ``pyproject.toml``.
|
||||||
|
If the file cannot be read or declares no version, it defaults to "NA".
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
with open(path.join(BASE_DIR, "pyproject.toml"), "rb") as pyproject:
|
||||||
|
return tomllib.load(pyproject)["project"]["version"]
|
||||||
|
except (FileNotFoundError, KeyError, tomllib.TOMLDecodeError):
|
||||||
|
return "NA" # Default: not available
|
||||||
|
|
||||||
|
|
||||||
|
def configure_sentry(agent_name: str) -> None:
|
||||||
|
"""Initialize Sentry for the current agent process.
|
||||||
|
|
||||||
|
No-op if ``SENTRY_DSN`` is not configured. Otherwise (re)initializes Sentry
|
||||||
|
unconditionally so the calling process gets its own live transport.
|
||||||
|
|
||||||
|
Must be called once per process: in the worker entrypoint and again in the
|
||||||
|
per-job ``prewarm``/``setup_fnc`` hook, because LiveKit runs each job in a
|
||||||
|
forked process. A forked child inherits the parent's initialized Sentry
|
||||||
|
client but not its background transport thread (threads do not survive
|
||||||
|
``fork()``), so it must re-init to get a working transport. For that reason,
|
||||||
|
do NOT guard this with ``sentry_sdk.is_initialized()``: the child inherits it
|
||||||
|
as ``True`` and would skip init, silently dropping every event.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
agent_name: Identifier of the agent, attached as a tag to Sentry issues
|
||||||
|
"""
|
||||||
|
# Read the DSN at call time so it picks up variables that load_dotenv()
|
||||||
|
# populated after this module was first imported.
|
||||||
|
sentry_dsn = os.getenv("SENTRY_DSN")
|
||||||
|
if not sentry_dsn:
|
||||||
|
logger.debug("SENTRY_DSN not defined for agent '%s'", agent_name)
|
||||||
|
return
|
||||||
|
|
||||||
|
sentry_sdk.init(
|
||||||
|
dsn=sentry_dsn,
|
||||||
|
environment=os.getenv("SENTRY_ENVIRONMENT"),
|
||||||
|
release=get_release(),
|
||||||
|
debug=False,
|
||||||
|
integrations=[
|
||||||
|
# Capture log records emitted at ERROR and above as Sentry events.
|
||||||
|
# This covers the agents' explicit logger.exception(...) calls as
|
||||||
|
# well as asyncio's "Exception in callback" / "Task exception was
|
||||||
|
# never retrieved" records, so unhandled task failures surface too.
|
||||||
|
LoggingIntegration(level=logging.INFO, event_level=logging.ERROR),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
sentry_sdk.set_tag("application", "agents")
|
||||||
|
sentry_sdk.set_tag("agent", agent_name)
|
||||||
|
|
||||||
|
logger.info("Sentry initialized for agent '%s' (pid %d)", agent_name, os.getpid())
|
||||||
|
|
||||||
|
|
||||||
|
def set_job_context(*, room: str | None = None, job_id: str | None = None) -> None:
|
||||||
|
"""Tag the current Sentry scope with the LiveKit job being handled.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
room: Name of the room the job is serving.
|
||||||
|
job_id: LiveKit job identifier.
|
||||||
|
"""
|
||||||
|
scope = sentry_sdk.get_current_scope()
|
||||||
|
if room is not None:
|
||||||
|
scope.set_tag("room", room)
|
||||||
|
if job_id is not None:
|
||||||
|
scope.set_tag("job_id", job_id)
|
||||||
@@ -1,21 +1,22 @@
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "agents"
|
name = "agents"
|
||||||
version = "1.21.0"
|
version = "1.23.0"
|
||||||
requires-python = ">=3.12"
|
requires-python = ">=3.12"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"livekit-agents==1.5.13",
|
"livekit-agents==1.6.4",
|
||||||
"livekit-plugins-deepgram==1.5.13",
|
"livekit-plugins-deepgram==1.6.4",
|
||||||
"livekit-plugins-silero==1.5.13",
|
"livekit-plugins-silero==1.6.4",
|
||||||
"livekit-plugins-kyutai-lasuite==0.0.6",
|
"livekit-plugins-kyutai-lasuite==0.0.6",
|
||||||
"python-dotenv==1.2.2",
|
"python-dotenv==1.2.2",
|
||||||
"protobuf==6.33.6",
|
"protobuf==6.33.6",
|
||||||
"minio==7.2.20"
|
"minio==7.2.20",
|
||||||
|
"sentry-sdk==2.60.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
[project.optional-dependencies]
|
[project.optional-dependencies]
|
||||||
dev = [
|
dev = [
|
||||||
"ruff==0.15.14",
|
"ruff==0.15.19",
|
||||||
]
|
]
|
||||||
|
|
||||||
[tool.uv]
|
[tool.uv]
|
||||||
|
|||||||
@@ -0,0 +1,42 @@
|
|||||||
|
"""Helpers for managing asyncio tasks."""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
from collections.abc import Callable
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
|
||||||
|
def done_callback(
|
||||||
|
logger: logging.Logger,
|
||||||
|
tasks: set[asyncio.Task],
|
||||||
|
description: str,
|
||||||
|
*,
|
||||||
|
on_success: Callable[[Any], None] | None = None,
|
||||||
|
) -> Callable[[asyncio.Task], None]:
|
||||||
|
"""Build a done-callback for a background task.
|
||||||
|
|
||||||
|
Meant to be passed to `asyncio.Task.add_done_callback`.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
logger: Logger used to report failures, so records keep the caller's
|
||||||
|
logger name.
|
||||||
|
tasks: Set the task was registered in; the task is discarded from it.
|
||||||
|
description: Human-readable intended action
|
||||||
|
on_success: Optional callback invoked with the task's result when it
|
||||||
|
completes without error.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
A callback suitable for ``task.add_done_callback(...)``.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def _finalize(task: asyncio.Task) -> None:
|
||||||
|
tasks.discard(task)
|
||||||
|
if task.cancelled():
|
||||||
|
return
|
||||||
|
if (exc := task.exception()) is not None:
|
||||||
|
logger.exception("failed to %s", description, exc_info=exc)
|
||||||
|
return
|
||||||
|
if on_success is not None:
|
||||||
|
on_success(task.result())
|
||||||
|
|
||||||
|
return _finalize
|
||||||
Generated
+936
-832
File diff suppressed because it is too large
Load Diff
@@ -11,7 +11,7 @@ from core.recording.event import notification
|
|||||||
|
|
||||||
from . import models
|
from . import models
|
||||||
from .tasks.file import process_file_deletion
|
from .tasks.file import process_file_deletion
|
||||||
from .utils import generate_download_file_url
|
from .utils import generate_download_s3_url
|
||||||
|
|
||||||
|
|
||||||
def hard_delete_file(file):
|
def hard_delete_file(file):
|
||||||
@@ -242,7 +242,7 @@ class FileAdmin(admin.ModelAdmin):
|
|||||||
"""Return a clickable preview URL for the file."""
|
"""Return a clickable preview URL for the file."""
|
||||||
if not obj.is_ready:
|
if not obj.is_ready:
|
||||||
return "-"
|
return "-"
|
||||||
url = generate_download_file_url(obj, expires_in=60 * 60)
|
url = generate_download_s3_url(obj.key, expires_in=60 * 60)
|
||||||
|
|
||||||
return format_html(
|
return format_html(
|
||||||
'<a href="{}" target="_blank" rel="noopener noreferrer">Open File</a>', url
|
'<a href="{}" target="_blank" rel="noopener noreferrer">Open File</a>', url
|
||||||
@@ -402,7 +402,14 @@ class RecordingAdmin(admin.ModelAdmin):
|
|||||||
"""Recording admin interface declaration."""
|
"""Recording admin interface declaration."""
|
||||||
|
|
||||||
inlines = (RecordingAccessInline,)
|
inlines = (RecordingAccessInline,)
|
||||||
search_fields = ["status", "=id", "worker_id", "room__slug", "=room__id"]
|
search_fields = [
|
||||||
|
"status",
|
||||||
|
"=id",
|
||||||
|
"worker_id",
|
||||||
|
"room__slug",
|
||||||
|
"=room__id",
|
||||||
|
"accesses__user__email",
|
||||||
|
]
|
||||||
list_display = (
|
list_display = (
|
||||||
"id",
|
"id",
|
||||||
"status",
|
"status",
|
||||||
|
|||||||
@@ -0,0 +1,67 @@
|
|||||||
|
"""
|
||||||
|
Pluggable analytics.
|
||||||
|
|
||||||
|
Usage anywhere in the codebase:
|
||||||
|
|
||||||
|
from core import analytics
|
||||||
|
|
||||||
|
analytics.capture(request.user, "room_created", {"room_id": str(room.pk)})
|
||||||
|
|
||||||
|
The concrete backend is resolved lazily from Django settings, so swapping
|
||||||
|
PostHog for anything else is a configuration change, not a code change.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from functools import lru_cache
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from django.conf import settings
|
||||||
|
from django.utils.module_loading import import_string
|
||||||
|
|
||||||
|
from .base import AnalyticsBackend, NoOpAnalytics
|
||||||
|
from .events import AnalyticsEvent
|
||||||
|
from .user_feature_flags import UserFeatureFlag
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"get_analytics",
|
||||||
|
"identify",
|
||||||
|
"capture",
|
||||||
|
"AnalyticsBackend",
|
||||||
|
"AnalyticsEvent",
|
||||||
|
"is_user_feature_flag_enabled",
|
||||||
|
"UserFeatureFlag",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@lru_cache(maxsize=1)
|
||||||
|
def get_analytics() -> AnalyticsBackend:
|
||||||
|
"""Instantiate the configured backend once per process."""
|
||||||
|
dotted_path = getattr(settings, "ANALYTICS_BACKEND", None)
|
||||||
|
options = getattr(settings, "ANALYTICS_BACKEND_SETTINGS", {}) or {}
|
||||||
|
|
||||||
|
if not dotted_path:
|
||||||
|
return NoOpAnalytics()
|
||||||
|
|
||||||
|
backend_class = import_string(dotted_path)
|
||||||
|
return backend_class(**options)
|
||||||
|
|
||||||
|
|
||||||
|
# Convenience module-level shortcuts
|
||||||
|
|
||||||
|
analytics_instance = get_analytics()
|
||||||
|
|
||||||
|
|
||||||
|
def identify(user, properties: dict[str, Any] | None = None) -> None:
|
||||||
|
"""Associate traits with an identified user."""
|
||||||
|
analytics_instance.identify(user, properties)
|
||||||
|
|
||||||
|
|
||||||
|
def capture(
|
||||||
|
user, event: AnalyticsEvent, properties: dict[str, Any] | None = None
|
||||||
|
) -> None:
|
||||||
|
"""Record an event performed by an identified user."""
|
||||||
|
analytics_instance.capture(user, event, properties)
|
||||||
|
|
||||||
|
|
||||||
|
def is_user_feature_flag_enabled(user, feature_name: UserFeatureFlag) -> bool:
|
||||||
|
"""Check if a feature is enabled at the user level."""
|
||||||
|
return analytics_instance.is_user_feature_enabled(user, feature_name)
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
"""Analytics backend protocol and default no-op implementation."""
|
||||||
|
|
||||||
|
from abc import ABC, abstractmethod
|
||||||
|
from typing import Any, Mapping
|
||||||
|
|
||||||
|
from ..models import User
|
||||||
|
from .events import AnalyticsEvent
|
||||||
|
from .user_feature_flags import UserFeatureFlag
|
||||||
|
|
||||||
|
|
||||||
|
class AnalyticsBackend(ABC):
|
||||||
|
"""
|
||||||
|
Interface every analytics backend must implement.
|
||||||
|
|
||||||
|
Backends are instantiated once (singleton) with the kwargs declared in
|
||||||
|
settings.ANALYTICS_BACKEND_SETTINGS, e.g.:
|
||||||
|
|
||||||
|
ANALYTICS_BACKEND = "core.analytics.posthog.PostHogAnalytics"
|
||||||
|
ANALYTICS_BACKEND_SETTINGS = {"api_key": "...", "host": "..."}
|
||||||
|
"""
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def identify(self, user: User, properties: dict[str, Any] | None = None) -> None:
|
||||||
|
"""Associate traits (email, name, ...) with an identified user."""
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def capture(
|
||||||
|
self,
|
||||||
|
user: User,
|
||||||
|
event: AnalyticsEvent,
|
||||||
|
properties: dict[str, Any] | None = None,
|
||||||
|
) -> None:
|
||||||
|
"""Record an event performed by an identified user."""
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def shutdown(self) -> None:
|
||||||
|
"""Flush pending events. Called on process exit."""
|
||||||
|
|
||||||
|
def get_user_feature_flags(
|
||||||
|
self,
|
||||||
|
user: User, # pylint: disable=unused-argument
|
||||||
|
) -> Mapping[UserFeatureFlag, bool | str | None]:
|
||||||
|
"""Return a dict of feature flags for the given user."""
|
||||||
|
# We return an empty dict here by default to avoid a breaking change
|
||||||
|
# By making this method abstract.
|
||||||
|
return {}
|
||||||
|
|
||||||
|
def is_user_feature_enabled(
|
||||||
|
self, user: User, feature_name: UserFeatureFlag
|
||||||
|
) -> bool:
|
||||||
|
"""Check if a feature is enabled at the user level."""
|
||||||
|
return self.get_user_feature_flags(user).get(feature_name, False) is True
|
||||||
|
|
||||||
|
|
||||||
|
class NoOpAnalytics(AnalyticsBackend):
|
||||||
|
"""Default backend: silently discards everything."""
|
||||||
|
|
||||||
|
def identify(self, user: User, properties=None) -> None:
|
||||||
|
"""No-op: discards identify calls."""
|
||||||
|
|
||||||
|
def capture(self, user, event, properties=None) -> None:
|
||||||
|
"""No-op: discards captured events."""
|
||||||
|
|
||||||
|
def shutdown(self) -> None:
|
||||||
|
"""No-op: nothing to flush."""
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
"""Catalog of all analytics events emitted by the backend."""
|
||||||
|
|
||||||
|
from enum import StrEnum
|
||||||
|
|
||||||
|
|
||||||
|
class AnalyticsEvent(StrEnum):
|
||||||
|
"""All trackable events. Values are the wire names sent to the provider."""
|
||||||
|
|
||||||
|
# Rooms
|
||||||
|
ROOM_CREATED = "room_created"
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
"""PostHog implementation of the analytics backend protocol."""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from typing import Any, Mapping
|
||||||
|
|
||||||
|
from django.core.cache import cache
|
||||||
|
|
||||||
|
from posthog import Posthog
|
||||||
|
|
||||||
|
from ..models import User
|
||||||
|
from .base import AnalyticsBackend
|
||||||
|
from .events import AnalyticsEvent
|
||||||
|
from .user_feature_flags import UserFeatureFlag
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class PostHogAnalytics(AnalyticsBackend):
|
||||||
|
"""Send events to PostHog, keyed on the user's primary key (UUID)."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
api_key: str,
|
||||||
|
host: str = "https://eu.i.posthog.com",
|
||||||
|
feature_flags_cache_ttl: int = 60,
|
||||||
|
feature_flags_cache_prefix: str = "user_feature_flags:",
|
||||||
|
**kwargs: Any,
|
||||||
|
) -> None:
|
||||||
|
|
||||||
|
# The SDK batches and sends in a background thread by default,
|
||||||
|
# so calls below never block the request/response cycle.
|
||||||
|
self._client = Posthog(
|
||||||
|
project_api_key=api_key,
|
||||||
|
host=host,
|
||||||
|
**kwargs,
|
||||||
|
)
|
||||||
|
self._feature_flags_cache_ttl = feature_flags_cache_ttl
|
||||||
|
self._feature_flags_cache_prefix = feature_flags_cache_prefix
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _distinct_id(user: User) -> str | None:
|
||||||
|
"""Return the PostHog distinct_id for a user, or None if anonymous."""
|
||||||
|
if user is None or not getattr(user, "is_authenticated", False):
|
||||||
|
return None
|
||||||
|
return str(user.pk)
|
||||||
|
|
||||||
|
def identify(self, user: User, properties: dict[str, Any] | None = None) -> None:
|
||||||
|
"""Associate traits (email, name, ...) with an identified user."""
|
||||||
|
distinct_id = self._distinct_id(user)
|
||||||
|
if distinct_id is None:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
self._client.set(
|
||||||
|
distinct_id=distinct_id,
|
||||||
|
properties=properties or {},
|
||||||
|
)
|
||||||
|
except Exception: # pylint: disable=broad-exception-caught
|
||||||
|
logger.exception("PostHog identify failed")
|
||||||
|
|
||||||
|
def capture(
|
||||||
|
self,
|
||||||
|
user: User,
|
||||||
|
event: AnalyticsEvent,
|
||||||
|
properties: dict[str, Any] | None = None,
|
||||||
|
) -> None:
|
||||||
|
"""Record an event performed by an identified user."""
|
||||||
|
distinct_id = self._distinct_id(user)
|
||||||
|
if distinct_id is None:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
self._client.capture(
|
||||||
|
distinct_id=distinct_id,
|
||||||
|
event=str(event),
|
||||||
|
properties=properties or {},
|
||||||
|
)
|
||||||
|
except Exception: # pylint: disable=broad-exception-caught
|
||||||
|
logger.exception("PostHog capture failed for event %s", event)
|
||||||
|
|
||||||
|
def shutdown(self) -> None:
|
||||||
|
"""Flush pending events. Called on process exit."""
|
||||||
|
self._client.shutdown()
|
||||||
|
|
||||||
|
def _fetch_user_feature_flags(
|
||||||
|
self, user: User
|
||||||
|
) -> Mapping[UserFeatureFlag, bool | str | None]:
|
||||||
|
"""Compute feature flags for a user."""
|
||||||
|
|
||||||
|
distinct_id = self._distinct_id(user)
|
||||||
|
if distinct_id is None:
|
||||||
|
return {}
|
||||||
|
|
||||||
|
flags = self._client.evaluate_flags(distinct_id)
|
||||||
|
out: dict[UserFeatureFlag, bool | str | None] = {}
|
||||||
|
for flag_key in UserFeatureFlag:
|
||||||
|
out[flag_key] = flags.get_flag(flag_key.value)
|
||||||
|
|
||||||
|
return out
|
||||||
|
|
||||||
|
def get_user_feature_flags(
|
||||||
|
self, user: User
|
||||||
|
) -> Mapping[UserFeatureFlag, bool | str | None]:
|
||||||
|
"""Get feature flags for a user. Caches the result for a short time."""
|
||||||
|
distinct_id = self._distinct_id(user)
|
||||||
|
if distinct_id is None:
|
||||||
|
return {}
|
||||||
|
|
||||||
|
try:
|
||||||
|
return cache.get_or_set(
|
||||||
|
f"{self._feature_flags_cache_prefix}{distinct_id}",
|
||||||
|
default=lambda: self._fetch_user_feature_flags(user),
|
||||||
|
timeout=self._feature_flags_cache_ttl,
|
||||||
|
)
|
||||||
|
except Exception: # pylint: disable=broad-exception-caught
|
||||||
|
logger.exception("Failed to get feature flags for user %s", user.pk)
|
||||||
|
return {}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
"""Catalog of all analytics feature flags used by the backend."""
|
||||||
|
|
||||||
|
from enum import StrEnum
|
||||||
|
|
||||||
|
|
||||||
|
class UserFeatureFlag(StrEnum):
|
||||||
|
"""All feature flags configured in the app."""
|
||||||
|
|
||||||
|
TRANSCRIPT_SUMMARY_ENABLED = "summary-enabled"
|
||||||
@@ -68,6 +68,9 @@ def get_frontend_configuration(request):
|
|||||||
"enable_firefox_proxy_workaround": settings.LIVEKIT_ENABLE_FIREFOX_PROXY_WORKAROUND,
|
"enable_firefox_proxy_workaround": settings.LIVEKIT_ENABLE_FIREFOX_PROXY_WORKAROUND,
|
||||||
"default_sources": settings.LIVEKIT_DEFAULT_SOURCES,
|
"default_sources": settings.LIVEKIT_DEFAULT_SOURCES,
|
||||||
},
|
},
|
||||||
|
"authenticated_users_can_edit_display_name": (
|
||||||
|
settings.AUTHENTICATED_PARTICIPANTS_CAN_EDIT_DISPLAY_NAME
|
||||||
|
),
|
||||||
}
|
}
|
||||||
frontend_configuration.update(settings.FRONTEND_CONFIGURATION)
|
frontend_configuration.update(settings.FRONTEND_CONFIGURATION)
|
||||||
return Response(frontend_configuration)
|
return Response(frontend_configuration)
|
||||||
|
|||||||
@@ -13,7 +13,12 @@ from django.core.exceptions import SuspiciousOperation
|
|||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
|
|
||||||
from django_pydantic_field.rest_framework import SchemaField
|
from django_pydantic_field.rest_framework import SchemaField
|
||||||
from pydantic import BaseModel, Field, field_serializer
|
from pydantic import (
|
||||||
|
BaseModel,
|
||||||
|
Field,
|
||||||
|
field_serializer,
|
||||||
|
field_validator,
|
||||||
|
)
|
||||||
from pydantic import ValidationError as PydanticValidationError
|
from pydantic import ValidationError as PydanticValidationError
|
||||||
from rest_framework import serializers
|
from rest_framework import serializers
|
||||||
from rest_framework.exceptions import PermissionDenied
|
from rest_framework.exceptions import PermissionDenied
|
||||||
@@ -227,6 +232,49 @@ class BaseValidationOnlySerializer(serializers.Serializer):
|
|||||||
raise NotImplementedError(f"{self.__class__.__name__} is validation-only")
|
raise NotImplementedError(f"{self.__class__.__name__} is validation-only")
|
||||||
|
|
||||||
|
|
||||||
|
class EncodingConfig(BaseModel):
|
||||||
|
"""Configuration options for recording encoding.
|
||||||
|
|
||||||
|
The allowed `resolution` and `profile` values are derived at validation time
|
||||||
|
from ``settings.RECORDING_ENCODING_AVAILABLE_RESOLUTIONS`` and
|
||||||
|
``settings.RECORDING_ENCODING_AVAILABLE_PROFILES``, so adding a resolution or profile
|
||||||
|
to those maps is enough to make it accepted here.
|
||||||
|
|
||||||
|
Attributes:
|
||||||
|
resolution: Target video resolution.
|
||||||
|
profile: Encoding profile to balance quality and CPU usage. When `None`,
|
||||||
|
LiveKit default framerate/bitrate are used for the resolution.
|
||||||
|
"""
|
||||||
|
|
||||||
|
resolution: str
|
||||||
|
profile: str | None = None
|
||||||
|
model_config = {"extra": "forbid"}
|
||||||
|
|
||||||
|
@field_validator("resolution")
|
||||||
|
@classmethod
|
||||||
|
def _validate_resolution(cls, value):
|
||||||
|
"""Reject resolutions absent from RECORDING_ENCODING_AVAILABLE_RESOLUTIONS."""
|
||||||
|
allowed = set(settings.RECORDING_ENCODING_AVAILABLE_RESOLUTIONS)
|
||||||
|
if value not in allowed:
|
||||||
|
raise ValueError(
|
||||||
|
f"Invalid resolution '{value}'. Choose from {sorted(allowed)}."
|
||||||
|
)
|
||||||
|
return value
|
||||||
|
|
||||||
|
@field_validator("profile")
|
||||||
|
@classmethod
|
||||||
|
def _validate_profile(cls, value):
|
||||||
|
"""Reject profiles absent from RECORDING_ENCODING_AVAILABLE_PROFILES."""
|
||||||
|
if value is None:
|
||||||
|
return None
|
||||||
|
allowed = set(settings.RECORDING_ENCODING_AVAILABLE_PROFILES)
|
||||||
|
if value not in allowed:
|
||||||
|
raise ValueError(
|
||||||
|
f"Invalid profile '{value}'. Choose from {sorted(allowed)}."
|
||||||
|
)
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
class RecordingOptions(BaseModel):
|
class RecordingOptions(BaseModel):
|
||||||
"""Configuration options for recording.
|
"""Configuration options for recording.
|
||||||
|
|
||||||
@@ -247,7 +295,7 @@ class RecordingOptions(BaseModel):
|
|||||||
transcribe: bool | None = None
|
transcribe: bool | None = None
|
||||||
collect_metadata: bool | None = None
|
collect_metadata: bool | None = None
|
||||||
original_mode: Literal["screen_recording", "transcript"] | None = None
|
original_mode: Literal["screen_recording", "transcript"] | None = None
|
||||||
|
encoding: EncodingConfig | None = None
|
||||||
model_config = {"extra": "forbid"}
|
model_config = {"extra": "forbid"}
|
||||||
|
|
||||||
|
|
||||||
@@ -563,3 +611,13 @@ class RenameParticipantSerializer(BaseValidationOnlySerializer):
|
|||||||
"""Serializer for renaming a participant in a room."""
|
"""Serializer for renaming a participant in a room."""
|
||||||
|
|
||||||
name = serializers.CharField(min_length=1, max_length=255, allow_blank=False)
|
name = serializers.CharField(min_length=1, max_length=255, allow_blank=False)
|
||||||
|
|
||||||
|
|
||||||
|
class ExternalProcessEventSerializer(BaseValidationOnlySerializer):
|
||||||
|
"""Validate external process event data."""
|
||||||
|
|
||||||
|
job_id = serializers.CharField(required=True)
|
||||||
|
# We are not strict on purpose on those fields to avoid
|
||||||
|
# useless bad requests
|
||||||
|
type = serializers.CharField(required=False, allow_null=True, allow_blank=True)
|
||||||
|
status = serializers.CharField(required=False, allow_null=True, allow_blank=True)
|
||||||
|
|||||||
@@ -35,28 +35,35 @@ from rest_framework import (
|
|||||||
)
|
)
|
||||||
from rest_framework.settings import api_settings
|
from rest_framework.settings import api_settings
|
||||||
|
|
||||||
from core import enums, models, utils
|
from core import analytics, enums, models, utils
|
||||||
from core.api.filters import ListFileFilter
|
from core.api.filters import ListFileFilter
|
||||||
from core.enums import MEDIA_STORAGE_URL_PATTERN
|
from core.enums import MEDIA_STORAGE_URL_PATTERN
|
||||||
from core.recording.enums import FileExtension
|
from core.recording.enums import FileExtension
|
||||||
from core.recording.event.authentication import StorageEventAuthentication
|
from core.recording.event.authentication import (
|
||||||
|
RecordingProcessWebhookAuthentication,
|
||||||
|
StorageEventAuthentication,
|
||||||
|
)
|
||||||
from core.recording.event.exceptions import (
|
from core.recording.event.exceptions import (
|
||||||
InvalidBucketError,
|
InvalidBucketError,
|
||||||
InvalidFilepathError,
|
InvalidFilepathError,
|
||||||
InvalidFileTypeError,
|
InvalidFileTypeError,
|
||||||
ParsingEventDataError,
|
ParsingEventDataError,
|
||||||
)
|
)
|
||||||
from core.recording.event.notification import notification_service
|
|
||||||
from core.recording.event.parsers import get_parser
|
from core.recording.event.parsers import get_parser
|
||||||
from core.recording.services.metadata_collector import (
|
from core.recording.services.metadata_collector import (
|
||||||
MetadataCollectorException,
|
MetadataCollectorException,
|
||||||
MetadataCollectorService,
|
MetadataCollectorService,
|
||||||
)
|
)
|
||||||
|
from core.recording.services.recording_events import (
|
||||||
|
RecordingEventsService,
|
||||||
|
RecordingNotSavableError,
|
||||||
|
)
|
||||||
from core.recording.worker.exceptions import (
|
from core.recording.worker.exceptions import (
|
||||||
RecordingStartError,
|
RecordingStartError,
|
||||||
RecordingStopError,
|
RecordingStopError,
|
||||||
)
|
)
|
||||||
from core.recording.worker.factories import (
|
from core.recording.worker.factories import (
|
||||||
|
build_encoding_options,
|
||||||
get_worker_service,
|
get_worker_service,
|
||||||
)
|
)
|
||||||
from core.recording.worker.mediator import (
|
from core.recording.worker.mediator import (
|
||||||
@@ -86,6 +93,7 @@ from core.services.subtitle import SubtitleException, SubtitleService
|
|||||||
from core.tasks.file import process_file_deletion
|
from core.tasks.file import process_file_deletion
|
||||||
|
|
||||||
from ..authentication.livekit import LiveKitTokenAuthentication
|
from ..authentication.livekit import LiveKitTokenAuthentication
|
||||||
|
from ..models import RoomAccessLevel
|
||||||
from . import permissions, serializers, throttling
|
from . import permissions, serializers, throttling
|
||||||
from .feature_flag import FeatureFlag
|
from .feature_flag import FeatureFlag
|
||||||
|
|
||||||
@@ -261,6 +269,9 @@ class RoomViewSet(
|
|||||||
username = request.query_params.get("username", None)
|
username = request.query_params.get("username", None)
|
||||||
data = {
|
data = {
|
||||||
"id": None,
|
"id": None,
|
||||||
|
"slug": slug,
|
||||||
|
"is_administrable": False,
|
||||||
|
"access_level": RoomAccessLevel.PUBLIC,
|
||||||
"livekit": {
|
"livekit": {
|
||||||
"url": settings.LIVEKIT_CONFIGURATION["url"],
|
"url": settings.LIVEKIT_CONFIGURATION["url"],
|
||||||
"room": slug,
|
"room": slug,
|
||||||
@@ -305,6 +316,16 @@ class RoomViewSet(
|
|||||||
if callback_id := self.request.data.get("callback_id"):
|
if callback_id := self.request.data.get("callback_id"):
|
||||||
RoomCreation().persist_callback_state(callback_id, room)
|
RoomCreation().persist_callback_state(callback_id, room)
|
||||||
|
|
||||||
|
analytics.capture(
|
||||||
|
self.request.user,
|
||||||
|
analytics.AnalyticsEvent.ROOM_CREATED,
|
||||||
|
{
|
||||||
|
"room_id": str(room.pk),
|
||||||
|
"access_level": room.access_level,
|
||||||
|
"from_callback": bool(self.request.data.get("callback_id")),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
def perform_update(self, serializer):
|
def perform_update(self, serializer):
|
||||||
"""Persist the room update, then sync metadata to LiveKit."""
|
"""Persist the room update, then sync metadata to LiveKit."""
|
||||||
|
|
||||||
@@ -363,12 +384,34 @@ class RoomViewSet(
|
|||||||
options = serializer.validated_data.get("options")
|
options = serializer.validated_data.get("options")
|
||||||
room = self.get_object()
|
room = self.get_object()
|
||||||
|
|
||||||
|
if (
|
||||||
|
options is not None
|
||||||
|
and options.encoding is not None
|
||||||
|
and not settings.RECORDING_CUSTOM_ENCODING_ENABLED
|
||||||
|
):
|
||||||
|
# Per-recording encoding selection is gated by
|
||||||
|
# RECORDING_CUSTOM_ENCODING_ENABLED. When disabled, recordings use
|
||||||
|
# encoding defined by RECORDING_ENCODING_DEFAULT_RESOLUTION
|
||||||
|
# and RECORDING_ENCODING_DEFAULT_PROFILE.
|
||||||
|
return drf_response.Response(
|
||||||
|
{"detail": "Per-recording encoding selection is disabled."},
|
||||||
|
status=drf_status.HTTP_400_BAD_REQUEST,
|
||||||
|
)
|
||||||
|
|
||||||
|
options_data = options.model_dump(exclude_none=True) if options else {}
|
||||||
|
if options is not None and options.encoding is not None:
|
||||||
|
# Persist the resolved encoding (concrete width/height/framerate/
|
||||||
|
# bitrate) alongside the requested resolution/profile for traceability.
|
||||||
|
options_data["encoding"]["resolved"] = build_encoding_options(
|
||||||
|
options.encoding.resolution, options.encoding.profile
|
||||||
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
with transaction.atomic():
|
with transaction.atomic():
|
||||||
recording = models.Recording.objects.create(
|
recording = models.Recording.objects.create(
|
||||||
room=room,
|
room=room,
|
||||||
mode=mode,
|
mode=mode,
|
||||||
options=options.model_dump(exclude_none=True) if options else {},
|
options=options_data,
|
||||||
)
|
)
|
||||||
models.RecordingAccess.objects.create(
|
models.RecordingAccess.objects.create(
|
||||||
user=self.request.user,
|
user=self.request.user,
|
||||||
@@ -972,29 +1015,74 @@ class RecordingViewSet(
|
|||||||
except models.Recording.DoesNotExist as e:
|
except models.Recording.DoesNotExist as e:
|
||||||
raise drf_exceptions.NotFound("No recording found for this event.") from e
|
raise drf_exceptions.NotFound("No recording found for this event.") from e
|
||||||
|
|
||||||
if not recording.is_savable():
|
# Save recording
|
||||||
|
recording_events_service = RecordingEventsService()
|
||||||
|
try:
|
||||||
|
recording_events_service.handle_complete(recording)
|
||||||
|
except RecordingNotSavableError:
|
||||||
raise drf_exceptions.PermissionDenied(
|
raise drf_exceptions.PermissionDenied(
|
||||||
f"Recording with ID {recording_id} cannot be saved because it is either,"
|
f"Recording with ID {recording_id} cannot be saved because it is either,"
|
||||||
" in an error state or has already been saved."
|
" in an error state or has already been saved."
|
||||||
)
|
) from None
|
||||||
|
|
||||||
# Attempt to notify external services about the recording
|
|
||||||
# This is a non-blocking operation - failures are logged but don't interrupt the flow
|
|
||||||
notification_succeeded = notification_service.notify_external_services(
|
|
||||||
recording
|
|
||||||
)
|
|
||||||
|
|
||||||
recording.status = (
|
|
||||||
models.RecordingStatusChoices.NOTIFICATION_SUCCEEDED
|
|
||||||
if notification_succeeded
|
|
||||||
else models.RecordingStatusChoices.SAVED
|
|
||||||
)
|
|
||||||
recording.save()
|
|
||||||
|
|
||||||
return drf_response.Response(
|
return drf_response.Response(
|
||||||
{"message": "Event processed."},
|
{"message": "Event processed."},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@decorators.action(
|
||||||
|
detail=False,
|
||||||
|
methods=["post"],
|
||||||
|
url_path="external-process-hook",
|
||||||
|
authentication_classes=[RecordingProcessWebhookAuthentication],
|
||||||
|
serializer_class=serializers.ExternalProcessEventSerializer,
|
||||||
|
)
|
||||||
|
def on_external_process_event_received(self, request, pk=None): # pylint: disable=unused-argument
|
||||||
|
"""Handle incoming external process events for recordings."""
|
||||||
|
logger.debug("Processing external process event %s", request.data)
|
||||||
|
serializer = self.get_serializer(data=request.data)
|
||||||
|
serializer.is_valid(raise_exception=True)
|
||||||
|
|
||||||
|
ok_response = drf_response.Response(
|
||||||
|
{"message": "Event processed."},
|
||||||
|
)
|
||||||
|
|
||||||
|
validated_data = serializer.validated_data
|
||||||
|
job_id = validated_data["job_id"]
|
||||||
|
try:
|
||||||
|
recording = models.Recording.objects.get(external_process_id=job_id)
|
||||||
|
except models.Recording.DoesNotExist as e:
|
||||||
|
logger.warning("No recording found for job_id %s: %s", job_id, e)
|
||||||
|
return ok_response
|
||||||
|
|
||||||
|
if validated_data.get("type") == "transcript":
|
||||||
|
if validated_data.get("status") == "success":
|
||||||
|
logger.info(
|
||||||
|
"External process transcript success received for recording %s",
|
||||||
|
job_id,
|
||||||
|
)
|
||||||
|
recording.status = (
|
||||||
|
models.RecordingStatusChoices.EXTERNAL_PROCESS_SUCCESSFUL
|
||||||
|
)
|
||||||
|
recording.save()
|
||||||
|
return ok_response
|
||||||
|
|
||||||
|
if validated_data.get("status") == "failure":
|
||||||
|
logger.info(
|
||||||
|
"External process transcript failure received for recording %s",
|
||||||
|
job_id,
|
||||||
|
)
|
||||||
|
recording.status = models.RecordingStatusChoices.EXTERNAL_PROCESS_FAILED
|
||||||
|
recording.save()
|
||||||
|
return ok_response
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"No changes to save for external process id %s and payload %s",
|
||||||
|
job_id,
|
||||||
|
validated_data,
|
||||||
|
)
|
||||||
|
|
||||||
|
return ok_response
|
||||||
|
|
||||||
def _auth_get_original_url(self, request):
|
def _auth_get_original_url(self, request):
|
||||||
"""
|
"""
|
||||||
Extracts and parses the original URL from the "HTTP_X_ORIGINAL_URL" header.
|
Extracts and parses the original URL from the "HTTP_X_ORIGINAL_URL" header.
|
||||||
@@ -1187,7 +1275,10 @@ class FileViewSet(
|
|||||||
serializer.save(creator=self.request.user)
|
serializer.save(creator=self.request.user)
|
||||||
|
|
||||||
def perform_destroy(self, instance):
|
def perform_destroy(self, instance):
|
||||||
"""Override to implement a soft delete instead of dumping the record in database."""
|
"""Override to implement a soft delete instead of dumping the record in database.
|
||||||
|
|
||||||
|
Files are actually purged by commands that should run periodically.
|
||||||
|
"""
|
||||||
instance.soft_delete()
|
instance.soft_delete()
|
||||||
|
|
||||||
@decorators.action(detail=True, methods=["post"], url_path="upload-ended")
|
@decorators.action(detail=True, methods=["post"], url_path="upload-ended")
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ from django.utils.translation import gettext_lazy as _
|
|||||||
from lasuite.oidc_login.backends import (
|
from lasuite.oidc_login.backends import (
|
||||||
OIDCAuthenticationBackend as LaSuiteOIDCAuthenticationBackend,
|
OIDCAuthenticationBackend as LaSuiteOIDCAuthenticationBackend,
|
||||||
)
|
)
|
||||||
|
from rest_framework.authentication import SessionAuthentication
|
||||||
|
|
||||||
from core.models import User
|
from core.models import User
|
||||||
from core.services.marketing import (
|
from core.services.marketing import (
|
||||||
@@ -96,3 +97,17 @@ class OIDCAuthenticationBackend(LaSuiteOIDCAuthenticationBackend):
|
|||||||
"Multiple user accounts share a common email."
|
"Multiple user accounts share a common email."
|
||||||
) from e
|
) from e
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
class SessionAuthenticationWith401(SessionAuthentication):
|
||||||
|
"""
|
||||||
|
Identical to DRF's SessionAuthentication, but returns a WWW-Authenticate
|
||||||
|
header so unauthenticated requests get a 401 instead of a 403.
|
||||||
|
|
||||||
|
The scheme is deliberately NOT 'Basic' — that would trigger the browser's
|
||||||
|
native login popup. 'Session' is ignored by the browser's auth UI but is
|
||||||
|
still truthy, so DRF keeps the status at 401.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def authenticate_header(self, request):
|
||||||
|
return "Session"
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ from rest_framework import (
|
|||||||
status as drf_status,
|
status as drf_status,
|
||||||
)
|
)
|
||||||
|
|
||||||
from core import api, models
|
from core import analytics, api, models
|
||||||
from core.api.feature_flag import FeatureFlag
|
from core.api.feature_flag import FeatureFlag
|
||||||
from core.services.jwt_token import JwtTokenService
|
from core.services.jwt_token import JwtTokenService
|
||||||
|
|
||||||
@@ -194,10 +194,27 @@ class RoomViewSet(
|
|||||||
role=models.RoleChoices.OWNER,
|
role=models.RoleChoices.OWNER,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
auth_method = type(self.request.successful_authenticator).__name__
|
||||||
|
client_id = (self.request.auth or {}).get("client_id", "unknown")
|
||||||
|
|
||||||
# Log for auditing
|
# Log for auditing
|
||||||
logger.info(
|
logger.info(
|
||||||
"Room created via application: room_id=%s, user_id=%s, client_id=%s",
|
"Room created via application: room_id=%s, user_id=%s, client_id=%s, auth_method=%s",
|
||||||
room.id,
|
room.id,
|
||||||
self.request.user.id,
|
self.request.user.id,
|
||||||
getattr(self.request.auth, "client_id", "unknown"),
|
client_id,
|
||||||
|
auth_method,
|
||||||
|
)
|
||||||
|
|
||||||
|
analytics.capture(
|
||||||
|
self.request.user,
|
||||||
|
analytics.AnalyticsEvent.ROOM_CREATED,
|
||||||
|
{
|
||||||
|
"room_id": str(room.pk),
|
||||||
|
"access_level": room.access_level,
|
||||||
|
"client_id": client_id,
|
||||||
|
"external_api": True,
|
||||||
|
"auth_method": auth_method,
|
||||||
|
"$set": {"email": self.request.user.email},
|
||||||
|
},
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -0,0 +1,47 @@
|
|||||||
|
"""Clean stale pending files that were never fully uploaded."""
|
||||||
|
|
||||||
|
from datetime import timedelta
|
||||||
|
|
||||||
|
from django.core.management.base import BaseCommand, CommandError
|
||||||
|
from django.utils import timezone
|
||||||
|
|
||||||
|
from core.models import File, FileUploadStateChoices
|
||||||
|
from core.tasks.file import process_file_deletion
|
||||||
|
|
||||||
|
|
||||||
|
class Command(BaseCommand):
|
||||||
|
"""Remove pending files older than a given threshold."""
|
||||||
|
|
||||||
|
help = "Delete pending files that have been stuck for too long"
|
||||||
|
|
||||||
|
def add_arguments(self, parser):
|
||||||
|
parser.add_argument(
|
||||||
|
"--hours",
|
||||||
|
type=int,
|
||||||
|
default=24,
|
||||||
|
help="Age threshold in hours (default: 24)",
|
||||||
|
)
|
||||||
|
|
||||||
|
def handle(self, *args, **options):
|
||||||
|
hours = options["hours"]
|
||||||
|
if hours < 0:
|
||||||
|
raise CommandError("Hours must be greater than 0")
|
||||||
|
|
||||||
|
threshold = timezone.now() - timedelta(hours=hours)
|
||||||
|
|
||||||
|
files = File.objects.filter(
|
||||||
|
upload_state=FileUploadStateChoices.PENDING,
|
||||||
|
created_at__lt=threshold,
|
||||||
|
hard_deleted_at__isnull=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
count = 0
|
||||||
|
for file in files.iterator():
|
||||||
|
# This check shouldn't happen, but just in case we do it to avoid an error
|
||||||
|
if not file.deleted_at:
|
||||||
|
file.soft_delete()
|
||||||
|
file.hard_delete()
|
||||||
|
process_file_deletion(file.id)
|
||||||
|
count += 1
|
||||||
|
|
||||||
|
self.stdout.write(f"Cleaned {count} stale pending file(s).")
|
||||||
@@ -6,6 +6,7 @@ from django.contrib.auth import get_user_model
|
|||||||
from django.core.management.base import BaseCommand, CommandError
|
from django.core.management.base import BaseCommand, CommandError
|
||||||
from django.db import transaction
|
from django.db import transaction
|
||||||
from django.db.models import Count
|
from django.db.models import Count
|
||||||
|
from django.db.models.functions import Lower
|
||||||
|
|
||||||
from core.models import File, RecordingAccess, ResourceAccess, RoleChoices
|
from core.models import File, RecordingAccess, ResourceAccess, RoleChoices
|
||||||
|
|
||||||
@@ -20,11 +21,14 @@ ROLE_PRIORITY = {
|
|||||||
|
|
||||||
class Command(BaseCommand):
|
class Command(BaseCommand):
|
||||||
"""
|
"""
|
||||||
Merge duplicate users sharing the same email into the most recently created one.
|
Merge duplicate users sharing the same email (case-insensitive) into the
|
||||||
|
most recently created one.
|
||||||
|
|
||||||
The KEPT user is the most recently created. All room memberships, recording
|
Emails are compared case-insensitively, so 'John@Example.com' and
|
||||||
accesses and files are transferred to it. When a conflict exists, the
|
'john@example.com' are treated as duplicates. The KEPT user is the most
|
||||||
higher-privilege role wins. Stale users are then deleted.
|
recently created. All room memberships, recording accesses and files are
|
||||||
|
transferred to it. When a conflict exists, the higher-privilege role wins.
|
||||||
|
Stale users are then deleted.
|
||||||
Each email group is processed inside a single database transaction.
|
Each email group is processed inside a single database transaction.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
@@ -56,13 +60,16 @@ class Command(BaseCommand):
|
|||||||
users_qs = users_qs.filter(email__icontains=email_filter)
|
users_qs = users_qs.filter(email__icontains=email_filter)
|
||||||
self.stdout.write(f"[INFO] Filtering emails containing '{email_filter}'.\n")
|
self.stdout.write(f"[INFO] Filtering emails containing '{email_filter}'.\n")
|
||||||
|
|
||||||
|
# Group emails case-insensitively so 'John@X.com' and 'john@x.com'
|
||||||
|
# are detected as duplicates of each other.
|
||||||
duplicate_emails = (
|
duplicate_emails = (
|
||||||
users_qs.exclude(email__isnull=True)
|
users_qs.exclude(email__isnull=True)
|
||||||
.exclude(email="")
|
.exclude(email="")
|
||||||
.values("email")
|
.annotate(email_lower=Lower("email"))
|
||||||
|
.values("email_lower")
|
||||||
.annotate(cnt=Count("id"))
|
.annotate(cnt=Count("id"))
|
||||||
.filter(cnt__gt=1)
|
.filter(cnt__gt=1)
|
||||||
.values_list("email", flat=True)
|
.values_list("email_lower", flat=True)
|
||||||
)
|
)
|
||||||
|
|
||||||
if not duplicate_emails:
|
if not duplicate_emails:
|
||||||
@@ -78,9 +85,12 @@ class Command(BaseCommand):
|
|||||||
failed_emails = []
|
failed_emails = []
|
||||||
|
|
||||||
for email in duplicate_emails:
|
for email in duplicate_emails:
|
||||||
|
# Case-insensitive lookup to fetch every casing variant of the email.
|
||||||
# Secondary sort by id ensures a stable, deterministic order when
|
# Secondary sort by id ensures a stable, deterministic order when
|
||||||
# created_at timestamps are equal (common in tests and bulk imports).
|
# created_at timestamps are equal (common in tests and bulk imports).
|
||||||
users = list(User.objects.filter(email=email).order_by("created_at", "id"))
|
users = list(
|
||||||
|
User.objects.filter(email__iexact=email).order_by("created_at", "id")
|
||||||
|
)
|
||||||
kept_user = users[-1]
|
kept_user = users[-1]
|
||||||
stale_users = users[:-1]
|
stale_users = users[:-1]
|
||||||
|
|
||||||
@@ -120,6 +130,10 @@ class Command(BaseCommand):
|
|||||||
failed_emails.append(email)
|
failed_emails.append(email)
|
||||||
self.stderr.write(f"[ERROR] Failed to merge '{email}': {exc}")
|
self.stderr.write(f"[ERROR] Failed to merge '{email}': {exc}")
|
||||||
|
|
||||||
|
if not kept_user.email.islower():
|
||||||
|
kept_user.email = kept_user.email.lower()
|
||||||
|
kept_user.save(update_fields=["email"])
|
||||||
|
|
||||||
if failed_emails:
|
if failed_emails:
|
||||||
raise CommandError(
|
raise CommandError(
|
||||||
f"Failed to merge {len(failed_emails)} email group(s): {', '.join(failed_emails)}"
|
f"Failed to merge {len(failed_emails)} email group(s): {', '.join(failed_emails)}"
|
||||||
|
|||||||
@@ -0,0 +1,40 @@
|
|||||||
|
"""Purge deleted files."""
|
||||||
|
|
||||||
|
from datetime import timedelta
|
||||||
|
|
||||||
|
from django.conf import settings
|
||||||
|
from django.core.management.base import BaseCommand
|
||||||
|
from django.db.models import Q
|
||||||
|
from django.utils import timezone
|
||||||
|
|
||||||
|
from core.models import File
|
||||||
|
from core.tasks.file import process_file_deletion
|
||||||
|
|
||||||
|
|
||||||
|
class Command(BaseCommand):
|
||||||
|
"""
|
||||||
|
Purge deleted files (object storage and database object):
|
||||||
|
- files marked as hard deleted in database
|
||||||
|
- files marked as soft deleted and for which the trashbin retention period has expired
|
||||||
|
"""
|
||||||
|
|
||||||
|
help = "Purge deleted files"
|
||||||
|
|
||||||
|
def handle(self, *args, **options):
|
||||||
|
"""Browse purgeable files and queue them through the file deletion task."""
|
||||||
|
|
||||||
|
is_hard_deleted = Q(hard_deleted_at__isnull=False)
|
||||||
|
is_purgeable = Q(
|
||||||
|
deleted_at__lte=timezone.now()
|
||||||
|
- timedelta(days=settings.FILE_PURGE_GRACE_DAYS)
|
||||||
|
)
|
||||||
|
|
||||||
|
count = 0
|
||||||
|
for file in File.objects.filter(is_hard_deleted | is_purgeable).iterator():
|
||||||
|
if file.hard_deleted_at is None:
|
||||||
|
file.hard_delete()
|
||||||
|
|
||||||
|
process_file_deletion.delay(file.id)
|
||||||
|
count += 1
|
||||||
|
|
||||||
|
self.stdout.write(f"Purged {count} deleted file(s).")
|
||||||
+23
@@ -0,0 +1,23 @@
|
|||||||
|
# Generated by Django 5.2.14 on 2026-06-22 08:26
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('core', '0020_alter_file_upload_state'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='recording',
|
||||||
|
name='external_process_id',
|
||||||
|
field=models.CharField(blank=True, help_text='ID of the external process associated with the recording.', max_length=255, null=True, unique=True, verbose_name='External Process ID'),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='recording',
|
||||||
|
name='status',
|
||||||
|
field=models.CharField(choices=[('initiated', 'Initiated'), ('active', 'Active'), ('stopped', 'Stopped'), ('saved', 'Saved'), ('aborted', 'Aborted'), ('failed_to_start', 'Failed to Start'), ('failed_to_stop', 'Failed to Stop'), ('notification_succeeded', 'Notification succeeded'), ('external_process_successful', 'External process successful'), ('external_process_failed', 'External process failed')], default='initiated', max_length=50),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -60,6 +60,11 @@ class RecordingStatusChoices(models.TextChoices):
|
|||||||
FAILED_TO_START = "failed_to_start", _("Failed to Start")
|
FAILED_TO_START = "failed_to_start", _("Failed to Start")
|
||||||
FAILED_TO_STOP = "failed_to_stop", _("Failed to Stop")
|
FAILED_TO_STOP = "failed_to_stop", _("Failed to Stop")
|
||||||
NOTIFICATION_SUCCEEDED = "notification_succeeded", _("Notification succeeded")
|
NOTIFICATION_SUCCEEDED = "notification_succeeded", _("Notification succeeded")
|
||||||
|
EXTERNAL_PROCESS_SUCCESSFUL = (
|
||||||
|
"external_process_successful",
|
||||||
|
_("External process successful"),
|
||||||
|
)
|
||||||
|
EXTERNAL_PROCESS_FAILED = "external_process_failed", _("External process failed")
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def is_final(cls, status):
|
def is_final(cls, status):
|
||||||
@@ -73,6 +78,8 @@ class RecordingStatusChoices(models.TextChoices):
|
|||||||
cls.STOPPED,
|
cls.STOPPED,
|
||||||
cls.SAVED,
|
cls.SAVED,
|
||||||
cls.ABORTED,
|
cls.ABORTED,
|
||||||
|
cls.EXTERNAL_PROCESS_SUCCESSFUL,
|
||||||
|
cls.EXTERNAL_PROCESS_FAILED,
|
||||||
cls.FAILED_TO_START,
|
cls.FAILED_TO_START,
|
||||||
cls.FAILED_TO_STOP,
|
cls.FAILED_TO_STOP,
|
||||||
}
|
}
|
||||||
@@ -598,6 +605,14 @@ class Recording(BaseModel):
|
|||||||
verbose_name=_("Recording options"),
|
verbose_name=_("Recording options"),
|
||||||
help_text=_("Recording options"),
|
help_text=_("Recording options"),
|
||||||
)
|
)
|
||||||
|
external_process_id = models.CharField(
|
||||||
|
max_length=255,
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
unique=True,
|
||||||
|
verbose_name=_("External Process ID"),
|
||||||
|
help_text=_("ID of the external process associated with the recording."),
|
||||||
|
)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
db_table = "meet_recording"
|
db_table = "meet_recording"
|
||||||
@@ -653,6 +668,8 @@ class Recording(BaseModel):
|
|||||||
return self.status in {
|
return self.status in {
|
||||||
RecordingStatusChoices.NOTIFICATION_SUCCEEDED,
|
RecordingStatusChoices.NOTIFICATION_SUCCEEDED,
|
||||||
RecordingStatusChoices.SAVED,
|
RecordingStatusChoices.SAVED,
|
||||||
|
RecordingStatusChoices.EXTERNAL_PROCESS_SUCCESSFUL,
|
||||||
|
RecordingStatusChoices.EXTERNAL_PROCESS_FAILED,
|
||||||
}
|
}
|
||||||
|
|
||||||
@property
|
@property
|
||||||
|
|||||||
@@ -14,9 +14,9 @@ logger = logging.getLogger(__name__)
|
|||||||
class MachineUser:
|
class MachineUser:
|
||||||
"""Represent a non-interactive system user for automated storage operations."""
|
"""Represent a non-interactive system user for automated storage operations."""
|
||||||
|
|
||||||
def __init__(self) -> None:
|
def __init__(self, username: str = "storage_event_user") -> None:
|
||||||
self.pk = None
|
self.pk = None
|
||||||
self.username = "storage_event_user"
|
self.username = username
|
||||||
self.is_active = True
|
self.is_active = True
|
||||||
|
|
||||||
@property
|
@property
|
||||||
@@ -34,33 +34,33 @@ class MachineUser:
|
|||||||
return self.username
|
return self.username
|
||||||
|
|
||||||
|
|
||||||
class StorageEventAuthentication(BaseAuthentication):
|
class HeaderBasedAuthentication(BaseAuthentication):
|
||||||
"""Authenticate requests using a Bearer token for storage event integration.
|
"""Authenticate requests using a header with a secret key."""
|
||||||
This class validates Bearer tokens for storage events that don't map to database users.
|
|
||||||
It's designed for S3-compatible storage integrations and similar use cases.
|
|
||||||
Events are submitted when a webhook is configured on some bucket's events.
|
|
||||||
"""
|
|
||||||
|
|
||||||
AUTH_HEADER = "Authorization"
|
AUTH_HEADER = "Authorization"
|
||||||
TOKEN_TYPE = "Bearer" # noqa S105
|
TOKEN_TYPE = "Bearer" # noqa S105
|
||||||
|
REALM = ""
|
||||||
|
|
||||||
|
IS_ENFORCED_SETTINGS_KEY = None
|
||||||
|
EXPECTED_TOKEN_SETTINGS_KEY = None
|
||||||
|
|
||||||
def authenticate(self, request):
|
def authenticate(self, request):
|
||||||
"""Validate the Bearer token from the Authorization header."""
|
"""Validate the Bearer token from the Authorization header."""
|
||||||
|
|
||||||
if not settings.RECORDING_ENABLE_STORAGE_EVENT_AUTH:
|
if self.IS_ENFORCED_SETTINGS_KEY is not None:
|
||||||
return MachineUser(), None
|
if not getattr(settings, self.IS_ENFORCED_SETTINGS_KEY):
|
||||||
|
return MachineUser(), None
|
||||||
|
|
||||||
required_token = settings.RECORDING_STORAGE_EVENT_TOKEN
|
if (
|
||||||
if not required_token:
|
self.EXPECTED_TOKEN_SETTINGS_KEY is None
|
||||||
if settings.RECORDING_ENABLE_STORAGE_EVENT_AUTH:
|
or (required_token := getattr(settings, self.EXPECTED_TOKEN_SETTINGS_KEY))
|
||||||
raise AuthenticationFailed(
|
is None
|
||||||
"Authentication is enabled but token is not configured."
|
):
|
||||||
)
|
raise AuthenticationFailed(
|
||||||
|
"Authentication is enabled but token is not configured."
|
||||||
return MachineUser(), None
|
)
|
||||||
|
|
||||||
auth_header = request.headers.get(self.AUTH_HEADER)
|
auth_header = request.headers.get(self.AUTH_HEADER)
|
||||||
|
|
||||||
if not auth_header:
|
if not auth_header:
|
||||||
logger.warning(
|
logger.warning(
|
||||||
"Authentication failed: Missing Authorization header (ip: %s)",
|
"Authentication failed: Missing Authorization header (ip: %s)",
|
||||||
@@ -68,15 +68,10 @@ class StorageEventAuthentication(BaseAuthentication):
|
|||||||
)
|
)
|
||||||
raise AuthenticationFailed("Authorization header is required")
|
raise AuthenticationFailed("Authorization header is required")
|
||||||
|
|
||||||
auth_parts = auth_header.split(" ")
|
scheme, _, token = auth_header.partition(" ")
|
||||||
if len(auth_parts) != 2 or auth_parts[0] != self.TOKEN_TYPE:
|
if scheme.lower() != self.TOKEN_TYPE.lower() or not token.strip():
|
||||||
logger.warning(
|
raise AuthenticationFailed("Invalid authorization header format.")
|
||||||
"Authentication failed: Invalid authorization header (ip: %s)",
|
token = token.strip()
|
||||||
request.META.get("REMOTE_ADDR"),
|
|
||||||
)
|
|
||||||
raise AuthenticationFailed("Invalid authorization header.")
|
|
||||||
|
|
||||||
token = auth_parts[1]
|
|
||||||
|
|
||||||
# Use constant-time comparison to prevent timing attacks
|
# Use constant-time comparison to prevent timing attacks
|
||||||
if not secrets.compare_digest(token.encode(), required_token.encode()):
|
if not secrets.compare_digest(token.encode(), required_token.encode()):
|
||||||
@@ -90,4 +85,26 @@ class StorageEventAuthentication(BaseAuthentication):
|
|||||||
|
|
||||||
def authenticate_header(self, request):
|
def authenticate_header(self, request):
|
||||||
"""Return the WWW-Authenticate header value."""
|
"""Return the WWW-Authenticate header value."""
|
||||||
return f"{self.TOKEN_TYPE} realm='Storage event API'"
|
return f"{self.TOKEN_TYPE} realm='{self.REALM}'"
|
||||||
|
|
||||||
|
|
||||||
|
class StorageEventAuthentication(HeaderBasedAuthentication):
|
||||||
|
"""Authenticate requests using a Bearer token for storage event integration.
|
||||||
|
This class validates Bearer tokens for storage events that don't map to database users.
|
||||||
|
It's designed for S3-compatible storage integrations and similar use cases.
|
||||||
|
Events are submitted when a webhook is configured on some bucket's events.
|
||||||
|
"""
|
||||||
|
|
||||||
|
REALM = "Storage event API"
|
||||||
|
IS_ENFORCED_SETTINGS_KEY = "RECORDING_ENABLE_STORAGE_EVENT_AUTH"
|
||||||
|
EXPECTED_TOKEN_SETTINGS_KEY = "RECORDING_STORAGE_EVENT_TOKEN" # noqa S105
|
||||||
|
|
||||||
|
|
||||||
|
class RecordingProcessWebhookAuthentication(HeaderBasedAuthentication):
|
||||||
|
"""
|
||||||
|
Custom authentication class for recording process webhook requests.
|
||||||
|
Validates the API key in the Authorization header.
|
||||||
|
"""
|
||||||
|
|
||||||
|
REALM = "External process webhook API"
|
||||||
|
EXPECTED_TOKEN_SETTINGS_KEY = "SUMMARY_SERVICE_WEBHOOK_API_TOKEN" # noqa S105
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import asyncio
|
|||||||
import logging
|
import logging
|
||||||
import smtplib
|
import smtplib
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
|
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
|
||||||
|
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.core.mail import send_mail
|
from django.core.mail import send_mail
|
||||||
@@ -17,6 +18,8 @@ from asgiref.sync import async_to_sync
|
|||||||
from livekit import api as livekit_api
|
from livekit import api as livekit_api
|
||||||
|
|
||||||
from core import models, utils
|
from core import models, utils
|
||||||
|
from core.analytics import UserFeatureFlag, is_user_feature_flag_enabled
|
||||||
|
from core.utils import generate_download_s3_url
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -178,8 +181,49 @@ class NotificationService:
|
|||||||
|
|
||||||
return _ns_to_utc(file_result.started_at), _ns_to_utc(file_result.ended_at)
|
return _ns_to_utc(file_result.started_at), _ns_to_utc(file_result.ended_at)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _generate_title(
|
||||||
|
*,
|
||||||
|
locale: str,
|
||||||
|
room: str,
|
||||||
|
recording_datetime: datetime | None,
|
||||||
|
owner_timezone: str | None,
|
||||||
|
) -> str:
|
||||||
|
"""Generate title from context or return default."""
|
||||||
|
if recording_datetime is None:
|
||||||
|
with override(locale):
|
||||||
|
return _("Transcription")
|
||||||
|
|
||||||
|
dt = recording_datetime
|
||||||
|
if owner_timezone:
|
||||||
|
try:
|
||||||
|
dt = recording_datetime.astimezone(ZoneInfo(owner_timezone))
|
||||||
|
except (KeyError, ZoneInfoNotFoundError):
|
||||||
|
pass # Keep the original UTC datetime
|
||||||
|
|
||||||
|
with override(locale):
|
||||||
|
translated_template = _(
|
||||||
|
'Meeting "{room}" on {room_recording_date} at {room_recording_time}'
|
||||||
|
)
|
||||||
|
return translated_template.format(
|
||||||
|
room=room,
|
||||||
|
room_recording_date=dt.strftime("%Y-%m-%d"),
|
||||||
|
room_recording_time=dt.strftime("%H:%M"),
|
||||||
|
)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _notify_summary_service(recording: models.Recording):
|
def _notify_summary_service(recording: models.Recording):
|
||||||
|
if settings.SUMMARY_SERVICE_VERSION == 1:
|
||||||
|
return NotificationService._notify_summary_service_v1(recording)
|
||||||
|
if settings.SUMMARY_SERVICE_VERSION == 2:
|
||||||
|
return NotificationService._notify_summary_service_v2(recording)
|
||||||
|
|
||||||
|
raise NotImplementedError(
|
||||||
|
f"Unknown summary service version: {settings.SUMMARY_SERVICE_VERSION}"
|
||||||
|
)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _notify_summary_service_v1(recording: models.Recording):
|
||||||
"""Notify summary service about a new recording."""
|
"""Notify summary service about a new recording."""
|
||||||
|
|
||||||
if (
|
if (
|
||||||
@@ -253,5 +297,120 @@ class NotificationService:
|
|||||||
|
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _notify_summary_service_v2(recording: models.Recording):
|
||||||
|
"""Notify summary service about a new recording."""
|
||||||
|
|
||||||
|
if (
|
||||||
|
not settings.SUMMARY_SERVICE_ENDPOINT
|
||||||
|
or not settings.SUMMARY_SERVICE_API_TOKEN
|
||||||
|
):
|
||||||
|
logger.error("Summary service not configured")
|
||||||
|
return False
|
||||||
|
|
||||||
|
owner_access = (
|
||||||
|
models.RecordingAccess.objects.select_related("user")
|
||||||
|
.filter(
|
||||||
|
role=models.RoleChoices.OWNER,
|
||||||
|
recording_id=recording.id,
|
||||||
|
)
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
metadata_filename: None | str = None
|
||||||
|
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"
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
|
form_base_url = settings.TRANSCRIPTION_SATISFACTION_FORM_BASE_URL
|
||||||
|
form_link = (
|
||||||
|
f"{form_base_url}?room_id={recording.room.id}"
|
||||||
|
if (form_base_url and metadata_filename is not None)
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
metadata_payload = None
|
||||||
|
if started_at and ended_at and metadata_filename:
|
||||||
|
metadata_payload = {
|
||||||
|
"cloud_storage_url": generate_download_s3_url(
|
||||||
|
metadata_filename,
|
||||||
|
expires_in=settings.SUMMARY_SERVICE_CLOUD_STORAGE_SIGNED_URL_EXPIRY_SECONDS,
|
||||||
|
override_domain=False,
|
||||||
|
),
|
||||||
|
"started_at": started_at.isoformat(),
|
||||||
|
"ended_at": ended_at.isoformat(),
|
||||||
|
}
|
||||||
|
|
||||||
|
payload = {
|
||||||
|
"user_sub": owner_access.user.sub,
|
||||||
|
"user_email": owner_access.user.email,
|
||||||
|
"cloud_storage_url": generate_download_s3_url(
|
||||||
|
recording.key,
|
||||||
|
expires_in=settings.SUMMARY_SERVICE_CLOUD_STORAGE_SIGNED_URL_EXPIRY_SECONDS,
|
||||||
|
override_domain=False,
|
||||||
|
),
|
||||||
|
"language": recording.options.get(
|
||||||
|
"language", get_language().split("-")[0].lower()
|
||||||
|
),
|
||||||
|
"context_language": owner_access.user.language,
|
||||||
|
"push_to_docs_config": {
|
||||||
|
"user_email": owner_access.user.email,
|
||||||
|
"title": NotificationService._generate_title(
|
||||||
|
locale=owner_access.user.language
|
||||||
|
or recording.options.get("language", get_language()),
|
||||||
|
room=recording.room.name,
|
||||||
|
recording_datetime=started_at,
|
||||||
|
owner_timezone=str(owner_access.user.timezone),
|
||||||
|
),
|
||||||
|
"download_link": f"{get_recording_download_base_url()}/{recording.id}",
|
||||||
|
"form_link": form_link,
|
||||||
|
"auto_create_summary": is_user_feature_flag_enabled(
|
||||||
|
owner_access.user, UserFeatureFlag.TRANSCRIPT_SUMMARY_ENABLED
|
||||||
|
),
|
||||||
|
},
|
||||||
|
"metadata": metadata_payload,
|
||||||
|
}
|
||||||
|
|
||||||
|
headers = {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"Authorization": f"Bearer {settings.SUMMARY_SERVICE_API_TOKEN}",
|
||||||
|
}
|
||||||
|
|
||||||
|
try:
|
||||||
|
response = requests.post(
|
||||||
|
settings.SUMMARY_SERVICE_ENDPOINT,
|
||||||
|
json=payload,
|
||||||
|
headers=headers,
|
||||||
|
timeout=30,
|
||||||
|
)
|
||||||
|
response.raise_for_status()
|
||||||
|
response_json = response.json()
|
||||||
|
# We do not require a job_id to avoid a breaking change
|
||||||
|
job_id = response_json.get("job_id")
|
||||||
|
if not isinstance(job_id, str):
|
||||||
|
raise ValueError("job_id is not a string")
|
||||||
|
|
||||||
|
recording.external_process_id = job_id
|
||||||
|
recording.save()
|
||||||
|
|
||||||
|
except requests.RequestException as exc:
|
||||||
|
logger.exception(
|
||||||
|
"Summary service error for recording %s. URL: %s. Exception: %s",
|
||||||
|
recording.id,
|
||||||
|
settings.SUMMARY_SERVICE_ENDPOINT,
|
||||||
|
exc,
|
||||||
|
)
|
||||||
|
return False
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
notification_service = NotificationService()
|
notification_service = NotificationService()
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import re
|
|||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from functools import lru_cache
|
from functools import lru_cache
|
||||||
from typing import Any, Dict, Optional, Protocol
|
from typing import Any, Dict, Optional, Protocol
|
||||||
|
from urllib.parse import quote
|
||||||
|
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.utils.module_loading import import_string
|
from django.utils.module_loading import import_string
|
||||||
@@ -58,7 +59,7 @@ class EventParser(Protocol):
|
|||||||
def parse(self, data: Dict) -> StorageEvent:
|
def parse(self, data: Dict) -> StorageEvent:
|
||||||
"""Extract storage event data from raw dictionary input."""
|
"""Extract storage event data from raw dictionary input."""
|
||||||
|
|
||||||
def validate(self, data: StorageEvent) -> None:
|
def validate(self, data: StorageEvent) -> str:
|
||||||
"""Verify storage event data meets all requirements."""
|
"""Verify storage event data meets all requirements."""
|
||||||
|
|
||||||
def get_recording_id(self, data: Dict) -> str:
|
def get_recording_id(self, data: Dict) -> str:
|
||||||
@@ -164,6 +165,9 @@ class S3Parser(BaseS3Parser):
|
|||||||
if not filepath:
|
if not filepath:
|
||||||
raise ParsingEventDataError("Missing object key name")
|
raise ParsingEventDataError("Missing object key name")
|
||||||
filetype, _ = mimetypes.guess_type(filepath)
|
filetype, _ = mimetypes.guess_type(filepath)
|
||||||
|
# Normalize raw S3-compatible object keys without re-encoding
|
||||||
|
# already encoded AWS S3 notification keys.
|
||||||
|
filepath = quote(filepath, safe="%+")
|
||||||
return StorageEvent(
|
return StorageEvent(
|
||||||
filepath=filepath,
|
filepath=filepath,
|
||||||
filetype=filetype,
|
filetype=filetype,
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ from livekit import api
|
|||||||
|
|
||||||
from core import models, utils
|
from core import models, utils
|
||||||
from core.models import Recording
|
from core.models import Recording
|
||||||
|
from core.recording.event.notification import notification_service
|
||||||
|
|
||||||
logger = getLogger(__name__)
|
logger = getLogger(__name__)
|
||||||
|
|
||||||
@@ -16,6 +17,10 @@ class RecordingEventsError(Exception):
|
|||||||
"""Recording event handling fails."""
|
"""Recording event handling fails."""
|
||||||
|
|
||||||
|
|
||||||
|
class RecordingNotSavableError(Exception):
|
||||||
|
"""Recording cannot be saved because it is either in an error state or has already been saved"""
|
||||||
|
|
||||||
|
|
||||||
class RecordingEventsService:
|
class RecordingEventsService:
|
||||||
"""Handles recording-related LiveKit webhook events."""
|
"""Handles recording-related LiveKit webhook events."""
|
||||||
|
|
||||||
@@ -73,3 +78,23 @@ class RecordingEventsService:
|
|||||||
f"Failed to notify participants in room '{recording.room.id}' about "
|
f"Failed to notify participants in room '{recording.room.id}' about "
|
||||||
f"recording limit reached (recording_id={recording.id})"
|
f"recording limit reached (recording_id={recording.id})"
|
||||||
) from e
|
) from e
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def handle_complete(recording: Recording):
|
||||||
|
"""Notify external services and save recording."""
|
||||||
|
|
||||||
|
if not recording.is_savable():
|
||||||
|
raise RecordingNotSavableError
|
||||||
|
|
||||||
|
# Attempt to notify external services about the recording
|
||||||
|
# This is a non-blocking operation - failures are logged but don't interrupt the flow
|
||||||
|
notification_succeeded = notification_service.notify_external_services(
|
||||||
|
recording
|
||||||
|
)
|
||||||
|
|
||||||
|
recording.status = (
|
||||||
|
models.RecordingStatusChoices.NOTIFICATION_SUCCEEDED
|
||||||
|
if notification_succeeded
|
||||||
|
else models.RecordingStatusChoices.SAVED
|
||||||
|
)
|
||||||
|
recording.save()
|
||||||
|
|||||||
@@ -22,6 +22,44 @@ _RECORDING_AUDIO_CODEC = livekit_api.AudioCodec.AAC
|
|||||||
_RECORDING_AUDIO_FREQUENCY_HZ = 48000
|
_RECORDING_AUDIO_FREQUENCY_HZ = 48000
|
||||||
|
|
||||||
|
|
||||||
|
def build_encoding_options(resolution, profile):
|
||||||
|
"""Assemble the LiveKit ``EncodingOptions`` kwargs for a resolution/profile.
|
||||||
|
|
||||||
|
Single source of truth shared by the default encoding
|
||||||
|
(``WorkerServiceConfig.from_settings``) and the per-recording encoding
|
||||||
|
persisted by the start-recording API, so both paths always produce the
|
||||||
|
same shape.
|
||||||
|
|
||||||
|
The profile-independent fields (audio bitrate, keyframe interval and the
|
||||||
|
pinned codec / frequency constants) are always included.
|
||||||
|
The resolution-dependent fields are added only when they can be resolved:
|
||||||
|
width/height require a resolution; framerate/video_bitrate require both a
|
||||||
|
resolution and a profile (a resolution-only encoding leaves framerate and
|
||||||
|
bitrate to LiveKit's defaults).
|
||||||
|
"""
|
||||||
|
options: Dict[str, Any] = {
|
||||||
|
"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,
|
||||||
|
}
|
||||||
|
|
||||||
|
if resolution:
|
||||||
|
resolution_config = settings.RECORDING_ENCODING_AVAILABLE_RESOLUTIONS[
|
||||||
|
resolution
|
||||||
|
]
|
||||||
|
options["width"] = resolution_config["width"]
|
||||||
|
options["height"] = resolution_config["height"]
|
||||||
|
|
||||||
|
if resolution and profile:
|
||||||
|
profile_config = settings.RECORDING_ENCODING_AVAILABLE_PROFILES[profile]
|
||||||
|
options["framerate"] = profile_config["fps"]
|
||||||
|
options["video_bitrate"] = profile_config["kbps"][resolution]
|
||||||
|
|
||||||
|
return options
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
class WorkerServiceConfig:
|
class WorkerServiceConfig:
|
||||||
"""Declare Worker Service common configurations"""
|
"""Declare Worker Service common configurations"""
|
||||||
@@ -38,22 +76,16 @@ class WorkerServiceConfig:
|
|||||||
|
|
||||||
logger.debug("Loading WorkerServiceConfig from settings.")
|
logger.debug("Loading WorkerServiceConfig from settings.")
|
||||||
|
|
||||||
|
# The default encoding is resolved from the default profile/resolution and
|
||||||
|
# applied to every recording that carries no per-recording encoding.
|
||||||
|
# When either default is missing, we leave this as None so LiveKit falls
|
||||||
|
# back to its built-in preset.
|
||||||
|
resolution = settings.RECORDING_ENCODING_DEFAULT_RESOLUTION
|
||||||
|
profile = settings.RECORDING_ENCODING_DEFAULT_PROFILE
|
||||||
|
|
||||||
encoding_options: Optional[Dict[str, Any]] = None
|
encoding_options: Optional[Dict[str, Any]] = None
|
||||||
if settings.RECORDING_ENCODING_ENABLED:
|
if resolution and profile:
|
||||||
# Single source of truth for the EncodingOptions kwargs:
|
encoding_options = build_encoding_options(resolution, profile)
|
||||||
# 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(
|
return cls(
|
||||||
output_folder=settings.RECORDING_OUTPUT_FOLDER,
|
output_folder=settings.RECORDING_OUTPUT_FOLDER,
|
||||||
@@ -78,7 +110,12 @@ class WorkerService(Protocol):
|
|||||||
def __init__(self, config: WorkerServiceConfig):
|
def __init__(self, config: WorkerServiceConfig):
|
||||||
"""Initialize the service with the given configuration."""
|
"""Initialize the service with the given configuration."""
|
||||||
|
|
||||||
def start(self, room_id: str, recording_id: str) -> str:
|
def start(
|
||||||
|
self,
|
||||||
|
room_id: str,
|
||||||
|
recording_id: str,
|
||||||
|
encoding_options: Optional[Dict[str, Any]] = None,
|
||||||
|
) -> str:
|
||||||
"""Start a recording for a specified room."""
|
"""Start a recording for a specified room."""
|
||||||
|
|
||||||
def stop(self, worker_id: str) -> str:
|
def stop(self, worker_id: str) -> str:
|
||||||
|
|||||||
@@ -47,8 +47,11 @@ class WorkerServiceMediator:
|
|||||||
raise RecordingStartError()
|
raise RecordingStartError()
|
||||||
|
|
||||||
room_name = str(recording.room.id)
|
room_name = str(recording.room.id)
|
||||||
|
encoding_options = (recording.options.get("encoding") or {}).get("resolved")
|
||||||
try:
|
try:
|
||||||
worker_id = self._worker_service.start(room_name, recording.id)
|
worker_id = self._worker_service.start(
|
||||||
|
room_name, recording.id, encoding_options=encoding_options
|
||||||
|
)
|
||||||
except (WorkerRequestError, WorkerConnectionError, WorkerResponseError) as e:
|
except (WorkerRequestError, WorkerConnectionError, WorkerResponseError) as e:
|
||||||
logger.exception(
|
logger.exception(
|
||||||
"Failed to start recording for room %s: %s", recording.room.slug, e
|
"Failed to start recording for room %s: %s", recording.room.slug, e
|
||||||
|
|||||||
@@ -76,7 +76,7 @@ class BaseEgressService:
|
|||||||
|
|
||||||
return "FAILED_TO_STOP"
|
return "FAILED_TO_STOP"
|
||||||
|
|
||||||
def start(self, room_name, recording_id):
|
def start(self, room_name, recording_id, encoding_options=None):
|
||||||
"""Start the egress process for a recording (not implemented in the base class).
|
"""Start the egress process for a recording (not implemented in the base class).
|
||||||
Each derived class must implement this method, providing the necessary parameters for
|
Each derived class must implement this method, providing the necessary parameters for
|
||||||
its specific egress type (e.g. audio_only, streaming output).
|
its specific egress type (e.g. audio_only, streaming output).
|
||||||
@@ -99,13 +99,24 @@ class BaseEgressService:
|
|||||||
|
|
||||||
return livekit_api.EncodingOptions(**opts)
|
return livekit_api.EncodingOptions(**opts)
|
||||||
|
|
||||||
|
def _resolve_encoding_options(self, encoding_options):
|
||||||
|
"""Build LiveKit EncodingOptions from a resolved per-recording dict, or None.
|
||||||
|
|
||||||
|
``encoding_options`` is the dict persisted by the API in
|
||||||
|
``recording.options["encoding"]["resolved"]``.
|
||||||
|
"""
|
||||||
|
if not encoding_options:
|
||||||
|
return None
|
||||||
|
|
||||||
|
return livekit_api.EncodingOptions(**encoding_options)
|
||||||
|
|
||||||
|
|
||||||
class VideoCompositeEgressService(BaseEgressService):
|
class VideoCompositeEgressService(BaseEgressService):
|
||||||
"""Record multiple participant video and audio tracks into a single output '.mp4' file."""
|
"""Record multiple participant video and audio tracks into a single output '.mp4' file."""
|
||||||
|
|
||||||
hrid = "video-recording-composite-livekit-egress"
|
hrid = "video-recording-composite-livekit-egress"
|
||||||
|
|
||||||
def start(self, room_name, recording_id):
|
def start(self, room_name, recording_id, encoding_options=None):
|
||||||
"""Start the video composite egress process for a recording."""
|
"""Start the video composite egress process for a recording."""
|
||||||
|
|
||||||
# Save room's recording as a mp4 video file.
|
# Save room's recording as a mp4 video file.
|
||||||
@@ -126,7 +137,10 @@ class VideoCompositeEgressService(BaseEgressService):
|
|||||||
"layout": "speaker-light",
|
"layout": "speaker-light",
|
||||||
}
|
}
|
||||||
|
|
||||||
advanced = self._build_encoding_options()
|
advanced = (
|
||||||
|
self._resolve_encoding_options(encoding_options)
|
||||||
|
or self._build_encoding_options()
|
||||||
|
)
|
||||||
if advanced is not None:
|
if advanced is not None:
|
||||||
request_kwargs["advanced"] = advanced
|
request_kwargs["advanced"] = advanced
|
||||||
|
|
||||||
@@ -145,8 +159,13 @@ class AudioCompositeEgressService(BaseEgressService):
|
|||||||
|
|
||||||
hrid = "audio-recording-composite-livekit-egress"
|
hrid = "audio-recording-composite-livekit-egress"
|
||||||
|
|
||||||
def start(self, room_name, recording_id):
|
def start(self, room_name, recording_id, encoding_options=None):
|
||||||
"""Start the audio composite egress process for a recording."""
|
"""Start the audio composite egress process for a recording.
|
||||||
|
|
||||||
|
``encoding_options`` is accepted for signature compatibility with the
|
||||||
|
WorkerService protocol but ignored: audio-only egress has no
|
||||||
|
encoding to configure.
|
||||||
|
"""
|
||||||
|
|
||||||
# Save room's recording as an ogg audio file.
|
# Save room's recording as an ogg audio file.
|
||||||
file_type = livekit_api.EncodedFileType.OGG
|
file_type = livekit_api.EncodedFileType.OGG
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ from core.recording.services.metadata_collector import (
|
|||||||
from core.recording.services.recording_events import (
|
from core.recording.services.recording_events import (
|
||||||
RecordingEventsError,
|
RecordingEventsError,
|
||||||
RecordingEventsService,
|
RecordingEventsService,
|
||||||
|
RecordingNotSavableError,
|
||||||
)
|
)
|
||||||
|
|
||||||
from .lobby import LobbyService
|
from .lobby import LobbyService
|
||||||
@@ -88,6 +89,13 @@ class LiveKitEventsService:
|
|||||||
def __init__(self):
|
def __init__(self):
|
||||||
"""Initialize with required services."""
|
"""Initialize with required services."""
|
||||||
|
|
||||||
|
self._webhook_handlers = {
|
||||||
|
"egress_updated": self._handle_egress_updated,
|
||||||
|
"egress_ended": self._handle_egress_ended,
|
||||||
|
"room_started": self._handle_room_started,
|
||||||
|
"room_finished": self._handle_room_finished,
|
||||||
|
}
|
||||||
|
|
||||||
token_verifier = api.TokenVerifier(
|
token_verifier = api.TokenVerifier(
|
||||||
settings.LIVEKIT_CONFIGURATION["api_key"],
|
settings.LIVEKIT_CONFIGURATION["api_key"],
|
||||||
settings.LIVEKIT_CONFIGURATION["api_secret"],
|
settings.LIVEKIT_CONFIGURATION["api_secret"],
|
||||||
@@ -135,14 +143,11 @@ class LiveKitEventsService:
|
|||||||
f"Unknown webhook type: {data.event}"
|
f"Unknown webhook type: {data.event}"
|
||||||
) from e
|
) from e
|
||||||
|
|
||||||
handler_name = f"_handle_{webhook_type.value}"
|
# Handle according to received webhook type
|
||||||
handler = getattr(self, handler_name, None)
|
handler = self._webhook_handlers.get(webhook_type.value)
|
||||||
|
|
||||||
if not handler or not callable(handler):
|
if handler is not None:
|
||||||
return
|
handler(data)
|
||||||
|
|
||||||
# pylint: disable=not-callable
|
|
||||||
handler(data)
|
|
||||||
|
|
||||||
def _handle_egress_updated(self, data):
|
def _handle_egress_updated(self, data):
|
||||||
"""Handle 'egress_updated' event."""
|
"""Handle 'egress_updated' event."""
|
||||||
@@ -195,6 +200,24 @@ class LiveKitEventsService:
|
|||||||
f"Failed to process limit reached event for recording {recording}"
|
f"Failed to process limit reached event for recording {recording}"
|
||||||
) from e
|
) from e
|
||||||
|
|
||||||
|
# Fallback for completion when no MinIO/S3 webhooks are configured
|
||||||
|
if (
|
||||||
|
not settings.RECORDING_STORAGE_EVENT_ENABLE
|
||||||
|
) and data.egress_info.status in [
|
||||||
|
api.EgressStatus.EGRESS_COMPLETE,
|
||||||
|
api.EgressStatus.EGRESS_LIMIT_REACHED,
|
||||||
|
]:
|
||||||
|
try:
|
||||||
|
self.recording_events.handle_complete(recording)
|
||||||
|
except RecordingNotSavableError:
|
||||||
|
logger.warning(
|
||||||
|
"Recording %s is not savable on egress complete "
|
||||||
|
"(already saved or in an error state); ignoring.",
|
||||||
|
recording.id,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Silently ignoring EGRESS_ABORTED, EGRESS_FAILED
|
||||||
|
|
||||||
def _handle_room_started(self, data):
|
def _handle_room_started(self, data):
|
||||||
"""Handle 'room_started' event."""
|
"""Handle 'room_started' event."""
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,318 @@
|
|||||||
|
"""
|
||||||
|
Unit tests for PostHogAnalytics.
|
||||||
|
"""
|
||||||
|
|
||||||
|
# pylint: disable=redefined-outer-name,unused-argument,protected-access
|
||||||
|
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
from django.contrib.auth.models import AnonymousUser
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from core.analytics.events import AnalyticsEvent
|
||||||
|
from core.analytics.posthog import PostHogAnalytics
|
||||||
|
from core.analytics.user_feature_flags import UserFeatureFlag
|
||||||
|
from core.factories import UserFactory
|
||||||
|
|
||||||
|
pytestmark = pytest.mark.django_db
|
||||||
|
|
||||||
|
|
||||||
|
# ==============================
|
||||||
|
# __init__
|
||||||
|
# ==============================
|
||||||
|
|
||||||
|
|
||||||
|
@patch("core.analytics.posthog.Posthog")
|
||||||
|
def test_init_constructs_posthog_client_with_api_key_and_host(mock_posthog_cls):
|
||||||
|
"""Should forward api_key and host to the Posthog SDK constructor."""
|
||||||
|
PostHogAnalytics(api_key="my-key", host="https://custom.i.posthog.com")
|
||||||
|
|
||||||
|
mock_posthog_cls.assert_called_once_with(
|
||||||
|
project_api_key="my-key",
|
||||||
|
host="https://custom.i.posthog.com",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@patch("core.analytics.posthog.Posthog")
|
||||||
|
def test_init_defaults_to_eu_host(mock_posthog_cls):
|
||||||
|
"""Should default host to the EU PostHog cloud when not specified."""
|
||||||
|
PostHogAnalytics(api_key="my-key")
|
||||||
|
|
||||||
|
_, kwargs = mock_posthog_cls.call_args
|
||||||
|
assert kwargs["host"] == "https://eu.i.posthog.com"
|
||||||
|
|
||||||
|
|
||||||
|
@patch("core.analytics.posthog.Posthog")
|
||||||
|
def test_init_forwards_extra_kwargs_to_client(mock_posthog_cls):
|
||||||
|
"""Should pass through arbitrary extra kwargs (e.g. debug, disabled) to the SDK."""
|
||||||
|
PostHogAnalytics(api_key="my-key", debug=True, disabled=False)
|
||||||
|
|
||||||
|
_, kwargs = mock_posthog_cls.call_args
|
||||||
|
assert kwargs["debug"] is True
|
||||||
|
assert kwargs["disabled"] is False
|
||||||
|
|
||||||
|
|
||||||
|
# ==============================
|
||||||
|
# _distinct_id
|
||||||
|
# ==============================
|
||||||
|
|
||||||
|
|
||||||
|
@patch("core.analytics.posthog.Posthog")
|
||||||
|
def test_distinct_id_returns_none_for_none_user(mock_posthog_cls):
|
||||||
|
"""Should return None when user is None."""
|
||||||
|
backend = PostHogAnalytics(api_key="test-api-key")
|
||||||
|
|
||||||
|
assert backend._distinct_id(None) is None
|
||||||
|
|
||||||
|
|
||||||
|
@patch("core.analytics.posthog.Posthog")
|
||||||
|
def test_distinct_id_returns_none_for_anonymous_user(mock_posthog_cls):
|
||||||
|
"""Should return None when user.is_authenticated is falsy."""
|
||||||
|
backend = PostHogAnalytics(api_key="test-api-key")
|
||||||
|
|
||||||
|
assert backend._distinct_id(AnonymousUser()) is None
|
||||||
|
|
||||||
|
|
||||||
|
@patch("core.analytics.posthog.Posthog")
|
||||||
|
def test_distinct_id_returns_none_when_attribute_missing(mock_posthog_cls):
|
||||||
|
"""Should return None when the user object has no is_authenticated attribute at all."""
|
||||||
|
backend = PostHogAnalytics(api_key="test-api-key")
|
||||||
|
|
||||||
|
assert backend._distinct_id(object()) is None
|
||||||
|
|
||||||
|
|
||||||
|
@patch("core.analytics.posthog.Posthog")
|
||||||
|
def test_distinct_id_returns_stringified_pk_for_authenticated_user(mock_posthog_cls):
|
||||||
|
"""Should return str(user.pk) for an authenticated user."""
|
||||||
|
backend = PostHogAnalytics(api_key="test-api-key")
|
||||||
|
user = UserFactory()
|
||||||
|
|
||||||
|
assert backend._distinct_id(user) == str(user.pk)
|
||||||
|
|
||||||
|
|
||||||
|
# ==============================
|
||||||
|
# identify
|
||||||
|
# ==============================
|
||||||
|
|
||||||
|
|
||||||
|
@patch("core.analytics.posthog.Posthog")
|
||||||
|
def test_identify_noop_for_anonymous_user(mock_posthog_cls):
|
||||||
|
"""Should not call the SDK when the user is anonymous."""
|
||||||
|
backend = PostHogAnalytics(api_key="test-api-key")
|
||||||
|
|
||||||
|
backend.identify(AnonymousUser(), {"email": "a@example.com"})
|
||||||
|
|
||||||
|
mock_posthog_cls.return_value.set.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
@patch("core.analytics.posthog.Posthog")
|
||||||
|
def test_identify_noop_for_none_user(mock_posthog_cls):
|
||||||
|
"""Should not call the SDK when user is None."""
|
||||||
|
backend = PostHogAnalytics(api_key="test-api-key")
|
||||||
|
|
||||||
|
backend.identify(None, {"email": "a@example.com"})
|
||||||
|
|
||||||
|
mock_posthog_cls.return_value.set.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
@patch("core.analytics.posthog.Posthog")
|
||||||
|
def test_identify_sends_set_properties_for_authenticated_user(mock_posthog_cls):
|
||||||
|
"""Should call capture with event=$identify and properties wrapped in $set."""
|
||||||
|
backend = PostHogAnalytics(api_key="test-api-key")
|
||||||
|
user = UserFactory()
|
||||||
|
|
||||||
|
backend.identify(user, {"email": "a@example.com", "name": "A"})
|
||||||
|
|
||||||
|
mock_posthog_cls.return_value.set.assert_called_once_with(
|
||||||
|
distinct_id=str(user.pk),
|
||||||
|
properties={"email": "a@example.com", "name": "A"},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@patch("core.analytics.posthog.Posthog")
|
||||||
|
def test_identify_defaults_properties_to_empty_dict(mock_posthog_cls):
|
||||||
|
"""Should send an empty $set payload when properties is None."""
|
||||||
|
backend = PostHogAnalytics(api_key="test-api-key")
|
||||||
|
user = UserFactory()
|
||||||
|
|
||||||
|
backend.identify(user, None)
|
||||||
|
|
||||||
|
mock_posthog_cls.return_value.set.assert_called_once_with(
|
||||||
|
distinct_id=str(user.pk),
|
||||||
|
properties={},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@patch("core.analytics.posthog.Posthog")
|
||||||
|
def test_identify_swallows_sdk_exceptions(mock_posthog_cls):
|
||||||
|
"""Should log and not raise when the SDK call fails."""
|
||||||
|
mock_posthog_cls.return_value.set.side_effect = RuntimeError("network down")
|
||||||
|
backend = PostHogAnalytics(api_key="test-api-key")
|
||||||
|
user = UserFactory()
|
||||||
|
|
||||||
|
# Must not propagate.
|
||||||
|
backend.identify(user, {"email": "a@example.com"})
|
||||||
|
|
||||||
|
|
||||||
|
# ==============================
|
||||||
|
# capture
|
||||||
|
# ==============================
|
||||||
|
|
||||||
|
|
||||||
|
@patch("core.analytics.posthog.Posthog")
|
||||||
|
def test_capture_noop_for_anonymous_user(mock_posthog_cls):
|
||||||
|
"""Should not call the SDK when the user is anonymous."""
|
||||||
|
backend = PostHogAnalytics(api_key="test-api-key")
|
||||||
|
|
||||||
|
backend.capture(AnonymousUser(), AnalyticsEvent.ROOM_CREATED, {"room_id": "1"})
|
||||||
|
|
||||||
|
mock_posthog_cls.return_value.capture.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
@patch("core.analytics.posthog.Posthog")
|
||||||
|
def test_capture_noop_for_none_user(mock_posthog_cls):
|
||||||
|
"""Should not call the SDK when user is None."""
|
||||||
|
backend = PostHogAnalytics(api_key="test-api-key")
|
||||||
|
|
||||||
|
backend.capture(None, AnalyticsEvent.ROOM_CREATED, {"room_id": "1"})
|
||||||
|
|
||||||
|
mock_posthog_cls.return_value.capture.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
@patch("core.analytics.posthog.Posthog")
|
||||||
|
def test_capture_sends_event_and_properties_for_authenticated_user(mock_posthog_cls):
|
||||||
|
"""Should call capture with the distinct_id, event name, and properties."""
|
||||||
|
backend = PostHogAnalytics(api_key="test-api-key")
|
||||||
|
user = UserFactory()
|
||||||
|
|
||||||
|
backend.capture(user, AnalyticsEvent.ROOM_CREATED, {"room_id": "room-1"})
|
||||||
|
|
||||||
|
mock_posthog_cls.return_value.capture.assert_called_once_with(
|
||||||
|
distinct_id=str(user.pk),
|
||||||
|
event="room_created",
|
||||||
|
properties={"room_id": "room-1"},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@patch("core.analytics.posthog.Posthog")
|
||||||
|
def test_capture_serializes_event_enum_to_plain_string(mock_posthog_cls):
|
||||||
|
"""Should send the wire string, not the AnalyticsEvent enum member, to the SDK."""
|
||||||
|
backend = PostHogAnalytics(api_key="test-api-key")
|
||||||
|
user = UserFactory()
|
||||||
|
|
||||||
|
backend.capture(user, AnalyticsEvent.ROOM_CREATED)
|
||||||
|
|
||||||
|
_, kwargs = mock_posthog_cls.return_value.capture.call_args
|
||||||
|
assert kwargs["event"] == "room_created"
|
||||||
|
assert isinstance(
|
||||||
|
kwargs["event"], str
|
||||||
|
) # not AnalyticsEvent, not StrEnum subclass leaking through
|
||||||
|
|
||||||
|
|
||||||
|
@patch("core.analytics.posthog.Posthog")
|
||||||
|
def test_capture_defaults_properties_to_empty_dict(mock_posthog_cls):
|
||||||
|
"""Should send an empty properties dict when properties is None."""
|
||||||
|
backend = PostHogAnalytics(api_key="test-api-key")
|
||||||
|
user = UserFactory()
|
||||||
|
|
||||||
|
backend.capture(user, AnalyticsEvent.ROOM_CREATED, None)
|
||||||
|
|
||||||
|
mock_posthog_cls.return_value.capture.assert_called_once_with(
|
||||||
|
distinct_id=str(user.pk),
|
||||||
|
event="room_created",
|
||||||
|
properties={},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@patch("core.analytics.posthog.Posthog")
|
||||||
|
def test_capture_swallows_sdk_exceptions(mock_posthog_cls):
|
||||||
|
"""Should log and not raise when the SDK call fails."""
|
||||||
|
mock_posthog_cls.return_value.capture.side_effect = RuntimeError("network down")
|
||||||
|
backend = PostHogAnalytics(api_key="test-api-key")
|
||||||
|
user = UserFactory()
|
||||||
|
|
||||||
|
# Must not propagate.
|
||||||
|
backend.capture(user, AnalyticsEvent.ROOM_CREATED, {"room_id": "1"})
|
||||||
|
|
||||||
|
|
||||||
|
@patch("core.analytics.posthog.Posthog")
|
||||||
|
def test_capture_logs_the_failing_event_name_on_exception(mock_posthog_cls, caplog):
|
||||||
|
"""Should log which event failed, to aid debugging without crashing the caller."""
|
||||||
|
mock_posthog_cls.return_value.capture.side_effect = RuntimeError("network down")
|
||||||
|
backend = PostHogAnalytics(api_key="test-api-key")
|
||||||
|
user = UserFactory()
|
||||||
|
|
||||||
|
with caplog.at_level("ERROR"):
|
||||||
|
backend.capture(user, AnalyticsEvent.ROOM_CREATED)
|
||||||
|
|
||||||
|
assert any("PostHog capture failed" in record.message for record in caplog.records)
|
||||||
|
|
||||||
|
|
||||||
|
# ==============================
|
||||||
|
# feature flags
|
||||||
|
# ==============================
|
||||||
|
|
||||||
|
|
||||||
|
@patch("core.analytics.posthog.Posthog")
|
||||||
|
def test_compute_feature_flags_returns_all_catalog_entries(mock_posthog_cls):
|
||||||
|
"""Should map every declared feature flag key to the SDK evaluated value."""
|
||||||
|
backend = PostHogAnalytics(api_key="test-api-key")
|
||||||
|
user = UserFactory()
|
||||||
|
|
||||||
|
mock_posthog_cls.return_value.evaluate_flags.return_value.get_flag.return_value = (
|
||||||
|
True
|
||||||
|
)
|
||||||
|
|
||||||
|
flags = backend._fetch_user_feature_flags(user)
|
||||||
|
|
||||||
|
assert flags == {UserFeatureFlag.TRANSCRIPT_SUMMARY_ENABLED: True}
|
||||||
|
mock_posthog_cls.return_value.evaluate_flags.assert_called_once_with(str(user.pk))
|
||||||
|
mock_posthog_cls.return_value.evaluate_flags.return_value.get_flag.assert_called_once_with(
|
||||||
|
UserFeatureFlag.TRANSCRIPT_SUMMARY_ENABLED.value
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@patch("core.analytics.posthog.cache.get_or_set")
|
||||||
|
@patch("core.analytics.posthog.Posthog")
|
||||||
|
def test_get_feature_flags_uses_cache_get_or_set(
|
||||||
|
mock_posthog_cls, mock_cache_get_or_set
|
||||||
|
):
|
||||||
|
"""Should cache feature flags by user distinct id with configured TTL."""
|
||||||
|
cached_flags = {UserFeatureFlag.TRANSCRIPT_SUMMARY_ENABLED: False}
|
||||||
|
mock_cache_get_or_set.return_value = cached_flags
|
||||||
|
backend = PostHogAnalytics(api_key="test-api-key", feature_flags_cache_ttl=120)
|
||||||
|
user = UserFactory()
|
||||||
|
|
||||||
|
flags = backend.get_user_feature_flags(user)
|
||||||
|
|
||||||
|
assert flags == cached_flags
|
||||||
|
mock_cache_get_or_set.assert_called_once()
|
||||||
|
args, kwargs = mock_cache_get_or_set.call_args
|
||||||
|
assert kwargs["timeout"] == 120
|
||||||
|
assert args[0] == f"user_feature_flags:{user.pk}"
|
||||||
|
assert callable(kwargs["default"])
|
||||||
|
|
||||||
|
|
||||||
|
@patch("core.analytics.posthog.Posthog")
|
||||||
|
def test_get_feature_flags_returns_empty_dict_on_exception(mock_posthog_cls):
|
||||||
|
"""Should swallow failures and return an empty mapping."""
|
||||||
|
backend = PostHogAnalytics(api_key="test-api-key")
|
||||||
|
user = UserFactory()
|
||||||
|
with patch("core.analytics.posthog.cache.get_or_set", side_effect=RuntimeError):
|
||||||
|
assert backend.get_user_feature_flags(user) == {}
|
||||||
|
|
||||||
|
|
||||||
|
# ==============================
|
||||||
|
# shutdown
|
||||||
|
# ==============================
|
||||||
|
|
||||||
|
|
||||||
|
@patch("core.analytics.posthog.Posthog")
|
||||||
|
def test_shutdown_flushes_the_client(mock_posthog_cls):
|
||||||
|
"""Should delegate to the SDK's shutdown to flush pending events."""
|
||||||
|
backend = PostHogAnalytics(api_key="test-api-key")
|
||||||
|
|
||||||
|
backend.shutdown()
|
||||||
|
|
||||||
|
mock_posthog_cls.return_value.shutdown.assert_called_once()
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
"""Tests for the clean_pending_files management command."""
|
||||||
|
|
||||||
|
from datetime import timedelta
|
||||||
|
|
||||||
|
from django.core.files.storage import default_storage
|
||||||
|
from django.core.management import call_command
|
||||||
|
from django.utils import timezone
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from core import factories, models
|
||||||
|
|
||||||
|
pytestmark = pytest.mark.django_db
|
||||||
|
|
||||||
|
|
||||||
|
def test_clean_pending_files_no_stale_files():
|
||||||
|
"""Nothing happens when there are no stale pending files."""
|
||||||
|
call_command("clean_pending_files")
|
||||||
|
|
||||||
|
|
||||||
|
def test_clean_pending_files_recent_pending_not_deleted():
|
||||||
|
"""Recent pending files (within threshold) should not be deleted."""
|
||||||
|
file = factories.FileFactory(
|
||||||
|
type=models.FileTypeChoices.BACKGROUND_IMAGE,
|
||||||
|
update_upload_state=models.FileUploadStateChoices.PENDING,
|
||||||
|
upload_bytes=b"hello",
|
||||||
|
)
|
||||||
|
|
||||||
|
call_command("clean_pending_files")
|
||||||
|
|
||||||
|
file.refresh_from_db()
|
||||||
|
assert file.deleted_at is None
|
||||||
|
assert default_storage.exists(file.file_key)
|
||||||
|
|
||||||
|
|
||||||
|
def test_clean_pending_files_old_pending_deleted():
|
||||||
|
"""Pending files older than the threshold should be deleted."""
|
||||||
|
old_date = timezone.now() - timedelta(hours=49)
|
||||||
|
file = factories.FileFactory(
|
||||||
|
type=models.FileTypeChoices.BACKGROUND_IMAGE,
|
||||||
|
update_upload_state=models.FileUploadStateChoices.PENDING,
|
||||||
|
upload_bytes=b"hello",
|
||||||
|
)
|
||||||
|
assert default_storage.exists(file.file_key)
|
||||||
|
models.File.objects.filter(pk=file.pk).update(created_at=old_date)
|
||||||
|
|
||||||
|
call_command("clean_pending_files")
|
||||||
|
|
||||||
|
assert not models.File.objects.filter(pk=file.pk).exists()
|
||||||
|
assert not default_storage.exists(file.file_key)
|
||||||
|
|
||||||
|
|
||||||
|
def test_clean_pending_files_old_non_pending_not_deleted():
|
||||||
|
"""Old files that are not pending should not be deleted."""
|
||||||
|
old_date = timezone.now() - timedelta(hours=49)
|
||||||
|
file = factories.FileFactory(
|
||||||
|
type=models.FileTypeChoices.BACKGROUND_IMAGE,
|
||||||
|
update_upload_state=models.FileUploadStateChoices.READY,
|
||||||
|
)
|
||||||
|
models.File.objects.filter(pk=file.pk).update(created_at=old_date)
|
||||||
|
|
||||||
|
call_command("clean_pending_files")
|
||||||
|
|
||||||
|
file.refresh_from_db()
|
||||||
|
assert file.deleted_at is None
|
||||||
|
assert file.hard_deleted_at is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_clean_pending_files_custom_hours():
|
||||||
|
"""The --hours argument controls the age threshold."""
|
||||||
|
old_date = timezone.now() - timedelta(hours=10)
|
||||||
|
file = factories.FileFactory(
|
||||||
|
type=models.FileTypeChoices.BACKGROUND_IMAGE,
|
||||||
|
update_upload_state=models.FileUploadStateChoices.PENDING,
|
||||||
|
upload_bytes=b"hello",
|
||||||
|
)
|
||||||
|
models.File.objects.filter(pk=file.pk).update(created_at=old_date)
|
||||||
|
|
||||||
|
# Default 24h threshold -> file not deleted
|
||||||
|
call_command("clean_pending_files")
|
||||||
|
|
||||||
|
file.refresh_from_db()
|
||||||
|
assert file.deleted_at is None
|
||||||
|
assert default_storage.exists(file.file_key)
|
||||||
|
|
||||||
|
# 8h threshold -> file deleted
|
||||||
|
call_command("clean_pending_files", "--hours=8")
|
||||||
|
|
||||||
|
assert not models.File.objects.filter(pk=file.pk).exists()
|
||||||
|
assert not default_storage.exists(file.file_key)
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
"""Tests for the purge_deleted_files management command."""
|
||||||
|
|
||||||
|
from datetime import timedelta
|
||||||
|
from io import StringIO
|
||||||
|
from random import randint
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
from django.core.files.storage import default_storage
|
||||||
|
from django.core.management import call_command
|
||||||
|
from django.utils import timezone
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from core import factories, models
|
||||||
|
from core.tasks.file import process_file_deletion
|
||||||
|
|
||||||
|
pytestmark = pytest.mark.django_db
|
||||||
|
|
||||||
|
|
||||||
|
def test_purge_deleted_files_no_deleted_files(django_assert_num_queries):
|
||||||
|
"""Nothing happens when there are no purgeable files."""
|
||||||
|
with django_assert_num_queries(1):
|
||||||
|
call_command("purge_deleted_files")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db(transaction=True)
|
||||||
|
def test_purge_deleted_files_success(settings):
|
||||||
|
"""
|
||||||
|
Queue deletion for:
|
||||||
|
- hard-deleted files
|
||||||
|
- soft-deleted files past retention period + grace period.
|
||||||
|
"""
|
||||||
|
out = StringIO()
|
||||||
|
|
||||||
|
settings.FILE_PURGE_GRACE_DAYS = grace = randint(1, 20)
|
||||||
|
|
||||||
|
now = timezone.now()
|
||||||
|
purge_now = now - timedelta(days=grace)
|
||||||
|
|
||||||
|
not_deleted_file = factories.FileFactory(
|
||||||
|
type=models.FileTypeChoices.BACKGROUND_IMAGE,
|
||||||
|
upload_bytes=b"hello",
|
||||||
|
)
|
||||||
|
|
||||||
|
with patch("django.utils.timezone.now", return_value=now):
|
||||||
|
not_purgeable_file = factories.FileFactory(
|
||||||
|
type=models.FileTypeChoices.BACKGROUND_IMAGE,
|
||||||
|
upload_bytes=b"hello",
|
||||||
|
)
|
||||||
|
not_purgeable_file.soft_delete()
|
||||||
|
|
||||||
|
with patch("django.utils.timezone.now", return_value=purge_now):
|
||||||
|
purgeable_file = factories.FileFactory(
|
||||||
|
type=models.FileTypeChoices.BACKGROUND_IMAGE,
|
||||||
|
upload_bytes=b"hello",
|
||||||
|
)
|
||||||
|
purgeable_file.soft_delete()
|
||||||
|
|
||||||
|
hard_deleted_file = factories.FileFactory(
|
||||||
|
type=models.FileTypeChoices.BACKGROUND_IMAGE,
|
||||||
|
upload_bytes=b"hello",
|
||||||
|
)
|
||||||
|
hard_deleted_file.soft_delete()
|
||||||
|
hard_deleted_file.hard_delete()
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"core.management.commands.purge_deleted_files.process_file_deletion.delay",
|
||||||
|
side_effect=process_file_deletion,
|
||||||
|
) as mock_delay:
|
||||||
|
call_command("purge_deleted_files", stdout=out)
|
||||||
|
|
||||||
|
assert "Purged 2 deleted file(s)." in out.getvalue()
|
||||||
|
assert mock_delay.call_count == 2
|
||||||
|
called_ids = {call.args[0] for call in mock_delay.call_args_list}
|
||||||
|
assert called_ids == {purgeable_file.id, hard_deleted_file.id}
|
||||||
|
|
||||||
|
assert models.File.objects.filter(id=not_deleted_file.id).exists()
|
||||||
|
assert models.File.objects.filter(id=not_purgeable_file.id).exists()
|
||||||
|
assert not models.File.objects.filter(id=purgeable_file.id).exists()
|
||||||
|
assert not models.File.objects.filter(id=hard_deleted_file.id).exists()
|
||||||
|
|
||||||
|
assert default_storage.exists(not_deleted_file.file_key)
|
||||||
|
assert default_storage.exists(not_purgeable_file.file_key)
|
||||||
|
assert not default_storage.exists(purgeable_file.file_key)
|
||||||
|
assert not default_storage.exists(hard_deleted_file.file_key)
|
||||||
@@ -36,6 +36,26 @@ def test_merge_keeps_most_recently_created_user():
|
|||||||
assert User.objects.filter(id=user2.id).exists()
|
assert User.objects.filter(id=user2.id).exists()
|
||||||
|
|
||||||
|
|
||||||
|
def test_merge_user_case_insensitive():
|
||||||
|
"""Emails differing only by case should be treated as duplicates and merged,
|
||||||
|
keeping the most recently created user."""
|
||||||
|
user1 = UserFactory(email="Dup@example.com")
|
||||||
|
user2 = UserFactory(email="dup@example.com")
|
||||||
|
call_command("merge_duplicate_users")
|
||||||
|
|
||||||
|
assert not User.objects.filter(id=user1.id).exists()
|
||||||
|
assert User.objects.filter(id=user2.id).exists()
|
||||||
|
|
||||||
|
user3 = UserFactory(email="joe@example.com")
|
||||||
|
user4 = UserFactory(email="Joe@example.com")
|
||||||
|
call_command("merge_duplicate_users")
|
||||||
|
assert not User.objects.filter(id=user3.id).exists()
|
||||||
|
assert User.objects.filter(id=user4.id).exists()
|
||||||
|
|
||||||
|
user4.refresh_from_db()
|
||||||
|
assert user4.email.islower()
|
||||||
|
|
||||||
|
|
||||||
def test_merge_deletes_all_stale_users():
|
def test_merge_deletes_all_stale_users():
|
||||||
"""Command should delete all stale users and keep only the most recently created one."""
|
"""Command should delete all stale users and keep only the most recently created one."""
|
||||||
email = "many@example.com"
|
email = "many@example.com"
|
||||||
@@ -457,4 +477,4 @@ def test_merge_email_filter_is_case_insensitive():
|
|||||||
UserFactory(email="user1@Example.com")
|
UserFactory(email="user1@Example.com")
|
||||||
UserFactory(email="user1@Example.com")
|
UserFactory(email="user1@Example.com")
|
||||||
call_command("merge_duplicate_users", email_filter="@example.com")
|
call_command("merge_duplicate_users", email_filter="@example.com")
|
||||||
assert User.objects.filter(email="user1@Example.com").count() == 1
|
assert User.objects.filter(email="user1@example.com").count() == 1
|
||||||
|
|||||||
@@ -136,10 +136,10 @@ def test_authenticate_header():
|
|||||||
|
|
||||||
|
|
||||||
def test_multiple_spaces_in_auth_header(settings):
|
def test_multiple_spaces_in_auth_header(settings):
|
||||||
"""Test failure when Authorization header contains multiple spaces."""
|
"""Test success when Authorization header contains multiple spaces."""
|
||||||
settings.RECORDING_STORAGE_EVENT_TOKEN = "valid-test-token"
|
settings.RECORDING_STORAGE_EVENT_TOKEN = "valid-test-token"
|
||||||
request = RequestFactory().get("/")
|
request = RequestFactory().get("/")
|
||||||
request.headers = {"Authorization": "Bearer extra-spaces-token"}
|
request.headers = {"Authorization": "Bearer extra-spaces-token"}
|
||||||
|
|
||||||
with pytest.raises(AuthenticationFailed, match="Invalid authorization header"):
|
header = StorageEventAuthentication().authenticate_header(request)
|
||||||
StorageEventAuthentication().authenticate(request)
|
assert header == "Bearer realm='Storage event API'"
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ from django.contrib.sites.models import Site
|
|||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from core import factories, models
|
from core import factories, models
|
||||||
|
from core.analytics import UserFeatureFlag
|
||||||
from core.recording.event.notification import NotificationService, notification_service
|
from core.recording.event.notification import NotificationService, notification_service
|
||||||
|
|
||||||
pytestmark = pytest.mark.django_db
|
pytestmark = pytest.mark.django_db
|
||||||
@@ -243,3 +244,177 @@ def test_notify_user_by_email_smtp_exception(mocked_current_site, caplog):
|
|||||||
assert result is False
|
assert result is False
|
||||||
assert mock_send_mail.call_count == 2
|
assert mock_send_mail.call_count == 2
|
||||||
assert "notification could not be sent:" in caplog.text
|
assert "notification could not be sent:" in caplog.text
|
||||||
|
|
||||||
|
|
||||||
|
@mock.patch("core.recording.event.notification.requests.post")
|
||||||
|
@mock.patch("core.recording.event.notification.generate_download_s3_url")
|
||||||
|
@mock.patch.object(
|
||||||
|
NotificationService, "_get_recording_timestamps", new_callable=mock.AsyncMock
|
||||||
|
)
|
||||||
|
def test_notify_summary_service_post_args_with_metadata(
|
||||||
|
mock_get_recording_timestamps,
|
||||||
|
mock_generate_download_s3_url,
|
||||||
|
mock_post,
|
||||||
|
settings,
|
||||||
|
):
|
||||||
|
"""Test summary notification computed request args when metadata is enabled."""
|
||||||
|
settings.SUMMARY_SERVICE_VERSION = 2
|
||||||
|
settings.SUMMARY_SERVICE_ENDPOINT = "https://summary.test/api/v2/tasks"
|
||||||
|
settings.SUMMARY_SERVICE_API_TOKEN = "summary-token"
|
||||||
|
settings.RECORDING_DOWNLOAD_BASE_URL = "https://app.test/recordings"
|
||||||
|
settings.SCREEN_RECORDING_BASE_URL = None
|
||||||
|
settings.METADATA_COLLECTOR_ENABLED = True
|
||||||
|
settings.METADATA_COLLECTOR_OUTPUT_FOLDER = "recordings-metadata"
|
||||||
|
|
||||||
|
recording = factories.RecordingFactory(
|
||||||
|
room__name="Engineering Sync",
|
||||||
|
worker_id="egress-1",
|
||||||
|
options={"collect_metadata": True, "language": "en-us"},
|
||||||
|
)
|
||||||
|
owner = factories.UserFactory(
|
||||||
|
email="owner@test.com",
|
||||||
|
sub="owner-sub",
|
||||||
|
language="fr-fr",
|
||||||
|
timezone="Europe/Paris",
|
||||||
|
)
|
||||||
|
factories.UserRecordingAccessFactory(
|
||||||
|
recording=recording, role=models.RoleChoices.OWNER, user=owner
|
||||||
|
)
|
||||||
|
|
||||||
|
started_at = datetime.datetime(2026, 1, 2, 10, 30, tzinfo=datetime.timezone.utc)
|
||||||
|
ended_at = datetime.datetime(2026, 1, 2, 11, 45, tzinfo=datetime.timezone.utc)
|
||||||
|
mock_get_recording_timestamps.return_value = (started_at, ended_at)
|
||||||
|
mock_generate_download_s3_url.side_effect = [
|
||||||
|
"https://storage.test/metadata.json",
|
||||||
|
"https://storage.test/recording.ogg",
|
||||||
|
]
|
||||||
|
|
||||||
|
mock_response = mock.Mock()
|
||||||
|
mock_response.raise_for_status.return_value = None
|
||||||
|
mock_response.json.return_value = {"job_id": "job-42"}
|
||||||
|
mock_post.return_value = mock_response
|
||||||
|
|
||||||
|
result = NotificationService._notify_summary_service(recording)
|
||||||
|
recording.refresh_from_db()
|
||||||
|
|
||||||
|
assert result is True
|
||||||
|
assert recording.external_process_id == "job-42"
|
||||||
|
|
||||||
|
metadata_filename = (
|
||||||
|
f"{settings.METADATA_COLLECTOR_OUTPUT_FOLDER}/{recording.id}-metadata.json"
|
||||||
|
)
|
||||||
|
expected_payload = {
|
||||||
|
"user_sub": owner.sub,
|
||||||
|
"user_email": owner.email,
|
||||||
|
"cloud_storage_url": "https://storage.test/recording.ogg",
|
||||||
|
"language": "en-us",
|
||||||
|
"context_language": owner.language,
|
||||||
|
"push_to_docs_config": {
|
||||||
|
"user_email": owner.email,
|
||||||
|
"title": 'Réunion "Engineering Sync" du 2026-01-02 à 11:30',
|
||||||
|
"download_link": f"{settings.RECORDING_DOWNLOAD_BASE_URL}/{recording.id}",
|
||||||
|
"auto_create_summary": False,
|
||||||
|
"form_link": None,
|
||||||
|
},
|
||||||
|
"metadata": {
|
||||||
|
"cloud_storage_url": "https://storage.test/metadata.json",
|
||||||
|
"started_at": started_at.isoformat(),
|
||||||
|
"ended_at": ended_at.isoformat(),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
expected_headers = {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"Authorization": "Bearer summary-token",
|
||||||
|
}
|
||||||
|
mock_post.assert_called_once_with(
|
||||||
|
"https://summary.test/api/v2/tasks",
|
||||||
|
json=expected_payload,
|
||||||
|
headers=expected_headers,
|
||||||
|
timeout=30,
|
||||||
|
)
|
||||||
|
assert mock_generate_download_s3_url.call_args_list == [
|
||||||
|
mock.call(metadata_filename, expires_in=60 * 60 * 24, override_domain=False),
|
||||||
|
mock.call(recording.key, expires_in=60 * 60 * 24, override_domain=False),
|
||||||
|
]
|
||||||
|
mock_get_recording_timestamps.assert_awaited_once_with("egress-1")
|
||||||
|
|
||||||
|
|
||||||
|
@mock.patch("core.recording.event.notification.requests.post")
|
||||||
|
@mock.patch("core.recording.event.notification.generate_download_s3_url")
|
||||||
|
@mock.patch.object(
|
||||||
|
NotificationService, "_get_recording_timestamps", new_callable=mock.AsyncMock
|
||||||
|
)
|
||||||
|
@pytest.mark.parametrize("auto_create_summary_enabled", [False, True])
|
||||||
|
def test_notify_summary_service_post_args_without_metadata(
|
||||||
|
mock_get_recording_timestamps,
|
||||||
|
mock_generate_download_s3_url,
|
||||||
|
mock_post,
|
||||||
|
auto_create_summary_enabled,
|
||||||
|
settings,
|
||||||
|
):
|
||||||
|
"""Test summary notification computed request args when metadata is not available."""
|
||||||
|
settings.SUMMARY_SERVICE_VERSION = 2
|
||||||
|
settings.SUMMARY_SERVICE_ENDPOINT = "https://summary.test/api/v2/tasks"
|
||||||
|
settings.SUMMARY_SERVICE_API_TOKEN = "summary-token"
|
||||||
|
settings.RECORDING_DOWNLOAD_BASE_URL = "https://app.test/recordings"
|
||||||
|
settings.SCREEN_RECORDING_BASE_URL = None
|
||||||
|
settings.METADATA_COLLECTOR_ENABLED = False
|
||||||
|
|
||||||
|
recording = factories.RecordingFactory(room__name="Daily")
|
||||||
|
owner = factories.UserFactory(
|
||||||
|
email="owner@test.com",
|
||||||
|
sub="owner-sub",
|
||||||
|
language="en-us",
|
||||||
|
timezone="UTC",
|
||||||
|
)
|
||||||
|
factories.UserRecordingAccessFactory(
|
||||||
|
recording=recording, role=models.RoleChoices.OWNER, user=owner
|
||||||
|
)
|
||||||
|
|
||||||
|
mock_get_recording_timestamps.return_value = (None, None)
|
||||||
|
mock_generate_download_s3_url.return_value = "https://storage.test/recording.mp4"
|
||||||
|
|
||||||
|
mock_response = mock.Mock()
|
||||||
|
mock_response.raise_for_status.return_value = None
|
||||||
|
mock_response.json.return_value = {"job_id": "job-51"}
|
||||||
|
mock_post.return_value = mock_response
|
||||||
|
|
||||||
|
with mock.patch(
|
||||||
|
"core.recording.event.notification.is_user_feature_flag_enabled",
|
||||||
|
return_value=auto_create_summary_enabled,
|
||||||
|
) as mock_is_feature_flag_enabled:
|
||||||
|
result = NotificationService._notify_summary_service(recording)
|
||||||
|
|
||||||
|
assert result is True
|
||||||
|
expected_payload = {
|
||||||
|
"user_sub": owner.sub,
|
||||||
|
"user_email": owner.email,
|
||||||
|
"cloud_storage_url": "https://storage.test/recording.mp4",
|
||||||
|
"language": "en",
|
||||||
|
"context_language": owner.language,
|
||||||
|
"push_to_docs_config": {
|
||||||
|
"user_email": owner.email,
|
||||||
|
"title": "Transcription",
|
||||||
|
"download_link": f"{settings.RECORDING_DOWNLOAD_BASE_URL}/{recording.id}",
|
||||||
|
"auto_create_summary": auto_create_summary_enabled,
|
||||||
|
"form_link": None,
|
||||||
|
},
|
||||||
|
"metadata": None,
|
||||||
|
}
|
||||||
|
expected_headers = {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"Authorization": "Bearer summary-token",
|
||||||
|
}
|
||||||
|
mock_post.assert_called_once_with(
|
||||||
|
"https://summary.test/api/v2/tasks",
|
||||||
|
json=expected_payload,
|
||||||
|
headers=expected_headers,
|
||||||
|
timeout=30,
|
||||||
|
)
|
||||||
|
mock_generate_download_s3_url.assert_called_once_with(
|
||||||
|
recording.key, expires_in=60 * 60 * 24, override_domain=False
|
||||||
|
)
|
||||||
|
mock_get_recording_timestamps.assert_awaited_once_with(recording.worker_id)
|
||||||
|
mock_is_feature_flag_enabled.assert_called_once_with(
|
||||||
|
owner, UserFeatureFlag.TRANSCRIPT_SUMMARY_ENABLED
|
||||||
|
)
|
||||||
|
|||||||
@@ -360,6 +360,75 @@ def test_s3_parse_unrecognized_extension(s3_parser):
|
|||||||
s3_parser.parse(event_with_unknown_ext)
|
s3_parser.parse(event_with_unknown_ext)
|
||||||
|
|
||||||
|
|
||||||
|
def test_s3_parser_keeps_encoded_filepath_compatible(settings):
|
||||||
|
"""Test S3 parser keeps already encoded object keys compatible."""
|
||||||
|
settings.RECORDING_OUTPUT_FOLDER = "recordings"
|
||||||
|
|
||||||
|
recording_id = "80ae9fe5-639a-438b-b86e-9e3dd2d55f4d"
|
||||||
|
parser = S3Parser(bucket_name="recordings-bucket")
|
||||||
|
|
||||||
|
data = {
|
||||||
|
"Records": [
|
||||||
|
{
|
||||||
|
"s3": {
|
||||||
|
"bucket": {"name": "recordings-bucket"},
|
||||||
|
"object": {
|
||||||
|
"key": f"recordings%2F{recording_id}.mp4",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
assert parser.get_recording_id(data) == recording_id
|
||||||
|
|
||||||
|
|
||||||
|
def test_s3_parser_accepts_unencoded_filepath(settings):
|
||||||
|
"""Test S3 parser accepts raw object keys with slash separators."""
|
||||||
|
settings.RECORDING_OUTPUT_FOLDER = "recordings"
|
||||||
|
|
||||||
|
recording_id = "80ae9fe5-639a-438b-b86e-9e3dd2d55f4d"
|
||||||
|
parser = S3Parser(bucket_name="recordings-bucket")
|
||||||
|
|
||||||
|
data = {
|
||||||
|
"Records": [
|
||||||
|
{
|
||||||
|
"s3": {
|
||||||
|
"bucket": {"name": "recordings-bucket"},
|
||||||
|
"object": {
|
||||||
|
"key": f"recordings/{recording_id}.mp4",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
assert parser.get_recording_id(data) == recording_id
|
||||||
|
|
||||||
|
|
||||||
|
def test_s3_parser_preserves_plus_signs_in_encoded_filepath(settings):
|
||||||
|
"""Test S3 parser preserves plus signs in already encoded object keys."""
|
||||||
|
settings.RECORDING_OUTPUT_FOLDER = "recordings"
|
||||||
|
|
||||||
|
recording_id = "80ae9fe5-639a-438b-b86e-9e3dd2d55f4d"
|
||||||
|
parser = S3Parser(bucket_name="recordings-bucket")
|
||||||
|
|
||||||
|
data = {
|
||||||
|
"Records": [
|
||||||
|
{
|
||||||
|
"s3": {
|
||||||
|
"bucket": {"name": "recordings-bucket"},
|
||||||
|
"object": {
|
||||||
|
"key": f"folder+name%2Frecordings%2F{recording_id}.mp4",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
assert parser.get_recording_id(data) == recording_id
|
||||||
|
|
||||||
|
|
||||||
def test_s3_get_recording_id_success(s3_parser, valid_s3_event):
|
def test_s3_get_recording_id_success(s3_parser, valid_s3_event):
|
||||||
"""Test successful extraction of recording ID from S3 event."""
|
"""Test successful extraction of recording ID from S3 event."""
|
||||||
recording_id = s3_parser.get_recording_id(valid_s3_event)
|
recording_id = s3_parser.get_recording_id(valid_s3_event)
|
||||||
|
|||||||
@@ -0,0 +1,134 @@
|
|||||||
|
"""
|
||||||
|
Test recordings API endpoints: external process hook.
|
||||||
|
"""
|
||||||
|
|
||||||
|
# pylint: disable=redefined-outer-name,unused-argument
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from ...factories import RecordingFactory
|
||||||
|
from ...models import RecordingStatusChoices
|
||||||
|
|
||||||
|
pytestmark = pytest.mark.django_db
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def external_process_settings(settings):
|
||||||
|
"""Configure authentication token for the external process webhook."""
|
||||||
|
settings.SUMMARY_SERVICE_WEBHOOK_API_TOKEN = "testWebhookToken"
|
||||||
|
return settings
|
||||||
|
|
||||||
|
|
||||||
|
def test_external_process_event_missing_authorization_header(
|
||||||
|
external_process_settings, client
|
||||||
|
):
|
||||||
|
"""Requests without authorization must be rejected."""
|
||||||
|
response = client.post(
|
||||||
|
"/api/v1.0/recordings/external-process-hook/",
|
||||||
|
{"job_id": "job-1", "type": "transcript", "status": "success"},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 401
|
||||||
|
|
||||||
|
|
||||||
|
def test_external_process_event_wrong_bearer_token(external_process_settings, client):
|
||||||
|
"""Requests with invalid bearer token must be rejected."""
|
||||||
|
response = client.post(
|
||||||
|
"/api/v1.0/recordings/external-process-hook/",
|
||||||
|
{"job_id": "job-1", "type": "transcript", "status": "success"},
|
||||||
|
HTTP_AUTHORIZATION="Bearer wrongToken",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 401
|
||||||
|
|
||||||
|
|
||||||
|
def test_external_process_event_missing_job_id(external_process_settings, client):
|
||||||
|
"""Payload without job_id must fail validation."""
|
||||||
|
response = client.post(
|
||||||
|
"/api/v1.0/recordings/external-process-hook/",
|
||||||
|
{"type": "transcript", "status": "success"},
|
||||||
|
HTTP_AUTHORIZATION="Bearer testWebhookToken",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 400
|
||||||
|
assert response.json() == {"job_id": ["This field is required."]}
|
||||||
|
|
||||||
|
|
||||||
|
def test_external_process_event_success_updates_recording_status(
|
||||||
|
external_process_settings, client
|
||||||
|
):
|
||||||
|
"""A successful transcript process should update recording status."""
|
||||||
|
recording = RecordingFactory(
|
||||||
|
status=RecordingStatusChoices.SAVED,
|
||||||
|
external_process_id="job-123",
|
||||||
|
)
|
||||||
|
|
||||||
|
response = client.post(
|
||||||
|
"/api/v1.0/recordings/external-process-hook/",
|
||||||
|
{"job_id": "job-123", "type": "transcript", "status": "success"},
|
||||||
|
HTTP_AUTHORIZATION="Bearer testWebhookToken",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.json() == {"message": "Event processed."}
|
||||||
|
|
||||||
|
recording.refresh_from_db()
|
||||||
|
assert recording.status == RecordingStatusChoices.EXTERNAL_PROCESS_SUCCESSFUL
|
||||||
|
|
||||||
|
|
||||||
|
def test_external_process_event_failure_updates_recording_status(
|
||||||
|
external_process_settings, client
|
||||||
|
):
|
||||||
|
"""A failing transcript process should update recording status."""
|
||||||
|
recording = RecordingFactory(
|
||||||
|
status=RecordingStatusChoices.SAVED,
|
||||||
|
external_process_id="job-456",
|
||||||
|
)
|
||||||
|
|
||||||
|
response = client.post(
|
||||||
|
"/api/v1.0/recordings/external-process-hook/",
|
||||||
|
{"job_id": "job-456", "type": "transcript", "status": "failure"},
|
||||||
|
HTTP_AUTHORIZATION="Bearer testWebhookToken",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.json() == {"message": "Event processed."}
|
||||||
|
|
||||||
|
recording.refresh_from_db()
|
||||||
|
assert recording.status == RecordingStatusChoices.EXTERNAL_PROCESS_FAILED
|
||||||
|
|
||||||
|
|
||||||
|
def test_external_process_event_unknown_recording_is_ignored(
|
||||||
|
external_process_settings, client
|
||||||
|
):
|
||||||
|
"""Unknown job_id should not fail the webhook processing."""
|
||||||
|
response = client.post(
|
||||||
|
"/api/v1.0/recordings/external-process-hook/",
|
||||||
|
{"job_id": "missing-job", "type": "transcript", "status": "success"},
|
||||||
|
HTTP_AUTHORIZATION="Bearer testWebhookToken",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.json() == {"message": "Event processed."}
|
||||||
|
|
||||||
|
|
||||||
|
def test_external_process_event_non_transcript_event_does_not_change_status(
|
||||||
|
external_process_settings, client
|
||||||
|
):
|
||||||
|
"""Only transcript events should update recording status."""
|
||||||
|
recording = RecordingFactory(
|
||||||
|
status=RecordingStatusChoices.SAVED,
|
||||||
|
external_process_id="job-789",
|
||||||
|
)
|
||||||
|
|
||||||
|
response = client.post(
|
||||||
|
"/api/v1.0/recordings/external-process-hook/",
|
||||||
|
{"job_id": "job-789", "type": "thumbnail", "status": "success"},
|
||||||
|
HTTP_AUTHORIZATION="Bearer testWebhookToken",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.json() == {"message": "Event processed."}
|
||||||
|
|
||||||
|
recording.refresh_from_db()
|
||||||
|
assert recording.status == RecordingStatusChoices.SAVED
|
||||||
@@ -224,3 +224,44 @@ def test_save_recording_success(recording_settings, mock_get_parser, client, sta
|
|||||||
|
|
||||||
recording.refresh_from_db()
|
recording.refresh_from_db()
|
||||||
assert recording.status == RecordingStatusChoices.SAVED
|
assert recording.status == RecordingStatusChoices.SAVED
|
||||||
|
|
||||||
|
|
||||||
|
@mock.patch(
|
||||||
|
"core.recording.services.recording_events.notification_service."
|
||||||
|
"notify_external_services"
|
||||||
|
)
|
||||||
|
@pytest.mark.parametrize("notification_succeeded", [True, False])
|
||||||
|
def test_save_recording_notifies_external_services(
|
||||||
|
mock_notify_external_services,
|
||||||
|
recording_settings,
|
||||||
|
mock_get_parser,
|
||||||
|
client,
|
||||||
|
notification_succeeded,
|
||||||
|
):
|
||||||
|
"""External services should be notified when a recording is saved."""
|
||||||
|
|
||||||
|
recording = RecordingFactory(status="active")
|
||||||
|
|
||||||
|
mock_parser = mock.Mock()
|
||||||
|
mock_parser.get_recording_id.return_value = recording.id
|
||||||
|
mock_get_parser.return_value = mock_parser
|
||||||
|
|
||||||
|
mock_notify_external_services.return_value = notification_succeeded
|
||||||
|
|
||||||
|
response = client.post(
|
||||||
|
"/api/v1.0/recordings/storage-hook/",
|
||||||
|
{"recording_data": "valid-data"},
|
||||||
|
HTTP_AUTHORIZATION="Bearer testAuthToken",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.json() == {"message": "Event processed."}
|
||||||
|
|
||||||
|
mock_notify_external_services.assert_called_once_with(recording)
|
||||||
|
|
||||||
|
recording.refresh_from_db()
|
||||||
|
assert recording.status == (
|
||||||
|
RecordingStatusChoices.NOTIFICATION_SUCCEEDED
|
||||||
|
if notification_succeeded
|
||||||
|
else RecordingStatusChoices.SAVED
|
||||||
|
)
|
||||||
|
|||||||
@@ -0,0 +1,126 @@
|
|||||||
|
"""Tests for the per-recording encoding resolution in BaseEgressService."""
|
||||||
|
|
||||||
|
# pylint: disable=protected-access,redefined-outer-name,unused-argument,no-member
|
||||||
|
|
||||||
|
from unittest.mock import Mock
|
||||||
|
|
||||||
|
from django.conf import settings
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from livekit import api as livekit_api
|
||||||
|
from pydantic import ValidationError as PydanticValidationError
|
||||||
|
|
||||||
|
from core.api.serializers import EncodingConfig
|
||||||
|
from core.recording.worker.factories import build_encoding_options
|
||||||
|
from core.recording.worker.services import VideoCompositeEgressService
|
||||||
|
|
||||||
|
|
||||||
|
def make_config():
|
||||||
|
"""Build a minimal WorkerServiceConfig-like mock for service instantiation."""
|
||||||
|
config = Mock()
|
||||||
|
config.bucket_args = {
|
||||||
|
"endpoint": "https://s3.test.com",
|
||||||
|
"access_key": "test_key",
|
||||||
|
"secret": "test_secret",
|
||||||
|
"region": "test-region",
|
||||||
|
"bucket": "test-bucket",
|
||||||
|
"force_path_style": True,
|
||||||
|
}
|
||||||
|
config.encoding_options = None
|
||||||
|
return config
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def service():
|
||||||
|
"""Return a VideoCompositeEgressService with mocked handle_request."""
|
||||||
|
svc = VideoCompositeEgressService(make_config())
|
||||||
|
svc._handle_request = Mock()
|
||||||
|
return svc
|
||||||
|
|
||||||
|
|
||||||
|
# --- build_encoding_options ---
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_options_without_profile_omits_profile_fields():
|
||||||
|
"""A resolution-only config should resolve dimensions but no framerate/bitrate.
|
||||||
|
|
||||||
|
The profile-independent fields (audio bitrate, keyframe interval, codec /
|
||||||
|
frequency pins) are always present, matching the default encoding.
|
||||||
|
"""
|
||||||
|
resolved = build_encoding_options("720p", None)
|
||||||
|
|
||||||
|
assert resolved == {
|
||||||
|
"audio_bitrate": settings.RECORDING_ENCODING_AUDIO_BITRATE_KBPS,
|
||||||
|
"key_frame_interval": settings.RECORDING_ENCODING_KEY_FRAME_INTERVAL_S,
|
||||||
|
"video_codec": livekit_api.VideoCodec.H264_MAIN,
|
||||||
|
"audio_codec": livekit_api.AudioCodec.AAC,
|
||||||
|
"audio_frequency": 48000,
|
||||||
|
"width": 1280,
|
||||||
|
"height": 720,
|
||||||
|
}
|
||||||
|
assert "framerate" not in resolved
|
||||||
|
assert "video_bitrate" not in resolved
|
||||||
|
|
||||||
|
|
||||||
|
def test_encoding_config_requires_resolution():
|
||||||
|
"""A profile-only or empty encoding config should be rejected at validation."""
|
||||||
|
with pytest.raises(PydanticValidationError):
|
||||||
|
EncodingConfig(profile="mixed")
|
||||||
|
with pytest.raises(PydanticValidationError):
|
||||||
|
EncodingConfig()
|
||||||
|
|
||||||
|
|
||||||
|
# --- _resolve_encoding_options ---
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("encoding_options", [None, {}])
|
||||||
|
def test_resolve_options_returns_none_when_empty(service, encoding_options):
|
||||||
|
"""Resolver should return None when the resolved dict is empty or missing."""
|
||||||
|
assert service._resolve_encoding_options(encoding_options) is None
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"resolution",
|
||||||
|
list(settings.RECORDING_ENCODING_AVAILABLE_RESOLUTIONS),
|
||||||
|
)
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"profile",
|
||||||
|
list(settings.RECORDING_ENCODING_AVAILABLE_PROFILES),
|
||||||
|
)
|
||||||
|
def test_resolve_profile_resolution_combinations(service, profile, resolution):
|
||||||
|
"""Every (profile, resolution) pair should resolve to the values from settings."""
|
||||||
|
resolution_config = settings.RECORDING_ENCODING_AVAILABLE_RESOLUTIONS[resolution]
|
||||||
|
expected_width = resolution_config["width"]
|
||||||
|
expected_height = resolution_config["height"]
|
||||||
|
profile_config = settings.RECORDING_ENCODING_AVAILABLE_PROFILES[profile]
|
||||||
|
expected_fps = profile_config["fps"]
|
||||||
|
expected_bitrate = profile_config["kbps"][resolution]
|
||||||
|
|
||||||
|
resolved = build_encoding_options(resolution, profile)
|
||||||
|
result = service._resolve_encoding_options(resolved)
|
||||||
|
|
||||||
|
assert result.width == expected_width
|
||||||
|
assert result.height == expected_height
|
||||||
|
assert result.framerate == expected_fps
|
||||||
|
assert result.video_bitrate == expected_bitrate
|
||||||
|
# Profile-independent fields match the default encoding, never dropped.
|
||||||
|
assert result.audio_bitrate == settings.RECORDING_ENCODING_AUDIO_BITRATE_KBPS
|
||||||
|
assert result.video_codec == livekit_api.VideoCodec.H264_MAIN
|
||||||
|
assert result.audio_codec == livekit_api.AudioCodec.AAC
|
||||||
|
assert result.audio_frequency == 48000
|
||||||
|
|
||||||
|
|
||||||
|
def test_resolve_options_none_profile_uses_livekit_defaults(service):
|
||||||
|
"""Missing profile should pass 0 fps/bitrate (LiveKit protobuf default).
|
||||||
|
|
||||||
|
The pinned codec / audio fields are still applied even without a profile.
|
||||||
|
"""
|
||||||
|
resolved = build_encoding_options("720p", None)
|
||||||
|
result = service._resolve_encoding_options(resolved)
|
||||||
|
assert result.width == 1280
|
||||||
|
assert result.height == 720
|
||||||
|
assert result.framerate == 0
|
||||||
|
assert result.video_bitrate == 0
|
||||||
|
assert result.audio_bitrate == settings.RECORDING_ENCODING_AUDIO_BITRATE_KBPS
|
||||||
|
assert result.video_codec == livekit_api.VideoCodec.H264_MAIN
|
||||||
|
assert result.audio_codec == livekit_api.AudioCodec.AAC
|
||||||
@@ -40,6 +40,16 @@ def test_settings():
|
|||||||
"AWS_S3_SECRET_ACCESS_KEY": "test_secret",
|
"AWS_S3_SECRET_ACCESS_KEY": "test_secret",
|
||||||
"AWS_S3_REGION_NAME": "test-region",
|
"AWS_S3_REGION_NAME": "test-region",
|
||||||
"AWS_STORAGE_BUCKET_NAME": "test-bucket",
|
"AWS_STORAGE_BUCKET_NAME": "test-bucket",
|
||||||
|
"RECORDING_ENCODING_AVAILABLE_RESOLUTIONS": {
|
||||||
|
"720p": {"width": 1280, "height": 720}
|
||||||
|
},
|
||||||
|
"RECORDING_ENCODING_AVAILABLE_PROFILES": {
|
||||||
|
"full": {"fps": 30, "kbps": {"720p": 3000}}
|
||||||
|
},
|
||||||
|
"RECORDING_ENCODING_DEFAULT_RESOLUTION": "720p",
|
||||||
|
"RECORDING_ENCODING_DEFAULT_PROFILE": "full",
|
||||||
|
"RECORDING_ENCODING_AUDIO_BITRATE_KBPS": 128,
|
||||||
|
"RECORDING_ENCODING_KEY_FRAME_INTERVAL_S": 4.0,
|
||||||
}
|
}
|
||||||
|
|
||||||
# Use override_settings to properly patch Django settings
|
# Use override_settings to properly patch Django settings
|
||||||
@@ -66,8 +76,18 @@ def test_config_initialization(default_config):
|
|||||||
"bucket": "test-bucket",
|
"bucket": "test-bucket",
|
||||||
"force_path_style": True,
|
"force_path_style": True,
|
||||||
}
|
}
|
||||||
# Encoding override is opt-in; disabled by default.
|
# The default encoding is always resolved from the default profile/resolution.
|
||||||
assert default_config.encoding_options is None
|
assert default_config.encoding_options == {
|
||||||
|
"width": 1280,
|
||||||
|
"height": 720,
|
||||||
|
"framerate": 30,
|
||||||
|
"video_bitrate": 3000,
|
||||||
|
"audio_bitrate": 128,
|
||||||
|
"key_frame_interval": 4.0,
|
||||||
|
"video_codec": livekit_api_codec.VideoCodec.H264_MAIN,
|
||||||
|
"audio_codec": livekit_api_codec.AudioCodec.AAC,
|
||||||
|
"audio_frequency": 48000,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def test_config_immutability(default_config):
|
def test_config_immutability(default_config):
|
||||||
@@ -76,6 +96,7 @@ def test_config_immutability(default_config):
|
|||||||
default_config.output_folder = "new/path"
|
default_config.output_folder = "new/path"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("custom_encoding_enabled", [True, False])
|
||||||
@override_settings(
|
@override_settings(
|
||||||
RECORDING_OUTPUT_FOLDER="/test/output",
|
RECORDING_OUTPUT_FOLDER="/test/output",
|
||||||
LIVEKIT_CONFIGURATION={"server": "test.example.com"},
|
LIVEKIT_CONFIGURATION={"server": "test.example.com"},
|
||||||
@@ -84,23 +105,25 @@ def test_config_immutability(default_config):
|
|||||||
AWS_S3_SECRET_ACCESS_KEY="test_secret",
|
AWS_S3_SECRET_ACCESS_KEY="test_secret",
|
||||||
AWS_S3_REGION_NAME="test-region",
|
AWS_S3_REGION_NAME="test-region",
|
||||||
AWS_STORAGE_BUCKET_NAME="test-bucket",
|
AWS_STORAGE_BUCKET_NAME="test-bucket",
|
||||||
RECORDING_ENCODING_ENABLED=True,
|
RECORDING_ENCODING_AVAILABLE_RESOLUTIONS={"720p": {"width": 1280, "height": 720}},
|
||||||
RECORDING_ENCODING_WIDTH=1280,
|
RECORDING_ENCODING_AVAILABLE_PROFILES={"low": {"fps": 15, "kbps": {"720p": 600}}},
|
||||||
RECORDING_ENCODING_HEIGHT=720,
|
RECORDING_ENCODING_DEFAULT_RESOLUTION="720p",
|
||||||
RECORDING_ENCODING_FRAMERATE=15,
|
RECORDING_ENCODING_DEFAULT_PROFILE="low",
|
||||||
RECORDING_ENCODING_VIDEO_BITRATE_KBPS=600,
|
|
||||||
RECORDING_ENCODING_AUDIO_BITRATE_KBPS=64,
|
RECORDING_ENCODING_AUDIO_BITRATE_KBPS=64,
|
||||||
RECORDING_ENCODING_KEY_FRAME_INTERVAL_S=10.0,
|
RECORDING_ENCODING_KEY_FRAME_INTERVAL_S=10.0,
|
||||||
)
|
)
|
||||||
def test_config_encoding_options_enabled():
|
def test_config_encoding_options_default(custom_encoding_enabled):
|
||||||
"""When RECORDING_ENCODING_ENABLED is True, encoding options are populated.
|
"""The default encoding is always resolved from the default profile/resolution.
|
||||||
|
|
||||||
The dict mixes operator-tunable values from settings with pinned codec /
|
The default fallback resolves the default profile/resolution and mixes those
|
||||||
frequency constants, so the services layer can simply unpack it.
|
operator-tunable values with pinned codec / frequency constants. This works
|
||||||
|
regardless of RECORDING_CUSTOM_ENCODING_ENABLED, which only gates the
|
||||||
|
per-recording API, so both toggle states produce the same default.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
WorkerServiceConfig.from_settings.cache_clear()
|
with override_settings(RECORDING_CUSTOM_ENCODING_ENABLED=custom_encoding_enabled):
|
||||||
config = WorkerServiceConfig.from_settings()
|
WorkerServiceConfig.from_settings.cache_clear()
|
||||||
|
config = WorkerServiceConfig.from_settings()
|
||||||
|
|
||||||
assert config.encoding_options == {
|
assert config.encoding_options == {
|
||||||
"width": 1280,
|
"width": 1280,
|
||||||
@@ -115,6 +138,27 @@ def test_config_encoding_options_enabled():
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("default_resolution", "default_profile"),
|
||||||
|
[("", "full"), ("720p", ""), ("", "")],
|
||||||
|
)
|
||||||
|
def test_config_encoding_options_none_when_default_missing(
|
||||||
|
test_settings, default_resolution, default_profile
|
||||||
|
):
|
||||||
|
"""A missing default resolution/profile leaves encoding_options None.
|
||||||
|
|
||||||
|
The service then omits the `advanced` field so LiveKit uses its built-in preset.
|
||||||
|
"""
|
||||||
|
with override_settings(
|
||||||
|
RECORDING_ENCODING_DEFAULT_RESOLUTION=default_resolution,
|
||||||
|
RECORDING_ENCODING_DEFAULT_PROFILE=default_profile,
|
||||||
|
):
|
||||||
|
WorkerServiceConfig.from_settings.cache_clear()
|
||||||
|
config = WorkerServiceConfig.from_settings()
|
||||||
|
|
||||||
|
assert config.encoding_options is None
|
||||||
|
|
||||||
|
|
||||||
@override_settings(
|
@override_settings(
|
||||||
RECORDING_OUTPUT_FOLDER="/test/output",
|
RECORDING_OUTPUT_FOLDER="/test/output",
|
||||||
LIVEKIT_CONFIGURATION={"server": "test.example.com"},
|
LIVEKIT_CONFIGURATION={"server": "test.example.com"},
|
||||||
|
|||||||
@@ -52,7 +52,7 @@ def test_start_recording_success(
|
|||||||
# Verify worker service call
|
# Verify worker service call
|
||||||
expected_room_name = str(mock_recording.room.id)
|
expected_room_name = str(mock_recording.room.id)
|
||||||
mock_worker_service.start.assert_called_once_with(
|
mock_worker_service.start.assert_called_once_with(
|
||||||
expected_room_name, mock_recording.id
|
expected_room_name, mock_recording.id, encoding_options=None
|
||||||
)
|
)
|
||||||
|
|
||||||
# Verify recording updates
|
# Verify recording updates
|
||||||
@@ -66,6 +66,38 @@ def test_start_recording_success(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@mock.patch("core.utils.update_room_metadata")
|
||||||
|
def test_start_recording_passes_resolved_encoding(
|
||||||
|
mock_update_room_metadata, mediator, mock_worker_service
|
||||||
|
):
|
||||||
|
"""The resolved encoding persisted in recording.options reaches the worker."""
|
||||||
|
mock_worker_service.start.return_value = "test-worker-123"
|
||||||
|
|
||||||
|
resolved = {
|
||||||
|
"key_frame_interval": 4.0,
|
||||||
|
"width": 1280,
|
||||||
|
"height": 720,
|
||||||
|
"framerate": 15,
|
||||||
|
"video_bitrate": 700,
|
||||||
|
}
|
||||||
|
mock_recording = RecordingFactory(
|
||||||
|
status=RecordingStatusChoices.INITIATED,
|
||||||
|
worker_id=None,
|
||||||
|
options={
|
||||||
|
"encoding": {
|
||||||
|
"resolution": "720p",
|
||||||
|
"profile": "talking_heads",
|
||||||
|
"resolved": resolved,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
mediator.start(mock_recording)
|
||||||
|
|
||||||
|
mock_worker_service.start.assert_called_once_with(
|
||||||
|
str(mock_recording.room.id), mock_recording.id, encoding_options=resolved
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
@pytest.mark.parametrize(
|
||||||
"error_class", [WorkerRequestError, WorkerConnectionError, WorkerResponseError]
|
"error_class", [WorkerRequestError, WorkerConnectionError, WorkerResponseError]
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -130,6 +130,9 @@ def test_api_rooms_retrieve_anonymous_unregistered_allowed(mock_token):
|
|||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
assert response.json() == {
|
assert response.json() == {
|
||||||
"id": None,
|
"id": None,
|
||||||
|
"slug": "unregistered-room",
|
||||||
|
"access_level": "public",
|
||||||
|
"is_administrable": False,
|
||||||
"livekit": {
|
"livekit": {
|
||||||
"url": "test_url_value",
|
"url": "test_url_value",
|
||||||
"room": "unregistered-room",
|
"room": "unregistered-room",
|
||||||
@@ -162,6 +165,9 @@ def test_api_rooms_retrieve_anonymous_unregistered_allowed_not_normalized(mock_t
|
|||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
assert response.json() == {
|
assert response.json() == {
|
||||||
"id": None,
|
"id": None,
|
||||||
|
"slug": "reunion",
|
||||||
|
"access_level": "public",
|
||||||
|
"is_administrable": False,
|
||||||
"livekit": {
|
"livekit": {
|
||||||
"url": "test_url_value",
|
"url": "test_url_value",
|
||||||
"room": "reunion",
|
"room": "reunion",
|
||||||
|
|||||||
@@ -2,11 +2,12 @@
|
|||||||
Test rooms API endpoints in the Meet core app: start recording.
|
Test rooms API endpoints in the Meet core app: start recording.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
# pylint: disable=redefined-outer-name,unused-argument
|
# pylint: disable=redefined-outer-name,unused-argument,no-member
|
||||||
|
|
||||||
from unittest import mock
|
from unittest import mock
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
from livekit import api as livekit_api
|
||||||
from rest_framework.test import APIClient
|
from rest_framework.test import APIClient
|
||||||
|
|
||||||
from ...factories import RoomFactory, UserFactory
|
from ...factories import RoomFactory, UserFactory
|
||||||
@@ -470,6 +471,194 @@ def test_start_recording_options_unknown_field_rejected(settings):
|
|||||||
assert response.status_code == 400
|
assert response.status_code == 400
|
||||||
|
|
||||||
|
|
||||||
|
def test_start_recording_options_encoding_valid(
|
||||||
|
settings, mock_worker_service_factory, mock_worker_manager
|
||||||
|
):
|
||||||
|
"""Should accept a valid encoding configuration."""
|
||||||
|
settings.RECORDING_ENABLE = True
|
||||||
|
settings.RECORDING_CUSTOM_ENCODING_ENABLED = True
|
||||||
|
room = RoomFactory()
|
||||||
|
user = UserFactory()
|
||||||
|
room.accesses.create(user=user, role="owner")
|
||||||
|
client = APIClient()
|
||||||
|
client.force_login(user)
|
||||||
|
|
||||||
|
response = client.post(
|
||||||
|
f"/api/v1.0/rooms/{room.id}/start-recording/",
|
||||||
|
{
|
||||||
|
"mode": "screen_recording",
|
||||||
|
"options": {"encoding": {"resolution": "720p", "profile": "talking_heads"}},
|
||||||
|
},
|
||||||
|
format="json",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 201
|
||||||
|
|
||||||
|
|
||||||
|
def test_start_recording_options_encoding_rejected_when_custom_encoding_disabled(
|
||||||
|
settings, mock_worker_service_factory, mock_worker_manager
|
||||||
|
):
|
||||||
|
"""Per-recording encoding is rejected when RECORDING_CUSTOM_ENCODING_ENABLED is off."""
|
||||||
|
settings.RECORDING_ENABLE = True
|
||||||
|
settings.RECORDING_CUSTOM_ENCODING_ENABLED = False
|
||||||
|
room = RoomFactory()
|
||||||
|
user = UserFactory()
|
||||||
|
room.accesses.create(user=user, role="owner")
|
||||||
|
client = APIClient()
|
||||||
|
client.force_login(user)
|
||||||
|
|
||||||
|
response = client.post(
|
||||||
|
f"/api/v1.0/rooms/{room.id}/start-recording/",
|
||||||
|
{
|
||||||
|
"mode": "screen_recording",
|
||||||
|
"options": {"encoding": {"resolution": "720p", "profile": "talking_heads"}},
|
||||||
|
},
|
||||||
|
format="json",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 400
|
||||||
|
assert not Recording.objects.filter(room=room).exists()
|
||||||
|
|
||||||
|
|
||||||
|
def test_start_recording_persists_resolved_encoding(
|
||||||
|
settings, mock_worker_service_factory, mock_worker_manager
|
||||||
|
):
|
||||||
|
"""The resolved encoding should be persisted in recording.options alongside
|
||||||
|
the requested resolution/profile for traceability."""
|
||||||
|
settings.RECORDING_ENABLE = True
|
||||||
|
settings.RECORDING_CUSTOM_ENCODING_ENABLED = True
|
||||||
|
room = RoomFactory()
|
||||||
|
user = UserFactory()
|
||||||
|
room.accesses.create(user=user, role="owner")
|
||||||
|
client = APIClient()
|
||||||
|
client.force_login(user)
|
||||||
|
|
||||||
|
response = client.post(
|
||||||
|
f"/api/v1.0/rooms/{room.id}/start-recording/",
|
||||||
|
{
|
||||||
|
"mode": "screen_recording",
|
||||||
|
"options": {"encoding": {"resolution": "720p", "profile": "talking_heads"}},
|
||||||
|
},
|
||||||
|
format="json",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 201
|
||||||
|
recording = Recording.objects.get(room=room)
|
||||||
|
assert recording.options["encoding"] == {
|
||||||
|
"resolution": "720p",
|
||||||
|
"profile": "talking_heads",
|
||||||
|
"resolved": {
|
||||||
|
"audio_bitrate": settings.RECORDING_ENCODING_AUDIO_BITRATE_KBPS,
|
||||||
|
"key_frame_interval": settings.RECORDING_ENCODING_KEY_FRAME_INTERVAL_S,
|
||||||
|
"video_codec": livekit_api.VideoCodec.H264_MAIN,
|
||||||
|
"audio_codec": livekit_api.AudioCodec.AAC,
|
||||||
|
"audio_frequency": 48000,
|
||||||
|
"width": 1280,
|
||||||
|
"height": 720,
|
||||||
|
"framerate": 15,
|
||||||
|
"video_bitrate": 700,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_start_recording_forwards_resolved_encoding_to_worker(
|
||||||
|
settings, mock_worker_service, mock_worker_service_factory
|
||||||
|
):
|
||||||
|
"""The resolved encoding should passed on to the worker."""
|
||||||
|
settings.RECORDING_ENABLE = True
|
||||||
|
settings.RECORDING_CUSTOM_ENCODING_ENABLED = True
|
||||||
|
room = RoomFactory()
|
||||||
|
user = UserFactory()
|
||||||
|
room.accesses.create(user=user, role="owner")
|
||||||
|
client = APIClient()
|
||||||
|
client.force_login(user)
|
||||||
|
|
||||||
|
mock_worker_service.start.return_value = "egress-123"
|
||||||
|
|
||||||
|
with mock.patch("core.utils.update_room_metadata"):
|
||||||
|
response = client.post(
|
||||||
|
f"/api/v1.0/rooms/{room.id}/start-recording/",
|
||||||
|
{
|
||||||
|
"mode": "screen_recording",
|
||||||
|
"options": {
|
||||||
|
"encoding": {"resolution": "720p", "profile": "talking_heads"}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
format="json",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 201
|
||||||
|
recording = Recording.objects.get(room=room)
|
||||||
|
mock_worker_service.start.assert_called_once_with(
|
||||||
|
str(room.id),
|
||||||
|
recording.id,
|
||||||
|
encoding_options=recording.options["encoding"]["resolved"],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_start_recording_options_encoding_invalid_resolution(settings):
|
||||||
|
"""Should reject invalid encoding resolution values."""
|
||||||
|
settings.RECORDING_ENABLE = True
|
||||||
|
settings.RECORDING_CUSTOM_ENCODING_ENABLED = True
|
||||||
|
room = RoomFactory()
|
||||||
|
user = UserFactory()
|
||||||
|
room.accesses.create(user=user, role="owner")
|
||||||
|
client = APIClient()
|
||||||
|
client.force_login(user)
|
||||||
|
|
||||||
|
response = client.post(
|
||||||
|
f"/api/v1.0/rooms/{room.id}/start-recording/",
|
||||||
|
{"mode": "screen_recording", "options": {"encoding": {"resolution": "4K"}}},
|
||||||
|
format="json",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 400
|
||||||
|
|
||||||
|
|
||||||
|
def test_start_recording_options_encoding_unknown_key_rejected(settings):
|
||||||
|
"""Should reject unknown keys in encoding configuration."""
|
||||||
|
settings.RECORDING_ENABLE = True
|
||||||
|
settings.RECORDING_CUSTOM_ENCODING_ENABLED = True
|
||||||
|
room = RoomFactory()
|
||||||
|
user = UserFactory()
|
||||||
|
room.accesses.create(user=user, role="owner")
|
||||||
|
client = APIClient()
|
||||||
|
client.force_login(user)
|
||||||
|
|
||||||
|
response = client.post(
|
||||||
|
f"/api/v1.0/rooms/{room.id}/start-recording/",
|
||||||
|
{
|
||||||
|
"mode": "screen_recording",
|
||||||
|
"options": {"encoding": {"bitrate": 9000}},
|
||||||
|
},
|
||||||
|
format="json",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 400
|
||||||
|
|
||||||
|
|
||||||
|
def test_start_recording_options_without_encoding_unchanged(
|
||||||
|
settings, mock_worker_service_factory, mock_worker_manager
|
||||||
|
):
|
||||||
|
"""Requests without encoding should keep existing options behavior."""
|
||||||
|
settings.RECORDING_ENABLE = True
|
||||||
|
room = RoomFactory()
|
||||||
|
user = UserFactory()
|
||||||
|
room.accesses.create(user=user, role="owner")
|
||||||
|
client = APIClient()
|
||||||
|
client.force_login(user)
|
||||||
|
|
||||||
|
response = client.post(
|
||||||
|
f"/api/v1.0/rooms/{room.id}/start-recording/",
|
||||||
|
{"mode": "screen_recording", "options": {"language": "fr"}},
|
||||||
|
format="json",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 201
|
||||||
|
recording = Recording.objects.get(room=room)
|
||||||
|
assert recording.options == {"language": "fr"}
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("value", ["foo", 12])
|
@pytest.mark.parametrize("value", ["foo", 12])
|
||||||
def test_start_recording_options_invalid_transcribe_type(settings, value):
|
def test_start_recording_options_invalid_transcribe_type(settings, value):
|
||||||
"""Should reject non-boolean transcribe values."""
|
"""Should reject non-boolean transcribe values."""
|
||||||
|
|||||||
@@ -91,7 +91,9 @@ def test_handle_egress_ended_success(
|
|||||||
)
|
)
|
||||||
|
|
||||||
recording.refresh_from_db()
|
recording.refresh_from_db()
|
||||||
assert recording.status == "stopped"
|
|
||||||
|
# NB: notify_external_services will return False, so status is "saved"
|
||||||
|
assert recording.status == "saved"
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
@pytest.mark.parametrize(
|
||||||
@@ -155,7 +157,7 @@ def test_handle_egress_updated_non_handled(
|
|||||||
def test_handle_egress_ended_metadata_update_fails(
|
def test_handle_egress_ended_metadata_update_fails(
|
||||||
mock_update_room_metadata, mock_notify, mode, notification_type, service
|
mock_update_room_metadata, mock_notify, mode, notification_type, service
|
||||||
):
|
):
|
||||||
"""Should successfully stop recording when metadata's update fails."""
|
"""Should successfully stop and save recording when metadata's update fails."""
|
||||||
|
|
||||||
recording = RecordingFactory(worker_id="worker-1", mode=mode, status="active")
|
recording = RecordingFactory(worker_id="worker-1", mode=mode, status="active")
|
||||||
mock_data = mock.MagicMock()
|
mock_data = mock.MagicMock()
|
||||||
@@ -170,7 +172,9 @@ def test_handle_egress_ended_metadata_update_fails(
|
|||||||
room_name=str(recording.room.id), notification_data={"type": notification_type}
|
room_name=str(recording.room.id), notification_data={"type": notification_type}
|
||||||
)
|
)
|
||||||
recording.refresh_from_db()
|
recording.refresh_from_db()
|
||||||
assert recording.status == "stopped"
|
|
||||||
|
# NB: notify_external_services will return False, so status is "saved"
|
||||||
|
assert recording.status == "saved"
|
||||||
|
|
||||||
|
|
||||||
@mock.patch("core.utils.notify_participants")
|
@mock.patch("core.utils.notify_participants")
|
||||||
@@ -326,6 +330,143 @@ def test_handle_egress_ended_does_not_call_metadata_collector_stop_when_conditio
|
|||||||
mock_collector.stop.assert_not_called()
|
mock_collector.stop.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
@mock.patch(
|
||||||
|
"core.recording.services.recording_events.notification_service."
|
||||||
|
"notify_external_services"
|
||||||
|
)
|
||||||
|
@mock.patch("core.utils.notify_participants")
|
||||||
|
@mock.patch("core.utils.update_room_metadata")
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"egress_status",
|
||||||
|
[EgressStatus.EGRESS_COMPLETE, EgressStatus.EGRESS_LIMIT_REACHED],
|
||||||
|
)
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"notify_return_value, recording_status",
|
||||||
|
[(True, "notification_succeeded"), (False, "saved")],
|
||||||
|
)
|
||||||
|
def test_handle_egress_ended_finalizes_recording( # noqa: PLR0913
|
||||||
|
mock_update_room_metadata,
|
||||||
|
mock_notify,
|
||||||
|
mock_notify_external_services,
|
||||||
|
notify_return_value,
|
||||||
|
recording_status,
|
||||||
|
egress_status,
|
||||||
|
service,
|
||||||
|
settings,
|
||||||
|
): # pylint: disable=too-many-arguments,too-many-positional-arguments
|
||||||
|
"""Should notify external services and save the recording on egress completion
|
||||||
|
(EGRESS_COMPLETE or EGRESS_LIMIT_REACHED) when RECORDING_STORAGE_EVENT_ENABLE is False.
|
||||||
|
"""
|
||||||
|
settings.RECORDING_STORAGE_EVENT_ENABLE = False
|
||||||
|
mock_notify_external_services.return_value = notify_return_value
|
||||||
|
|
||||||
|
recording = RecordingFactory(worker_id="worker-1", status="active")
|
||||||
|
mock_data = mock.MagicMock()
|
||||||
|
mock_data.egress_info.egress_id = recording.worker_id
|
||||||
|
mock_data.egress_info.status = egress_status
|
||||||
|
|
||||||
|
service._handle_egress_ended(mock_data)
|
||||||
|
|
||||||
|
mock_notify_external_services.assert_called_once_with(recording)
|
||||||
|
|
||||||
|
recording.refresh_from_db()
|
||||||
|
assert recording.status == recording_status
|
||||||
|
|
||||||
|
|
||||||
|
@mock.patch(
|
||||||
|
"core.recording.services.recording_events.notification_service."
|
||||||
|
"notify_external_services"
|
||||||
|
)
|
||||||
|
@mock.patch("core.utils.notify_participants")
|
||||||
|
@mock.patch("core.utils.update_room_metadata")
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"egress_status, expected_status",
|
||||||
|
[
|
||||||
|
(EgressStatus.EGRESS_COMPLETE, "active"),
|
||||||
|
(EgressStatus.EGRESS_LIMIT_REACHED, "stopped"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_handle_egress_ended_does_not_finalize_when_webhooks_enabled( # noqa: PLR0913
|
||||||
|
mock_update_room_metadata,
|
||||||
|
mock_notify,
|
||||||
|
mock_notify_external_services,
|
||||||
|
egress_status,
|
||||||
|
expected_status,
|
||||||
|
service,
|
||||||
|
settings,
|
||||||
|
): # pylint: disable=too-many-arguments,too-many-positional-arguments
|
||||||
|
"""When storage event webhooks are enabled, egress_ended must not finalize the
|
||||||
|
recording: external services are never notified. EGRESS_LIMIT_REACHED still stops
|
||||||
|
the recording, EGRESS_COMPLETE leaves it active.
|
||||||
|
"""
|
||||||
|
settings.RECORDING_STORAGE_EVENT_ENABLE = True
|
||||||
|
|
||||||
|
recording = RecordingFactory(worker_id="worker-1", status="active")
|
||||||
|
mock_data = mock.MagicMock()
|
||||||
|
mock_data.egress_info.egress_id = recording.worker_id
|
||||||
|
mock_data.egress_info.status = egress_status
|
||||||
|
|
||||||
|
service._handle_egress_ended(mock_data)
|
||||||
|
|
||||||
|
mock_notify_external_services.assert_not_called()
|
||||||
|
|
||||||
|
recording.refresh_from_db()
|
||||||
|
assert recording.status == expected_status
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"egress_status",
|
||||||
|
[
|
||||||
|
EgressStatus.EGRESS_STARTING,
|
||||||
|
EgressStatus.EGRESS_ACTIVE,
|
||||||
|
EgressStatus.EGRESS_ENDING,
|
||||||
|
EgressStatus.EGRESS_FAILED,
|
||||||
|
EgressStatus.EGRESS_ABORTED,
|
||||||
|
],
|
||||||
|
)
|
||||||
|
@mock.patch("core.utils.update_room_metadata")
|
||||||
|
def test_handle_egress_ended_does_not_save_on_wrong_status(
|
||||||
|
mock_update_room_metadata, egress_status, service, settings
|
||||||
|
):
|
||||||
|
"""Shouldn't save on invalid status."""
|
||||||
|
settings.RECORDING_STORAGE_EVENT_ENABLE = False
|
||||||
|
|
||||||
|
recording = RecordingFactory(worker_id="worker-1", status="active")
|
||||||
|
mock_data = mock.MagicMock()
|
||||||
|
mock_data.egress_info.egress_id = recording.worker_id
|
||||||
|
mock_data.egress_info.status = egress_status
|
||||||
|
|
||||||
|
service._handle_egress_ended(mock_data)
|
||||||
|
|
||||||
|
recording.refresh_from_db()
|
||||||
|
assert recording.status == "active"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"status", ["failed_to_start", "aborted", "failed_to_stop", "saved", "initiated"]
|
||||||
|
)
|
||||||
|
@mock.patch("core.utils.update_room_metadata")
|
||||||
|
def test_handle_egress_ended_ignores_non_savable_recording(
|
||||||
|
mock_update_room_metadata, status, service, settings
|
||||||
|
):
|
||||||
|
"""Should handle non-savable recordings idempotently without raising.
|
||||||
|
|
||||||
|
'egress_ended' may be redelivered (e.g. for an already-saved recording);
|
||||||
|
this must not raise, otherwise the webhook would 500 and LiveKit would retry.
|
||||||
|
"""
|
||||||
|
settings.RECORDING_STORAGE_EVENT_ENABLE = False
|
||||||
|
|
||||||
|
recording = RecordingFactory(worker_id="worker-1", status=status)
|
||||||
|
mock_data = mock.MagicMock()
|
||||||
|
mock_data.egress_info.egress_id = recording.worker_id
|
||||||
|
mock_data.egress_info.status = EgressStatus.EGRESS_COMPLETE
|
||||||
|
|
||||||
|
service._handle_egress_ended(mock_data)
|
||||||
|
|
||||||
|
recording.refresh_from_db()
|
||||||
|
assert recording.status == status
|
||||||
|
|
||||||
|
|
||||||
@mock.patch.object(LobbyService, "clear_room_cache")
|
@mock.patch.object(LobbyService, "clear_room_cache")
|
||||||
@mock.patch.object(TelephonyService, "delete_dispatch_rule")
|
@mock.patch.object(TelephonyService, "delete_dispatch_rule")
|
||||||
def test_handle_room_finished_clears_cache_and_deletes_dispatch_rule(
|
def test_handle_room_finished_clears_cache_and_deletes_dispatch_rule(
|
||||||
|
|||||||
@@ -2,13 +2,109 @@
|
|||||||
Test utils functions
|
Test utils functions
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
# pylint: disable=W0621
|
||||||
import json
|
import json
|
||||||
from unittest import mock
|
from unittest import mock
|
||||||
|
|
||||||
|
from django.conf import settings
|
||||||
|
from django.contrib.auth.models import AnonymousUser
|
||||||
|
|
||||||
|
import jwt
|
||||||
import pytest
|
import pytest
|
||||||
from livekit.api import TwirpError
|
from livekit.api import TwirpError
|
||||||
|
|
||||||
from core.utils import NotificationError, create_livekit_client, notify_participants
|
from core.factories import UserFactory
|
||||||
|
from core.utils import (
|
||||||
|
NotificationError,
|
||||||
|
create_livekit_client,
|
||||||
|
generate_token,
|
||||||
|
notify_participants,
|
||||||
|
)
|
||||||
|
|
||||||
|
pytestmark = pytest.mark.django_db
|
||||||
|
|
||||||
|
|
||||||
|
def decode_token(token: str) -> dict:
|
||||||
|
"""Decode a LiveKit JWT access token for inspection."""
|
||||||
|
return jwt.decode(
|
||||||
|
token,
|
||||||
|
settings.LIVEKIT_CONFIGURATION["api_secret"],
|
||||||
|
algorithms=["HS256"],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_generate_token_authenticated_uses_full_name():
|
||||||
|
"""The token's display name should default to the user's full name."""
|
||||||
|
user = UserFactory(full_name="Jane Doe")
|
||||||
|
|
||||||
|
token = generate_token(room="my-room", user=user)
|
||||||
|
|
||||||
|
claims = decode_token(token)
|
||||||
|
assert claims["name"] == "Jane Doe"
|
||||||
|
assert claims["sub"] == str(user.sub)
|
||||||
|
|
||||||
|
|
||||||
|
def test_generate_token_authenticated_fallback_user_representation():
|
||||||
|
"""
|
||||||
|
When the user has no full name, the token's display name should fall back
|
||||||
|
to the user's string representation.
|
||||||
|
"""
|
||||||
|
user = UserFactory(full_name=None)
|
||||||
|
|
||||||
|
token = generate_token(room="my-room", user=user)
|
||||||
|
|
||||||
|
claims = decode_token(token)
|
||||||
|
assert claims["name"] == str(user)
|
||||||
|
|
||||||
|
|
||||||
|
def test_generate_token_explicit_username_overrides_default():
|
||||||
|
"""An explicitly provided username should take precedence over the full name."""
|
||||||
|
user = UserFactory(full_name="Jane Doe")
|
||||||
|
|
||||||
|
token = generate_token(room="my-room", user=user, username="Custom Name")
|
||||||
|
|
||||||
|
claims = decode_token(token)
|
||||||
|
assert claims["name"] == "Custom Name"
|
||||||
|
|
||||||
|
|
||||||
|
def test_authenticated_username_ignored_when_editing_disabled(settings):
|
||||||
|
"""With editing disabled, an authenticated user's username is ignored."""
|
||||||
|
settings.AUTHENTICATED_PARTICIPANTS_CAN_EDIT_DISPLAY_NAME = False
|
||||||
|
user = UserFactory(full_name="Jane Doe")
|
||||||
|
token = generate_token(room="my-room", user=user, username="Custom Name")
|
||||||
|
claims = decode_token(token)
|
||||||
|
assert claims["name"] == "Jane Doe"
|
||||||
|
|
||||||
|
|
||||||
|
def test_authenticated_default_name_unaffected_when_editing_disabled(settings):
|
||||||
|
"""Disabling editing doesn't disturb the default full-name path."""
|
||||||
|
settings.AUTHENTICATED_PARTICIPANTS_CAN_EDIT_DISPLAY_NAME = False
|
||||||
|
user = UserFactory(full_name="Jane Doe")
|
||||||
|
token = generate_token(room="my-room", user=user)
|
||||||
|
claims = decode_token(token)
|
||||||
|
assert claims["name"] == "Jane Doe"
|
||||||
|
|
||||||
|
|
||||||
|
def test_anonymous_uses_username_when_provided():
|
||||||
|
"""An anonymous user's provided username is used as the display name."""
|
||||||
|
token = generate_token(room="my-room", user=AnonymousUser(), username="Guest42")
|
||||||
|
claims = decode_token(token)
|
||||||
|
assert claims["name"] == "Guest42"
|
||||||
|
|
||||||
|
|
||||||
|
def test_anonymous_username_used_even_when_editing_disabled(settings):
|
||||||
|
"""The setting governs authenticated users only; anonymous can still set a name."""
|
||||||
|
settings.AUTHENTICATED_PARTICIPANTS_CAN_EDIT_DISPLAY_NAME = False
|
||||||
|
token = generate_token(room="my-room", user=AnonymousUser(), username="Guest42")
|
||||||
|
claims = decode_token(token)
|
||||||
|
assert claims["name"] == "Guest42"
|
||||||
|
|
||||||
|
|
||||||
|
def test_anonymous_falls_back_to_anonymous_label():
|
||||||
|
"""With no username, an anonymous user is labelled 'Anonymous'."""
|
||||||
|
token = generate_token(room="my-room", user=AnonymousUser())
|
||||||
|
claims = decode_token(token)
|
||||||
|
assert claims["name"] == "Anonymous"
|
||||||
|
|
||||||
|
|
||||||
@mock.patch("asyncio.get_running_loop")
|
@mock.patch("asyncio.get_running_loop")
|
||||||
|
|||||||
@@ -109,11 +109,16 @@ def generate_token(
|
|||||||
default_username = "Anonymous"
|
default_username = "Anonymous"
|
||||||
else:
|
else:
|
||||||
identity = str(user.sub)
|
identity = str(user.sub)
|
||||||
default_username = str(user)
|
default_username = user.full_name or str(user)
|
||||||
|
|
||||||
if color is None:
|
if color is None:
|
||||||
color = generate_color(identity)
|
color = generate_color(identity)
|
||||||
|
|
||||||
|
can_edit = (
|
||||||
|
settings.AUTHENTICATED_PARTICIPANTS_CAN_EDIT_DISPLAY_NAME or user.is_anonymous
|
||||||
|
)
|
||||||
|
display_name = (username or default_username) if can_edit else default_username
|
||||||
|
|
||||||
token = (
|
token = (
|
||||||
AccessToken(
|
AccessToken(
|
||||||
api_key=settings.LIVEKIT_CONFIGURATION["api_key"],
|
api_key=settings.LIVEKIT_CONFIGURATION["api_key"],
|
||||||
@@ -121,7 +126,7 @@ def generate_token(
|
|||||||
)
|
)
|
||||||
.with_grants(video_grants)
|
.with_grants(video_grants)
|
||||||
.with_identity(identity)
|
.with_identity(identity)
|
||||||
.with_name(username or default_username)
|
.with_name(display_name)
|
||||||
.with_attributes(
|
.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"}
|
||||||
)
|
)
|
||||||
@@ -459,12 +464,14 @@ def generate_upload_policy(file):
|
|||||||
return policy
|
return policy
|
||||||
|
|
||||||
|
|
||||||
def generate_download_file_url(file, *, expires_in: int, override_domain: bool = True):
|
def generate_download_s3_url(
|
||||||
|
key: str, *, expires_in: int, override_domain: bool = True
|
||||||
|
):
|
||||||
"""
|
"""
|
||||||
Generate a S3 signed download url for a given file.
|
Generate a S3 signed download url for a given key.
|
||||||
"""
|
"""
|
||||||
|
if not key:
|
||||||
key = file.file_key
|
raise ValueError("key cannot be empty")
|
||||||
|
|
||||||
# This setting should be used if the backend application and the frontend application
|
# This setting should be used if the backend application and the frontend application
|
||||||
# can't connect to the object storage with the same domain. This is the case in the
|
# can't connect to the object storage with the same domain. This is the case in the
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ msgid ""
|
|||||||
msgstr ""
|
msgstr ""
|
||||||
"Project-Id-Version: PACKAGE VERSION\n"
|
"Project-Id-Version: PACKAGE VERSION\n"
|
||||||
"Report-Msgid-Bugs-To: \n"
|
"Report-Msgid-Bugs-To: \n"
|
||||||
"POT-Creation-Date: 2026-04-21 14:09+0000\n"
|
"POT-Creation-Date: 2026-07-02 10:47+0000\n"
|
||||||
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
|
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
|
||||||
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
|
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
|
||||||
"Language-Team: LANGUAGE <LL@li.org>\n"
|
"Language-Team: LANGUAGE <LL@li.org>\n"
|
||||||
@@ -17,70 +17,92 @@ msgstr ""
|
|||||||
"Content-Type: text/plain; charset=UTF-8\n"
|
"Content-Type: text/plain; charset=UTF-8\n"
|
||||||
"Content-Transfer-Encoding: 8bit\n"
|
"Content-Transfer-Encoding: 8bit\n"
|
||||||
|
|
||||||
#: core/admin.py:29
|
#: core/admin.py:67
|
||||||
msgid "Personal info"
|
msgid "Personal info"
|
||||||
msgstr "Persönliche Informationen"
|
msgstr "Persönliche Informationen"
|
||||||
|
|
||||||
#: core/admin.py:42
|
#: core/admin.py:80
|
||||||
msgid "Permissions"
|
msgid "Permissions"
|
||||||
msgstr "Berechtigungen"
|
msgstr "Berechtigungen"
|
||||||
|
|
||||||
#: core/admin.py:54
|
#: core/admin.py:92
|
||||||
msgid "Important dates"
|
msgid "Important dates"
|
||||||
msgstr "Wichtige Daten"
|
msgstr "Wichtige Daten"
|
||||||
|
|
||||||
#: core/admin.py:132 core/admin.py:275
|
#: core/admin.py:206
|
||||||
|
msgid "Content"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: core/admin.py:217
|
||||||
|
#, fuzzy
|
||||||
|
#| msgid "Delete rooms"
|
||||||
|
msgid "Deletion"
|
||||||
|
msgstr "Räume löschen"
|
||||||
|
|
||||||
|
#: core/admin.py:226
|
||||||
|
msgid "Derived info"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: core/admin.py:237
|
||||||
|
msgid "Timestamps"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: core/admin.py:240
|
||||||
|
msgid "File preview"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: core/admin.py:300 core/admin.py:443
|
||||||
msgid "No owner"
|
msgid "No owner"
|
||||||
msgstr "Kein Eigentümer"
|
msgstr "Kein Eigentümer"
|
||||||
|
|
||||||
#: core/admin.py:135 core/admin.py:278
|
#: core/admin.py:303 core/admin.py:446
|
||||||
msgid "Multiple owners"
|
msgid "Multiple owners"
|
||||||
msgstr "Mehrere Eigentümer"
|
msgstr "Mehrere Eigentümer"
|
||||||
|
|
||||||
#: core/admin.py:148
|
#: core/admin.py:316
|
||||||
msgid "Resend notification to external service"
|
msgid "Resend notification to external service"
|
||||||
msgstr "Benachrichtigung erneut an externen Dienst senden"
|
msgstr "Benachrichtigung erneut an externen Dienst senden"
|
||||||
|
|
||||||
#: core/admin.py:171
|
#: core/admin.py:339
|
||||||
#, python-format
|
#, python-format
|
||||||
msgid "Failed to notify for recording %(id)s"
|
msgid "Failed to notify for recording %(id)s"
|
||||||
msgstr "Benachrichtigung für Aufnahme %(id)s fehlgeschlagen"
|
msgstr "Benachrichtigung für Aufnahme %(id)s fehlgeschlagen"
|
||||||
|
|
||||||
#: core/admin.py:179
|
#: core/admin.py:347
|
||||||
#, python-format
|
#, python-format
|
||||||
msgid "Failed to notify for recording %(id)s: %(error)s"
|
msgid "Failed to notify for recording %(id)s: %(error)s"
|
||||||
msgstr "Benachrichtigung für Aufnahme %(id)s fehlgeschlagen: %(error)s"
|
msgstr "Benachrichtigung für Aufnahme %(id)s fehlgeschlagen: %(error)s"
|
||||||
|
|
||||||
#: core/admin.py:187
|
#: core/admin.py:355
|
||||||
#, python-format
|
#, python-format
|
||||||
msgid "Successfully sent notifications for %(count)s recording(s)."
|
msgid "Successfully sent notifications for %(count)s recording(s)."
|
||||||
msgstr "Benachrichtigungen für %(count)s Aufnahme(n) erfolgreich gesendet."
|
msgstr "Benachrichtigungen für %(count)s Aufnahme(n) erfolgreich gesendet."
|
||||||
|
|
||||||
#: core/admin.py:195
|
#: core/admin.py:363
|
||||||
#, python-format
|
#, python-format
|
||||||
msgid "Skipped %(count)s expired recording(s)."
|
msgid "Skipped %(count)s expired recording(s)."
|
||||||
msgstr "%(count)s abgelaufene Aufnahme(n) übersprungen."
|
msgstr "%(count)s abgelaufene Aufnahme(n) übersprungen."
|
||||||
|
|
||||||
#: core/admin.py:200
|
#: core/admin.py:368
|
||||||
msgid "Mark selected recordings as 'Failed to Stop'"
|
msgid "Mark selected recordings as 'Failed to Stop'"
|
||||||
msgstr "Ausgewählte Aufnahmen als ‚Fehler beim Stoppen‘ markieren"
|
msgstr "Ausgewählte Aufnahmen als ‚Fehler beim Stoppen‘ markieren"
|
||||||
|
|
||||||
#: core/admin.py:218
|
#: core/admin.py:386
|
||||||
#, python-format
|
#, python-format
|
||||||
msgid "%(count)s recording(s) successfully marked as 'Failed to Stop'."
|
msgid "%(count)s recording(s) successfully marked as 'Failed to Stop'."
|
||||||
msgstr "%(count)s Aufnahme(n) erfolgreich als ‚Fehler beim Stoppen‘ markiert."
|
msgstr "%(count)s Aufnahme(n) erfolgreich als ‚Fehler beim Stoppen‘ markiert."
|
||||||
|
|
||||||
#: core/admin.py:226
|
#: core/admin.py:394
|
||||||
#, fuzzy, python-format
|
#, fuzzy, python-format
|
||||||
#| msgid "Skipped %(count)s expired recording(s)."
|
#| msgid "Skipped %(count)s expired recording(s)."
|
||||||
msgid "Skipped %(count)s recording(s) with an ineligible status."
|
msgid "Skipped %(count)s recording(s) with an ineligible status."
|
||||||
msgstr "%(count)s abgelaufene Aufnahme(n) übersprungen."
|
msgstr "%(count)s abgelaufene Aufnahme(n) übersprungen."
|
||||||
|
|
||||||
#: core/admin.py:342
|
#: core/admin.py:510
|
||||||
msgid "No scopes"
|
msgid "No scopes"
|
||||||
msgstr "Keine Scopes"
|
msgstr "Keine Scopes"
|
||||||
|
|
||||||
#: core/admin.py:344
|
#: core/admin.py:512
|
||||||
msgid "Scopes"
|
msgid "Scopes"
|
||||||
msgstr "Scopes"
|
msgstr "Scopes"
|
||||||
|
|
||||||
@@ -88,17 +110,17 @@ msgstr "Scopes"
|
|||||||
msgid "Creator is me"
|
msgid "Creator is me"
|
||||||
msgstr "Ersteller bin ich"
|
msgstr "Ersteller bin ich"
|
||||||
|
|
||||||
#: core/api/serializers.py:88
|
#: core/api/serializers.py:89
|
||||||
msgid "You must be administrator or owner of a room to add accesses to it."
|
msgid "You must be administrator or owner of a room to add accesses to it."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
"Sie müssen Administrator oder Eigentümer eines Raums sein, um Zugriffe "
|
"Sie müssen Administrator oder Eigentümer eines Raums sein, um Zugriffe "
|
||||||
"hinzuzufügen."
|
"hinzuzufügen."
|
||||||
|
|
||||||
#: core/api/serializers.py:509
|
#: core/api/serializers.py:534
|
||||||
msgid "This file extension is not allowed."
|
msgid "This file extension is not allowed."
|
||||||
msgstr "Diese Dateiendung ist nicht erlaubt."
|
msgstr "Diese Dateiendung ist nicht erlaubt."
|
||||||
|
|
||||||
#: core/api/viewsets.py:1090
|
#: core/api/viewsets.py:1222
|
||||||
msgid "You have reached the maximum number of files for this type."
|
msgid "You have reached the maximum number of files for this type."
|
||||||
msgstr "Sie haben die maximale Anzahl an Dateien dieses Typs erreicht."
|
msgstr "Sie haben die maximale Anzahl an Dateien dieses Typs erreicht."
|
||||||
|
|
||||||
@@ -146,51 +168,59 @@ msgstr "Stopp fehlgeschlagen"
|
|||||||
msgid "Notification succeeded"
|
msgid "Notification succeeded"
|
||||||
msgstr "Benachrichtigung erfolgreich"
|
msgstr "Benachrichtigung erfolgreich"
|
||||||
|
|
||||||
#: core/models.py:89
|
#: core/models.py:65
|
||||||
|
msgid "External process successful"
|
||||||
|
msgstr "Externer Prozess erfolgreich"
|
||||||
|
|
||||||
|
#: core/models.py:67
|
||||||
|
msgid "External process failed"
|
||||||
|
msgstr "Externer Prozess fehlgeschlagen"
|
||||||
|
|
||||||
|
#: core/models.py:96
|
||||||
msgid "SCREEN_RECORDING"
|
msgid "SCREEN_RECORDING"
|
||||||
msgstr "BILDSCHIRMAUFZEICHNUNG"
|
msgstr "BILDSCHIRMAUFZEICHNUNG"
|
||||||
|
|
||||||
#: core/models.py:90
|
#: core/models.py:97
|
||||||
msgid "TRANSCRIPT"
|
msgid "TRANSCRIPT"
|
||||||
msgstr "TRANSKRIPT"
|
msgstr "TRANSKRIPT"
|
||||||
|
|
||||||
#: core/models.py:96
|
#: core/models.py:103
|
||||||
msgid "Public Access"
|
msgid "Public Access"
|
||||||
msgstr "Öffentlicher Zugriff"
|
msgstr "Öffentlicher Zugriff"
|
||||||
|
|
||||||
#: core/models.py:97
|
#: core/models.py:104
|
||||||
msgid "Trusted Access"
|
msgid "Trusted Access"
|
||||||
msgstr "Vertrauenswürdiger Zugriff"
|
msgstr "Vertrauenswürdiger Zugriff"
|
||||||
|
|
||||||
#: core/models.py:98
|
#: core/models.py:105
|
||||||
msgid "Restricted Access"
|
msgid "Restricted Access"
|
||||||
msgstr "Eingeschränkter Zugriff"
|
msgstr "Eingeschränkter Zugriff"
|
||||||
|
|
||||||
#: core/models.py:110
|
#: core/models.py:117
|
||||||
msgid "id"
|
msgid "id"
|
||||||
msgstr "ID"
|
msgstr "ID"
|
||||||
|
|
||||||
#: core/models.py:111
|
#: core/models.py:118
|
||||||
msgid "primary key for the record as UUID"
|
msgid "primary key for the record as UUID"
|
||||||
msgstr "Primärschlüssel des Eintrags als UUID"
|
msgstr "Primärschlüssel des Eintrags als UUID"
|
||||||
|
|
||||||
#: core/models.py:117
|
#: core/models.py:124
|
||||||
msgid "created on"
|
msgid "created on"
|
||||||
msgstr "erstellt am"
|
msgstr "erstellt am"
|
||||||
|
|
||||||
#: core/models.py:118
|
#: core/models.py:125
|
||||||
msgid "date and time at which a record was created"
|
msgid "date and time at which a record was created"
|
||||||
msgstr "Datum und Uhrzeit der Erstellung eines Eintrags"
|
msgstr "Datum und Uhrzeit der Erstellung eines Eintrags"
|
||||||
|
|
||||||
#: core/models.py:123
|
#: core/models.py:130
|
||||||
msgid "updated on"
|
msgid "updated on"
|
||||||
msgstr "aktualisiert am"
|
msgstr "aktualisiert am"
|
||||||
|
|
||||||
#: core/models.py:124
|
#: core/models.py:131
|
||||||
msgid "date and time at which a record was last updated"
|
msgid "date and time at which a record was last updated"
|
||||||
msgstr "Datum und Uhrzeit der letzten Aktualisierung eines Eintrags"
|
msgstr "Datum und Uhrzeit der letzten Aktualisierung eines Eintrags"
|
||||||
|
|
||||||
#: core/models.py:144
|
#: core/models.py:151
|
||||||
msgid ""
|
msgid ""
|
||||||
"Enter a valid sub. This value may contain only letters, numbers, and @/./+/-/"
|
"Enter a valid sub. This value may contain only letters, numbers, and @/./+/-/"
|
||||||
"_ characters."
|
"_ characters."
|
||||||
@@ -198,11 +228,11 @@ msgstr ""
|
|||||||
"Geben Sie einen gültigen Sub ein. Dieser Wert darf nur Buchstaben, Zahlen "
|
"Geben Sie einen gültigen Sub ein. Dieser Wert darf nur Buchstaben, Zahlen "
|
||||||
"und die Zeichen @/./+/-/_ enthalten."
|
"und die Zeichen @/./+/-/_ enthalten."
|
||||||
|
|
||||||
#: core/models.py:150
|
#: core/models.py:157
|
||||||
msgid "sub"
|
msgid "sub"
|
||||||
msgstr "Sub"
|
msgstr "Sub"
|
||||||
|
|
||||||
#: core/models.py:152
|
#: core/models.py:159
|
||||||
msgid ""
|
msgid ""
|
||||||
"Optional for pending users; required upon account activation. 255 characters "
|
"Optional for pending users; required upon account activation. 255 characters "
|
||||||
"or fewer. Letters, numbers, and @/./+/-/_ characters only."
|
"or fewer. Letters, numbers, and @/./+/-/_ characters only."
|
||||||
@@ -210,55 +240,55 @@ msgstr ""
|
|||||||
"Optional für ausstehende Benutzer; erforderlich nach Kontoaktivierung. "
|
"Optional für ausstehende Benutzer; erforderlich nach Kontoaktivierung. "
|
||||||
"Maximal 255 Zeichen. Nur Buchstaben, Zahlen und @/./+/-/_ Zeichen erlaubt."
|
"Maximal 255 Zeichen. Nur Buchstaben, Zahlen und @/./+/-/_ Zeichen erlaubt."
|
||||||
|
|
||||||
#: core/models.py:161
|
#: core/models.py:168
|
||||||
msgid "identity email address"
|
msgid "identity email address"
|
||||||
msgstr "Identitäts-E-Mail-Adresse"
|
msgstr "Identitäts-E-Mail-Adresse"
|
||||||
|
|
||||||
#: core/models.py:166
|
#: core/models.py:173
|
||||||
msgid "admin email address"
|
msgid "admin email address"
|
||||||
msgstr "Administrator-E-Mail-Adresse"
|
msgstr "Administrator-E-Mail-Adresse"
|
||||||
|
|
||||||
#: core/models.py:168
|
#: core/models.py:175
|
||||||
msgid "full name"
|
msgid "full name"
|
||||||
msgstr "Vollständiger Name"
|
msgstr "Vollständiger Name"
|
||||||
|
|
||||||
#: core/models.py:170
|
#: core/models.py:177
|
||||||
msgid "short name"
|
msgid "short name"
|
||||||
msgstr "Kurzname"
|
msgstr "Kurzname"
|
||||||
|
|
||||||
#: core/models.py:176
|
#: core/models.py:183
|
||||||
msgid "language"
|
msgid "language"
|
||||||
msgstr "Sprache"
|
msgstr "Sprache"
|
||||||
|
|
||||||
#: core/models.py:177
|
#: core/models.py:184
|
||||||
msgid "The language in which the user wants to see the interface."
|
msgid "The language in which the user wants to see the interface."
|
||||||
msgstr "Die Sprache, in der der Benutzer die Oberfläche sehen möchte."
|
msgstr "Die Sprache, in der der Benutzer die Oberfläche sehen möchte."
|
||||||
|
|
||||||
#: core/models.py:183
|
#: core/models.py:190
|
||||||
msgid "The timezone in which the user wants to see times."
|
msgid "The timezone in which the user wants to see times."
|
||||||
msgstr "Die Zeitzone, in der der Benutzer die Zeiten sehen möchte."
|
msgstr "Die Zeitzone, in der der Benutzer die Zeiten sehen möchte."
|
||||||
|
|
||||||
#: core/models.py:186
|
#: core/models.py:193
|
||||||
msgid "device"
|
msgid "device"
|
||||||
msgstr "Gerät"
|
msgstr "Gerät"
|
||||||
|
|
||||||
#: core/models.py:188
|
#: core/models.py:195
|
||||||
msgid "Whether the user is a device or a real user."
|
msgid "Whether the user is a device or a real user."
|
||||||
msgstr "Ob es sich um ein Gerät oder einen echten Benutzer handelt."
|
msgstr "Ob es sich um ein Gerät oder einen echten Benutzer handelt."
|
||||||
|
|
||||||
#: core/models.py:191
|
#: core/models.py:198
|
||||||
msgid "staff status"
|
msgid "staff status"
|
||||||
msgstr "Mitarbeiterstatus"
|
msgstr "Mitarbeiterstatus"
|
||||||
|
|
||||||
#: core/models.py:193
|
#: core/models.py:200
|
||||||
msgid "Whether the user can log into this admin site."
|
msgid "Whether the user can log into this admin site."
|
||||||
msgstr "Ob der Benutzer sich bei dieser Admin-Seite anmelden kann."
|
msgstr "Ob der Benutzer sich bei dieser Admin-Seite anmelden kann."
|
||||||
|
|
||||||
#: core/models.py:196
|
#: core/models.py:203
|
||||||
msgid "active"
|
msgid "active"
|
||||||
msgstr "aktiv"
|
msgstr "aktiv"
|
||||||
|
|
||||||
#: core/models.py:199
|
#: core/models.py:206
|
||||||
msgid ""
|
msgid ""
|
||||||
"Whether this user should be treated as active. Unselect this instead of "
|
"Whether this user should be treated as active. Unselect this instead of "
|
||||||
"deleting accounts."
|
"deleting accounts."
|
||||||
@@ -266,66 +296,66 @@ msgstr ""
|
|||||||
"Ob dieser Benutzer als aktiv behandelt werden soll. Deaktivieren Sie dies "
|
"Ob dieser Benutzer als aktiv behandelt werden soll. Deaktivieren Sie dies "
|
||||||
"anstelle des Löschens des Kontos."
|
"anstelle des Löschens des Kontos."
|
||||||
|
|
||||||
#: core/models.py:212
|
#: core/models.py:219
|
||||||
msgid "user"
|
msgid "user"
|
||||||
msgstr "Benutzer"
|
msgstr "Benutzer"
|
||||||
|
|
||||||
#: core/models.py:213
|
#: core/models.py:220
|
||||||
msgid "users"
|
msgid "users"
|
||||||
msgstr "Benutzer"
|
msgstr "Benutzer"
|
||||||
|
|
||||||
#: core/models.py:272
|
#: core/models.py:286
|
||||||
msgid "Resource"
|
msgid "Resource"
|
||||||
msgstr "Ressource"
|
msgstr "Ressource"
|
||||||
|
|
||||||
#: core/models.py:273
|
#: core/models.py:287
|
||||||
msgid "Resources"
|
msgid "Resources"
|
||||||
msgstr "Ressourcen"
|
msgstr "Ressourcen"
|
||||||
|
|
||||||
#: core/models.py:331
|
#: core/models.py:345
|
||||||
msgid "Resource access"
|
msgid "Resource access"
|
||||||
msgstr "Ressourcenzugriff"
|
msgstr "Ressourcenzugriff"
|
||||||
|
|
||||||
#: core/models.py:332
|
#: core/models.py:346
|
||||||
msgid "Resource accesses"
|
msgid "Resource accesses"
|
||||||
msgstr "Ressourcenzugriffe"
|
msgstr "Ressourcenzugriffe"
|
||||||
|
|
||||||
#: core/models.py:338
|
#: core/models.py:352
|
||||||
msgid "Resource access with this User and Resource already exists."
|
msgid "Resource access with this User and Resource already exists."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
"Ein Ressourcenzugriff mit diesem Benutzer und dieser Ressource existiert "
|
"Ein Ressourcenzugriff mit diesem Benutzer und dieser Ressource existiert "
|
||||||
"bereits."
|
"bereits."
|
||||||
|
|
||||||
#: core/models.py:394
|
#: core/models.py:409
|
||||||
msgid "Visio room configuration"
|
msgid "Visio room configuration"
|
||||||
msgstr "Visio-Raumkonfiguration"
|
msgstr "Visio-Raumkonfiguration"
|
||||||
|
|
||||||
#: core/models.py:395
|
#: core/models.py:410
|
||||||
msgid "Values for Visio parameters to configure the room."
|
msgid "Values for Visio parameters to configure the room."
|
||||||
msgstr "Werte für Visio-Parameter zur Konfiguration des Raums."
|
msgstr "Werte für Visio-Parameter zur Konfiguration des Raums."
|
||||||
|
|
||||||
#: core/models.py:402
|
#: core/models.py:417
|
||||||
msgid "Room PIN code"
|
msgid "Room PIN code"
|
||||||
msgstr "PIN-Code für den Raum"
|
msgstr "PIN-Code für den Raum"
|
||||||
|
|
||||||
#: core/models.py:403
|
#: core/models.py:418
|
||||||
msgid "Unique n-digit code that identifies this room in telephony mode."
|
msgid "Unique n-digit code that identifies this room in telephony mode."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
"Eindeutiger n-stelliger Code, der diesen Raum im Telephonmodus identifiziert."
|
"Eindeutiger n-stelliger Code, der diesen Raum im Telephonmodus identifiziert."
|
||||||
|
|
||||||
#: core/models.py:409 core/models.py:563
|
#: core/models.py:424 core/models.py:578
|
||||||
msgid "Room"
|
msgid "Room"
|
||||||
msgstr "Raum"
|
msgstr "Raum"
|
||||||
|
|
||||||
#: core/models.py:410
|
#: core/models.py:425
|
||||||
msgid "Rooms"
|
msgid "Rooms"
|
||||||
msgstr "Räume"
|
msgstr "Räume"
|
||||||
|
|
||||||
#: core/models.py:574
|
#: core/models.py:589
|
||||||
msgid "Worker ID"
|
msgid "Worker ID"
|
||||||
msgstr "Worker-ID"
|
msgstr "Worker-ID"
|
||||||
|
|
||||||
#: core/models.py:576
|
#: core/models.py:591
|
||||||
msgid ""
|
msgid ""
|
||||||
"Enter an identifier for the worker recording.This ID is retained even when "
|
"Enter an identifier for the worker recording.This ID is retained even when "
|
||||||
"the worker stops, allowing for easy tracking."
|
"the worker stops, allowing for easy tracking."
|
||||||
@@ -334,141 +364,153 @@ msgstr ""
|
|||||||
"erhalten, auch wenn der Worker stoppt, was ein einfaches Nachverfolgen "
|
"erhalten, auch wenn der Worker stoppt, was ein einfaches Nachverfolgen "
|
||||||
"ermöglicht."
|
"ermöglicht."
|
||||||
|
|
||||||
#: core/models.py:584
|
#: core/models.py:599
|
||||||
msgid "Recording mode"
|
msgid "Recording mode"
|
||||||
msgstr "Aufzeichnungsmodus"
|
msgstr "Aufzeichnungsmodus"
|
||||||
|
|
||||||
#: core/models.py:585
|
#: core/models.py:600
|
||||||
msgid "Defines the mode of recording being called."
|
msgid "Defines the mode of recording being called."
|
||||||
msgstr "Definiert den aufgerufenen Aufzeichnungsmodus."
|
msgstr "Definiert den aufgerufenen Aufzeichnungsmodus."
|
||||||
|
|
||||||
#: core/models.py:590 core/models.py:591
|
#: core/models.py:605 core/models.py:606
|
||||||
msgid "Recording options"
|
msgid "Recording options"
|
||||||
msgstr "Aufnahmeoptionen"
|
msgstr "Aufnahmeoptionen"
|
||||||
|
|
||||||
#: core/models.py:597
|
#: core/models.py:613
|
||||||
|
msgid "External Process ID"
|
||||||
|
msgstr "External Process ID"
|
||||||
|
|
||||||
|
#: core/models.py:614
|
||||||
|
msgid "ID of the external process associated with the recording."
|
||||||
|
msgstr "ID des externen Prozesses, der mit der Aufzeichnung verknüpft ist"
|
||||||
|
|
||||||
|
#: core/models.py:620
|
||||||
msgid "Recording"
|
msgid "Recording"
|
||||||
msgstr "Aufzeichnung"
|
msgstr "Aufzeichnung"
|
||||||
|
|
||||||
#: core/models.py:598
|
#: core/models.py:621
|
||||||
msgid "Recordings"
|
msgid "Recordings"
|
||||||
msgstr "Aufzeichnungen"
|
msgstr "Aufzeichnungen"
|
||||||
|
|
||||||
#: core/models.py:706
|
#: core/models.py:731
|
||||||
msgid "Recording/user relation"
|
msgid "Recording/user relation"
|
||||||
msgstr "Beziehung Aufzeichnung/Benutzer"
|
msgstr "Beziehung Aufzeichnung/Benutzer"
|
||||||
|
|
||||||
#: core/models.py:707
|
#: core/models.py:732
|
||||||
msgid "Recording/user relations"
|
msgid "Recording/user relations"
|
||||||
msgstr "Beziehungen Aufzeichnung/Benutzer"
|
msgstr "Beziehungen Aufzeichnung/Benutzer"
|
||||||
|
|
||||||
#: core/models.py:713
|
#: core/models.py:738
|
||||||
msgid "This user is already in this recording."
|
msgid "This user is already in this recording."
|
||||||
msgstr "Dieser Benutzer ist bereits Teil dieser Aufzeichnung."
|
msgstr "Dieser Benutzer ist bereits Teil dieser Aufzeichnung."
|
||||||
|
|
||||||
#: core/models.py:719
|
#: core/models.py:744
|
||||||
msgid "This team is already in this recording."
|
msgid "This team is already in this recording."
|
||||||
msgstr "Dieses Team ist bereits Teil dieser Aufzeichnung."
|
msgstr "Dieses Team ist bereits Teil dieser Aufzeichnung."
|
||||||
|
|
||||||
#: core/models.py:725
|
#: core/models.py:750
|
||||||
msgid "Either user or team must be set, not both."
|
msgid "Either user or team must be set, not both."
|
||||||
msgstr "Entweder Benutzer oder Team muss festgelegt werden, nicht beides."
|
msgstr "Entweder Benutzer oder Team muss festgelegt werden, nicht beides."
|
||||||
|
|
||||||
#: core/models.py:742
|
#: core/models.py:767
|
||||||
msgid "Create rooms"
|
msgid "Create rooms"
|
||||||
msgstr "Räume erstellen"
|
msgstr "Räume erstellen"
|
||||||
|
|
||||||
#: core/models.py:743
|
#: core/models.py:768
|
||||||
msgid "List rooms"
|
msgid "List rooms"
|
||||||
msgstr "Räume auflisten"
|
msgstr "Räume auflisten"
|
||||||
|
|
||||||
#: core/models.py:744
|
#: core/models.py:769
|
||||||
msgid "Retrieve room details"
|
msgid "Retrieve room details"
|
||||||
msgstr "Raumdetails abrufen"
|
msgstr "Raumdetails abrufen"
|
||||||
|
|
||||||
#: core/models.py:745
|
#: core/models.py:770
|
||||||
msgid "Update rooms"
|
msgid "Update rooms"
|
||||||
msgstr "Räume aktualisieren"
|
msgstr "Räume aktualisieren"
|
||||||
|
|
||||||
#: core/models.py:746
|
#: core/models.py:771
|
||||||
msgid "Delete rooms"
|
msgid "Delete rooms"
|
||||||
msgstr "Räume löschen"
|
msgstr "Räume löschen"
|
||||||
|
|
||||||
#: core/models.py:759
|
#: core/models.py:784
|
||||||
msgid "Application name"
|
msgid "Application name"
|
||||||
msgstr "Anwendungsname"
|
msgstr "Anwendungsname"
|
||||||
|
|
||||||
#: core/models.py:760
|
#: core/models.py:785
|
||||||
msgid "Descriptive name for this application."
|
msgid "Descriptive name for this application."
|
||||||
msgstr "Beschreibender Name für diese Anwendung."
|
msgstr "Beschreibender Name für diese Anwendung."
|
||||||
|
|
||||||
#: core/models.py:770
|
#: core/models.py:795
|
||||||
msgid "Hashed on Save. Copy it now if this is a new secret."
|
msgid "Hashed on Save. Copy it now if this is a new secret."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
"Beim Speichern gehasht. Jetzt kopieren, wenn dies ein neues Geheimnis ist."
|
"Beim Speichern gehasht. Jetzt kopieren, wenn dies ein neues Geheimnis ist."
|
||||||
|
|
||||||
#: core/models.py:781
|
#: core/models.py:806
|
||||||
msgid "Application"
|
msgid "Application"
|
||||||
msgstr "Anwendung"
|
msgstr "Anwendung"
|
||||||
|
|
||||||
#: core/models.py:782
|
#: core/models.py:807
|
||||||
msgid "Applications"
|
msgid "Applications"
|
||||||
msgstr "Anwendungen"
|
msgstr "Anwendungen"
|
||||||
|
|
||||||
#: core/models.py:805
|
#: core/models.py:830
|
||||||
msgid "Enter a valid domain"
|
msgid "Enter a valid domain"
|
||||||
msgstr "Geben Sie eine gültige Domain ein"
|
msgstr "Geben Sie eine gültige Domain ein"
|
||||||
|
|
||||||
#: core/models.py:808
|
#: core/models.py:833
|
||||||
msgid "Domain"
|
msgid "Domain"
|
||||||
msgstr "Domain"
|
msgstr "Domain"
|
||||||
|
|
||||||
#: core/models.py:809
|
#: core/models.py:834
|
||||||
msgid "Email domain this application can act on behalf of."
|
msgid "Email domain this application can act on behalf of."
|
||||||
msgstr "E-Mail-Domain, im Namen der diese Anwendung handeln kann."
|
msgstr "E-Mail-Domain, im Namen der diese Anwendung handeln kann."
|
||||||
|
|
||||||
#: core/models.py:821
|
#: core/models.py:846
|
||||||
msgid "Application domain"
|
msgid "Application domain"
|
||||||
msgstr "Anwendungsdomain"
|
msgstr "Anwendungsdomain"
|
||||||
|
|
||||||
#: core/models.py:822
|
#: core/models.py:847
|
||||||
msgid "Application domains"
|
msgid "Application domains"
|
||||||
msgstr "Anwendungsdomains"
|
msgstr "Anwendungsdomains"
|
||||||
|
|
||||||
#: core/models.py:840
|
#: core/models.py:865
|
||||||
msgid "Pending"
|
msgid "Pending"
|
||||||
msgstr "Ausstehend"
|
msgstr "Ausstehend"
|
||||||
|
|
||||||
#: core/models.py:848
|
#: core/models.py:866
|
||||||
|
msgid "Analyzing"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: core/models.py:873
|
||||||
msgid "Ready"
|
msgid "Ready"
|
||||||
msgstr "Bereit"
|
msgstr "Bereit"
|
||||||
|
|
||||||
#: core/models.py:854
|
#: core/models.py:879
|
||||||
msgid "Background image"
|
msgid "Background image"
|
||||||
msgstr "Hintergrundbild"
|
msgstr "Hintergrundbild"
|
||||||
|
|
||||||
#: core/models.py:866
|
#: core/models.py:891
|
||||||
msgid "title"
|
msgid "title"
|
||||||
msgstr "Titel"
|
msgstr "Titel"
|
||||||
|
|
||||||
#: core/models.py:890
|
#: core/models.py:915
|
||||||
msgid "Malware detection info when the analysis status is unsafe."
|
msgid "Malware detection info when the analysis status is unsafe."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
"Informationen zur Malware-Erkennung, wenn der Analyse-Status unsicher ist."
|
"Informationen zur Malware-Erkennung, wenn der Analyse-Status unsicher ist."
|
||||||
|
|
||||||
#: core/models.py:895
|
#: core/models.py:920
|
||||||
msgid "File"
|
msgid "File"
|
||||||
msgstr "Datei"
|
msgstr "Datei"
|
||||||
|
|
||||||
#: core/models.py:896
|
#: core/models.py:921
|
||||||
msgid "Files"
|
msgid "Files"
|
||||||
msgstr "Dateien"
|
msgstr "Dateien"
|
||||||
|
|
||||||
#: core/models.py:1000
|
#: core/models.py:1041
|
||||||
msgid "This file is already hard deleted."
|
msgid "This file is already hard deleted."
|
||||||
msgstr "Diese Datei wurde bereits endgültig gelöscht."
|
msgstr "Diese Datei wurde bereits endgültig gelöscht."
|
||||||
|
|
||||||
#: core/models.py:1010
|
#: core/models.py:1051
|
||||||
#, fuzzy
|
#, fuzzy
|
||||||
#| msgid "To hard delete a file, it must first be soft deleted."
|
#| msgid "To hard delete a file, it must first be soft deleted."
|
||||||
msgid "To hard delete a file, it must first be soft deleted."
|
msgid "To hard delete a file, it must first be soft deleted."
|
||||||
@@ -476,10 +518,20 @@ msgstr ""
|
|||||||
"Um eine Datei endgültig zu löschen, muss sie zuvor weich gelöscht worden "
|
"Um eine Datei endgültig zu löschen, muss sie zuvor weich gelöscht worden "
|
||||||
"sein."
|
"sein."
|
||||||
|
|
||||||
#: core/recording/event/notification.py:116
|
#: core/recording/event/notification.py:123
|
||||||
msgid "Your recording is ready"
|
msgid "Your recording is ready"
|
||||||
msgstr "Ihre Aufzeichnung ist bereit"
|
msgstr "Ihre Aufzeichnung ist bereit"
|
||||||
|
|
||||||
|
#: core/recording/event/notification.py:194
|
||||||
|
msgid "Transcription"
|
||||||
|
msgstr "Transkription"
|
||||||
|
|
||||||
|
#: core/recording/event/notification.py:204
|
||||||
|
#, python-brace-format
|
||||||
|
msgid "Meeting \"{room}\" on {room_recording_date} at {room_recording_time}"
|
||||||
|
msgstr ""
|
||||||
|
"Besprechung \"{room}\" am {room_recording_date} um {room_recording_time}"
|
||||||
|
|
||||||
#: core/services/invitation.py:44
|
#: core/services/invitation.py:44
|
||||||
#, python-brace-format
|
#, python-brace-format
|
||||||
msgid "Video call in progress: {sender.email} is waiting for you to connect"
|
msgid "Video call in progress: {sender.email} is waiting for you to connect"
|
||||||
@@ -575,7 +627,7 @@ msgid "To keep this recording permanently:"
|
|||||||
msgstr "So speichern Sie diese Aufzeichnung dauerhaft:"
|
msgstr "So speichern Sie diese Aufzeichnung dauerhaft:"
|
||||||
|
|
||||||
#: core/templates/mail/html/screen_recording.html:208
|
#: core/templates/mail/html/screen_recording.html:208
|
||||||
#: core/templates/mail/text/screen_recording.txt:13
|
#, python-format
|
||||||
msgid "Click the \"<a href=\"%(link)s\">Open</a>\" link below "
|
msgid "Click the \"<a href=\"%(link)s\">Open</a>\" link below "
|
||||||
msgstr "Klicken Sie auf den Link „<a href=\"%(link)s\">Öffnen</a>\" unten "
|
msgstr "Klicken Sie auf den Link „<a href=\"%(link)s\">Öffnen</a>\" unten "
|
||||||
|
|
||||||
@@ -604,18 +656,24 @@ msgstr ""
|
|||||||
" Wenn Sie Fragen haben oder Unterstützung benötigen, wenden Sie sich bitte "
|
" Wenn Sie Fragen haben oder Unterstützung benötigen, wenden Sie sich bitte "
|
||||||
"an unser Support-Team unter %(support_email)s. "
|
"an unser Support-Team unter %(support_email)s. "
|
||||||
|
|
||||||
#: meet/settings.py:223
|
#: core/templates/mail/text/screen_recording.txt:13
|
||||||
|
#, fuzzy, python-format
|
||||||
|
#| msgid "Click the \"<a href=\"%(link)s\">Open</a>\" link below "
|
||||||
|
msgid "Click the \"Open [%(link)s]\" link below "
|
||||||
|
msgstr "Klicken Sie auf den Link „<a href=\"%(link)s\">Öffnen</a>\" unten "
|
||||||
|
|
||||||
|
#: meet/settings.py:228
|
||||||
msgid "English"
|
msgid "English"
|
||||||
msgstr "Englisch"
|
msgstr "Englisch"
|
||||||
|
|
||||||
#: meet/settings.py:224
|
#: meet/settings.py:229
|
||||||
msgid "French"
|
msgid "French"
|
||||||
msgstr "Französisch"
|
msgstr "Französisch"
|
||||||
|
|
||||||
#: meet/settings.py:225
|
#: meet/settings.py:230
|
||||||
msgid "Dutch"
|
msgid "Dutch"
|
||||||
msgstr "Niederländisch"
|
msgstr "Niederländisch"
|
||||||
|
|
||||||
#: meet/settings.py:226
|
#: meet/settings.py:231
|
||||||
msgid "German"
|
msgid "German"
|
||||||
msgstr "Deutsch"
|
msgstr "Deutsch"
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ msgid ""
|
|||||||
msgstr ""
|
msgstr ""
|
||||||
"Project-Id-Version: PACKAGE VERSION\n"
|
"Project-Id-Version: PACKAGE VERSION\n"
|
||||||
"Report-Msgid-Bugs-To: \n"
|
"Report-Msgid-Bugs-To: \n"
|
||||||
"POT-Creation-Date: 2026-04-21 14:09+0000\n"
|
"POT-Creation-Date: 2026-07-02 10:47+0000\n"
|
||||||
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
|
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
|
||||||
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
|
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
|
||||||
"Language-Team: LANGUAGE <LL@li.org>\n"
|
"Language-Team: LANGUAGE <LL@li.org>\n"
|
||||||
@@ -17,70 +17,92 @@ msgstr ""
|
|||||||
"Content-Type: text/plain; charset=UTF-8\n"
|
"Content-Type: text/plain; charset=UTF-8\n"
|
||||||
"Content-Transfer-Encoding: 8bit\n"
|
"Content-Transfer-Encoding: 8bit\n"
|
||||||
|
|
||||||
#: core/admin.py:29
|
#: core/admin.py:67
|
||||||
msgid "Personal info"
|
msgid "Personal info"
|
||||||
msgstr "Personal info"
|
msgstr "Personal info"
|
||||||
|
|
||||||
#: core/admin.py:42
|
#: core/admin.py:80
|
||||||
msgid "Permissions"
|
msgid "Permissions"
|
||||||
msgstr "Permissions"
|
msgstr "Permissions"
|
||||||
|
|
||||||
#: core/admin.py:54
|
#: core/admin.py:92
|
||||||
msgid "Important dates"
|
msgid "Important dates"
|
||||||
msgstr "Important dates"
|
msgstr "Important dates"
|
||||||
|
|
||||||
#: core/admin.py:132 core/admin.py:275
|
#: core/admin.py:206
|
||||||
|
msgid "Content"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: core/admin.py:217
|
||||||
|
#, fuzzy
|
||||||
|
#| msgid "Delete rooms"
|
||||||
|
msgid "Deletion"
|
||||||
|
msgstr "Delete rooms"
|
||||||
|
|
||||||
|
#: core/admin.py:226
|
||||||
|
msgid "Derived info"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: core/admin.py:237
|
||||||
|
msgid "Timestamps"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: core/admin.py:240
|
||||||
|
msgid "File preview"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: core/admin.py:300 core/admin.py:443
|
||||||
msgid "No owner"
|
msgid "No owner"
|
||||||
msgstr "No owner"
|
msgstr "No owner"
|
||||||
|
|
||||||
#: core/admin.py:135 core/admin.py:278
|
#: core/admin.py:303 core/admin.py:446
|
||||||
msgid "Multiple owners"
|
msgid "Multiple owners"
|
||||||
msgstr "Multiple owners"
|
msgstr "Multiple owners"
|
||||||
|
|
||||||
#: core/admin.py:148
|
#: core/admin.py:316
|
||||||
msgid "Resend notification to external service"
|
msgid "Resend notification to external service"
|
||||||
msgstr "Resend notification to external service"
|
msgstr "Resend notification to external service"
|
||||||
|
|
||||||
#: core/admin.py:171
|
#: core/admin.py:339
|
||||||
#, python-format
|
#, python-format
|
||||||
msgid "Failed to notify for recording %(id)s"
|
msgid "Failed to notify for recording %(id)s"
|
||||||
msgstr "Failed to notify for recording %(id)s"
|
msgstr "Failed to notify for recording %(id)s"
|
||||||
|
|
||||||
#: core/admin.py:179
|
#: core/admin.py:347
|
||||||
#, python-format
|
#, python-format
|
||||||
msgid "Failed to notify for recording %(id)s: %(error)s"
|
msgid "Failed to notify for recording %(id)s: %(error)s"
|
||||||
msgstr "Failed to notify for recording %(id)s: %(error)s"
|
msgstr "Failed to notify for recording %(id)s: %(error)s"
|
||||||
|
|
||||||
#: core/admin.py:187
|
#: core/admin.py:355
|
||||||
#, python-format
|
#, python-format
|
||||||
msgid "Successfully sent notifications for %(count)s recording(s)."
|
msgid "Successfully sent notifications for %(count)s recording(s)."
|
||||||
msgstr "Successfully sent notifications for %(count)s recording(s)."
|
msgstr "Successfully sent notifications for %(count)s recording(s)."
|
||||||
|
|
||||||
#: core/admin.py:195
|
#: core/admin.py:363
|
||||||
#, python-format
|
#, python-format
|
||||||
msgid "Skipped %(count)s expired recording(s)."
|
msgid "Skipped %(count)s expired recording(s)."
|
||||||
msgstr "Skipped %(count)s expired recording(s)."
|
msgstr "Skipped %(count)s expired recording(s)."
|
||||||
|
|
||||||
#: core/admin.py:200
|
#: core/admin.py:368
|
||||||
msgid "Mark selected recordings as 'Failed to Stop'"
|
msgid "Mark selected recordings as 'Failed to Stop'"
|
||||||
msgstr "Mark selected recordings as 'Failed to Stop'"
|
msgstr "Mark selected recordings as 'Failed to Stop'"
|
||||||
|
|
||||||
#: core/admin.py:218
|
#: core/admin.py:386
|
||||||
#, python-format
|
#, python-format
|
||||||
msgid "%(count)s recording(s) successfully marked as 'Failed to Stop'."
|
msgid "%(count)s recording(s) successfully marked as 'Failed to Stop'."
|
||||||
msgstr "%(count)s recording(s) successfully marked as 'Failed to Stop'."
|
msgstr "%(count)s recording(s) successfully marked as 'Failed to Stop'."
|
||||||
|
|
||||||
#: core/admin.py:226
|
#: core/admin.py:394
|
||||||
#, fuzzy, python-format
|
#, fuzzy, python-format
|
||||||
#| msgid "Skipped %(count)s expired recording(s)."
|
#| msgid "Skipped %(count)s expired recording(s)."
|
||||||
msgid "Skipped %(count)s recording(s) with an ineligible status."
|
msgid "Skipped %(count)s recording(s) with an ineligible status."
|
||||||
msgstr "Skipped %(count)s expired recording(s)."
|
msgstr "Skipped %(count)s expired recording(s)."
|
||||||
|
|
||||||
#: core/admin.py:342
|
#: core/admin.py:510
|
||||||
msgid "No scopes"
|
msgid "No scopes"
|
||||||
msgstr "No scopes"
|
msgstr "No scopes"
|
||||||
|
|
||||||
#: core/admin.py:344
|
#: core/admin.py:512
|
||||||
msgid "Scopes"
|
msgid "Scopes"
|
||||||
msgstr "Scopes"
|
msgstr "Scopes"
|
||||||
|
|
||||||
@@ -88,15 +110,15 @@ msgstr "Scopes"
|
|||||||
msgid "Creator is me"
|
msgid "Creator is me"
|
||||||
msgstr "Creator is me"
|
msgstr "Creator is me"
|
||||||
|
|
||||||
#: core/api/serializers.py:88
|
#: core/api/serializers.py:89
|
||||||
msgid "You must be administrator or owner of a room to add accesses to it."
|
msgid "You must be administrator or owner of a room to add accesses to it."
|
||||||
msgstr "You must be administrator or owner of a room to add accesses to it."
|
msgstr "You must be administrator or owner of a room to add accesses to it."
|
||||||
|
|
||||||
#: core/api/serializers.py:509
|
#: core/api/serializers.py:534
|
||||||
msgid "This file extension is not allowed."
|
msgid "This file extension is not allowed."
|
||||||
msgstr "This file extension is not allowed."
|
msgstr "This file extension is not allowed."
|
||||||
|
|
||||||
#: core/api/viewsets.py:1090
|
#: core/api/viewsets.py:1222
|
||||||
msgid "You have reached the maximum number of files for this type."
|
msgid "You have reached the maximum number of files for this type."
|
||||||
msgstr "You have reached the maximum number of files for this type."
|
msgstr "You have reached the maximum number of files for this type."
|
||||||
|
|
||||||
@@ -144,51 +166,59 @@ msgstr "Failed to Stop"
|
|||||||
msgid "Notification succeeded"
|
msgid "Notification succeeded"
|
||||||
msgstr "Notification succeeded"
|
msgstr "Notification succeeded"
|
||||||
|
|
||||||
#: core/models.py:89
|
#: core/models.py:65
|
||||||
|
msgid "External process successful"
|
||||||
|
msgstr "External process successful"
|
||||||
|
|
||||||
|
#: core/models.py:67
|
||||||
|
msgid "External process failed"
|
||||||
|
msgstr "External process failed"
|
||||||
|
|
||||||
|
#: core/models.py:96
|
||||||
msgid "SCREEN_RECORDING"
|
msgid "SCREEN_RECORDING"
|
||||||
msgstr "SCREEN_RECORDING"
|
msgstr "SCREEN_RECORDING"
|
||||||
|
|
||||||
#: core/models.py:90
|
#: core/models.py:97
|
||||||
msgid "TRANSCRIPT"
|
msgid "TRANSCRIPT"
|
||||||
msgstr "TRANSCRIPT"
|
msgstr "TRANSCRIPT"
|
||||||
|
|
||||||
#: core/models.py:96
|
#: core/models.py:103
|
||||||
msgid "Public Access"
|
msgid "Public Access"
|
||||||
msgstr "Public Access"
|
msgstr "Public Access"
|
||||||
|
|
||||||
#: core/models.py:97
|
#: core/models.py:104
|
||||||
msgid "Trusted Access"
|
msgid "Trusted Access"
|
||||||
msgstr "Trusted Access"
|
msgstr "Trusted Access"
|
||||||
|
|
||||||
#: core/models.py:98
|
#: core/models.py:105
|
||||||
msgid "Restricted Access"
|
msgid "Restricted Access"
|
||||||
msgstr "Restricted Access"
|
msgstr "Restricted Access"
|
||||||
|
|
||||||
#: core/models.py:110
|
#: core/models.py:117
|
||||||
msgid "id"
|
msgid "id"
|
||||||
msgstr "id"
|
msgstr "id"
|
||||||
|
|
||||||
#: core/models.py:111
|
#: core/models.py:118
|
||||||
msgid "primary key for the record as UUID"
|
msgid "primary key for the record as UUID"
|
||||||
msgstr "primary key for the record as UUID"
|
msgstr "primary key for the record as UUID"
|
||||||
|
|
||||||
#: core/models.py:117
|
#: core/models.py:124
|
||||||
msgid "created on"
|
msgid "created on"
|
||||||
msgstr "created on"
|
msgstr "created on"
|
||||||
|
|
||||||
#: core/models.py:118
|
#: core/models.py:125
|
||||||
msgid "date and time at which a record was created"
|
msgid "date and time at which a record was created"
|
||||||
msgstr "date and time at which a record was created"
|
msgstr "date and time at which a record was created"
|
||||||
|
|
||||||
#: core/models.py:123
|
#: core/models.py:130
|
||||||
msgid "updated on"
|
msgid "updated on"
|
||||||
msgstr "updated on"
|
msgstr "updated on"
|
||||||
|
|
||||||
#: core/models.py:124
|
#: core/models.py:131
|
||||||
msgid "date and time at which a record was last updated"
|
msgid "date and time at which a record was last updated"
|
||||||
msgstr "date and time at which a record was last updated"
|
msgstr "date and time at which a record was last updated"
|
||||||
|
|
||||||
#: core/models.py:144
|
#: core/models.py:151
|
||||||
msgid ""
|
msgid ""
|
||||||
"Enter a valid sub. This value may contain only letters, numbers, and @/./+/-/"
|
"Enter a valid sub. This value may contain only letters, numbers, and @/./+/-/"
|
||||||
"_ characters."
|
"_ characters."
|
||||||
@@ -196,11 +226,11 @@ msgstr ""
|
|||||||
"Enter a valid sub. This value may contain only letters, numbers, and @/./+/-/"
|
"Enter a valid sub. This value may contain only letters, numbers, and @/./+/-/"
|
||||||
"_ characters."
|
"_ characters."
|
||||||
|
|
||||||
#: core/models.py:150
|
#: core/models.py:157
|
||||||
msgid "sub"
|
msgid "sub"
|
||||||
msgstr "sub"
|
msgstr "sub"
|
||||||
|
|
||||||
#: core/models.py:152
|
#: core/models.py:159
|
||||||
msgid ""
|
msgid ""
|
||||||
"Optional for pending users; required upon account activation. 255 characters "
|
"Optional for pending users; required upon account activation. 255 characters "
|
||||||
"or fewer. Letters, numbers, and @/./+/-/_ characters only."
|
"or fewer. Letters, numbers, and @/./+/-/_ characters only."
|
||||||
@@ -208,55 +238,55 @@ msgstr ""
|
|||||||
"Required. 255 characters or fewer. Letters, numbers, and @/./+/-/_ "
|
"Required. 255 characters or fewer. Letters, numbers, and @/./+/-/_ "
|
||||||
"characters only."
|
"characters only."
|
||||||
|
|
||||||
#: core/models.py:161
|
#: core/models.py:168
|
||||||
msgid "identity email address"
|
msgid "identity email address"
|
||||||
msgstr "identity email address"
|
msgstr "identity email address"
|
||||||
|
|
||||||
#: core/models.py:166
|
#: core/models.py:173
|
||||||
msgid "admin email address"
|
msgid "admin email address"
|
||||||
msgstr "admin email address"
|
msgstr "admin email address"
|
||||||
|
|
||||||
#: core/models.py:168
|
#: core/models.py:175
|
||||||
msgid "full name"
|
msgid "full name"
|
||||||
msgstr "full name"
|
msgstr "full name"
|
||||||
|
|
||||||
#: core/models.py:170
|
#: core/models.py:177
|
||||||
msgid "short name"
|
msgid "short name"
|
||||||
msgstr "short name"
|
msgstr "short name"
|
||||||
|
|
||||||
#: core/models.py:176
|
#: core/models.py:183
|
||||||
msgid "language"
|
msgid "language"
|
||||||
msgstr "language"
|
msgstr "language"
|
||||||
|
|
||||||
#: core/models.py:177
|
#: core/models.py:184
|
||||||
msgid "The language in which the user wants to see the interface."
|
msgid "The language in which the user wants to see the interface."
|
||||||
msgstr "The language in which the user wants to see the interface."
|
msgstr "The language in which the user wants to see the interface."
|
||||||
|
|
||||||
#: core/models.py:183
|
#: core/models.py:190
|
||||||
msgid "The timezone in which the user wants to see times."
|
msgid "The timezone in which the user wants to see times."
|
||||||
msgstr "The timezone in which the user wants to see times."
|
msgstr "The timezone in which the user wants to see times."
|
||||||
|
|
||||||
#: core/models.py:186
|
#: core/models.py:193
|
||||||
msgid "device"
|
msgid "device"
|
||||||
msgstr "device"
|
msgstr "device"
|
||||||
|
|
||||||
#: core/models.py:188
|
#: core/models.py:195
|
||||||
msgid "Whether the user is a device or a real user."
|
msgid "Whether the user is a device or a real user."
|
||||||
msgstr "Whether the user is a device or a real user."
|
msgstr "Whether the user is a device or a real user."
|
||||||
|
|
||||||
#: core/models.py:191
|
#: core/models.py:198
|
||||||
msgid "staff status"
|
msgid "staff status"
|
||||||
msgstr "staff status"
|
msgstr "staff status"
|
||||||
|
|
||||||
#: core/models.py:193
|
#: core/models.py:200
|
||||||
msgid "Whether the user can log into this admin site."
|
msgid "Whether the user can log into this admin site."
|
||||||
msgstr "Whether the user can log into this admin site."
|
msgstr "Whether the user can log into this admin site."
|
||||||
|
|
||||||
#: core/models.py:196
|
#: core/models.py:203
|
||||||
msgid "active"
|
msgid "active"
|
||||||
msgstr "active"
|
msgstr "active"
|
||||||
|
|
||||||
#: core/models.py:199
|
#: core/models.py:206
|
||||||
msgid ""
|
msgid ""
|
||||||
"Whether this user should be treated as active. Unselect this instead of "
|
"Whether this user should be treated as active. Unselect this instead of "
|
||||||
"deleting accounts."
|
"deleting accounts."
|
||||||
@@ -264,63 +294,63 @@ msgstr ""
|
|||||||
"Whether this user should be treated as active. Unselect this instead of "
|
"Whether this user should be treated as active. Unselect this instead of "
|
||||||
"deleting accounts."
|
"deleting accounts."
|
||||||
|
|
||||||
#: core/models.py:212
|
#: core/models.py:219
|
||||||
msgid "user"
|
msgid "user"
|
||||||
msgstr "user"
|
msgstr "user"
|
||||||
|
|
||||||
#: core/models.py:213
|
#: core/models.py:220
|
||||||
msgid "users"
|
msgid "users"
|
||||||
msgstr "users"
|
msgstr "users"
|
||||||
|
|
||||||
#: core/models.py:272
|
#: core/models.py:286
|
||||||
msgid "Resource"
|
msgid "Resource"
|
||||||
msgstr "Resource"
|
msgstr "Resource"
|
||||||
|
|
||||||
#: core/models.py:273
|
#: core/models.py:287
|
||||||
msgid "Resources"
|
msgid "Resources"
|
||||||
msgstr "Resources"
|
msgstr "Resources"
|
||||||
|
|
||||||
#: core/models.py:331
|
#: core/models.py:345
|
||||||
msgid "Resource access"
|
msgid "Resource access"
|
||||||
msgstr "Resource access"
|
msgstr "Resource access"
|
||||||
|
|
||||||
#: core/models.py:332
|
#: core/models.py:346
|
||||||
msgid "Resource accesses"
|
msgid "Resource accesses"
|
||||||
msgstr "Resource accesses"
|
msgstr "Resource accesses"
|
||||||
|
|
||||||
#: core/models.py:338
|
#: core/models.py:352
|
||||||
msgid "Resource access with this User and Resource already exists."
|
msgid "Resource access with this User and Resource already exists."
|
||||||
msgstr "Resource access with this User and Resource already exists."
|
msgstr "Resource access with this User and Resource already exists."
|
||||||
|
|
||||||
#: core/models.py:394
|
#: core/models.py:409
|
||||||
msgid "Visio room configuration"
|
msgid "Visio room configuration"
|
||||||
msgstr "Visio room configuration"
|
msgstr "Visio room configuration"
|
||||||
|
|
||||||
#: core/models.py:395
|
#: core/models.py:410
|
||||||
msgid "Values for Visio parameters to configure the room."
|
msgid "Values for Visio parameters to configure the room."
|
||||||
msgstr "Values for Visio parameters to configure the room."
|
msgstr "Values for Visio parameters to configure the room."
|
||||||
|
|
||||||
#: core/models.py:402
|
#: core/models.py:417
|
||||||
msgid "Room PIN code"
|
msgid "Room PIN code"
|
||||||
msgstr "Room PIN code"
|
msgstr "Room PIN code"
|
||||||
|
|
||||||
#: core/models.py:403
|
#: core/models.py:418
|
||||||
msgid "Unique n-digit code that identifies this room in telephony mode."
|
msgid "Unique n-digit code that identifies this room in telephony mode."
|
||||||
msgstr "Unique n-digit code that identifies this room in telephony mode."
|
msgstr "Unique n-digit code that identifies this room in telephony mode."
|
||||||
|
|
||||||
#: core/models.py:409 core/models.py:563
|
#: core/models.py:424 core/models.py:578
|
||||||
msgid "Room"
|
msgid "Room"
|
||||||
msgstr "Room"
|
msgstr "Room"
|
||||||
|
|
||||||
#: core/models.py:410
|
#: core/models.py:425
|
||||||
msgid "Rooms"
|
msgid "Rooms"
|
||||||
msgstr "Rooms"
|
msgstr "Rooms"
|
||||||
|
|
||||||
#: core/models.py:574
|
#: core/models.py:589
|
||||||
msgid "Worker ID"
|
msgid "Worker ID"
|
||||||
msgstr "Worker ID"
|
msgstr "Worker ID"
|
||||||
|
|
||||||
#: core/models.py:576
|
#: core/models.py:591
|
||||||
msgid ""
|
msgid ""
|
||||||
"Enter an identifier for the worker recording.This ID is retained even when "
|
"Enter an identifier for the worker recording.This ID is retained even when "
|
||||||
"the worker stops, allowing for easy tracking."
|
"the worker stops, allowing for easy tracking."
|
||||||
@@ -328,154 +358,175 @@ msgstr ""
|
|||||||
"Enter an identifier for the worker recording.This ID is retained even when "
|
"Enter an identifier for the worker recording.This ID is retained even when "
|
||||||
"the worker stops, allowing for easy tracking."
|
"the worker stops, allowing for easy tracking."
|
||||||
|
|
||||||
#: core/models.py:584
|
#: core/models.py:599
|
||||||
msgid "Recording mode"
|
msgid "Recording mode"
|
||||||
msgstr "Recording mode"
|
msgstr "Recording mode"
|
||||||
|
|
||||||
#: core/models.py:585
|
#: core/models.py:600
|
||||||
msgid "Defines the mode of recording being called."
|
msgid "Defines the mode of recording being called."
|
||||||
msgstr "Defines the mode of recording being called."
|
msgstr "Defines the mode of recording being called."
|
||||||
|
|
||||||
#: core/models.py:590 core/models.py:591
|
#: core/models.py:605 core/models.py:606
|
||||||
msgid "Recording options"
|
msgid "Recording options"
|
||||||
msgstr "Recording options"
|
msgstr "Recording options"
|
||||||
|
|
||||||
#: core/models.py:597
|
#: core/models.py:613
|
||||||
|
msgid "External Process ID"
|
||||||
|
msgstr "External Process ID"
|
||||||
|
|
||||||
|
#: core/models.py:614
|
||||||
|
msgid "ID of the external process associated with the recording."
|
||||||
|
msgstr "ID of the external process associated with the recording."
|
||||||
|
|
||||||
|
#: core/models.py:620
|
||||||
msgid "Recording"
|
msgid "Recording"
|
||||||
msgstr "Recording"
|
msgstr "Recording"
|
||||||
|
|
||||||
#: core/models.py:598
|
#: core/models.py:621
|
||||||
msgid "Recordings"
|
msgid "Recordings"
|
||||||
msgstr "Recordings"
|
msgstr "Recordings"
|
||||||
|
|
||||||
#: core/models.py:706
|
#: core/models.py:731
|
||||||
msgid "Recording/user relation"
|
msgid "Recording/user relation"
|
||||||
msgstr "Recording/user relation"
|
msgstr "Recording/user relation"
|
||||||
|
|
||||||
#: core/models.py:707
|
#: core/models.py:732
|
||||||
msgid "Recording/user relations"
|
msgid "Recording/user relations"
|
||||||
msgstr "Recording/user relations"
|
msgstr "Recording/user relations"
|
||||||
|
|
||||||
#: core/models.py:713
|
#: core/models.py:738
|
||||||
msgid "This user is already in this recording."
|
msgid "This user is already in this recording."
|
||||||
msgstr "This user is already in this recording."
|
msgstr "This user is already in this recording."
|
||||||
|
|
||||||
#: core/models.py:719
|
#: core/models.py:744
|
||||||
msgid "This team is already in this recording."
|
msgid "This team is already in this recording."
|
||||||
msgstr "This team is already in this recording."
|
msgstr "This team is already in this recording."
|
||||||
|
|
||||||
#: core/models.py:725
|
#: core/models.py:750
|
||||||
msgid "Either user or team must be set, not both."
|
msgid "Either user or team must be set, not both."
|
||||||
msgstr "Either user or team must be set, not both."
|
msgstr "Either user or team must be set, not both."
|
||||||
|
|
||||||
#: core/models.py:742
|
#: core/models.py:767
|
||||||
#, fuzzy
|
#, fuzzy
|
||||||
#| msgid "created on"
|
#| msgid "created on"
|
||||||
msgid "Create rooms"
|
msgid "Create rooms"
|
||||||
msgstr "Create rooms"
|
msgstr "Create rooms"
|
||||||
|
|
||||||
#: core/models.py:743
|
#: core/models.py:768
|
||||||
msgid "List rooms"
|
msgid "List rooms"
|
||||||
msgstr "List rooms"
|
msgstr "List rooms"
|
||||||
|
|
||||||
#: core/models.py:744
|
#: core/models.py:769
|
||||||
msgid "Retrieve room details"
|
msgid "Retrieve room details"
|
||||||
msgstr "Retrieve room details"
|
msgstr "Retrieve room details"
|
||||||
|
|
||||||
#: core/models.py:745
|
#: core/models.py:770
|
||||||
#, fuzzy
|
#, fuzzy
|
||||||
#| msgid "updated on"
|
#| msgid "updated on"
|
||||||
msgid "Update rooms"
|
msgid "Update rooms"
|
||||||
msgstr "Update rooms"
|
msgstr "Update rooms"
|
||||||
|
|
||||||
#: core/models.py:746
|
#: core/models.py:771
|
||||||
msgid "Delete rooms"
|
msgid "Delete rooms"
|
||||||
msgstr "Delete rooms"
|
msgstr "Delete rooms"
|
||||||
|
|
||||||
#: core/models.py:759
|
#: core/models.py:784
|
||||||
msgid "Application name"
|
msgid "Application name"
|
||||||
msgstr "Application name"
|
msgstr "Application name"
|
||||||
|
|
||||||
#: core/models.py:760
|
#: core/models.py:785
|
||||||
msgid "Descriptive name for this application."
|
msgid "Descriptive name for this application."
|
||||||
msgstr "Descriptive name for this application."
|
msgstr "Descriptive name for this application."
|
||||||
|
|
||||||
#: core/models.py:770
|
#: core/models.py:795
|
||||||
msgid "Hashed on Save. Copy it now if this is a new secret."
|
msgid "Hashed on Save. Copy it now if this is a new secret."
|
||||||
msgstr "Hashed on Save. Copy it now if this is a new secret."
|
msgstr "Hashed on Save. Copy it now if this is a new secret."
|
||||||
|
|
||||||
#: core/models.py:781
|
#: core/models.py:806
|
||||||
msgid "Application"
|
msgid "Application"
|
||||||
msgstr "Application"
|
msgstr "Application"
|
||||||
|
|
||||||
#: core/models.py:782
|
#: core/models.py:807
|
||||||
msgid "Applications"
|
msgid "Applications"
|
||||||
msgstr "Applications"
|
msgstr "Applications"
|
||||||
|
|
||||||
#: core/models.py:805
|
#: core/models.py:830
|
||||||
msgid "Enter a valid domain"
|
msgid "Enter a valid domain"
|
||||||
msgstr "Enter a valid domain"
|
msgstr "Enter a valid domain"
|
||||||
|
|
||||||
#: core/models.py:808
|
#: core/models.py:833
|
||||||
msgid "Domain"
|
msgid "Domain"
|
||||||
msgstr "Domain"
|
msgstr "Domain"
|
||||||
|
|
||||||
#: core/models.py:809
|
#: core/models.py:834
|
||||||
msgid "Email domain this application can act on behalf of."
|
msgid "Email domain this application can act on behalf of."
|
||||||
msgstr "Email domain this application can act on behalf of."
|
msgstr "Email domain this application can act on behalf of."
|
||||||
|
|
||||||
#: core/models.py:821
|
#: core/models.py:846
|
||||||
msgid "Application domain"
|
msgid "Application domain"
|
||||||
msgstr "Application domain"
|
msgstr "Application domain"
|
||||||
|
|
||||||
#: core/models.py:822
|
#: core/models.py:847
|
||||||
msgid "Application domains"
|
msgid "Application domains"
|
||||||
msgstr "Application domains"
|
msgstr "Application domains"
|
||||||
|
|
||||||
#: core/models.py:840
|
#: core/models.py:865
|
||||||
#, fuzzy
|
#, fuzzy
|
||||||
#| msgid "Recording"
|
#| msgid "Recording"
|
||||||
msgid "Pending"
|
msgid "Pending"
|
||||||
msgstr "Pending"
|
msgstr "Pending"
|
||||||
|
|
||||||
#: core/models.py:848
|
#: core/models.py:866
|
||||||
|
msgid "Analyzing"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: core/models.py:873
|
||||||
msgid "Ready"
|
msgid "Ready"
|
||||||
msgstr "Ready"
|
msgstr "Ready"
|
||||||
|
|
||||||
#: core/models.py:854
|
#: core/models.py:879
|
||||||
msgid "Background image"
|
msgid "Background image"
|
||||||
msgstr "Background image"
|
msgstr "Background image"
|
||||||
|
|
||||||
#: core/models.py:866
|
#: core/models.py:891
|
||||||
msgid "title"
|
msgid "title"
|
||||||
msgstr "title"
|
msgstr "title"
|
||||||
|
|
||||||
#: core/models.py:890
|
#: core/models.py:915
|
||||||
msgid "Malware detection info when the analysis status is unsafe."
|
msgid "Malware detection info when the analysis status is unsafe."
|
||||||
msgstr "Malware detection info when the analysis status is unsafe."
|
msgstr "Malware detection info when the analysis status is unsafe."
|
||||||
|
|
||||||
#: core/models.py:895
|
#: core/models.py:920
|
||||||
msgid "File"
|
msgid "File"
|
||||||
msgstr "File"
|
msgstr "File"
|
||||||
|
|
||||||
#: core/models.py:896
|
#: core/models.py:921
|
||||||
msgid "Files"
|
msgid "Files"
|
||||||
msgstr "Files"
|
msgstr "Files"
|
||||||
|
|
||||||
#: core/models.py:1000
|
#: core/models.py:1041
|
||||||
#, fuzzy
|
#, fuzzy
|
||||||
#| msgid "This user is already in this recording."
|
#| msgid "This user is already in this recording."
|
||||||
msgid "This file is already hard deleted."
|
msgid "This file is already hard deleted."
|
||||||
msgstr "This file is already hard deleted."
|
msgstr "This file is already hard deleted."
|
||||||
|
|
||||||
#: core/models.py:1010
|
#: core/models.py:1051
|
||||||
msgid "To hard delete a file, it must first be soft deleted."
|
msgid "To hard delete a file, it must first be soft deleted."
|
||||||
msgstr "To hard delete a file, it must first be soft deleted."
|
msgstr "To hard delete a file, it must first be soft deleted."
|
||||||
|
|
||||||
#: core/recording/event/notification.py:116
|
#: core/recording/event/notification.py:123
|
||||||
msgid "Your recording is ready"
|
msgid "Your recording is ready"
|
||||||
msgstr "Your recording is ready"
|
msgstr "Your recording is ready"
|
||||||
|
|
||||||
|
#: core/recording/event/notification.py:194
|
||||||
|
msgid "Transcription"
|
||||||
|
msgstr "Transcription"
|
||||||
|
|
||||||
|
#: core/recording/event/notification.py:204
|
||||||
|
#, python-brace-format
|
||||||
|
msgid "Meeting \"{room}\" on {room_recording_date} at {room_recording_time}"
|
||||||
|
msgstr "Meeting \"{room}\" on {room_recording_date} at {room_recording_time}"
|
||||||
|
|
||||||
#: core/services/invitation.py:44
|
#: core/services/invitation.py:44
|
||||||
#, python-brace-format
|
#, python-brace-format
|
||||||
msgid "Video call in progress: {sender.email} is waiting for you to connect"
|
msgid "Video call in progress: {sender.email} is waiting for you to connect"
|
||||||
@@ -571,7 +622,7 @@ msgid "To keep this recording permanently:"
|
|||||||
msgstr "To keep this recording permanently:"
|
msgstr "To keep this recording permanently:"
|
||||||
|
|
||||||
#: core/templates/mail/html/screen_recording.html:208
|
#: core/templates/mail/html/screen_recording.html:208
|
||||||
#: core/templates/mail/text/screen_recording.txt:13
|
#, python-format
|
||||||
msgid "Click the \"<a href=\"%(link)s\">Open</a>\" link below "
|
msgid "Click the \"<a href=\"%(link)s\">Open</a>\" link below "
|
||||||
msgstr "Click the \"<a href=\"%(link)s\">Open</a>\" link below "
|
msgstr "Click the \"<a href=\"%(link)s\">Open</a>\" link below "
|
||||||
|
|
||||||
@@ -600,18 +651,24 @@ msgstr ""
|
|||||||
" If you have any questions or need assistance, please contact our support "
|
" If you have any questions or need assistance, please contact our support "
|
||||||
"team at %(support_email)s. "
|
"team at %(support_email)s. "
|
||||||
|
|
||||||
#: meet/settings.py:223
|
#: core/templates/mail/text/screen_recording.txt:13
|
||||||
|
#, fuzzy, python-format
|
||||||
|
#| msgid "Click the \"<a href=\"%(link)s\">Open</a>\" link below "
|
||||||
|
msgid "Click the \"Open [%(link)s]\" link below "
|
||||||
|
msgstr "Click the \"<a href=\"%(link)s\">Open</a>\" link below "
|
||||||
|
|
||||||
|
#: meet/settings.py:228
|
||||||
msgid "English"
|
msgid "English"
|
||||||
msgstr "English"
|
msgstr "English"
|
||||||
|
|
||||||
#: meet/settings.py:224
|
#: meet/settings.py:229
|
||||||
msgid "French"
|
msgid "French"
|
||||||
msgstr "French"
|
msgstr "French"
|
||||||
|
|
||||||
#: meet/settings.py:225
|
#: meet/settings.py:230
|
||||||
msgid "Dutch"
|
msgid "Dutch"
|
||||||
msgstr "Dutch"
|
msgstr "Dutch"
|
||||||
|
|
||||||
#: meet/settings.py:226
|
#: meet/settings.py:231
|
||||||
msgid "German"
|
msgid "German"
|
||||||
msgstr "German"
|
msgstr "German"
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ msgid ""
|
|||||||
msgstr ""
|
msgstr ""
|
||||||
"Project-Id-Version: PACKAGE VERSION\n"
|
"Project-Id-Version: PACKAGE VERSION\n"
|
||||||
"Report-Msgid-Bugs-To: \n"
|
"Report-Msgid-Bugs-To: \n"
|
||||||
"POT-Creation-Date: 2026-04-21 14:09+0000\n"
|
"POT-Creation-Date: 2026-07-02 10:47+0000\n"
|
||||||
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
|
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
|
||||||
"Last-Translator: antoine.lebaud@mail.numerique.gouv.fr\n"
|
"Last-Translator: antoine.lebaud@mail.numerique.gouv.fr\n"
|
||||||
"Language-Team: LANGUAGE <LL@li.org>\n"
|
"Language-Team: LANGUAGE <LL@li.org>\n"
|
||||||
@@ -17,71 +17,93 @@ msgstr ""
|
|||||||
"Content-Type: text/plain; charset=UTF-8\n"
|
"Content-Type: text/plain; charset=UTF-8\n"
|
||||||
"Content-Transfer-Encoding: 8bit\n"
|
"Content-Transfer-Encoding: 8bit\n"
|
||||||
|
|
||||||
#: core/admin.py:29
|
#: core/admin.py:67
|
||||||
msgid "Personal info"
|
msgid "Personal info"
|
||||||
msgstr "Informations personnelles"
|
msgstr "Informations personnelles"
|
||||||
|
|
||||||
#: core/admin.py:42
|
#: core/admin.py:80
|
||||||
msgid "Permissions"
|
msgid "Permissions"
|
||||||
msgstr "Permissions"
|
msgstr "Permissions"
|
||||||
|
|
||||||
#: core/admin.py:54
|
#: core/admin.py:92
|
||||||
msgid "Important dates"
|
msgid "Important dates"
|
||||||
msgstr "Dates importantes"
|
msgstr "Dates importantes"
|
||||||
|
|
||||||
#: core/admin.py:132 core/admin.py:275
|
#: core/admin.py:206
|
||||||
|
msgid "Content"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: core/admin.py:217
|
||||||
|
#, fuzzy
|
||||||
|
#| msgid "Delete rooms"
|
||||||
|
msgid "Deletion"
|
||||||
|
msgstr "Supprimer les salles"
|
||||||
|
|
||||||
|
#: core/admin.py:226
|
||||||
|
msgid "Derived info"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: core/admin.py:237
|
||||||
|
msgid "Timestamps"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: core/admin.py:240
|
||||||
|
msgid "File preview"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: core/admin.py:300 core/admin.py:443
|
||||||
msgid "No owner"
|
msgid "No owner"
|
||||||
msgstr "Pas de propriétaire"
|
msgstr "Pas de propriétaire"
|
||||||
|
|
||||||
#: core/admin.py:135 core/admin.py:278
|
#: core/admin.py:303 core/admin.py:446
|
||||||
msgid "Multiple owners"
|
msgid "Multiple owners"
|
||||||
msgstr "Plusieurs propriétaires"
|
msgstr "Plusieurs propriétaires"
|
||||||
|
|
||||||
#: core/admin.py:148
|
#: core/admin.py:316
|
||||||
msgid "Resend notification to external service"
|
msgid "Resend notification to external service"
|
||||||
msgstr "Renvoyer la notification au service externe"
|
msgstr "Renvoyer la notification au service externe"
|
||||||
|
|
||||||
#: core/admin.py:171
|
#: core/admin.py:339
|
||||||
#, python-format
|
#, python-format
|
||||||
msgid "Failed to notify for recording %(id)s"
|
msgid "Failed to notify for recording %(id)s"
|
||||||
msgstr "Échec de la notification pour l’enregistrement %(id)s"
|
msgstr "Échec de la notification pour l’enregistrement %(id)s"
|
||||||
|
|
||||||
#: core/admin.py:179
|
#: core/admin.py:347
|
||||||
#, python-format
|
#, python-format
|
||||||
msgid "Failed to notify for recording %(id)s: %(error)s"
|
msgid "Failed to notify for recording %(id)s: %(error)s"
|
||||||
msgstr "Échec de la notification pour l’enregistrement %(id)s : %(error)s"
|
msgstr "Échec de la notification pour l’enregistrement %(id)s : %(error)s"
|
||||||
|
|
||||||
#: core/admin.py:187
|
#: core/admin.py:355
|
||||||
#, python-format
|
#, python-format
|
||||||
msgid "Successfully sent notifications for %(count)s recording(s)."
|
msgid "Successfully sent notifications for %(count)s recording(s)."
|
||||||
msgstr "Notifications envoyées avec succès pour %(count)s enregistrement(s)."
|
msgstr "Notifications envoyées avec succès pour %(count)s enregistrement(s)."
|
||||||
|
|
||||||
#: core/admin.py:195
|
#: core/admin.py:363
|
||||||
#, python-format
|
#, python-format
|
||||||
msgid "Skipped %(count)s expired recording(s)."
|
msgid "Skipped %(count)s expired recording(s)."
|
||||||
msgstr "%(count)s enregistrement(s) expiré(s) ignoré(s)."
|
msgstr "%(count)s enregistrement(s) expiré(s) ignoré(s)."
|
||||||
|
|
||||||
#: core/admin.py:200
|
#: core/admin.py:368
|
||||||
msgid "Mark selected recordings as 'Failed to Stop'"
|
msgid "Mark selected recordings as 'Failed to Stop'"
|
||||||
msgstr "Marquer les enregistrements sélectionnés comme « Échec d’arrêt »"
|
msgstr "Marquer les enregistrements sélectionnés comme « Échec d’arrêt »"
|
||||||
|
|
||||||
#: core/admin.py:218
|
#: core/admin.py:386
|
||||||
#, python-format
|
#, python-format
|
||||||
msgid "%(count)s recording(s) successfully marked as 'Failed to Stop'."
|
msgid "%(count)s recording(s) successfully marked as 'Failed to Stop'."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
"%(count)s enregistrement(s) marqué(s) avec succès comme « Échec d’arrêt »."
|
"%(count)s enregistrement(s) marqué(s) avec succès comme « Échec d’arrêt »."
|
||||||
|
|
||||||
#: core/admin.py:226
|
#: core/admin.py:394
|
||||||
#, fuzzy, python-format
|
#, fuzzy, python-format
|
||||||
#| msgid "Skipped %(count)s expired recording(s)."
|
#| msgid "Skipped %(count)s expired recording(s)."
|
||||||
msgid "Skipped %(count)s recording(s) with an ineligible status."
|
msgid "Skipped %(count)s recording(s) with an ineligible status."
|
||||||
msgstr "%(count)s enregistrement(s) avec un statut inéligible ignoré(s)."
|
msgstr "%(count)s enregistrement(s) avec un statut inéligible ignoré(s)."
|
||||||
|
|
||||||
#: core/admin.py:342
|
#: core/admin.py:510
|
||||||
msgid "No scopes"
|
msgid "No scopes"
|
||||||
msgstr "Aucun scopes"
|
msgstr "Aucun scopes"
|
||||||
|
|
||||||
#: core/admin.py:344
|
#: core/admin.py:512
|
||||||
msgid "Scopes"
|
msgid "Scopes"
|
||||||
msgstr "Scopes"
|
msgstr "Scopes"
|
||||||
|
|
||||||
@@ -89,17 +111,17 @@ msgstr "Scopes"
|
|||||||
msgid "Creator is me"
|
msgid "Creator is me"
|
||||||
msgstr "Je suis le créateur"
|
msgstr "Je suis le créateur"
|
||||||
|
|
||||||
#: core/api/serializers.py:88
|
#: core/api/serializers.py:89
|
||||||
msgid "You must be administrator or owner of a room to add accesses to it."
|
msgid "You must be administrator or owner of a room to add accesses to it."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
"Vous devez être administrateur ou propriétaire d'une salle pour y ajouter "
|
"Vous devez être administrateur ou propriétaire d'une salle pour y ajouter "
|
||||||
"des accès."
|
"des accès."
|
||||||
|
|
||||||
#: core/api/serializers.py:509
|
#: core/api/serializers.py:534
|
||||||
msgid "This file extension is not allowed."
|
msgid "This file extension is not allowed."
|
||||||
msgstr "Cette extension n'est pas autorisée"
|
msgstr "Cette extension n'est pas autorisée"
|
||||||
|
|
||||||
#: core/api/viewsets.py:1090
|
#: core/api/viewsets.py:1222
|
||||||
msgid "You have reached the maximum number of files for this type."
|
msgid "You have reached the maximum number of files for this type."
|
||||||
msgstr "Vous avez atteint le nombre maximum de fichiers de ce type"
|
msgstr "Vous avez atteint le nombre maximum de fichiers de ce type"
|
||||||
|
|
||||||
@@ -147,53 +169,61 @@ msgstr "Échec à l'arrêt"
|
|||||||
msgid "Notification succeeded"
|
msgid "Notification succeeded"
|
||||||
msgstr "Notification réussie"
|
msgstr "Notification réussie"
|
||||||
|
|
||||||
#: core/models.py:89
|
#: core/models.py:65
|
||||||
|
msgid "External process successful"
|
||||||
|
msgstr "Traitement externe : succès"
|
||||||
|
|
||||||
|
#: core/models.py:67
|
||||||
|
msgid "External process failed"
|
||||||
|
msgstr "Traitement externe : erreur"
|
||||||
|
|
||||||
|
#: core/models.py:96
|
||||||
msgid "SCREEN_RECORDING"
|
msgid "SCREEN_RECORDING"
|
||||||
msgstr "ENREGISTREMENT_ÉCRAN"
|
msgstr "ENREGISTREMENT_ÉCRAN"
|
||||||
|
|
||||||
#: core/models.py:90
|
#: core/models.py:97
|
||||||
msgid "TRANSCRIPT"
|
msgid "TRANSCRIPT"
|
||||||
msgstr "TRANSCRIPTION"
|
msgstr "TRANSCRIPTION"
|
||||||
|
|
||||||
#: core/models.py:96
|
#: core/models.py:103
|
||||||
msgid "Public Access"
|
msgid "Public Access"
|
||||||
msgstr "Accès public"
|
msgstr "Accès public"
|
||||||
|
|
||||||
#: core/models.py:97
|
#: core/models.py:104
|
||||||
msgid "Trusted Access"
|
msgid "Trusted Access"
|
||||||
msgstr "Accès de confiance"
|
msgstr "Accès de confiance"
|
||||||
|
|
||||||
#: core/models.py:98
|
#: core/models.py:105
|
||||||
msgid "Restricted Access"
|
msgid "Restricted Access"
|
||||||
msgstr "Accès restreint"
|
msgstr "Accès restreint"
|
||||||
|
|
||||||
#: core/models.py:110
|
#: core/models.py:117
|
||||||
msgid "id"
|
msgid "id"
|
||||||
msgstr "id"
|
msgstr "id"
|
||||||
|
|
||||||
#: core/models.py:111
|
#: core/models.py:118
|
||||||
msgid "primary key for the record as UUID"
|
msgid "primary key for the record as UUID"
|
||||||
msgstr "clé primaire pour l'enregistrement sous forme d'UUID"
|
msgstr "clé primaire pour l'enregistrement sous forme d'UUID"
|
||||||
|
|
||||||
#: core/models.py:117
|
#: core/models.py:124
|
||||||
msgid "created on"
|
msgid "created on"
|
||||||
msgstr "créé le"
|
msgstr "créé le"
|
||||||
|
|
||||||
#: core/models.py:118
|
#: core/models.py:125
|
||||||
msgid "date and time at which a record was created"
|
msgid "date and time at which a record was created"
|
||||||
msgstr "date et heure auxquelles un enregistrement a été créé"
|
msgstr "date et heure auxquelles un enregistrement a été créé"
|
||||||
|
|
||||||
#: core/models.py:123
|
#: core/models.py:130
|
||||||
msgid "updated on"
|
msgid "updated on"
|
||||||
msgstr "mis à jour le"
|
msgstr "mis à jour le"
|
||||||
|
|
||||||
#: core/models.py:124
|
#: core/models.py:131
|
||||||
msgid "date and time at which a record was last updated"
|
msgid "date and time at which a record was last updated"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
"date et heure auxquelles un enregistrement a été mis à jour pour la dernière "
|
"date et heure auxquelles un enregistrement a été mis à jour pour la dernière "
|
||||||
"fois"
|
"fois"
|
||||||
|
|
||||||
#: core/models.py:144
|
#: core/models.py:151
|
||||||
msgid ""
|
msgid ""
|
||||||
"Enter a valid sub. This value may contain only letters, numbers, and @/./+/-/"
|
"Enter a valid sub. This value may contain only letters, numbers, and @/./+/-/"
|
||||||
"_ characters."
|
"_ characters."
|
||||||
@@ -201,11 +231,11 @@ msgstr ""
|
|||||||
"Entrez un sub valide. Cette valeur ne peut contenir que des lettres, des "
|
"Entrez un sub valide. Cette valeur ne peut contenir que des lettres, des "
|
||||||
"chiffres et les caractères @/./+/-/_."
|
"chiffres et les caractères @/./+/-/_."
|
||||||
|
|
||||||
#: core/models.py:150
|
#: core/models.py:157
|
||||||
msgid "sub"
|
msgid "sub"
|
||||||
msgstr "sub"
|
msgstr "sub"
|
||||||
|
|
||||||
#: core/models.py:152
|
#: core/models.py:159
|
||||||
msgid ""
|
msgid ""
|
||||||
"Optional for pending users; required upon account activation. 255 characters "
|
"Optional for pending users; required upon account activation. 255 characters "
|
||||||
"or fewer. Letters, numbers, and @/./+/-/_ characters only."
|
"or fewer. Letters, numbers, and @/./+/-/_ characters only."
|
||||||
@@ -213,55 +243,55 @@ msgstr ""
|
|||||||
"Optionnel pour les utilisateurs en attente ; requis lors de l'activation du "
|
"Optionnel pour les utilisateurs en attente ; requis lors de l'activation du "
|
||||||
"compte. 255 caractères maximum. Lettres, chiffres et @/./+/-/_ uniquement."
|
"compte. 255 caractères maximum. Lettres, chiffres et @/./+/-/_ uniquement."
|
||||||
|
|
||||||
#: core/models.py:161
|
#: core/models.py:168
|
||||||
msgid "identity email address"
|
msgid "identity email address"
|
||||||
msgstr "adresse e-mail d'identité"
|
msgstr "adresse e-mail d'identité"
|
||||||
|
|
||||||
#: core/models.py:166
|
#: core/models.py:173
|
||||||
msgid "admin email address"
|
msgid "admin email address"
|
||||||
msgstr "adresse e-mail d'administrateur"
|
msgstr "adresse e-mail d'administrateur"
|
||||||
|
|
||||||
#: core/models.py:168
|
#: core/models.py:175
|
||||||
msgid "full name"
|
msgid "full name"
|
||||||
msgstr "nom complet"
|
msgstr "nom complet"
|
||||||
|
|
||||||
#: core/models.py:170
|
#: core/models.py:177
|
||||||
msgid "short name"
|
msgid "short name"
|
||||||
msgstr "nom court"
|
msgstr "nom court"
|
||||||
|
|
||||||
#: core/models.py:176
|
#: core/models.py:183
|
||||||
msgid "language"
|
msgid "language"
|
||||||
msgstr "langue"
|
msgstr "langue"
|
||||||
|
|
||||||
#: core/models.py:177
|
#: core/models.py:184
|
||||||
msgid "The language in which the user wants to see the interface."
|
msgid "The language in which the user wants to see the interface."
|
||||||
msgstr "La langue dans laquelle l'utilisateur souhaite voir l'interface."
|
msgstr "La langue dans laquelle l'utilisateur souhaite voir l'interface."
|
||||||
|
|
||||||
#: core/models.py:183
|
#: core/models.py:190
|
||||||
msgid "The timezone in which the user wants to see times."
|
msgid "The timezone in which the user wants to see times."
|
||||||
msgstr "Le fuseau horaire dans lequel l'utilisateur souhaite voir les heures."
|
msgstr "Le fuseau horaire dans lequel l'utilisateur souhaite voir les heures."
|
||||||
|
|
||||||
#: core/models.py:186
|
#: core/models.py:193
|
||||||
msgid "device"
|
msgid "device"
|
||||||
msgstr "appareil"
|
msgstr "appareil"
|
||||||
|
|
||||||
#: core/models.py:188
|
#: core/models.py:195
|
||||||
msgid "Whether the user is a device or a real user."
|
msgid "Whether the user is a device or a real user."
|
||||||
msgstr "Si l'utilisateur est un appareil ou un utilisateur réel."
|
msgstr "Si l'utilisateur est un appareil ou un utilisateur réel."
|
||||||
|
|
||||||
#: core/models.py:191
|
#: core/models.py:198
|
||||||
msgid "staff status"
|
msgid "staff status"
|
||||||
msgstr "statut du personnel"
|
msgstr "statut du personnel"
|
||||||
|
|
||||||
#: core/models.py:193
|
#: core/models.py:200
|
||||||
msgid "Whether the user can log into this admin site."
|
msgid "Whether the user can log into this admin site."
|
||||||
msgstr "Si l'utilisateur peut se connecter à ce site d'administration."
|
msgstr "Si l'utilisateur peut se connecter à ce site d'administration."
|
||||||
|
|
||||||
#: core/models.py:196
|
#: core/models.py:203
|
||||||
msgid "active"
|
msgid "active"
|
||||||
msgstr "actif"
|
msgstr "actif"
|
||||||
|
|
||||||
#: core/models.py:199
|
#: core/models.py:206
|
||||||
msgid ""
|
msgid ""
|
||||||
"Whether this user should be treated as active. Unselect this instead of "
|
"Whether this user should be treated as active. Unselect this instead of "
|
||||||
"deleting accounts."
|
"deleting accounts."
|
||||||
@@ -269,65 +299,65 @@ msgstr ""
|
|||||||
"Si cet utilisateur doit être traité comme actif. Désélectionnez cette option "
|
"Si cet utilisateur doit être traité comme actif. Désélectionnez cette option "
|
||||||
"au lieu de supprimer des comptes."
|
"au lieu de supprimer des comptes."
|
||||||
|
|
||||||
#: core/models.py:212
|
#: core/models.py:219
|
||||||
msgid "user"
|
msgid "user"
|
||||||
msgstr "utilisateur"
|
msgstr "utilisateur"
|
||||||
|
|
||||||
#: core/models.py:213
|
#: core/models.py:220
|
||||||
msgid "users"
|
msgid "users"
|
||||||
msgstr "utilisateurs"
|
msgstr "utilisateurs"
|
||||||
|
|
||||||
#: core/models.py:272
|
#: core/models.py:286
|
||||||
msgid "Resource"
|
msgid "Resource"
|
||||||
msgstr "Ressource"
|
msgstr "Ressource"
|
||||||
|
|
||||||
#: core/models.py:273
|
#: core/models.py:287
|
||||||
msgid "Resources"
|
msgid "Resources"
|
||||||
msgstr "Ressources"
|
msgstr "Ressources"
|
||||||
|
|
||||||
#: core/models.py:331
|
#: core/models.py:345
|
||||||
msgid "Resource access"
|
msgid "Resource access"
|
||||||
msgstr "Accès aux ressources"
|
msgstr "Accès aux ressources"
|
||||||
|
|
||||||
#: core/models.py:332
|
#: core/models.py:346
|
||||||
msgid "Resource accesses"
|
msgid "Resource accesses"
|
||||||
msgstr "Accès aux ressources"
|
msgstr "Accès aux ressources"
|
||||||
|
|
||||||
#: core/models.py:338
|
#: core/models.py:352
|
||||||
msgid "Resource access with this User and Resource already exists."
|
msgid "Resource access with this User and Resource already exists."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
"L'accès à la ressource avec cet utilisateur et cette ressource existe déjà."
|
"L'accès à la ressource avec cet utilisateur et cette ressource existe déjà."
|
||||||
|
|
||||||
#: core/models.py:394
|
#: core/models.py:409
|
||||||
msgid "Visio room configuration"
|
msgid "Visio room configuration"
|
||||||
msgstr "Configuration de la salle de visioconférence"
|
msgstr "Configuration de la salle de visioconférence"
|
||||||
|
|
||||||
#: core/models.py:395
|
#: core/models.py:410
|
||||||
msgid "Values for Visio parameters to configure the room."
|
msgid "Values for Visio parameters to configure the room."
|
||||||
msgstr "Valeurs des paramètres de visioconférence pour configurer la salle."
|
msgstr "Valeurs des paramètres de visioconférence pour configurer la salle."
|
||||||
|
|
||||||
#: core/models.py:402
|
#: core/models.py:417
|
||||||
msgid "Room PIN code"
|
msgid "Room PIN code"
|
||||||
msgstr "Code PIN de la salle"
|
msgstr "Code PIN de la salle"
|
||||||
|
|
||||||
#: core/models.py:403
|
#: core/models.py:418
|
||||||
msgid "Unique n-digit code that identifies this room in telephony mode."
|
msgid "Unique n-digit code that identifies this room in telephony mode."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
"Code unique à n chiffres qui identifie cette salle en mode téléphonique."
|
"Code unique à n chiffres qui identifie cette salle en mode téléphonique."
|
||||||
|
|
||||||
#: core/models.py:409 core/models.py:563
|
#: core/models.py:424 core/models.py:578
|
||||||
msgid "Room"
|
msgid "Room"
|
||||||
msgstr "Salle"
|
msgstr "Salle"
|
||||||
|
|
||||||
#: core/models.py:410
|
#: core/models.py:425
|
||||||
msgid "Rooms"
|
msgid "Rooms"
|
||||||
msgstr "Salles"
|
msgstr "Salles"
|
||||||
|
|
||||||
#: core/models.py:574
|
#: core/models.py:589
|
||||||
msgid "Worker ID"
|
msgid "Worker ID"
|
||||||
msgstr "ID du Worker"
|
msgstr "ID du Worker"
|
||||||
|
|
||||||
#: core/models.py:576
|
#: core/models.py:591
|
||||||
msgid ""
|
msgid ""
|
||||||
"Enter an identifier for the worker recording.This ID is retained even when "
|
"Enter an identifier for the worker recording.This ID is retained even when "
|
||||||
"the worker stops, allowing for easy tracking."
|
"the worker stops, allowing for easy tracking."
|
||||||
@@ -335,153 +365,174 @@ msgstr ""
|
|||||||
"Entrez un identifiant pour l'enregistrement du Worker. Cet identifiant est "
|
"Entrez un identifiant pour l'enregistrement du Worker. Cet identifiant est "
|
||||||
"conservé même lorsque le Worker s'arrête, permettant un suivi facile."
|
"conservé même lorsque le Worker s'arrête, permettant un suivi facile."
|
||||||
|
|
||||||
#: core/models.py:584
|
#: core/models.py:599
|
||||||
msgid "Recording mode"
|
msgid "Recording mode"
|
||||||
msgstr "Mode d'enregistrement"
|
msgstr "Mode d'enregistrement"
|
||||||
|
|
||||||
#: core/models.py:585
|
#: core/models.py:600
|
||||||
msgid "Defines the mode of recording being called."
|
msgid "Defines the mode of recording being called."
|
||||||
msgstr "Définit le mode d'enregistrement appelé."
|
msgstr "Définit le mode d'enregistrement appelé."
|
||||||
|
|
||||||
#: core/models.py:590 core/models.py:591
|
#: core/models.py:605 core/models.py:606
|
||||||
msgid "Recording options"
|
msgid "Recording options"
|
||||||
msgstr "Options d'enregistrement"
|
msgstr "Options d'enregistrement"
|
||||||
|
|
||||||
#: core/models.py:597
|
#: core/models.py:613
|
||||||
|
msgid "External Process ID"
|
||||||
|
msgstr "ID Traitement externe"
|
||||||
|
|
||||||
|
#: core/models.py:614
|
||||||
|
msgid "ID of the external process associated with the recording."
|
||||||
|
msgstr "ID du traitement externe associé avec l'enregistrement."
|
||||||
|
|
||||||
|
#: core/models.py:620
|
||||||
msgid "Recording"
|
msgid "Recording"
|
||||||
msgstr "Enregistrement"
|
msgstr "Enregistrement"
|
||||||
|
|
||||||
#: core/models.py:598
|
#: core/models.py:621
|
||||||
msgid "Recordings"
|
msgid "Recordings"
|
||||||
msgstr "Enregistrements"
|
msgstr "Enregistrements"
|
||||||
|
|
||||||
#: core/models.py:706
|
#: core/models.py:731
|
||||||
msgid "Recording/user relation"
|
msgid "Recording/user relation"
|
||||||
msgstr "Relation enregistrement/utilisateur"
|
msgstr "Relation enregistrement/utilisateur"
|
||||||
|
|
||||||
#: core/models.py:707
|
#: core/models.py:732
|
||||||
msgid "Recording/user relations"
|
msgid "Recording/user relations"
|
||||||
msgstr "Relations enregistrement/utilisateur"
|
msgstr "Relations enregistrement/utilisateur"
|
||||||
|
|
||||||
#: core/models.py:713
|
#: core/models.py:738
|
||||||
msgid "This user is already in this recording."
|
msgid "This user is already in this recording."
|
||||||
msgstr "Cet utilisateur est déjà dans cet enregistrement."
|
msgstr "Cet utilisateur est déjà dans cet enregistrement."
|
||||||
|
|
||||||
#: core/models.py:719
|
#: core/models.py:744
|
||||||
msgid "This team is already in this recording."
|
msgid "This team is already in this recording."
|
||||||
msgstr "Cette équipe est déjà dans cet enregistrement."
|
msgstr "Cette équipe est déjà dans cet enregistrement."
|
||||||
|
|
||||||
#: core/models.py:725
|
#: core/models.py:750
|
||||||
msgid "Either user or team must be set, not both."
|
msgid "Either user or team must be set, not both."
|
||||||
msgstr "Soit l'utilisateur, soit l'équipe doit être défini, pas les deux."
|
msgstr "Soit l'utilisateur, soit l'équipe doit être défini, pas les deux."
|
||||||
|
|
||||||
#: core/models.py:742
|
#: core/models.py:767
|
||||||
msgid "Create rooms"
|
msgid "Create rooms"
|
||||||
msgstr "Créer des salles"
|
msgstr "Créer des salles"
|
||||||
|
|
||||||
#: core/models.py:743
|
#: core/models.py:768
|
||||||
msgid "List rooms"
|
msgid "List rooms"
|
||||||
msgstr "Lister les salles"
|
msgstr "Lister les salles"
|
||||||
|
|
||||||
#: core/models.py:744
|
#: core/models.py:769
|
||||||
msgid "Retrieve room details"
|
msgid "Retrieve room details"
|
||||||
msgstr "Afficher les détails d’une salle"
|
msgstr "Afficher les détails d’une salle"
|
||||||
|
|
||||||
#: core/models.py:745
|
#: core/models.py:770
|
||||||
msgid "Update rooms"
|
msgid "Update rooms"
|
||||||
msgstr "Mettre à jour les salles"
|
msgstr "Mettre à jour les salles"
|
||||||
|
|
||||||
#: core/models.py:746
|
#: core/models.py:771
|
||||||
msgid "Delete rooms"
|
msgid "Delete rooms"
|
||||||
msgstr "Supprimer les salles"
|
msgstr "Supprimer les salles"
|
||||||
|
|
||||||
#: core/models.py:759
|
#: core/models.py:784
|
||||||
msgid "Application name"
|
msgid "Application name"
|
||||||
msgstr "Nom de l’application"
|
msgstr "Nom de l’application"
|
||||||
|
|
||||||
#: core/models.py:760
|
#: core/models.py:785
|
||||||
msgid "Descriptive name for this application."
|
msgid "Descriptive name for this application."
|
||||||
msgstr "Nom descriptif de cette application."
|
msgstr "Nom descriptif de cette application."
|
||||||
|
|
||||||
#: core/models.py:770
|
#: core/models.py:795
|
||||||
msgid "Hashed on Save. Copy it now if this is a new secret."
|
msgid "Hashed on Save. Copy it now if this is a new secret."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
"Haché lors de l’enregistrement. Copiez-le maintenant s’il s’agit d’un "
|
"Haché lors de l’enregistrement. Copiez-le maintenant s’il s’agit d’un "
|
||||||
"nouveau secret."
|
"nouveau secret."
|
||||||
|
|
||||||
#: core/models.py:781
|
#: core/models.py:806
|
||||||
msgid "Application"
|
msgid "Application"
|
||||||
msgstr "Application"
|
msgstr "Application"
|
||||||
|
|
||||||
#: core/models.py:782
|
#: core/models.py:807
|
||||||
msgid "Applications"
|
msgid "Applications"
|
||||||
msgstr "Applications"
|
msgstr "Applications"
|
||||||
|
|
||||||
#: core/models.py:805
|
#: core/models.py:830
|
||||||
msgid "Enter a valid domain"
|
msgid "Enter a valid domain"
|
||||||
msgstr "Saisissez un domaine valide"
|
msgstr "Saisissez un domaine valide"
|
||||||
|
|
||||||
#: core/models.py:808
|
#: core/models.py:833
|
||||||
msgid "Domain"
|
msgid "Domain"
|
||||||
msgstr "Domaine"
|
msgstr "Domaine"
|
||||||
|
|
||||||
#: core/models.py:809
|
#: core/models.py:834
|
||||||
msgid "Email domain this application can act on behalf of."
|
msgid "Email domain this application can act on behalf of."
|
||||||
msgstr "Domaine de messagerie au nom duquel cette application peut agir."
|
msgstr "Domaine de messagerie au nom duquel cette application peut agir."
|
||||||
|
|
||||||
#: core/models.py:821
|
#: core/models.py:846
|
||||||
msgid "Application domain"
|
msgid "Application domain"
|
||||||
msgstr "Domaine d’application"
|
msgstr "Domaine d’application"
|
||||||
|
|
||||||
#: core/models.py:822
|
#: core/models.py:847
|
||||||
msgid "Application domains"
|
msgid "Application domains"
|
||||||
msgstr "Domaines d’application"
|
msgstr "Domaines d’application"
|
||||||
|
|
||||||
#: core/models.py:840
|
#: core/models.py:865
|
||||||
msgid "Pending"
|
msgid "Pending"
|
||||||
msgstr "En attente"
|
msgstr "En attente"
|
||||||
|
|
||||||
#: core/models.py:848
|
#: core/models.py:866
|
||||||
|
msgid "Analyzing"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: core/models.py:873
|
||||||
msgid "Ready"
|
msgid "Ready"
|
||||||
msgstr "Prêt"
|
msgstr "Prêt"
|
||||||
|
|
||||||
#: core/models.py:854
|
#: core/models.py:879
|
||||||
msgid "Background image"
|
msgid "Background image"
|
||||||
msgstr "Image de fond"
|
msgstr "Image de fond"
|
||||||
|
|
||||||
#: core/models.py:866
|
#: core/models.py:891
|
||||||
msgid "title"
|
msgid "title"
|
||||||
msgstr "Titre"
|
msgstr "Titre"
|
||||||
|
|
||||||
#: core/models.py:890
|
#: core/models.py:915
|
||||||
msgid "Malware detection info when the analysis status is unsafe."
|
msgid "Malware detection info when the analysis status is unsafe."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
"Information concernant la détection de Malware cand le statut n'est pas sain"
|
"Information concernant la détection de Malware cand le statut n'est pas sain"
|
||||||
|
|
||||||
#: core/models.py:895
|
#: core/models.py:920
|
||||||
msgid "File"
|
msgid "File"
|
||||||
msgstr "Fichier"
|
msgstr "Fichier"
|
||||||
|
|
||||||
#: core/models.py:896
|
#: core/models.py:921
|
||||||
msgid "Files"
|
msgid "Files"
|
||||||
msgstr "Fichiers"
|
msgstr "Fichiers"
|
||||||
|
|
||||||
#: core/models.py:1000
|
#: core/models.py:1041
|
||||||
#, fuzzy
|
#, fuzzy
|
||||||
#| msgid "This user is already in this recording."
|
#| msgid "This user is already in this recording."
|
||||||
msgid "This file is already hard deleted."
|
msgid "This file is already hard deleted."
|
||||||
msgstr "Ce fichier a été supprimé."
|
msgstr "Ce fichier a été supprimé."
|
||||||
|
|
||||||
#: core/models.py:1010
|
#: core/models.py:1051
|
||||||
msgid "To hard delete a file, it must first be soft deleted."
|
msgid "To hard delete a file, it must first be soft deleted."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
"Pour supprimer définitivement un fichier il doit d'abord avoir été marqué "
|
"Pour supprimer définitivement un fichier il doit d'abord avoir été marqué "
|
||||||
"comme supprimé (soft delete)"
|
"comme supprimé (soft delete)"
|
||||||
|
|
||||||
#: core/recording/event/notification.py:116
|
#: core/recording/event/notification.py:123
|
||||||
msgid "Your recording is ready"
|
msgid "Your recording is ready"
|
||||||
msgstr "Votre enregistrement est prêt"
|
msgstr "Votre enregistrement est prêt"
|
||||||
|
|
||||||
|
#: core/recording/event/notification.py:194
|
||||||
|
msgid "Transcription"
|
||||||
|
msgstr "Transcription"
|
||||||
|
|
||||||
|
#: core/recording/event/notification.py:204
|
||||||
|
#, python-brace-format
|
||||||
|
msgid "Meeting \"{room}\" on {room_recording_date} at {room_recording_time}"
|
||||||
|
msgstr "Réunion \"{room}\" du {room_recording_date} à {room_recording_time}"
|
||||||
|
|
||||||
#: core/services/invitation.py:44
|
#: core/services/invitation.py:44
|
||||||
#, python-brace-format
|
#, python-brace-format
|
||||||
msgid "Video call in progress: {sender.email} is waiting for you to connect"
|
msgid "Video call in progress: {sender.email} is waiting for you to connect"
|
||||||
@@ -577,7 +628,7 @@ msgid "To keep this recording permanently:"
|
|||||||
msgstr "Pour conserver cet enregistrement de façon permanente :"
|
msgstr "Pour conserver cet enregistrement de façon permanente :"
|
||||||
|
|
||||||
#: core/templates/mail/html/screen_recording.html:208
|
#: core/templates/mail/html/screen_recording.html:208
|
||||||
#: core/templates/mail/text/screen_recording.txt:13
|
#, python-format
|
||||||
msgid "Click the \"<a href=\"%(link)s\">Open</a>\" link below "
|
msgid "Click the \"<a href=\"%(link)s\">Open</a>\" link below "
|
||||||
msgstr "Cliquez sur le lien \"<a href=\"%(link)s\">Ouvrir</a>\" ci-dessous "
|
msgstr "Cliquez sur le lien \"<a href=\"%(link)s\">Ouvrir</a>\" ci-dessous "
|
||||||
|
|
||||||
@@ -606,18 +657,24 @@ msgstr ""
|
|||||||
" Si vous avez des questions ou besoin d'assistance, veuillez contacter notre "
|
" Si vous avez des questions ou besoin d'assistance, veuillez contacter notre "
|
||||||
"équipe d'assistance à %(support_email)s. "
|
"équipe d'assistance à %(support_email)s. "
|
||||||
|
|
||||||
#: meet/settings.py:223
|
#: core/templates/mail/text/screen_recording.txt:13
|
||||||
|
#, fuzzy, python-format
|
||||||
|
#| msgid "Click the \"<a href=\"%(link)s\">Open</a>\" link below "
|
||||||
|
msgid "Click the \"Open [%(link)s]\" link below "
|
||||||
|
msgstr "Cliquez sur le lien \"<a href=\"%(link)s\">Ouvrir</a>\" ci-dessous "
|
||||||
|
|
||||||
|
#: meet/settings.py:228
|
||||||
msgid "English"
|
msgid "English"
|
||||||
msgstr "Anglais"
|
msgstr "Anglais"
|
||||||
|
|
||||||
#: meet/settings.py:224
|
#: meet/settings.py:229
|
||||||
msgid "French"
|
msgid "French"
|
||||||
msgstr "Français"
|
msgstr "Français"
|
||||||
|
|
||||||
#: meet/settings.py:225
|
#: meet/settings.py:230
|
||||||
msgid "Dutch"
|
msgid "Dutch"
|
||||||
msgstr "Néerlandais"
|
msgstr "Néerlandais"
|
||||||
|
|
||||||
#: meet/settings.py:226
|
#: meet/settings.py:231
|
||||||
msgid "German"
|
msgid "German"
|
||||||
msgstr "Allemand"
|
msgstr "Allemand"
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ msgid ""
|
|||||||
msgstr ""
|
msgstr ""
|
||||||
"Project-Id-Version: PACKAGE VERSION\n"
|
"Project-Id-Version: PACKAGE VERSION\n"
|
||||||
"Report-Msgid-Bugs-To: \n"
|
"Report-Msgid-Bugs-To: \n"
|
||||||
"POT-Creation-Date: 2026-04-21 14:09+0000\n"
|
"POT-Creation-Date: 2026-07-02 10:47+0000\n"
|
||||||
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
|
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
|
||||||
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
|
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
|
||||||
"Language-Team: LANGUAGE <LL@li.org>\n"
|
"Language-Team: LANGUAGE <LL@li.org>\n"
|
||||||
@@ -17,70 +17,92 @@ msgstr ""
|
|||||||
"Content-Type: text/plain; charset=UTF-8\n"
|
"Content-Type: text/plain; charset=UTF-8\n"
|
||||||
"Content-Transfer-Encoding: 8bit\n"
|
"Content-Transfer-Encoding: 8bit\n"
|
||||||
|
|
||||||
#: core/admin.py:29
|
#: core/admin.py:67
|
||||||
msgid "Personal info"
|
msgid "Personal info"
|
||||||
msgstr "Persoonlijke informatie"
|
msgstr "Persoonlijke informatie"
|
||||||
|
|
||||||
#: core/admin.py:42
|
#: core/admin.py:80
|
||||||
msgid "Permissions"
|
msgid "Permissions"
|
||||||
msgstr "Rechten"
|
msgstr "Rechten"
|
||||||
|
|
||||||
#: core/admin.py:54
|
#: core/admin.py:92
|
||||||
msgid "Important dates"
|
msgid "Important dates"
|
||||||
msgstr "Belangrijke datums"
|
msgstr "Belangrijke datums"
|
||||||
|
|
||||||
#: core/admin.py:132 core/admin.py:275
|
#: core/admin.py:206
|
||||||
|
msgid "Content"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: core/admin.py:217
|
||||||
|
#, fuzzy
|
||||||
|
#| msgid "Delete rooms"
|
||||||
|
msgid "Deletion"
|
||||||
|
msgstr "Ruimtes verwijderen"
|
||||||
|
|
||||||
|
#: core/admin.py:226
|
||||||
|
msgid "Derived info"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: core/admin.py:237
|
||||||
|
msgid "Timestamps"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: core/admin.py:240
|
||||||
|
msgid "File preview"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: core/admin.py:300 core/admin.py:443
|
||||||
msgid "No owner"
|
msgid "No owner"
|
||||||
msgstr "Geen eigenaar"
|
msgstr "Geen eigenaar"
|
||||||
|
|
||||||
#: core/admin.py:135 core/admin.py:278
|
#: core/admin.py:303 core/admin.py:446
|
||||||
msgid "Multiple owners"
|
msgid "Multiple owners"
|
||||||
msgstr "Meerdere eigenaren"
|
msgstr "Meerdere eigenaren"
|
||||||
|
|
||||||
#: core/admin.py:148
|
#: core/admin.py:316
|
||||||
msgid "Resend notification to external service"
|
msgid "Resend notification to external service"
|
||||||
msgstr "Melding opnieuw verzenden naar externe dienst"
|
msgstr "Melding opnieuw verzenden naar externe dienst"
|
||||||
|
|
||||||
#: core/admin.py:171
|
#: core/admin.py:339
|
||||||
#, python-format
|
#, python-format
|
||||||
msgid "Failed to notify for recording %(id)s"
|
msgid "Failed to notify for recording %(id)s"
|
||||||
msgstr "Melding voor opname %(id)s mislukt"
|
msgstr "Melding voor opname %(id)s mislukt"
|
||||||
|
|
||||||
#: core/admin.py:179
|
#: core/admin.py:347
|
||||||
#, python-format
|
#, python-format
|
||||||
msgid "Failed to notify for recording %(id)s: %(error)s"
|
msgid "Failed to notify for recording %(id)s: %(error)s"
|
||||||
msgstr "Melding voor opname %(id)s mislukt: %(error)s"
|
msgstr "Melding voor opname %(id)s mislukt: %(error)s"
|
||||||
|
|
||||||
#: core/admin.py:187
|
#: core/admin.py:355
|
||||||
#, python-format
|
#, python-format
|
||||||
msgid "Successfully sent notifications for %(count)s recording(s)."
|
msgid "Successfully sent notifications for %(count)s recording(s)."
|
||||||
msgstr "Meldingen succesvol verzonden voor %(count)s opname(n)."
|
msgstr "Meldingen succesvol verzonden voor %(count)s opname(n)."
|
||||||
|
|
||||||
#: core/admin.py:195
|
#: core/admin.py:363
|
||||||
#, python-format
|
#, python-format
|
||||||
msgid "Skipped %(count)s expired recording(s)."
|
msgid "Skipped %(count)s expired recording(s)."
|
||||||
msgstr "%(count)s verlopen opname(n) overgeslagen."
|
msgstr "%(count)s verlopen opname(n) overgeslagen."
|
||||||
|
|
||||||
#: core/admin.py:200
|
#: core/admin.py:368
|
||||||
msgid "Mark selected recordings as 'Failed to Stop'"
|
msgid "Mark selected recordings as 'Failed to Stop'"
|
||||||
msgstr "Geselecteerde opnames markeren als 'Mislukt bij stoppen'"
|
msgstr "Geselecteerde opnames markeren als 'Mislukt bij stoppen'"
|
||||||
|
|
||||||
#: core/admin.py:218
|
#: core/admin.py:386
|
||||||
#, python-format
|
#, python-format
|
||||||
msgid "%(count)s recording(s) successfully marked as 'Failed to Stop'."
|
msgid "%(count)s recording(s) successfully marked as 'Failed to Stop'."
|
||||||
msgstr "%(count)s opname(s) succesvol gemarkeerd als 'Mislukt bij stoppen'."
|
msgstr "%(count)s opname(s) succesvol gemarkeerd als 'Mislukt bij stoppen'."
|
||||||
|
|
||||||
#: core/admin.py:226
|
#: core/admin.py:394
|
||||||
#, fuzzy, python-format
|
#, fuzzy, python-format
|
||||||
#| msgid "Skipped %(count)s expired recording(s)."
|
#| msgid "Skipped %(count)s expired recording(s)."
|
||||||
msgid "Skipped %(count)s recording(s) with an ineligible status."
|
msgid "Skipped %(count)s recording(s) with an ineligible status."
|
||||||
msgstr "%(count)s opname(s) met een niet-toegestane status overgeslagen."
|
msgstr "%(count)s opname(s) met een niet-toegestane status overgeslagen."
|
||||||
|
|
||||||
#: core/admin.py:342
|
#: core/admin.py:510
|
||||||
msgid "No scopes"
|
msgid "No scopes"
|
||||||
msgstr "Geen scopes"
|
msgstr "Geen scopes"
|
||||||
|
|
||||||
#: core/admin.py:344
|
#: core/admin.py:512
|
||||||
msgid "Scopes"
|
msgid "Scopes"
|
||||||
msgstr "Scopes"
|
msgstr "Scopes"
|
||||||
|
|
||||||
@@ -88,16 +110,16 @@ msgstr "Scopes"
|
|||||||
msgid "Creator is me"
|
msgid "Creator is me"
|
||||||
msgstr "Maker ben ik"
|
msgstr "Maker ben ik"
|
||||||
|
|
||||||
#: core/api/serializers.py:88
|
#: core/api/serializers.py:89
|
||||||
msgid "You must be administrator or owner of a room to add accesses to it."
|
msgid "You must be administrator or owner of a room to add accesses to it."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
"Je moet beheerder of eigenaar van een ruimte zijn om toegang toe te voegen."
|
"Je moet beheerder of eigenaar van een ruimte zijn om toegang toe te voegen."
|
||||||
|
|
||||||
#: core/api/serializers.py:509
|
#: core/api/serializers.py:534
|
||||||
msgid "This file extension is not allowed."
|
msgid "This file extension is not allowed."
|
||||||
msgstr "Deze bestandsextensie is niet toegestaan."
|
msgstr "Deze bestandsextensie is niet toegestaan."
|
||||||
|
|
||||||
#: core/api/viewsets.py:1090
|
#: core/api/viewsets.py:1222
|
||||||
msgid "You have reached the maximum number of files for this type."
|
msgid "You have reached the maximum number of files for this type."
|
||||||
msgstr "Het maximale aantal bestanden voor dit type is bereikt."
|
msgstr "Het maximale aantal bestanden voor dit type is bereikt."
|
||||||
|
|
||||||
@@ -145,51 +167,59 @@ msgstr "Stoppen mislukt"
|
|||||||
msgid "Notification succeeded"
|
msgid "Notification succeeded"
|
||||||
msgstr "Notificatie geslaagd"
|
msgstr "Notificatie geslaagd"
|
||||||
|
|
||||||
#: core/models.py:89
|
#: core/models.py:65
|
||||||
|
msgid "External process successful"
|
||||||
|
msgstr "Externe procedure succesvol"
|
||||||
|
|
||||||
|
#: core/models.py:67
|
||||||
|
msgid "External process failed"
|
||||||
|
msgstr "Het externe proces is mislukt."
|
||||||
|
|
||||||
|
#: core/models.py:96
|
||||||
msgid "SCREEN_RECORDING"
|
msgid "SCREEN_RECORDING"
|
||||||
msgstr "SCHERM_OPNAME"
|
msgstr "SCHERM_OPNAME"
|
||||||
|
|
||||||
#: core/models.py:90
|
#: core/models.py:97
|
||||||
msgid "TRANSCRIPT"
|
msgid "TRANSCRIPT"
|
||||||
msgstr "TRANSCRIPT"
|
msgstr "TRANSCRIPT"
|
||||||
|
|
||||||
#: core/models.py:96
|
#: core/models.py:103
|
||||||
msgid "Public Access"
|
msgid "Public Access"
|
||||||
msgstr "Openbare toegang"
|
msgstr "Openbare toegang"
|
||||||
|
|
||||||
#: core/models.py:97
|
#: core/models.py:104
|
||||||
msgid "Trusted Access"
|
msgid "Trusted Access"
|
||||||
msgstr "Vertrouwde toegang"
|
msgstr "Vertrouwde toegang"
|
||||||
|
|
||||||
#: core/models.py:98
|
#: core/models.py:105
|
||||||
msgid "Restricted Access"
|
msgid "Restricted Access"
|
||||||
msgstr "Beperkte toegang"
|
msgstr "Beperkte toegang"
|
||||||
|
|
||||||
#: core/models.py:110
|
#: core/models.py:117
|
||||||
msgid "id"
|
msgid "id"
|
||||||
msgstr "id"
|
msgstr "id"
|
||||||
|
|
||||||
#: core/models.py:111
|
#: core/models.py:118
|
||||||
msgid "primary key for the record as UUID"
|
msgid "primary key for the record as UUID"
|
||||||
msgstr "primaire sleutel voor het record als UUID"
|
msgstr "primaire sleutel voor het record als UUID"
|
||||||
|
|
||||||
#: core/models.py:117
|
#: core/models.py:124
|
||||||
msgid "created on"
|
msgid "created on"
|
||||||
msgstr "aangemaakt op"
|
msgstr "aangemaakt op"
|
||||||
|
|
||||||
#: core/models.py:118
|
#: core/models.py:125
|
||||||
msgid "date and time at which a record was created"
|
msgid "date and time at which a record was created"
|
||||||
msgstr "datum en tijd waarop een record werd aangemaakt"
|
msgstr "datum en tijd waarop een record werd aangemaakt"
|
||||||
|
|
||||||
#: core/models.py:123
|
#: core/models.py:130
|
||||||
msgid "updated on"
|
msgid "updated on"
|
||||||
msgstr "bijgewerkt op"
|
msgstr "bijgewerkt op"
|
||||||
|
|
||||||
#: core/models.py:124
|
#: core/models.py:131
|
||||||
msgid "date and time at which a record was last updated"
|
msgid "date and time at which a record was last updated"
|
||||||
msgstr "datum en tijd waarop een record voor het laatst werd bijgewerkt"
|
msgstr "datum en tijd waarop een record voor het laatst werd bijgewerkt"
|
||||||
|
|
||||||
#: core/models.py:144
|
#: core/models.py:151
|
||||||
msgid ""
|
msgid ""
|
||||||
"Enter a valid sub. This value may contain only letters, numbers, and @/./+/-/"
|
"Enter a valid sub. This value may contain only letters, numbers, and @/./+/-/"
|
||||||
"_ characters."
|
"_ characters."
|
||||||
@@ -197,11 +227,11 @@ msgstr ""
|
|||||||
"Voer een geldige sub in. Deze waarde mag alleen letters, cijfers en @/./+/-/"
|
"Voer een geldige sub in. Deze waarde mag alleen letters, cijfers en @/./+/-/"
|
||||||
"_ tekens bevatten."
|
"_ tekens bevatten."
|
||||||
|
|
||||||
#: core/models.py:150
|
#: core/models.py:157
|
||||||
msgid "sub"
|
msgid "sub"
|
||||||
msgstr "sub"
|
msgstr "sub"
|
||||||
|
|
||||||
#: core/models.py:152
|
#: core/models.py:159
|
||||||
msgid ""
|
msgid ""
|
||||||
"Optional for pending users; required upon account activation. 255 characters "
|
"Optional for pending users; required upon account activation. 255 characters "
|
||||||
"or fewer. Letters, numbers, and @/./+/-/_ characters only."
|
"or fewer. Letters, numbers, and @/./+/-/_ characters only."
|
||||||
@@ -209,55 +239,55 @@ msgstr ""
|
|||||||
"Optioneel voor gebruikers in afwachting; vereist bij accountactivering. "
|
"Optioneel voor gebruikers in afwachting; vereist bij accountactivering. "
|
||||||
"Maximum 255 tekens. Alleen letters, cijfers en @/./+/-/_ toegestaan."
|
"Maximum 255 tekens. Alleen letters, cijfers en @/./+/-/_ toegestaan."
|
||||||
|
|
||||||
#: core/models.py:161
|
#: core/models.py:168
|
||||||
msgid "identity email address"
|
msgid "identity email address"
|
||||||
msgstr "identiteit e-mailadres"
|
msgstr "identiteit e-mailadres"
|
||||||
|
|
||||||
#: core/models.py:166
|
#: core/models.py:173
|
||||||
msgid "admin email address"
|
msgid "admin email address"
|
||||||
msgstr "beheerder e-mailadres"
|
msgstr "beheerder e-mailadres"
|
||||||
|
|
||||||
#: core/models.py:168
|
#: core/models.py:175
|
||||||
msgid "full name"
|
msgid "full name"
|
||||||
msgstr "volledige naam"
|
msgstr "volledige naam"
|
||||||
|
|
||||||
#: core/models.py:170
|
#: core/models.py:177
|
||||||
msgid "short name"
|
msgid "short name"
|
||||||
msgstr "korte naam"
|
msgstr "korte naam"
|
||||||
|
|
||||||
#: core/models.py:176
|
#: core/models.py:183
|
||||||
msgid "language"
|
msgid "language"
|
||||||
msgstr "taal"
|
msgstr "taal"
|
||||||
|
|
||||||
#: core/models.py:177
|
#: core/models.py:184
|
||||||
msgid "The language in which the user wants to see the interface."
|
msgid "The language in which the user wants to see the interface."
|
||||||
msgstr "De taal waarin de gebruiker de interface wil zien."
|
msgstr "De taal waarin de gebruiker de interface wil zien."
|
||||||
|
|
||||||
#: core/models.py:183
|
#: core/models.py:190
|
||||||
msgid "The timezone in which the user wants to see times."
|
msgid "The timezone in which the user wants to see times."
|
||||||
msgstr "De tijdzone waarin de gebruiker tijden wil zien."
|
msgstr "De tijdzone waarin de gebruiker tijden wil zien."
|
||||||
|
|
||||||
#: core/models.py:186
|
#: core/models.py:193
|
||||||
msgid "device"
|
msgid "device"
|
||||||
msgstr "apparaat"
|
msgstr "apparaat"
|
||||||
|
|
||||||
#: core/models.py:188
|
#: core/models.py:195
|
||||||
msgid "Whether the user is a device or a real user."
|
msgid "Whether the user is a device or a real user."
|
||||||
msgstr "Of de gebruiker een apparaat is of een echte gebruiker."
|
msgstr "Of de gebruiker een apparaat is of een echte gebruiker."
|
||||||
|
|
||||||
#: core/models.py:191
|
#: core/models.py:198
|
||||||
msgid "staff status"
|
msgid "staff status"
|
||||||
msgstr "personeelsstatus"
|
msgstr "personeelsstatus"
|
||||||
|
|
||||||
#: core/models.py:193
|
#: core/models.py:200
|
||||||
msgid "Whether the user can log into this admin site."
|
msgid "Whether the user can log into this admin site."
|
||||||
msgstr "Of de gebruiker kan inloggen op deze beheersite."
|
msgstr "Of de gebruiker kan inloggen op deze beheersite."
|
||||||
|
|
||||||
#: core/models.py:196
|
#: core/models.py:203
|
||||||
msgid "active"
|
msgid "active"
|
||||||
msgstr "actief"
|
msgstr "actief"
|
||||||
|
|
||||||
#: core/models.py:199
|
#: core/models.py:206
|
||||||
msgid ""
|
msgid ""
|
||||||
"Whether this user should be treated as active. Unselect this instead of "
|
"Whether this user should be treated as active. Unselect this instead of "
|
||||||
"deleting accounts."
|
"deleting accounts."
|
||||||
@@ -265,64 +295,64 @@ msgstr ""
|
|||||||
"Of deze gebruiker als actief moet worden behandeld. Deselecteer dit in "
|
"Of deze gebruiker als actief moet worden behandeld. Deselecteer dit in "
|
||||||
"plaats van accounts te verwijderen."
|
"plaats van accounts te verwijderen."
|
||||||
|
|
||||||
#: core/models.py:212
|
#: core/models.py:219
|
||||||
msgid "user"
|
msgid "user"
|
||||||
msgstr "gebruiker"
|
msgstr "gebruiker"
|
||||||
|
|
||||||
#: core/models.py:213
|
#: core/models.py:220
|
||||||
msgid "users"
|
msgid "users"
|
||||||
msgstr "gebruikers"
|
msgstr "gebruikers"
|
||||||
|
|
||||||
#: core/models.py:272
|
#: core/models.py:286
|
||||||
msgid "Resource"
|
msgid "Resource"
|
||||||
msgstr "Bron"
|
msgstr "Bron"
|
||||||
|
|
||||||
#: core/models.py:273
|
#: core/models.py:287
|
||||||
msgid "Resources"
|
msgid "Resources"
|
||||||
msgstr "Bronnen"
|
msgstr "Bronnen"
|
||||||
|
|
||||||
#: core/models.py:331
|
#: core/models.py:345
|
||||||
msgid "Resource access"
|
msgid "Resource access"
|
||||||
msgstr "Brontoegang"
|
msgstr "Brontoegang"
|
||||||
|
|
||||||
#: core/models.py:332
|
#: core/models.py:346
|
||||||
msgid "Resource accesses"
|
msgid "Resource accesses"
|
||||||
msgstr "Brontoegangsrechten"
|
msgstr "Brontoegangsrechten"
|
||||||
|
|
||||||
#: core/models.py:338
|
#: core/models.py:352
|
||||||
msgid "Resource access with this User and Resource already exists."
|
msgid "Resource access with this User and Resource already exists."
|
||||||
msgstr "Brontoegang met deze gebruiker en bron bestaat al."
|
msgstr "Brontoegang met deze gebruiker en bron bestaat al."
|
||||||
|
|
||||||
#: core/models.py:394
|
#: core/models.py:409
|
||||||
msgid "Visio room configuration"
|
msgid "Visio room configuration"
|
||||||
msgstr "Visio-ruimteconfiguratie"
|
msgstr "Visio-ruimteconfiguratie"
|
||||||
|
|
||||||
#: core/models.py:395
|
#: core/models.py:410
|
||||||
msgid "Values for Visio parameters to configure the room."
|
msgid "Values for Visio parameters to configure the room."
|
||||||
msgstr "Waarden voor Visio-parameters om de ruimte te configureren."
|
msgstr "Waarden voor Visio-parameters om de ruimte te configureren."
|
||||||
|
|
||||||
#: core/models.py:402
|
#: core/models.py:417
|
||||||
msgid "Room PIN code"
|
msgid "Room PIN code"
|
||||||
msgstr "Pincode van de kamer"
|
msgstr "Pincode van de kamer"
|
||||||
|
|
||||||
#: core/models.py:403
|
#: core/models.py:418
|
||||||
msgid "Unique n-digit code that identifies this room in telephony mode."
|
msgid "Unique n-digit code that identifies this room in telephony mode."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
"Unieke n-cijferige code die deze kamer identificeert in telefonie-modus."
|
"Unieke n-cijferige code die deze kamer identificeert in telefonie-modus."
|
||||||
|
|
||||||
#: core/models.py:409 core/models.py:563
|
#: core/models.py:424 core/models.py:578
|
||||||
msgid "Room"
|
msgid "Room"
|
||||||
msgstr "Ruimte"
|
msgstr "Ruimte"
|
||||||
|
|
||||||
#: core/models.py:410
|
#: core/models.py:425
|
||||||
msgid "Rooms"
|
msgid "Rooms"
|
||||||
msgstr "Ruimtes"
|
msgstr "Ruimtes"
|
||||||
|
|
||||||
#: core/models.py:574
|
#: core/models.py:589
|
||||||
msgid "Worker ID"
|
msgid "Worker ID"
|
||||||
msgstr "Worker ID"
|
msgstr "Worker ID"
|
||||||
|
|
||||||
#: core/models.py:576
|
#: core/models.py:591
|
||||||
msgid ""
|
msgid ""
|
||||||
"Enter an identifier for the worker recording.This ID is retained even when "
|
"Enter an identifier for the worker recording.This ID is retained even when "
|
||||||
"the worker stops, allowing for easy tracking."
|
"the worker stops, allowing for easy tracking."
|
||||||
@@ -330,140 +360,152 @@ msgstr ""
|
|||||||
"Voer een identificatie in voor de worker-opname. Deze ID blijft behouden, "
|
"Voer een identificatie in voor de worker-opname. Deze ID blijft behouden, "
|
||||||
"zelfs wanneer de worker stopt, waardoor eenvoudige tracking mogelijk is."
|
"zelfs wanneer de worker stopt, waardoor eenvoudige tracking mogelijk is."
|
||||||
|
|
||||||
#: core/models.py:584
|
#: core/models.py:599
|
||||||
msgid "Recording mode"
|
msgid "Recording mode"
|
||||||
msgstr "Opnamemodus"
|
msgstr "Opnamemodus"
|
||||||
|
|
||||||
#: core/models.py:585
|
#: core/models.py:600
|
||||||
msgid "Defines the mode of recording being called."
|
msgid "Defines the mode of recording being called."
|
||||||
msgstr "Definieert de modus van opname die wordt aangeroepen."
|
msgstr "Definieert de modus van opname die wordt aangeroepen."
|
||||||
|
|
||||||
#: core/models.py:590 core/models.py:591
|
#: core/models.py:605 core/models.py:606
|
||||||
msgid "Recording options"
|
msgid "Recording options"
|
||||||
msgstr "Opnameopties"
|
msgstr "Opnameopties"
|
||||||
|
|
||||||
#: core/models.py:597
|
#: core/models.py:613
|
||||||
|
msgid "External Process ID"
|
||||||
|
msgstr "Externe proces-ID"
|
||||||
|
|
||||||
|
#: core/models.py:614
|
||||||
|
msgid "ID of the external process associated with the recording."
|
||||||
|
msgstr "ID van het externe proces dat aan de opname is gekoppeld."
|
||||||
|
|
||||||
|
#: core/models.py:620
|
||||||
msgid "Recording"
|
msgid "Recording"
|
||||||
msgstr "Opname"
|
msgstr "Opname"
|
||||||
|
|
||||||
#: core/models.py:598
|
#: core/models.py:621
|
||||||
msgid "Recordings"
|
msgid "Recordings"
|
||||||
msgstr "Opnames"
|
msgstr "Opnames"
|
||||||
|
|
||||||
#: core/models.py:706
|
#: core/models.py:731
|
||||||
msgid "Recording/user relation"
|
msgid "Recording/user relation"
|
||||||
msgstr "Opname/gebruiker-relatie"
|
msgstr "Opname/gebruiker-relatie"
|
||||||
|
|
||||||
#: core/models.py:707
|
#: core/models.py:732
|
||||||
msgid "Recording/user relations"
|
msgid "Recording/user relations"
|
||||||
msgstr "Opname/gebruiker-relaties"
|
msgstr "Opname/gebruiker-relaties"
|
||||||
|
|
||||||
#: core/models.py:713
|
#: core/models.py:738
|
||||||
msgid "This user is already in this recording."
|
msgid "This user is already in this recording."
|
||||||
msgstr "Deze gebruiker is al in deze opname."
|
msgstr "Deze gebruiker is al in deze opname."
|
||||||
|
|
||||||
#: core/models.py:719
|
#: core/models.py:744
|
||||||
msgid "This team is already in this recording."
|
msgid "This team is already in this recording."
|
||||||
msgstr "Dit team is al in deze opname."
|
msgstr "Dit team is al in deze opname."
|
||||||
|
|
||||||
#: core/models.py:725
|
#: core/models.py:750
|
||||||
msgid "Either user or team must be set, not both."
|
msgid "Either user or team must be set, not both."
|
||||||
msgstr "Ofwel gebruiker of team moet worden ingesteld, niet beide."
|
msgstr "Ofwel gebruiker of team moet worden ingesteld, niet beide."
|
||||||
|
|
||||||
#: core/models.py:742
|
#: core/models.py:767
|
||||||
msgid "Create rooms"
|
msgid "Create rooms"
|
||||||
msgstr "Ruimtes aanmaken"
|
msgstr "Ruimtes aanmaken"
|
||||||
|
|
||||||
#: core/models.py:743
|
#: core/models.py:768
|
||||||
msgid "List rooms"
|
msgid "List rooms"
|
||||||
msgstr "Ruimtes weergeven"
|
msgstr "Ruimtes weergeven"
|
||||||
|
|
||||||
#: core/models.py:744
|
#: core/models.py:769
|
||||||
msgid "Retrieve room details"
|
msgid "Retrieve room details"
|
||||||
msgstr "Details van een ruimte ophalen"
|
msgstr "Details van een ruimte ophalen"
|
||||||
|
|
||||||
#: core/models.py:745
|
#: core/models.py:770
|
||||||
msgid "Update rooms"
|
msgid "Update rooms"
|
||||||
msgstr "Ruimtes bijwerken"
|
msgstr "Ruimtes bijwerken"
|
||||||
|
|
||||||
#: core/models.py:746
|
#: core/models.py:771
|
||||||
msgid "Delete rooms"
|
msgid "Delete rooms"
|
||||||
msgstr "Ruimtes verwijderen"
|
msgstr "Ruimtes verwijderen"
|
||||||
|
|
||||||
#: core/models.py:759
|
#: core/models.py:784
|
||||||
msgid "Application name"
|
msgid "Application name"
|
||||||
msgstr "Naam van de applicatie"
|
msgstr "Naam van de applicatie"
|
||||||
|
|
||||||
#: core/models.py:760
|
#: core/models.py:785
|
||||||
msgid "Descriptive name for this application."
|
msgid "Descriptive name for this application."
|
||||||
msgstr "Beschrijvende naam voor deze applicatie."
|
msgstr "Beschrijvende naam voor deze applicatie."
|
||||||
|
|
||||||
#: core/models.py:770
|
#: core/models.py:795
|
||||||
msgid "Hashed on Save. Copy it now if this is a new secret."
|
msgid "Hashed on Save. Copy it now if this is a new secret."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
"Wordt gehasht bij het opslaan. Kopieer het nu als dit een nieuw geheim is."
|
"Wordt gehasht bij het opslaan. Kopieer het nu als dit een nieuw geheim is."
|
||||||
|
|
||||||
#: core/models.py:781
|
#: core/models.py:806
|
||||||
msgid "Application"
|
msgid "Application"
|
||||||
msgstr "Applicatie"
|
msgstr "Applicatie"
|
||||||
|
|
||||||
#: core/models.py:782
|
#: core/models.py:807
|
||||||
msgid "Applications"
|
msgid "Applications"
|
||||||
msgstr "Applicaties"
|
msgstr "Applicaties"
|
||||||
|
|
||||||
#: core/models.py:805
|
#: core/models.py:830
|
||||||
msgid "Enter a valid domain"
|
msgid "Enter a valid domain"
|
||||||
msgstr "Voer een geldig domein in"
|
msgstr "Voer een geldig domein in"
|
||||||
|
|
||||||
#: core/models.py:808
|
#: core/models.py:833
|
||||||
msgid "Domain"
|
msgid "Domain"
|
||||||
msgstr "Domein"
|
msgstr "Domein"
|
||||||
|
|
||||||
#: core/models.py:809
|
#: core/models.py:834
|
||||||
msgid "Email domain this application can act on behalf of."
|
msgid "Email domain this application can act on behalf of."
|
||||||
msgstr "E-maildomein namens welke deze applicatie kan handelen."
|
msgstr "E-maildomein namens welke deze applicatie kan handelen."
|
||||||
|
|
||||||
#: core/models.py:821
|
#: core/models.py:846
|
||||||
msgid "Application domain"
|
msgid "Application domain"
|
||||||
msgstr "Applicatiedomein"
|
msgstr "Applicatiedomein"
|
||||||
|
|
||||||
#: core/models.py:822
|
#: core/models.py:847
|
||||||
msgid "Application domains"
|
msgid "Application domains"
|
||||||
msgstr "Applicatiedomeinen"
|
msgstr "Applicatiedomeinen"
|
||||||
|
|
||||||
#: core/models.py:840
|
#: core/models.py:865
|
||||||
msgid "Pending"
|
msgid "Pending"
|
||||||
msgstr "In afwachting"
|
msgstr "In afwachting"
|
||||||
|
|
||||||
#: core/models.py:848
|
#: core/models.py:866
|
||||||
|
msgid "Analyzing"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: core/models.py:873
|
||||||
msgid "Ready"
|
msgid "Ready"
|
||||||
msgstr "Klaar"
|
msgstr "Klaar"
|
||||||
|
|
||||||
#: core/models.py:854
|
#: core/models.py:879
|
||||||
msgid "Background image"
|
msgid "Background image"
|
||||||
msgstr "Achtergrondafbeelding"
|
msgstr "Achtergrondafbeelding"
|
||||||
|
|
||||||
#: core/models.py:866
|
#: core/models.py:891
|
||||||
msgid "title"
|
msgid "title"
|
||||||
msgstr "Titel"
|
msgstr "Titel"
|
||||||
|
|
||||||
#: core/models.py:890
|
#: core/models.py:915
|
||||||
msgid "Malware detection info when the analysis status is unsafe."
|
msgid "Malware detection info when the analysis status is unsafe."
|
||||||
msgstr "Informatie over malwaredetectie wanneer de analysestatus onveilig is."
|
msgstr "Informatie over malwaredetectie wanneer de analysestatus onveilig is."
|
||||||
|
|
||||||
#: core/models.py:895
|
#: core/models.py:920
|
||||||
msgid "File"
|
msgid "File"
|
||||||
msgstr "Bestand"
|
msgstr "Bestand"
|
||||||
|
|
||||||
#: core/models.py:896
|
#: core/models.py:921
|
||||||
msgid "Files"
|
msgid "Files"
|
||||||
msgstr "Bestanden"
|
msgstr "Bestanden"
|
||||||
|
|
||||||
#: core/models.py:1000
|
#: core/models.py:1041
|
||||||
msgid "This file is already hard deleted."
|
msgid "This file is already hard deleted."
|
||||||
msgstr "Dit bestand is al definitief verwijderd."
|
msgstr "Dit bestand is al definitief verwijderd."
|
||||||
|
|
||||||
#: core/models.py:1010
|
#: core/models.py:1051
|
||||||
#, fuzzy
|
#, fuzzy
|
||||||
#| msgid "To hard delete a file, it must first be soft deleted."
|
#| msgid "To hard delete a file, it must first be soft deleted."
|
||||||
msgid "To hard delete a file, it must first be soft deleted."
|
msgid "To hard delete a file, it must first be soft deleted."
|
||||||
@@ -471,10 +513,20 @@ msgstr ""
|
|||||||
"Om een bestand definitief te verwijderen, moet het eerst zacht verwijderd "
|
"Om een bestand definitief te verwijderen, moet het eerst zacht verwijderd "
|
||||||
"zijn."
|
"zijn."
|
||||||
|
|
||||||
#: core/recording/event/notification.py:116
|
#: core/recording/event/notification.py:123
|
||||||
msgid "Your recording is ready"
|
msgid "Your recording is ready"
|
||||||
msgstr "Je opname is klaar"
|
msgstr "Je opname is klaar"
|
||||||
|
|
||||||
|
#: core/recording/event/notification.py:194
|
||||||
|
msgid "Transcription"
|
||||||
|
msgstr "Transcriptie"
|
||||||
|
|
||||||
|
#: core/recording/event/notification.py:204
|
||||||
|
#, python-brace-format
|
||||||
|
msgid "Meeting \"{room}\" on {room_recording_date} at {room_recording_time}"
|
||||||
|
msgstr ""
|
||||||
|
"Vergadering \"{room}\" op {room_recording_date} om {room_recording_time}"
|
||||||
|
|
||||||
#: core/services/invitation.py:44
|
#: core/services/invitation.py:44
|
||||||
#, python-brace-format
|
#, python-brace-format
|
||||||
msgid "Video call in progress: {sender.email} is waiting for you to connect"
|
msgid "Video call in progress: {sender.email} is waiting for you to connect"
|
||||||
@@ -570,7 +622,7 @@ msgid "To keep this recording permanently:"
|
|||||||
msgstr "Om deze opname permanent te bewaren:"
|
msgstr "Om deze opname permanent te bewaren:"
|
||||||
|
|
||||||
#: core/templates/mail/html/screen_recording.html:208
|
#: core/templates/mail/html/screen_recording.html:208
|
||||||
#: core/templates/mail/text/screen_recording.txt:13
|
#, python-format
|
||||||
msgid "Click the \"<a href=\"%(link)s\">Open</a>\" link below "
|
msgid "Click the \"<a href=\"%(link)s\">Open</a>\" link below "
|
||||||
msgstr "Klik op de \"<a href=\"%(link)s\">Openen</a>\"-link hieronder "
|
msgstr "Klik op de \"<a href=\"%(link)s\">Openen</a>\"-link hieronder "
|
||||||
|
|
||||||
@@ -599,18 +651,24 @@ msgstr ""
|
|||||||
" Als je vragen hebt of hulp nodig hebt, neem dan contact op met ons support "
|
" Als je vragen hebt of hulp nodig hebt, neem dan contact op met ons support "
|
||||||
"team via %(support_email)s. "
|
"team via %(support_email)s. "
|
||||||
|
|
||||||
#: meet/settings.py:223
|
#: core/templates/mail/text/screen_recording.txt:13
|
||||||
|
#, fuzzy, python-format
|
||||||
|
#| msgid "Click the \"<a href=\"%(link)s\">Open</a>\" link below "
|
||||||
|
msgid "Click the \"Open [%(link)s]\" link below "
|
||||||
|
msgstr "Klik op de \"<a href=\"%(link)s\">Openen</a>\"-link hieronder "
|
||||||
|
|
||||||
|
#: meet/settings.py:228
|
||||||
msgid "English"
|
msgid "English"
|
||||||
msgstr "Engels"
|
msgstr "Engels"
|
||||||
|
|
||||||
#: meet/settings.py:224
|
#: meet/settings.py:229
|
||||||
msgid "French"
|
msgid "French"
|
||||||
msgstr "Frans"
|
msgstr "Frans"
|
||||||
|
|
||||||
#: meet/settings.py:225
|
#: meet/settings.py:230
|
||||||
msgid "Dutch"
|
msgid "Dutch"
|
||||||
msgstr "Nederlands"
|
msgstr "Nederlands"
|
||||||
|
|
||||||
#: meet/settings.py:226
|
#: meet/settings.py:231
|
||||||
msgid "German"
|
msgid "German"
|
||||||
msgstr "Duits"
|
msgstr "Duits"
|
||||||
|
|||||||
+183
-23
@@ -13,6 +13,7 @@ https://docs.djangoproject.com/en/3.1/ref/settings/
|
|||||||
# pylint: disable=too-many-lines
|
# pylint: disable=too-many-lines
|
||||||
|
|
||||||
import json
|
import json
|
||||||
|
import warnings
|
||||||
from os import path
|
from os import path
|
||||||
from socket import gethostbyname, gethostname
|
from socket import gethostbyname, gethostname
|
||||||
|
|
||||||
@@ -182,7 +183,9 @@ class Base(Configuration):
|
|||||||
environ_name="FILE_UPLOAD_ENABLED",
|
environ_name="FILE_UPLOAD_ENABLED",
|
||||||
environ_prefix=None,
|
environ_prefix=None,
|
||||||
)
|
)
|
||||||
|
FILE_PURGE_GRACE_DAYS = values.PositiveIntegerValue(
|
||||||
|
default=7, environ_name="FILE_PURGE_GRACE_DAYS", environ_prefix=None
|
||||||
|
)
|
||||||
FILE_UPLOAD_PATH = values.Value(
|
FILE_UPLOAD_PATH = values.Value(
|
||||||
"files", environ_name="FILE_UPLOAD_PATH", environ_prefix=None
|
"files", environ_name="FILE_UPLOAD_PATH", environ_prefix=None
|
||||||
)
|
)
|
||||||
@@ -321,8 +324,7 @@ class Base(Configuration):
|
|||||||
|
|
||||||
REST_FRAMEWORK = {
|
REST_FRAMEWORK = {
|
||||||
"DEFAULT_AUTHENTICATION_CLASSES": (
|
"DEFAULT_AUTHENTICATION_CLASSES": (
|
||||||
"mozilla_django_oidc.contrib.drf.OIDCAuthentication",
|
"core.authentication.backends.SessionAuthenticationWith401",
|
||||||
"rest_framework.authentication.SessionAuthentication",
|
|
||||||
),
|
),
|
||||||
"DEFAULT_PARSER_CLASSES": [
|
"DEFAULT_PARSER_CLASSES": [
|
||||||
"rest_framework.parsers.JSONParser",
|
"rest_framework.parsers.JSONParser",
|
||||||
@@ -669,6 +671,11 @@ class Base(Configuration):
|
|||||||
environ_name="PARTICIPANT_FORBIDDEN_PERMISSION_FIELDS",
|
environ_name="PARTICIPANT_FORBIDDEN_PERMISSION_FIELDS",
|
||||||
environ_prefix=None,
|
environ_prefix=None,
|
||||||
)
|
)
|
||||||
|
AUTHENTICATED_PARTICIPANTS_CAN_EDIT_DISPLAY_NAME = values.BooleanValue(
|
||||||
|
True,
|
||||||
|
environ_name="AUTHENTICATED_PARTICIPANTS_CAN_EDIT_DISPLAY_NAME",
|
||||||
|
environ_prefix=None,
|
||||||
|
)
|
||||||
|
|
||||||
# Recording settings
|
# Recording settings
|
||||||
RECORDING_ENABLE = values.BooleanValue(
|
RECORDING_ENABLE = values.BooleanValue(
|
||||||
@@ -714,28 +721,74 @@ class Base(Configuration):
|
|||||||
# These settings affect screen recordings handled by VideoCompositeEgressService;
|
# These settings affect screen recordings handled by VideoCompositeEgressService;
|
||||||
# they are silently ignored by AudioCompositeEgressService (audio-only transcript
|
# they are silently ignored by AudioCompositeEgressService (audio-only transcript
|
||||||
# recordings), whose request never carries advanced EncodingOptions.
|
# 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).
|
# A default encoding is applied to every recording: it is resolved from the default
|
||||||
# When enabled, the values below are passed to LiveKit as EncodingOptions
|
# profile and resolution below and passed to LiveKit as EncodingOptions (advanced),
|
||||||
# (advanced) and replace the preset. Lowering framerate and bitrate reduces
|
# replacing LiveKit's built-in H264_720P_30 preset. Lowering framerate and bitrate
|
||||||
# output file size and CPU load on the egress worker.
|
# reduces output file size and CPU load on the egress worker. If either
|
||||||
RECORDING_ENCODING_ENABLED = values.BooleanValue(
|
# RECORDING_ENCODING_DEFAULT_RESOLUTION or RECORDING_ENCODING_DEFAULT_PROFILE is
|
||||||
False, environ_name="RECORDING_ENCODING_ENABLED", environ_prefix=None
|
# unset, no default encoding is built (a startup warning is emitted) and LiveKit's
|
||||||
|
# built-in preset is used instead.
|
||||||
|
#
|
||||||
|
# RECORDING_CUSTOM_ENCODING_ENABLED gates whether the start-recording API lets a
|
||||||
|
# client override that default per recording (via an `encoding` object selecting a
|
||||||
|
# resolution/profile). When False, the API rejects per-recording `encoding` and
|
||||||
|
# every recording uses the default; when True, clients may pick from the
|
||||||
|
# available resolutions/profiles below.
|
||||||
|
RECORDING_CUSTOM_ENCODING_ENABLED = values.BooleanValue(
|
||||||
|
False, environ_name="RECORDING_CUSTOM_ENCODING_ENABLED", environ_prefix=None
|
||||||
)
|
)
|
||||||
RECORDING_ENCODING_WIDTH = values.PositiveIntegerValue(
|
|
||||||
1280, environ_name="RECORDING_ENCODING_WIDTH", environ_prefix=None
|
# Map resolution string -> {"width", "height"} in pixels.
|
||||||
)
|
RECORDING_ENCODING_AVAILABLE_RESOLUTIONS = values.DictValue(
|
||||||
RECORDING_ENCODING_HEIGHT = values.PositiveIntegerValue(
|
{
|
||||||
720, environ_name="RECORDING_ENCODING_HEIGHT", environ_prefix=None
|
"540p": {"width": 960, "height": 540},
|
||||||
)
|
"720p": {"width": 1280, "height": 720},
|
||||||
RECORDING_ENCODING_FRAMERATE = values.PositiveIntegerValue(
|
"1080p": {"width": 1920, "height": 1080},
|
||||||
30, environ_name="RECORDING_ENCODING_FRAMERATE", environ_prefix=None
|
},
|
||||||
)
|
environ_name="RECORDING_ENCODING_AVAILABLE_RESOLUTIONS",
|
||||||
RECORDING_ENCODING_VIDEO_BITRATE_KBPS = values.PositiveIntegerValue(
|
|
||||||
3000,
|
|
||||||
environ_name="RECORDING_ENCODING_VIDEO_BITRATE_KBPS",
|
|
||||||
environ_prefix=None,
|
environ_prefix=None,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Map profile string -> {"fps", "kbps": {resolution: video_bitrate_kbps}}.
|
||||||
|
# Bitrate scales with resolution so quality stays consistent across sizes.
|
||||||
|
RECORDING_ENCODING_AVAILABLE_PROFILES = values.DictValue(
|
||||||
|
{
|
||||||
|
"talking_heads": {
|
||||||
|
"fps": 15,
|
||||||
|
"kbps": {"540p": 400, "720p": 700, "1080p": 1200},
|
||||||
|
},
|
||||||
|
"text": {
|
||||||
|
"fps": 15,
|
||||||
|
"kbps": {"540p": 600, "720p": 1000, "1080p": 1800},
|
||||||
|
},
|
||||||
|
"mixed": {
|
||||||
|
"fps": 20,
|
||||||
|
"kbps": {"540p": 900, "720p": 1500, "1080p": 2500},
|
||||||
|
},
|
||||||
|
"full": {
|
||||||
|
"fps": 30,
|
||||||
|
"kbps": {"540p": 2000, "720p": 3000, "1080p": 4500},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
environ_name="RECORDING_ENCODING_AVAILABLE_PROFILES",
|
||||||
|
environ_prefix=None,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Defaults used when no profile/resolution is specified per recording.
|
||||||
|
# Must be keys of the two dicts above (validated at startup).
|
||||||
|
RECORDING_ENCODING_DEFAULT_PROFILE = values.Value(
|
||||||
|
"full",
|
||||||
|
environ_name="RECORDING_ENCODING_DEFAULT_PROFILE",
|
||||||
|
environ_prefix=None,
|
||||||
|
)
|
||||||
|
RECORDING_ENCODING_DEFAULT_RESOLUTION = values.Value(
|
||||||
|
"720p",
|
||||||
|
environ_name="RECORDING_ENCODING_DEFAULT_RESOLUTION",
|
||||||
|
environ_prefix=None,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Settings independent of profile/resolution.
|
||||||
RECORDING_ENCODING_AUDIO_BITRATE_KBPS = values.PositiveIntegerValue(
|
RECORDING_ENCODING_AUDIO_BITRATE_KBPS = values.PositiveIntegerValue(
|
||||||
128,
|
128,
|
||||||
environ_name="RECORDING_ENCODING_AUDIO_BITRATE_KBPS",
|
environ_name="RECORDING_ENCODING_AUDIO_BITRATE_KBPS",
|
||||||
@@ -747,12 +800,31 @@ class Base(Configuration):
|
|||||||
environ_prefix=None,
|
environ_prefix=None,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
SUMMARY_SERVICE_VERSION = values.PositiveIntegerValue(
|
||||||
|
1, environ_name="SUMMARY_SERVICE_VERSION", environ_prefix=None
|
||||||
|
)
|
||||||
|
|
||||||
SUMMARY_SERVICE_ENDPOINT = values.Value(
|
SUMMARY_SERVICE_ENDPOINT = values.Value(
|
||||||
None, environ_name="SUMMARY_SERVICE_ENDPOINT", environ_prefix=None
|
None, environ_name="SUMMARY_SERVICE_ENDPOINT", environ_prefix=None
|
||||||
)
|
)
|
||||||
SUMMARY_SERVICE_API_TOKEN = SecretFileValue(
|
SUMMARY_SERVICE_API_TOKEN = SecretFileValue(
|
||||||
None, environ_name="SUMMARY_SERVICE_API_TOKEN", environ_prefix=None
|
None, environ_name="SUMMARY_SERVICE_API_TOKEN", environ_prefix=None
|
||||||
)
|
)
|
||||||
|
SUMMARY_SERVICE_WEBHOOK_API_TOKEN = SecretFileValue(
|
||||||
|
None, environ_name="SUMMARY_SERVICE_WEBHOOK_API_TOKEN", environ_prefix=None
|
||||||
|
)
|
||||||
|
SUMMARY_SERVICE_CLOUD_STORAGE_SIGNED_URL_EXPIRY_SECONDS = (
|
||||||
|
values.PositiveIntegerValue(
|
||||||
|
60 * 60 * 24,
|
||||||
|
environ_name="SUMMARY_SERVICE_CLOUD_STORAGE_SIGNED_URL_EXPIRY_SECONDS",
|
||||||
|
environ_prefix=None,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
TRANSCRIPTION_SATISFACTION_FORM_BASE_URL = values.Value(
|
||||||
|
None,
|
||||||
|
environ_name="TRANSCRIPTION_SATISFACTION_FORM_BASE_URL",
|
||||||
|
environ_prefix=None,
|
||||||
|
)
|
||||||
SCREEN_RECORDING_BASE_URL = values.Value(
|
SCREEN_RECORDING_BASE_URL = values.Value(
|
||||||
None, environ_name="SCREEN_RECORDING_BASE_URL", environ_prefix=None
|
None, environ_name="SCREEN_RECORDING_BASE_URL", environ_prefix=None
|
||||||
)
|
)
|
||||||
@@ -760,6 +832,14 @@ class Base(Configuration):
|
|||||||
None, environ_name="RECORDING_DOWNLOAD_BASE_URL", environ_prefix=None
|
None, environ_name="RECORDING_DOWNLOAD_BASE_URL", environ_prefix=None
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Analytics
|
||||||
|
ANALYTICS_BACKEND = values.Value(
|
||||||
|
None, environ_name="ANALYTICS_BACKEND", environ_prefix=None
|
||||||
|
)
|
||||||
|
ANALYTICS_BACKEND_SETTINGS = values.DictValue(
|
||||||
|
{}, environ_name="ANALYTICS_BACKEND_SETTINGS", environ_prefix=None
|
||||||
|
)
|
||||||
|
|
||||||
# Marketing and communication settings
|
# Marketing and communication settings
|
||||||
SIGNUP_NEW_USER_TO_MARKETING_EMAIL = values.BooleanValue(
|
SIGNUP_NEW_USER_TO_MARKETING_EMAIL = values.BooleanValue(
|
||||||
False, # When enabled, new users are automatically added to mailing list.
|
False, # When enabled, new users are automatically added to mailing list.
|
||||||
@@ -1088,6 +1168,70 @@ class Base(Configuration):
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _check_recording_encoding_maps(cls):
|
||||||
|
"""Ensure the per-recording encoding maps are mutually consistent.
|
||||||
|
|
||||||
|
Every profile in RECORDING_ENCODING_AVAILABLE_PROFILES must define a bitrate for
|
||||||
|
each resolution declared in RECORDING_ENCODING_AVAILABLE_RESOLUTIONS.
|
||||||
|
|
||||||
|
The default profile / resolution feed the default encoding. When either is
|
||||||
|
missing, no custom default encoding can be built: a warning is emitted and
|
||||||
|
recordings fall back to LiveKit's built-in preset. When both are set, they
|
||||||
|
must reference keys that actually exist in the maps above.
|
||||||
|
"""
|
||||||
|
resolutions = set(cls.RECORDING_ENCODING_AVAILABLE_RESOLUTIONS)
|
||||||
|
profiles = set(cls.RECORDING_ENCODING_AVAILABLE_PROFILES)
|
||||||
|
# DictValue resolves to a dict at runtime; pylint sees the descriptor.
|
||||||
|
for (
|
||||||
|
profile,
|
||||||
|
profile_config,
|
||||||
|
) in cls.RECORDING_ENCODING_AVAILABLE_PROFILES.items(): # pylint: disable=no-member
|
||||||
|
profile_resolutions = set(profile_config["kbps"])
|
||||||
|
if profile_resolutions != resolutions:
|
||||||
|
raise ValueError(
|
||||||
|
f"Profile '{profile}' in RECORDING_ENCODING_AVAILABLE_PROFILES must "
|
||||||
|
"define a bitrate for exactly the resolutions in "
|
||||||
|
"RECORDING_ENCODING_AVAILABLE_RESOLUTIONS, mismatch on: "
|
||||||
|
f"{resolutions ^ profile_resolutions}"
|
||||||
|
)
|
||||||
|
|
||||||
|
missing = [
|
||||||
|
name
|
||||||
|
for name, value in (
|
||||||
|
(
|
||||||
|
"RECORDING_ENCODING_DEFAULT_RESOLUTION",
|
||||||
|
cls.RECORDING_ENCODING_DEFAULT_RESOLUTION,
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"RECORDING_ENCODING_DEFAULT_PROFILE",
|
||||||
|
cls.RECORDING_ENCODING_DEFAULT_PROFILE,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
if not value
|
||||||
|
]
|
||||||
|
if missing:
|
||||||
|
warnings.warn(
|
||||||
|
f"{' and '.join(missing)} not set; recordings will use LiveKit's "
|
||||||
|
"built-in encoding preset instead of a custom default encoding.",
|
||||||
|
UserWarning,
|
||||||
|
stacklevel=2,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
if cls.RECORDING_ENCODING_DEFAULT_RESOLUTION not in resolutions:
|
||||||
|
raise ValueError(
|
||||||
|
"RECORDING_ENCODING_DEFAULT_RESOLUTION "
|
||||||
|
f"'{cls.RECORDING_ENCODING_DEFAULT_RESOLUTION}' is not a key of "
|
||||||
|
f"RECORDING_ENCODING_AVAILABLE_RESOLUTIONS ({sorted(resolutions)})."
|
||||||
|
)
|
||||||
|
if cls.RECORDING_ENCODING_DEFAULT_PROFILE not in profiles:
|
||||||
|
raise ValueError(
|
||||||
|
"RECORDING_ENCODING_DEFAULT_PROFILE "
|
||||||
|
f"'{cls.RECORDING_ENCODING_DEFAULT_PROFILE}' is not a key of "
|
||||||
|
f"RECORDING_ENCODING_AVAILABLE_PROFILES ({sorted(profiles)})."
|
||||||
|
)
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def post_setup(cls):
|
def post_setup(cls):
|
||||||
"""Post setup configuration.
|
"""Post setup configuration.
|
||||||
@@ -1101,11 +1245,27 @@ class Base(Configuration):
|
|||||||
"FILE_UPLOAD_TMP_PATH cannot be the same as FILE_UPLOAD_PATH"
|
"FILE_UPLOAD_TMP_PATH cannot be the same as FILE_UPLOAD_PATH"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
cls._check_recording_encoding_maps()
|
||||||
|
|
||||||
|
if (
|
||||||
|
cls.SUMMARY_SERVICE_VERSION == 1
|
||||||
|
and cls.SUMMARY_SERVICE_ENDPOINT is not None
|
||||||
|
):
|
||||||
|
warnings.warn(
|
||||||
|
"SUMMARY_SERVICE_VERSION=1 is deprecated. "
|
||||||
|
"The legacy v1 API has been removed from the experimental "
|
||||||
|
"Summary service. Please update your Summary service deployment to "
|
||||||
|
"the v2 API and set SUMMARY_SERVICE_VERSION=2.",
|
||||||
|
# We use UserWarning to make sure it shows up in production deployment
|
||||||
|
UserWarning,
|
||||||
|
stacklevel=2,
|
||||||
|
)
|
||||||
|
|
||||||
# The SENTRY_DSN setting should be available to activate sentry for an environment
|
# The SENTRY_DSN setting should be available to activate sentry for an environment
|
||||||
if cls.SENTRY_DSN is not None:
|
if cls.SENTRY_DSN is not None:
|
||||||
sentry_sdk.init(
|
sentry_sdk.init(
|
||||||
dsn=cls.SENTRY_DSN,
|
dsn=cls.SENTRY_DSN,
|
||||||
environment=cls.__name__.lower(),
|
environment=cls.__name__.lower(), # build, test, development, production
|
||||||
release=get_release(),
|
release=get_release(),
|
||||||
integrations=[DjangoIntegration()],
|
integrations=[DjangoIntegration()],
|
||||||
)
|
)
|
||||||
|
|||||||
+14
-13
@@ -7,7 +7,7 @@ build-backend = "uv_build"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "meet"
|
name = "meet"
|
||||||
version = "1.21.0"
|
version = "1.23.0"
|
||||||
authors = [{ "name" = "DINUM", "email" = "dev@mail.numerique.gouv.fr" }]
|
authors = [{ "name" = "DINUM", "email" = "dev@mail.numerique.gouv.fr" }]
|
||||||
classifiers = [
|
classifiers = [
|
||||||
"Development Status :: 5 - Production/Stable",
|
"Development Status :: 5 - Production/Stable",
|
||||||
@@ -24,19 +24,19 @@ keywords = ["Django", "Contacts", "Templates", "RBAC"]
|
|||||||
license = "MIT"
|
license = "MIT"
|
||||||
requires-python = ">=3.13"
|
requires-python = ">=3.13"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"boto3==1.43.14",
|
"boto3==1.43.36",
|
||||||
"Brotli==1.2.0",
|
"Brotli==1.2.0",
|
||||||
"brevo-python==1.2.0",
|
"brevo-python==1.2.0",
|
||||||
"celery[redis]==5.6.3",
|
"celery[redis]==5.6.3",
|
||||||
"dj-database-url==3.1.2",
|
"dj-database-url==3.1.2",
|
||||||
"django-configurations==2.5.1",
|
"django-configurations==2.5.1",
|
||||||
"django-cors-headers==4.9.0",
|
"django-cors-headers==4.9.0",
|
||||||
"django-countries==8.2.0",
|
"django-countries==9.0.0",
|
||||||
"django-filter==25.2",
|
"django-filter==25.2",
|
||||||
"django-lasuite[all]==0.0.26",
|
"django-lasuite[all]==0.0.27",
|
||||||
"django-parler==2.4",
|
"django-parler==2.4",
|
||||||
"redis==5.2.1",
|
"redis==5.2.1",
|
||||||
"django-redis==6.0.0",
|
"django-redis==7.0.0",
|
||||||
"django-storages[s3]==1.14.6",
|
"django-storages[s3]==1.14.6",
|
||||||
"django-timezone-field>=5.1",
|
"django-timezone-field>=5.1",
|
||||||
"django-pydantic-field==0.5.4",
|
"django-pydantic-field==0.5.4",
|
||||||
@@ -50,19 +50,20 @@ dependencies = [
|
|||||||
"jsonschema==4.26.0",
|
"jsonschema==4.26.0",
|
||||||
"markdown==3.10.2",
|
"markdown==3.10.2",
|
||||||
"nested-multipart-parser==1.6.0",
|
"nested-multipart-parser==1.6.0",
|
||||||
|
"posthog==7.16.1",
|
||||||
"psycopg[binary]==3.3.4",
|
"psycopg[binary]==3.3.4",
|
||||||
"pydantic==2.13.4",
|
"pydantic==2.13.4",
|
||||||
"PyJWT==2.13.0",
|
"PyJWT==2.13.0",
|
||||||
"python-frontmatter==1.3.0",
|
"python-frontmatter==1.3.0",
|
||||||
"python-magic==0.4.27",
|
"python-magic==0.4.27",
|
||||||
"requests==2.34.2",
|
"requests==2.34.2",
|
||||||
"sentry-sdk==2.60.0",
|
"sentry-sdk==2.63.0",
|
||||||
"whitenoise==6.12.0",
|
"whitenoise==6.12.0",
|
||||||
"mozilla-django-oidc==5.0.2",
|
"mozilla-django-oidc==5.0.2",
|
||||||
"livekit-api==1.1.0",
|
"livekit-api==1.1.1",
|
||||||
"aiohttp==3.14.0",
|
"aiohttp==3.14.1",
|
||||||
"urllib3==2.7.0",
|
"urllib3==2.7.0",
|
||||||
"phonenumbers==9.0.31",
|
"phonenumbers==9.0.33",
|
||||||
]
|
]
|
||||||
|
|
||||||
[project.urls]
|
[project.urls]
|
||||||
@@ -74,20 +75,20 @@ dependencies = [
|
|||||||
[dependency-groups]
|
[dependency-groups]
|
||||||
dev = [
|
dev = [
|
||||||
"django-extensions==4.1",
|
"django-extensions==4.1",
|
||||||
"drf-spectacular-sidecar==2026.5.1",
|
"drf-spectacular-sidecar==2026.6.1",
|
||||||
"freezegun==1.5.5",
|
"freezegun==1.5.5",
|
||||||
"ipdb==0.13.13",
|
"ipdb==0.13.13",
|
||||||
"ipython==9.13.0",
|
"ipython==9.14.1",
|
||||||
"pyfakefs==6.2.0",
|
"pyfakefs==6.2.0",
|
||||||
"pylint-django==2.7.0",
|
"pylint-django==2.7.0",
|
||||||
"pylint<4.0.0",
|
"pylint<4.0.0",
|
||||||
"pytest-cov==7.1.0",
|
"pytest-cov==7.1.0",
|
||||||
"pytest-django==4.12.0",
|
"pytest-django==4.12.0",
|
||||||
"pytest==9.0.3",
|
"pytest==9.1.1",
|
||||||
"pytest-icdiff==0.9",
|
"pytest-icdiff==0.9",
|
||||||
"pytest-xdist==3.8.0",
|
"pytest-xdist==3.8.0",
|
||||||
"responses==0.26.1",
|
"responses==0.26.1",
|
||||||
"ruff==0.15.14",
|
"ruff==0.15.19",
|
||||||
"types-requests==2.33.0.20260518",
|
"types-requests==2.33.0.20260518",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
Generated
+584
-561
File diff suppressed because it is too large
Load Diff
@@ -1,4 +1,4 @@
|
|||||||
FROM node:20-alpine AS frontend-deps
|
FROM node:22-alpine AS frontend-deps
|
||||||
|
|
||||||
USER node
|
USER node
|
||||||
|
|
||||||
@@ -39,7 +39,7 @@ ENV VITE_API_BASE_URL=${VITE_API_BASE_URL}
|
|||||||
RUN npm run build
|
RUN npm run build
|
||||||
|
|
||||||
# ---- Front-end image ----
|
# ---- Front-end image ----
|
||||||
FROM nginxinc/nginx-unprivileged:alpine3.23 AS frontend-production
|
FROM nginxinc/nginx-unprivileged:1.30.3-alpine3.23 AS frontend-production
|
||||||
|
|
||||||
USER root
|
USER root
|
||||||
|
|
||||||
|
|||||||
Generated
+263
-2045
File diff suppressed because it is too large
Load Diff
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "meet",
|
"name": "meet",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "1.21.0",
|
"version": "1.23.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "panda codegen && vite",
|
"dev": "panda codegen && vite",
|
||||||
@@ -17,31 +17,33 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@fontsource-variable/atkinson-hyperlegible-next": "5.2.6",
|
"@fontsource-variable/atkinson-hyperlegible-next": "5.2.6",
|
||||||
"@fontsource-variable/lexend": "5.2.11",
|
"@fontsource-variable/lexend": "5.2.11",
|
||||||
"@libreaudio/la-call": "0.1.4",
|
|
||||||
"@fontsource/opendyslexic": "5.2.5",
|
"@fontsource/opendyslexic": "5.2.5",
|
||||||
|
"@libreaudio/la-call": "0.1.4",
|
||||||
"@livekit/components-react": "2.9.21",
|
"@livekit/components-react": "2.9.21",
|
||||||
"@livekit/components-styles": "1.2.0",
|
"@livekit/components-styles": "1.2.0",
|
||||||
"@livekit/track-processors": "0.7.2",
|
"@livekit/track-processors": "0.7.2",
|
||||||
"@pandacss/preset-panda": "1.11.1",
|
"@mediapipe/tasks-vision": "0.10.14",
|
||||||
"@react-aria/toast": "3.0.10",
|
"@pandacss/preset-panda": "1.11.3",
|
||||||
"@react-types/overlays": "3.10.0",
|
"@react-types/overlays": "3.10.0",
|
||||||
"@remixicon/react": "4.9.0",
|
"@remixicon/react": "4.9.0",
|
||||||
"@tanstack/react-query": "5.100.14",
|
"@tanstack/react-query": "5.101.0",
|
||||||
"@timephy/rnnoise-wasm": "1.0.0",
|
"@timephy/rnnoise-wasm": "1.0.0",
|
||||||
"crisp-sdk-web": "1.1.2",
|
"crisp-sdk-web": "1.1.2",
|
||||||
"derive-valtio": "0.2.0",
|
"derive-valtio": "0.2.0",
|
||||||
"hoofd": "1.7.3",
|
"hoofd": "1.7.3",
|
||||||
"humanize-duration": "3.33.2",
|
"humanize-duration": "3.33.2",
|
||||||
"i18next": "26.2.0",
|
"i18next": "26.3.1",
|
||||||
"i18next-browser-languagedetector": "8.2.1",
|
"i18next-browser-languagedetector": "8.2.1",
|
||||||
"i18next-parser": "9.4.0",
|
"i18next-parser": "9.4.0",
|
||||||
"i18next-resources-to-backend": "1.2.1",
|
"i18next-resources-to-backend": "1.2.1",
|
||||||
"livekit-client": "2.19.0",
|
"livekit-client": "2.19.2",
|
||||||
"posthog-js": "1.376.0",
|
"posthog-js": "1.391.2",
|
||||||
"react": "18.3.1",
|
"react": "18.3.1",
|
||||||
"react-aria-components": "1.14.0",
|
"react-aria": "3.50.0",
|
||||||
|
"react-aria-components": "1.19.0",
|
||||||
"react-dom": "18.3.1",
|
"react-dom": "18.3.1",
|
||||||
"react-i18next": "17.0.8",
|
"react-i18next": "17.0.8",
|
||||||
|
"react-stately": "3.48.0",
|
||||||
"use-sound": "5.0.0",
|
"use-sound": "5.0.0",
|
||||||
"valtio": "2.3.2",
|
"valtio": "2.3.2",
|
||||||
"wouter": "3.10.0"
|
"wouter": "3.10.0"
|
||||||
@@ -52,6 +54,7 @@
|
|||||||
"@tanstack/eslint-plugin-query": "5.100.14",
|
"@tanstack/eslint-plugin-query": "5.100.14",
|
||||||
"@tanstack/react-query-devtools": "5.100.14",
|
"@tanstack/react-query-devtools": "5.100.14",
|
||||||
"@types/humanize-duration": "3.27.4",
|
"@types/humanize-duration": "3.27.4",
|
||||||
|
"@types/node": "24.12.4",
|
||||||
"@types/react": "18.3.12",
|
"@types/react": "18.3.12",
|
||||||
"@types/react-dom": "18.3.1",
|
"@types/react-dom": "18.3.1",
|
||||||
"@vitejs/plugin-react": "6.0.2",
|
"@vitejs/plugin-react": "6.0.2",
|
||||||
@@ -68,6 +71,7 @@
|
|||||||
"typescript": "6.0.3",
|
"typescript": "6.0.3",
|
||||||
"typescript-eslint": "8.60.1",
|
"typescript-eslint": "8.60.1",
|
||||||
"vite": "8.0.14",
|
"vite": "8.0.14",
|
||||||
|
"vite-plugin-static-copy": "4.1.1",
|
||||||
"vite-plugin-svgr": "5.2.0"
|
"vite-plugin-svgr": "5.2.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
@@ -9,6 +9,7 @@ export interface ApiConfig {
|
|||||||
analytics?: {
|
analytics?: {
|
||||||
id: string
|
id: string
|
||||||
host: string
|
host: string
|
||||||
|
flags_api_host?: string
|
||||||
}
|
}
|
||||||
support?: {
|
support?: {
|
||||||
id: string
|
id: string
|
||||||
@@ -57,6 +58,7 @@ export interface ApiConfig {
|
|||||||
transcription_destination?: string
|
transcription_destination?: string
|
||||||
max_participants_for_sound: number
|
max_participants_for_sound: number
|
||||||
auto_mute_on_join_threshold: number
|
auto_mute_on_join_threshold: number
|
||||||
|
authenticated_users_can_edit_display_name: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
const fetchConfig = (): Promise<ApiConfig> => {
|
const fetchConfig = (): Promise<ApiConfig> => {
|
||||||
|
|||||||
@@ -28,10 +28,16 @@ export const terminateAnalyticsSession = async () => {
|
|||||||
export type useAnalyticsProps = {
|
export type useAnalyticsProps = {
|
||||||
id?: string
|
id?: string
|
||||||
host?: string
|
host?: string
|
||||||
|
flags_api_host?: string
|
||||||
isDisabled?: boolean
|
isDisabled?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export const useAnalytics = ({ id, host, isDisabled }: useAnalyticsProps) => {
|
export const useAnalytics = ({
|
||||||
|
id,
|
||||||
|
host,
|
||||||
|
flags_api_host,
|
||||||
|
isDisabled,
|
||||||
|
}: useAnalyticsProps) => {
|
||||||
const [location] = useLocation()
|
const [location] = useLocation()
|
||||||
const { user } = useUser()
|
const { user } = useUser()
|
||||||
|
|
||||||
@@ -39,9 +45,13 @@ export const useAnalytics = ({ id, host, isDisabled }: useAnalyticsProps) => {
|
|||||||
if (!id || !host || isDisabled) return
|
if (!id || !host || isDisabled) return
|
||||||
getPosthog().then((ph) => {
|
getPosthog().then((ph) => {
|
||||||
if (ph.__loaded) return
|
if (ph.__loaded) return
|
||||||
ph.init(id, { api_host: host, person_profiles: 'always' })
|
ph.init(id, {
|
||||||
|
api_host: host,
|
||||||
|
flags_api_host: flags_api_host,
|
||||||
|
person_profiles: 'always',
|
||||||
|
})
|
||||||
})
|
})
|
||||||
}, [id, host, isDisabled])
|
}, [id, host, flags_api_host, isDisabled])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!user) return
|
if (!user) return
|
||||||
|
|||||||
@@ -9,10 +9,11 @@ import { useState } from 'react'
|
|||||||
|
|
||||||
import { menuRecipe } from '@/primitives/menuRecipe'
|
import { menuRecipe } from '@/primitives/menuRecipe'
|
||||||
import { ApiRoom } from '@/features/rooms/api/ApiRoom'
|
import { ApiRoom } from '@/features/rooms/api/ApiRoom'
|
||||||
import { loadUserChoices } from '@livekit/components-core'
|
import { useSnapshot } from 'valtio'
|
||||||
|
import { userStore } from '@/stores/user'
|
||||||
|
|
||||||
export const CreateMeetingMenu = () => {
|
export const CreateMeetingMenu = () => {
|
||||||
const { username } = loadUserChoices()
|
const { username } = useSnapshot(userStore)
|
||||||
|
|
||||||
const { t } = useTranslation('home')
|
const { t } = useTranslation('home')
|
||||||
const { mutateAsync: createRoom } = useCreateRoom()
|
const { mutateAsync: createRoom } = useCreateRoom()
|
||||||
|
|||||||
@@ -44,7 +44,8 @@ export function PaginationControl({
|
|||||||
if (totalPageCount <= 1) return null
|
if (totalPageCount <= 1) return null
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<nav
|
||||||
|
aria-label={t('label')}
|
||||||
className={css({
|
className={css({
|
||||||
position: 'absolute',
|
position: 'absolute',
|
||||||
bottom: '1rem',
|
bottom: '1rem',
|
||||||
@@ -74,7 +75,7 @@ export function PaginationControl({
|
|||||||
<RiArrowLeftSLine />
|
<RiArrowLeftSLine />
|
||||||
</Button>
|
</Button>
|
||||||
<span
|
<span
|
||||||
aria-live="polite"
|
role="status"
|
||||||
className={css({
|
className={css({
|
||||||
padding: '0.25rem 0.5rem',
|
padding: '0.25rem 0.5rem',
|
||||||
})}
|
})}
|
||||||
@@ -93,6 +94,6 @@ export function PaginationControl({
|
|||||||
>
|
>
|
||||||
<RiArrowRightSLine />
|
<RiArrowRightSLine />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</nav>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
import { useToast } from '@react-aria/toast'
|
import { useToast } from 'react-aria'
|
||||||
import { Button } from '@/primitives'
|
import { Button } from '@/primitives'
|
||||||
import { RiCloseLine } from '@remixicon/react'
|
import { RiCloseLine } from '@remixicon/react'
|
||||||
import { useRef } from 'react'
|
import { useRef } from 'react'
|
||||||
import type { ToastState } from '@react-stately/toast'
|
import type { ToastState } from 'react-stately'
|
||||||
import type { ToastData } from './ToastProvider'
|
import type { ToastData } from './ToastProvider'
|
||||||
import type { QueuedToast } from '@react-stately/toast'
|
import type { QueuedToast } from 'react-stately'
|
||||||
import { StyledToastContainer } from './StyledToastContainer'
|
import { StyledToastContainer } from './StyledToastContainer'
|
||||||
import { StyledToast } from './StyledToast'
|
import { StyledToast } from './StyledToast'
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useToast } from '@react-aria/toast'
|
import { useToast } from 'react-aria'
|
||||||
import { useMemo, useRef } from 'react'
|
import { useMemo, useRef } from 'react'
|
||||||
|
|
||||||
import type { ToastProps } from './Toast'
|
import type { ToastProps } from './Toast'
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useToast } from '@react-aria/toast'
|
import { useToast } from 'react-aria'
|
||||||
import { useRef } from 'react'
|
import { useRef } from 'react'
|
||||||
|
|
||||||
import { type ToastProps } from './Toast'
|
import { type ToastProps } from './Toast'
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useToast } from '@react-aria/toast'
|
import { useToast } from 'react-aria'
|
||||||
import { useRef } from 'react'
|
import { useRef } from 'react'
|
||||||
import { Button as RACButton } from 'react-aria-components'
|
import { Button as RACButton } from 'react-aria-components'
|
||||||
import { Track } from 'livekit-client'
|
import { Track } from 'livekit-client'
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useToast } from '@react-aria/toast'
|
import { useToast } from 'react-aria'
|
||||||
import { useRef } from 'react'
|
import { useRef } from 'react'
|
||||||
|
|
||||||
import { type ToastProps } from './Toast'
|
import { type ToastProps } from './Toast'
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useToast } from '@react-aria/toast'
|
import { useToast } from 'react-aria'
|
||||||
import { useEffect, useRef } from 'react'
|
import { useEffect, useRef } from 'react'
|
||||||
|
|
||||||
import { type ToastProps } from './Toast'
|
import { type ToastProps } from './Toast'
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useToast } from '@react-aria/toast'
|
import { useToast } from 'react-aria'
|
||||||
import { useRef } from 'react'
|
import { useRef } from 'react'
|
||||||
|
|
||||||
import { type ToastProps } from './Toast'
|
import { type ToastProps } from './Toast'
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useToast } from '@react-aria/toast'
|
import { useToast } from 'react-aria'
|
||||||
import { useMemo, useRef } from 'react'
|
import { useMemo, useRef } from 'react'
|
||||||
|
|
||||||
import { type ToastProps } from './Toast'
|
import { type ToastProps } from './Toast'
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
/* eslint-disable react-refresh/only-export-components */
|
/* eslint-disable react-refresh/only-export-components */
|
||||||
import { ToastQueue, useToastQueue } from '@react-stately/toast'
|
import { ToastQueue, useToastQueue } from 'react-stately'
|
||||||
import { ToastRegion } from './ToastRegion'
|
import { ToastRegion } from './ToastRegion'
|
||||||
import { Participant } from 'livekit-client'
|
import { Participant } from 'livekit-client'
|
||||||
import type { NotificationType } from '../NotificationType'
|
import type { NotificationType } from '../NotificationType'
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useToast } from '@react-aria/toast'
|
import { useToast } from 'react-aria'
|
||||||
import { useRef } from 'react'
|
import { useRef } from 'react'
|
||||||
|
|
||||||
import { type ToastProps } from './Toast'
|
import { type ToastProps } from './Toast'
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useToast } from '@react-aria/toast'
|
import { useToast } from 'react-aria'
|
||||||
import { useMemo, useRef } from 'react'
|
import { useMemo, useRef } from 'react'
|
||||||
|
|
||||||
import { type ToastProps } from './Toast'
|
import { type ToastProps } from './Toast'
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useToast } from '@react-aria/toast'
|
import { useToast } from 'react-aria'
|
||||||
import { useMemo, useRef } from 'react'
|
import { useMemo, useRef } from 'react'
|
||||||
import { Text } from '@/primitives'
|
import { Text } from '@/primitives'
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { AriaToastRegionProps, useToastRegion } from '@react-aria/toast'
|
import { AriaToastRegionProps, useToastRegion } from 'react-aria'
|
||||||
import type { QueuedToast, ToastState } from '@react-stately/toast'
|
import type { QueuedToast, ToastState } from 'react-stately'
|
||||||
import { Toast } from './Toast'
|
import { Toast } from './Toast'
|
||||||
import { useRef } from 'react'
|
import { useRef } from 'react'
|
||||||
import { NotificationType } from '../NotificationType'
|
import { NotificationType } from '../NotificationType'
|
||||||
|
|||||||
@@ -68,7 +68,7 @@ export const PipView = () => {
|
|||||||
</ConnectionStateWrapper>
|
</ConnectionStateWrapper>
|
||||||
<PipStage />
|
<PipStage />
|
||||||
<ReactionsToolbar adjustedCentering={false} />
|
<ReactionsToolbar adjustedCentering={false} />
|
||||||
<PipControlBar showScreenShare={false} />
|
<PipControlBar showScreenShare={true} />
|
||||||
<PipFloatingReactions />
|
<PipFloatingReactions />
|
||||||
<NotificationProvider bottom={30} />
|
<NotificationProvider bottom={30} />
|
||||||
</Container>
|
</Container>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useEffect, useRef, useState } from 'react'
|
import { useEffect, useRef, useState } from 'react'
|
||||||
import { RiMoreFill } from '@remixicon/react'
|
import { RiMoreFill } from '@remixicon/react'
|
||||||
import { FocusScope } from '@react-aria/focus'
|
import { FocusScope } from 'react-aria'
|
||||||
import { Box, Button } from '@/primitives'
|
import { Box, Button } from '@/primitives'
|
||||||
import { css } from '@/styled-system/css'
|
import { css } from '@/styled-system/css'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ export const PipFocusLayout = memo(
|
|||||||
<ParticipantTile
|
<ParticipantTile
|
||||||
key={getTrackKey(mainTrack)}
|
key={getTrackKey(mainTrack)}
|
||||||
trackRef={mainTrack}
|
trackRef={mainTrack}
|
||||||
|
disableTileControls
|
||||||
/>
|
/>
|
||||||
</MainSlot>
|
</MainSlot>
|
||||||
)}
|
)}
|
||||||
@@ -34,6 +35,7 @@ export const PipFocusLayout = memo(
|
|||||||
<ParticipantTile
|
<ParticipantTile
|
||||||
key={getTrackKey(thumbnailTrack)}
|
key={getTrackKey(thumbnailTrack)}
|
||||||
trackRef={thumbnailTrack}
|
trackRef={thumbnailTrack}
|
||||||
|
disableTileControls
|
||||||
/>
|
/>
|
||||||
</Thumbnail>
|
</Thumbnail>
|
||||||
)}
|
)}
|
||||||
@@ -48,9 +50,10 @@ const FocusContainer = styled('div', {
|
|||||||
position: 'relative',
|
position: 'relative',
|
||||||
width: '100%',
|
width: '100%',
|
||||||
height: '100%',
|
height: '100%',
|
||||||
borderRadius: '4px',
|
borderRadius: '8px',
|
||||||
overflow: 'hidden',
|
overflow: 'hidden',
|
||||||
backgroundColor: 'primaryDark.100',
|
backgroundColor: 'primaryDark.100',
|
||||||
|
boxSizing: 'border-box',
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -58,6 +61,8 @@ const MainSlot = styled('div', {
|
|||||||
base: {
|
base: {
|
||||||
width: '100%',
|
width: '100%',
|
||||||
height: '100%',
|
height: '100%',
|
||||||
|
borderRadius: '8px',
|
||||||
|
overflow: 'hidden',
|
||||||
'& .lk-participant-media-video': {
|
'& .lk-participant-media-video': {
|
||||||
objectFit: 'contain',
|
objectFit: 'contain',
|
||||||
},
|
},
|
||||||
@@ -67,13 +72,13 @@ const MainSlot = styled('div', {
|
|||||||
const Thumbnail = styled('div', {
|
const Thumbnail = styled('div', {
|
||||||
base: {
|
base: {
|
||||||
position: 'absolute',
|
position: 'absolute',
|
||||||
right: '1rem',
|
right: '1.25rem',
|
||||||
bottom: '1rem',
|
bottom: '1.25rem',
|
||||||
width: '42%',
|
width: '42%',
|
||||||
maxWidth: '220px',
|
maxWidth: '220px',
|
||||||
minWidth: '140px',
|
minWidth: '140px',
|
||||||
aspectRatio: '16 / 9',
|
aspectRatio: '16 / 9',
|
||||||
borderRadius: '4px',
|
borderRadius: '8px',
|
||||||
overflow: 'hidden',
|
overflow: 'hidden',
|
||||||
boxShadow: 'md',
|
boxShadow: 'md',
|
||||||
zIndex: 2,
|
zIndex: 2,
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ export const PipGridLayout = memo(({ tracks }: PipGridLayoutProps) => {
|
|||||||
<GridContainer ref={containerRef} style={gridStyle}>
|
<GridContainer ref={containerRef} style={gridStyle}>
|
||||||
{tracks.map((track, index) => (
|
{tracks.map((track, index) => (
|
||||||
<GridCell key={getTrackKey(track)} style={placements[index]}>
|
<GridCell key={getTrackKey(track)} style={placements[index]}>
|
||||||
<ParticipantTile trackRef={track} />
|
<ParticipantTile trackRef={track} disableTileControls />
|
||||||
</GridCell>
|
</GridCell>
|
||||||
))}
|
))}
|
||||||
</GridContainer>
|
</GridContainer>
|
||||||
@@ -54,7 +54,8 @@ const GridContainer = styled('div', {
|
|||||||
width: '100%',
|
width: '100%',
|
||||||
height: '100%',
|
height: '100%',
|
||||||
display: 'grid',
|
display: 'grid',
|
||||||
gap: '0.25rem',
|
gap: '0.5rem',
|
||||||
|
boxSizing: 'border-box',
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -63,7 +64,7 @@ const GridCell = styled('div', {
|
|||||||
position: 'relative',
|
position: 'relative',
|
||||||
minWidth: 0,
|
minWidth: 0,
|
||||||
minHeight: 0,
|
minHeight: 0,
|
||||||
borderRadius: '4px',
|
borderRadius: '8px',
|
||||||
overflow: 'hidden',
|
overflow: 'hidden',
|
||||||
backgroundColor: 'primaryDark.100',
|
backgroundColor: 'primaryDark.100',
|
||||||
// Paint on own layer so FLIP transforms don't trigger layout thrash.
|
// Paint on own layer so FLIP transforms don't trigger layout thrash.
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user