Compare commits

..

1 Commits

Author SHA1 Message Date
lebaudantoine 8972c8dbb8 wip allow authenticated user to launch recording/transcription 2026-08-05 17:27:32 +02:00
237 changed files with 4833 additions and 13327 deletions
+2 -2
View File
@@ -13,10 +13,10 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 uses: actions/checkout@v6
- name: Download Crowdin files - name: Download Crowdin files
uses: crowdin/github-action@c7af9bc98b01694653031fef2a0dc6c7888ce9bc # v2.17.0 uses: crowdin/github-action@v2
with: with:
upload_sources: false upload_sources: false
upload_translations: false upload_translations: false
+36 -36
View File
@@ -30,36 +30,36 @@ jobs:
steps: steps:
- -
name: Checkout repository name: Checkout repository
uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 uses: actions/checkout@v6
- -
name: Set up QEMU name: Set up QEMU
if: env.IS_MULTI_PLATFORM_BUILD == 'true' if: env.IS_MULTI_PLATFORM_BUILD == 'true'
uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 # v3.7.0 uses: docker/setup-qemu-action@v3
- -
name: Set up Docker Buildx name: Set up Docker Buildx
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0 uses: docker/setup-buildx-action@v3
- -
name: Docker meta name: Docker meta
id: meta id: meta
uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5.10.0 uses: docker/metadata-action@v5
with: with:
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' || startsWith(github.head_ref, 'integration/') if: github.event_name != 'pull_request' || startsWith(github.head_ref, 'integration/')
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0 uses: docker/login-action@v3
with: with:
username: ${{ secrets.DOCKER_HUB_USER }} username: ${{ secrets.DOCKER_HUB_USER }}
password: ${{ secrets.DOCKER_HUB_PASSWORD }} password: ${{ secrets.DOCKER_HUB_PASSWORD }}
- -
name: Run trivy scan name: Run trivy scan
uses: numerique-gouv/action-trivy-cache@d6e94cfb488f03a0b3e8b8739aad94e74d24d8da # main uses: numerique-gouv/action-trivy-cache@main
with: with:
docker-build-args: '--target backend-production -f Dockerfile' docker-build-args: '--target backend-production -f Dockerfile'
docker-image-name: '${{ env.DOCKER_CONTAINER_REGISTRY_HOSTNAME }}/${{ env.DOCKER_CONTAINER_REGISTRY_NAMESPACE }}/meet-backend:${{ github.sha }}' docker-image-name: '${{ env.DOCKER_CONTAINER_REGISTRY_HOSTNAME }}/${{ env.DOCKER_CONTAINER_REGISTRY_NAMESPACE }}/meet-backend:${{ github.sha }}'
- -
name: Build and push name: Build and push
uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6.19.2 uses: docker/build-push-action@v6
with: with:
context: . context: .
target: backend-production target: backend-production
@@ -76,36 +76,36 @@ jobs:
steps: steps:
- -
name: Checkout repository name: Checkout repository
uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 uses: actions/checkout@v6
- -
name: Set up QEMU name: Set up QEMU
if: env.IS_MULTI_PLATFORM_BUILD == 'true' if: env.IS_MULTI_PLATFORM_BUILD == 'true'
uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 # v3.7.0 uses: docker/setup-qemu-action@v3
- -
name: Set up Docker Buildx name: Set up Docker Buildx
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0 uses: docker/setup-buildx-action@v3
- -
name: Docker meta name: Docker meta
id: meta id: meta
uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5.10.0 uses: docker/metadata-action@v5
with: with:
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' || startsWith(github.head_ref, 'integration/') if: github.event_name != 'pull_request' || startsWith(github.head_ref, 'integration/')
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0 uses: docker/login-action@v3
with: with:
username: ${{ secrets.DOCKER_HUB_USER }} username: ${{ secrets.DOCKER_HUB_USER }}
password: ${{ secrets.DOCKER_HUB_PASSWORD }} password: ${{ secrets.DOCKER_HUB_PASSWORD }}
- -
name: Run trivy scan name: Run trivy scan
uses: numerique-gouv/action-trivy-cache@d6e94cfb488f03a0b3e8b8739aad94e74d24d8da # main uses: numerique-gouv/action-trivy-cache@main
with: with:
docker-build-args: '-f src/frontend/Dockerfile --target frontend-production' docker-build-args: '-f src/frontend/Dockerfile --target frontend-production'
docker-image-name: '${{ env.DOCKER_CONTAINER_REGISTRY_HOSTNAME }}/${{ env.DOCKER_CONTAINER_REGISTRY_NAMESPACE }}/meet-frontend:${{ github.sha }}' docker-image-name: '${{ env.DOCKER_CONTAINER_REGISTRY_HOSTNAME }}/${{ env.DOCKER_CONTAINER_REGISTRY_NAMESPACE }}/meet-frontend:${{ github.sha }}'
- -
name: Build and push name: Build and push
uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6.19.2 uses: docker/build-push-action@v6
with: with:
context: . context: .
file: ./src/frontend/Dockerfile file: ./src/frontend/Dockerfile
@@ -123,36 +123,36 @@ jobs:
steps: steps:
- -
name: Checkout repository name: Checkout repository
uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 uses: actions/checkout@v6
- -
name: Set up QEMU name: Set up QEMU
if: env.IS_MULTI_PLATFORM_BUILD == 'true' if: env.IS_MULTI_PLATFORM_BUILD == 'true'
uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 # v3.7.0 uses: docker/setup-qemu-action@v3
- -
name: Set up Docker Buildx name: Set up Docker Buildx
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0 uses: docker/setup-buildx-action@v3
- -
name: Docker meta name: Docker meta
id: meta id: meta
uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5.10.0 uses: docker/metadata-action@v5
with: with:
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' || startsWith(github.head_ref, 'integration/') if: github.event_name != 'pull_request' || startsWith(github.head_ref, 'integration/')
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0 uses: docker/login-action@v3
with: with:
username: ${{ secrets.DOCKER_HUB_USER }} username: ${{ secrets.DOCKER_HUB_USER }}
password: ${{ secrets.DOCKER_HUB_PASSWORD }} password: ${{ secrets.DOCKER_HUB_PASSWORD }}
- -
name: Run trivy scan name: Run trivy scan
uses: numerique-gouv/action-trivy-cache@d6e94cfb488f03a0b3e8b8739aad94e74d24d8da # main uses: numerique-gouv/action-trivy-cache@main
with: with:
docker-build-args: '-f docker/dinum-frontend/Dockerfile --target frontend-production' docker-build-args: '-f docker/dinum-frontend/Dockerfile --target frontend-production'
docker-image-name: '${{ env.DOCKER_CONTAINER_REGISTRY_HOSTNAME }}/${{ env.DOCKER_CONTAINER_REGISTRY_NAMESPACE }}/meet-frontend-dinum:${{ github.sha }}' docker-image-name: '${{ env.DOCKER_CONTAINER_REGISTRY_HOSTNAME }}/${{ env.DOCKER_CONTAINER_REGISTRY_NAMESPACE }}/meet-frontend-dinum:${{ github.sha }}'
- -
name: Build and push name: Build and push
uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6.19.2 uses: docker/build-push-action@v6
with: with:
context: . context: .
file: ./docker/dinum-frontend/Dockerfile file: ./docker/dinum-frontend/Dockerfile
@@ -170,30 +170,30 @@ jobs:
steps: steps:
- -
name: Checkout repository name: Checkout repository
uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 uses: actions/checkout@v6
- -
name: Set up QEMU name: Set up QEMU
if: env.IS_MULTI_PLATFORM_BUILD == 'true' if: env.IS_MULTI_PLATFORM_BUILD == 'true'
uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 # v3.7.0 uses: docker/setup-qemu-action@v3
- -
name: Set up Docker Buildx name: Set up Docker Buildx
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0 uses: docker/setup-buildx-action@v3
- -
name: Docker meta name: Docker meta
id: meta id: meta
uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5.10.0 uses: docker/metadata-action@v5
with: with:
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' || startsWith(github.head_ref, 'integration/') if: github.event_name != 'pull_request' || startsWith(github.head_ref, 'integration/')
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0 uses: docker/login-action@v3
with: with:
username: ${{ secrets.DOCKER_HUB_USER }} username: ${{ secrets.DOCKER_HUB_USER }}
password: ${{ secrets.DOCKER_HUB_PASSWORD }} password: ${{ secrets.DOCKER_HUB_PASSWORD }}
- -
name: Run trivy scan name: Run trivy scan
uses: numerique-gouv/action-trivy-cache@d6e94cfb488f03a0b3e8b8739aad94e74d24d8da # main uses: numerique-gouv/action-trivy-cache@main
continue-on-error: true continue-on-error: true
with: with:
docker-build-args: '-f src/summary/Dockerfile --target production' docker-build-args: '-f src/summary/Dockerfile --target production'
@@ -201,7 +201,7 @@ jobs:
docker-context: './src/summary' docker-context: './src/summary'
- -
name: Build and push name: Build and push
uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6.19.2 uses: docker/build-push-action@v6
with: with:
context: ./src/summary context: ./src/summary
file: ./src/summary/Dockerfile file: ./src/summary/Dockerfile
@@ -219,30 +219,30 @@ jobs:
steps: steps:
- -
name: Checkout repository name: Checkout repository
uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 uses: actions/checkout@v6
- -
name: Set up QEMU name: Set up QEMU
if: env.IS_MULTI_PLATFORM_BUILD == 'true' if: env.IS_MULTI_PLATFORM_BUILD == 'true'
uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 # v3.7.0 uses: docker/setup-qemu-action@v3
- -
name: Set up Docker Buildx name: Set up Docker Buildx
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0 uses: docker/setup-buildx-action@v3
- -
name: Docker meta name: Docker meta
id: meta id: meta
uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5.10.0 uses: docker/metadata-action@v5
with: with:
images: lasuite/meet-agents images: lasuite/meet-agents
- -
name: Login to DockerHub name: Login to DockerHub
if: github.event_name != 'pull_request' || startsWith(github.head_ref, 'integration/') if: github.event_name != 'pull_request' || startsWith(github.head_ref, 'integration/')
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0 uses: docker/login-action@v3
with: with:
username: ${{ secrets.DOCKER_HUB_USER }} username: ${{ secrets.DOCKER_HUB_USER }}
password: ${{ secrets.DOCKER_HUB_PASSWORD }} password: ${{ secrets.DOCKER_HUB_PASSWORD }}
- -
name: Run trivy scan name: Run trivy scan
uses: numerique-gouv/action-trivy-cache@d6e94cfb488f03a0b3e8b8739aad94e74d24d8da # main uses: numerique-gouv/action-trivy-cache@main
continue-on-error: true continue-on-error: true
with: with:
docker-build-args: '-f src/agents/Dockerfile --target production' docker-build-args: '-f src/agents/Dockerfile --target production'
@@ -250,7 +250,7 @@ jobs:
docker-context: './src/agents' docker-context: './src/agents'
- -
name: Build and push name: Build and push
uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6.19.2 uses: docker/build-push-action@v6
with: with:
context: ./src/agents context: ./src/agents
file: ./src/agents/Dockerfile file: ./src/agents/Dockerfile
@@ -273,7 +273,7 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
if: github.event_name != 'pull_request' if: github.event_name != 'pull_request'
steps: steps:
- uses: numerique-gouv/action-argocd-webhook-notification@cac2ee67896eb13e84e804f60c4271370424eaa8 # main - uses: numerique-gouv/action-argocd-webhook-notification@main
id: notify id: notify
with: with:
deployment_repo_path: "${{ secrets.DEPLOYMENT_REPO_URL }}" deployment_repo_path: "${{ secrets.DEPLOYMENT_REPO_URL }}"
@@ -1,4 +1,4 @@
name: CI name: meet Workflow
on: on:
push: push:
@@ -18,7 +18,7 @@ jobs:
contents: read contents: read
steps: steps:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 uses: actions/checkout@v6
with: with:
fetch-depth: 0 fetch-depth: 0
- name: show - name: show
@@ -26,17 +26,17 @@ jobs:
- name: Enforce absence of print statements in code - name: Enforce absence of print statements in code
if: always() if: always()
run: | run: |
! git diff origin/${{ github.event.pull_request.base.ref }}..HEAD -- . ':(exclude).github/workflows/**' | grep "print(" ! git diff origin/${{ github.event.pull_request.base.ref }}..HEAD -- . ':(exclude)**/meet.yml' | grep "print("
- name: Check absence of fixup commits - name: Check absence of fixup commits
if: always() if: always()
run: | run: |
! git log | grep 'fixup!' ! git log | grep 'fixup!'
- name: Install uv - name: Install gitlint
if: always() if: always()
uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7.6.0 run: pip install --user requests gitlint
- name: Lint commit messages added to main - name: Lint commit messages added to main
if: always() if: always()
run: uvx --no-build --from gitlint-core==0.19.1 gitlint --commits origin/${{ github.event.pull_request.base.ref }}..HEAD run: ~/.local/bin/gitlint --commits origin/${{ github.event.pull_request.base.ref }}..HEAD
check-changelog: check-changelog:
runs-on: ubuntu-latest runs-on: ubuntu-latest
@@ -47,7 +47,7 @@ jobs:
contents: read contents: read
steps: steps:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 uses: actions/checkout@v6
with: with:
fetch-depth: 50 fetch-depth: 50
- name: Check that the CHANGELOG has been modified in the current branch - name: Check that the CHANGELOG has been modified in the current branch
@@ -59,7 +59,7 @@ jobs:
contents: read contents: read
steps: steps:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 uses: actions/checkout@v6
- name: Check CHANGELOG max line length - name: Check CHANGELOG max line length
run: | run: |
max_line_length=$(cat CHANGELOG.md | grep -Ev "^\[.*\]: https://github.com" | wc -L) max_line_length=$(cat CHANGELOG.md | grep -Ev "^\[.*\]: https://github.com" | wc -L)
@@ -77,15 +77,15 @@ jobs:
working-directory: src/mail working-directory: src/mail
steps: steps:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 uses: actions/checkout@v6
- name: Install Node.js - name: Install Node.js
uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6.5.0 uses: actions/setup-node@v6
with: with:
node-version: "22" node-version: "22"
- name: Restore the mail templates - name: Restore the mail templates
uses: actions/cache@caa296126883cff596d87d8935842f9db880ef25 # v5.1.0 uses: actions/cache@v5
id: mail-templates id: mail-templates
with: with:
path: "src/backend/core/templates/mail" path: "src/backend/core/templates/mail"
@@ -93,11 +93,11 @@ jobs:
- name: Install yarn - name: Install yarn
if: steps.mail-templates.outputs.cache-hit != 'true' if: steps.mail-templates.outputs.cache-hit != 'true'
run: npm install -g --ignore-scripts yarn@1.22.22 run: npm install -g yarn
- name: Install node dependencies - name: Install node dependencies
if: steps.mail-templates.outputs.cache-hit != 'true' if: steps.mail-templates.outputs.cache-hit != 'true'
run: yarn install --frozen-lockfile --ignore-scripts run: yarn install --frozen-lockfile
- name: Build mails - name: Build mails
if: steps.mail-templates.outputs.cache-hit != 'true' if: steps.mail-templates.outputs.cache-hit != 'true'
@@ -105,7 +105,7 @@ jobs:
- name: Cache mail templates - name: Cache mail templates
if: steps.mail-templates.outputs.cache-hit != 'true' if: steps.mail-templates.outputs.cache-hit != 'true'
uses: actions/cache@caa296126883cff596d87d8935842f9db880ef25 # v5.1.0 uses: actions/cache@v5
with: with:
path: "src/backend/core/templates/mail" path: "src/backend/core/templates/mail"
key: mail-templates-${{ hashFiles('src/mail/mjml') }} key: mail-templates-${{ hashFiles('src/mail/mjml') }}
@@ -119,22 +119,22 @@ jobs:
working-directory: src/backend working-directory: src/backend
steps: steps:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 uses: actions/checkout@v6
- name: Install Python - name: Install Python
uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0 uses: actions/setup-python@v6
with: with:
python-version: "3.13" python-version: "3.13"
- name: Install uv - name: Install uv
uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7.6.0 uses: astral-sh/setup-uv@v7
- name: Install the project - name: Install the project
run: uv sync --locked --all-extras run: uv sync --locked --all-extras
- name: Check code formatting with ruff - name: Check code formatting with ruff
run: uv run --no-sync --no-build ruff format . --diff run: uv run ruff format . --diff
- name: Lint code with ruff - name: Lint code with ruff
run: uv run --no-sync --no-build ruff check . run: uv run ruff check .
- name: Lint code with pylint - name: Lint code with pylint
run: uv run --no-sync --no-build pylint meet demo core run: uv run pylint meet demo core
lint-agents: lint-agents:
runs-on: ubuntu-latest runs-on: ubuntu-latest
@@ -145,19 +145,19 @@ jobs:
working-directory: src/agents working-directory: src/agents
steps: steps:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 uses: actions/checkout@v6
- name: Install Python - name: Install Python
uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0 uses: actions/setup-python@v6
with: with:
python-version: "3.13" python-version: "3.13"
- name: Install uv - name: Install uv
uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7.6.0 uses: astral-sh/setup-uv@v7
- name: Install the project - name: Install the project
run: uv sync --locked --all-extras --no-build run: uv sync --locked --all-extras
- name: Check code formatting with ruff - name: Check code formatting with ruff
run: uv run --no-sync --no-build ruff format . --diff run: uv run ruff format . --diff
- name: Lint code with ruff - name: Lint code with ruff
run: uv run --no-sync --no-build ruff check . run: uv run ruff check .
lint-summary: lint-summary:
runs-on: ubuntu-latest runs-on: ubuntu-latest
@@ -168,19 +168,18 @@ jobs:
working-directory: src/summary working-directory: src/summary
steps: steps:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 uses: actions/checkout@v6
- name: Install Python - name: Install Python
uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0 uses: actions/setup-python@v6
with: with:
python-version: "3.13" python-version: "3.13"
- name: Install uv cache: "pip"
uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7.6.0 - name: Install development dependencies
- name: Install the project run: pip install --user .[dev]
run: uv sync --locked --all-extras
- name: Check code formatting with ruff - name: Check code formatting with ruff
run: uv run --no-sync --no-build ruff format . --diff run: ~/.local/bin/ruff format . --diff
- name: Lint code with ruff - name: Lint code with ruff
run: uv run --no-sync --no-build ruff check . run: ~/.local/bin/ruff check .
test-back: test-back:
runs-on: ubuntu-latest runs-on: ubuntu-latest
@@ -236,7 +235,7 @@ jobs:
steps: steps:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 uses: actions/checkout@v6
- name: Create writable /data - name: Create writable /data
run: | run: |
@@ -244,7 +243,7 @@ jobs:
sudo mkdir -p /data/static sudo mkdir -p /data/static
- name: Restore the mail templates - name: Restore the mail templates
uses: actions/cache@caa296126883cff596d87d8935842f9db880ef25 # v5.1.0 uses: actions/cache@v5
id: mail-templates id: mail-templates
with: with:
path: "src/backend/core/templates/mail" path: "src/backend/core/templates/mail"
@@ -263,9 +262,7 @@ jobs:
# Tool to wait for a service to be ready # Tool to wait for a service to be ready
- name: Install Dockerize - name: Install Dockerize
run: | run: |
curl --proto "=https" --proto-redir "=https" --tlsv1.2 -sSLf \ curl -sSL https://github.com/jwilder/dockerize/releases/download/v0.8.0/dockerize-linux-amd64-v0.8.0.tar.gz | sudo tar -C /usr/local/bin -xzv
https://github.com/jwilder/dockerize/releases/download/v0.8.0/dockerize-linux-amd64-v0.8.0.tar.gz |
sudo tar -C /usr/local/bin -xzv
- name: Wait for MinIO to be ready - name: Wait for MinIO to be ready
run: | run: |
@@ -280,11 +277,11 @@ jobs:
mc mb meet/meet-media-storage" mc mb meet/meet-media-storage"
- name: Install Python - name: Install Python
uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0 uses: actions/setup-python@v6
with: with:
python-version: "3.13" python-version: "3.13"
- name: Install uv - name: Install uv
uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7.6.0 uses: astral-sh/setup-uv@v7
- name: Install the dependencies - name: Install the dependencies
run: uv sync --locked --all-extras run: uv sync --locked --all-extras
@@ -294,10 +291,10 @@ jobs:
sudo apt-get install -y gettext sudo apt-get install -y gettext
- name: Generate a MO file from strings extracted from the project - name: Generate a MO file from strings extracted from the project
run: uv run --no-sync --no-build python manage.py compilemessages run: uv run python manage.py compilemessages
- name: Run tests - name: Run tests
run: uv run --no-sync --no-build pytest -n 2 run: uv run pytest -n 2
test-summary: test-summary:
runs-on: ubuntu-latest runs-on: ubuntu-latest
@@ -323,7 +320,7 @@ jobs:
steps: steps:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 uses: actions/checkout@v6
- name: Install ffmpeg - name: Install ffmpeg
run: | run: |
@@ -331,18 +328,16 @@ jobs:
sudo apt-get install -y ffmpeg sudo apt-get install -y ffmpeg
- name: Install Python - name: Install Python
uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0 uses: actions/setup-python@v6
with: with:
python-version: "3.13" python-version: "3.13"
cache: "pip"
- name: Install uv - name: Install development dependencies
uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7.6.0 run: pip install --user .[dev]
- name: Install the project
run: uv sync --locked --all-extras
- name: Run summary tests - name: Run summary tests
run: uv run --no-sync --no-build pytest run: ~/.local/bin/pytest
lint-front: lint-front:
runs-on: ubuntu-latest runs-on: ubuntu-latest
@@ -350,10 +345,10 @@ jobs:
contents: read contents: read
steps: steps:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 uses: actions/checkout@v6
- name: Install dependencies - name: Install dependencies
run: cd src/frontend/ && npm ci --ignore-scripts run: cd src/frontend/ && npm ci
- name: Check linting - name: Check linting
run: cd src/frontend/ && npm run lint run: cd src/frontend/ && npm run lint
@@ -370,10 +365,10 @@ jobs:
working-directory: src/sdk/library working-directory: src/sdk/library
steps: steps:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 uses: actions/checkout@v6
- name: Install dependencies - name: Install dependencies
run: npm ci --ignore-scripts run: npm ci
- name: Check linting - name: Check linting
run: npm run lint run: npm run lint
@@ -391,10 +386,10 @@ jobs:
working-directory: src/sdk/library working-directory: src/sdk/library
steps: steps:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 uses: actions/checkout@v6
- name: Install dependencies - name: Install dependencies
run: npm ci --ignore-scripts run: npm ci
- name: Build SDK - name: Build SDK
run: npm run build run: npm run build
+3 -3
View File
@@ -13,7 +13,7 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 uses: actions/checkout@v6
with: with:
fetch-depth: 0 fetch-depth: 0
@@ -21,12 +21,12 @@ jobs:
run: rm -rf ./src/helm/extra run: rm -rf ./src/helm/extra
- name: Install Helm - name: Install Helm
uses: azure/setup-helm@1a275c3b69536ee54be43f2070a358922e12c8d4 # v4.3.1 uses: azure/setup-helm@v4
env: env:
GITHUB_TOKEN: "${{ secrets.GITHUB_TOKEN }}" GITHUB_TOKEN: "${{ secrets.GITHUB_TOKEN }}"
- name: Publish Helm charts - name: Publish Helm charts
uses: numerique-gouv/helm-gh-pages@2cf477ae49d7c70037ceb1685803f4f7bad9b981 # add-overwrite-option uses: numerique-gouv/helm-gh-pages@add-overwrite-option
with: with:
charts_dir: ./src/helm charts_dir: ./src/helm
linting: on linting: on
-150
View File
@@ -10,156 +10,6 @@ and this project adheres to
### Added ### Added
- ✨(frontend) add 1080p sending resolution option #1660
- ✨(backend) add Traefik support via configurable media-auth url header #1649
### Changed
- ⬆️(dev) pin LiveKit server to v1.13.6
### Fixed
- 🐛(backend) allow any printable ASCII characters in user sub field #1673
- 🐛(frontend) keep the sending resolution picked while the camera is off #1667
## [1.30.0] - 2026-09-01
### Added
- ✨(agent) support Voxtral realtime as inference engine
- 🌐(i18n) add Spanish language support
- ✨(frontend) expose publish permissions on the media state element #1661
### Changed
- 🔥(backend) remove the S3 storage-event webhook for recordings
- ♻️(backend) always finalize recordings using the LiveKit egress_ended webhook
- ⬆️(frontend) upgrade posthog-js from 1.409.5 to 1.414.0
- ⬆️(frontend) upgrade @fontsource-variable/lexend from 5.2.11 to 5.3.0
- ⬆️(frontend) upgrade @fontsource/opendyslexic from 5.2.5 to 5.3.0
- ⬆️(addons) upgrade core-js from 3.49.0 to 3.50.0
- ♻️(backend) factorize s3 client creation in utils
- ♿️(frontend) close side panel with Escape key #1507
### Fixed
- 🐛(frontend) fix chat text-area bug
## [1.29.0] - 2026-08-25
### Added
- ✨(any) let any authenticated user manage the lobby on trusted rooms
### Changed
- 📱(frontend) collapse mobile control bar items on narrow viewports
- 📱(frontend) stack idle modal buttons in a column on mobile
- 📱(frontend) improve feedback screen responsiveness on mobile
- ⬆️(frontend) upgrade @fontsource-variable/atkinson-hyperlegible-next
- ⬆️(frontend) upgrade i18next-resources-to-backend from 1.2.1 to 1.2.3
- ⬆️(frontend) upgrade @tanstack/react-query from 5.101.1 to 5.101.4
- ⬆️(frontend) upgrade @pandacss/preset-panda from 1.11.3 to 1.12.0
- ⬆️(frontend) upgrade posthog-js from 1.404.1 to 1.409.5
- ⚡️(frontend) apply frugal constraint to the active meeting audio track
- ⚡️(backend) replace blocking Redis KEYS with cursor-based SCAN
- ✨(summary) add hostname to analytics properties
## [1.28.0] - 2026-08-24
### Added
- 📈(frontend) track errors when starting or stopping a recording
- 🚸(frontend) explain camera-in-use failures on the join screen
### Changed
- ✨(backend) accept form-urlencoded on the user token endpoint
- ✨(summary) configurable s3 region
- ⬆️(frontend) upgrade i18next and react-i18next patch versions
- ⬆️(frontend) upgrade posthog-js from 1.395.0 to 1.404.1
- ⬆️(frontend) upgrade livekit-client and @livekit/components-react
- 💄(frontend) increase the blur intensity
### Fixed
- 📝(docs) fix minor typos in comments and docstrings
- ⬆️(backend) bump sqlparse from 0.5.5 to 0.6.0
- ⬆️(mail) bump @html-to/text-cli from 0.6.0 to 0.6.1
- 🐛(frontend) treat client-initiated connect aborts as events
- 🐛(frontend) use state instead of a ref for MoreControls container
- 🐛(frontend) stop init_virtual_background from firing on blur updates
- 🐛(frontend) hoist mute confirmation dialog to VideoConference level
- 🐛(frontend) fix joined notification tile no longer rendering properly
- 🐛(frontend) handle device-in-use errors on Chrome / Windows 10
- 🐛(frontend) handle Firefox/Windows AbortError on device start
- 🐛(frontend) treat "Timeout starting source" AbortError as device-in-use
- 🔇(frontend) suppress leaked WebSocket error events from livekit-client
## [1.27.0] - 2026-08-14
### Changed
- 🔥(frontend) drop unused vendored ConnectionObserver
- 🐛(frontend) vendor formatChatMessageLinks and trim surrounding newlines
### Fixed
- 📈(frontend) downgrade unreachable external home URL from error to event
- 🐛(frontend) handle 401 responses when syncing user preferences
- 🐛(frontend) harden speaker test against missing sinks and play errors
- 🐛(frontend) implement hysteresis band for the control bar layout
- 🐛(frontend) fix toolbar ResizeObserver loop and alignment drift
- 🐛(analytics) filter benign ResizeObserver loop error in Sentry/PostHog
- 🐛(frontend) stop reporting screen-share denials as errors
- 🐛(frontend) generalize screen-share error modal beyond macOS
- 📈(frontend) stop double-reporting media device failures
## [1.26.0] - 2026-08-12
### Added
- 📈(frontend) capture media diagnostics on media errors
- ✨(frontend) add an audio gauge to the microphone select menu
- ✨(frontend) add a sound tester to the output select menu
- ✨(frontend) prompt for permissions when toggling a denied device
- ⚗️(frontend) capture console.error in PostHog
- 📈(frontend) snapshot media devices on the happy path
- 🚸(frontend) guide users when the OS blocks browser media access
- ✨(frontend) add a silent-microphone watcher on join and room screens
### Changed
- ♻️(frontend) encapsulate error tracking behind a telemetry module
- ♻️(frontend) encapsulate PostHog capture calls in the telemetry module
- 🔧(frontend) sync persisted device ids with the actual selected devices
- 💄(frontend) hide the ProConnect button on narrow viewports
- ♻️(frontend) prefer captureMediaEvent over reportError when no-op
### Fixed
- 🐛(frontend) drop exact deviceId constraint on dynamic track creation
- 🐛(frontend) fix permission store regression
- 🐛(frontend) handle missing device errors gracefully
- 🐛(frontend) display the meeting id in the join screen page title
## [1.25.2] - 2026-08-06
### Fixed
- 🐛(frontend) serve MediaPipe assets under a versioned path
- 🐛(frontend) harmonize cache configuration for MediaPipe assets
## [1.25.1] - 2026-08-06
### Fixed
- 🚑️(frontend) fix background crash from MediaPipe WASM version mismatch
## [1.25.0] - 2026-08-05
### Added
- ✨(summary) report exception type in failure analytics - ✨(summary) report exception type in failure analytics
- ✨(frontend) add configurable documentation menu item - ✨(frontend) add configurable documentation menu item
- ✨(frontend) allow promoting authenticated participants - ✨(frontend) allow promoting authenticated participants
+13 -33
View File
@@ -39,16 +39,14 @@ DB_PORT = 5432
DOCKER_UID = $(shell id -u) DOCKER_UID = $(shell id -u)
DOCKER_GID = $(shell id -g) DOCKER_GID = $(shell id -g)
DOCKER_USER = $(DOCKER_UID):$(DOCKER_GID) DOCKER_USER = $(DOCKER_UID):$(DOCKER_GID)
COMPOSE = DOCKER_USER=$(DOCKER_USER) docker compose COMPOSE = DOCKER_USER=$(DOCKER_USER) docker compose
COMPOSE_EXEC = $(COMPOSE) exec COMPOSE_EXEC = $(COMPOSE) exec
COMPOSE_EXEC_APP = $(COMPOSE_EXEC) app-dev COMPOSE_EXEC_APP = $(COMPOSE_EXEC) app-dev
COMPOSE_RUN = $(COMPOSE) run --rm COMPOSE_RUN = $(COMPOSE) run --rm
COMPOSE_RUN_APP = $(COMPOSE_RUN) app-dev COMPOSE_RUN_APP = $(COMPOSE_RUN) app-dev
COMPOSE_RUN_LINT_BACK = $(COMPOSE_RUN) --no-deps app-dev COMPOSE_RUN_LINT = $(COMPOSE_RUN) --no-deps app-dev
COMPOSE_RUN_LINT_AGENTS = $(COMPOSE_RUN) --no-deps multi-user-transcriber-dev COMPOSE_RUN_CROWDIN = $(COMPOSE_RUN) crowdin crowdin
COMPOSE_RUN_LINT_SUMMARY = $(COMPOSE_RUN) --no-deps app-summary-dev WAIT_DB = @$(COMPOSE_RUN) dockerize -wait tcp://$(DB_HOST):$(DB_PORT) -timeout 60s
COMPOSE_RUN_CROWDIN = $(COMPOSE_RUN) crowdin crowdin
WAIT_DB = @$(COMPOSE_RUN) dockerize -wait tcp://$(DB_HOST):$(DB_PORT) -timeout 60s
# -- Backend # -- Backend
MANAGE = $(COMPOSE_RUN_APP) python manage.py MANAGE = $(COMPOSE_RUN_APP) python manage.py
@@ -61,10 +59,6 @@ LINT_PYLINT = pylint meet demo core
LINT_BACK = echo 'lint:ruff-format started…' && $(LINT_RUFF_FORMAT) \ LINT_BACK = echo 'lint:ruff-format started…' && $(LINT_RUFF_FORMAT) \
&& echo 'lint:ruff-check started…' && $(LINT_RUFF_CHECK) \ && echo 'lint:ruff-check started…' && $(LINT_RUFF_CHECK) \
&& echo 'lint:pylint started…' && $(LINT_PYLINT) && echo 'lint:pylint started…' && $(LINT_PYLINT)
LINT_AGENTS = echo 'lint:ruff-format started…' && $(LINT_RUFF_FORMAT) \
&& echo 'lint:ruff-check started…' && $(LINT_RUFF_CHECK)
LINT_SUMMARY = echo 'lint:ruff-format started…' && $(LINT_RUFF_FORMAT) \
&& echo 'lint:ruff-check started…' && $(LINT_RUFF_CHECK)
# -- Frontend # -- Frontend
PATH_FRONT = ./src/frontend PATH_FRONT = ./src/frontend
@@ -204,37 +198,23 @@ demo: ## flush db then create a demo for load testing purpose
@$(MANAGE) create_demo @$(MANAGE) create_demo
.PHONY: demo .PHONY: demo
lint: ## lint all python sources (back-end, agents, summary) lint: ## lint back-end python sources
@$(MAKE) lint-back @$(COMPOSE_RUN_LINT) sh -c "$(LINT_BACK)"
@$(MAKE) lint-agents
@$(MAKE) lint-summary
.PHONY: lint .PHONY: lint
lint-back: ## lint back-end python sources
@$(COMPOSE_RUN_LINT_BACK) sh -c "$(LINT_BACK)"
.PHONY: lint-back
lint-agents: ## lint agents python sources
@$(COMPOSE_RUN_LINT_AGENTS) sh -c "$(LINT_AGENTS)"
.PHONY: lint-agents
lint-summary: ## lint summary python sources
@$(COMPOSE_RUN_LINT_SUMMARY) sh -c "$(LINT_SUMMARY)"
.PHONY: lint-summary
lint-ruff-format: ## format back-end python sources with ruff lint-ruff-format: ## format back-end python sources with ruff
@echo 'lint:ruff-format started…' @echo 'lint:ruff-format started…'
@$(COMPOSE_RUN_LINT_BACK) $(LINT_RUFF_FORMAT) @$(COMPOSE_RUN_LINT) $(LINT_RUFF_FORMAT)
.PHONY: lint-ruff-format .PHONY: lint-ruff-format
lint-ruff-check: ## lint back-end python sources with ruff lint-ruff-check: ## lint back-end python sources with ruff
@echo 'lint:ruff-check started…' @echo 'lint:ruff-check started…'
@$(COMPOSE_RUN_LINT_BACK) $(LINT_RUFF_CHECK) @$(COMPOSE_RUN_LINT) $(LINT_RUFF_CHECK)
.PHONY: lint-ruff-check .PHONY: lint-ruff-check
lint-pylint: ## lint back-end python sources with pylint only on changed files from main lint-pylint: ## lint back-end python sources with pylint only on changed files from main
@echo 'lint:pylint started…' @echo 'lint:pylint started…'
@$(COMPOSE_RUN_LINT_BACK) $(LINT_PYLINT) @$(COMPOSE_RUN_LINT) $(LINT_PYLINT)
.PHONY: lint-pylint .PHONY: lint-pylint
test: ## run project tests; pass extra pytest args via ARGS, e.g. `make test ARGS="-vv"` test: ## run project tests; pass extra pytest args via ARGS, e.g. `make test ARGS="-vv"`
+1 -1
View File
@@ -4,7 +4,7 @@
Security is very important to us. Security is very important to us.
If you have any issue regarding security, please disclose the information responsibly by submitting [this form](https://vdp.numerique.gouv.fr/p/Send-a-report?lang=en) and not by creating an issue on the repository. You can also email us at visio@numerique.gouv.fr If you have any issue regarding security, please disclose the information responsibly submiting [this form](https://vdp.numerique.gouv.fr/p/Send-a-report?lang=en) and not by creating an issue on the repository. You can also email us at visio@numerique.gouv.fr
We appreciate your effort to make Visio more secure. We appreciate your effort to make Visio more secure.
-19
View File
@@ -16,25 +16,6 @@ the following command inside your docker container:
## [Unreleased] ## [Unreleased]
## v1.30.0
### Removing S3 storage-event webhooks for recordings
Recordings were previously confirmed as saved by an S3 storage-event webhook posting to `/api/v1.0/recordings/storage-hook/`. That endpoint has been removed: recordings are now always finalized from LiveKit's own `egress_ended` webhook, which has been the default path since v1.22.0.
**Required for every deployment:** LiveKit must be able to deliver webhooks to the backend at `/api/v1.0/rooms/webhooks-livekit/`. This is now the only way a recording reaches a saved state; if `egress_ended` is never delivered, recordings stay in the `active` state.
For hosters who had configured storage-event webhooks:
- Recordings reach the same final state, but they are now finalized when LiveKit reports the egress as ended rather than when the storage backend reports the upload.
- Remove the event notification from your bucket configuration: it now targets a non-existent endpoint and will fail on every delivery.
For hosters who had **not** configured storage-event webhooks:
- Nothing changes. Recordings have been finalized from the `egress_ended` webhook since v1.22.0.
In both cases, the following settings are no longer used and can be removed from your env: `RECORDING_EVENT_PARSER_CLASS`, `RECORDING_ENABLE_STORAGE_EVENT_AUTH`, `RECORDING_STORAGE_EVENT_ENABLE`, `RECORDING_STORAGE_EVENT_TOKEN`.
On completion of the egress, a recording moves to `notification_succeeded`, or to `saved` if notifying external services failed.
## v1.23.0 ## 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`. 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`.
-7
View File
@@ -110,12 +110,6 @@ cd -
# Update summary pyproject.toml # Update summary pyproject.toml
update_python_version "summary" update_python_version "summary"
# Run uv lock in summary
print_info "Running uv lock in summary..."
cd "src/summary"
uv lock
cd -
# Update agents pyproject.toml # Update agents pyproject.toml
update_python_version "agents" update_python_version "agents"
@@ -169,7 +163,6 @@ echo " - src/mail/package.json"
echo " - src/backend/pyproject.toml" echo " - src/backend/pyproject.toml"
echo " - src/backend/uv.lock" echo " - src/backend/uv.lock"
echo " - src/summary/pyproject.toml" echo " - src/summary/pyproject.toml"
echo " - src/summary/uv.lock"
echo " - src/agents/pyproject.toml" echo " - src/agents/pyproject.toml"
echo " - src/agents/uv.lock" echo " - src/agents/uv.lock"
echo " - CHANGELOG.md" echo " - CHANGELOG.md"
+18 -7
View File
@@ -46,6 +46,21 @@ services:
/usr/bin/mc mb meet/meet-media-storage && \ /usr/bin/mc mb meet/meet-media-storage && \
exit 0;" exit 0;"
createwebhook:
image: minio/mc
depends_on:
minio:
condition: service_healthy
restart: true
entrypoint: >
sh -c "
/usr/bin/mc alias set meet http://minio:9000 meet password &&
/usr/bin/mc admin config set meet notify_webhook:meet-webhook endpoint='http://app-dev:8000/api/v1.0/recordings/storage-hook/' auth_token='Bearer password' &&
/usr/bin/mc admin service restart meet --wait --json &&
sleep 15 &&
/usr/bin/mc event add meet/meet-media-storage arn:minio:sqs::meet-webhook:webhook --event put --prefix "recordings" &&
exit 0;"
app-dev: app-dev:
build: build:
context: . context: .
@@ -71,6 +86,7 @@ services:
- mailcatcher - mailcatcher
- redis - redis
- createbuckets - createbuckets
- createwebhook
extra_hosts: extra_hosts:
- "127.0.0.1.nip.io:host-gateway" - "127.0.0.1.nip.io:host-gateway"
networks: networks:
@@ -164,7 +180,7 @@ services:
working_dir: /app working_dir: /app
node: node:
image: node:22 image: node:18
user: "${DOCKER_USER:-1000}" user: "${DOCKER_USER:-1000}"
environment: environment:
HOME: /tmp HOME: /tmp
@@ -207,14 +223,12 @@ services:
- kc_postgresql - kc_postgresql
livekit: livekit:
image: livekit/livekit-server:v1.13.6 image: livekit/livekit-server
entrypoint: /livekit-server --dev --bind 0.0.0.0 --config ./config.yaml entrypoint: /livekit-server --dev --bind 0.0.0.0 --config ./config.yaml
ports: ports:
- "7880:7880" - "7880:7880"
- "7881:7881" - "7881:7881"
- "7882:7882/udp" - "7882:7882/udp"
- "3478:3478/udp"
- "30000-30100:30000-30100/udp"
volumes: volumes:
- ./docker/livekit/config/livekit-server.yaml:/config.yaml - ./docker/livekit/config/livekit-server.yaml:/config.yaml
depends_on: depends_on:
@@ -235,7 +249,6 @@ services:
build: build:
context: ./src/agents context: ./src/agents
target: development target: development
user: ${DOCKER_USER:-1000}
command: ["python", "metadata_collector.py", "dev"] command: ["python", "metadata_collector.py", "dev"]
env_file: env_file:
- env.d/development/metadata_collector - env.d/development/metadata_collector
@@ -254,8 +267,6 @@ services:
build: build:
context: ./src/agents context: ./src/agents
target: development target: development
user: ${DOCKER_USER:-1000}
command: ["python", "multi_user_transcriber.py", "dev"]
env_file: env_file:
- env.d/development/multi_user_transcriber - env.d/development/multi_user_transcriber
volumes: volumes:
-1
View File
@@ -65,7 +65,6 @@ RUN apk update && apk upgrade \
musl \ musl \
musl-utils \ musl-utils \
zlib>=1.3.2-r0 \ zlib>=1.3.2-r0 \
libexpat>=2.8.4-r0 \
&& apk del curl && apk del curl
USER nginx USER nginx
-5
View File
@@ -65,11 +65,6 @@ server {
sub_filter_once off; sub_filter_once off;
} }
location ^~ /assets/mediapipe/wasm/ {
expires 30d;
add_header Cache-Control "public, max-age=2592000";
}
# Serve static files with caching # Serve static files with caching
location ~* ^/assets/.*\.(css|js|json|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ { location ~* ^/assets/.*\.(css|js|json|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
expires 30d; expires 30d;
+1 -1
View File
@@ -1,4 +1,4 @@
FROM livekit/livekit-server:v1.13.6 FROM livekit/livekit-server:v1.9.4
# We inject the nip.io certificate manually because the livekit chart doesn't support volume mounting # We inject the nip.io certificate manually because the livekit chart doesn't support volume mounting
COPY rootCA.pem /etc/ssl/certs/ COPY rootCA.pem /etc/ssl/certs/
-13
View File
@@ -8,16 +8,3 @@ webhook:
api_key: devkey api_key: devkey
urls: urls:
- http://app-dev:8000/api/v1.0/rooms/webhooks-livekit/ - http://app-dev:8000/api/v1.0/rooms/webhooks-livekit/
turn:
enabled: true
domain: turn.127.0.0.1.nip.io
udp_port: 3478
tls_port: 0
external_tls: false
relay_range_start: 30000
relay_range_end: 30100
allow_restricted_peer_cidrs:
- 192.168.0.0/16
- 172.16.0.0/12
+22 -2
View File
@@ -23,11 +23,14 @@ It uses LiveKit Egress to record room sessions. For reference, see the [LiveKit
To use the room recording feature, the following components are required: To use the room recording feature, the following components are required:
- A running [LiveKit Egress](https://github.com/livekit/egress) server capable of handling room composite recordings. - A running [LiveKit Egress](https://github.com/livekit/egress) server capable of handling room composite recordings.
- A S3-compatible object storage where the egress uploads the recorded files. - A S3-compatible object storage that supports webhook events to notify the backend when recordings are uploaded.
- An email service to notify room owners when a recording is available for download. - An email service to notify room owners when a recording is available for download.
- Webhook events configured between LiveKit Server and the backend. - Webhook events configured between LiveKit Server and the backend.
> [!CAUTION]
> Minio supports lifecycle events; other providers may not work out of the box. There is currently a dependency on Minio, which is planned to be refactored in the future.
> [!NOTE] > [!NOTE]
> Celery isnt in use for these async tasks yet. Its something wed like to add, but its not planned at this stage. > Celery isnt in use for these async tasks yet. Its something wed like to add, but its not planned at this stage.
@@ -72,7 +75,7 @@ sequenceDiagram
LiveKit->>Egress: Stop recording LiveKit->>Egress: Stop recording
Egress->>Storage: Upload recorded file Egress->>Storage: Upload recorded file
LiveKit->>Backend: POST /api/v1.0/rooms/webhooks-livekit/ (egress_ended) Storage->>Backend: Storage event notification
Backend->>Backend: Update Recording status to SAVED Backend->>Backend: Update Recording status to SAVED
Backend->>Email: Send notification to room owner Backend->>Email: Send notification to room owner
@@ -91,6 +94,10 @@ sequenceDiagram
| **RECORDING_ENABLE** | Boolean | `False` | Enable or disable the room recording feature. | | **RECORDING_ENABLE** | Boolean | `False` | Enable or disable the room recording feature. |
| **RECORDING_OUTPUT_FOLDER** | String | `"recordings"` | Folder/prefix where recordings are stored in the object storage. | | **RECORDING_OUTPUT_FOLDER** | String | `"recordings"` | Folder/prefix where recordings are stored in the object storage. |
| **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_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). 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_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_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). |
@@ -102,6 +109,19 @@ sequenceDiagram
| **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. Only applied when `RECORDING_ENCODING_ENABLED` is `True`. |
### Manual Storage Webhook
Storage events must be configured manually; the Kubernetes chart does not do this automatically.
1. Configure your S3 bucket to send file creation events to the backend webhook.
2. Enable events and token in settings:
```python
RECORDING_STORAGE_EVENT_ENABLE = True
RECORDING_ENABLE_STORAGE_EVENT_AUTH = True
RECORDING_STORAGE_EVENT_TOKEN = <token>
```
> [!NOTE] > [!NOTE]
> Questions? Open an issue on [GitHub](https://github.com/suitenumerique/meet/issues/new?assignees=&labels=bug&template=Bug_report.md) or join our [Matrix community](https://matrix.to/#/#meet-official:matrix.org). > Questions? Open an issue on [GitHub](https://github.com/suitenumerique/meet/issues/new?assignees=&labels=bug&template=Bug_report.md) or join our [Matrix community](https://matrix.to/#/#meet-official:matrix.org).
+4
View File
@@ -406,6 +406,10 @@ These are the environmental options available on meet backend.
| RECORDING_ENABLE | Record meeting option | false | | RECORDING_ENABLE | Record meeting option | false |
| RECORDING_OUTPUT_FOLDER | Folder to store meetings | recordings | | RECORDING_OUTPUT_FOLDER | Folder to store meetings | recordings |
| 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_ENABLE_STORAGE_EVENT_AUTH | Enable storage event authorization | true |
| RECORDING_STORAGE_EVENT_ENABLE | Enable recording storage events. If false, fallback to egress webhook. | false |
| 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. | |
| SCREEN_RECORDING_BASE_URL | Screen recording base URL | | | SCREEN_RECORDING_BASE_URL | Screen recording base URL | |
-27
View File
@@ -50,24 +50,10 @@ paths:
The application must be authorized for the user's email domain. The application must be authorized for the user's email domain.
The returned token expires after a configured duration and must be refreshed by calling this endpoint again. The returned token expires after a configured duration and must be refreshed by calling this endpoint again.
Request parameters may be sent either as "application/x-www-form-urlencoded"
(as specified by RFC 6749 for OAuth 2.0 token endpoints) or as "application/json".
operationId: generateToken operationId: generateToken
requestBody: requestBody:
required: true required: true
content: content:
application/x-www-form-urlencoded:
schema:
$ref: '#/components/schemas/TokenRequest'
examples:
tokenRequest:
summary: Request token for user delegation
value:
client_id: "550e8400-e29b-41d4-a716-446655440000"
client_secret: "1234567890abcdefghijklmnopqrstuvwxyz"
grant_type: "client_credentials"
scope: "user@example.com"
application/json: application/json:
schema: schema:
$ref: '#/components/schemas/TokenRequest' $ref: '#/components/schemas/TokenRequest'
@@ -131,19 +117,6 @@ paths:
summary: Domain not authorized summary: Domain not authorized
value: value:
error: "This application is not authorized for this email domain." error: "This application is not authorized for this email domain."
'415':
description: |
Unsupported media type. The request body must be sent as
"application/x-www-form-urlencoded" or "application/json".
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
examples:
unsupportedMediaType:
summary: Unsupported request content type
value:
detail: 'Unsupported media type "text/plain" in request.'
/rooms: /rooms:
get: get:
+2
View File
@@ -63,6 +63,8 @@ ALLOW_UNREGISTERED_ROOMS=False
# Recording # Recording
RECORDING_ENABLE=True RECORDING_ENABLE=True
RECORDING_STORAGE_EVENT_ENABLE=False
RECORDING_STORAGE_EVENT_TOKEN=password
SUMMARY_SERVICE_ENDPOINT=http://app-summary-dev:8000/api/v2/async-jobs/transcribe/ 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 SUMMARY_SERVICE_WEBHOOK_API_TOKEN=webhook-password
+4 -13
View File
@@ -1,23 +1,14 @@
AWS_S3_ENDPOINT_URL=minio:9000
AWS_S3_ACCESS_KEY_ID=meet
AWS_S3_SECRET_ACCESS_KEY=password
LIVEKIT_URL=ws://livekit:7880 LIVEKIT_URL=ws://livekit:7880
LIVEKIT_API_KEY=devkey LIVEKIT_API_KEY=devkey
LIVEKIT_API_SECRET=secret LIVEKIT_API_SECRET=secret
STT_PROVIDER=voxtral-vllm # voxtral-vllm, kyutai, deepgram STT_PROVIDER=kyutai # kyutai, deepgram
ENABLE_SILERO_VAD=False ENABLE_SILERO_VAD=False
DEEPGRAM_API_KEY=your-deepgram-api-key DEEPGRAM_API_KEY=
KYUTAI_STT_BASE_URL=url KYUTAI_STT_BASE_URL=
KYUTAI_API_KEY=your-kyutai-api-key KYUTAI_API_KEY=
VOXTRAL_VLLM_BASE_URL=wss://<host>/v1/realtime
VOXTRAL_VLLM_MODEL=voxtral-mini-4b-realtime-2602
VOXTRAL_VLLM_API_KEY=your-vllm-api-key
VOXTRAL_VLLM_TARGET_STREAMING_DELAY_MS=480
SENTRY_DSN= SENTRY_DSN=
SENTRY_ENVIRONMENT= SENTRY_ENVIRONMENT=
+8 -7
View File
@@ -3,14 +3,14 @@ Gitlint extra rule to validate that the message title is of the form
"<gitmoji>(<scope>) <subject>" "<gitmoji>(<scope>) <subject>"
""" """
import json from __future__ import unicode_literals
import re import re
import urllib.request
import requests
from gitlint.rules import CommitMessageTitle, LineRule, RuleViolation from gitlint.rules import CommitMessageTitle, LineRule, RuleViolation
GITMOJIS_URL = "https://raw.githubusercontent.com/carloscuesta/gitmoji/master/packages/gitmojis/src/gitmojis.json"
class GitmojiTitle(LineRule): class GitmojiTitle(LineRule):
""" """
@@ -28,9 +28,10 @@ class GitmojiTitle(LineRule):
Download the list possible gitmojis from the project's github repository and check that Download the list possible gitmojis from the project's github repository and check that
title contains one of them. title contains one of them.
""" """
with urllib.request.urlopen(GITMOJIS_URL, timeout=10) as response: gitmojis = requests.get(
gitmojis = json.load(response)["gitmojis"] "https://raw.githubusercontent.com/carloscuesta/gitmoji/master/packages/gitmojis/src/gitmojis.json"
emojis = [re.escape(item["emoji"]) for item in gitmojis] ).json()["gitmojis"]
emojis = [item["emoji"] for item in gitmojis]
pattern = r"^({:s})\(.*\)\s[a-z].*$".format("|".join(emojis)) pattern = r"^({:s})\(.*\)\s[a-z].*$".format("|".join(emojis))
if not re.search(pattern, title): if not re.search(pattern, title):
violation_msg = 'Title does not match regex "<gitmoji>(<scope>) <subject>"' violation_msg = 'Title does not match regex "<gitmoji>(<scope>) <subject>"'
+5 -8
View File
@@ -9,8 +9,8 @@
"version": "0.0.1", "version": "0.0.1",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"core-js": "3.50.0", "core-js": "3.49.0",
"i18next": "26.3.6", "i18next": "^26.3.6",
"i18next-browser-languagedetector": "8.2.1", "i18next-browser-languagedetector": "8.2.1",
"regenerator-runtime": "0.14.1" "regenerator-runtime": "0.14.1"
}, },
@@ -6863,14 +6863,11 @@
} }
}, },
"node_modules/core-js": { "node_modules/core-js": {
"version": "3.50.0", "version": "3.49.0",
"resolved": "https://registry.npmjs.org/core-js/-/core-js-3.50.0.tgz", "resolved": "https://registry.npmjs.org/core-js/-/core-js-3.49.0.tgz",
"integrity": "sha512-BRWgOLKkFeCgRudR6zrs8p9XJZcE14grzKMMssoYrk6krtuEZ7MTKPIY5RzOnqsEKIR9kst7wNzphttraT+Yqw==", "integrity": "sha512-es1U2+YTtzpwkxVLwAFdSpaIMyQaq0PBgm3YD1W3Qpsn1NAmO3KSgZfu+oGSWVu6NvLHoHCV/aYcsE5wiB7ALg==",
"hasInstallScript": true, "hasInstallScript": true,
"license": "MIT", "license": "MIT",
"engines": {
"node": "*"
},
"funding": { "funding": {
"type": "opencollective", "type": "opencollective",
"url": "https://opencollective.com/core-js" "url": "https://opencollective.com/core-js"
+1 -1
View File
@@ -26,7 +26,7 @@
"watch": "webpack --mode development --watch" "watch": "webpack --mode development --watch"
}, },
"dependencies": { "dependencies": {
"core-js": "3.50.0", "core-js": "3.49.0",
"i18next": "26.3.6", "i18next": "26.3.6",
"i18next-browser-languagedetector": "8.2.1", "i18next-browser-languagedetector": "8.2.1",
"regenerator-runtime": "0.14.1" "regenerator-runtime": "0.14.1"
+16 -53
View File
@@ -1,7 +1,6 @@
"""Multi user transcription agent.""" """Multi user transcription agent."""
import asyncio import asyncio
import contextlib
import logging import logging
import os import os
@@ -26,7 +25,6 @@ from livekit.agents import (
) )
from livekit.plugins import deepgram, silero from livekit.plugins import deepgram, silero
import voxtral_vllm_stt
from observability import configure_sentry, set_job_context from observability import configure_sentry, set_job_context
from tasks import done_callback from tasks import done_callback
@@ -38,18 +36,9 @@ TRANSCRIBER_AGENT_NAME = os.getenv("TRANSCRIBER_AGENT_NAME", "multi-user-transcr
STT_PROVIDER = os.getenv("STT_PROVIDER", "deepgram") STT_PROVIDER = os.getenv("STT_PROVIDER", "deepgram")
ENABLE_SILERO_VAD = os.getenv("ENABLE_SILERO_VAD", "true").lower() == "true" ENABLE_SILERO_VAD = os.getenv("ENABLE_SILERO_VAD", "true").lower() == "true"
SESSION_DRAIN_TIMEOUT_S = 15.0
def create_stt_provider():
def create_stt_provider(vad: silero.VAD | None = None): """Create STT provider based on environment configuration."""
"""Create STT provider based on environment configuration.
Args:
vad: Shared, prewarmed VAD instance. Required in practice for
voxtral-vllm (no server-side endpointing): if omitted, the plugin
loads its own Silero model synchronously on the event loop, once
per participant, freezing all active sessions for the duration.
"""
if STT_PROVIDER == "deepgram": if STT_PROVIDER == "deepgram":
# Note: Not all Deepgram API parameters are supported by the LiveKit plugin # Note: Not all Deepgram API parameters are supported by the LiveKit plugin
# detect_language is NOT supported for real-time streaming # detect_language is NOT supported for real-time streaming
@@ -60,9 +49,6 @@ def create_stt_provider(vad: silero.VAD | None = None):
) )
elif STT_PROVIDER == "kyutai": elif STT_PROVIDER == "kyutai":
_stt_instance = kyutai.STT(base_url=os.getenv("KYUTAI_STT_BASE_URL")) _stt_instance = kyutai.STT(base_url=os.getenv("KYUTAI_STT_BASE_URL"))
elif STT_PROVIDER == "voxtral-vllm":
# The plugin resolves base_url / model / api_key from the environment.
_stt_instance = voxtral_vllm_stt.STT(vad=vad)
else: else:
raise ValueError(f"Unknown STT_PROVIDER: {STT_PROVIDER}") raise ValueError(f"Unknown STT_PROVIDER: {STT_PROVIDER}")
@@ -72,9 +58,9 @@ def create_stt_provider(vad: silero.VAD | None = None):
class Transcriber(Agent): class Transcriber(Agent):
"""Create a transcription agent for a specific participant.""" """Create a transcription agent for a specific participant."""
def __init__(self, *, participant_identity: str, vad: silero.VAD | None = None): def __init__(self, *, participant_identity: str):
"""Init transcription agent.""" """Init transcription agent."""
stt = create_stt_provider(vad=vad) stt = create_stt_provider()
super().__init__( super().__init__(
instructions="not-needed", instructions="not-needed",
@@ -90,7 +76,6 @@ class MultiUserTranscriber:
"""Init multi user transcription agent.""" """Init multi user transcription agent."""
self.ctx = ctx self.ctx = ctx
self._sessions: dict[str, AgentSession] = {} self._sessions: dict[str, AgentSession] = {}
self._starting: dict[str, asyncio.Task] = {}
self._tasks: set[asyncio.Task] = set() self._tasks: set[asyncio.Task] = set()
def start(self): def start(self):
@@ -111,30 +96,22 @@ class MultiUserTranscriber:
def on_participant_connected(self, participant: rtc.RemoteParticipant): def on_participant_connected(self, participant: rtc.RemoteParticipant):
"""Handle new participant connection by starting transcription session.""" """Handle new participant connection by starting transcription session."""
identity = participant.identity if participant.identity in self._sessions:
if identity in self._sessions or identity in self._starting:
return return
logger.info(f"starting session for {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._starting[identity] = task
self._tasks.add(task) self._tasks.add(task)
task.add_done_callback(lambda t, i=identity: self._starting.pop(i, None))
task.add_done_callback( task.add_done_callback(
done_callback( done_callback(
logger, logger,
self._tasks, self._tasks,
f"start transcription session for {identity}", f"start transcription session for {participant.identity}",
) )
) )
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 (start_task := self._starting.pop(participant.identity, None)) is not None:
logger.info(f"cancelling pending session start for {participant.identity}")
start_task.cancel()
return
if (session := self._sessions.pop(participant.identity, None)) is None: if (session := self._sessions.pop(participant.identity, None)) is None:
return return
@@ -150,12 +127,10 @@ class MultiUserTranscriber:
) )
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."""
if participant.identity in self._sessions:
return self._sessions[participant.identity]
Deduplication happens synchronously in on_participant_connected via
self._starting; by the time this coroutine runs, the identity is
already reserved.
"""
vad = self.ctx.proc.userdata.get("vad", None) vad = self.ctx.proc.userdata.get("vad", None)
session = AgentSession(vad=vad) session = AgentSession(vad=vad)
room_io = RoomIO( room_io = RoomIO(
@@ -166,30 +141,18 @@ class MultiUserTranscriber:
text_input=False, audio_output=False, text_output=True text_input=False, audio_output=False, text_output=True
), ),
) )
try: await room_io.start()
await room_io.start() await session.start(
await session.start( agent=Transcriber(
agent=Transcriber( participant_identity=participant.identity,
participant_identity=participant.identity,
vad=vad,
)
) )
except BaseException: )
with contextlib.suppress(Exception):
await session.aclose()
raise
self._sessions[participant.identity] = session 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:
"""Close and cleanup transcription session.""" """Close and cleanup transcription session."""
try: await sess.drain()
await asyncio.wait_for(sess.drain(), timeout=SESSION_DRAIN_TIMEOUT_S)
except (TimeoutError, asyncio.TimeoutError):
logger.warning(
"session drain timed out after %.0fs; forcing close",
SESSION_DRAIN_TIMEOUT_S,
)
await sess.aclose() await sess.aclose()
+1 -3
View File
@@ -1,7 +1,7 @@
[project] [project]
name = "agents" name = "agents"
version = "1.29.0" version = "1.24.0"
requires-python = ">=3.12" requires-python = ">=3.12"
dependencies = [ dependencies = [
"livekit-agents==1.6.7", "livekit-agents==1.6.7",
@@ -12,8 +12,6 @@ dependencies = [
"protobuf==6.33.6", "protobuf==6.33.6",
"minio==7.2.20", "minio==7.2.20",
"sentry-sdk==2.66.1", "sentry-sdk==2.66.1",
"websockets==17.1",
"httpx==0.28.1",
] ]
[project.optional-dependencies] [project.optional-dependencies]
+634 -915
View File
File diff suppressed because it is too large Load Diff
-476
View File
@@ -1,476 +0,0 @@
"""LiveKit STT plugin for Voxtral Realtime served via vLLM (/v1/realtime).
vLLM exposes Voxtral Realtime over a WebSocket that follows the OpenAI Realtime
API protocol (not Mistral's proprietary realtime protocol).
"""
from __future__ import annotations
import asyncio
import base64
import json
import logging
import os
import weakref
from collections import deque
from dataclasses import dataclass, field
import websockets
from livekit.agents import (
DEFAULT_API_CONNECT_OPTIONS,
APIConnectionError,
APIConnectOptions,
APIStatusError,
stt,
utils,
)
from livekit.agents import (
vad as vad_module,
)
from livekit.agents.types import NOT_GIVEN, NotGivenOr
from livekit.agents.utils import is_given
logger = logging.getLogger("voxtral-vllm-stt")
SAMPLE_RATE = 16000
NUM_CHANNELS = 1
CHUNK_SAMPLES = 1600 # 100 ms @ 16 kHz mono
PREROLL_CHUNKS = 5 # keep 500 ms of audio before start of speech as detected by VAD
# Reconnect policy: exponential backoff capped at MAX, give up after MAX_ATTEMPTS
# consecutive failures (a successful handshake resets the counter).
RECONNECT_BACKOFF_BASE_S = 0.5
RECONNECT_BACKOFF_MAX_S = 8.0
RECONNECT_MAX_ATTEMPTS = 5
@dataclass
class _STTOptions:
base_url: str
model: str
api_key: str | None
target_streaming_delay_ms: int | None
@dataclass
class _PendingUtterance:
"""An utterance in flight on the shared websocket used for reconnect.
`sent_chunks` holds every chunk we have already enqueued for send on this
or a prior connection; on reconnect we replay them before resuming reads
from `queue`. vLLM concatenates `input_audio_buffer.append` events into a
single audio buffer per generation, so duplicates from a partial prior send
are harmless.
"""
queue: asyncio.Queue[bytes | None]
sent_chunks: list[bytes] = field(default_factory=list)
ended: bool = False
class STT(stt.STT):
"""LiveKit STT speaking the OpenAI Realtime protocol served by vLLM."""
def __init__(
self,
*,
base_url: NotGivenOr[str] = NOT_GIVEN,
model: NotGivenOr[str] = NOT_GIVEN,
api_key: NotGivenOr[str] = NOT_GIVEN,
target_streaming_delay_ms: NotGivenOr[int] = NOT_GIVEN,
vad: vad_module.VAD | None = None,
) -> None:
"""Build the STT.
Args:
base_url: WebSocket URL of the vLLM realtime endpoint, e.g.
ws://example:8000/v1/realtime. Falls back to $VOXTRAL_VLLM_BASE_URL.
model: Model name exposed by vLLM, default
mistralai/Voxtral-Mini-4B-Realtime-2602.
api_key: Optional bearer token. Falls back to $VOXTRAL_VLLM_API_KEY.
target_streaming_delay_ms: Target streaming delay in ms forwarded to
vLLM via session.update. Falls back to
$VOXTRAL_VLLM_TARGET_STREAMING_DELAY_MS, else server default.
vad: Voice Activity Detector. If omitted, Silero VAD is loaded.
"""
super().__init__(
capabilities=stt.STTCapabilities(streaming=True, interim_results=True)
)
resolved_url = (
base_url
if is_given(base_url)
else os.environ.get(
"VOXTRAL_VLLM_BASE_URL", "ws://127.0.0.1:8000/v1/realtime"
)
)
resolved_model = (
model
if is_given(model)
else os.environ.get(
"VOXTRAL_VLLM_MODEL", "mistralai/Voxtral-Mini-4B-Realtime-2602"
)
)
resolved_key = (
api_key if is_given(api_key) else os.environ.get("VOXTRAL_VLLM_API_KEY")
)
resolved_delay = (
target_streaming_delay_ms
if is_given(target_streaming_delay_ms)
else (
int(os.environ["VOXTRAL_VLLM_TARGET_STREAMING_DELAY_MS"])
if os.environ.get("VOXTRAL_VLLM_TARGET_STREAMING_DELAY_MS")
else None
)
)
if vad is None:
try:
from livekit.plugins.silero import VAD as SileroVAD # noqa: PLC0415
except ImportError as exc:
raise ImportError(
"livekit-plugins-silero is required for vLLM Voxtral realtime "
"(no server-side endpointing)."
) from exc
vad = SileroVAD.load()
self._vad = vad
self._opts = _STTOptions(
base_url=resolved_url,
model=resolved_model,
api_key=resolved_key,
target_streaming_delay_ms=resolved_delay,
)
self._streams: weakref.WeakSet[SpeechStream] = weakref.WeakSet()
@property
def model(self) -> str:
"""Return the configured vLLM model name."""
return self._opts.model
@property
def provider(self) -> str:
"""Return the provider identifier."""
return "vllm-voxtral-realtime"
async def _recognize_impl(self, *_args, **_kwargs) -> stt.SpeechEvent:
raise NotImplementedError(
"vLLM Voxtral Realtime STT only supports streaming recognition."
)
def stream(
self,
*,
conn_options: APIConnectOptions = DEFAULT_API_CONNECT_OPTIONS,
) -> SpeechStream:
"""Open a new streaming recognition stream."""
s = SpeechStream(
stt=self,
opts=self._opts,
vad_instance=self._vad,
conn_options=conn_options,
)
self._streams.add(s)
return s
class SpeechStream(stt.RecognizeStream):
"""Voxtral realtime handler."""
def __init__(
self,
*,
stt: STT,
opts: _STTOptions,
vad_instance: vad_module.VAD,
conn_options: APIConnectOptions,
) -> None:
"""Init the speech stream."""
super().__init__(stt=stt, conn_options=conn_options, sample_rate=SAMPLE_RATE)
self._opts = opts
self._vad = vad_instance
self._utterance_q: asyncio.Queue[bytes | None] | None = None
self._speaking = False
self._preroll: deque[bytes] = deque(maxlen=PREROLL_CHUNKS)
# Voxtral realtime is strictly sequential: only one generation runs at a
# time, and a new `commit` is ignored while the previous one is still
# producing. We queue per-utterance audio buffers here and let the
# pipeline process them one by one on the shared websocket.
self._utterance_chan: asyncio.Queue[asyncio.Queue[bytes | None] | None] = (
asyncio.Queue()
)
@utils.log_exceptions(logger=logger)
async def _run(self) -> None:
vad_stream = self._vad.stream()
bstream = utils.audio.AudioByteStream(
sample_rate=SAMPLE_RATE,
num_channels=NUM_CHANNELS,
samples_per_channel=CHUNK_SAMPLES,
)
async def input_task() -> None:
async for data in self._input_ch:
if isinstance(data, self._FlushSentinel):
for frame in bstream.flush():
self._handle_chunk(frame.data.tobytes())
continue
vad_stream.push_frame(data)
for frame in bstream.write(data.data.tobytes()):
self._handle_chunk(frame.data.tobytes())
vad_stream.end_input()
async def vad_task() -> None:
async for ev in vad_stream:
if ev.type == vad_module.VADEventType.START_OF_SPEECH:
self._on_start_of_speech()
elif ev.type == vad_module.VADEventType.END_OF_SPEECH:
self._on_end_of_speech()
pipeline_t = asyncio.create_task(self._utterance_pipeline())
try:
await asyncio.gather(input_task(), vad_task())
# signal end-of-stream; pipeline finishes pending utterances first
self._utterance_chan.put_nowait(None)
await pipeline_t
except (APIStatusError, APIConnectionError, asyncio.CancelledError):
raise
except Exception as exc:
logger.exception("vLLM realtime stream failed")
raise APIConnectionError() from exc
finally:
if not pipeline_t.done():
pipeline_t.cancel()
try:
await pipeline_t
except asyncio.CancelledError:
# CancelledError is the expected flow on cancel()
pass
except Exception:
logger.exception("utterance pipeline failed during finalize")
await vad_stream.aclose()
def _handle_chunk(self, chunk: bytes) -> None:
self._preroll.append(chunk)
if self._speaking and self._utterance_q is not None:
self._utterance_q.put_nowait(chunk)
def _on_start_of_speech(self) -> None:
if self._speaking:
return
self._speaking = True
q: asyncio.Queue[bytes | None] = asyncio.Queue()
for chunk in self._preroll:
q.put_nowait(chunk)
self._utterance_q = q
self._utterance_chan.put_nowait(q)
self._event_ch.send_nowait(
stt.SpeechEvent(type=stt.SpeechEventType.START_OF_SPEECH)
)
def _on_end_of_speech(self) -> None:
if not self._speaking:
return
self._speaking = False
if self._utterance_q is not None:
self._utterance_q.put_nowait(None)
self._utterance_q = None
self._event_ch.send_nowait(
stt.SpeechEvent(type=stt.SpeechEventType.END_OF_SPEECH)
)
async def _handshake(self, ws: websockets.ClientConnection) -> str:
created = json.loads(await ws.recv())
if created.get("type") != "session.created":
raise APIStatusError(
f"expected session.created, got {created}",
status_code=500,
body=created,
)
session_update: dict = {"type": "session.update", "model": self._opts.model}
if self._opts.target_streaming_delay_ms is not None:
session_update["target_streaming_delay_ms"] = (
self._opts.target_streaming_delay_ms
)
await ws.send(json.dumps(session_update))
return created.get("id", "")
def _auth_headers(self) -> dict[str, str]:
if self._opts.api_key:
return {"Authorization": f"Bearer {self._opts.api_key}"}
return {}
async def _utterance_pipeline(self) -> None:
# Owns the websocket lifecycle. On drop, reopens and resumes the
# in-flight utterance (if any) by replaying its already-sent chunks.
pending: _PendingUtterance | None = None
attempt = 0
while True:
try:
async with websockets.connect(
self._opts.base_url,
additional_headers=self._auth_headers(),
open_timeout=self._conn_options.timeout,
) as ws:
request_id = await self._handshake(ws)
attempt = 0
while True:
if pending is None:
q = await self._utterance_chan.get()
if q is None:
return
pending = _PendingUtterance(queue=q)
await self._process_utterance(ws, pending, request_id)
pending = None
except (websockets.WebSocketException, OSError, TimeoutError) as exc:
attempt += 1
if attempt > RECONNECT_MAX_ATTEMPTS:
logger.exception(
"vLLM realtime: giving up after %d reconnect attempts",
RECONNECT_MAX_ATTEMPTS,
)
raise APIConnectionError() from exc
backoff = min(
RECONNECT_BACKOFF_BASE_S * (2 ** (attempt - 1)),
RECONNECT_BACKOFF_MAX_S,
)
if pending is None:
logger.warning(
"vLLM WS connection lost between utterances "
"(attempt %d/%d): %s; retrying in %.1fs",
attempt,
RECONNECT_MAX_ATTEMPTS,
exc,
backoff,
)
else:
logger.warning(
"vLLM WS dropped mid-utterance (%d chunks buffered, "
"ended=%s, attempt %d/%d): %s; retrying in %.1fs",
len(pending.sent_chunks),
pending.ended,
attempt,
RECONNECT_MAX_ATTEMPTS,
exc,
backoff,
)
await asyncio.sleep(backoff)
async def _process_utterance(
self,
ws: websockets.ClientConnection,
pending: _PendingUtterance,
request_id: str,
) -> None:
# Start a fresh generation. Safe to send here: the previous utterance's
# transcription.done has already been received (we await it below), so
# the server-side generation_task is done and won't ignore this commit.
await ws.send(json.dumps({"type": "input_audio_buffer.commit"}))
send_t = asyncio.create_task(self._send_audio(ws, pending))
try:
await self._receive_one_transcription(ws, request_id)
finally:
if not send_t.done():
send_t.cancel()
try:
await send_t
except (asyncio.CancelledError, websockets.WebSocketException):
pass
except Exception:
logger.exception("send-audio task failed during finalize")
@staticmethod
async def _send_audio(
ws: websockets.ClientConnection, pending: _PendingUtterance
) -> None:
# Replay anything already sent on a previous (now-dead) connection.
# sent_chunks is appended before send, so a chunk that failed to send
# last time is still present and gets retried here.
for chunk in pending.sent_chunks:
await ws.send(
json.dumps(
{
"type": "input_audio_buffer.append",
"audio": base64.b64encode(chunk).decode("ascii"),
}
)
)
if pending.ended:
await ws.send(
json.dumps({"type": "input_audio_buffer.commit", "final": True})
)
return
while True:
chunk = await pending.queue.get()
if chunk is None:
pending.ended = True
await ws.send(
json.dumps({"type": "input_audio_buffer.commit", "final": True})
)
return
pending.sent_chunks.append(chunk)
await ws.send(
json.dumps(
{
"type": "input_audio_buffer.append",
"audio": base64.b64encode(chunk).decode("ascii"),
}
)
)
async def _receive_one_transcription(
self, ws: websockets.ClientConnection, request_id: str
) -> None:
# Use recv() rather than `async for`: the latter swallows
# ConnectionClosed on close-mid-iteration, which would let a dropped
# WS look like a clean "no transcription" return.
current_text = ""
while True:
raw = await ws.recv()
data = json.loads(raw)
event_type = data.get("type")
if event_type == "transcription.delta":
delta = data.get("delta", "")
if not delta:
continue
current_text += delta
self._event_ch.send_nowait(
stt.SpeechEvent(
type=stt.SpeechEventType.INTERIM_TRANSCRIPT,
request_id=request_id,
alternatives=[stt.SpeechData(text=current_text, language="")],
)
)
elif event_type == "transcription.done":
final_text = data.get("text") or current_text
self._event_ch.send_nowait(
stt.SpeechEvent(
type=stt.SpeechEventType.FINAL_TRANSCRIPT,
request_id=request_id,
alternatives=[stt.SpeechData(text=final_text, language="")],
)
)
usage = data.get("usage") or {}
self._event_ch.send_nowait(
stt.SpeechEvent(
type=stt.SpeechEventType.RECOGNITION_USAGE,
request_id=request_id,
recognition_usage=stt.RecognitionUsage(
audio_duration=float(
usage.get("audio_seconds")
or usage.get("prompt_audio_seconds")
or 0
),
input_tokens=int(usage.get("prompt_tokens") or 0),
output_tokens=int(usage.get("completion_tokens") or 0),
),
)
)
return
elif event_type == "error":
err = data.get("error")
raise APIStatusError(str(err), status_code=500, body=data)
+1
View File
@@ -11,6 +11,7 @@ class FeatureFlag:
FLAGS = { FLAGS = {
"recording": "RECORDING_ENABLE", "recording": "RECORDING_ENABLE",
"storage_event": "RECORDING_STORAGE_EVENT_ENABLE",
"subtitle": "ROOM_SUBTITLE_ENABLED", "subtitle": "ROOM_SUBTITLE_ENABLED",
"file_upload": "FILE_UPLOAD_ENABLED", "file_upload": "FILE_UPLOAD_ENABLED",
"addons": "ADDONS_ENABLED", "addons": "ADDONS_ENABLED",
+14 -25
View File
@@ -5,7 +5,7 @@ from django.http import Http404
from rest_framework import permissions from rest_framework import permissions
from ..models import RoleChoices, RoomAccessLevel from ..models import RoleChoices
from ..services.participants_management import ( from ..services.participants_management import (
ParticipantNotFoundException, ParticipantNotFoundException,
ParticipantsManagement, ParticipantsManagement,
@@ -200,46 +200,35 @@ class IsPresentInMeeting(permissions.BasePermission):
return False return False
class CanManageLobby(permissions.BasePermission): class RoomCapabilityPermission(permissions.BasePermission):
"""Grant lobby management (list/accept/deny waiting participants). """Wip."""
- Room admins/owners can always manage the lobby. capability = None
- When the room access level is TRUSTED, any authenticated user who is
currently connected to the meeting can manage the lobby. Presence is
verified cache-first (Redis), falling back to the LiveKit API.
Access level is always read fresh from the DB; only presence is cached, def has_object_permission(self, request, view, obj):
so changing the room to RESTRICTED takes effect immediately. """Wip."""
"""
message = "You are not allowed to manage this room's lobby."
# pylint: disable=too-many-return-statements
def has_object_permission(self, request, view, obj): # noqa: PLR0911
"""Check privileges first, then the trusted-room presence path."""
user = request.user user = request.user
if not user or not user.is_authenticated: if not user or not user.is_authenticated:
return False return False
# Product choice: lobby management is reserved for session-authenticated
# users with a real account, not holders of a LiveKit room token.
if request.auth and hasattr(request.auth, "video"):
return False
if obj.is_administrator_or_owner(user): if obj.is_administrator_or_owner(user):
return True return True
if obj.access_level != RoomAccessLevel.TRUSTED: if not obj.configuration.get(self.capability, True):
return False return False
self.message = "You must be connected to the meeting to manage its lobby."
try: try:
return ParticipantsManagement().check_if_in_meeting_cached( return ParticipantsManagement().check_if_in_meeting(
room_name=str(obj.pk), identity=str(user.sub) room_name=str(obj.pk), identity=str(user.sub)
) )
except ParticipantNotFoundException: except ParticipantNotFoundException:
return False return False
except ParticipantsManagementException: except ParticipantsManagementException:
return False return False
class CanRecord(RoomCapabilityPermission):
"""Wip."""
capability = "authenticated_can_record"
+1
View File
@@ -349,6 +349,7 @@ class RoomConfiguration(BaseModel):
can_publish_sources: list[TrackSource] | None = None can_publish_sources: list[TrackSource] | None = None
everyone_can_mute: bool | None = None everyone_can_mute: bool | None = None
authenticated_can_record: bool | None = None
model_config = {"extra": "forbid"} model_config = {"extra": "forbid"}
+100 -27
View File
@@ -45,11 +45,25 @@ from core.api import throttling
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 RecordingProcessWebhookAuthentication from core.recording.event.authentication import (
RecordingProcessWebhookAuthentication,
StorageEventAuthentication,
)
from core.recording.event.exceptions import (
InvalidBucketError,
InvalidFilepathError,
InvalidFileTypeError,
ParsingEventDataError,
)
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,
@@ -382,7 +396,7 @@ class RoomViewSet(
methods=["post"], methods=["post"],
url_path="start-recording", url_path="start-recording",
permission_classes=[ permission_classes=[
permissions.HasPrivilegesOnRoom, permissions.CanRecord,
], ],
) )
@FeatureFlag.require("recording") @FeatureFlag.require("recording")
@@ -407,11 +421,30 @@ class RoomViewSet(
mode=mode, mode=mode,
options=options.model_dump(exclude_none=True) if options else {}, options=options.model_dump(exclude_none=True) if options else {},
) )
models.RecordingAccess.objects.create(
user=self.request.user, if room.is_administrator_or_owner(request.user):
role=models.RoleChoices.OWNER, models.RecordingAccess.objects.create(
recording=recording, user=self.request.user,
) role=models.RoleChoices.OWNER,
recording=recording,
)
else:
# todo - encapsulate it in a clear method which replicate privileges role on obj
accesses = models.ResourceAccess.objects.filter(
resource=room,
role__in=[models.RoleChoices.OWNER, models.RoleChoices.ADMIN],
).values_list("user_id", "role")
models.RecordingAccess.objects.bulk_create(
[
models.RecordingAccess(
user_id=user_id,
role=role,
recording=recording,
)
for user_id, role in accesses
]
)
except (DjangoValidationError, IntegrityError): except (DjangoValidationError, IntegrityError):
# DjangoValidationError covers the Python-level check (full_clean); # DjangoValidationError covers the Python-level check (full_clean);
@@ -455,7 +488,7 @@ class RoomViewSet(
methods=["post"], methods=["post"],
url_path="stop-recording", url_path="stop-recording",
permission_classes=[ permission_classes=[
permissions.HasPrivilegesOnRoom, permissions.CanRecord,
], ],
) )
@FeatureFlag.require("recording") @FeatureFlag.require("recording")
@@ -522,7 +555,7 @@ class RoomViewSet(
methods=["post"], methods=["post"],
url_path="enter", url_path="enter",
permission_classes=[ permission_classes=[
permissions.CanManageLobby, permissions.HasPrivilegesOnRoom,
], ],
) )
def allow_participant_to_enter(self, request, pk=None): # pylint: disable=unused-argument def allow_participant_to_enter(self, request, pk=None): # pylint: disable=unused-argument
@@ -560,7 +593,7 @@ class RoomViewSet(
methods=["GET"], methods=["GET"],
url_path="waiting-participants", url_path="waiting-participants",
permission_classes=[ permission_classes=[
permissions.CanManageLobby, permissions.HasPrivilegesOnRoom,
], ],
) )
def list_waiting_participants(self, request, pk=None): # pylint: disable=unused-argument def list_waiting_participants(self, request, pk=None): # pylint: disable=unused-argument
@@ -1020,6 +1053,56 @@ class RecordingViewSet(
.filter(Q(accesses__user=user) | Q(accesses__team__in=user.get_teams())) .filter(Q(accesses__user=user) | Q(accesses__team__in=user.get_teams()))
) )
@decorators.action(
detail=False,
methods=["post"],
url_path="storage-hook",
authentication_classes=[StorageEventAuthentication],
)
@FeatureFlag.require("storage_event")
def on_storage_event_received(self, request, pk=None): # pylint: disable=unused-argument
"""Handle incoming storage hook events for recordings."""
parser = get_parser()
try:
recording_id = parser.get_recording_id(request.data)
except ParsingEventDataError as e:
raise drf_exceptions.PermissionDenied("Invalid request data.") from e
except InvalidBucketError as e:
raise drf_exceptions.PermissionDenied("Invalid bucket specified.") from e
except InvalidFilepathError:
return drf_response.Response(
{"message": "Notification ignored."},
)
except InvalidFileTypeError:
return drf_response.Response(
{"message": "Notification ignored."},
)
try:
recording = models.Recording.objects.get(id=recording_id)
except models.Recording.DoesNotExist as e:
raise drf_exceptions.NotFound("No recording found for this event.") from e
# Save recording
recording_events_service = RecordingEventsService()
try:
recording_events_service.handle_complete(recording)
except RecordingNotSavableError:
raise drf_exceptions.PermissionDenied(
f"Recording with ID {recording_id} cannot be saved because it is either,"
" in an error state or has already been saved."
) from None
return drf_response.Response(
{"message": "Event processed."},
)
@decorators.action( @decorators.action(
detail=False, detail=False,
methods=["post"], methods=["post"],
@@ -1076,10 +1159,9 @@ class RecordingViewSet(
def _auth_get_original_url(self, request): def _auth_get_original_url(self, request):
""" """
Extracts and parses the original URL from the configured header. Extracts and parses the original URL from the "HTTP_X_ORIGINAL_URL" header.
Raises PermissionDenied if the header is missing. Raises PermissionDenied if the header is missing.
The original url is passed by the reverse proxy in the header named by the The original url is passed by nginx in the "HTTP_X_ORIGINAL_URL" header.
MEDIA_AUTH_ORIGINAL_URL_HEADER setting, which defaults to "HTTP_X_ORIGINAL_URL".
See corresponding ingress configuration in Helm chart and read about the See corresponding ingress configuration in Helm chart and read about the
nginx.ingress.kubernetes.io/auth-url annotation to understand how the Nginx ingress nginx.ingress.kubernetes.io/auth-url annotation to understand how the Nginx ingress
is configured to do this. is configured to do this.
@@ -1089,13 +1171,9 @@ class RecordingViewSet(
reasons. reasons.
""" """
# Extract the original URL from the request header # Extract the original URL from the request header
original_url = request.META.get(settings.MEDIA_AUTH_ORIGINAL_URL_HEADER) original_url = request.META.get("HTTP_X_ORIGINAL_URL")
if not original_url: if not original_url:
logger.warning( logger.warning("Missing HTTP_X_ORIGINAL_URL header in subrequest")
"Missing %s header in subrequest. Set MEDIA_AUTH_ORIGINAL_URL_HEADER "
"to the header your reverse proxy sends.",
settings.MEDIA_AUTH_ORIGINAL_URL_HEADER,
)
raise drf_exceptions.PermissionDenied() raise drf_exceptions.PermissionDenied()
logger.debug("Original url: '%s'", original_url) logger.debug("Original url: '%s'", original_url)
@@ -1420,8 +1498,7 @@ class FileViewSet(
Authorize access based on the original URL of an Nginx subrequest Authorize access based on the original URL of an Nginx subrequest
and user permissions. Returns a dictionary of URL parameters if authorized. and user permissions. Returns a dictionary of URL parameters if authorized.
The original url is passed by the reverse proxy in the header named by the The original url is passed by nginx in the "HTTP_X_ORIGINAL_URL" header.
MEDIA_AUTH_ORIGINAL_URL_HEADER setting, which defaults to "HTTP_X_ORIGINAL_URL".
See corresponding ingress configuration in Helm chart and read about the See corresponding ingress configuration in Helm chart and read about the
nginx.ingress.kubernetes.io/auth-url annotation to understand how the Nginx ingress nginx.ingress.kubernetes.io/auth-url annotation to understand how the Nginx ingress
is configured to do this. is configured to do this.
@@ -1440,13 +1517,9 @@ class FileViewSet(
- PermissionDenied if authorization fails. - PermissionDenied if authorization fails.
""" """
# Extract the original URL from the request header # Extract the original URL from the request header
original_url = request.META.get(settings.MEDIA_AUTH_ORIGINAL_URL_HEADER) original_url = request.META.get("HTTP_X_ORIGINAL_URL")
if not original_url: if not original_url:
logger.warning( logger.warning("Missing HTTP_X_ORIGINAL_URL header in subrequest")
"Missing %s header in subrequest. Set MEDIA_AUTH_ORIGINAL_URL_HEADER "
"to the header your reverse proxy sends.",
settings.MEDIA_AUTH_ORIGINAL_URL_HEADER,
)
raise drf_exceptions.PermissionDenied() raise drf_exceptions.PermissionDenied()
parsed_url = urlparse(original_url) parsed_url = urlparse(original_url)
+1 -19
View File
@@ -3,11 +3,7 @@
import contextlib import contextlib
from django.conf import settings from django.conf import settings
from django.core.exceptions import ( from django.core.exceptions import ImproperlyConfigured, SuspiciousOperation
ImproperlyConfigured,
SuspiciousOperation,
ValidationError,
)
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from lasuite.oidc_login.backends import ( from lasuite.oidc_login.backends import (
@@ -21,7 +17,6 @@ from core.services.marketing import (
ContactData, ContactData,
get_marketing_service, get_marketing_service,
) )
from core.validators import sub_validator
class OIDCAuthenticationBackend(LaSuiteOIDCAuthenticationBackend): class OIDCAuthenticationBackend(LaSuiteOIDCAuthenticationBackend):
@@ -89,19 +84,6 @@ class OIDCAuthenticationBackend(LaSuiteOIDCAuthenticationBackend):
def get_existing_user(self, sub, email): def get_existing_user(self, sub, email):
"""Fetch existing user by sub or email.""" """Fetch existing user by sub or email."""
sub = str(sub)
try:
sub_validator(sub)
except ValidationError as err:
raise SuspiciousOperation(
"User info contained an invalid sub claim"
) from err
if len(sub) > 255:
raise SuspiciousOperation("User info contained an invalid sub claim")
try: try:
return User.objects.get(sub=sub) return User.objects.get(sub=sub)
except User.DoesNotExist: except User.DoesNotExist:
@@ -12,9 +12,6 @@ from rest_framework import decorators, mixins, viewsets
from rest_framework import ( from rest_framework import (
exceptions as drf_exceptions, exceptions as drf_exceptions,
) )
from rest_framework import (
parsers as drf_parsers,
)
from rest_framework import ( from rest_framework import (
response as drf_response, response as drf_response,
) )
@@ -44,7 +41,6 @@ class ApplicationViewSet(viewsets.ViewSet):
methods=["post"], methods=["post"],
url_path="token", url_path="token",
url_name="token", url_name="token",
parser_classes=[drf_parsers.FormParser, drf_parsers.JSONParser],
) )
@FeatureFlag.require("application") @FeatureFlag.require("application")
def generate_jwt_access_token(self, request, *args, **kwargs): def generate_jwt_access_token(self, request, *args, **kwargs):
+1 -3
View File
@@ -8,8 +8,6 @@ import uuid
from django.conf import settings from django.conf import settings
from django.db import migrations, models from django.db import migrations, models
import core.validators
class Migration(migrations.Migration): class Migration(migrations.Migration):
@@ -43,7 +41,7 @@ class Migration(migrations.Migration):
('id', models.UUIDField(default=uuid.uuid4, editable=False, help_text='primary key for the record as UUID', primary_key=True, serialize=False, verbose_name='id')), ('id', models.UUIDField(default=uuid.uuid4, editable=False, help_text='primary key for the record as UUID', primary_key=True, serialize=False, verbose_name='id')),
('created_at', models.DateTimeField(auto_now_add=True, help_text='date and time at which a record was created', verbose_name='created on')), ('created_at', models.DateTimeField(auto_now_add=True, help_text='date and time at which a record was created', verbose_name='created on')),
('updated_at', models.DateTimeField(auto_now=True, help_text='date and time at which a record was last updated', verbose_name='updated on')), ('updated_at', models.DateTimeField(auto_now=True, help_text='date and time at which a record was last updated', verbose_name='updated on')),
('sub', models.CharField(blank=True, help_text='Optional for pending users; required upon account activation. 255 characters or fewer. Printable ASCII characters only.', max_length=255, null=True, unique=True, validators=[core.validators.sub_validator], verbose_name='sub')), ('sub', models.CharField(blank=True, help_text='Optional for pending users; required upon account activation. 255 characters or fewer. Letters, numbers, and @/./+/-/_ characters only.', max_length=255, null=True, unique=True, validators=[django.core.validators.RegexValidator(message='Enter a valid sub. This value may contain only letters, numbers, and @/./+/-/_ characters.', regex='^[\\w.@+-]+\\Z')], verbose_name='sub')),
('email', models.EmailField(blank=True, max_length=254, null=True, verbose_name='identity email address')), ('email', models.EmailField(blank=True, max_length=254, null=True, verbose_name='identity email address')),
('admin_email', models.EmailField(blank=True, max_length=254, null=True, unique=True, verbose_name='admin email address')), ('admin_email', models.EmailField(blank=True, max_length=254, null=True, unique=True, verbose_name='admin email address')),
('language', models.CharField(choices=settings.LANGUAGES, default=settings.LANGUAGE_CODE, help_text='The language in which the user wants to see the interface.', max_length=10, verbose_name='language')), ('language', models.CharField(choices=settings.LANGUAGES, default=settings.LANGUAGE_CODE, help_text='The language in which the user wants to see the interface.', max_length=10, verbose_name='language')),
+9 -2
View File
@@ -27,7 +27,6 @@ from timezone_field import TimeZoneField
from . import fields, utils from . import fields, utils
from .recording.enums import FileExtension from .recording.enums import FileExtension
from .validators import sub_validator
logger = getLogger(__name__) logger = getLogger(__name__)
@@ -146,11 +145,19 @@ class BaseModel(models.Model):
class User(AbstractBaseUser, BaseModel, auth_models.PermissionsMixin): class User(AbstractBaseUser, BaseModel, auth_models.PermissionsMixin):
"""User model to work with OIDC only authentication.""" """User model to work with OIDC only authentication."""
sub_validator = validators.RegexValidator(
regex=r"^[\w.@+-]+\Z",
message=_(
"Enter a valid sub. This value may contain only letters, "
"numbers, and @/./+/-/_ characters."
),
)
sub = models.CharField( sub = models.CharField(
_("sub"), _("sub"),
help_text=_( help_text=_(
"Optional for pending users; required upon account activation. " "Optional for pending users; required upon account activation. "
"255 characters or fewer. Printable ASCII characters only." "255 characters or fewer. Letters, numbers, and @/./+/-/_ characters only."
), ),
max_length=255, max_length=255,
unique=True, unique=True,
@@ -1,4 +1,4 @@
"""Authentication classes for server-to-server webhook token validation.""" """Authentication class for storage event token validation."""
import logging import logging
import secrets import secrets
@@ -12,9 +12,9 @@ logger = logging.getLogger(__name__)
class MachineUser: class MachineUser:
"""Represent a non-interactive system user for automated operations.""" """Represent a non-interactive system user for automated storage operations."""
def __init__(self, username: str = "machine_user") -> None: def __init__(self, username: str = "storage_event_user") -> None:
self.pk = None self.pk = None
self.username = username self.username = username
self.is_active = True self.is_active = True
@@ -41,17 +41,24 @@ class HeaderBasedAuthentication(BaseAuthentication):
TOKEN_TYPE = "Bearer" # noqa S105 TOKEN_TYPE = "Bearer" # noqa S105
REALM = "" REALM = ""
IS_ENFORCED_SETTINGS_KEY = None
EXPECTED_TOKEN_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 self.IS_ENFORCED_SETTINGS_KEY is not None:
if not getattr(settings, self.IS_ENFORCED_SETTINGS_KEY):
return MachineUser(), None
if ( if (
self.EXPECTED_TOKEN_SETTINGS_KEY is None self.EXPECTED_TOKEN_SETTINGS_KEY is None
or (required_token := getattr(settings, self.EXPECTED_TOKEN_SETTINGS_KEY)) or (required_token := getattr(settings, self.EXPECTED_TOKEN_SETTINGS_KEY))
is None is None
): ):
raise AuthenticationFailed("Authentication token is not configured.") raise AuthenticationFailed(
"Authentication is enabled but token is not configured."
)
auth_header = request.headers.get(self.AUTH_HEADER) auth_header = request.headers.get(self.AUTH_HEADER)
if not auth_header: if not auth_header:
@@ -81,6 +88,18 @@ class HeaderBasedAuthentication(BaseAuthentication):
return f"{self.TOKEN_TYPE} realm='{self.REALM}'" 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): class RecordingProcessWebhookAuthentication(HeaderBasedAuthentication):
""" """
Custom authentication class for recording process webhook requests. Custom authentication class for recording process webhook requests.
@@ -0,0 +1,17 @@
"""Storage parsers specific exceptions."""
class ParsingEventDataError(Exception):
"""Raised when the request data is malformed, incomplete, or missing."""
class InvalidBucketError(Exception):
"""Raised when the bucket name in the request does not match the expected one."""
class InvalidFileTypeError(Exception):
"""Raised when the file type in the request is not supported."""
class InvalidFilepathError(Exception):
"""Raised when the filepath in the request is invalid."""
+178
View File
@@ -0,0 +1,178 @@
"""Meet storage event parser classes."""
import logging
import mimetypes
import re
from dataclasses import dataclass
from functools import lru_cache
from typing import Any, Dict, Optional, Protocol
from urllib.parse import quote
from django.conf import settings
from django.utils.module_loading import import_string
from core.enums import FILE_EXT_REGEX, UUID_REGEX
from .exceptions import (
InvalidBucketError,
InvalidFilepathError,
InvalidFileTypeError,
ParsingEventDataError,
)
# Additional MIME type mapping
mimetypes.add_type("audio/ogg", ".ogg")
logger = logging.getLogger(__name__)
@dataclass
class StorageEvent:
"""Represents a storage event with relevant metadata.
Attributes:
filepath: Identifier for the affected recording
filetype: Type of storage event
bucket_name: When the event occurred
metadata: Additional event data
"""
filepath: str
filetype: str
bucket_name: str
metadata: Optional[Dict[str, Any]]
def __post_init__(self):
if self.filepath is None:
raise TypeError("filepath cannot be None")
if self.filetype is None:
raise TypeError("filetype cannot be None")
if self.bucket_name is None:
raise TypeError("bucket_name cannot be None")
class EventParser(Protocol):
"""Interface for parsing storage events."""
def __init__(self, bucket_name, allowed_filetypes=None):
"""Initialize parser with bucket name and optional allowed filetypes."""
def parse(self, data: Dict) -> StorageEvent:
"""Extract storage event data from raw dictionary input."""
def validate(self, data: StorageEvent) -> str:
"""Verify storage event data meets all requirements."""
def get_recording_id(self, data: Dict) -> str:
"""Extract recording ID from event dictionary."""
@lru_cache(maxsize=1)
def get_parser() -> EventParser:
"""Return cached instance of configured event parser.
Uses function memoization instead of a factory class since the only
varying parameter is the parser class from settings. A factory class
would add unnecessary complexity when a cached function provides the
same singleton behavior with simpler code.
"""
event_parser_cls = import_string(settings.RECORDING_EVENT_PARSER_CLASS)
return event_parser_cls(bucket_name=settings.AWS_STORAGE_BUCKET_NAME)
class BaseS3Parser:
"""Base class for handling parsing and validation of S3-compatible storage events."""
def __init__(self, bucket_name: str, allowed_filetypes=None):
"""Initialize parser with target bucket name and accepted filetypes."""
if not bucket_name:
raise ValueError("Bucket name cannot be None or empty")
self._bucket_name = bucket_name
self._allowed_filetypes = allowed_filetypes or {"audio/ogg", "video/mp4"}
# pylint: disable=line-too-long
self._filepath_regex = re.compile(
rf"(?P<url_encoded_folder_path>(?:[^%]+%2F)+)?{settings.RECORDING_OUTPUT_FOLDER}%2F(?P<recording_id>{UUID_REGEX})\.(?P<extension>{FILE_EXT_REGEX})"
)
def validate(self, event_data: StorageEvent) -> str:
"""Verify StorageEvent matches bucket, filetype and filepath requirements."""
if event_data.bucket_name != self._bucket_name:
raise InvalidBucketError(
f"Invalid bucket: expected {self._bucket_name}, got {event_data.bucket_name}"
)
if event_data.filetype not in self._allowed_filetypes:
raise InvalidFileTypeError(
f"Invalid file type, expected {self._allowed_filetypes},"
f"got '{event_data.filetype}'"
)
match = self._filepath_regex.match(event_data.filepath)
if not match:
raise InvalidFilepathError(
f"Invalid filepath structure: {event_data.filepath}"
)
recording_id = match.group("recording_id")
return recording_id
def get_recording_id(self, data):
"""Extract recording ID from S3 event through parsing and validation."""
event_data = self.parse(data)
return self.validate(event_data)
def parse(self, data: Dict) -> StorageEvent:
"""To be implemented by subclasses."""
raise NotImplementedError("Subclasses must implement parse()")
class MinioParser(BaseS3Parser):
"""Minio specific event parsing."""
def parse(self, data: Dict) -> StorageEvent:
if not data:
raise ParsingEventDataError("Received empty data.")
try:
record = data["Records"][0]
s3 = record["s3"]
return StorageEvent(
filepath=s3["object"]["key"],
filetype=s3["object"]["contentType"], # Minio-specific field
bucket_name=s3["bucket"]["name"],
metadata=None,
)
except (KeyError, IndexError) as e:
raise ParsingEventDataError(f"Malformed Minio event: {e}") from e
except TypeError as e:
raise ParsingEventDataError(f"Missing essential data fields: {e}") from e
class S3Parser(BaseS3Parser):
"""AWS S3 specific event parsing."""
def parse(self, data: Dict) -> StorageEvent:
if not data:
raise ParsingEventDataError("Received empty data.")
try:
# AWS S3 structure can slightly differ from Minio implementation
record = data["Records"][0]
s3 = record["s3"]
filepath = s3["object"]["key"]
if not filepath:
raise ParsingEventDataError("Missing object key name")
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(
filepath=filepath,
filetype=filetype,
bucket_name=s3["bucket"]["name"],
metadata=None,
)
except (KeyError, IndexError) as e:
raise ParsingEventDataError(f"Malformed S3 event: {e}") from e
+4 -27
View File
@@ -23,7 +23,6 @@ from core.recording.services.recording_events import (
) )
from .lobby import LobbyService from .lobby import LobbyService
from .presence import PresenceCache
from .room_management import ( from .room_management import (
RoomManagement, RoomManagement,
RoomManagementException, RoomManagementException,
@@ -100,7 +99,6 @@ class LiveKitEventsService:
"egress_ended": self._handle_egress_ended, "egress_ended": self._handle_egress_ended,
"room_started": self._handle_room_started, "room_started": self._handle_room_started,
"room_finished": self._handle_room_finished, "room_finished": self._handle_room_finished,
"participant_left": self._handle_participant_left,
} }
token_verifier = api.TokenVerifier( token_verifier = api.TokenVerifier(
@@ -109,7 +107,6 @@ class LiveKitEventsService:
) )
self.webhook_receiver = api.WebhookReceiver(token_verifier) self.webhook_receiver = api.WebhookReceiver(token_verifier)
self.lobby_service = LobbyService() self.lobby_service = LobbyService()
self.presence_cache = PresenceCache()
self.sip_management = SIPManagement() self.sip_management = SIPManagement()
self.recording_events = RecordingEventsService() self.recording_events = RecordingEventsService()
@@ -220,8 +217,10 @@ 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
# Finalize the recording, the egress has uploaded the file to the storage # Fallback for completion when no MinIO/S3 webhooks are configured
if data.egress_info.status in [ if (
not settings.RECORDING_STORAGE_EVENT_ENABLE
) and data.egress_info.status in [
api.EgressStatus.EGRESS_COMPLETE, api.EgressStatus.EGRESS_COMPLETE,
api.EgressStatus.EGRESS_LIMIT_REACHED, api.EgressStatus.EGRESS_LIMIT_REACHED,
]: ]:
@@ -286,31 +285,9 @@ class LiveKitEventsService:
f"Failed to delete sip dispatch rule for room {room_id}" f"Failed to delete sip dispatch rule for room {room_id}"
) from e ) from e
self.presence_cache.clear_room(room_id)
try: try:
self.lobby_service.clear_room_cache(room_id) self.lobby_service.clear_room_cache(room_id)
except Exception as e: except Exception as e:
raise ActionFailedError( raise ActionFailedError(
f"Failed to clear room cache for room {room_id}" f"Failed to clear room cache for room {room_id}"
) from e ) from e
def _handle_participant_left(self, data):
"""Handle 'participant_left': invalidate the presence cache.
Presence entries are created lazily (only for users who administrate
the lobby of a trusted room), so for most participants this delete is
a no-op DEL on a key that never existed. Eager invalidation shrinks
the window during which a departed participant could still act on a
trusted room's lobby (cache hit until TTL expiry). It is gated behind
`PRESENCE_CLEAR_ON_PARTICIPANT_LEFT` so its production impact can be
measured and the behaviour reverted independently of the feature.
When disabled, invalidation relies on `room_finished` and the TTL.
"""
if not settings.PRESENCE_CLEAR_ON_PARTICIPANT_LEFT:
return
identity = data.participant.identity
if not identity:
return
self.presence_cache.clear(data.room.name, identity)
+8 -4
View File
@@ -270,7 +270,7 @@ class LobbyService:
"""List all waiting participants for a room.""" """List all waiting participants for a room."""
pattern = self._get_cache_key(room_id, "*") pattern = self._get_cache_key(room_id, "*")
keys = list(cache.iter_keys(pattern, itersize=utils.CACHE_SCAN_ITERSIZE)) keys = cache.keys(pattern)
if not keys: if not keys:
return [] return []
@@ -345,9 +345,13 @@ class LobbyService:
def clear_room_cache(self, room_id: UUID) -> None: def clear_room_cache(self, room_id: UUID) -> None:
"""Clear all participant entries from the cache for a specific room.""" """Clear all participant entries from the cache for a specific room."""
cache.delete_pattern( pattern = self._get_cache_key(room_id, "*")
self._get_cache_key(room_id, "*"), itersize=utils.CACHE_SCAN_ITERSIZE keys = cache.keys(pattern)
)
if not keys:
return
cache.delete_many(keys)
def clear_participant_cache(self, room_id: UUID, participant_id: str) -> None: def clear_participant_cache(self, room_id: UUID, participant_id: str) -> None:
"""Clear a given participant entry from the cache for a specific room.""" """Clear a given participant entry from the cache for a specific room."""
@@ -20,7 +20,6 @@ from livekit.protocol.models import ParticipantInfo
from core import utils from core import utils
from .lobby import LobbyService from .lobby import LobbyService
from .presence import PresenceCache
logger = getLogger(__name__) logger = getLogger(__name__)
@@ -73,9 +72,7 @@ class ParticipantsManagement:
@async_to_sync @async_to_sync
async def remove(self, room_name: str, identity: str): async def remove(self, room_name: str, identity: str):
"""Remove a participant from a room and clear their lobby/presence cache.""" """Remove a participant from a room and clear their lobby cache."""
PresenceCache().clear(room_name, identity)
try: try:
LobbyService().clear_participant_cache( LobbyService().clear_participant_cache(
@@ -159,30 +156,6 @@ class ParticipantsManagement:
finally: finally:
await lkapi.aclose() await lkapi.aclose()
def check_if_in_meeting_cached(self, room_name: str, identity: str) -> bool:
"""Cache-first variant of `check_if_in_meeting`.
Cache hit -> True without touching LiveKit.
Cache miss -> ask LiveKit; memoize only positive answers.
Raises the same exceptions as `check_if_in_meeting` so callers keep
failing closed the same way.
"""
if not room_name or not identity:
return False
presence_cache = PresenceCache()
if presence_cache.is_marked_present(room_name, identity):
return True
present = self.check_if_in_meeting(room_name=room_name, identity=identity)
if present:
presence_cache.mark_present(room_name, identity)
return present
@async_to_sync @async_to_sync
async def check_if_in_meeting(self, room_name: str, identity: str) -> bool: async def check_if_in_meeting(self, room_name: str, identity: str) -> bool:
"""Check whether `identity` is currently a participant in `room_name`. """Check whether `identity` is currently a participant in `room_name`.
-52
View File
@@ -1,52 +0,0 @@
"""Presence cache.
Redis-backed memo of "this identity is currently connected to this room".
This module is intentionally a *pure cache store* with no dependency on other
services, so that `participants_management` (which talks to LiveKit) can
import it without creating an import cycle. The composition of "check cache,
fall back to LiveKit" lives in
`ParticipantsManagement.check_if_in_meeting_cached`.
Only positive answers are stored: a sticky negative would lock out someone
who joins right after a miss for the whole TTL. The TTL is a safety net in
case an invalidation webhook is lost.
"""
from uuid import UUID
from django.conf import settings
from django.core.cache import cache
from core.utils import CACHE_SCAN_ITERSIZE
class PresenceCache:
"""Store and invalidate (room, identity) presence entries."""
@staticmethod
def _get_cache_key(room_id: UUID | str, identity: str) -> str:
"""Cache key for a (room, identity) presence entry."""
return f"{settings.PRESENCE_KEY_PREFIX}_{room_id!s}_{identity}"
def is_marked_present(self, room_id: UUID | str, identity: str) -> bool:
"""Return True if a positive presence entry exists in cache."""
return bool(cache.get(self._get_cache_key(room_id, identity)))
def mark_present(self, room_id: UUID | str, identity: str) -> None:
"""Record that `identity` is in `room_id`."""
cache.set(
self._get_cache_key(room_id, identity),
True,
timeout=settings.PRESENCE_CACHE_TIMEOUT,
)
def clear(self, room_id: UUID | str, identity: str) -> None:
"""Forget presence for one participant (e.g. on participant_left)."""
cache.delete(self._get_cache_key(room_id, identity))
def clear_room(self, room_id: UUID | str) -> None:
"""Forget presence for every participant of a room (on room_finished)."""
cache.delete_pattern(
self._get_cache_key(room_id, "*"), itersize=CACHE_SCAN_ITERSIZE
)
@@ -40,111 +40,6 @@ def test_authentication_getter_existing_user(monkeypatch):
assert user == db_user assert user == db_user
@pytest.mark.parametrize(
"sub",
[
# NUL (U+0000) passes str.isascii() but PostgreSQL text fields
# cannot store or compare it (DataError)
"auth0|abc\x00def",
# lone surrogates cannot be encoded to UTF-8 for the DB lookup
# (UnicodeEncodeError), which runs before any model validation
"bad\ud800sub",
# plainly invalid subs would otherwise escape as ValidationError
# on user creation, which mozilla-django-oidc does not catch
"\u00e9milie",
"a" * 256,
# ASCII control characters are rejected by policy
"tab\tsub",
"del\x7fsub",
],
)
def test_authentication_getter_invalid_sub_rejected_cleanly(monkeypatch, sub):
"""
Subs that can never be persisted should be rejected with
SuspiciousOperation (turned into a clean authentication failure by
mozilla-django-oidc) instead of leaking DataError, UnicodeEncodeError
or ValidationError as a server error.
"""
klass = OIDCAuthenticationBackend()
def get_userinfo_mocked(*args):
return {"sub": sub, "email": "john@example.com"}
monkeypatch.setattr(OIDCAuthenticationBackend, "get_userinfo", get_userinfo_mocked)
with pytest.raises(
SuspiciousOperation,
match="User info contained an invalid sub claim",
):
klass.get_or_create_user(access_token="test-token", id_token=None, payload=None)
assert models.User.objects.exists() is False
def test_authentication_getter_numeric_sub(monkeypatch):
"""
Some providers serialize the sub as a JSON number. It should keep working
(CharField coerces it to a string on save) and must not crash the early
sub checks in get_existing_user.
"""
klass = OIDCAuthenticationBackend()
def get_userinfo_mocked(*args):
return {"sub": 12345, "email": "john@example.com"}
monkeypatch.setattr(OIDCAuthenticationBackend, "get_userinfo", get_userinfo_mocked)
user = klass.get_or_create_user(
access_token="test-token", id_token=None, payload=None
)
assert user.sub == "12345"
assert models.User.objects.count() == 1
def test_authentication_getter_new_user_auth0_pipe_sub(monkeypatch):
"""
A first login with an Auth0-style sub containing a pipe ("provider|user-id")
should create the user instead of raising a ValidationError.
Regression test for https://github.com/suitenumerique/meet/issues/[XXX].
"""
klass = OIDCAuthenticationBackend()
def get_userinfo_mocked(*args):
return {"sub": "auth0|644c0bc8f1874ef6d339fb34", "email": "john@example.com"}
monkeypatch.setattr(OIDCAuthenticationBackend, "get_userinfo", get_userinfo_mocked)
user = klass.get_or_create_user(
access_token="test-token", id_token=None, payload=None
)
assert user.sub == "auth0|644c0bc8f1874ef6d339fb34"
assert user.email == "john@example.com"
assert models.User.objects.count() == 1
def test_authentication_getter_existing_user_auth0_pipe_sub(monkeypatch):
"""
A returning user with an Auth0-style pipe sub should be matched by sub,
not duplicated or rejected.
"""
klass = OIDCAuthenticationBackend()
db_user = UserFactory(sub="auth0|644c0bc8f1874ef6d339fb34")
def get_userinfo_mocked(*args):
return {"sub": db_user.sub}
monkeypatch.setattr(OIDCAuthenticationBackend, "get_userinfo", get_userinfo_mocked)
user = klass.get_or_create_user(
access_token="test-token", id_token=None, payload=None
)
assert user == db_user
assert models.User.objects.count() == 1
def test_authentication_getter_new_user_no_email(monkeypatch): def test_authentication_getter_new_user_no_email(monkeypatch):
""" """
If no user matches, a user should be created. If no user matches, a user should be created.
@@ -7,7 +7,6 @@ from urllib.parse import quote, urlparse
from django.conf import settings from django.conf import settings
from django.core.files.storage import default_storage from django.core.files.storage import default_storage
from django.test import override_settings
from django.utils import timezone from django.utils import timezone
import pytest import pytest
@@ -144,59 +143,3 @@ def test_api_files_media_auth_own_file_deleted():
) )
assert response.status_code == 403 assert response.status_code == 403
@override_settings(MEDIA_AUTH_ORIGINAL_URL_HEADER="HTTP_X_FORWARDED_URI")
def test_api_files_media_auth_custom_original_url_header():
"""
Authorization should honour the configured original-url header.
Covers the attachment subrequest path, which resolves the header separately
from the recording one. Reverse proxies other than nginx-ingress use
different headers: Traefik's ForwardAuth sends X-Forwarded-Uri and cannot
emit X-Original-URL at all.
"""
user = factories.UserFactory()
file = factories.FileFactory(
type=models.FileTypeChoices.BACKGROUND_IMAGE,
update_upload_state=models.FileUploadStateChoices.READY,
creator=user,
)
client = APIClient()
client.force_login(user)
default_storage.save(file.file_key, BytesIO(b"my prose"))
original_url = f"http://localhost/media/{file.file_key:s}"
response = client.get(
"/api/v1.0/files/media-auth/", HTTP_X_FORWARDED_URI=original_url
)
assert response.status_code == 200
assert "AWS4-HMAC-SHA256 Credential=" in response["Authorization"]
@override_settings(MEDIA_AUTH_ORIGINAL_URL_HEADER="HTTP_X_FORWARDED_URI")
def test_api_files_media_auth_default_header_ignored_when_reconfigured():
"""
Only the configured header should be honoured, never a hardcoded fallback.
"""
user = factories.UserFactory()
file = factories.FileFactory(
type=models.FileTypeChoices.BACKGROUND_IMAGE,
update_upload_state=models.FileUploadStateChoices.READY,
creator=user,
)
client = APIClient()
client.force_login(user)
original_url = f"http://localhost/media/{file.file_key:s}"
response = client.get(
"/api/v1.0/files/media-auth/", HTTP_X_ORIGINAL_URL=original_url
)
assert response.status_code == 403
@@ -11,98 +11,135 @@ from rest_framework.exceptions import AuthenticationFailed
from core.recording.event.authentication import ( from core.recording.event.authentication import (
MachineUser, MachineUser,
RecordingProcessWebhookAuthentication, StorageEventAuthentication,
) )
def test_successful_authentication(settings): def test_successful_authentication(settings):
"""Test successful authentication with valid token.""" """Test successful authentication with valid token."""
settings.SUMMARY_SERVICE_WEBHOOK_API_TOKEN = "valid-test-token" settings.RECORDING_STORAGE_EVENT_TOKEN = "valid-test-token"
request = RequestFactory().get("/") request = RequestFactory().get("/")
request.headers = {"Authorization": "Bearer valid-test-token"} request.headers = {"Authorization": "Bearer valid-test-token"}
user, token = RecordingProcessWebhookAuthentication().authenticate(request) user, token = StorageEventAuthentication().authenticate(request)
assert token == "valid-test-token" assert token == "valid-test-token"
assert isinstance(user, MachineUser) assert isinstance(user, MachineUser)
def test_authentication_fails_when_token_not_configured(settings): def test_disabled_authentication_with_header(settings):
"""Authentication should fail when no token is configured.""" """Authentication should pass when no auth is configured, and header is present."""
settings.RECORDING_STORAGE_EVENT_TOKEN = None
settings.RECORDING_ENABLE_STORAGE_EVENT_AUTH = False
settings.SUMMARY_SERVICE_WEBHOOK_API_TOKEN = None request = RequestFactory().get("/")
request.headers = {"Authorization": "Bearer some-token"}
user, token = StorageEventAuthentication().authenticate(request)
assert token is None
assert isinstance(user, MachineUser)
def test_disabled_authentication_without_header(settings):
"""Authentication should pass when no auth is configured, and no header is present."""
settings.RECORDING_STORAGE_EVENT_TOKEN = None
settings.RECORDING_ENABLE_STORAGE_EVENT_AUTH = False
request = RequestFactory().get("/")
user, token = StorageEventAuthentication().authenticate(request)
assert token is None
assert isinstance(user, MachineUser)
def test_authentication_when_disabled(settings):
"""Authentication should pass when disabled, regardless of token configuration."""
settings.RECORDING_STORAGE_EVENT_TOKEN = "some-token"
settings.RECORDING_ENABLE_STORAGE_EVENT_AUTH = False
request = RequestFactory().get("/")
user, token = StorageEventAuthentication().authenticate(request)
assert token is None
assert isinstance(user, MachineUser)
def test_authentication_fails_when_token_not_configured(settings):
"""Authentication should fail when authentication is enabled but no token is configured."""
# By default RECORDING_ENABLE_STORAGE_EVENT_AUTH should be True
settings.RECORDING_STORAGE_EVENT_TOKEN = None
request = RequestFactory().get("/") request = RequestFactory().get("/")
with pytest.raises( with pytest.raises(
AuthenticationFailed, AuthenticationFailed,
match="Authentication token is not configured", match="Authentication is enabled but token is not configured",
): ):
RecordingProcessWebhookAuthentication().authenticate(request) StorageEventAuthentication().authenticate(request)
def test_missing_auth_header(settings): def test_missing_auth_header(settings):
"""Test failure when Authorization header is missing.""" """Test failure when Authorization header is missing."""
settings.SUMMARY_SERVICE_WEBHOOK_API_TOKEN = "valid-test-token" settings.RECORDING_STORAGE_EVENT_TOKEN = "valid-test-token"
request = RequestFactory().get("/") request = RequestFactory().get("/")
request.headers = {} request.headers = {}
with pytest.raises(AuthenticationFailed, match="Authorization header is required"): with pytest.raises(AuthenticationFailed, match="Authorization header is required"):
RecordingProcessWebhookAuthentication().authenticate(request) StorageEventAuthentication().authenticate(request)
def test_invalid_auth_header_format(settings): def test_invalid_auth_header_format(settings):
"""Test failure when Authorization header has invalid format.""" """Test failure when Authorization header has invalid format."""
settings.SUMMARY_SERVICE_WEBHOOK_API_TOKEN = "valid-test-token" settings.RECORDING_STORAGE_EVENT_TOKEN = "valid-test-token"
request = RequestFactory().get("/") request = RequestFactory().get("/")
request.headers = {"Authorization": "InvalidFormat"} request.headers = {"Authorization": "InvalidFormat"}
with pytest.raises(AuthenticationFailed, match="Invalid authorization header"): with pytest.raises(AuthenticationFailed, match="Invalid authorization header"):
RecordingProcessWebhookAuthentication().authenticate(request) StorageEventAuthentication().authenticate(request)
def test_invalid_token_type(settings): def test_invalid_token_type(settings):
"""Test failure when token type is not Bearer.""" """Test failure when token type is not Bearer."""
settings.SUMMARY_SERVICE_WEBHOOK_API_TOKEN = "valid-test-token" settings.RECORDING_STORAGE_EVENT_TOKEN = "valid-test-token"
request = RequestFactory().get("/") request = RequestFactory().get("/")
request.headers = {"Authorization": "Basic some-token"} request.headers = {"Authorization": "Basic some-token"}
with pytest.raises(AuthenticationFailed, match="Invalid authorization header"): with pytest.raises(AuthenticationFailed, match="Invalid authorization header"):
RecordingProcessWebhookAuthentication().authenticate(request) StorageEventAuthentication().authenticate(request)
def test_invalid_token(settings): def test_invalid_token(settings):
"""Test failure when token is invalid.""" """Test failure when token is invalid."""
settings.SUMMARY_SERVICE_WEBHOOK_API_TOKEN = "valid-test-token" settings.RECORDING_STORAGE_EVENT_TOKEN = "valid-test-token"
request = RequestFactory().get("/") request = RequestFactory().get("/")
request.headers = {"Authorization": "Bearer wrong-token"} request.headers = {"Authorization": "Bearer wrong-token"}
with pytest.raises(AuthenticationFailed, match="Invalid token"): with pytest.raises(AuthenticationFailed, match="Invalid token"):
RecordingProcessWebhookAuthentication().authenticate(request) StorageEventAuthentication().authenticate(request)
def test_malformed_auth_header(settings): def test_malformed_auth_header(settings):
"""Test failure when Authorization header is malformed.""" """Test failure when Authorization header is malformed."""
settings.SUMMARY_SERVICE_WEBHOOK_API_TOKEN = "valid-test-token" settings.RECORDING_STORAGE_EVENT_TOKEN = "valid-test-token"
request = RequestFactory().get("/") request = RequestFactory().get("/")
request.headers = {"Authorization": "Bearer"} # Missing token part request.headers = {"Authorization": "Bearer"} # Missing token part
with pytest.raises(AuthenticationFailed, match="Invalid authorization header"): with pytest.raises(AuthenticationFailed, match="Invalid authorization header"):
RecordingProcessWebhookAuthentication().authenticate(request) StorageEventAuthentication().authenticate(request)
def test_authenticate_header(): def test_authenticate_header():
"""Test the WWW-Authenticate header value.""" """Test the WWW-Authenticate header value."""
request = RequestFactory().get("/") request = RequestFactory().get("/")
header = RecordingProcessWebhookAuthentication().authenticate_header(request) header = StorageEventAuthentication().authenticate_header(request)
assert header == "Bearer realm='External process webhook API'" assert header == "Bearer realm='Storage event API'"
def test_multiple_spaces_in_auth_header(settings): def test_multiple_spaces_in_auth_header(settings):
"""Extra spaces between the scheme and the token should be tolerated.""" """Test success when Authorization header contains multiple spaces."""
settings.SUMMARY_SERVICE_WEBHOOK_API_TOKEN = "extra-spaces-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"}
user, token = RecordingProcessWebhookAuthentication().authenticate(request) header = StorageEventAuthentication().authenticate_header(request)
assert token == "extra-spaces-token" assert header == "Bearer realm='Storage event API'"
assert isinstance(user, MachineUser)
@@ -0,0 +1,512 @@
"""
Test event parsers.
"""
# pylint: disable=protected-access,redefined-outer-name,unused-argument
from unittest import mock
from django.conf import settings
import pytest
from core.recording.event.exceptions import (
InvalidBucketError,
InvalidFilepathError,
InvalidFileTypeError,
ParsingEventDataError,
)
from core.recording.event.parsers import (
MinioParser,
S3Parser,
StorageEvent,
get_parser,
)
# MinioParser
@pytest.fixture
def valid_minio_event():
"""Mock a valid Minio event."""
return {
"Records": [
{
"s3": {
"bucket": {"name": "test-bucket"},
"object": {
"key": "recordings%2F46d1a121-2426-484d-8fb3-09b5d886f7a8.ogg",
"contentType": "audio/ogg",
},
}
}
]
}
@pytest.fixture
def minio_parser():
"""Mock a Minio parser."""
return MinioParser(bucket_name="test-bucket")
def test_minio_parse_valid_event(minio_parser, valid_minio_event):
"""Test parsing a valid Minio event."""
event = minio_parser.parse(valid_minio_event)
assert isinstance(event, StorageEvent)
assert event.filepath == "recordings%2F46d1a121-2426-484d-8fb3-09b5d886f7a8.ogg"
assert event.filetype == "audio/ogg"
assert event.bucket_name == "test-bucket"
assert event.metadata is None
def test_minio_parse_with_video_type(minio_parser):
"""Test parsing event with video file type."""
video_event = {
"Records": [
{
"s3": {
"bucket": {"name": "test-bucket"},
"object": {
"key": "46d1a121-2426-484d-8fb3-09b5d886f7a8.mp4",
"contentType": "video/mp4",
},
}
}
]
}
event = minio_parser.parse(video_event)
assert event.filetype == "video/mp4"
assert event.filepath.endswith(".mp4")
def test_minio_parse_empty_data(minio_parser):
"""Test parsing empty event data raises error."""
with pytest.raises(ParsingEventDataError, match="Received empty data."):
minio_parser.parse({})
def test_minio_parse_missing_keys(minio_parser):
"""Test parsing event with missing key."""
invalid_minio_event = {
"Records": [
{
"s3": {
"bucket": {"name": None},
# Missing 'object' key
}
}
]
}
with pytest.raises(ParsingEventDataError, match="Malformed Minio event:"):
minio_parser.parse(invalid_minio_event)
def test_minio_parse_none_key(minio_parser):
"""Test parsing event with None field."""
invalid_minio_event = {
"Records": [
{
"s3": {
"bucket": {"name": "test-bucket"},
"object": {
"key": "recording%2F46d1a121-2426-484d-8fb3-09b5d886f7a8.ogg",
"contentType": None, # 'contentType' should not be None
},
}
}
]
}
with pytest.raises(ParsingEventDataError, match="Missing essential data fields"):
minio_parser.parse(invalid_minio_event)
def test_minio_validate_invalid_bucket(minio_parser):
"""Test validation with wrong bucket name."""
event = StorageEvent(
filepath="recording%2F46d1a121-2426-484d-8fb3-09b5d886f7a8.ogg",
filetype="audio/ogg",
bucket_name="wrong-bucket",
metadata=None,
)
with pytest.raises(InvalidBucketError):
minio_parser.validate(event)
def test_minio_validate_invalid_filetype(minio_parser):
"""Test validation with unsupported file type."""
event = StorageEvent(
filepath="recording%2F46d1a121-2426-484d-8fb3-09b5d886f7a8.txt",
filetype="text/plain", # Not included in the default allowed filetypes
bucket_name="test-bucket",
metadata=None,
)
with pytest.raises(InvalidFileTypeError):
minio_parser.validate(event)
@pytest.mark.parametrize(
"invalid_filepath",
[
"invalid_filepath", # totally invalid string
"recordings/46d1a121-2426-484d-8fb3-09b5d886f7a8.ogg",
"recordings/46d1a121-2426-484d-8fb3-09b5d886f7a8", # missing extension
"46d1a121-2426-484d-8fb3-09b5d886f7a8", # missing url_encoded_folder_path and extension
"", # empty string
"46d1a121-2426-484d-8fb3-09b5d886f7a8.ogg", # no folder at all
"uploads%2F46d1a121-2426-484d-8fb3-09b5d886f7a8.ogg", # wrong folder name
"folder%2Fuploads%2F46d1a121-2426-484d-8fb3-09b5d886f7a8.ogg", # nested but no recordings/
],
)
def test_minio_validate_invalid_filepath(invalid_filepath, minio_parser):
"""Test validation with malformed filepath."""
event = StorageEvent(
filepath=invalid_filepath,
filetype="audio/ogg",
bucket_name="test-bucket",
metadata=None,
)
with pytest.raises(InvalidFilepathError):
minio_parser.validate(event)
def test_minio_validate_valid_event(minio_parser):
"""Test validation with valid event data."""
event = StorageEvent(
filepath="recordings%2F46d1a121-2426-484d-8fb3-09b5d886f7a8.ogg",
filetype="audio/ogg",
bucket_name="test-bucket",
metadata=None,
)
recording_id = minio_parser.validate(event)
assert recording_id == "46d1a121-2426-484d-8fb3-09b5d886f7a8"
def test_minio_get_recording_id_success(minio_parser, valid_minio_event):
"""Test successful extraction of recording ID."""
recording_id = minio_parser.get_recording_id(valid_minio_event)
assert recording_id == "46d1a121-2426-484d-8fb3-09b5d886f7a8"
def test_minio_validate_filepath_with_folder(minio_parser):
"""Test validation of filepath with folder structure."""
event = StorageEvent(
filepath="parent_folder%2Frecordings%2F46d1a121-2426-484d-8fb3-09b5d886f7a8.ogg",
filetype="audio/ogg",
bucket_name="test-bucket",
metadata=None,
)
recording_id = minio_parser.validate(event)
assert recording_id == "46d1a121-2426-484d-8fb3-09b5d886f7a8"
def test_minio_empty_allowed_filetypes():
"""Test MinioParser with empty allowed_filetypes."""
empty_types = set()
parser = MinioParser(bucket_name="test-bucket", allowed_filetypes=empty_types)
assert parser._allowed_filetypes == {"audio/ogg", "video/mp4"}
def test_minio_custom_allowed_filetypes():
"""Test MinioParser with empty allowed_filetypes."""
custom_types = {"audio/mp3", "video/mov"}
parser = MinioParser(bucket_name="test-bucket", allowed_filetypes=custom_types)
assert parser._allowed_filetypes == {"audio/mp3", "video/mov"}
def test_minio_validate_custom_filetypes():
"""Test validation of filepath with folder structure."""
parser = MinioParser(bucket_name="test-bucket", allowed_filetypes={"audio/mp3"})
event = StorageEvent(
filepath="parent_folder%2Frecordings%2F46d1a121-2426-484d-8fb3-09b5d886f7a8.ogg",
filetype="audio/mp3",
bucket_name="test-bucket",
metadata=None,
)
parser.validate(event)
def test_minio_constructor_none_bucket():
"""Test MinioParser constructor with None bucket name."""
with pytest.raises(ValueError, match="Bucket name cannot be None or empty"):
MinioParser(bucket_name=None)
def test_minio_constructor_empty_bucket():
"""Test MinioParser constructor with empty bucket name."""
with pytest.raises(ValueError, match="Bucket name cannot be None or empty"):
MinioParser(bucket_name="")
# S3Parser
@pytest.fixture
def valid_s3_event():
"""Mock a valid S3 event."""
return {
"Records": [
{
"s3": {
"bucket": {"name": "test-bucket"},
"object": {
"key": "recordings%2F46d1a121-2426-484d-8fb3-09b5d886f7a8.ogg",
},
}
}
]
}
@pytest.fixture
def s3_parser():
"""Mock an S3 parser."""
return S3Parser(bucket_name="test-bucket")
def test_s3_parse_valid_event(s3_parser, valid_s3_event):
"""Test parsing a valid S3 event."""
event = s3_parser.parse(valid_s3_event)
assert isinstance(event, StorageEvent)
assert event.filepath == "recordings%2F46d1a121-2426-484d-8fb3-09b5d886f7a8.ogg"
assert event.filetype == "audio/ogg"
assert event.bucket_name == "test-bucket"
assert event.metadata is None
def test_s3_parse_empty_data(s3_parser):
"""Test parsing empty S3 event data raises error."""
with pytest.raises(ParsingEventDataError, match="Received empty data."):
s3_parser.parse({})
def test_s3_parse_missing_keys(s3_parser):
"""Test parsing S3 event with missing key."""
invalid_s3_event = {
"Records": [
{
"s3": {
"bucket": {"name": "test-bucket"},
# Missing 'object' key
}
}
]
}
with pytest.raises(ParsingEventDataError, match="Malformed S3 event:"):
s3_parser.parse(invalid_s3_event)
def test_s3_parse_none_key(s3_parser):
"""Test parsing S3 event with None field."""
invalid_s3_event = {
"Records": [
{
"s3": {
"bucket": {"name": "test-bucket"},
"object": {
"key": None,
},
}
}
]
}
with pytest.raises(ParsingEventDataError, match="Missing object key name"):
s3_parser.parse(invalid_s3_event)
def test_s3_parse_with_video_type(s3_parser):
"""Test parsing S3 event with mp4 file extension."""
video_event = {
"Records": [
{
"s3": {
"bucket": {"name": "test-bucket"},
"object": {
"key": "recordings%2F46d1a121-2426-484d-8fb3-09b5d886f7a8.mp4",
},
}
}
]
}
event = s3_parser.parse(video_event)
assert event.filetype == "video/mp4"
assert event.filepath.endswith(".mp4")
def test_s3_parse_unrecognized_extension(s3_parser):
"""Test parsing S3 event with unrecognized file extension."""
event_with_unknown_ext = {
"Records": [
{
"s3": {
"bucket": {"name": "test-bucket"},
"object": {
"key": "recordings%2F46d1a121-2426-484d-8fb3-09b5d886f7a8.zzunknown999",
},
}
}
]
}
with pytest.raises(TypeError, match="filetype cannot be None"):
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):
"""Test successful extraction of recording ID from S3 event."""
recording_id = s3_parser.get_recording_id(valid_s3_event)
assert recording_id == "46d1a121-2426-484d-8fb3-09b5d886f7a8"
# get_parser
@pytest.fixture
def clear_lru_cache():
"""Fixture to clear the LRU cache between tests."""
get_parser.cache_clear()
yield
get_parser.cache_clear()
def test_returns_correct_instance(clear_lru_cache):
"""Test if get_parser returns the correct parser instance."""
settings.AWS_STORAGE_BUCKET_NAME = "test-bucket"
parser = get_parser()
assert isinstance(parser, MinioParser)
assert parser._bucket_name == "test-bucket"
def test_caching_behavior(clear_lru_cache):
"""Test if the function properly caches the parser instance."""
settings.AWS_STORAGE_BUCKET_NAME = "test-bucket"
parser1 = get_parser()
parser2 = get_parser()
assert parser1 is parser2 # Check object identity
def test_different_settings_new_instance():
"""Test if changing settings creates a new instance."""
settings.AWS_STORAGE_BUCKET_NAME = "different-bucket"
parser = get_parser()
assert parser._bucket_name == "different-bucket"
def test_import_error_handling(clear_lru_cache):
"""Test handling of import errors for invalid parser class."""
settings.RECORDING_EVENT_PARSER_CLASS = "invalid.parser.path"
with pytest.raises(ImportError):
get_parser()
@mock.patch("core.recording.event.parsers.import_string")
def test_parser_instantiation_called_once(mock_import_string, clear_lru_cache):
"""Test that parser class is instantiated only once due to caching."""
mock_parser_cls = type(
"MockParser",
(),
{
"__init__": lambda self, bucket_name: setattr(
self, "_bucket_name", bucket_name
)
},
)
mock_import_string.return_value = mock_parser_cls
# First call
parser1 = get_parser()
# Second call
parser2 = get_parser()
# Verify import_string was called only once
mock_import_string.assert_called_once_with(settings.RECORDING_EVENT_PARSER_CLASS)
assert parser1 is parser2
def test_cache_clear_behavior(clear_lru_cache, settings):
"""Test that cache clearing creates new instance."""
settings.RECORDING_EVENT_PARSER_CLASS = "core.recording.event.parsers.MinioParser"
parser1 = get_parser()
get_parser.cache_clear()
parser2 = get_parser()
assert parser1 is not parser2 # Should be different instances after cache clear
@@ -12,7 +12,6 @@ from core.factories import RecordingFactory
from core.recording.services.recording_events import ( from core.recording.services.recording_events import (
RecordingEventsError, RecordingEventsError,
RecordingEventsService, RecordingEventsService,
RecordingNotSavableError,
) )
from core.utils import NotificationError from core.utils import NotificationError
@@ -71,56 +70,3 @@ def test_handle_limit_reached_error(mock_notify, mode, notification_type, servic
mock_notify.assert_called_once_with( mock_notify.assert_called_once_with(
room_name=str(recording.room.id), notification_data={"type": notification_type} room_name=str(recording.room.id), notification_data={"type": notification_type}
) )
@pytest.mark.parametrize("status", ["active", "stopped"])
@pytest.mark.parametrize(
("notify_return_value", "expected_status"),
((True, "notification_succeeded"), (False, "saved")),
)
@mock.patch(
"core.recording.services.recording_events.notification_service."
"notify_external_services"
)
def test_handle_complete_saves_recording( # pylint: disable=too-many-arguments, too-many-positional-arguments
mock_notify_external_services,
notify_return_value,
expected_status,
status,
service,
):
"""Test handle_complete notifies external services and saves a savable recording."""
mock_notify_external_services.return_value = notify_return_value
recording = RecordingFactory(status=status)
service.handle_complete(recording)
mock_notify_external_services.assert_called_once_with(recording)
recording.refresh_from_db()
assert recording.status == expected_status
@pytest.mark.parametrize(
"status",
["initiated", "saved", "notification_succeeded", "aborted", "failed_to_start"],
)
@mock.patch(
"core.recording.services.recording_events.notification_service."
"notify_external_services"
)
def test_handle_complete_non_savable_recording(
mock_notify_external_services, status, service
):
"""Test handle_complete refuses recordings that are already saved or in error."""
recording = RecordingFactory(status=status)
with pytest.raises(RecordingNotSavableError):
service.handle_complete(recording)
mock_notify_external_services.assert_not_called()
recording.refresh_from_db()
assert recording.status == status
@@ -8,7 +8,6 @@ from uuid import uuid4
from django.conf import settings from django.conf import settings
from django.core.files.storage import default_storage from django.core.files.storage import default_storage
from django.test import override_settings
from django.utils import timezone from django.utils import timezone
import pytest import pytest
@@ -283,63 +282,3 @@ def test_api_recordings_media_auth_success_administrator(mode):
timeout=1, timeout=1,
) )
assert response.content.decode("utf-8") == "my prose" assert response.content.decode("utf-8") == "my prose"
def test_api_recordings_media_auth_missing_header():
"""
Test that a subrequest without the configured original-url header is rejected.
"""
user = UserFactory()
client = APIClient()
client.force_login(user)
response = client.get("/api/v1.0/recordings/media-auth/")
assert response.status_code == 403
@override_settings(MEDIA_AUTH_ORIGINAL_URL_HEADER="HTTP_X_FORWARDED_URI")
def test_api_recordings_media_auth_custom_original_url_header():
"""
Test that the header carrying the original URL can be configured.
Reverse proxies other than nginx-ingress use different headers: Traefik's
ForwardAuth sends X-Forwarded-Uri and cannot emit X-Original-URL at all.
"""
user = UserFactory()
client = APIClient()
client.force_login(user)
original_url = f"http://localhost/media/recordings/{uuid4()!s}.mp4"
response = client.get(
"/api/v1.0/recordings/media-auth/", HTTP_X_FORWARDED_URI=original_url
)
# The header was read and parsed: we get as far as looking the recording up,
# rather than being rejected for a missing header.
assert response.status_code == 404
@override_settings(MEDIA_AUTH_ORIGINAL_URL_HEADER="HTTP_X_FORWARDED_URI")
def test_api_recordings_media_auth_default_header_ignored_when_reconfigured():
"""
Test that only the configured header is honoured.
Guards against the header being read from a hardcoded name in parallel with
the setting.
"""
user = UserFactory()
client = APIClient()
client.force_login(user)
original_url = f"http://localhost/media/recordings/{uuid4()!s}.mp4"
response = client.get(
"/api/v1.0/recordings/media-auth/", HTTP_X_ORIGINAL_URL=original_url
)
assert response.status_code == 403
@@ -0,0 +1,267 @@
"""
Test recordings API endpoints in the Meet core app: save recording.
"""
# pylint: disable=redefined-outer-name,unused-argument
import uuid
from unittest import mock
import pytest
from rest_framework.test import APIClient
from ...factories import RecordingFactory
from ...models import Recording, RecordingStatusChoices
from ...recording.event.exceptions import (
InvalidBucketError,
InvalidFilepathError,
InvalidFileTypeError,
ParsingEventDataError,
)
pytestmark = pytest.mark.django_db
@pytest.fixture
def recording_settings(settings):
"""Configure recording-related and storage event Django settings."""
settings.RECORDING_STORAGE_EVENT_TOKEN = "testAuthToken"
settings.RECORDING_STORAGE_EVENT_ENABLE = True
return settings
@pytest.fixture
def mock_get_parser():
"""Mock 'get_parser' factory function."""
with mock.patch("core.api.viewsets.get_parser") as mock_parser:
yield mock_parser
def test_save_recording_anonymous(settings, client):
"""Anonymous users should not be allowed to save room recordings."""
settings.RECORDING_STORAGE_EVENT_TOKEN = "testAuthToken"
RecordingFactory(status="active")
response = client.post(
"/api/v1.0/recordings/storage-hook/",
{"recording_data": "valid-data"},
)
assert response.status_code == 401
assert Recording.objects.count() == 1
def test_save_recording_wrong_bearer(settings, client):
"""Requests with incorrect bearer token should be rejected when auth is required."""
settings.RECORDING_STORAGE_EVENT_TOKEN = "testAuthToken"
response = client.post(
"/api/v1.0/recordings/storage-hook/",
{"recording_data": "valid-data"},
HTTP_AUTHORIZATION="Bearer wrongAuthToken",
)
assert response.status_code == 401
def test_save_recording_permission_needed(settings, client):
"""Recordings should not be saved when feature is disabled."""
settings.RECORDING_STORAGE_EVENT_TOKEN = "testAuthToken"
settings.RECORDING_STORAGE_EVENT_ENABLE = False
response = client.post(
"/api/v1.0/recordings/storage-hook/",
{"recording_data": "valid-data"},
HTTP_AUTHORIZATION="Bearer testAuthToken",
)
assert response.status_code == 404
assert response.json() == {"detail": "Not found."}
def test_save_recording_parsing_error(recording_settings, mock_get_parser, client):
"""Test handling of parsing errors in recording event data."""
mock_parser = mock.Mock()
mock_parser.get_recording_id.side_effect = ParsingEventDataError("Error message")
mock_get_parser.return_value = mock_parser
response = client.post(
"/api/v1.0/recordings/storage-hook/",
{"recording_data": "valid-data"},
HTTP_AUTHORIZATION="Bearer testAuthToken",
)
assert response.status_code == 403
assert response.json() == {"detail": "Invalid request data."}
def test_save_recording_bucket_error(recording_settings, mock_get_parser, client):
"""Test handling of invalid storage bucket errors in recording event data."""
mock_parser = mock.Mock()
mock_parser.get_recording_id.side_effect = InvalidBucketError("Error message")
mock_get_parser.return_value = mock_parser
response = client.post(
"/api/v1.0/recordings/storage-hook/",
{"recording_data": "valid-data"},
HTTP_AUTHORIZATION="Bearer testAuthToken",
)
assert response.status_code == 403
assert response.json() == {"detail": "Invalid bucket specified."}
def test_save_recording_filetype_error(recording_settings, mock_get_parser):
"""Test handling of unsupported file types in recording event data."""
mock_parser = mock.Mock()
mock_parser.get_recording_id.side_effect = InvalidFileTypeError(
"unsupported '.json'"
)
mock_get_parser.return_value = mock_parser
client = APIClient()
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": "Notification ignored."}
def test_save_recording_filepath_error(recording_settings, mock_get_parser):
"""Test handling of unsupported filepath in recording event data."""
mock_parser = mock.Mock()
mock_parser.get_recording_id.side_effect = InvalidFilepathError(
"Invalid filepath structure: parent/folder/recording.jpeg"
)
mock_get_parser.return_value = mock_parser
client = APIClient()
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": "Notification ignored."}
def test_save_recording_unknown_recording(recording_settings, mock_get_parser, client):
"""Test handling of events for non-existent recordings."""
RecordingFactory(status="active")
mock_parser = mock.Mock()
mock_parser.get_recording_id.return_value = uuid.uuid4()
mock_get_parser.return_value = mock_parser
response = client.post(
"/api/v1.0/recordings/storage-hook/",
{"recording_data": "valid-data"},
HTTP_AUTHORIZATION="Bearer testAuthToken",
)
assert response.status_code == 404
assert response.json() == {"detail": "No recording found for this event."}
@pytest.mark.parametrize(
"status", ["failed_to_start", "aborted", "failed_to_stop", "saved", "initiated"]
)
def test_save_recording_non_savable_recording(
recording_settings, mock_get_parser, client, status
):
"""Test that recordings in non-savable states cannot be saved."""
recording = RecordingFactory(status=status)
mock_parser = mock.Mock()
mock_parser.get_recording_id.return_value = recording.id
mock_get_parser.return_value = mock_parser
response = client.post(
"/api/v1.0/recordings/storage-hook/",
{"recording_data": "valid-data"},
HTTP_AUTHORIZATION="Bearer testAuthToken",
)
assert response.status_code == 403
assert response.json() == {
"detail": f"Recording with ID {recording.id} cannot be saved because it is either,"
" in an error state or has already been saved."
}
@pytest.mark.parametrize("status", ["active", "stopped"])
def test_save_recording_success(recording_settings, mock_get_parser, client, status):
"""Test successful saving of recordings in valid states."""
recording = RecordingFactory(status=status)
mock_parser = mock.Mock()
mock_parser.get_recording_id.return_value = recording.id
mock_get_parser.return_value = mock_parser
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."}
recording.refresh_from_db()
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
)
@@ -389,7 +389,7 @@ def test_allow_participant_to_enter_anonymous():
def test_allow_participant_to_enter_non_owner(): def test_allow_participant_to_enter_non_owner():
"""Non-privileged users should not be allowed to manage entry requests.""" """Non-privileged users should not be allowed to manage entry requests."""
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED) room = RoomFactory()
user = UserFactory() user = UserFactory()
client = APIClient() client = APIClient()
client.force_login(user) client.force_login(user)
@@ -522,7 +522,7 @@ def test_list_waiting_participants_anonymous():
def test_list_waiting_participants_non_owner(): def test_list_waiting_participants_non_owner():
"""Non-privileged users should not be allowed to list waiting participants.""" """Non-privileged users should not be allowed to list waiting participants."""
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED) room = RoomFactory()
user = UserFactory() user = UserFactory()
client = APIClient() client = APIClient()
client.force_login(user) client.force_login(user)
@@ -1,106 +0,0 @@
"""Trusted rooms: any authenticated participant present in the meeting can manage the lobby."""
from unittest import mock
import pytest
from rest_framework.test import APIClient
from core.factories import RoomFactory, UserFactory
from core.models import RoomAccessLevel
from core.services.presence import PresenceCache
pytestmark = pytest.mark.django_db
@mock.patch(
"core.services.participants_management.ParticipantsManagement.check_if_in_meeting"
)
def test_trusted_room_present_user_can_list_waiting(mock_check):
"""Authenticated + present in a trusted room -> 200, LiveKit asked once then cached."""
mock_check.return_value = True
user = UserFactory()
room = RoomFactory(access_level=RoomAccessLevel.TRUSTED)
client = APIClient()
client.force_login(user)
url = f"/api/v1.0/rooms/{room.id}/waiting-participants/"
assert client.get(url).status_code == 200
assert client.get(url).status_code == 200
assert mock_check.call_count == 1
@mock.patch(
"core.services.participants_management.ParticipantsManagement.check_if_in_meeting"
)
def test_trusted_room_absent_user_forbidden(mock_check):
"""Authenticated but not connected to the meeting -> 403."""
mock_check.return_value = False
user = UserFactory()
room = RoomFactory(access_level=RoomAccessLevel.TRUSTED)
client = APIClient()
client.force_login(user)
response = client.get(f"/api/v1.0/rooms/{room.id}/waiting-participants/")
assert response.status_code == 403
@mock.patch(
"core.services.participants_management.ParticipantsManagement.check_if_in_meeting"
)
def test_restricted_room_present_user_forbidden(mock_check):
"""Presence is not enough on a restricted room; LiveKit must not even be asked."""
user = UserFactory()
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
client = APIClient()
client.force_login(user)
response = client.get(f"/api/v1.0/rooms/{room.id}/waiting-participants/")
assert response.status_code == 403
mock_check.assert_not_called()
@mock.patch(
"core.services.participants_management.ParticipantsManagement.check_if_in_meeting"
)
def test_trusted_room_presence_cleared_after_leave(mock_check):
"""Once the presence cache is cleared (participant_left), LiveKit is re-checked."""
mock_check.return_value = True
user = UserFactory()
room = RoomFactory(access_level=RoomAccessLevel.TRUSTED)
client = APIClient()
client.force_login(user)
url = f"/api/v1.0/rooms/{room.id}/waiting-participants/"
assert client.get(url).status_code == 200
PresenceCache().clear(room.id, str(user.sub))
mock_check.return_value = False
assert client.get(url).status_code == 403
assert mock_check.call_count == 2
def test_trusted_room_anonymous_forbidden():
"""Anonymous users never manage the lobby."""
room = RoomFactory(access_level=RoomAccessLevel.TRUSTED)
response = APIClient().get(f"/api/v1.0/rooms/{room.id}/waiting-participants/")
assert response.status_code == 401
@mock.patch(
"core.services.participants_management.ParticipantsManagement.check_if_in_meeting"
)
def test_trusted_room_present_user_can_accept_entry(mock_check):
"""Authenticated + present in a trusted room can accept a waiting participant."""
mock_check.return_value = True
user = UserFactory()
room = RoomFactory(access_level=RoomAccessLevel.TRUSTED)
client = APIClient()
client.force_login(user)
response = client.post(
f"/api/v1.0/rooms/{room.id}/enter/",
{"participant_id": "2f7f162f-e7d1-421b-90e7-02bfbfbf8def", "allow_entry": True},
)
# Permission passed; 404 because that participant isn't actually waiting.
assert response.status_code == 404
assert response.json() == {"message": "Participant not found."}
@@ -75,11 +75,12 @@ def test_initialization(
) )
@mock.patch("core.utils.notify_participants") @mock.patch("core.utils.notify_participants")
@mock.patch("core.services.room_management.RoomManagement.update_metadata") @mock.patch("core.services.room_management.RoomManagement.update_metadata")
def test_handle_egress_ended_success( # pylint: disable=too-many-arguments, too-many-positional-arguments def test_handle_egress_ended_success( # noqa: PLR0913, PLR0917 # pylint: disable=too-many-arguments, too-many-positional-arguments
mock_update_metadata, mock_notify, mode, notification_type, service mock_update_metadata, mock_notify, mode, notification_type, service, settings
): ):
"""Should successfully stop recording and notifies all participant.""" """Should successfully stop recording and notifies all participant."""
settings.RECORDING_STORAGE_EVENT_ENABLE = False
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()
mock_data.egress_info.egress_id = recording.worker_id mock_data.egress_info.egress_id = recording.worker_id
@@ -158,11 +159,12 @@ def test_handle_egress_updated_non_handled(
) )
@mock.patch("core.utils.notify_participants") @mock.patch("core.utils.notify_participants")
@mock.patch("core.services.room_management.RoomManagement.update_metadata") @mock.patch("core.services.room_management.RoomManagement.update_metadata")
def test_handle_egress_ended_metadata_update_fails( # pylint: disable=too-many-arguments, too-many-positional-arguments def test_handle_egress_ended_metadata_update_fails( # noqa: PLR0913, PLR0917 # pylint: disable=too-many-arguments, too-many-positional-arguments
mock_update_metadata, mock_notify, mode, notification_type, service mock_update_metadata, mock_notify, mode, notification_type, service, settings
): ):
"""Should successfully stop and save recording when metadata's update fails.""" """Should successfully stop and save recording when metadata's update fails."""
settings.RECORDING_STORAGE_EVENT_ENABLE = False
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()
mock_data.egress_info.egress_id = recording.worker_id mock_data.egress_info.egress_id = recording.worker_id
@@ -356,10 +358,12 @@ def test_handle_egress_ended_finalizes_recording( # noqa: PLR0913, PLR0917
recording_status, recording_status,
egress_status, egress_status,
service, service,
settings,
): # pylint: disable=too-many-arguments,too-many-positional-arguments ): # pylint: disable=too-many-arguments,too-many-positional-arguments
"""Should notify external services and save the recording on egress completion """Should notify external services and save the recording on egress completion
(EGRESS_COMPLETE or EGRESS_LIMIT_REACHED). (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 mock_notify_external_services.return_value = notify_return_value
recording = RecordingFactory(worker_id="worker-1", status="active") recording = RecordingFactory(worker_id="worker-1", status="active")
@@ -375,6 +379,47 @@ def test_handle_egress_ended_finalizes_recording( # noqa: PLR0913, PLR0917
assert recording.status == recording_status 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.services.room_management.RoomManagement.update_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, PLR0917
mock_update_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( @pytest.mark.parametrize(
"egress_status", "egress_status",
[ [
@@ -387,9 +432,10 @@ def test_handle_egress_ended_finalizes_recording( # noqa: PLR0913, PLR0917
) )
@mock.patch("core.services.room_management.RoomManagement.update_metadata") @mock.patch("core.services.room_management.RoomManagement.update_metadata")
def test_handle_egress_ended_does_not_save_on_wrong_status( def test_handle_egress_ended_does_not_save_on_wrong_status(
mock_update_metadata, egress_status, service mock_update_metadata, egress_status, service, settings
): ):
"""Shouldn't save on invalid status.""" """Shouldn't save on invalid status."""
settings.RECORDING_STORAGE_EVENT_ENABLE = False
recording = RecordingFactory(worker_id="worker-1", status="active") recording = RecordingFactory(worker_id="worker-1", status="active")
mock_data = mock.MagicMock() mock_data = mock.MagicMock()
@@ -407,13 +453,14 @@ def test_handle_egress_ended_does_not_save_on_wrong_status(
) )
@mock.patch("core.services.room_management.RoomManagement.update_metadata") @mock.patch("core.services.room_management.RoomManagement.update_metadata")
def test_handle_egress_ended_ignores_non_savable_recording( def test_handle_egress_ended_ignores_non_savable_recording(
mock_update_metadata, status, service mock_update_metadata, status, service, settings
): ):
"""Should handle non-savable recordings idempotently without raising. """Should handle non-savable recordings idempotently without raising.
'egress_ended' may be redelivered (e.g. for an already-saved recording); '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. 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) recording = RecordingFactory(worker_id="worker-1", status=status)
mock_data = mock.MagicMock() mock_data = mock.MagicMock()
@@ -807,31 +854,3 @@ def test_receive_ignores_connection_test_room(
mock_handle_room_started.assert_not_called() mock_handle_room_started.assert_not_called()
mock_handle_room_finished.assert_not_called() mock_handle_room_finished.assert_not_called()
@mock.patch("core.services.presence.cache.delete")
def test_participant_left_clearing_gated_by_setting(mock_delete, service, settings):
"""PRESENCE_CLEAR_ON_PARTICIPANT_LEFT toggles eager presence invalidation."""
data = mock.Mock()
data.room.name = "room-name"
data.participant.identity = "user-sub"
settings.PRESENCE_CLEAR_ON_PARTICIPANT_LEFT = False
service._handle_participant_left(data) # pylint: disable=protected-access
mock_delete.assert_not_called()
settings.PRESENCE_CLEAR_ON_PARTICIPANT_LEFT = True
service._handle_participant_left(data) # pylint: disable=protected-access
mock_delete.assert_called_once()
@mock.patch("core.services.presence.cache.delete")
def test_participant_left_without_identity_is_ignored(mock_delete, service, settings):
"""No cache operation when the webhook carries no identity."""
settings.PRESENCE_CLEAR_ON_PARTICIPANT_LEFT = True
data = mock.Mock()
data.room.name = "room-name"
data.participant.identity = ""
service._handle_participant_left(data) # pylint: disable=protected-access
mock_delete.assert_not_called()
+10 -11
View File
@@ -24,7 +24,6 @@ from core.services.lobby import (
LobbyParticipantStatus, LobbyParticipantStatus,
LobbyService, LobbyService,
) )
from core.services.presence import CACHE_SCAN_ITERSIZE
from core.utils import NotificationError from core.utils import NotificationError
pytestmark = pytest.mark.django_db pytestmark = pytest.mark.django_db
@@ -579,14 +578,14 @@ def test_get_participant_parsing_error(
@mock.patch("core.services.lobby.cache") @mock.patch("core.services.lobby.cache")
def test_list_waiting_participants_empty(mock_cache, lobby_service): def test_list_waiting_participants_empty(mock_cache, lobby_service):
"""Test listing waiting participants when none exist.""" """Test listing waiting participants when none exist."""
mock_cache.iter_keys.return_value = [] mock_cache.keys.return_value = []
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED) room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
result = lobby_service.list_waiting_participants(room.id) result = lobby_service.list_waiting_participants(room.id)
assert result == [] assert result == []
pattern = f"{settings.LOBBY_KEY_PREFIX}_{room.id!s}_*" pattern = f"{settings.LOBBY_KEY_PREFIX}_{room.id!s}_*"
mock_cache.iter_keys.assert_called_once_with(pattern, itersize=CACHE_SCAN_ITERSIZE) mock_cache.keys.assert_called_once_with(pattern)
mock_cache.get_many.assert_not_called() mock_cache.get_many.assert_not_called()
@@ -595,7 +594,7 @@ def test_list_waiting_participants(mock_cache, lobby_service, participant_dict):
"""Test listing waiting participants with valid data.""" """Test listing waiting participants with valid data."""
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED) room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
cache_key = f"{settings.LOBBY_KEY_PREFIX}_{room.id!s}_participant1" cache_key = f"{settings.LOBBY_KEY_PREFIX}_{room.id!s}_participant1"
mock_cache.iter_keys.return_value = [cache_key] mock_cache.keys.return_value = [cache_key]
mock_cache.get_many.return_value = {cache_key: participant_dict} mock_cache.get_many.return_value = {cache_key: participant_dict}
result = lobby_service.list_waiting_participants(room.id) result = lobby_service.list_waiting_participants(room.id)
@@ -604,7 +603,7 @@ def test_list_waiting_participants(mock_cache, lobby_service, participant_dict):
assert result[0]["status"] == "waiting" assert result[0]["status"] == "waiting"
assert result[0]["username"] == "test-username" assert result[0]["username"] == "test-username"
pattern = f"{settings.LOBBY_KEY_PREFIX}_{room.id!s}_*" pattern = f"{settings.LOBBY_KEY_PREFIX}_{room.id!s}_*"
mock_cache.iter_keys.assert_called_once_with(pattern, itersize=CACHE_SCAN_ITERSIZE) mock_cache.keys.assert_called_once_with(pattern)
mock_cache.get_many.assert_called_once_with([cache_key]) mock_cache.get_many.assert_called_once_with([cache_key])
@@ -629,7 +628,7 @@ def test_list_waiting_participants_multiple(mock_cache, lobby_service):
"color": "#654321", "color": "#654321",
} }
mock_cache.iter_keys.return_value = [cache_key1, cache_key2] mock_cache.keys.return_value = [cache_key1, cache_key2]
mock_cache.get_many.return_value = { mock_cache.get_many.return_value = {
cache_key1: participant1, cache_key1: participant1,
cache_key2: participant2, cache_key2: participant2,
@@ -647,7 +646,7 @@ def test_list_waiting_participants_multiple(mock_cache, lobby_service):
assert all(p["status"] == "waiting" for p in result) assert all(p["status"] == "waiting" for p in result)
pattern = f"{settings.LOBBY_KEY_PREFIX}_{room.id!s}_*" pattern = f"{settings.LOBBY_KEY_PREFIX}_{room.id!s}_*"
mock_cache.iter_keys.assert_called_once_with(pattern, itersize=CACHE_SCAN_ITERSIZE) mock_cache.keys.assert_called_once_with(pattern)
mock_cache.get_many.assert_called_once_with([cache_key1, cache_key2]) mock_cache.get_many.assert_called_once_with([cache_key1, cache_key2])
@@ -656,7 +655,7 @@ def test_list_waiting_participants_corrupted_data(mock_cache, lobby_service):
"""Test listing waiting participants with corrupted data.""" """Test listing waiting participants with corrupted data."""
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED) room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
cache_key = f"{settings.LOBBY_KEY_PREFIX}_{room.id!s}_participant1" cache_key = f"{settings.LOBBY_KEY_PREFIX}_{room.id!s}_participant1"
mock_cache.iter_keys.return_value = [cache_key] mock_cache.keys.return_value = [cache_key]
mock_cache.get_many.return_value = {cache_key: {"invalid": "data"}} mock_cache.get_many.return_value = {cache_key: {"invalid": "data"}}
result = lobby_service.list_waiting_participants(room.id) result = lobby_service.list_waiting_participants(room.id)
@@ -681,7 +680,7 @@ def test_list_waiting_participants_partially_corrupted(mock_cache, lobby_service
corrupted_participant = {"invalid": "data"} corrupted_participant = {"invalid": "data"}
mock_cache.iter_keys.return_value = [cache_key1, cache_key2] mock_cache.keys.return_value = [cache_key1, cache_key2]
mock_cache.get_many.return_value = { mock_cache.get_many.return_value = {
cache_key1: corrupted_participant, cache_key1: corrupted_participant,
cache_key2: valid_participant, cache_key2: valid_participant,
@@ -700,7 +699,7 @@ def test_list_waiting_participants_partially_corrupted(mock_cache, lobby_service
# Verify both cache keys were queried # Verify both cache keys were queried
pattern = f"{settings.LOBBY_KEY_PREFIX}_{room.id!s}_*" pattern = f"{settings.LOBBY_KEY_PREFIX}_{room.id!s}_*"
mock_cache.iter_keys.assert_called_once_with(pattern, itersize=CACHE_SCAN_ITERSIZE) mock_cache.keys.assert_called_once_with(pattern)
mock_cache.get_many.assert_called_once_with([cache_key1, cache_key2]) mock_cache.get_many.assert_called_once_with([cache_key1, cache_key2])
@@ -724,7 +723,7 @@ def test_list_waiting_participants_non_waiting(mock_cache, lobby_service):
"color": "#654321", "color": "#654321",
} }
mock_cache.iter_keys.return_value = [cache_key1, cache_key2] mock_cache.keys.return_value = [cache_key1, cache_key2]
mock_cache.get_many.return_value = { mock_cache.get_many.return_value = {
cache_key1: participant1, cache_key1: participant1,
cache_key2: participant2, cache_key2: participant2,
@@ -1,108 +0,0 @@
"""Tests for the presence cache and the cached presence check."""
# pylint: disable=W0212
from unittest import mock
from uuid import uuid4
from django.core.cache import cache
import pytest
from core.services.participants_management import (
ParticipantNotFoundException,
ParticipantsManagement,
ParticipantsManagementException,
)
from core.services.presence import PresenceCache
@mock.patch(
"core.services.participants_management.ParticipantsManagement.check_if_in_meeting"
)
def test_presence_cache_hit_skips_livekit(mock_check):
"""A cached positive answer must not call LiveKit."""
room_id, identity = str(uuid4()), "user-sub"
PresenceCache().mark_present(room_id, identity)
assert (
ParticipantsManagement().check_if_in_meeting_cached(room_id, identity) is True
)
mock_check.assert_not_called()
@mock.patch(
"core.services.participants_management.ParticipantsManagement.check_if_in_meeting"
)
def test_presence_cache_miss_calls_livekit_and_caches_positive(mock_check):
"""On a miss, LiveKit is asked once and a positive answer is memoized."""
mock_check.return_value = True
room_id, identity = str(uuid4()), "user-sub"
service = ParticipantsManagement()
assert service.check_if_in_meeting_cached(room_id, identity) is True
assert service.check_if_in_meeting_cached(room_id, identity) is True
assert mock_check.call_count == 1
@mock.patch(
"core.services.participants_management.ParticipantsManagement.check_if_in_meeting"
)
def test_presence_negative_not_cached(mock_check):
"""Negative answers are never memoized."""
mock_check.return_value = False
room_id, identity = str(uuid4()), "user-sub"
service = ParticipantsManagement()
assert service.check_if_in_meeting_cached(room_id, identity) is False
assert service.check_if_in_meeting_cached(room_id, identity) is False
assert mock_check.call_count == 2
@mock.patch(
"core.services.participants_management.ParticipantsManagement.check_if_in_meeting"
)
def test_presence_errors_propagate_and_cache_nothing(mock_check):
"""LiveKit errors propagate to the caller (which fails closed); nothing cached."""
room_id, identity = str(uuid4()), "user-sub"
for exc in (ParticipantNotFoundException(), ParticipantsManagementException()):
mock_check.side_effect = exc
with pytest.raises(type(exc)):
ParticipantsManagement().check_if_in_meeting_cached(room_id, identity)
assert cache.get(PresenceCache._get_cache_key(room_id, identity)) is None
def test_presence_clear_and_clear_room():
"""clear() removes one entry, clear_room() removes all entries of a room."""
room_id, other_room = str(uuid4()), str(uuid4())
presence = PresenceCache()
presence.mark_present(room_id, "a")
presence.mark_present(room_id, "b")
presence.mark_present(other_room, "a")
presence.clear(room_id, "a")
assert presence.is_marked_present(room_id, "a") is False
assert presence.is_marked_present(room_id, "b") is True
presence.clear_room(room_id)
assert presence.is_marked_present(room_id, "b") is False
assert presence.is_marked_present(other_room, "a") is True
def test_presence_clear_room_scans_in_pages():
"""clear_room removes every match, even across several SCAN pages,
and only within the room."""
room_id, other_room = str(uuid4()), str(uuid4())
presence = PresenceCache()
for i in range(7):
presence.mark_present(room_id, f"user-{i}")
presence.mark_present(other_room, "user-0")
# An itersize smaller than the match count forces delete_pattern to
# page through several SCAN cursors rather than finish in one pass.
with mock.patch("core.utils.CACHE_SCAN_ITERSIZE", 3):
presence.clear_room(room_id)
assert all(not presence.is_marked_present(room_id, f"user-{i}") for i in range(7))
assert presence.is_marked_present(other_room, "user-0") is True
@@ -5,7 +5,6 @@ Tests for external API /token endpoint
# pylint: disable=W0621 # pylint: disable=W0621
from unittest import mock from unittest import mock
from urllib.parse import urlencode
import jwt import jwt
import pytest import pytest
@@ -89,155 +88,6 @@ def test_api_applications_generate_token_success(settings):
} }
def test_api_applications_generate_token_form_urlencoded(settings):
"""The token endpoint should accept "application/x-www-form-urlencoded"
requests, as mandated by RFC 6749 (sections 3.2 and 4.4.2) for OAuth 2.0
token endpoints, so that standard OAuth 2.0 client libraries work
out of the box."""
UserFactory(email="user@example.com")
application = ApplicationFactory(
is_active=True,
scopes=[ApplicationScope.ROOMS_LIST, ApplicationScope.ROOMS_CREATE],
)
plain_secret = "test-secret-123"
application.client_secret = plain_secret
application.save()
client = APIClient()
response = client.post(
"/external-api/v1.0/application/token/",
(
f"client_id={application.client_id}"
f"&client_secret={plain_secret}"
"&grant_type=client_credentials"
"&scope=user%40example.com"
),
content_type="application/x-www-form-urlencoded",
)
assert response.status_code == 200
assert "access_token" in response.data
response.data.pop("access_token")
assert response.data == {
"token_type": "Bearer",
"expires_in": settings.APPLICATION_JWT_EXPIRATION_SECONDS,
"scope": "rooms:list rooms:create",
}
def test_api_applications_generate_token_form_urlencoded_invalid_credentials():
"""Invalid credentials sent as form-urlencoded should be parsed and
rejected with 401, proving the request body is properly decoded."""
user = UserFactory(email="user@example.com")
application = ApplicationFactory(is_active=True)
client = APIClient()
response = client.post(
"/external-api/v1.0/application/token/",
urlencode(
{
"client_id": application.client_id,
"client_secret": "wrong-secret",
"grant_type": "client_credentials",
"scope": user.email,
}
),
content_type="application/x-www-form-urlencoded",
)
assert response.status_code == 401
assert "Invalid credentials" in str(response.data)
def test_api_applications_generate_token_form_urlencoded_missing_fields():
"""Missing required fields in a form-urlencoded request should return
a 400 validation error, like for JSON requests."""
client = APIClient()
response = client.post(
"/external-api/v1.0/application/token/",
urlencode({"grant_type": "client_credentials"}),
content_type="application/x-www-form-urlencoded",
)
assert response.status_code == 400
for field in ("client_id", "client_secret", "scope"):
assert field in response.data
def test_api_applications_generate_token_form_urlencoded_invalid_grant_type():
"""An unsupported grant_type sent as form-urlencoded should return 400."""
user = UserFactory(email="user@example.com")
application = ApplicationFactory(is_active=True)
plain_secret = "test-secret-123"
application.client_secret = plain_secret
application.save()
client = APIClient()
response = client.post(
"/external-api/v1.0/application/token/",
urlencode(
{
"client_id": application.client_id,
"client_secret": plain_secret,
"grant_type": "authorization_code",
"scope": user.email,
}
),
content_type="application/x-www-form-urlencoded",
)
assert response.status_code == 400
assert "grant_type" in response.data
def test_api_applications_generate_token_form_urlencoded_special_characters():
"""Percent-encoded reserved characters ("&", "=", "+", "%") in the
client_secret should survive form-urlencoded decoding."""
UserFactory(email="user@example.com")
application = ApplicationFactory(
is_active=True,
scopes=[ApplicationScope.ROOMS_LIST],
)
plain_secret = "s3cr3t&with=special+chars%42"
application.client_secret = plain_secret
application.save()
client = APIClient()
response = client.post(
"/external-api/v1.0/application/token/",
urlencode(
{
"client_id": application.client_id,
"client_secret": plain_secret,
"grant_type": "client_credentials",
"scope": "user@example.com",
}
),
content_type="application/x-www-form-urlencoded",
)
assert response.status_code == 200
assert "access_token" in response.data
def test_api_applications_generate_token_unsupported_media_type():
"""Content types other than JSON and form-urlencoded should still be
rejected with 415 Unsupported Media Type."""
client = APIClient()
response = client.post(
"/external-api/v1.0/application/token/",
"client_id=x&client_secret=y&grant_type=client_credentials&scope=a@b.co",
content_type="text/plain",
)
assert response.status_code == 415
def test_api_applications_generate_token_invalid_client_id(): def test_api_applications_generate_token_invalid_client_id():
"""Invalid client_id should return 401.""" """Invalid client_id should return 401."""
user = UserFactory(email="user@example.com") user = UserFactory(email="user@example.com")
@@ -26,64 +26,6 @@ def test_models_users_id_unique():
factories.UserFactory(id=user.id) factories.UserFactory(id=user.id)
@pytest.mark.parametrize(
"sub,is_valid",
[
# cases from suitenumerique/docs PR #1295 (same validator)
("valid_sub.@+-:=/", True),
("invalid süb", False),
(12345, True),
# Auth0 emits "provider|user-id" subject identifiers
("auth0|644c0bc8f1874ef6d339fb34", True),
("google-oauth2|103547991597142817347", True),
# Keycloak-style UUID
("f:550e8400-e29b-41d4-a716-446655440000:jdoe", True),
# base64/URN-style identifiers
("dGVzdC1zdWItdmFsdWU=", True),
("urn:example:user/42", True),
# legacy format still accepted
("user@example.com", True),
# space (U+0020) is printable ASCII and remains allowed
("sub with space", True),
# non-ASCII values are rejected
("émilie", False),
# ASCII control characters (U+0000-U+001F, U+007F) are rejected:
# NUL passes isascii() but cannot be stored in PostgreSQL text
# fields, and the others invite log injection and interop issues
("nul\x00sub", False),
("\x00", False),
("tab\tsub", False),
("newline\nsub", False),
("del\x7fsub", False),
],
)
def test_models_users_sub_validator(sub, is_valid):
"""
The "sub" field should accept any ASCII string as required by
OpenID Connect Core 1.0 §2 and RFC 7519 §4.1.2, and reject non-ASCII values.
"""
user = factories.UserFactory()
user.sub = sub
if is_valid:
user.full_clean()
else:
with pytest.raises(
ValidationError,
match="Enter a valid sub. This value should be printable ASCII only.",
):
user.full_clean()
def test_models_users_sub_max_length():
"""The "sub" field should enforce the 255 ASCII characters limit of OIDC Core 1.0 §2."""
user = factories.UserFactory(sub="a" * 255)
assert user.sub == "a" * 255
user.sub = "a" * 256
with pytest.raises(ValidationError, match="at most 255 characters"):
user.full_clean()
def test_models_users_send_mail_main_existing(): def test_models_users_send_mail_main_existing():
"""The "email_user' method should send mail to the user's email address.""" """The "email_user' method should send mail to the user's email address."""
user = factories.UserFactory() user = factories.UserFactory()
+40 -30
View File
@@ -381,31 +381,6 @@ def detect_mimetype(file_buffer: bytes, filename: str | None = None) -> str:
return mimetype_from_content or "application/octet-stream" return mimetype_from_content or "application/octet-stream"
def _get_s3_client(*, override_domain: bool = True):
"""Return an S3 client, honoring the AWS_S3_DOMAIN_REPLACE endpoint override.
AWS_S3_DOMAIN_REPLACE is used when the backend and frontend reach object
storage under different domains (this is the case in the docker compose stack
used in development: the frontend connects to the object storage on localhost
while the backend uses the object storage service name declared in the stack).
The domain name is used to compute the signature, so it can't be changed
dynamically by the frontend; we build a dedicated boto3 client pointed at that
endpoint. Otherwise we reuse the default storage client.
"""
if settings.AWS_S3_DOMAIN_REPLACE and override_domain:
return boto3.client(
"s3",
aws_access_key_id=settings.AWS_S3_ACCESS_KEY_ID,
aws_secret_access_key=settings.AWS_S3_SECRET_ACCESS_KEY,
endpoint_url=settings.AWS_S3_DOMAIN_REPLACE,
config=botocore.client.Config(
region_name=settings.AWS_S3_REGION_NAME,
signature_version=settings.AWS_S3_SIGNATURE_VERSION,
),
)
return default_storage.connection.meta.client
def generate_upload_policy(file): def generate_upload_policy(file):
""" """
Generate a S3 upload policy for a given file. Generate a S3 upload policy for a given file.
@@ -416,7 +391,26 @@ def generate_upload_policy(file):
key = file.temporary_file_key key = file.temporary_file_key
s3_client = _get_s3_client() # This settings 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
# docker compose stack used in development. The frontend application will use localhost
# to connect to the object storage while the backend application will use the object storage
# service name declared in the docker compose stack.
# This is needed because the domain name is used to compute the signature. So it can't be
# changed dynamically by the frontend application.
if settings.AWS_S3_DOMAIN_REPLACE:
s3_client = boto3.client(
"s3",
aws_access_key_id=settings.AWS_S3_ACCESS_KEY_ID,
aws_secret_access_key=settings.AWS_S3_SECRET_ACCESS_KEY,
endpoint_url=settings.AWS_S3_DOMAIN_REPLACE,
config=botocore.client.Config(
region_name=settings.AWS_S3_REGION_NAME,
signature_version=settings.AWS_S3_SIGNATURE_VERSION,
),
)
else:
s3_client = default_storage.connection.meta.client
# Generate the policy # Generate the policy
policy = s3_client.generate_presigned_url( policy = s3_client.generate_presigned_url(
@@ -437,7 +431,26 @@ def generate_download_s3_url(
if not key: if not key:
raise ValueError("key cannot be empty") raise ValueError("key cannot be empty")
s3_client = _get_s3_client(override_domain=override_domain) # 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
# docker compose stack used in development. The frontend application will use localhost
# to connect to the object storage while the backend application will use the object storage
# service name declared in the docker compose stack.
# This is needed because the domain name is used to compute the signature. So it can't be
# changed dynamically by the frontend application.
if settings.AWS_S3_DOMAIN_REPLACE and override_domain:
s3_client = boto3.client(
"s3",
aws_access_key_id=settings.AWS_S3_ACCESS_KEY_ID,
aws_secret_access_key=settings.AWS_S3_SECRET_ACCESS_KEY,
endpoint_url=settings.AWS_S3_DOMAIN_REPLACE,
config=botocore.client.Config(
region_name=settings.AWS_S3_REGION_NAME,
signature_version=settings.AWS_S3_SIGNATURE_VERSION,
),
)
else:
s3_client = default_storage.connection.meta.client
return s3_client.generate_presigned_url( return s3_client.generate_presigned_url(
ClientMethod="get_object", ClientMethod="get_object",
@@ -499,6 +512,3 @@ def build_telephony_config():
"default_country": country, "default_country": country,
"international_phone_number": international, "international_phone_number": international,
} }
CACHE_SCAN_ITERSIZE = 500
-23
View File
@@ -1,23 +0,0 @@
"""Custom validators for the core app."""
from django.core.exceptions import ValidationError
from django.utils.translation import gettext_lazy as _
def sub_validator(value):
"""Validate that the sub is printable ASCII only.
OpenID Connect Core 1.0 (section 2) allows any ASCII (RFC 20) string of
at most 255 characters, so no character whitelist is applied: providers
legitimately emit "|" (Auth0), ":" (Keycloak), "=", "/", etc. As a
deliberate hardening beyond the spec, ASCII control characters
(U+0000-U+001F and U+007F) are rejected: no known provider emits them,
NUL cannot be stored in PostgreSQL text fields, and the others invite
log-injection and interoperability issues. For str values,
``isprintable()`` is false exactly for those control characters, while
space (U+0020) remains allowed.
"""
if not value.isascii() or not value.isprintable():
raise ValidationError(
_("Enter a valid sub. This value should be printable ASCII only.")
)
+158 -189
View File
@@ -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-09-02 22:52+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"
@@ -51,11 +51,11 @@ msgstr ""
msgid "File preview" msgid "File preview"
msgstr "" msgstr ""
#: core/admin.py:300 core/admin.py:450 #: 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:303 core/admin.py:453 #: core/admin.py:303 core/admin.py:446
msgid "Multiple owners" msgid "Multiple owners"
msgstr "Mehrere Eigentümer" msgstr "Mehrere Eigentümer"
@@ -98,11 +98,11 @@ msgstr "%(count)s Aufnahme(n) erfolgreich als Fehler beim Stoppen markiert
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:517 #: core/admin.py:510
msgid "No scopes" msgid "No scopes"
msgstr "Keine Scopes" msgstr "Keine Scopes"
#: core/admin.py:519 #: core/admin.py:512
msgid "Scopes" msgid "Scopes"
msgstr "Scopes" msgstr "Scopes"
@@ -110,201 +110,185 @@ msgstr "Scopes"
msgid "Creator is me" msgid "Creator is me"
msgstr "Ersteller bin ich" msgstr "Ersteller bin ich"
#: core/api/serializers.py:108 #: 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:560 #: 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:1268 #: 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."
#: core/models.py:38 #: core/models.py:37
msgid "Member" msgid "Member"
msgstr "Mitglied" msgstr "Mitglied"
#: core/models.py:39 #: core/models.py:38
msgid "Administrator" msgid "Administrator"
msgstr "Administrator" msgstr "Administrator"
#: core/models.py:40 #: core/models.py:39
msgid "Owner" msgid "Owner"
msgstr "Eigentümer" msgstr "Eigentümer"
#: core/models.py:56 #: core/models.py:55
msgid "Initiated" msgid "Initiated"
msgstr "Gestartet" msgstr "Gestartet"
#: core/models.py:57 #: core/models.py:56
msgid "Active" msgid "Active"
msgstr "Aktiv" msgstr "Aktiv"
#: core/models.py:58 #: core/models.py:57
msgid "Stopped" msgid "Stopped"
msgstr "Beendet" msgstr "Beendet"
#: core/models.py:59 #: core/models.py:58
msgid "Saved" msgid "Saved"
msgstr "Gespeichert" msgstr "Gespeichert"
#: core/models.py:60 #: core/models.py:59
msgid "Aborted" msgid "Aborted"
msgstr "Abgebrochen" msgstr "Abgebrochen"
#: core/models.py:61 #: core/models.py:60
msgid "Failed to Start" msgid "Failed to Start"
msgstr "Start fehlgeschlagen" msgstr "Start fehlgeschlagen"
#: core/models.py:62 #: core/models.py:61
msgid "Failed to Stop" msgid "Failed to Stop"
msgstr "Stopp fehlgeschlagen" msgstr "Stopp fehlgeschlagen"
#: core/models.py:63 #: core/models.py:62
msgid "Notification succeeded" msgid "Notification succeeded"
msgstr "Benachrichtigung erfolgreich" msgstr "Benachrichtigung erfolgreich"
#: core/models.py:66 #: core/models.py:65
msgid "External process successful" msgid "External process successful"
msgstr "Externer Prozess erfolgreich" msgstr "Externer Prozess erfolgreich"
#: core/models.py:68 #: core/models.py:67
msgid "External process failed" msgid "External process failed"
msgstr "Externer Prozess fehlgeschlagen" msgstr "Externer Prozess fehlgeschlagen"
#: core/models.py:97 #: core/models.py:96
msgid "SCREEN_RECORDING" msgid "SCREEN_RECORDING"
msgstr "BILDSCHIRMAUFZEICHNUNG" msgstr "BILDSCHIRMAUFZEICHNUNG"
#: core/models.py:98 #: core/models.py:97
msgid "TRANSCRIPT" msgid "TRANSCRIPT"
msgstr "TRANSKRIPT" msgstr "TRANSKRIPT"
#: core/models.py:104 #: core/models.py:103
msgid "Public Access" msgid "Public Access"
msgstr "Öffentlicher Zugriff" msgstr "Öffentlicher Zugriff"
#: core/models.py:105 #: core/models.py:104
msgid "Trusted Access" msgid "Trusted Access"
msgstr "Vertrauenswürdiger Zugriff" msgstr "Vertrauenswürdiger Zugriff"
#: core/models.py:106 #: core/models.py:105
msgid "Restricted Access" msgid "Restricted Access"
msgstr "Eingeschränkter Zugriff" msgstr "Eingeschränkter Zugriff"
#: core/models.py:118 #: core/models.py:117
msgid "id" msgid "id"
msgstr "ID" msgstr "ID"
#: core/models.py:119 #: 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:125 #: core/models.py:124
msgid "created on" msgid "created on"
msgstr "erstellt am" msgstr "erstellt am"
#: core/models.py:126 #: 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:131 #: core/models.py:130
msgid "updated on" msgid "updated on"
msgstr "aktualisiert am" msgstr "aktualisiert am"
#: core/models.py:132 #: 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:150 #: core/models.py:151
msgid ""
"Enter a valid sub. This value may contain only letters, numbers, and @/./+/-/"
"_ characters."
msgstr ""
"Geben Sie einen gültigen Sub ein. Dieser Wert darf nur Buchstaben, Zahlen "
"und die Zeichen @/./+/-/_ enthalten."
#: 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. Printable ASCII characters only." "or fewer. Letters, numbers, and @/./+/-/_ characters only."
msgstr "" msgstr ""
"Optional für ausstehende Benutzer; erforderlich nach Kontoaktivierung. " "Optional für ausstehende Benutzer; erforderlich nach Kontoaktivierung. "
"Maximal 255 Zeichen. Nur druckbare ASCII-Zeichen erlaubt." "Maximal 255 Zeichen. Nur Buchstaben, Zahlen und @/./+/-/_ Zeichen erlaubt."
#: core/validators.py:22 #: core/models.py:168
msgid "Enter a valid sub. This value should be printable ASCII only."
msgstr "Geben Sie einen gültigen sub ein. Dieser Wert darf nur druckbare ASCII-Zeichen enthalten."
#: core/models.py:161
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:190 #: core/models.py:193
msgid "default room access level"
msgstr ""
#: core/models.py:192
msgid ""
"Access level applied by default to new rooms created by this user. When "
"empty, the instance default is used."
msgstr ""
#: core/models.py:199
#, fuzzy
#| msgid "Visio room configuration"
msgid "default room configuration"
msgstr "Visio-Raumkonfiguration"
#: core/models.py:201
msgid "Configurations applied by default to new rooms created by this user."
msgstr ""
#: core/models.py:205
msgid "device" msgid "device"
msgstr "Gerät" msgstr "Gerät"
#: core/models.py:207 #: 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:210 #: core/models.py:198
msgid "staff status" msgid "staff status"
msgstr "Mitarbeiterstatus" msgstr "Mitarbeiterstatus"
#: core/models.py:212 #: 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:215 #: core/models.py:203
msgid "active" msgid "active"
msgstr "aktiv" msgstr "aktiv"
#: core/models.py:218 #: 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."
@@ -312,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:231 #: core/models.py:219
msgid "user" msgid "user"
msgstr "Benutzer" msgstr "Benutzer"
#: core/models.py:232 #: core/models.py:220
msgid "users" msgid "users"
msgstr "Benutzer" msgstr "Benutzer"
#: core/models.py:298 #: core/models.py:286
msgid "Resource" msgid "Resource"
msgstr "Ressource" msgstr "Ressource"
#: core/models.py:299 #: core/models.py:287
msgid "Resources" msgid "Resources"
msgstr "Ressourcen" msgstr "Ressourcen"
#: core/models.py:357 #: core/models.py:345
msgid "Resource access" msgid "Resource access"
msgstr "Ressourcenzugriff" msgstr "Ressourcenzugriff"
#: core/models.py:358 #: core/models.py:346
msgid "Resource accesses" msgid "Resource accesses"
msgstr "Ressourcenzugriffe" msgstr "Ressourcenzugriffe"
#: core/models.py:364 #: 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:421 #: core/models.py:409
msgid "Visio room configuration" msgid "Visio room configuration"
msgstr "Visio-Raumkonfiguration" msgstr "Visio-Raumkonfiguration"
#: core/models.py:422 #: 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:429 #: 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:430 #: 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:436 core/models.py:597 #: core/models.py:424 core/models.py:578
msgid "Room" msgid "Room"
msgstr "Raum" msgstr "Raum"
#: core/models.py:437 #: core/models.py:425
msgid "Rooms" msgid "Rooms"
msgstr "Räume" msgstr "Räume"
#: core/models.py:608 #: core/models.py:589
msgid "Worker ID" msgid "Worker ID"
msgstr "Worker-ID" msgstr "Worker-ID"
#: core/models.py:610 #: 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."
@@ -380,153 +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:618 #: core/models.py:599
msgid "Recording mode" msgid "Recording mode"
msgstr "Aufzeichnungsmodus" msgstr "Aufzeichnungsmodus"
#: core/models.py:619 #: 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:624 core/models.py:625 #: core/models.py:605 core/models.py:606
msgid "Recording options" msgid "Recording options"
msgstr "Aufnahmeoptionen" msgstr "Aufnahmeoptionen"
#: core/models.py:632 #: core/models.py:613
msgid "External Process ID" msgid "External Process ID"
msgstr "External Process ID" msgstr "External Process ID"
#: core/models.py:633 #: core/models.py:614
msgid "ID of the external process associated with the recording." msgid "ID of the external process associated with the recording."
msgstr "ID des externen Prozesses, der mit der Aufzeichnung verknüpft ist" msgstr "ID des externen Prozesses, der mit der Aufzeichnung verknüpft ist"
#: core/models.py:639 #: core/models.py:620
msgid "Recording" msgid "Recording"
msgstr "Aufzeichnung" msgstr "Aufzeichnung"
#: core/models.py:640 #: core/models.py:621
msgid "Recordings" msgid "Recordings"
msgstr "Aufzeichnungen" msgstr "Aufzeichnungen"
#: core/models.py:750 #: core/models.py:731
msgid "Recording/user relation" msgid "Recording/user relation"
msgstr "Beziehung Aufzeichnung/Benutzer" msgstr "Beziehung Aufzeichnung/Benutzer"
#: core/models.py:751 #: core/models.py:732
msgid "Recording/user relations" msgid "Recording/user relations"
msgstr "Beziehungen Aufzeichnung/Benutzer" msgstr "Beziehungen Aufzeichnung/Benutzer"
#: core/models.py:757 #: 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:763 #: 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:769 #: 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:786 #: core/models.py:767
msgid "Create rooms" msgid "Create rooms"
msgstr "Räume erstellen" msgstr "Räume erstellen"
#: core/models.py:787 #: core/models.py:768
msgid "List rooms" msgid "List rooms"
msgstr "Räume auflisten" msgstr "Räume auflisten"
#: core/models.py:788 #: core/models.py:769
msgid "Retrieve room details" msgid "Retrieve room details"
msgstr "Raumdetails abrufen" msgstr "Raumdetails abrufen"
#: core/models.py:789 #: core/models.py:770
msgid "Update rooms" msgid "Update rooms"
msgstr "Räume aktualisieren" msgstr "Räume aktualisieren"
#: core/models.py:790 #: core/models.py:771
msgid "Delete rooms" msgid "Delete rooms"
msgstr "Räume löschen" msgstr "Räume löschen"
#: core/models.py:803 #: core/models.py:784
msgid "Application name" msgid "Application name"
msgstr "Anwendungsname" msgstr "Anwendungsname"
#: core/models.py:804 #: 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:814 #: 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:825 #: core/models.py:806
msgid "Application" msgid "Application"
msgstr "Anwendung" msgstr "Anwendung"
#: core/models.py:826 #: core/models.py:807
msgid "Applications" msgid "Applications"
msgstr "Anwendungen" msgstr "Anwendungen"
#: core/models.py:849 #: 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:852 #: core/models.py:833
msgid "Domain" msgid "Domain"
msgstr "Domain" msgstr "Domain"
#: core/models.py:853 #: 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:865 #: core/models.py:846
msgid "Application domain" msgid "Application domain"
msgstr "Anwendungsdomain" msgstr "Anwendungsdomain"
#: core/models.py:866 #: core/models.py:847
msgid "Application domains" msgid "Application domains"
msgstr "Anwendungsdomains" msgstr "Anwendungsdomains"
#: core/models.py:884 #: core/models.py:865
msgid "Pending" msgid "Pending"
msgstr "Ausstehend" msgstr "Ausstehend"
#: core/models.py:885 #: core/models.py:866
msgid "Analyzing" msgid "Analyzing"
msgstr "" msgstr ""
#: core/models.py:892 #: core/models.py:873
msgid "Ready" msgid "Ready"
msgstr "Bereit" msgstr "Bereit"
#: core/models.py:898 #: core/models.py:879
msgid "Background image" msgid "Background image"
msgstr "Hintergrundbild" msgstr "Hintergrundbild"
#: core/models.py:910 #: core/models.py:891
msgid "title" msgid "title"
msgstr "Titel" msgstr "Titel"
#: core/models.py:934 #: 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:939 #: core/models.py:920
msgid "File" msgid "File"
msgstr "Datei" msgstr "Datei"
#: core/models.py:940 #: core/models.py:921
msgid "Files" msgid "Files"
msgstr "Dateien" msgstr "Dateien"
#: core/models.py:1060 #: 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:1070 #: 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."
@@ -534,15 +518,15 @@ 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:124 #: 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:195 #: core/recording/event/notification.py:194
msgid "Transcription" msgid "Transcription"
msgstr "Transkription" msgstr "Transkription"
#: core/recording/event/notification.py:206 #: core/recording/event/notification.py:204
#, python-brace-format #, python-brace-format
msgid "Meeting \"{room}\" on {room_recording_date} at {room_recording_time}" msgid "Meeting \"{room}\" on {room_recording_date} at {room_recording_time}"
msgstr "" msgstr ""
@@ -553,25 +537,25 @@ msgstr ""
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"
msgstr "Videoanruf läuft: {sender.email} wartet auf Ihre Teilnahme" msgstr "Videoanruf läuft: {sender.email} wartet auf Ihre Teilnahme"
#: core/templates/mail/html/invitation.html:151 #: core/templates/mail/html/invitation.html:159
#: core/templates/mail/html/screen_recording.html:151 #: core/templates/mail/html/screen_recording.html:159
#: core/templates/mail/text/invitation.txt:4 #: core/templates/mail/text/invitation.txt:3
#: core/templates/mail/text/screen_recording.txt:4 #: core/templates/mail/text/screen_recording.txt:3
msgid "Logo email" msgid "Logo email"
msgstr "Logo-E-Mail" msgstr "Logo-E-Mail"
#: core/templates/mail/html/invitation.html:181 #: core/templates/mail/html/invitation.html:189
#: core/templates/mail/text/invitation.txt:6 #: core/templates/mail/text/invitation.txt:5
msgid "invites you to join an ongoing video call" msgid "invites you to join an ongoing video call"
msgstr "lädt Sie zu einem laufenden Videoanruf ein" msgstr "lädt Sie zu einem laufenden Videoanruf ein"
#: core/templates/mail/html/invitation.html:192 #: core/templates/mail/html/invitation.html:200
#: core/templates/mail/text/invitation.txt:8 #: core/templates/mail/text/invitation.txt:7
msgid "JOIN THE CALL" msgid "JOIN THE CALL"
msgstr "AM ANRUF TEILNEHMEN" msgstr "AM ANRUF TEILNEHMEN"
#: core/templates/mail/html/invitation.html:219 #: core/templates/mail/html/invitation.html:227
#: core/templates/mail/text/invitation.txt:14 #: core/templates/mail/text/invitation.txt:13
msgid "" msgid ""
"If you can't click the button, copy and paste the URL into your browser to " "If you can't click the button, copy and paste the URL into your browser to "
"join the call." "join the call."
@@ -579,47 +563,41 @@ msgstr ""
"Wenn Sie den Button nicht anklicken können, kopieren Sie die URL und fügen " "Wenn Sie den Button nicht anklicken können, kopieren Sie die URL und fügen "
"Sie sie in Ihren Browser ein, um am Anruf teilzunehmen." "Sie sie in Ihren Browser ein, um am Anruf teilzunehmen."
#: core/templates/mail/html/invitation.html:227 #: core/templates/mail/html/invitation.html:235
#: core/templates/mail/text/invitation.txt:16 #: core/templates/mail/text/invitation.txt:15
msgid "Tips for a better experience:" msgid "Tips for a better experience:"
msgstr "Tipps für ein besseres Erlebnis:" msgstr "Tipps für ein besseres Erlebnis:"
#: core/templates/mail/html/invitation.html:229 #: core/templates/mail/html/invitation.html:237
#: core/templates/mail/text/invitation.txt:18 #: core/templates/mail/text/invitation.txt:17
msgid "Use Chrome or Firefox for better call quality" msgid "Use Chrome or Firefox for better call quality"
msgstr "Verwenden Sie Chrome oder Firefox für eine bessere Anrufqualität" msgstr "Verwenden Sie Chrome oder Firefox für eine bessere Anrufqualität"
#: core/templates/mail/html/invitation.html:230 #: core/templates/mail/html/invitation.html:238
#: core/templates/mail/text/invitation.txt:19 #: core/templates/mail/text/invitation.txt:18
msgid "Test your microphone and camera before joining" msgid "Test your microphone and camera before joining"
msgstr "Testen Sie Ihr Mikrofon und Ihre Kamera vor dem Beitritt" msgstr "Testen Sie Ihr Mikrofon und Ihre Kamera vor dem Beitritt"
#: core/templates/mail/html/invitation.html:231 #: core/templates/mail/html/invitation.html:239
#: core/templates/mail/text/invitation.txt:20 #: core/templates/mail/text/invitation.txt:19
msgid "Make sure you have a stable internet connection" msgid "Make sure you have a stable internet connection"
msgstr "Stellen Sie sicher, dass Sie eine stabile Internetverbindung haben" msgstr "Stellen Sie sicher, dass Sie eine stabile Internetverbindung haben"
#: core/templates/mail/html/invitation.html:240 #: core/templates/mail/html/invitation.html:248
#: core/templates/mail/html/screen_recording.html:237 #: core/templates/mail/html/screen_recording.html:245
#: core/templates/mail/text/invitation.txt:22 #: core/templates/mail/text/invitation.txt:21
#: core/templates/mail/text/screen_recording.txt:24 #: core/templates/mail/text/screen_recording.txt:23
#, python-format #, python-format
msgid " Thank you for using %(brandname)s. " msgid " Thank you for using %(brandname)s. "
msgstr " Vielen Dank für die Nutzung von %(brandname)s. " msgstr " Vielen Dank für die Nutzung von %(brandname)s. "
#: core/templates/mail/html/invitation.html:271 #: core/templates/mail/html/screen_recording.html:188
#, python-format #: core/templates/mail/text/screen_recording.txt:6
msgid ""
"This mail has been sent to %(email)s by <a href=\"%(href)s\">%(name)s</a>"
msgstr ""
#: core/templates/mail/html/screen_recording.html:180
#: core/templates/mail/text/screen_recording.txt:7
msgid "Your recording is ready!" msgid "Your recording is ready!"
msgstr "Ihre Aufzeichnung ist fertig!" msgstr "Ihre Aufzeichnung ist fertig!"
#: core/templates/mail/html/screen_recording.html:187 #: core/templates/mail/html/screen_recording.html:195
#: core/templates/mail/text/screen_recording.txt:9 #: core/templates/mail/text/screen_recording.txt:8
#, python-format #, python-format
msgid "" msgid ""
" Your recording of \"%(room_name)s\" on %(recording_date)s at " " Your recording of \"%(room_name)s\" on %(recording_date)s at "
@@ -628,14 +606,14 @@ msgstr ""
" Ihre Aufzeichnung von \"%(room_name)s\" am %(recording_date)s um " " Ihre Aufzeichnung von \"%(room_name)s\" am %(recording_date)s um "
"%(recording_time)s steht nun zum Herunterladen bereit. " "%(recording_time)s steht nun zum Herunterladen bereit. "
#: core/templates/mail/html/screen_recording.html:187 #: core/templates/mail/html/screen_recording.html:195
#: core/templates/mail/text/screen_recording.txt:9 #: core/templates/mail/text/screen_recording.txt:8
#, python-format #, python-format
msgid " The recording will expire in %(days)s days. " msgid " The recording will expire in %(days)s days. "
msgstr " Die Aufzeichnung wird in %(days)s Tagen ablaufen. " msgstr " Die Aufzeichnung wird in %(days)s Tagen ablaufen. "
#: core/templates/mail/html/screen_recording.html:192 #: core/templates/mail/html/screen_recording.html:200
#: core/templates/mail/text/screen_recording.txt:10 #: core/templates/mail/text/screen_recording.txt:9
msgid "" msgid ""
" Sharing the recording via link is not yet available. Only organizers can " " Sharing the recording via link is not yet available. Only organizers can "
"download it. " "download it. "
@@ -643,33 +621,33 @@ msgstr ""
" Die Freigabe der Aufzeichnung per Link ist noch nicht verfügbar. Nur " " Die Freigabe der Aufzeichnung per Link ist noch nicht verfügbar. Nur "
"Organisatoren können sie herunterladen. " "Organisatoren können sie herunterladen. "
#: core/templates/mail/html/screen_recording.html:198 #: core/templates/mail/html/screen_recording.html:206
#: core/templates/mail/text/screen_recording.txt:12 #: core/templates/mail/text/screen_recording.txt:11
msgid "To keep this recording permanently:" 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:200 #: core/templates/mail/html/screen_recording.html:208
#, python-format #, 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 "
#: core/templates/mail/html/screen_recording.html:201 #: core/templates/mail/html/screen_recording.html:209
#: core/templates/mail/text/screen_recording.txt:15 #: core/templates/mail/text/screen_recording.txt:14
msgid "Use the \"Download\" button in the interface " msgid "Use the \"Download\" button in the interface "
msgstr "Verwenden Sie den Button „Herunterladen“ in der Oberfläche " msgstr "Verwenden Sie den Button „Herunterladen“ in der Oberfläche "
#: core/templates/mail/html/screen_recording.html:202 #: core/templates/mail/html/screen_recording.html:210
#: core/templates/mail/text/screen_recording.txt:16 #: core/templates/mail/text/screen_recording.txt:15
msgid "Save the file to your preferred location" msgid "Save the file to your preferred location"
msgstr "Speichern Sie die Datei an einem gewünschten Ort" msgstr "Speichern Sie die Datei an einem gewünschten Ort"
#: core/templates/mail/html/screen_recording.html:213 #: core/templates/mail/html/screen_recording.html:221
#: core/templates/mail/text/screen_recording.txt:18 #: core/templates/mail/text/screen_recording.txt:17
msgid "Open" msgid "Open"
msgstr "Öffnen" msgstr "Öffnen"
#: core/templates/mail/html/screen_recording.html:222 #: core/templates/mail/html/screen_recording.html:230
#: core/templates/mail/text/screen_recording.txt:20 #: core/templates/mail/text/screen_recording.txt:19
#, python-format #, python-format
msgid "" msgid ""
" If you have any questions or need assistance, please contact our support " " If you have any questions or need assistance, please contact our support "
@@ -678,33 +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. "
#: core/templates/mail/text/invitation.txt:24 #: core/templates/mail/text/screen_recording.txt:13
#, python-format
msgid "This mail has been sent to %(email)s by %(name)s [%(href)s]"
msgstr ""
#: core/templates/mail/text/screen_recording.txt:14
#, fuzzy, python-format #, fuzzy, python-format
#| msgid "Click the \"<a href=\"%(link)s\">Open</a>\" link below " #| msgid "Click the \"<a href=\"%(link)s\">Open</a>\" link below "
msgid "Click the \"Open [%(link)s]\" link below " msgid "Click the \"Open [%(link)s]\" 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 "
#: meet/settings.py:238 #: meet/settings.py:228
msgid "English" msgid "English"
msgstr "Englisch" msgstr "Englisch"
#: meet/settings.py:239 #: meet/settings.py:229
msgid "French" msgid "French"
msgstr "Französisch" msgstr "Französisch"
#: meet/settings.py:240 #: meet/settings.py:230
msgid "Dutch" msgid "Dutch"
msgstr "Niederländisch" msgstr "Niederländisch"
#: meet/settings.py:241 #: meet/settings.py:231
msgid "German" msgid "German"
msgstr "Deutsch" msgstr "Deutsch"
#: meet/settings.py:242
msgid "Spanish"
msgstr "Spanisch"
+159 -190
View File
@@ -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-09-02 22:52+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"
@@ -51,11 +51,11 @@ msgstr ""
msgid "File preview" msgid "File preview"
msgstr "" msgstr ""
#: core/admin.py:300 core/admin.py:450 #: core/admin.py:300 core/admin.py:443
msgid "No owner" msgid "No owner"
msgstr "No owner" msgstr "No owner"
#: core/admin.py:303 core/admin.py:453 #: core/admin.py:303 core/admin.py:446
msgid "Multiple owners" msgid "Multiple owners"
msgstr "Multiple owners" msgstr "Multiple owners"
@@ -98,11 +98,11 @@ msgstr "%(count)s recording(s) successfully marked as 'Failed to Stop'."
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:517 #: core/admin.py:510
msgid "No scopes" msgid "No scopes"
msgstr "No scopes" msgstr "No scopes"
#: core/admin.py:519 #: core/admin.py:512
msgid "Scopes" msgid "Scopes"
msgstr "Scopes" msgstr "Scopes"
@@ -110,199 +110,183 @@ msgstr "Scopes"
msgid "Creator is me" msgid "Creator is me"
msgstr "Creator is me" msgstr "Creator is me"
#: core/api/serializers.py:108 #: 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:560 #: 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:1268 #: 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."
#: core/models.py:38 #: core/models.py:37
msgid "Member" msgid "Member"
msgstr "Member" msgstr "Member"
#: core/models.py:39 #: core/models.py:38
msgid "Administrator" msgid "Administrator"
msgstr "Administrator" msgstr "Administrator"
#: core/models.py:40 #: core/models.py:39
msgid "Owner" msgid "Owner"
msgstr "Owner" msgstr "Owner"
#: core/models.py:56 #: core/models.py:55
msgid "Initiated" msgid "Initiated"
msgstr "Initiated" msgstr "Initiated"
#: core/models.py:57 #: core/models.py:56
msgid "Active" msgid "Active"
msgstr "Active" msgstr "Active"
#: core/models.py:58 #: core/models.py:57
msgid "Stopped" msgid "Stopped"
msgstr "Stopped" msgstr "Stopped"
#: core/models.py:59 #: core/models.py:58
msgid "Saved" msgid "Saved"
msgstr "Saved" msgstr "Saved"
#: core/models.py:60 #: core/models.py:59
msgid "Aborted" msgid "Aborted"
msgstr "Aborted" msgstr "Aborted"
#: core/models.py:61 #: core/models.py:60
msgid "Failed to Start" msgid "Failed to Start"
msgstr "Failed to Start" msgstr "Failed to Start"
#: core/models.py:62 #: core/models.py:61
msgid "Failed to Stop" msgid "Failed to Stop"
msgstr "Failed to Stop" msgstr "Failed to Stop"
#: core/models.py:63 #: core/models.py:62
msgid "Notification succeeded" msgid "Notification succeeded"
msgstr "Notification succeeded" msgstr "Notification succeeded"
#: core/models.py:66 #: core/models.py:65
msgid "External process successful" msgid "External process successful"
msgstr "External process successful" msgstr "External process successful"
#: core/models.py:68 #: core/models.py:67
msgid "External process failed" msgid "External process failed"
msgstr "External process failed" msgstr "External process failed"
#: core/models.py:97 #: core/models.py:96
msgid "SCREEN_RECORDING" msgid "SCREEN_RECORDING"
msgstr "SCREEN_RECORDING" msgstr "SCREEN_RECORDING"
#: core/models.py:98 #: core/models.py:97
msgid "TRANSCRIPT" msgid "TRANSCRIPT"
msgstr "TRANSCRIPT" msgstr "TRANSCRIPT"
#: core/models.py:104 #: core/models.py:103
msgid "Public Access" msgid "Public Access"
msgstr "Public Access" msgstr "Public Access"
#: core/models.py:105 #: core/models.py:104
msgid "Trusted Access" msgid "Trusted Access"
msgstr "Trusted Access" msgstr "Trusted Access"
#: core/models.py:106 #: core/models.py:105
msgid "Restricted Access" msgid "Restricted Access"
msgstr "Restricted Access" msgstr "Restricted Access"
#: core/models.py:118 #: core/models.py:117
msgid "id" msgid "id"
msgstr "id" msgstr "id"
#: core/models.py:119 #: 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:125 #: core/models.py:124
msgid "created on" msgid "created on"
msgstr "created on" msgstr "created on"
#: core/models.py:126 #: 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:131 #: core/models.py:130
msgid "updated on" msgid "updated on"
msgstr "updated on" msgstr "updated on"
#: core/models.py:132 #: 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:150 #: core/models.py:151
msgid ""
"Enter a valid sub. This value may contain only letters, numbers, and @/./+/-/"
"_ characters."
msgstr ""
"Enter a valid sub. This value may contain only letters, numbers, and @/./+/-/"
"_ characters."
#: 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. Printable ASCII characters only." "or fewer. Letters, numbers, and @/./+/-/_ characters only."
msgstr "" msgstr ""
"Optional for pending users; required upon account activation. 255 characters " "Required. 255 characters or fewer. Letters, numbers, and @/./+/-/_ "
"or fewer. Printable ASCII characters only." "characters only."
#: core/validators.py:22 #: core/models.py:168
msgid "Enter a valid sub. This value should be printable ASCII only."
msgstr "Enter a valid sub. This value should be printable ASCII only."
#: core/models.py:161
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:190 #: core/models.py:193
msgid "default room access level"
msgstr ""
#: core/models.py:192
msgid ""
"Access level applied by default to new rooms created by this user. When "
"empty, the instance default is used."
msgstr ""
#: core/models.py:199
#, fuzzy
#| msgid "Visio room configuration"
msgid "default room configuration"
msgstr "Visio room configuration"
#: core/models.py:201
msgid "Configurations applied by default to new rooms created by this user."
msgstr ""
#: core/models.py:205
msgid "device" msgid "device"
msgstr "device" msgstr "device"
#: core/models.py:207 #: 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:210 #: core/models.py:198
msgid "staff status" msgid "staff status"
msgstr "staff status" msgstr "staff status"
#: core/models.py:212 #: 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:215 #: core/models.py:203
msgid "active" msgid "active"
msgstr "active" msgstr "active"
#: core/models.py:218 #: 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."
@@ -310,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:231 #: core/models.py:219
msgid "user" msgid "user"
msgstr "user" msgstr "user"
#: core/models.py:232 #: core/models.py:220
msgid "users" msgid "users"
msgstr "users" msgstr "users"
#: core/models.py:298 #: core/models.py:286
msgid "Resource" msgid "Resource"
msgstr "Resource" msgstr "Resource"
#: core/models.py:299 #: core/models.py:287
msgid "Resources" msgid "Resources"
msgstr "Resources" msgstr "Resources"
#: core/models.py:357 #: core/models.py:345
msgid "Resource access" msgid "Resource access"
msgstr "Resource access" msgstr "Resource access"
#: core/models.py:358 #: core/models.py:346
msgid "Resource accesses" msgid "Resource accesses"
msgstr "Resource accesses" msgstr "Resource accesses"
#: core/models.py:364 #: 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:421 #: core/models.py:409
msgid "Visio room configuration" msgid "Visio room configuration"
msgstr "Visio room configuration" msgstr "Visio room configuration"
#: core/models.py:422 #: 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:429 #: core/models.py:417
msgid "Room PIN code" msgid "Room PIN code"
msgstr "Room PIN code" msgstr "Room PIN code"
#: core/models.py:430 #: 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:436 core/models.py:597 #: core/models.py:424 core/models.py:578
msgid "Room" msgid "Room"
msgstr "Room" msgstr "Room"
#: core/models.py:437 #: core/models.py:425
msgid "Rooms" msgid "Rooms"
msgstr "Rooms" msgstr "Rooms"
#: core/models.py:608 #: core/models.py:589
msgid "Worker ID" msgid "Worker ID"
msgstr "Worker ID" msgstr "Worker ID"
#: core/models.py:610 #: 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."
@@ -374,171 +358,171 @@ 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:618 #: core/models.py:599
msgid "Recording mode" msgid "Recording mode"
msgstr "Recording mode" msgstr "Recording mode"
#: core/models.py:619 #: 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:624 core/models.py:625 #: core/models.py:605 core/models.py:606
msgid "Recording options" msgid "Recording options"
msgstr "Recording options" msgstr "Recording options"
#: core/models.py:632 #: core/models.py:613
msgid "External Process ID" msgid "External Process ID"
msgstr "External Process ID" msgstr "External Process ID"
#: core/models.py:633 #: core/models.py:614
msgid "ID of the external process associated with the recording." msgid "ID of the external process associated with the recording."
msgstr "ID of the external process associated with the recording." msgstr "ID of the external process associated with the recording."
#: core/models.py:639 #: core/models.py:620
msgid "Recording" msgid "Recording"
msgstr "Recording" msgstr "Recording"
#: core/models.py:640 #: core/models.py:621
msgid "Recordings" msgid "Recordings"
msgstr "Recordings" msgstr "Recordings"
#: core/models.py:750 #: core/models.py:731
msgid "Recording/user relation" msgid "Recording/user relation"
msgstr "Recording/user relation" msgstr "Recording/user relation"
#: core/models.py:751 #: core/models.py:732
msgid "Recording/user relations" msgid "Recording/user relations"
msgstr "Recording/user relations" msgstr "Recording/user relations"
#: core/models.py:757 #: 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:763 #: 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:769 #: 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:786 #: 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:787 #: core/models.py:768
msgid "List rooms" msgid "List rooms"
msgstr "List rooms" msgstr "List rooms"
#: core/models.py:788 #: core/models.py:769
msgid "Retrieve room details" msgid "Retrieve room details"
msgstr "Retrieve room details" msgstr "Retrieve room details"
#: core/models.py:789 #: 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:790 #: core/models.py:771
msgid "Delete rooms" msgid "Delete rooms"
msgstr "Delete rooms" msgstr "Delete rooms"
#: core/models.py:803 #: core/models.py:784
msgid "Application name" msgid "Application name"
msgstr "Application name" msgstr "Application name"
#: core/models.py:804 #: 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:814 #: 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:825 #: core/models.py:806
msgid "Application" msgid "Application"
msgstr "Application" msgstr "Application"
#: core/models.py:826 #: core/models.py:807
msgid "Applications" msgid "Applications"
msgstr "Applications" msgstr "Applications"
#: core/models.py:849 #: 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:852 #: core/models.py:833
msgid "Domain" msgid "Domain"
msgstr "Domain" msgstr "Domain"
#: core/models.py:853 #: 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:865 #: core/models.py:846
msgid "Application domain" msgid "Application domain"
msgstr "Application domain" msgstr "Application domain"
#: core/models.py:866 #: core/models.py:847
msgid "Application domains" msgid "Application domains"
msgstr "Application domains" msgstr "Application domains"
#: core/models.py:884 #: core/models.py:865
#, fuzzy #, fuzzy
#| msgid "Recording" #| msgid "Recording"
msgid "Pending" msgid "Pending"
msgstr "Pending" msgstr "Pending"
#: core/models.py:885 #: core/models.py:866
msgid "Analyzing" msgid "Analyzing"
msgstr "" msgstr ""
#: core/models.py:892 #: core/models.py:873
msgid "Ready" msgid "Ready"
msgstr "Ready" msgstr "Ready"
#: core/models.py:898 #: core/models.py:879
msgid "Background image" msgid "Background image"
msgstr "Background image" msgstr "Background image"
#: core/models.py:910 #: core/models.py:891
msgid "title" msgid "title"
msgstr "title" msgstr "title"
#: core/models.py:934 #: 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:939 #: core/models.py:920
msgid "File" msgid "File"
msgstr "File" msgstr "File"
#: core/models.py:940 #: core/models.py:921
msgid "Files" msgid "Files"
msgstr "Files" msgstr "Files"
#: core/models.py:1060 #: 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:1070 #: 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:124 #: 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:195 #: core/recording/event/notification.py:194
msgid "Transcription" msgid "Transcription"
msgstr "Transcription" msgstr "Transcription"
#: core/recording/event/notification.py:206 #: core/recording/event/notification.py:204
#, python-brace-format #, python-brace-format
msgid "Meeting \"{room}\" on {room_recording_date} at {room_recording_time}" msgid "Meeting \"{room}\" on {room_recording_date} at {room_recording_time}"
msgstr "Meeting \"{room}\" on {room_recording_date} at {room_recording_time}" msgstr "Meeting \"{room}\" on {room_recording_date} at {room_recording_time}"
@@ -548,25 +532,25 @@ msgstr "Meeting \"{room}\" on {room_recording_date} at {room_recording_time}"
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"
msgstr "Video call in progress: {sender.email} is waiting for you to connect" msgstr "Video call in progress: {sender.email} is waiting for you to connect"
#: core/templates/mail/html/invitation.html:151 #: core/templates/mail/html/invitation.html:159
#: core/templates/mail/html/screen_recording.html:151 #: core/templates/mail/html/screen_recording.html:159
#: core/templates/mail/text/invitation.txt:4 #: core/templates/mail/text/invitation.txt:3
#: core/templates/mail/text/screen_recording.txt:4 #: core/templates/mail/text/screen_recording.txt:3
msgid "Logo email" msgid "Logo email"
msgstr "Logo email" msgstr "Logo email"
#: core/templates/mail/html/invitation.html:181 #: core/templates/mail/html/invitation.html:189
#: core/templates/mail/text/invitation.txt:6 #: core/templates/mail/text/invitation.txt:5
msgid "invites you to join an ongoing video call" msgid "invites you to join an ongoing video call"
msgstr "invites you to join an ongoing video call" msgstr "invites you to join an ongoing video call"
#: core/templates/mail/html/invitation.html:192 #: core/templates/mail/html/invitation.html:200
#: core/templates/mail/text/invitation.txt:8 #: core/templates/mail/text/invitation.txt:7
msgid "JOIN THE CALL" msgid "JOIN THE CALL"
msgstr "JOIN THE CALL" msgstr "JOIN THE CALL"
#: core/templates/mail/html/invitation.html:219 #: core/templates/mail/html/invitation.html:227
#: core/templates/mail/text/invitation.txt:14 #: core/templates/mail/text/invitation.txt:13
msgid "" msgid ""
"If you can't click the button, copy and paste the URL into your browser to " "If you can't click the button, copy and paste the URL into your browser to "
"join the call." "join the call."
@@ -574,47 +558,41 @@ msgstr ""
"If you can't click the button, copy and paste the URL into your browser to " "If you can't click the button, copy and paste the URL into your browser to "
"join the call." "join the call."
#: core/templates/mail/html/invitation.html:227 #: core/templates/mail/html/invitation.html:235
#: core/templates/mail/text/invitation.txt:16 #: core/templates/mail/text/invitation.txt:15
msgid "Tips for a better experience:" msgid "Tips for a better experience:"
msgstr "Tips for a better experience:" msgstr "Tips for a better experience:"
#: core/templates/mail/html/invitation.html:229 #: core/templates/mail/html/invitation.html:237
#: core/templates/mail/text/invitation.txt:18 #: core/templates/mail/text/invitation.txt:17
msgid "Use Chrome or Firefox for better call quality" msgid "Use Chrome or Firefox for better call quality"
msgstr "Use Chrome or Firefox for better call quality" msgstr "Use Chrome or Firefox for better call quality"
#: core/templates/mail/html/invitation.html:230 #: core/templates/mail/html/invitation.html:238
#: core/templates/mail/text/invitation.txt:19 #: core/templates/mail/text/invitation.txt:18
msgid "Test your microphone and camera before joining" msgid "Test your microphone and camera before joining"
msgstr "Test your microphone and camera before joining" msgstr "Test your microphone and camera before joining"
#: core/templates/mail/html/invitation.html:231 #: core/templates/mail/html/invitation.html:239
#: core/templates/mail/text/invitation.txt:20 #: core/templates/mail/text/invitation.txt:19
msgid "Make sure you have a stable internet connection" msgid "Make sure you have a stable internet connection"
msgstr "Make sure you have a stable internet connection" msgstr "Make sure you have a stable internet connection"
#: core/templates/mail/html/invitation.html:240 #: core/templates/mail/html/invitation.html:248
#: core/templates/mail/html/screen_recording.html:237 #: core/templates/mail/html/screen_recording.html:245
#: core/templates/mail/text/invitation.txt:22 #: core/templates/mail/text/invitation.txt:21
#: core/templates/mail/text/screen_recording.txt:24 #: core/templates/mail/text/screen_recording.txt:23
#, python-format #, python-format
msgid " Thank you for using %(brandname)s. " msgid " Thank you for using %(brandname)s. "
msgstr " Thank you for using %(brandname)s. " msgstr " Thank you for using %(brandname)s. "
#: core/templates/mail/html/invitation.html:271 #: core/templates/mail/html/screen_recording.html:188
#, python-format #: core/templates/mail/text/screen_recording.txt:6
msgid ""
"This mail has been sent to %(email)s by <a href=\"%(href)s\">%(name)s</a>"
msgstr ""
#: core/templates/mail/html/screen_recording.html:180
#: core/templates/mail/text/screen_recording.txt:7
msgid "Your recording is ready!" msgid "Your recording is ready!"
msgstr "Your recording is ready!" msgstr "Your recording is ready!"
#: core/templates/mail/html/screen_recording.html:187 #: core/templates/mail/html/screen_recording.html:195
#: core/templates/mail/text/screen_recording.txt:9 #: core/templates/mail/text/screen_recording.txt:8
#, python-format #, python-format
msgid "" msgid ""
" Your recording of \"%(room_name)s\" on %(recording_date)s at " " Your recording of \"%(room_name)s\" on %(recording_date)s at "
@@ -623,14 +601,14 @@ msgstr ""
" Your recording of \"%(room_name)s\" on %(recording_date)s at " " Your recording of \"%(room_name)s\" on %(recording_date)s at "
"%(recording_time)s is now ready to download. " "%(recording_time)s is now ready to download. "
#: core/templates/mail/html/screen_recording.html:187 #: core/templates/mail/html/screen_recording.html:195
#: core/templates/mail/text/screen_recording.txt:9 #: core/templates/mail/text/screen_recording.txt:8
#, python-format #, python-format
msgid " The recording will expire in %(days)s days. " msgid " The recording will expire in %(days)s days. "
msgstr " The recording will expire in %(days)s days. " msgstr " The recording will expire in %(days)s days. "
#: core/templates/mail/html/screen_recording.html:192 #: core/templates/mail/html/screen_recording.html:200
#: core/templates/mail/text/screen_recording.txt:10 #: core/templates/mail/text/screen_recording.txt:9
msgid "" msgid ""
" Sharing the recording via link is not yet available. Only organizers can " " Sharing the recording via link is not yet available. Only organizers can "
"download it. " "download it. "
@@ -638,33 +616,33 @@ msgstr ""
" Sharing the recording via link is not yet available. Only organizers can " " Sharing the recording via link is not yet available. Only organizers can "
"download it. " "download it. "
#: core/templates/mail/html/screen_recording.html:198 #: core/templates/mail/html/screen_recording.html:206
#: core/templates/mail/text/screen_recording.txt:12 #: core/templates/mail/text/screen_recording.txt:11
msgid "To keep this recording permanently:" 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:200 #: core/templates/mail/html/screen_recording.html:208
#, python-format #, 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 "
#: core/templates/mail/html/screen_recording.html:201 #: core/templates/mail/html/screen_recording.html:209
#: core/templates/mail/text/screen_recording.txt:15 #: core/templates/mail/text/screen_recording.txt:14
msgid "Use the \"Download\" button in the interface " msgid "Use the \"Download\" button in the interface "
msgstr "Use the \"Download\" button in the interface " msgstr "Use the \"Download\" button in the interface "
#: core/templates/mail/html/screen_recording.html:202 #: core/templates/mail/html/screen_recording.html:210
#: core/templates/mail/text/screen_recording.txt:16 #: core/templates/mail/text/screen_recording.txt:15
msgid "Save the file to your preferred location" msgid "Save the file to your preferred location"
msgstr "Save the file to your preferred location" msgstr "Save the file to your preferred location"
#: core/templates/mail/html/screen_recording.html:213 #: core/templates/mail/html/screen_recording.html:221
#: core/templates/mail/text/screen_recording.txt:18 #: core/templates/mail/text/screen_recording.txt:17
msgid "Open" msgid "Open"
msgstr "Open" msgstr "Open"
#: core/templates/mail/html/screen_recording.html:222 #: core/templates/mail/html/screen_recording.html:230
#: core/templates/mail/text/screen_recording.txt:20 #: core/templates/mail/text/screen_recording.txt:19
#, python-format #, python-format
msgid "" msgid ""
" If you have any questions or need assistance, please contact our support " " If you have any questions or need assistance, please contact our support "
@@ -673,33 +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. "
#: core/templates/mail/text/invitation.txt:24 #: core/templates/mail/text/screen_recording.txt:13
#, python-format
msgid "This mail has been sent to %(email)s by %(name)s [%(href)s]"
msgstr ""
#: core/templates/mail/text/screen_recording.txt:14
#, fuzzy, python-format #, fuzzy, python-format
#| msgid "Click the \"<a href=\"%(link)s\">Open</a>\" link below " #| msgid "Click the \"<a href=\"%(link)s\">Open</a>\" link below "
msgid "Click the \"Open [%(link)s]\" link below " msgid "Click the \"Open [%(link)s]\" link below "
msgstr "Click the \"<a href=\"%(link)s\">Open</a>\" link below " msgstr "Click the \"<a href=\"%(link)s\">Open</a>\" link below "
#: meet/settings.py:238 #: meet/settings.py:228
msgid "English" msgid "English"
msgstr "English" msgstr "English"
#: meet/settings.py:239 #: meet/settings.py:229
msgid "French" msgid "French"
msgstr "French" msgstr "French"
#: meet/settings.py:240 #: meet/settings.py:230
msgid "Dutch" msgid "Dutch"
msgstr "Dutch" msgstr "Dutch"
#: meet/settings.py:241 #: meet/settings.py:231
msgid "German" msgid "German"
msgstr "German" msgstr "German"
#: meet/settings.py:242
msgid "Spanish"
msgstr "Spanish"
@@ -1,707 +0,0 @@
# SOME DESCRIPTIVE TITLE.
# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER
# This file is distributed under the same license as the PACKAGE package.
# FIRST AUTHOR <EMAIL@ADDRESS>, YEAR.
#
#, fuzzy
msgid ""
msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-09-02 22:52+0000\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language-Team: LANGUAGE <LL@li.org>\n"
"Language: \n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
#: core/admin.py:67
msgid "Personal info"
msgstr "Datos Personales"
#: core/admin.py:80
msgid "Permissions"
msgstr "Permisos"
#: core/admin.py:92
msgid "Important dates"
msgstr "Fechas importantes"
#: core/admin.py:206
msgid "Content"
msgstr "Contenido"
#: core/admin.py:217
msgid "Deletion"
msgstr "Eliminar Salas"
#: core/admin.py:226
msgid "Derived info"
msgstr ""
# 'Marcas de tiempo' is a bad translation for spanish
#: core/admin.py:237
msgid "Timestamps"
msgstr ""
#: core/admin.py:240
msgid "File preview"
msgstr "Vista previa"
#: core/admin.py:300 core/admin.py:450
msgid "No owner"
msgstr "Sin propietario"
#: core/admin.py:303 core/admin.py:453
msgid "Multiple owners"
msgstr "Varios propietarios"
#: core/admin.py:316
msgid "Resend notification to external service"
msgstr "Reenviar la notificación al servicio externo"
#: core/admin.py:339
#, python-format
msgid "Failed to notify for recording %(id)s"
msgstr "Error al notificar la grabación %(id)s"
#: core/admin.py:347
#, python-format
msgid "Failed to notify for recording %(id)s: %(error)s"
msgstr "Error al notificar la grabación %(id)s: %(error)s"
#: core/admin.py:355
#, python-format
msgid "Successfully sent notifications for %(count)s recording(s)."
msgstr "Notificaciones enviadas correctamente para %(count)s grabación(es)."
#: core/admin.py:363
#, python-format
msgid "Skipped %(count)s expired recording(s)."
msgstr "Se han omitido %(count)s grabación(es) caducada(s)."
#: core/admin.py:368
msgid "Mark selected recordings as 'Failed to Stop'"
msgstr "Marcar las grabaciones seleccionadas como «Error al detener»"
#: core/admin.py:386
#, python-format
msgid "%(count)s recording(s) successfully marked as 'Failed to Stop'."
msgstr ""
"%(count)s grabación(es) marcada(s) correctamente como «Error al detener»."
#: core/admin.py:394
#, python-format
msgid "Skipped %(count)s recording(s) with an ineligible status."
msgstr "Se han omitido %(count)s grabación(es) con un estado no elegible."
#: core/admin.py:517
msgid "No scopes"
msgstr ""
#: core/admin.py:519
msgid "Scopes"
msgstr "Ámbitos"
#: core/api/filters.py:25
msgid "Creator is me"
msgstr "Yo soy el creador"
#: core/api/serializers.py:108
msgid "You must be administrator or owner of a room to add accesses to it."
msgstr ""
"Debes ser administrador o propietario de una reunión para añadirle accesos."
#: core/api/serializers.py:560
msgid "This file extension is not allowed."
msgstr "Esta extensión de archivo no está permitida."
#: core/api/viewsets.py:1268
msgid "You have reached the maximum number of files for this type."
msgstr "Has alcanzado el número máximo de archivos de este tipo."
#: core/models.py:38
msgid "Member"
msgstr "Miembro"
#: core/models.py:39
msgid "Administrator"
msgstr "Administrador"
#: core/models.py:40
msgid "Owner"
msgstr "Propietario"
# To check here and following lines for gender agreement (Masculine/Feminine)
#: core/models.py:56
msgid "Initiated"
msgstr "Iniciada"
#: core/models.py:57
msgid "Active"
msgstr "Activa"
#: core/models.py:58
msgid "Stopped"
msgstr "Detenida"
#: core/models.py:59
msgid "Saved"
msgstr "Guardada"
#: core/models.py:60
msgid "Aborted"
msgstr "Cancelada"
#: core/models.py:61
msgid "Failed to Start"
msgstr "Error al iniciar"
#: core/models.py:62
msgid "Failed to Stop"
msgstr "Error al detener"
#: core/models.py:63
msgid "Notification succeeded"
msgstr "Notificado correctamente"
#: core/models.py:66
msgid "External process successful"
msgstr "Proceso externo finalizado correctamente"
#: core/models.py:68
msgid "External process failed"
msgstr "Error en el Proceso externo"
#: core/models.py:97
msgid "SCREEN_RECORDING"
msgstr "GRABACIÓN_DE_PANTALLA"
#: core/models.py:98
msgid "TRANSCRIPT"
msgstr "TRANSCRIPCIÓN"
#: core/models.py:104
msgid "Public Access"
msgstr "Acceso público"
#: core/models.py:105
msgid "Trusted Access"
msgstr "Acceso usuarios autorizados"
#: core/models.py:106
msgid "Restricted Access"
msgstr "Acceso restringido"
#: core/models.py:118
msgid "id"
msgstr "id"
#: core/models.py:119
msgid "primary key for the record as UUID"
msgstr "clave primaria del registro en forma de UUID"
#: core/models.py:125
msgid "created on"
msgstr "creado el"
#: core/models.py:126
msgid "date and time at which a record was created"
msgstr "fecha y hora en que se creó un registro"
#: core/models.py:131
msgid "updated on"
msgstr "actualizado el"
#: core/models.py:132
msgid "date and time at which a record was last updated"
msgstr "fecha y hora de la última actualización de un registro"
#: core/models.py:150
msgid "sub"
msgstr "sub"
#: core/models.py:152
msgid ""
"Optional for pending users; required upon account activation. 255 characters "
"or fewer. Printable ASCII characters only."
msgstr ""
"Opcional para los usuarios pendientes; obligatorio al activar la cuenta. "
"255 caracteres como máximo. Solo caracteres ASCII imprimibles."
#: core/validators.py:22
msgid "Enter a valid sub. This value should be printable ASCII only."
msgstr "Introduzca un sub válido. Este valor solo debe contener caracteres ASCII imprimibles."
#: core/models.py:161
msgid "identity email address"
msgstr "dirección de correo electrónico"
#: core/models.py:166
msgid "admin email address"
msgstr "dirección de correo electrónico de administrador"
#: core/models.py:168
msgid "full name"
msgstr "nombre completo"
#: core/models.py:170
msgid "short name"
msgstr "nombre corto"
#: core/models.py:176
msgid "language"
msgstr "idioma"
#: core/models.py:177
msgid "The language in which the user wants to see the interface."
msgstr "El idioma preferido de interfaz."
#: core/models.py:183
msgid "The timezone in which the user wants to see times."
msgstr "La zona horaria en la que el usuario quiere ver las horas."
#: core/models.py:190
msgid "default room access level"
msgstr ""
#: core/models.py:192
msgid ""
"Access level applied by default to new rooms created by this user. When "
"empty, the instance default is used."
msgstr ""
#: core/models.py:199
#, fuzzy
#| msgid "Visio room configuration"
msgid "default room configuration"
msgstr "Configuración de la videoconferencia"
#: core/models.py:201
msgid "Configurations applied by default to new rooms created by this user."
msgstr ""
#: core/models.py:205
msgid "device"
msgstr "dispositivo"
#: core/models.py:207
msgid "Whether the user is a device or a real user."
msgstr "Si el usuario es un dispositivo o un usuario real."
#: core/models.py:210
msgid "staff status"
msgstr "estado del personal"
#: core/models.py:212
msgid "Whether the user can log into this admin site."
msgstr "Si el usuario puede acceder a este sitio de administración."
#: core/models.py:215
msgid "active"
msgstr "activo"
#: core/models.py:218
msgid ""
"Whether this user should be treated as active. Unselect this instead of "
"deleting accounts."
msgstr ""
"Si este usuario debe considerarse activo. Desmarca esta opción en lugar de "
"eliminar cuentas."
#: core/models.py:231
msgid "user"
msgstr "usuario"
#: core/models.py:232
msgid "users"
msgstr "usuarios"
#: core/models.py:298
msgid "Resource"
msgstr "Recurso"
#: core/models.py:299
msgid "Resources"
msgstr "Recursos"
#: core/models.py:357
msgid "Resource access"
msgstr "Acceso a recursos"
#: core/models.py:358
msgid "Resource accesses"
msgstr "Accesos a recursos"
#: core/models.py:364
msgid "Resource access with this User and Resource already exists."
msgstr "Ya existe un acceso al recurso con este usuario y este recurso."
#: core/models.py:421
msgid "Visio room configuration"
msgstr "Configuración de la videoconferencia"
#: core/models.py:422
msgid "Values for Visio parameters to configure the room."
msgstr ""
"Valores de los parámetros de videoconferencia para configurar la reunión."
#: core/models.py:429
msgid "Room PIN code"
msgstr "Código PIN de la reunión"
#: core/models.py:430
msgid "Unique n-digit code that identifies this room in telephony mode."
msgstr ""
"Código único de n dígitos que identifica esta reunión en modo telefónico."
#: core/models.py:436 core/models.py:597
msgid "Room"
msgstr "Reunión"
#: core/models.py:437
msgid "Rooms"
msgstr "Reuniones"
#: core/models.py:608
msgid "Worker ID"
msgstr "ID del worker"
#: core/models.py:610
msgid ""
"Enter an identifier for the worker recording.This ID is retained even when "
"the worker stops, allowing for easy tracking."
msgstr ""
"Introduce un identificador para la grabación del worker. Este identificador "
"se conserva incluso cuando el worker se detiene, lo que permite un "
"seguimiento sencillo."
#: core/models.py:618
msgid "Recording mode"
msgstr "Modo de grabación"
#: core/models.py:619
msgid "Defines the mode of recording being called."
msgstr "Define el modo de grabación a utilizar."
#: core/models.py:624 core/models.py:625
msgid "Recording options"
msgstr "Opciones de grabación"
#: core/models.py:632
msgid "External Process ID"
msgstr "ID del proceso externo"
#: core/models.py:633
msgid "ID of the external process associated with the recording."
msgstr "ID del proceso externo asociado a la grabación."
#: core/models.py:639
msgid "Recording"
msgstr "Grabación"
#: core/models.py:640
msgid "Recordings"
msgstr "Grabaciones"
#: core/models.py:750
msgid "Recording/user relation"
msgstr "Relación grabación/usuario"
#: core/models.py:751
msgid "Recording/user relations"
msgstr "Relaciones grabación/usuario"
#: core/models.py:757
msgid "This user is already in this recording."
msgstr "Este usuario ya está en esta grabación."
#: core/models.py:763
msgid "This team is already in this recording."
msgstr "Este equipo ya está en esta grabación."
#: core/models.py:769
msgid "Either user or team must be set, not both."
msgstr "Debe definirse el usuario o el equipo, pero no ambos."
#: core/models.py:786
msgid "Create rooms"
msgstr "Crear reunión"
#: core/models.py:787
msgid "List rooms"
msgstr "Listar reuniones"
#: core/models.py:788
msgid "Retrieve room details"
msgstr "Ver los detalles de una reunión"
#: core/models.py:789
msgid "Update rooms"
msgstr "Actualizar las reuniones"
#: core/models.py:790
msgid "Delete rooms"
msgstr "Eliminar las reuniones"
#: core/models.py:803
msgid "Application name"
msgstr "Nombre de la aplicación"
#: core/models.py:804
msgid "Descriptive name for this application."
msgstr "Nombre descriptivo de esta aplicación."
#: core/models.py:814
msgid "Hashed on Save. Copy it now if this is a new secret."
msgstr "Se cifra al guardar. Cópialo ahora si se trata de un secreto nuevo."
#: core/models.py:825
msgid "Application"
msgstr "Aplicación"
#: core/models.py:826
msgid "Applications"
msgstr "Aplicaciones"
#: core/models.py:849
msgid "Enter a valid domain"
msgstr "Introduce un dominio válido"
#: core/models.py:852
msgid "Domain"
msgstr "Dominio"
#: core/models.py:853
msgid "Email domain this application can act on behalf of."
msgstr ""
"Dominio de correo electrónico en cuyo nombre puede actuar esta aplicación."
#: core/models.py:865
msgid "Application domain"
msgstr "Dominio de aplicación"
#: core/models.py:866
msgid "Application domains"
msgstr "Dominios de aplicación"
#: core/models.py:884
msgid "Pending"
msgstr "Pendiente"
#: core/models.py:885
msgid "Analyzing"
msgstr "Analizando"
#: core/models.py:892
msgid "Ready"
msgstr "Listo"
#: core/models.py:898
msgid "Background image"
msgstr "Imagen de fondo"
#: core/models.py:910
msgid "title"
msgstr "título"
#: core/models.py:934
msgid "Malware detection info when the analysis status is unsafe."
msgstr ""
"Información sobre la detección de malware cuando el estado del análisis no "
"es seguro."
#: core/models.py:939
msgid "File"
msgstr "Archivo"
#: core/models.py:940
msgid "Files"
msgstr "Archivos"
#: core/models.py:1060
msgid "This file is already hard deleted."
msgstr "Este archivo ya se ha eliminado definitivamente."
#: core/models.py:1070
msgid "To hard delete a file, it must first be soft deleted."
msgstr ""
"Para eliminar definitivamente un archivo, primero debe haberse marcado como "
"eliminado."
#: core/recording/event/notification.py:124
msgid "Your recording is ready"
msgstr "Tu grabación está lista"
#: core/recording/event/notification.py:195
msgid "Transcription"
msgstr "Transcripción"
#: core/recording/event/notification.py:206
#, python-brace-format
msgid "Meeting \"{room}\" on {room_recording_date} at {room_recording_time}"
msgstr ""
"Reunión \"{room}\" del {room_recording_date} a las {room_recording_time}"
#: core/services/invitation.py:44
#, python-brace-format
msgid "Video call in progress: {sender.email} is waiting for you to connect"
msgstr "Videollamada en curso: {sender.email} está esperando a que te conectes"
#: core/templates/mail/html/invitation.html:151
#: core/templates/mail/html/screen_recording.html:151
#: core/templates/mail/text/invitation.txt:4
#: core/templates/mail/text/screen_recording.txt:4
msgid "Logo email"
msgstr "Logotipo del correo"
#: core/templates/mail/html/invitation.html:181
#: core/templates/mail/text/invitation.txt:6
msgid "invites you to join an ongoing video call"
msgstr "te invita a unirte a una videollamada en curso"
#: core/templates/mail/html/invitation.html:192
#: core/templates/mail/text/invitation.txt:8
msgid "JOIN THE CALL"
msgstr "UNIRSE A LA LLAMADA"
#: core/templates/mail/html/invitation.html:219
#: core/templates/mail/text/invitation.txt:14
msgid ""
"If you can't click the button, copy and paste the URL into your browser to "
"join the call."
msgstr ""
"Si no puedes hacer clic en el botón, copia y pega la URL en tu navegador "
"para unirte a la llamada."
#: core/templates/mail/html/invitation.html:227
#: core/templates/mail/text/invitation.txt:16
msgid "Tips for a better experience:"
msgstr "Consejos para una mejor experiencia:"
#: core/templates/mail/html/invitation.html:229
#: core/templates/mail/text/invitation.txt:18
msgid "Use Chrome or Firefox for better call quality"
msgstr "Usa Chrome o Firefox para una mejor calidad de llamada"
#: core/templates/mail/html/invitation.html:230
#: core/templates/mail/text/invitation.txt:19
msgid "Test your microphone and camera before joining"
msgstr "Prueba tu micrófono y tu cámara antes de unirte"
#: core/templates/mail/html/invitation.html:231
#: core/templates/mail/text/invitation.txt:20
msgid "Make sure you have a stable internet connection"
msgstr "Asegúrate de tener una conexión a internet estable"
#: core/templates/mail/html/invitation.html:240
#: core/templates/mail/html/screen_recording.html:237
#: core/templates/mail/text/invitation.txt:22
#: core/templates/mail/text/screen_recording.txt:24
#, python-format
msgid " Thank you for using %(brandname)s. "
msgstr " Gracias por usar %(brandname)s. "
#: core/templates/mail/html/invitation.html:271
#, python-format
msgid ""
"This mail has been sent to %(email)s by <a href=\"%(href)s\">%(name)s</a>"
msgstr ""
#: core/templates/mail/html/screen_recording.html:180
#: core/templates/mail/text/screen_recording.txt:7
msgid "Your recording is ready!"
msgstr "¡Tu grabación está lista!"
#: core/templates/mail/html/screen_recording.html:187
#: core/templates/mail/text/screen_recording.txt:9
#, python-format
msgid ""
" Your recording of \"%(room_name)s\" on %(recording_date)s at "
"%(recording_time)s is now ready to download. "
msgstr ""
" Tu grabación de \"%(room_name)s\" del %(recording_date)s a las "
"%(recording_time)s ya está lista para descargar. "
#: core/templates/mail/html/screen_recording.html:187
#: core/templates/mail/text/screen_recording.txt:9
#, python-format
msgid " The recording will expire in %(days)s days. "
msgstr " La grabación caducará dentro de %(days)s días. "
#: core/templates/mail/html/screen_recording.html:192
#: core/templates/mail/text/screen_recording.txt:10
msgid ""
" Sharing the recording via link is not yet available. Only organizers can "
"download it. "
msgstr ""
" Compartir la grabación mediante un enlace todavía no está disponible. Solo "
"los organizadores pueden descargarla. "
#: core/templates/mail/html/screen_recording.html:198
#: core/templates/mail/text/screen_recording.txt:12
msgid "To keep this recording permanently:"
msgstr "Para conservar esta grabación de forma permanente:"
#: core/templates/mail/html/screen_recording.html:200
#, python-format
msgid "Click the \"<a href=\"%(link)s\">Open</a>\" link below "
msgstr ""
"Haz clic en el enlace \"<a href=\"%(link)s\">Abrir</a>\" que aparece abajo "
#: core/templates/mail/html/screen_recording.html:201
#: core/templates/mail/text/screen_recording.txt:15
msgid "Use the \"Download\" button in the interface "
msgstr "Usa el botón \"Descargar\" de la interfaz "
#: core/templates/mail/html/screen_recording.html:202
#: core/templates/mail/text/screen_recording.txt:16
msgid "Save the file to your preferred location"
msgstr "Guarda el archivo en la ubicación que prefieras"
#: core/templates/mail/html/screen_recording.html:213
#: core/templates/mail/text/screen_recording.txt:18
msgid "Open"
msgstr "Abrir"
#: core/templates/mail/html/screen_recording.html:222
#: core/templates/mail/text/screen_recording.txt:20
#, python-format
msgid ""
" If you have any questions or need assistance, please contact our support "
"team at %(support_email)s. "
msgstr ""
" Si tienes alguna pregunta o necesitas ayuda, ponte en contacto con nuestro "
"equipo de soporte en %(support_email)s. "
#: core/templates/mail/text/invitation.txt:24
#, python-format
msgid "This mail has been sent to %(email)s by %(name)s [%(href)s]"
msgstr ""
#: core/templates/mail/text/screen_recording.txt:14
#, python-format
msgid "Click the \"Open [%(link)s]\" link below "
msgstr "Haz clic en el enlace \"Abrir [%(link)s]\" que aparece abajo "
#: meet/settings.py:238
msgid "English"
msgstr "Inglés"
#: meet/settings.py:239
msgid "French"
msgstr "Francés"
#: meet/settings.py:240
msgid "Dutch"
msgstr "Neerlandés"
#: meet/settings.py:241
msgid "German"
msgstr "Alemán"
#: meet/settings.py:242
msgid "Spanish"
msgstr "Español"
+159 -190
View File
@@ -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-09-02 22:52+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"
@@ -51,11 +51,11 @@ msgstr ""
msgid "File preview" msgid "File preview"
msgstr "" msgstr ""
#: core/admin.py:300 core/admin.py:450 #: 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:303 core/admin.py:453 #: core/admin.py:303 core/admin.py:446
msgid "Multiple owners" msgid "Multiple owners"
msgstr "Plusieurs propriétaires" msgstr "Plusieurs propriétaires"
@@ -99,11 +99,11 @@ msgstr ""
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:517 #: core/admin.py:510
msgid "No scopes" msgid "No scopes"
msgstr "Aucun scopes" msgstr "Aucun scopes"
#: core/admin.py:519 #: core/admin.py:512
msgid "Scopes" msgid "Scopes"
msgstr "Scopes" msgstr "Scopes"
@@ -111,203 +111,187 @@ 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:108 #: 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:560 #: 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:1268 #: 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"
#: core/models.py:38 #: core/models.py:37
msgid "Member" msgid "Member"
msgstr "Membre" msgstr "Membre"
#: core/models.py:39 #: core/models.py:38
msgid "Administrator" msgid "Administrator"
msgstr "Administrateur" msgstr "Administrateur"
#: core/models.py:40 #: core/models.py:39
msgid "Owner" msgid "Owner"
msgstr "Propriétaire" msgstr "Propriétaire"
#: core/models.py:56 #: core/models.py:55
msgid "Initiated" msgid "Initiated"
msgstr "Initié" msgstr "Initié"
#: core/models.py:57 #: core/models.py:56
msgid "Active" msgid "Active"
msgstr "Actif" msgstr "Actif"
#: core/models.py:58 #: core/models.py:57
msgid "Stopped" msgid "Stopped"
msgstr "Arrêté" msgstr "Arrêté"
#: core/models.py:59 #: core/models.py:58
msgid "Saved" msgid "Saved"
msgstr "Enregistré" msgstr "Enregistré"
#: core/models.py:60 #: core/models.py:59
msgid "Aborted" msgid "Aborted"
msgstr "Abandonné" msgstr "Abandonné"
#: core/models.py:61 #: core/models.py:60
msgid "Failed to Start" msgid "Failed to Start"
msgstr "Échec au démarrage" msgstr "Échec au démarrage"
#: core/models.py:62 #: core/models.py:61
msgid "Failed to Stop" msgid "Failed to Stop"
msgstr "Échec à l'arrêt" msgstr "Échec à l'arrêt"
#: core/models.py:63 #: core/models.py:62
msgid "Notification succeeded" msgid "Notification succeeded"
msgstr "Notification réussie" msgstr "Notification réussie"
#: core/models.py:66 #: core/models.py:65
msgid "External process successful" msgid "External process successful"
msgstr "Traitement externe : succès" msgstr "Traitement externe : succès"
#: core/models.py:68 #: core/models.py:67
msgid "External process failed" msgid "External process failed"
msgstr "Traitement externe : erreur" msgstr "Traitement externe : erreur"
#: core/models.py:97 #: core/models.py:96
msgid "SCREEN_RECORDING" msgid "SCREEN_RECORDING"
msgstr "ENREGISTREMENT_ÉCRAN" msgstr "ENREGISTREMENT_ÉCRAN"
#: core/models.py:98 #: core/models.py:97
msgid "TRANSCRIPT" msgid "TRANSCRIPT"
msgstr "TRANSCRIPTION" msgstr "TRANSCRIPTION"
#: core/models.py:104 #: core/models.py:103
msgid "Public Access" msgid "Public Access"
msgstr "Accès public" msgstr "Accès public"
#: core/models.py:105 #: core/models.py:104
msgid "Trusted Access" msgid "Trusted Access"
msgstr "Accès de confiance" msgstr "Accès de confiance"
#: core/models.py:106 #: core/models.py:105
msgid "Restricted Access" msgid "Restricted Access"
msgstr "Accès restreint" msgstr "Accès restreint"
#: core/models.py:118 #: core/models.py:117
msgid "id" msgid "id"
msgstr "id" msgstr "id"
#: core/models.py:119 #: 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:125 #: core/models.py:124
msgid "created on" msgid "created on"
msgstr "créé le" msgstr "créé le"
#: core/models.py:126 #: 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:131 #: core/models.py:130
msgid "updated on" msgid "updated on"
msgstr "mis à jour le" msgstr "mis à jour le"
#: core/models.py:132 #: 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:150 #: core/models.py:151
msgid ""
"Enter a valid sub. This value may contain only letters, numbers, and @/./+/-/"
"_ characters."
msgstr ""
"Entrez un sub valide. Cette valeur ne peut contenir que des lettres, des "
"chiffres et les caractères @/./+/-/_."
#: 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. Printable ASCII characters only." "or fewer. Letters, numbers, and @/./+/-/_ characters only."
msgstr "" msgstr ""
"Facultatif pour les utilisateurs en attente ; obligatoire lors de lactivation " "Optionnel pour les utilisateurs en attente ; requis lors de l'activation du "
"du compte. 255 caractères maximum. Caractères ASCII imprimables uniquement." "compte. 255 caractères maximum. Lettres, chiffres et @/./+/-/_ uniquement."
#: core/validators.py:22 #: core/models.py:168
msgid "Enter a valid sub. This value should be printable ASCII only."
msgstr "Saisissez un sub valide. Cette valeur ne doit contenir que des caractères ASCII imprimables."
#: core/models.py:161
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:190 #: core/models.py:193
msgid "default room access level"
msgstr ""
#: core/models.py:192
msgid ""
"Access level applied by default to new rooms created by this user. When "
"empty, the instance default is used."
msgstr ""
#: core/models.py:199
#, fuzzy
#| msgid "Visio room configuration"
msgid "default room configuration"
msgstr "Configuration de la salle de visioconférence"
#: core/models.py:201
msgid "Configurations applied by default to new rooms created by this user."
msgstr ""
#: core/models.py:205
msgid "device" msgid "device"
msgstr "appareil" msgstr "appareil"
#: core/models.py:207 #: 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:210 #: core/models.py:198
msgid "staff status" msgid "staff status"
msgstr "statut du personnel" msgstr "statut du personnel"
#: core/models.py:212 #: 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:215 #: core/models.py:203
msgid "active" msgid "active"
msgstr "actif" msgstr "actif"
#: core/models.py:218 #: 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."
@@ -315,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:231 #: core/models.py:219
msgid "user" msgid "user"
msgstr "utilisateur" msgstr "utilisateur"
#: core/models.py:232 #: core/models.py:220
msgid "users" msgid "users"
msgstr "utilisateurs" msgstr "utilisateurs"
#: core/models.py:298 #: core/models.py:286
msgid "Resource" msgid "Resource"
msgstr "Ressource" msgstr "Ressource"
#: core/models.py:299 #: core/models.py:287
msgid "Resources" msgid "Resources"
msgstr "Ressources" msgstr "Ressources"
#: core/models.py:357 #: core/models.py:345
msgid "Resource access" msgid "Resource access"
msgstr "Accès aux ressources" msgstr "Accès aux ressources"
#: core/models.py:358 #: core/models.py:346
msgid "Resource accesses" msgid "Resource accesses"
msgstr "Accès aux ressources" msgstr "Accès aux ressources"
#: core/models.py:364 #: 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:421 #: 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:422 #: 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:429 #: 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:430 #: 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:436 core/models.py:597 #: core/models.py:424 core/models.py:578
msgid "Room" msgid "Room"
msgstr "Salle" msgstr "Salle"
#: core/models.py:437 #: core/models.py:425
msgid "Rooms" msgid "Rooms"
msgstr "Salles" msgstr "Salles"
#: core/models.py:608 #: core/models.py:589
msgid "Worker ID" msgid "Worker ID"
msgstr "ID du Worker" msgstr "ID du Worker"
#: core/models.py:610 #: 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."
@@ -381,170 +365,170 @@ 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:618 #: core/models.py:599
msgid "Recording mode" msgid "Recording mode"
msgstr "Mode d'enregistrement" msgstr "Mode d'enregistrement"
#: core/models.py:619 #: 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:624 core/models.py:625 #: 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:632 #: core/models.py:613
msgid "External Process ID" msgid "External Process ID"
msgstr "ID Traitement externe" msgstr "ID Traitement externe"
#: core/models.py:633 #: core/models.py:614
msgid "ID of the external process associated with the recording." msgid "ID of the external process associated with the recording."
msgstr "ID du traitement externe associé avec l'enregistrement." msgstr "ID du traitement externe associé avec l'enregistrement."
#: core/models.py:639 #: core/models.py:620
msgid "Recording" msgid "Recording"
msgstr "Enregistrement" msgstr "Enregistrement"
#: core/models.py:640 #: core/models.py:621
msgid "Recordings" msgid "Recordings"
msgstr "Enregistrements" msgstr "Enregistrements"
#: core/models.py:750 #: core/models.py:731
msgid "Recording/user relation" msgid "Recording/user relation"
msgstr "Relation enregistrement/utilisateur" msgstr "Relation enregistrement/utilisateur"
#: core/models.py:751 #: core/models.py:732
msgid "Recording/user relations" msgid "Recording/user relations"
msgstr "Relations enregistrement/utilisateur" msgstr "Relations enregistrement/utilisateur"
#: core/models.py:757 #: 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:763 #: 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:769 #: 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:786 #: core/models.py:767
msgid "Create rooms" msgid "Create rooms"
msgstr "Créer des salles" msgstr "Créer des salles"
#: core/models.py:787 #: core/models.py:768
msgid "List rooms" msgid "List rooms"
msgstr "Lister les salles" msgstr "Lister les salles"
#: core/models.py:788 #: core/models.py:769
msgid "Retrieve room details" msgid "Retrieve room details"
msgstr "Afficher les détails dune salle" msgstr "Afficher les détails dune salle"
#: core/models.py:789 #: core/models.py:770
msgid "Update rooms" msgid "Update rooms"
msgstr "Mettre à jour les salles" msgstr "Mettre à jour les salles"
#: core/models.py:790 #: core/models.py:771
msgid "Delete rooms" msgid "Delete rooms"
msgstr "Supprimer les salles" msgstr "Supprimer les salles"
#: core/models.py:803 #: core/models.py:784
msgid "Application name" msgid "Application name"
msgstr "Nom de lapplication" msgstr "Nom de lapplication"
#: core/models.py:804 #: 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:814 #: 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 lenregistrement. Copiez-le maintenant sil sagit dun " "Haché lors de lenregistrement. Copiez-le maintenant sil sagit dun "
"nouveau secret." "nouveau secret."
#: core/models.py:825 #: core/models.py:806
msgid "Application" msgid "Application"
msgstr "Application" msgstr "Application"
#: core/models.py:826 #: core/models.py:807
msgid "Applications" msgid "Applications"
msgstr "Applications" msgstr "Applications"
#: core/models.py:849 #: 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:852 #: core/models.py:833
msgid "Domain" msgid "Domain"
msgstr "Domaine" msgstr "Domaine"
#: core/models.py:853 #: 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:865 #: core/models.py:846
msgid "Application domain" msgid "Application domain"
msgstr "Domaine dapplication" msgstr "Domaine dapplication"
#: core/models.py:866 #: core/models.py:847
msgid "Application domains" msgid "Application domains"
msgstr "Domaines dapplication" msgstr "Domaines dapplication"
#: core/models.py:884 #: core/models.py:865
msgid "Pending" msgid "Pending"
msgstr "En attente" msgstr "En attente"
#: core/models.py:885 #: core/models.py:866
msgid "Analyzing" msgid "Analyzing"
msgstr "" msgstr ""
#: core/models.py:892 #: core/models.py:873
msgid "Ready" msgid "Ready"
msgstr "Prêt" msgstr "Prêt"
#: core/models.py:898 #: core/models.py:879
msgid "Background image" msgid "Background image"
msgstr "Image de fond" msgstr "Image de fond"
#: core/models.py:910 #: core/models.py:891
msgid "title" msgid "title"
msgstr "Titre" msgstr "Titre"
#: core/models.py:934 #: 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:939 #: core/models.py:920
msgid "File" msgid "File"
msgstr "Fichier" msgstr "Fichier"
#: core/models.py:940 #: core/models.py:921
msgid "Files" msgid "Files"
msgstr "Fichiers" msgstr "Fichiers"
#: core/models.py:1060 #: 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:1070 #: 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:124 #: 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:195 #: core/recording/event/notification.py:194
msgid "Transcription" msgid "Transcription"
msgstr "Transcription" msgstr "Transcription"
#: core/recording/event/notification.py:206 #: core/recording/event/notification.py:204
#, python-brace-format #, python-brace-format
msgid "Meeting \"{room}\" on {room_recording_date} at {room_recording_time}" msgid "Meeting \"{room}\" on {room_recording_date} at {room_recording_time}"
msgstr "Réunion \"{room}\" du {room_recording_date} à {room_recording_time}" msgstr "Réunion \"{room}\" du {room_recording_date} à {room_recording_time}"
@@ -554,25 +538,25 @@ msgstr "Réunion \"{room}\" du {room_recording_date} à {room_recording_time}"
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"
msgstr "Appel vidéo en cours : {sender.email} attend que vous vous connectiez" msgstr "Appel vidéo en cours : {sender.email} attend que vous vous connectiez"
#: core/templates/mail/html/invitation.html:151 #: core/templates/mail/html/invitation.html:159
#: core/templates/mail/html/screen_recording.html:151 #: core/templates/mail/html/screen_recording.html:159
#: core/templates/mail/text/invitation.txt:4 #: core/templates/mail/text/invitation.txt:3
#: core/templates/mail/text/screen_recording.txt:4 #: core/templates/mail/text/screen_recording.txt:3
msgid "Logo email" msgid "Logo email"
msgstr "Logo email" msgstr "Logo email"
#: core/templates/mail/html/invitation.html:181 #: core/templates/mail/html/invitation.html:189
#: core/templates/mail/text/invitation.txt:6 #: core/templates/mail/text/invitation.txt:5
msgid "invites you to join an ongoing video call" msgid "invites you to join an ongoing video call"
msgstr "vous invite à rejoindre un appel vidéo en cours" msgstr "vous invite à rejoindre un appel vidéo en cours"
#: core/templates/mail/html/invitation.html:192 #: core/templates/mail/html/invitation.html:200
#: core/templates/mail/text/invitation.txt:8 #: core/templates/mail/text/invitation.txt:7
msgid "JOIN THE CALL" msgid "JOIN THE CALL"
msgstr "REJOINDRE L'APPEL" msgstr "REJOINDRE L'APPEL"
#: core/templates/mail/html/invitation.html:219 #: core/templates/mail/html/invitation.html:227
#: core/templates/mail/text/invitation.txt:14 #: core/templates/mail/text/invitation.txt:13
msgid "" msgid ""
"If you can't click the button, copy and paste the URL into your browser to " "If you can't click the button, copy and paste the URL into your browser to "
"join the call." "join the call."
@@ -580,47 +564,41 @@ msgstr ""
"Si vous ne pouvez pas cliquer sur le bouton, copiez et collez l'URL dans " "Si vous ne pouvez pas cliquer sur le bouton, copiez et collez l'URL dans "
"votre navigateur pour rejoindre l'appel." "votre navigateur pour rejoindre l'appel."
#: core/templates/mail/html/invitation.html:227 #: core/templates/mail/html/invitation.html:235
#: core/templates/mail/text/invitation.txt:16 #: core/templates/mail/text/invitation.txt:15
msgid "Tips for a better experience:" msgid "Tips for a better experience:"
msgstr "Conseils pour une meilleure expérience :" msgstr "Conseils pour une meilleure expérience :"
#: core/templates/mail/html/invitation.html:229 #: core/templates/mail/html/invitation.html:237
#: core/templates/mail/text/invitation.txt:18 #: core/templates/mail/text/invitation.txt:17
msgid "Use Chrome or Firefox for better call quality" msgid "Use Chrome or Firefox for better call quality"
msgstr "Utilisez Chrome ou Firefox pour une meilleure qualité d'appel" msgstr "Utilisez Chrome ou Firefox pour une meilleure qualité d'appel"
#: core/templates/mail/html/invitation.html:230 #: core/templates/mail/html/invitation.html:238
#: core/templates/mail/text/invitation.txt:19 #: core/templates/mail/text/invitation.txt:18
msgid "Test your microphone and camera before joining" msgid "Test your microphone and camera before joining"
msgstr "Testez votre microphone et votre caméra avant de rejoindre" msgstr "Testez votre microphone et votre caméra avant de rejoindre"
#: core/templates/mail/html/invitation.html:231 #: core/templates/mail/html/invitation.html:239
#: core/templates/mail/text/invitation.txt:20 #: core/templates/mail/text/invitation.txt:19
msgid "Make sure you have a stable internet connection" msgid "Make sure you have a stable internet connection"
msgstr "Assurez-vous d'avoir une connexion Internet stable" msgstr "Assurez-vous d'avoir une connexion Internet stable"
#: core/templates/mail/html/invitation.html:240 #: core/templates/mail/html/invitation.html:248
#: core/templates/mail/html/screen_recording.html:237 #: core/templates/mail/html/screen_recording.html:245
#: core/templates/mail/text/invitation.txt:22 #: core/templates/mail/text/invitation.txt:21
#: core/templates/mail/text/screen_recording.txt:24 #: core/templates/mail/text/screen_recording.txt:23
#, python-format #, python-format
msgid " Thank you for using %(brandname)s. " msgid " Thank you for using %(brandname)s. "
msgstr " Merci d'utiliser %(brandname)s. " msgstr " Merci d'utiliser %(brandname)s. "
#: core/templates/mail/html/invitation.html:271 #: core/templates/mail/html/screen_recording.html:188
#, python-format #: core/templates/mail/text/screen_recording.txt:6
msgid ""
"This mail has been sent to %(email)s by <a href=\"%(href)s\">%(name)s</a>"
msgstr ""
#: core/templates/mail/html/screen_recording.html:180
#: core/templates/mail/text/screen_recording.txt:7
msgid "Your recording is ready!" msgid "Your recording is ready!"
msgstr "Votre enregistrement est prêt !" msgstr "Votre enregistrement est prêt !"
#: core/templates/mail/html/screen_recording.html:187 #: core/templates/mail/html/screen_recording.html:195
#: core/templates/mail/text/screen_recording.txt:9 #: core/templates/mail/text/screen_recording.txt:8
#, python-format #, python-format
msgid "" msgid ""
" Your recording of \"%(room_name)s\" on %(recording_date)s at " " Your recording of \"%(room_name)s\" on %(recording_date)s at "
@@ -629,14 +607,14 @@ msgstr ""
" Votre enregistrement de \"%(room_name)s\" du %(recording_date)s à " " Votre enregistrement de \"%(room_name)s\" du %(recording_date)s à "
"%(recording_time)s est maintenant prêt à être téléchargé. " "%(recording_time)s est maintenant prêt à être téléchargé. "
#: core/templates/mail/html/screen_recording.html:187 #: core/templates/mail/html/screen_recording.html:195
#: core/templates/mail/text/screen_recording.txt:9 #: core/templates/mail/text/screen_recording.txt:8
#, python-format #, python-format
msgid " The recording will expire in %(days)s days. " msgid " The recording will expire in %(days)s days. "
msgstr " L'enregistrement expirera dans %(days)s jours. " msgstr " L'enregistrement expirera dans %(days)s jours. "
#: core/templates/mail/html/screen_recording.html:192 #: core/templates/mail/html/screen_recording.html:200
#: core/templates/mail/text/screen_recording.txt:10 #: core/templates/mail/text/screen_recording.txt:9
msgid "" msgid ""
" Sharing the recording via link is not yet available. Only organizers can " " Sharing the recording via link is not yet available. Only organizers can "
"download it. " "download it. "
@@ -644,33 +622,33 @@ msgstr ""
"Le partage de l'enregistrement via lien n'est pas encore disponible. Seuls " "Le partage de l'enregistrement via lien n'est pas encore disponible. Seuls "
"les organisateurs peuvent le télécharger." "les organisateurs peuvent le télécharger."
#: core/templates/mail/html/screen_recording.html:198 #: core/templates/mail/html/screen_recording.html:206
#: core/templates/mail/text/screen_recording.txt:12 #: core/templates/mail/text/screen_recording.txt:11
msgid "To keep this recording permanently:" 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:200 #: core/templates/mail/html/screen_recording.html:208
#, python-format #, 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 "
#: core/templates/mail/html/screen_recording.html:201 #: core/templates/mail/html/screen_recording.html:209
#: core/templates/mail/text/screen_recording.txt:15 #: core/templates/mail/text/screen_recording.txt:14
msgid "Use the \"Download\" button in the interface " msgid "Use the \"Download\" button in the interface "
msgstr "Utilisez le bouton \"Télécharger\" dans l'interface " msgstr "Utilisez le bouton \"Télécharger\" dans l'interface "
#: core/templates/mail/html/screen_recording.html:202 #: core/templates/mail/html/screen_recording.html:210
#: core/templates/mail/text/screen_recording.txt:16 #: core/templates/mail/text/screen_recording.txt:15
msgid "Save the file to your preferred location" msgid "Save the file to your preferred location"
msgstr "Enregistrez le fichier à l'emplacement de votre choix" msgstr "Enregistrez le fichier à l'emplacement de votre choix"
#: core/templates/mail/html/screen_recording.html:213 #: core/templates/mail/html/screen_recording.html:221
#: core/templates/mail/text/screen_recording.txt:18 #: core/templates/mail/text/screen_recording.txt:17
msgid "Open" msgid "Open"
msgstr "Ouvrir" msgstr "Ouvrir"
#: core/templates/mail/html/screen_recording.html:222 #: core/templates/mail/html/screen_recording.html:230
#: core/templates/mail/text/screen_recording.txt:20 #: core/templates/mail/text/screen_recording.txt:19
#, python-format #, python-format
msgid "" msgid ""
" If you have any questions or need assistance, please contact our support " " If you have any questions or need assistance, please contact our support "
@@ -679,33 +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. "
#: core/templates/mail/text/invitation.txt:24 #: core/templates/mail/text/screen_recording.txt:13
#, python-format
msgid "This mail has been sent to %(email)s by %(name)s [%(href)s]"
msgstr ""
#: core/templates/mail/text/screen_recording.txt:14
#, fuzzy, python-format #, fuzzy, python-format
#| msgid "Click the \"<a href=\"%(link)s\">Open</a>\" link below " #| msgid "Click the \"<a href=\"%(link)s\">Open</a>\" link below "
msgid "Click the \"Open [%(link)s]\" link below " msgid "Click the \"Open [%(link)s]\" 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 "
#: meet/settings.py:238 #: meet/settings.py:228
msgid "English" msgid "English"
msgstr "Anglais" msgstr "Anglais"
#: meet/settings.py:239 #: meet/settings.py:229
msgid "French" msgid "French"
msgstr "Français" msgstr "Français"
#: meet/settings.py:240 #: meet/settings.py:230
msgid "Dutch" msgid "Dutch"
msgstr "Néerlandais" msgstr "Néerlandais"
#: meet/settings.py:241 #: meet/settings.py:231
msgid "German" msgid "German"
msgstr "Allemand" msgstr "Allemand"
#: meet/settings.py:242
msgid "Spanish"
msgstr "Espagnol"
+159 -190
View File
@@ -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-09-02 22:52+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"
@@ -51,11 +51,11 @@ msgstr ""
msgid "File preview" msgid "File preview"
msgstr "" msgstr ""
#: core/admin.py:300 core/admin.py:450 #: core/admin.py:300 core/admin.py:443
msgid "No owner" msgid "No owner"
msgstr "Geen eigenaar" msgstr "Geen eigenaar"
#: core/admin.py:303 core/admin.py:453 #: core/admin.py:303 core/admin.py:446
msgid "Multiple owners" msgid "Multiple owners"
msgstr "Meerdere eigenaren" msgstr "Meerdere eigenaren"
@@ -98,11 +98,11 @@ msgstr "%(count)s opname(s) succesvol gemarkeerd als 'Mislukt bij stoppen'."
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:517 #: core/admin.py:510
msgid "No scopes" msgid "No scopes"
msgstr "Geen scopes" msgstr "Geen scopes"
#: core/admin.py:519 #: core/admin.py:512
msgid "Scopes" msgid "Scopes"
msgstr "Scopes" msgstr "Scopes"
@@ -110,200 +110,184 @@ msgstr "Scopes"
msgid "Creator is me" msgid "Creator is me"
msgstr "Maker ben ik" msgstr "Maker ben ik"
#: core/api/serializers.py:108 #: 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:560 #: 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:1268 #: 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."
#: core/models.py:38 #: core/models.py:37
msgid "Member" msgid "Member"
msgstr "Lid" msgstr "Lid"
#: core/models.py:39 #: core/models.py:38
msgid "Administrator" msgid "Administrator"
msgstr "Beheerder" msgstr "Beheerder"
#: core/models.py:40 #: core/models.py:39
msgid "Owner" msgid "Owner"
msgstr "Eigenaar" msgstr "Eigenaar"
#: core/models.py:56 #: core/models.py:55
msgid "Initiated" msgid "Initiated"
msgstr "Gestart" msgstr "Gestart"
#: core/models.py:57 #: core/models.py:56
msgid "Active" msgid "Active"
msgstr "Actief" msgstr "Actief"
#: core/models.py:58 #: core/models.py:57
msgid "Stopped" msgid "Stopped"
msgstr "Gestopt" msgstr "Gestopt"
#: core/models.py:59 #: core/models.py:58
msgid "Saved" msgid "Saved"
msgstr "Opgeslagen" msgstr "Opgeslagen"
#: core/models.py:60 #: core/models.py:59
msgid "Aborted" msgid "Aborted"
msgstr "Afgebroken" msgstr "Afgebroken"
#: core/models.py:61 #: core/models.py:60
msgid "Failed to Start" msgid "Failed to Start"
msgstr "Starten mislukt" msgstr "Starten mislukt"
#: core/models.py:62 #: core/models.py:61
msgid "Failed to Stop" msgid "Failed to Stop"
msgstr "Stoppen mislukt" msgstr "Stoppen mislukt"
#: core/models.py:63 #: core/models.py:62
msgid "Notification succeeded" msgid "Notification succeeded"
msgstr "Notificatie geslaagd" msgstr "Notificatie geslaagd"
#: core/models.py:66 #: core/models.py:65
msgid "External process successful" msgid "External process successful"
msgstr "Externe procedure succesvol" msgstr "Externe procedure succesvol"
#: core/models.py:68 #: core/models.py:67
msgid "External process failed" msgid "External process failed"
msgstr "Het externe proces is mislukt." msgstr "Het externe proces is mislukt."
#: core/models.py:97 #: core/models.py:96
msgid "SCREEN_RECORDING" msgid "SCREEN_RECORDING"
msgstr "SCHERM_OPNAME" msgstr "SCHERM_OPNAME"
#: core/models.py:98 #: core/models.py:97
msgid "TRANSCRIPT" msgid "TRANSCRIPT"
msgstr "TRANSCRIPT" msgstr "TRANSCRIPT"
#: core/models.py:104 #: core/models.py:103
msgid "Public Access" msgid "Public Access"
msgstr "Openbare toegang" msgstr "Openbare toegang"
#: core/models.py:105 #: core/models.py:104
msgid "Trusted Access" msgid "Trusted Access"
msgstr "Vertrouwde toegang" msgstr "Vertrouwde toegang"
#: core/models.py:106 #: core/models.py:105
msgid "Restricted Access" msgid "Restricted Access"
msgstr "Beperkte toegang" msgstr "Beperkte toegang"
#: core/models.py:118 #: core/models.py:117
msgid "id" msgid "id"
msgstr "id" msgstr "id"
#: core/models.py:119 #: 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:125 #: core/models.py:124
msgid "created on" msgid "created on"
msgstr "aangemaakt op" msgstr "aangemaakt op"
#: core/models.py:126 #: 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:131 #: core/models.py:130
msgid "updated on" msgid "updated on"
msgstr "bijgewerkt op" msgstr "bijgewerkt op"
#: core/models.py:132 #: 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:150 #: core/models.py:151
msgid ""
"Enter a valid sub. This value may contain only letters, numbers, and @/./+/-/"
"_ characters."
msgstr ""
"Voer een geldige sub in. Deze waarde mag alleen letters, cijfers en @/./+/-/"
"_ tekens bevatten."
#: 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. Printable ASCII characters only." "or fewer. Letters, numbers, and @/./+/-/_ characters only."
msgstr "" msgstr ""
"Optioneel voor gebruikers in afwachting; verplicht bij het activeren van het " "Optioneel voor gebruikers in afwachting; vereist bij accountactivering. "
"account. Maximaal 255 tekens. Alleen afdrukbare ASCII-tekens." "Maximum 255 tekens. Alleen letters, cijfers en @/./+/-/_ toegestaan."
#: core/validators.py:22 #: core/models.py:168
msgid "Enter a valid sub. This value should be printable ASCII only."
msgstr "Voer een geldige sub in. Deze waarde mag alleen afdrukbare ASCII-tekens bevatten."
#: core/models.py:161
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:190 #: core/models.py:193
msgid "default room access level"
msgstr ""
#: core/models.py:192
msgid ""
"Access level applied by default to new rooms created by this user. When "
"empty, the instance default is used."
msgstr ""
#: core/models.py:199
#, fuzzy
#| msgid "Visio room configuration"
msgid "default room configuration"
msgstr "Visio-ruimteconfiguratie"
#: core/models.py:201
msgid "Configurations applied by default to new rooms created by this user."
msgstr ""
#: core/models.py:205
msgid "device" msgid "device"
msgstr "apparaat" msgstr "apparaat"
#: core/models.py:207 #: 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:210 #: core/models.py:198
msgid "staff status" msgid "staff status"
msgstr "personeelsstatus" msgstr "personeelsstatus"
#: core/models.py:212 #: 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:215 #: core/models.py:203
msgid "active" msgid "active"
msgstr "actief" msgstr "actief"
#: core/models.py:218 #: 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."
@@ -311,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:231 #: core/models.py:219
msgid "user" msgid "user"
msgstr "gebruiker" msgstr "gebruiker"
#: core/models.py:232 #: core/models.py:220
msgid "users" msgid "users"
msgstr "gebruikers" msgstr "gebruikers"
#: core/models.py:298 #: core/models.py:286
msgid "Resource" msgid "Resource"
msgstr "Bron" msgstr "Bron"
#: core/models.py:299 #: core/models.py:287
msgid "Resources" msgid "Resources"
msgstr "Bronnen" msgstr "Bronnen"
#: core/models.py:357 #: core/models.py:345
msgid "Resource access" msgid "Resource access"
msgstr "Brontoegang" msgstr "Brontoegang"
#: core/models.py:358 #: core/models.py:346
msgid "Resource accesses" msgid "Resource accesses"
msgstr "Brontoegangsrechten" msgstr "Brontoegangsrechten"
#: core/models.py:364 #: 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:421 #: core/models.py:409
msgid "Visio room configuration" msgid "Visio room configuration"
msgstr "Visio-ruimteconfiguratie" msgstr "Visio-ruimteconfiguratie"
#: core/models.py:422 #: 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:429 #: 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:430 #: 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:436 core/models.py:597 #: core/models.py:424 core/models.py:578
msgid "Room" msgid "Room"
msgstr "Ruimte" msgstr "Ruimte"
#: core/models.py:437 #: core/models.py:425
msgid "Rooms" msgid "Rooms"
msgstr "Ruimtes" msgstr "Ruimtes"
#: core/models.py:608 #: core/models.py:589
msgid "Worker ID" msgid "Worker ID"
msgstr "Worker ID" msgstr "Worker ID"
#: core/models.py:610 #: 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."
@@ -376,152 +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:618 #: core/models.py:599
msgid "Recording mode" msgid "Recording mode"
msgstr "Opnamemodus" msgstr "Opnamemodus"
#: core/models.py:619 #: 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:624 core/models.py:625 #: core/models.py:605 core/models.py:606
msgid "Recording options" msgid "Recording options"
msgstr "Opnameopties" msgstr "Opnameopties"
#: core/models.py:632 #: core/models.py:613
msgid "External Process ID" msgid "External Process ID"
msgstr "Externe proces-ID" msgstr "Externe proces-ID"
#: core/models.py:633 #: core/models.py:614
msgid "ID of the external process associated with the recording." msgid "ID of the external process associated with the recording."
msgstr "ID van het externe proces dat aan de opname is gekoppeld." msgstr "ID van het externe proces dat aan de opname is gekoppeld."
#: core/models.py:639 #: core/models.py:620
msgid "Recording" msgid "Recording"
msgstr "Opname" msgstr "Opname"
#: core/models.py:640 #: core/models.py:621
msgid "Recordings" msgid "Recordings"
msgstr "Opnames" msgstr "Opnames"
#: core/models.py:750 #: core/models.py:731
msgid "Recording/user relation" msgid "Recording/user relation"
msgstr "Opname/gebruiker-relatie" msgstr "Opname/gebruiker-relatie"
#: core/models.py:751 #: core/models.py:732
msgid "Recording/user relations" msgid "Recording/user relations"
msgstr "Opname/gebruiker-relaties" msgstr "Opname/gebruiker-relaties"
#: core/models.py:757 #: 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:763 #: 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:769 #: 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:786 #: core/models.py:767
msgid "Create rooms" msgid "Create rooms"
msgstr "Ruimtes aanmaken" msgstr "Ruimtes aanmaken"
#: core/models.py:787 #: core/models.py:768
msgid "List rooms" msgid "List rooms"
msgstr "Ruimtes weergeven" msgstr "Ruimtes weergeven"
#: core/models.py:788 #: 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:789 #: core/models.py:770
msgid "Update rooms" msgid "Update rooms"
msgstr "Ruimtes bijwerken" msgstr "Ruimtes bijwerken"
#: core/models.py:790 #: core/models.py:771
msgid "Delete rooms" msgid "Delete rooms"
msgstr "Ruimtes verwijderen" msgstr "Ruimtes verwijderen"
#: core/models.py:803 #: core/models.py:784
msgid "Application name" msgid "Application name"
msgstr "Naam van de applicatie" msgstr "Naam van de applicatie"
#: core/models.py:804 #: 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:814 #: 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:825 #: core/models.py:806
msgid "Application" msgid "Application"
msgstr "Applicatie" msgstr "Applicatie"
#: core/models.py:826 #: core/models.py:807
msgid "Applications" msgid "Applications"
msgstr "Applicaties" msgstr "Applicaties"
#: core/models.py:849 #: 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:852 #: core/models.py:833
msgid "Domain" msgid "Domain"
msgstr "Domein" msgstr "Domein"
#: core/models.py:853 #: 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:865 #: core/models.py:846
msgid "Application domain" msgid "Application domain"
msgstr "Applicatiedomein" msgstr "Applicatiedomein"
#: core/models.py:866 #: core/models.py:847
msgid "Application domains" msgid "Application domains"
msgstr "Applicatiedomeinen" msgstr "Applicatiedomeinen"
#: core/models.py:884 #: core/models.py:865
msgid "Pending" msgid "Pending"
msgstr "In afwachting" msgstr "In afwachting"
#: core/models.py:885 #: core/models.py:866
msgid "Analyzing" msgid "Analyzing"
msgstr "" msgstr ""
#: core/models.py:892 #: core/models.py:873
msgid "Ready" msgid "Ready"
msgstr "Klaar" msgstr "Klaar"
#: core/models.py:898 #: core/models.py:879
msgid "Background image" msgid "Background image"
msgstr "Achtergrondafbeelding" msgstr "Achtergrondafbeelding"
#: core/models.py:910 #: core/models.py:891
msgid "title" msgid "title"
msgstr "Titel" msgstr "Titel"
#: core/models.py:934 #: 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:939 #: core/models.py:920
msgid "File" msgid "File"
msgstr "Bestand" msgstr "Bestand"
#: core/models.py:940 #: core/models.py:921
msgid "Files" msgid "Files"
msgstr "Bestanden" msgstr "Bestanden"
#: core/models.py:1060 #: 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:1070 #: 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."
@@ -529,15 +513,15 @@ 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:124 #: 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:195 #: core/recording/event/notification.py:194
msgid "Transcription" msgid "Transcription"
msgstr "Transcriptie" msgstr "Transcriptie"
#: core/recording/event/notification.py:206 #: core/recording/event/notification.py:204
#, python-brace-format #, python-brace-format
msgid "Meeting \"{room}\" on {room_recording_date} at {room_recording_time}" msgid "Meeting \"{room}\" on {room_recording_date} at {room_recording_time}"
msgstr "" msgstr ""
@@ -548,25 +532,25 @@ msgstr ""
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"
msgstr "Video-oproep bezig: {sender.email} wacht op je verbinding" msgstr "Video-oproep bezig: {sender.email} wacht op je verbinding"
#: core/templates/mail/html/invitation.html:151 #: core/templates/mail/html/invitation.html:159
#: core/templates/mail/html/screen_recording.html:151 #: core/templates/mail/html/screen_recording.html:159
#: core/templates/mail/text/invitation.txt:4 #: core/templates/mail/text/invitation.txt:3
#: core/templates/mail/text/screen_recording.txt:4 #: core/templates/mail/text/screen_recording.txt:3
msgid "Logo email" msgid "Logo email"
msgstr "Logo e-mail" msgstr "Logo e-mail"
#: core/templates/mail/html/invitation.html:181 #: core/templates/mail/html/invitation.html:189
#: core/templates/mail/text/invitation.txt:6 #: core/templates/mail/text/invitation.txt:5
msgid "invites you to join an ongoing video call" msgid "invites you to join an ongoing video call"
msgstr "nodigt je uit om deel te nemen aan een lopende video-oproep" msgstr "nodigt je uit om deel te nemen aan een lopende video-oproep"
#: core/templates/mail/html/invitation.html:192 #: core/templates/mail/html/invitation.html:200
#: core/templates/mail/text/invitation.txt:8 #: core/templates/mail/text/invitation.txt:7
msgid "JOIN THE CALL" msgid "JOIN THE CALL"
msgstr "NEEM DEEL AAN DE OPROEP" msgstr "NEEM DEEL AAN DE OPROEP"
#: core/templates/mail/html/invitation.html:219 #: core/templates/mail/html/invitation.html:227
#: core/templates/mail/text/invitation.txt:14 #: core/templates/mail/text/invitation.txt:13
msgid "" msgid ""
"If you can't click the button, copy and paste the URL into your browser to " "If you can't click the button, copy and paste the URL into your browser to "
"join the call." "join the call."
@@ -574,47 +558,41 @@ msgstr ""
"Als je niet op de knop kunt klikken, kopieer en plak dan de URL in je " "Als je niet op de knop kunt klikken, kopieer en plak dan de URL in je "
"browser om deel te nemen aan de oproep." "browser om deel te nemen aan de oproep."
#: core/templates/mail/html/invitation.html:227 #: core/templates/mail/html/invitation.html:235
#: core/templates/mail/text/invitation.txt:16 #: core/templates/mail/text/invitation.txt:15
msgid "Tips for a better experience:" msgid "Tips for a better experience:"
msgstr "Tips voor een betere ervaring:" msgstr "Tips voor een betere ervaring:"
#: core/templates/mail/html/invitation.html:229 #: core/templates/mail/html/invitation.html:237
#: core/templates/mail/text/invitation.txt:18 #: core/templates/mail/text/invitation.txt:17
msgid "Use Chrome or Firefox for better call quality" msgid "Use Chrome or Firefox for better call quality"
msgstr "Gebruik Chrome of Firefox voor betere gesprekskwaliteit" msgstr "Gebruik Chrome of Firefox voor betere gesprekskwaliteit"
#: core/templates/mail/html/invitation.html:230 #: core/templates/mail/html/invitation.html:238
#: core/templates/mail/text/invitation.txt:19 #: core/templates/mail/text/invitation.txt:18
msgid "Test your microphone and camera before joining" msgid "Test your microphone and camera before joining"
msgstr "Test je microfoon en camera voordat je deelneemt" msgstr "Test je microfoon en camera voordat je deelneemt"
#: core/templates/mail/html/invitation.html:231 #: core/templates/mail/html/invitation.html:239
#: core/templates/mail/text/invitation.txt:20 #: core/templates/mail/text/invitation.txt:19
msgid "Make sure you have a stable internet connection" msgid "Make sure you have a stable internet connection"
msgstr "Zorg ervoor dat je een stabiele internetverbinding hebt" msgstr "Zorg ervoor dat je een stabiele internetverbinding hebt"
#: core/templates/mail/html/invitation.html:240 #: core/templates/mail/html/invitation.html:248
#: core/templates/mail/html/screen_recording.html:237 #: core/templates/mail/html/screen_recording.html:245
#: core/templates/mail/text/invitation.txt:22 #: core/templates/mail/text/invitation.txt:21
#: core/templates/mail/text/screen_recording.txt:24 #: core/templates/mail/text/screen_recording.txt:23
#, python-format #, python-format
msgid " Thank you for using %(brandname)s. " msgid " Thank you for using %(brandname)s. "
msgstr " Bedankt voor het gebruik van %(brandname)s. " msgstr " Bedankt voor het gebruik van %(brandname)s. "
#: core/templates/mail/html/invitation.html:271 #: core/templates/mail/html/screen_recording.html:188
#, python-format #: core/templates/mail/text/screen_recording.txt:6
msgid ""
"This mail has been sent to %(email)s by <a href=\"%(href)s\">%(name)s</a>"
msgstr ""
#: core/templates/mail/html/screen_recording.html:180
#: core/templates/mail/text/screen_recording.txt:7
msgid "Your recording is ready!" msgid "Your recording is ready!"
msgstr "Je opname is klaar!" msgstr "Je opname is klaar!"
#: core/templates/mail/html/screen_recording.html:187 #: core/templates/mail/html/screen_recording.html:195
#: core/templates/mail/text/screen_recording.txt:9 #: core/templates/mail/text/screen_recording.txt:8
#, python-format #, python-format
msgid "" msgid ""
" Your recording of \"%(room_name)s\" on %(recording_date)s at " " Your recording of \"%(room_name)s\" on %(recording_date)s at "
@@ -623,14 +601,14 @@ msgstr ""
" Je opname van \"%(room_name)s\" op %(recording_date)s om %(recording_time)s " " Je opname van \"%(room_name)s\" op %(recording_date)s om %(recording_time)s "
"is nu klaar om te downloaden. " "is nu klaar om te downloaden. "
#: core/templates/mail/html/screen_recording.html:187 #: core/templates/mail/html/screen_recording.html:195
#: core/templates/mail/text/screen_recording.txt:9 #: core/templates/mail/text/screen_recording.txt:8
#, python-format #, python-format
msgid " The recording will expire in %(days)s days. " msgid " The recording will expire in %(days)s days. "
msgstr " De opname verloopt over %(days)s dagen. " msgstr " De opname verloopt over %(days)s dagen. "
#: core/templates/mail/html/screen_recording.html:192 #: core/templates/mail/html/screen_recording.html:200
#: core/templates/mail/text/screen_recording.txt:10 #: core/templates/mail/text/screen_recording.txt:9
msgid "" msgid ""
" Sharing the recording via link is not yet available. Only organizers can " " Sharing the recording via link is not yet available. Only organizers can "
"download it. " "download it. "
@@ -638,33 +616,33 @@ msgstr ""
"Het delen van de opname via een link is nog niet beschikbaar. Alleen " "Het delen van de opname via een link is nog niet beschikbaar. Alleen "
"organisatoren kunnen deze downloaden." "organisatoren kunnen deze downloaden."
#: core/templates/mail/html/screen_recording.html:198 #: core/templates/mail/html/screen_recording.html:206
#: core/templates/mail/text/screen_recording.txt:12 #: core/templates/mail/text/screen_recording.txt:11
msgid "To keep this recording permanently:" 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:200 #: core/templates/mail/html/screen_recording.html:208
#, python-format #, 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 "
#: core/templates/mail/html/screen_recording.html:201 #: core/templates/mail/html/screen_recording.html:209
#: core/templates/mail/text/screen_recording.txt:15 #: core/templates/mail/text/screen_recording.txt:14
msgid "Use the \"Download\" button in the interface " msgid "Use the \"Download\" button in the interface "
msgstr "Gebruik de \"Download\"-knop in de interface " msgstr "Gebruik de \"Download\"-knop in de interface "
#: core/templates/mail/html/screen_recording.html:202 #: core/templates/mail/html/screen_recording.html:210
#: core/templates/mail/text/screen_recording.txt:16 #: core/templates/mail/text/screen_recording.txt:15
msgid "Save the file to your preferred location" msgid "Save the file to your preferred location"
msgstr "Sla het bestand op naar je gewenste locatie" msgstr "Sla het bestand op naar je gewenste locatie"
#: core/templates/mail/html/screen_recording.html:213 #: core/templates/mail/html/screen_recording.html:221
#: core/templates/mail/text/screen_recording.txt:18 #: core/templates/mail/text/screen_recording.txt:17
msgid "Open" msgid "Open"
msgstr "Openen" msgstr "Openen"
#: core/templates/mail/html/screen_recording.html:222 #: core/templates/mail/html/screen_recording.html:230
#: core/templates/mail/text/screen_recording.txt:20 #: core/templates/mail/text/screen_recording.txt:19
#, python-format #, python-format
msgid "" msgid ""
" If you have any questions or need assistance, please contact our support " " If you have any questions or need assistance, please contact our support "
@@ -673,33 +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. "
#: core/templates/mail/text/invitation.txt:24 #: core/templates/mail/text/screen_recording.txt:13
#, python-format
msgid "This mail has been sent to %(email)s by %(name)s [%(href)s]"
msgstr ""
#: core/templates/mail/text/screen_recording.txt:14
#, fuzzy, python-format #, fuzzy, python-format
#| msgid "Click the \"<a href=\"%(link)s\">Open</a>\" link below " #| msgid "Click the \"<a href=\"%(link)s\">Open</a>\" link below "
msgid "Click the \"Open [%(link)s]\" link below " msgid "Click the \"Open [%(link)s]\" 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 "
#: meet/settings.py:238 #: meet/settings.py:228
msgid "English" msgid "English"
msgstr "Engels" msgstr "Engels"
#: meet/settings.py:239 #: meet/settings.py:229
msgid "French" msgid "French"
msgstr "Frans" msgstr "Frans"
#: meet/settings.py:240 #: meet/settings.py:230
msgid "Dutch" msgid "Dutch"
msgstr "Nederlands" msgstr "Nederlands"
#: meet/settings.py:241 #: meet/settings.py:231
msgid "German" msgid "German"
msgstr "Duits" msgstr "Duits"
#: meet/settings.py:242
msgid "Spanish"
msgstr "Spaans"
+14 -19
View File
@@ -129,15 +129,6 @@ class Base(Configuration):
MEDIA_BASE_URL = values.Value( MEDIA_BASE_URL = values.Value(
"", environ_name="MEDIA_BASE_URL", environ_prefix=None "", environ_name="MEDIA_BASE_URL", environ_prefix=None
) )
# Header the reverse proxy uses to pass the original request URL to the
# media-auth subrequest views. nginx-ingress sends X-Original-URL, which is
# the default. Other proxies use different headers -- Traefik's ForwardAuth,
# for instance, sends X-Forwarded-Uri and cannot emit X-Original-URL at all.
MEDIA_AUTH_ORIGINAL_URL_HEADER = values.Value(
default="HTTP_X_ORIGINAL_URL",
environ_name="MEDIA_AUTH_ORIGINAL_URL_HEADER",
environ_prefix=None,
)
SITE_ID = 1 SITE_ID = 1
@@ -239,7 +230,6 @@ class Base(Configuration):
("fr-fr", _("French")), ("fr-fr", _("French")),
("nl-nl", _("Dutch")), ("nl-nl", _("Dutch")),
("de-de", _("German")), ("de-de", _("German")),
("es-es", _("Spanish")),
) )
) )
@@ -739,6 +729,20 @@ class Base(Configuration):
environ_name="RECORDING_WORKER_CLASSES", environ_name="RECORDING_WORKER_CLASSES",
environ_prefix=None, environ_prefix=None,
) )
RECORDING_EVENT_PARSER_CLASS = values.Value(
"core.recording.event.parsers.MinioParser",
environ_name="RECORDING_EVENT_PARSER_CLASS",
environ_prefix=None,
)
RECORDING_ENABLE_STORAGE_EVENT_AUTH = values.BooleanValue(
True, environ_name="RECORDING_ENABLE_STORAGE_EVENT_AUTH", environ_prefix=None
)
RECORDING_STORAGE_EVENT_ENABLE = values.BooleanValue(
False, environ_name="RECORDING_STORAGE_EVENT_ENABLE", environ_prefix=None
)
RECORDING_STORAGE_EVENT_TOKEN = SecretFileValue(
None, environ_name="RECORDING_STORAGE_EVENT_TOKEN", environ_prefix=None
)
# Number of days before recordings expire - must be synced with bucket lifecycle policy # Number of days before recordings expire - must be synced with bucket lifecycle policy
# Set to None for no expiration # Set to None for no expiration
RECORDING_EXPIRATION_DAYS = values.IntegerValue( RECORDING_EXPIRATION_DAYS = values.IntegerValue(
@@ -852,15 +856,6 @@ class Base(Configuration):
) )
# Lobby configurations # Lobby configurations
PRESENCE_KEY_PREFIX = values.Value(
"room_presence", environ_name="PRESENCE_KEY_PREFIX", environ_prefix=None
)
PRESENCE_CACHE_TIMEOUT = values.PositiveIntegerValue(
3600, environ_name="PRESENCE_CACHE_TIMEOUT", environ_prefix=None
)
PRESENCE_CLEAR_ON_PARTICIPANT_LEFT = values.BooleanValue(
True, environ_name="PRESENCE_CLEAR_ON_PARTICIPANT_LEFT", environ_prefix=None
)
LOBBY_KEY_PREFIX = values.Value( LOBBY_KEY_PREFIX = values.Value(
"room_lobby", environ_name="LOBBY_KEY_PREFIX", environ_prefix=None "room_lobby", environ_name="LOBBY_KEY_PREFIX", environ_prefix=None
) )
+1 -1
View File
@@ -7,7 +7,7 @@ build-backend = "uv_build"
[project] [project]
name = "meet" name = "meet"
version = "1.30.0" version = "1.24.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",
+4 -4
View File
@@ -1187,7 +1187,7 @@ wheels = [
[[package]] [[package]]
name = "meet" name = "meet"
version = "1.30.0" version = "1.24.0"
source = { editable = "." } source = { editable = "." }
dependencies = [ dependencies = [
{ name = "aiohttp" }, { name = "aiohttp" },
@@ -2252,11 +2252,11 @@ wheels = [
[[package]] [[package]]
name = "sqlparse" name = "sqlparse"
version = "0.6.0" version = "0.5.5"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/5f/d3/3f06a1006f2261d1342aefb3c71eed02f5d4ca5bdbecd86ebc12ad38306e/sqlparse-0.6.0.tar.gz", hash = "sha256:113c35c75365ab9cc9c7231d68c6428fb11c085fc8e9eb1ad659b7ddbf6cd2b9", size = 178477, upload-time = "2026-08-13T19:16:06.396Z" } sdist = { url = "https://files.pythonhosted.org/packages/90/76/437d71068094df0726366574cf3432a4ed754217b436eb7429415cf2d480/sqlparse-0.5.5.tar.gz", hash = "sha256:e20d4a9b0b8585fdf63b10d30066c7c94c5d7a7ec47c889a2d83a3caa93ff28e", size = 120815, upload-time = "2025-12-19T07:17:45.073Z" }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/d9/50/f00935da0ec7cbf325f8dc4f772ae46fbc7b672dd62876e73f0a94adda57/sqlparse-0.6.0-py3-none-any.whl", hash = "sha256:b861c0288ce2fa56209a9a6412d2e066ac664b3873b89c26c9d8415e8e32996f", size = 50070, upload-time = "2026-08-13T19:16:04.062Z" }, { url = "https://files.pythonhosted.org/packages/49/4b/359f28a903c13438ef59ebeee215fb25da53066db67b305c125f1c6d2a25/sqlparse-0.5.5-py3-none-any.whl", hash = "sha256:12a08b3bf3eec877c519589833aed092e2444e68240a3577e8e26148acc7b1ba", size = 46138, upload-time = "2025-12-19T07:17:46.573Z" },
] ]
[[package]] [[package]]
-1
View File
@@ -53,7 +53,6 @@ RUN apk update && apk upgrade \
musl \ musl \
musl-utils \ musl-utils \
zlib>=1.3.2-r0 \ zlib>=1.3.2-r0 \
libexpat>=2.8.4-r0 \
&& apk del curl && apk del curl
USER nginx USER nginx
-5
View File
@@ -4,11 +4,6 @@ server {
server_tokens off; server_tokens off;
root /usr/share/nginx/html; root /usr/share/nginx/html;
location ^~ /assets/mediapipe/wasm/ {
expires 30d;
add_header Cache-Control "public, max-age=2592000";
}
# Serve static files with caching # Serve static files with caching
location ~* ^/assets/.*\.(css|js|json|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ { location ~* ^/assets/.*\.(css|js|json|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
+1 -1
View File
@@ -3,6 +3,6 @@
"input": ["src/**/*.{ts,tsx}", "!src/styled-system/**/*", "!src/**/*.d.ts"], "input": ["src/**/*.{ts,tsx}", "!src/styled-system/**/*", "!src/**/*.d.ts"],
"output": "src/locales/$LOCALE/$NAMESPACE.json", "output": "src/locales/$LOCALE/$NAMESPACE.json",
"createOldCatalogs": false, "createOldCatalogs": false,
"locales": ["en", "fr", "de", "nl", "es"], "locales": ["en", "fr", "de", "nl"],
"sort": true "sort": true
} }
+129 -133
View File
@@ -1,40 +1,41 @@
{ {
"name": "meet", "name": "meet",
"version": "1.30.0", "version": "1.24.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "meet", "name": "meet",
"version": "1.30.0", "version": "1.24.0",
"dependencies": { "dependencies": {
"@fontsource-variable/atkinson-hyperlegible-next": "5.3.0", "@fontsource-variable/atkinson-hyperlegible-next": "5.2.6",
"@fontsource-variable/lexend": "5.3.0", "@fontsource-variable/lexend": "5.2.11",
"@fontsource/opendyslexic": "5.3.0", "@fontsource/opendyslexic": "5.2.5",
"@libreaudio/la-call": "0.1.4", "@libreaudio/la-call": "0.1.4",
"@livekit/components-react": "2.9.23", "@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",
"@mediapipe/tasks-vision": "0.10.14", "@mediapipe/tasks-vision": "0.10.35",
"@pandacss/preset-panda": "1.12.0", "@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.101.4", "@tanstack/react-query": "5.101.1",
"@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",
"hoofd": "1.7.3", "hoofd": "1.7.3",
"humanize-duration": "3.33.2", "humanize-duration": "3.33.2",
"i18next": "26.3.6", "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.3", "i18next-resources-to-backend": "1.2.1",
"livekit-client": "2.21.0", "livekit-client": "2.20.0",
"posthog-js": "1.414.0", "posthog-js": "1.395.0",
"react": "18.3.1", "react": "18.3.1",
"react-aria": "3.50.0", "react-aria": "3.50.0",
"react-aria-components": "1.19.0", "react-aria-components": "1.19.0",
"react-dom": "18.3.1", "react-dom": "18.3.1",
"react-i18next": "17.0.10", "react-i18next": "17.0.8",
"react-stately": "3.48.0", "react-stately": "3.48.0",
"use-sound": "5.0.0", "use-sound": "5.0.0",
"valtio": "2.3.2", "valtio": "2.3.2",
@@ -742,52 +743,52 @@
} }
}, },
"node_modules/@floating-ui/core": { "node_modules/@floating-ui/core": {
"version": "1.8.0", "version": "1.7.5",
"resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.8.0.tgz", "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.5.tgz",
"integrity": "sha512-0CIZ5itps/8x7BG8dEIhs53BvCUH2PCoogtakwRTut+Arm58sJooJ0AuZhLw2HJYIR5cMLNPBSS728sPho2khQ==", "integrity": "sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@floating-ui/utils": "^0.2.12"
}
},
"node_modules/@floating-ui/dom": {
"version": "1.7.6",
"resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.6.tgz",
"integrity": "sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ==",
"license": "MIT",
"dependencies": {
"@floating-ui/core": "^1.7.5",
"@floating-ui/utils": "^0.2.11" "@floating-ui/utils": "^0.2.11"
} }
}, },
"node_modules/@floating-ui/dom": {
"version": "1.7.4",
"resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.4.tgz",
"integrity": "sha512-OOchDgh4F2CchOX94cRVqhvy7b3AFb+/rQXyswmzmGakRfkMgoWVjfnLWkRirfLEfuD4ysVW16eXzwt3jHIzKA==",
"license": "MIT",
"dependencies": {
"@floating-ui/core": "^1.7.3",
"@floating-ui/utils": "^0.2.10"
}
},
"node_modules/@floating-ui/utils": { "node_modules/@floating-ui/utils": {
"version": "0.2.12", "version": "0.2.11",
"resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.12.tgz", "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.11.tgz",
"integrity": "sha512-HpCo8tmWzLVad5s2d19EhAz5zqrrQ6s69qd6moPMQvkOuSwDT1YgRfWSVuc4ennqrgv3OHppiOGMQ7oC13yIww==", "integrity": "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/@fontsource-variable/atkinson-hyperlegible-next": { "node_modules/@fontsource-variable/atkinson-hyperlegible-next": {
"version": "5.3.0", "version": "5.2.6",
"resolved": "https://registry.npmjs.org/@fontsource-variable/atkinson-hyperlegible-next/-/atkinson-hyperlegible-next-5.3.0.tgz", "resolved": "https://registry.npmjs.org/@fontsource-variable/atkinson-hyperlegible-next/-/atkinson-hyperlegible-next-5.2.6.tgz",
"integrity": "sha512-UnLzNUy1RWoLDfydAatP4NaRm99X/WbQEph7q/PymJnCFIOOwKOx3pBmp5ys8trO0dM+CKGuJgeZP9fAR4mQpA==", "integrity": "sha512-D0Z8peFkRGYACvmWp8Sl8YPGYLBEJbzqapugtsMEYSUUtpQDiZVDHjk9s7bqB4hSCRYCrKuW6V1V+mnHybVnPw==",
"license": "OFL-1.1", "license": "OFL-1.1",
"funding": { "funding": {
"url": "https://github.com/sponsors/ayuhito" "url": "https://github.com/sponsors/ayuhito"
} }
}, },
"node_modules/@fontsource-variable/lexend": { "node_modules/@fontsource-variable/lexend": {
"version": "5.3.0", "version": "5.2.11",
"resolved": "https://registry.npmjs.org/@fontsource-variable/lexend/-/lexend-5.3.0.tgz", "resolved": "https://registry.npmjs.org/@fontsource-variable/lexend/-/lexend-5.2.11.tgz",
"integrity": "sha512-3SXtiZ8rFbT0oaPzEboCG5RM3jtyhpNxandh1jWNLqvrJRfV/eP3R+GGw+o5e3hS1uqmAGutKxSvaWG5oyrimA==", "integrity": "sha512-0hgEQ4O7Nh8fxL/WWmspJf0BErbocRkZwtLRGey/V4mUUqxfF7QUwqhcdzwpjom3NYCniY4uzQ5wYD7r9/92tQ==",
"license": "OFL-1.1", "license": "OFL-1.1",
"funding": { "funding": {
"url": "https://github.com/sponsors/ayuhito" "url": "https://github.com/sponsors/ayuhito"
} }
}, },
"node_modules/@fontsource/opendyslexic": { "node_modules/@fontsource/opendyslexic": {
"version": "5.3.0", "version": "5.2.5",
"resolved": "https://registry.npmjs.org/@fontsource/opendyslexic/-/opendyslexic-5.3.0.tgz", "resolved": "https://registry.npmjs.org/@fontsource/opendyslexic/-/opendyslexic-5.2.5.tgz",
"integrity": "sha512-BVYIW/ghc1U2iAghhYTN+GFWiZ3lnCy8Jf2KkZC23aHQHBNcMqKT718zckfqVxRx4qsXJmIMPWvczirRuhmtsg==", "integrity": "sha512-NNS9aaPQx2TlaTvb3vTEjw3xz8lKj23mBc+6rM00mSNFDygdoll0/nLMHFtDKKrBT6sMfY6TFFPOR0D9ktdspg==",
"license": "OFL-1.1", "license": "OFL-1.1",
"funding": { "funding": {
"url": "https://github.com/sponsors/ayuhito" "url": "https://github.com/sponsors/ayuhito"
@@ -966,12 +967,12 @@
"license": "GPL-3.0+" "license": "GPL-3.0+"
}, },
"node_modules/@livekit/components-core": { "node_modules/@livekit/components-core": {
"version": "0.12.14", "version": "0.12.13",
"resolved": "https://registry.npmjs.org/@livekit/components-core/-/components-core-0.12.14.tgz", "resolved": "https://registry.npmjs.org/@livekit/components-core/-/components-core-0.12.13.tgz",
"integrity": "sha512-6OKP/1Ok2fCZewDLKd3SzaTb7KvfZl/6hjggI+TgUdhp0Y7HBg3+tHA7YmhZRc0BO8wIyVy4VgTPn7qkLHt2nQ==", "integrity": "sha512-DQmi84afHoHjZ62wm8y+XPNIDHTwFHAltjd3lmyXj8UZHOY7wcza4vFt1xnghJOD5wLRY58L1dkAgAw59MgWvw==",
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"@floating-ui/dom": "1.7.6", "@floating-ui/dom": "1.7.4",
"loglevel": "1.9.1", "loglevel": "1.9.1",
"rxjs": "7.8.2" "rxjs": "7.8.2"
}, },
@@ -979,17 +980,17 @@
"node": ">=18" "node": ">=18"
}, },
"peerDependencies": { "peerDependencies": {
"livekit-client": "^2.20.1", "livekit-client": "^2.17.2",
"tslib": "^2.6.2" "tslib": "^2.6.2"
} }
}, },
"node_modules/@livekit/components-react": { "node_modules/@livekit/components-react": {
"version": "2.9.23", "version": "2.9.21",
"resolved": "https://registry.npmjs.org/@livekit/components-react/-/components-react-2.9.23.tgz", "resolved": "https://registry.npmjs.org/@livekit/components-react/-/components-react-2.9.21.tgz",
"integrity": "sha512-clO+0g/u3YBpuOvnAjUSAJBH/o7w+RpUAseswjiSML9rHrXlTUhwBNm8LPk+qN5GOU3A6lzsNnFYVpUHUBVOkg==", "integrity": "sha512-6hU9VucJJL+gAhilNGe4MBCDCZVk64qyjP9Ck86krvOIdVU76WeWksddg1MYUP10AlUwwrfD7davz41pJTcMJw==",
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"@livekit/components-core": "0.12.14", "@livekit/components-core": "0.12.13",
"clsx": "2.1.1", "clsx": "2.1.1",
"events": "^3.3.0", "events": "^3.3.0",
"jose": "^6.0.12", "jose": "^6.0.12",
@@ -999,8 +1000,8 @@
"node": ">=18" "node": ">=18"
}, },
"peerDependencies": { "peerDependencies": {
"@livekit/krisp-noise-filter": "^0.2.12 || ^0.3.0 || ^0.4.0", "@livekit/krisp-noise-filter": "^0.2.12 || ^0.3.0",
"livekit-client": "^2.20.1", "livekit-client": "^2.18.2",
"react": ">=18", "react": ">=18",
"react-dom": ">=18", "react-dom": ">=18",
"tslib": "^2.6.2" "tslib": "^2.6.2"
@@ -1027,9 +1028,9 @@
"license": "Apache-2.0" "license": "Apache-2.0"
}, },
"node_modules/@livekit/protocol": { "node_modules/@livekit/protocol": {
"version": "1.50.4", "version": "1.46.6",
"resolved": "https://registry.npmjs.org/@livekit/protocol/-/protocol-1.50.4.tgz", "resolved": "https://registry.npmjs.org/@livekit/protocol/-/protocol-1.46.6.tgz",
"integrity": "sha512-L1uggNQAqyY21smQY8AllyOYbcv9Me9TaxwuLytL1R8ck9nbYPmQLNwEDi3pOFGAMa5F8I2nUi2Jc59W5awxlA==", "integrity": "sha512-upzlHP1vi/kZ/QqALZTFskQ0ifqc2f15RKucHYOsIHJsaXvEYanG75mAb7o+Yomfs4XhQ4BaRsdY+TFHXpaqrg==",
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"@bufbuild/protobuf": "^1.10.0" "@bufbuild/protobuf": "^1.10.0"
@@ -1048,12 +1049,18 @@
"livekit-client": "^1.12.0 || ^2.1.0" "livekit-client": "^1.12.0 || ^2.1.0"
} }
}, },
"node_modules/@mediapipe/tasks-vision": { "node_modules/@livekit/track-processors/node_modules/@mediapipe/tasks-vision": {
"version": "0.10.14", "version": "0.10.14",
"resolved": "https://registry.npmjs.org/@mediapipe/tasks-vision/-/tasks-vision-0.10.14.tgz", "resolved": "https://registry.npmjs.org/@mediapipe/tasks-vision/-/tasks-vision-0.10.14.tgz",
"integrity": "sha512-vOifgZhkndgybdvoRITzRkIueWWSiCKuEUXXK6Q4FaJsFvRJuwgg++vqFUMlL0Uox62U5aEXFhHxlhV7Ja5e3Q==", "integrity": "sha512-vOifgZhkndgybdvoRITzRkIueWWSiCKuEUXXK6Q4FaJsFvRJuwgg++vqFUMlL0Uox62U5aEXFhHxlhV7Ja5e3Q==",
"license": "Apache-2.0" "license": "Apache-2.0"
}, },
"node_modules/@mediapipe/tasks-vision": {
"version": "0.10.35",
"resolved": "https://registry.npmjs.org/@mediapipe/tasks-vision/-/tasks-vision-0.10.35.tgz",
"integrity": "sha512-HOvadwVRE6JC+45nyYhmnywnr5h/J8KZvOeUNVOG9q/0875pZgItznFB9bRTvLc264YSJqiZ1NsIpCStJw/egg==",
"license": "Apache-2.0"
},
"node_modules/@modelcontextprotocol/sdk": { "node_modules/@modelcontextprotocol/sdk": {
"version": "1.29.0", "version": "1.29.0",
"resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.29.0.tgz", "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.29.0.tgz",
@@ -1642,21 +1649,21 @@
} }
}, },
"node_modules/@pandacss/preset-panda": { "node_modules/@pandacss/preset-panda": {
"version": "1.12.0", "version": "1.11.3",
"resolved": "https://registry.npmjs.org/@pandacss/preset-panda/-/preset-panda-1.12.0.tgz", "resolved": "https://registry.npmjs.org/@pandacss/preset-panda/-/preset-panda-1.11.3.tgz",
"integrity": "sha512-jhzhAmtGY34b1FlMJGEAh/IkfU4WyTNT2mpJfcBUPBi1rZLHSTcs0aqedRmMdwM4tTT43XFOXMiazbztKME60A==", "integrity": "sha512-IHeRQyIYeGKpQl78A/oI9fGg8MHQiOX/HttH+iQNaRU/MbGN6JsPe/N0ekQC6QY8iiKtLhqR+SRc455SN6JVWw==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@pandacss/types": "1.12.0" "@pandacss/types": "1.11.3"
}, },
"engines": { "engines": {
"node": ">=20" "node": ">=20"
} }
}, },
"node_modules/@pandacss/preset-panda/node_modules/@pandacss/types": { "node_modules/@pandacss/preset-panda/node_modules/@pandacss/types": {
"version": "1.12.0", "version": "1.11.3",
"resolved": "https://registry.npmjs.org/@pandacss/types/-/types-1.12.0.tgz", "resolved": "https://registry.npmjs.org/@pandacss/types/-/types-1.11.3.tgz",
"integrity": "sha512-PtlEIOBEV3RzWffeu1zH+/YFmP+fn24M63STGEbgTwjEBMhyo/FfT2sfZGB8oWAbWUBKBp69OMg25E+/rY3klQ==", "integrity": "sha512-tUbxa3WzoCuulP6kcMn7f0uNgLBvu81JAg4sfoUpz6PZE36VHA3k9n6ROEwziTbr7vqgeCj5DYQCUldrVNbEGQ==",
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">=20" "node": ">=20"
@@ -1719,29 +1726,19 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/@posthog/browser-common": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/@posthog/browser-common/-/browser-common-0.4.0.tgz",
"integrity": "sha512-W9DCGVks15docUMPvJ2nd8NS16Gn74bsGWuaeg31beEKFSjdW8wvnQ1ETY6WSql5pYxZb3GdJmEUZVVstKSrBQ==",
"license": "MIT",
"dependencies": {
"@posthog/core": "^1.46.8",
"@posthog/types": "^1.402.0"
}
},
"node_modules/@posthog/core": { "node_modules/@posthog/core": {
"version": "1.49.1", "version": "1.39.5",
"resolved": "https://registry.npmjs.org/@posthog/core/-/core-1.49.1.tgz", "resolved": "https://registry.npmjs.org/@posthog/core/-/core-1.39.5.tgz",
"integrity": "sha512-jdZh85tG56OXLH881CVwBZyiXCPPaZasfYeWwm9kVUvxC/Rb+lz7wYN9GuqSEmNPJgVnK4v8wS0bCaFc3OmVEA==", "integrity": "sha512-M8Imv7ZmmrT6derMOFXhX3c5VKKO2oG6OX70ozLnNpKJV73sNIhYmRX/raa90saF7OA0YmWWJlpKje3irGAyQQ==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@posthog/types": "^1.407.0" "@posthog/types": "^1.392.0"
} }
}, },
"node_modules/@posthog/types": { "node_modules/@posthog/types": {
"version": "1.407.1", "version": "1.392.0",
"resolved": "https://registry.npmjs.org/@posthog/types/-/types-1.407.1.tgz", "resolved": "https://registry.npmjs.org/@posthog/types/-/types-1.392.0.tgz",
"integrity": "sha512-WhbkXPC2rgylXqmxHqv70ffI3k+KxyR6s7DBIfr5NvIqHkxp6v0pk31D/jbz0DNVbzwkLjyll2pxr4FNbJiYzg==", "integrity": "sha512-nctNujXL3FC1v99FktaTMSugSD9ZOZekEpahUSafkU2TSvW+XGKNkQZbokuJtiWvPBK208dwMJva8UfBkChqpw==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/@react-aria/overlays": { "node_modules/@react-aria/overlays": {
@@ -2424,9 +2421,9 @@
} }
}, },
"node_modules/@tanstack/query-core": { "node_modules/@tanstack/query-core": {
"version": "5.101.4", "version": "5.101.1",
"resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.101.4.tgz", "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.101.1.tgz",
"integrity": "sha512-gNwcvOJcRbLWPOLG/2OBm+zM+Yv+MKsXKEOWC57USuZDEsI71hEErQsiEGx5wX9rzWWkfwM0fVSPoiIFSsxfiw==", "integrity": "sha512-Y6Y92dkXtNqx67m2pMSxUsA3zOCwv862JexZRP8/EPwvKXMPu9m8rv43spiXWzOUIggQ3SQApttALStzhA8B4g==",
"license": "MIT", "license": "MIT",
"funding": { "funding": {
"type": "github", "type": "github",
@@ -2445,12 +2442,12 @@
} }
}, },
"node_modules/@tanstack/react-query": { "node_modules/@tanstack/react-query": {
"version": "5.101.4", "version": "5.101.1",
"resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.101.4.tgz", "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.101.1.tgz",
"integrity": "sha512-yRg2pfOCxIs4ZJW3XYYHU/WgtD04FHSnfHlpRT7h7pR77hwkdRG4wxbKe4aq6P0RvXUTBSQpQeadS1SUYUe+KA==", "integrity": "sha512-ZnONUuQKJe1bJMStXUL1s5uKN9FcfC28j5cK+iDZcdSHtUv1wtin1cGc/Oewhf2Oc4eKY7lggtpvT/AbMmhHew==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@tanstack/query-core": "5.101.4" "@tanstack/query-core": "5.101.1"
}, },
"funding": { "funding": {
"type": "github", "type": "github",
@@ -4403,14 +4400,11 @@
} }
}, },
"node_modules/core-js": { "node_modules/core-js": {
"version": "3.50.0", "version": "3.39.0",
"resolved": "https://registry.npmjs.org/core-js/-/core-js-3.50.0.tgz", "resolved": "https://registry.npmjs.org/core-js/-/core-js-3.39.0.tgz",
"integrity": "sha512-BRWgOLKkFeCgRudR6zrs8p9XJZcE14grzKMMssoYrk6krtuEZ7MTKPIY5RzOnqsEKIR9kst7wNzphttraT+Yqw==", "integrity": "sha512-raM0ew0/jJUqkJ0E6e8UDtl+y/7ktFivgWvqw8dNSQeNWoSDLvQ1H/RN3aPXB9tBd4/FhyR4RDPGhsNIMsAn7g==",
"hasInstallScript": true, "hasInstallScript": true,
"license": "MIT", "license": "MIT",
"engines": {
"node": "*"
},
"funding": { "funding": {
"type": "opencollective", "type": "opencollective",
"url": "https://opencollective.com/core-js" "url": "https://opencollective.com/core-js"
@@ -4722,6 +4716,15 @@
"node": ">= 0.8" "node": ">= 0.8"
} }
}, },
"node_modules/derive-valtio": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/derive-valtio/-/derive-valtio-0.2.0.tgz",
"integrity": "sha512-6slhaFHtfaL3t5dLYaQt6s4G2xZymhu0Ktdl7OMeVk8+46RgR8ft6FL0Tr4F31W+yPH03nJe1SSP4JFy2hSMRA==",
"license": "MIT",
"peerDependencies": {
"valtio": ">=2.0.0-rc.0"
}
},
"node_modules/detect-libc": { "node_modules/detect-libc": {
"version": "2.1.2", "version": "2.1.2",
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
@@ -4794,10 +4797,13 @@
} }
}, },
"node_modules/dompurify": { "node_modules/dompurify": {
"version": "3.4.14", "version": "3.3.2",
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.14.tgz", "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.3.2.tgz",
"integrity": "sha512-dVoH9z+MY+C9IilgGCk3YfFqjLi3fChm2OiKJMzh6axrJ5qwxqWaZamgmHrpv22CN/KdbZJuGEGgfQoL00LTdg==", "integrity": "sha512-6obghkliLdmKa56xdbLOpUZ43pAR6xFy1uOrxBaIDjT+yaRuuybLjGS9eVBoSR/UPU5fq3OXClEHLJNGvbxKpQ==",
"license": "(MPL-2.0 OR Apache-2.0)", "license": "(MPL-2.0 OR Apache-2.0)",
"engines": {
"node": ">=20"
},
"optionalDependencies": { "optionalDependencies": {
"@types/trusted-types": "^2.0.7" "@types/trusted-types": "^2.0.7"
} }
@@ -6410,9 +6416,9 @@
} }
}, },
"node_modules/i18next": { "node_modules/i18next": {
"version": "26.3.6", "version": "26.3.1",
"resolved": "https://registry.npmjs.org/i18next/-/i18next-26.3.6.tgz", "resolved": "https://registry.npmjs.org/i18next/-/i18next-26.3.1.tgz",
"integrity": "sha512-Bu5Z2nAXgfVyM8xvW3jk9EKRIuX37PudsrBViThNFx7CR7aaYTpP01cxNB/E4c4UUzTDiAZRstEhsRfPOL/8xA==", "integrity": "sha512-txQqd5EULsqEh9OJqRH15aCaOuy/nLJyhw5EHCSKLKJE1aBbb3Zve2+uQIxgWhPm1QqUQoWyQBm2kfmmIrzkcQ==",
"funding": [ "funding": [
{ {
"type": "individual", "type": "individual",
@@ -6429,7 +6435,7 @@
], ],
"license": "MIT", "license": "MIT",
"peerDependencies": { "peerDependencies": {
"typescript": "^5 || ^6 || ^7" "typescript": "^5 || ^6"
}, },
"peerDependenciesMeta": { "peerDependenciesMeta": {
"typescript": { "typescript": {
@@ -6982,10 +6988,9 @@
} }
}, },
"node_modules/i18next-resources-to-backend": { "node_modules/i18next-resources-to-backend": {
"version": "1.2.3", "version": "1.2.1",
"resolved": "https://registry.npmjs.org/i18next-resources-to-backend/-/i18next-resources-to-backend-1.2.3.tgz", "resolved": "https://registry.npmjs.org/i18next-resources-to-backend/-/i18next-resources-to-backend-1.2.1.tgz",
"integrity": "sha512-8Y/LLAm5fqZc2ckQxtTWbu75ndjNLzF7mcYl6rhc4g+WBopIsG3YhdYqF5qhJy64tlYhlU2KSSnEgW3gGHLqwg==", "integrity": "sha512-okHbVA+HZ7n1/76MsfhPqDou0fptl2dAlhRDu2ideXloRRduzHsqDOznJBef+R3DFZnbvWoBW+KxJ7fnFjd6Yw==",
"license": "MIT",
"dependencies": { "dependencies": {
"@babel/runtime": "^7.23.2" "@babel/runtime": "^7.23.2"
} }
@@ -8120,20 +8125,20 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/livekit-client": { "node_modules/livekit-client": {
"version": "2.21.0", "version": "2.20.0",
"resolved": "https://registry.npmjs.org/livekit-client/-/livekit-client-2.21.0.tgz", "resolved": "https://registry.npmjs.org/livekit-client/-/livekit-client-2.20.0.tgz",
"integrity": "sha512-RBUhPkV/sl1nzl8lokVlK5uATPwn0AlsudCBZXissw/kDl9yz8ac4pNJ43iPpMVoHOeBYH/BZ3vUC1adqa/zFQ==", "integrity": "sha512-RIJcpvBmOmwz3jTj3rmdY6Dzr55HrhcaJjMgY+HSmoEM+yIRyA40m7r8UKv0hnZWM3z/AYhP1q8C8ciz5UWFKQ==",
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"@livekit/mutex": "1.1.1", "@livekit/mutex": "1.1.1",
"@livekit/protocol": "1.50.4", "@livekit/protocol": "1.46.6",
"events": "^3.3.0", "events": "^3.3.0",
"jose": "^6.1.0", "jose": "^6.1.0",
"loglevel": "^1.9.2", "loglevel": "^1.9.2",
"sdp-transform": "^2.15.0", "sdp-transform": "^2.15.0",
"tslib": "2.8.1", "tslib": "2.8.1",
"typed-emitter": "^2.1.0", "typed-emitter": "^2.1.0",
"webrtc-adapter": "9.0.6" "webrtc-adapter": "9.0.5"
}, },
"peerDependencies": { "peerDependencies": {
"@types/dom-mediacapture-record": "^1" "@types/dom-mediacapture-record": "^1"
@@ -9131,21 +9136,19 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/posthog-js": { "node_modules/posthog-js": {
"version": "1.414.0", "version": "1.395.0",
"resolved": "https://registry.npmjs.org/posthog-js/-/posthog-js-1.414.0.tgz", "resolved": "https://registry.npmjs.org/posthog-js/-/posthog-js-1.395.0.tgz",
"integrity": "sha512-dtZd4asdskr8lNyltAEX6zyn48uO1pO0EMvx6AXJU65PFhu6yn2LPbKtQcyLysjcN57FJPNT9QYL6St5SBJHqw==", "integrity": "sha512-5iTb00CGt2eQUUiBQysQiX89RAbCN6wK2sDNzvs9zv0alaY8mJ0ZySrUD3LQ+XyLhgM5pCpacBuUwChqiYDLDw==",
"license": "(Apache-2.0 AND MIT)", "license": "SEE LICENSE IN LICENSE",
"dependencies": { "dependencies": {
"@posthog/browser-common": "^0.4.0", "@posthog/core": "^1.38.0",
"@posthog/core": "^1.46.9", "@posthog/types": "^1.391.1",
"@posthog/types": "^1.402.2", "core-js": "^3.38.1",
"core-js": "^3.49.0", "dompurify": "^3.3.2",
"dompurify": "^3.4.12",
"fflate": "^0.4.8", "fflate": "^0.4.8",
"preact": "^10.29.3", "preact": "^10.29.2",
"query-selector-shadow-dom": "^1.0.1", "query-selector-shadow-dom": "^1.0.1",
"web-vitals": "^5.3.0", "web-vitals": "^5.3.0"
"web-vitals-soft-navs": "npm:web-vitals@6.0.0"
} }
}, },
"node_modules/powershell-utils": { "node_modules/powershell-utils": {
@@ -9435,9 +9438,9 @@
} }
}, },
"node_modules/react-i18next": { "node_modules/react-i18next": {
"version": "17.0.10", "version": "17.0.8",
"resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-17.0.10.tgz", "resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-17.0.8.tgz",
"integrity": "sha512-XneHftyYA774MJkkccSkZ5oKrUpCnXIPmxio3wemqrVzCRLWiGXOMbIzObrer03fNDEnm8g8R5yYls4HcE+esg==", "integrity": "sha512-0ooKbGLU8JXhe1zwpQUWIeXSgLPOfwJmgheWRIUpcoA0CpyabpGhayjdG+/eA5esC1AQ8h2jWpXjJfzQzeDOCw==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@babel/runtime": "^7.29.2", "@babel/runtime": "^7.29.2",
@@ -9447,7 +9450,7 @@
"peerDependencies": { "peerDependencies": {
"i18next": ">= 26.2.0", "i18next": ">= 26.2.0",
"react": ">= 16.8.0", "react": ">= 16.8.0",
"typescript": "^5 || ^6 || ^7" "typescript": "^5 || ^6"
}, },
"peerDependenciesMeta": { "peerDependenciesMeta": {
"react-dom": { "react-dom": {
@@ -11657,17 +11660,10 @@
"integrity": "sha512-q6LWsLatGYZp5VGBIOvbTj6JBV2nOmC8KvWztXBmwJcfFAzhwKwbOxhUH306XY3CcaZDUlSmSuNPBsCn0bFu+g==", "integrity": "sha512-q6LWsLatGYZp5VGBIOvbTj6JBV2nOmC8KvWztXBmwJcfFAzhwKwbOxhUH306XY3CcaZDUlSmSuNPBsCn0bFu+g==",
"license": "Apache-2.0" "license": "Apache-2.0"
}, },
"node_modules/web-vitals-soft-navs": {
"name": "web-vitals",
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/web-vitals/-/web-vitals-6.0.0.tgz",
"integrity": "sha512-Guaibvy/+uNtL6Bsu4jmMJGzuSl91oeRH5iO9pPRbYftnFUr3yqT1TUNX/OE4o9HexuEMU3Kb/Wg7iKhlffZUA==",
"license": "Apache-2.0"
},
"node_modules/webrtc-adapter": { "node_modules/webrtc-adapter": {
"version": "9.0.6", "version": "9.0.5",
"resolved": "https://registry.npmjs.org/webrtc-adapter/-/webrtc-adapter-9.0.6.tgz", "resolved": "https://registry.npmjs.org/webrtc-adapter/-/webrtc-adapter-9.0.5.tgz",
"integrity": "sha512-CHbl2ZQbxx164IgWRgzJno4hWtM4tFbRam1QfI3Yxhs3w/DvqluVxVWeXs3oL5/fbGkSNLKo0Ty5MgUWceNhog==", "integrity": "sha512-U9vjByy/sK2OMXu5mmfuZFKTMIUQe34c0JXRO+oDrxJTsntdYT2iIFwYMOV7HhMTuktcZLGf2W1N/OcSf9ssWg==",
"license": "BSD-3-Clause", "license": "BSD-3-Clause",
"dependencies": { "dependencies": {
"sdp": "^3.2.0" "sdp": "^3.2.0"
+14 -13
View File
@@ -1,7 +1,7 @@
{ {
"name": "meet", "name": "meet",
"private": true, "private": true,
"version": "1.30.0", "version": "1.24.0",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "panda codegen && vite", "dev": "panda codegen && vite",
@@ -15,33 +15,34 @@
"check": "prettier --check ./src" "check": "prettier --check ./src"
}, },
"dependencies": { "dependencies": {
"@fontsource-variable/atkinson-hyperlegible-next": "5.3.0", "@fontsource-variable/atkinson-hyperlegible-next": "5.2.6",
"@fontsource-variable/lexend": "5.3.0", "@fontsource-variable/lexend": "5.2.11",
"@fontsource/opendyslexic": "5.3.0", "@fontsource/opendyslexic": "5.2.5",
"@libreaudio/la-call": "0.1.4", "@libreaudio/la-call": "0.1.4",
"@livekit/components-react": "2.9.23", "@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",
"@mediapipe/tasks-vision": "0.10.14", "@mediapipe/tasks-vision": "0.10.35",
"@pandacss/preset-panda": "1.12.0", "@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.101.4", "@tanstack/react-query": "5.101.1",
"@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",
"hoofd": "1.7.3", "hoofd": "1.7.3",
"humanize-duration": "3.33.2", "humanize-duration": "3.33.2",
"i18next": "26.3.6", "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.3", "i18next-resources-to-backend": "1.2.1",
"livekit-client": "2.21.0", "livekit-client": "2.20.0",
"posthog-js": "1.414.0", "posthog-js": "1.395.0",
"react": "18.3.1", "react": "18.3.1",
"react-aria": "3.50.0", "react-aria": "3.50.0",
"react-aria-components": "1.19.0", "react-aria-components": "1.19.0",
"react-dom": "18.3.1", "react-dom": "18.3.1",
"react-i18next": "17.0.10", "react-i18next": "17.0.8",
"react-stately": "3.48.0", "react-stately": "3.48.0",
"use-sound": "5.0.0", "use-sound": "5.0.0",
"valtio": "2.3.2", "valtio": "2.3.2",
+13 -24
View File
@@ -2,31 +2,24 @@ import { Button } from '@/primitives'
import { useEffect, useRef, useState } from 'react' import { useEffect, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { useMediaDeviceSelect } from '@livekit/components-react' import { useMediaDeviceSelect } from '@livekit/components-react'
import { reportError } from '@/features/analytics/telemetry'
import { canTestAudioOutput } from '@/features/rooms/utils/canTestAudioOutput'
export const SoundTester = () => { export const SoundTester = () => {
const { t } = useTranslation('settings') const { t } = useTranslation('settings')
const [isPlaying, setIsPlaying] = useState(false) const [isPlaying, setIsPlaying] = useState(false)
const audioRef = useRef<HTMLAudioElement>(null) const audioRef = useRef<HTMLAudioElement>(null)
const { devices, activeDeviceId } = useMediaDeviceSelect({ const { activeDeviceId } = useMediaDeviceSelect({ kind: 'audiooutput' })
kind: 'audiooutput',
})
useEffect(() => { useEffect(() => {
if (!canTestAudioOutput() || !activeDeviceId) return const updateActiveId = async (deviceId: string) => {
if (!devices.some((device) => device.deviceId === activeDeviceId)) return try {
audioRef.current?.setSinkId(activeDeviceId).catch((error) => { await audioRef?.current?.setSinkId(deviceId)
if (error instanceof DOMException && error.name === 'NotFoundError') { } catch (error) {
return console.error(`Error setting sinkId: ${error}`)
} }
reportError( }
'device_switch_failure', updateActiveId(activeDeviceId)
new Error(`Error setting sinkId: ${error}`) }, [activeDeviceId])
)
})
}, [devices, activeDeviceId])
// prevent pausing the sound // prevent pausing the sound
navigator.mediaSession.setActionHandler('pause', function () {}) navigator.mediaSession.setActionHandler('pause', function () {})
@@ -35,13 +28,9 @@ export const SoundTester = () => {
<> <>
<Button <Button
variant="secondaryText" variant="secondaryText"
onPress={async () => { onPress={() => {
try { audioRef?.current?.play()
await audioRef?.current?.play() setIsPlaying(true)
setIsPlaying(true)
} catch {
setIsPlaying(false)
}
}} }}
size="sm" size="sm"
isDisabled={isPlaying} isDisabled={isPlaying}
@@ -55,7 +44,7 @@ export const SoundTester = () => {
{/* eslint-disable jsx-a11y/media-has-caption */} {/* eslint-disable jsx-a11y/media-has-caption */}
<audio <audio
ref={audioRef} ref={audioRef}
src="/sounds/uprise.mp3" src="sounds/uprise.mp3"
onEnded={() => setIsPlaying(false)} onEnded={() => setIsPlaying(false)}
/> />
</> </>
@@ -1,30 +0,0 @@
import type { CaptureResult } from 'posthog-js'
const IGNORED_EXCEPTION_PATTERNS = [
/ResizeObserver loop (completed with undelivered notifications|limit exceeded)/,
// livekit-client leaks the raw WebSocket error Event as an unhandled
// rejection when the signal ws errors after connect (Firefox-heavy,
// coincides with signal reconnects). Carries zero diagnostic content —
// the close reason is already logged by the SDK.
// See: https://github.com/livekit/client-sdk-js/issues/2062
/^Event captured as exception with keys: isTrusted$/,
]
const shouldIgnoreException = (value: unknown): boolean =>
typeof value === 'string' &&
IGNORED_EXCEPTION_PATTERNS.some((pattern) => pattern.test(value))
export const filterExceptions = (
event: CaptureResult | null
): CaptureResult | null => {
if (event?.event !== '$exception') return event
const exceptionList = event.properties?.['$exception_list']
const values: unknown[] = Array.isArray(exceptionList)
? exceptionList.map((exception) => exception?.value)
: []
values.push(event.properties?.['$exception_message'])
return values.some(shouldIgnoreException) ? null : event
}
@@ -1,8 +1,15 @@
import { useEffect } from 'react' import { useEffect } from 'react'
import { useLocation } from 'wouter'
import { type PostHog } from 'posthog-js'
import { type ApiUser } from '@/features/auth/api/ApiUser' import { type ApiUser } from '@/features/auth/api/ApiUser'
import { useUser } from '@/features/auth/api/useUser' import { useUser } from '@/features/auth/api/useUser'
import { getPosthog } from '../utils'
import { filterExceptions } from '../exceptionFilters' let posthog: PostHog | null = null
const getPosthog = async () => {
if (!posthog) posthog = (await import('posthog-js')).default
return posthog
}
export const startAnalyticsSession = (data: ApiUser) => { export const startAnalyticsSession = (data: ApiUser) => {
getPosthog().then((ph) => { getPosthog().then((ph) => {
@@ -31,6 +38,7 @@ export const useAnalytics = ({
flags_api_host, flags_api_host,
isDisabled, isDisabled,
}: useAnalyticsProps) => { }: useAnalyticsProps) => {
const [location] = useLocation()
const { user } = useUser() const { user } = useUser()
useEffect(() => { useEffect(() => {
@@ -41,14 +49,6 @@ export const useAnalytics = ({
api_host: host, api_host: host,
flags_api_host: flags_api_host, flags_api_host: flags_api_host,
person_profiles: 'always', person_profiles: 'always',
capture_pageview: 'history_change',
capture_pageleave: true,
capture_exceptions: {
capture_unhandled_errors: true,
capture_unhandled_rejections: true,
capture_console_errors: true,
},
before_send: filterExceptions,
}) })
}) })
}, [id, host, flags_api_host, isDisabled]) }, [id, host, flags_api_host, isDisabled])
@@ -58,5 +58,12 @@ export const useAnalytics = ({
startAnalyticsSession(user) startAnalyticsSession(user)
}, [user]) }, [user])
// From PostHog tutorial on PageView tracking in a Single Page Application (SPA) context.
useEffect(() => {
getPosthog().then((ph) => {
ph.capture('$pageview')
})
}, [location])
return null return null
} }
@@ -1,151 +0,0 @@
import { getPosthog } from './utils'
export const captureEvent = (
event: string,
props?: Record<string, unknown>
) => {
void getPosthog()
.then((ph) => {
ph.capture(event, props)
})
.catch(() => {
/* telemetry must never break the app */
})
if (import.meta.env.DEV) {
console.warn(`[telemetry] ${event}`, props)
}
}
export type LogCode =
// media
| 'join_preview_failure'
| 'room_media_failure'
| 'livekit_room_error'
| 'device_switch_failure'
| 'permission_poll_failure'
| 'media_devices_error_event'
// non-media families
| 'participant_mute_api_failure'
| 'permissions_api_failure'
| 'effects_processor_failure'
| 'clipboard_failure'
| 'fullscreen_failure'
| 'publish_sources_failure'
| 'disconnect_failure'
| 'generic_failure'
export const reportError = (
logCode: LogCode,
error: unknown,
extraInfo: Record<string, unknown> = {}
): void => {
const e = error instanceof Error ? error : new Error(String(error))
void getPosthog()
.then((ph) => {
ph.captureException(e, {
log_code: logCode,
error_name: e.name,
error_message: e.message,
...extraInfo,
})
})
.catch(() => {})
if (import.meta.env.DEV) {
console.warn(`[${logCode}]`, e, extraInfo)
}
}
export interface DeviceSnapshot {
cam_count: number
mic_count: number
out_count: number
labels_visible: boolean
saved_cam_present: boolean | null
saved_mic_present: boolean | null
saved_video_device_id_set: boolean
saved_audio_device_id_set: boolean
audio_enabled: boolean | null
video_enabled: boolean | null
cam_permission: PermissionState | 'unknown'
mic_permission: PermissionState | 'unknown'
}
/** Reads the persisted LiveKit user choices without importing the store. */
const readPersistedChoices = (): {
videoDeviceId?: string
audioDeviceId?: string
videoEnabled?: boolean
audioEnabled?: boolean
} => {
try {
return JSON.parse(localStorage.getItem('lk-user-choices') ?? '{}')
} catch {
return {}
}
}
const queryPermission = async (
name: 'camera' | 'microphone'
): Promise<PermissionState | 'unknown'> => {
try {
const status = await navigator.permissions.query({
name: name as PermissionName,
})
return status.state
} catch {
return 'unknown'
}
}
export const deviceSnapshot = async (): Promise<DeviceSnapshot> => {
const choices = readPersistedChoices()
let devices: MediaDeviceInfo[] = []
try {
devices = await navigator.mediaDevices.enumerateDevices()
} catch {
/* snapshot stays partial */
}
const ofKind = (k: MediaDeviceKind) => devices.filter((d) => d.kind === k)
const present = (k: MediaDeviceKind, id?: string) =>
id ? ofKind(k).some((d) => d.deviceId === id) : null
const [cam_permission, mic_permission] = await Promise.all([
queryPermission('camera'),
queryPermission('microphone'),
])
return {
cam_count: ofKind('videoinput').length,
mic_count: ofKind('audioinput').length,
out_count: ofKind('audiooutput').length,
labels_visible: devices.some((d) => !!d.label),
saved_cam_present: present('videoinput', choices.videoDeviceId),
saved_mic_present: present('audioinput', choices.audioDeviceId),
saved_video_device_id_set: !!choices.videoDeviceId,
saved_audio_device_id_set: !!choices.audioDeviceId,
audio_enabled: choices.audioEnabled ?? null,
video_enabled: choices.videoEnabled ?? null,
cam_permission,
mic_permission,
}
}
export const captureMediaEvent = async (
event:
| 'media-device-error'
| 'media-acquisition'
| 'media-device-topology'
| 'media-device-success'
| 'device-not-found'
| 'device-in-use'
| 'permissions-denied'
| 'screen-share-permission-denied'
| 'silent-mic-detected'
| 'silent-mic-analyser-unavailable'
| 'silent-mic-recovered'
| 'visit-room'
| 'connection-event',
props: Record<string, unknown>
) => {
captureEvent(event, { ...props, ...(await deviceSnapshot()) })
}
@@ -1,8 +0,0 @@
import type { PostHog } from 'posthog-js'
let posthog: PostHog | null = null
export const getPosthog = async () => {
if (!posthog) posthog = (await import('posthog-js')).default
return posthog
}
@@ -6,8 +6,6 @@ import { queryClient } from '@/api/queryClient'
import { updateUserPreferences } from './updateUserPreferences' import { updateUserPreferences } from './updateUserPreferences'
import { convertToBackendLanguage } from '@/utils/languages' import { convertToBackendLanguage } from '@/utils/languages'
import { useUser } from './useUser' import { useUser } from './useUser'
import { ApiError } from '@/api/ApiError.ts'
import { reportError } from '@/features/analytics/telemetry'
/** /**
* Hook that synchronizes user browser preferences (language, timezone) with backend user settings. * Hook that synchronizes user browser preferences (language, timezone) with backend user settings.
@@ -44,11 +42,6 @@ export const useSyncUserPreferencesWithBackend = () => {
} }
} }
syncBrowserPreferencesToBackend().catch((error) => { syncBrowserPreferencesToBackend()
if (error instanceof ApiError && error.statusCode === 401) return
reportError('generic_failure', error, {
context: '[useSyncUserPreferencesWithBackend] Failed to sync:',
})
})
}, [i18n.language, isLoggedIn, user, mutateAsync]) }, [i18n.language, isLoggedIn, user, mutateAsync])
} }
@@ -1,6 +1,6 @@
import { ChatRow } from '@/stores/chat' import { ChatRow } from '@/stores/chat'
import React, { useMemo } from 'react' import React, { useMemo } from 'react'
import { formatChatMessageLinks } from '../utils' import { formatChatMessageLinks } from '@livekit/components-react'
import { css } from '@/styled-system/css' import { css } from '@/styled-system/css'
import { Text } from '@/primitives' import { Text } from '@/primitives'
@@ -23,7 +23,7 @@ export const ChatProvider = () => {
resetChatStore() resetChatStore()
}, []) }, [])
// Trigger the message notification (temporary) // Tigger the message notification (temporary)
useEffect(() => { useEffect(() => {
// TEMPORARY: This is a brittle workaround that relies on message count tracking // TEMPORARY: This is a brittle workaround that relies on message count tracking
// due to recent LiveKit useChat changes breaking the previous implementation // due to recent LiveKit useChat changes breaking the previous implementation
@@ -21,9 +21,7 @@ const StyledContainer = styled('div', {
}) })
export const ChatTextArea = () => { export const ChatTextArea = () => {
const { isSending, send, textAreaValue } = useSnapshot(chatStore, { const { isSending, send, textAreaValue } = useSnapshot(chatStore)
sync: true,
})
const { t } = useTranslation('rooms', { keyPrefix: 'controls.chat.input' }) const { t } = useTranslation('rooms', { keyPrefix: 'controls.chat.input' })
@@ -51,7 +49,7 @@ export const ChatTextArea = () => {
const isDisabled = !textAreaValue.trim() || isSending const isDisabled = !textAreaValue.trim() || isSending
const onKeyDown = async (e: React.KeyboardEvent<HTMLTextAreaElement>) => { const onKeyDown = async (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (e.key !== 'Escape') e.stopPropagation() e.stopPropagation()
if (e.key !== 'Enter' || (e.key === 'Enter' && e.shiftKey) || isDisabled) if (e.key !== 'Enter' || (e.key === 'Enter' && e.shiftKey) || isDisabled)
return return
e.preventDefault() e.preventDefault()
-32
View File
@@ -1,32 +0,0 @@
import { tokenize, createDefaultGrammar } from '@livekit/components-core'
import { ReactNode } from 'react'
const defaultGrammar = Object.freeze(createDefaultGrammar())
export function formatChatMessageLinks(message: string): ReactNode {
const trimmedMessage = message.replace(/^[\r\n]+|[\r\n]+$/g, '')
return tokenize(trimmedMessage, defaultGrammar).map((tok, i) => {
if (typeof tok === `string`) {
return tok
} else {
const content = tok.content.toString()
const href =
tok.type === `url`
? /^http(s?):\/\//.test(content)
? content
: `https://${content}`
: `mailto:${content}`
return (
<a
className="lk-chat-link"
key={i}
href={href}
target="_blank"
rel="noreferrer"
>
{content}
</a>
)
}
})
}
@@ -1,595 +0,0 @@
import { CSSProperties, useState } from 'react'
import { useConnectionState, useRoomContext } from '@livekit/components-react'
import { ConnectionState } from 'livekit-client'
import { StatsSnapshot, TrackRow, useWebRTCStats } from './useWebRTCStats'
import { readRoomConfig } from './roomConfig'
import {
forceTransport,
releaseForcedTransport,
Scenario,
SCENARIOS,
setDownlinkCap,
setUplinkCap,
stepBitrate,
TransportMode,
transportModeFromRoute,
} from './simulation'
const SANS =
"-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif"
const MONO = 'ui-monospace, SFMono-Regular, Menlo, Consolas, monospace'
const COLOR = {
text: '#e8e8e8',
muted: '#9a9a9a',
faint: '#6f6f6f',
hairline: '#2c2d31',
border: '#3d3e44',
surface: '#1b1c1e',
chartBg: '#141517',
down: '#f6821f',
up: '#5a9cf8',
ok: '#10b981',
bad: '#f05a4a',
busy: '#e5a13c',
}
const stateColor = (state: ConnectionState): string => {
switch (state) {
case ConnectionState.Connected:
return COLOR.ok
case ConnectionState.Reconnecting:
case ConnectionState.SignalReconnecting:
return COLOR.busy
case ConnectionState.Connecting:
return COLOR.up
case ConnectionState.Disconnected:
return COLOR.bad
}
}
const styles: Record<string, CSSProperties> = {
toggle: {
position: 'fixed',
bottom: 12,
right: 12,
zIndex: 9999,
fontFamily: SANS,
fontSize: 12,
lineHeight: 1,
padding: '8px 12px',
borderRadius: 999,
borderWidth: 1,
borderStyle: 'solid',
borderColor: COLOR.border,
background: COLOR.surface,
color: COLOR.text,
cursor: 'pointer',
boxShadow: '0 2px 10px #0006',
},
panel: {
position: 'fixed',
bottom: 12,
right: 12,
zIndex: 9999,
width: 460,
maxWidth: 'calc(100vw - 24px)',
maxHeight: 'calc(100vh - 24px)',
overflowY: 'auto',
fontFamily: SANS,
fontSize: 11,
lineHeight: 1.5,
color: COLOR.text,
background: COLOR.surface,
borderWidth: 1,
borderStyle: 'solid',
borderColor: COLOR.border,
borderRadius: 10,
padding: 14,
boxShadow: '0 10px 34px #00000080',
},
sectionTitle: {
margin: '14px 0 5px',
color: COLOR.faint,
textTransform: 'uppercase',
letterSpacing: 1.2,
fontSize: 9,
fontWeight: 600,
display: 'flex',
justifyContent: 'space-between',
},
metricsLine: {
display: 'flex',
alignItems: 'center',
gap: 16,
margin: '0 0 10px',
paddingBottom: 10,
borderBottom: `1px solid ${COLOR.hairline}`,
fontVariantNumeric: 'tabular-nums',
},
legend: {
display: 'flex',
gap: 12,
marginTop: 3,
color: COLOR.faint,
fontSize: 10,
},
list: {
display: 'flex',
flexDirection: 'column',
maxHeight: '400px',
overflowY: 'auto',
},
configList: { display: 'flex', flexWrap: 'wrap' },
configRow: {
display: 'flex',
gap: 6,
flex: '0 0 50%',
minWidth: 0,
boxSizing: 'border-box',
padding: '1px 8px 1px 0',
},
configLabel: {
color: COLOR.muted,
flex: '0 0 45%',
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
},
configValue: {
flex: 1,
minWidth: 0,
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
},
trackRow: {
display: 'flex',
gap: 8,
padding: '3px 0',
borderBottom: `1px solid ${COLOR.hairline}`,
alignItems: 'baseline',
},
trackLabel: {
flex: '0 0 34%',
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
},
trackCodec: {
flex: '0 0 11%',
fontFamily: MONO,
fontSize: 10,
color: COLOR.muted,
},
trackKbps: {
flex: '0 0 9%',
textAlign: 'right',
fontVariantNumeric: 'tabular-nums',
},
trackDetail: {
flex: 1,
minWidth: 0,
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
},
mono: { fontFamily: MONO, fontSize: 10, color: COLOR.muted },
button: {
fontFamily: SANS,
fontSize: 11,
padding: '3px 9px',
borderRadius: 5,
borderWidth: 1,
borderStyle: 'solid',
borderColor: COLOR.border,
background: '#232428',
color: COLOR.text,
cursor: 'pointer',
},
buttonActive: { borderColor: COLOR.down, color: COLOR.down },
buttonDisabled: { color: COLOR.faint, cursor: 'not-allowed' },
row: { display: 'flex', alignItems: 'center', gap: 6, flexWrap: 'wrap' },
stepperValue: {
minWidth: 92,
textAlign: 'center',
borderWidth: 1,
borderStyle: 'solid',
borderColor: COLOR.hairline,
borderRadius: 4,
padding: '2px 6px',
background: COLOR.chartBg,
fontVariantNumeric: 'tabular-nums',
},
close: {
background: 'none',
border: 'none',
color: COLOR.faint,
cursor: 'pointer',
fontSize: 14,
},
}
const Sparkline = ({ history }: { history: StatsSnapshot[] }) => {
const width = 430
const height = 54
const max = Math.max(...history.map((s) => Math.max(s.upKbps, s.downKbps)), 1)
const points = (pick: (s: StatsSnapshot) => number) =>
history
.map(
(s, i) =>
`${((i / Math.max(history.length - 1, 1)) * width).toFixed(1)},${(
height -
(pick(s) / max) * (height - 6) -
3
).toFixed(1)}`
)
.join(' ')
return (
<>
<svg
width={width}
height={height}
role="img"
aria-label="Bandwidth over the last minute"
style={{
display: 'block',
background: COLOR.chartBg,
borderRadius: 6,
border: `1px solid ${COLOR.hairline}`,
}}
>
{history.length >= 2 && (
<>
<polyline
points={points((s) => s.downKbps)}
fill="none"
stroke={COLOR.down}
strokeWidth="1.5"
/>
<polyline
points={points((s) => s.upKbps)}
fill="none"
stroke={COLOR.up}
strokeWidth="1.5"
/>
</>
)}
</svg>
{/* Legend outside the plot: text over the lines was unreadable. */}
<div style={styles.legend}>
<span>
<span style={{ color: COLOR.down }}></span> down
</span>
<span>
<span style={{ color: COLOR.up }}></span> up
</span>
<span>last 60s · max {Math.round(max)} kbps</span>
</div>
</>
)
}
const CapStepper = ({
label,
hint,
valueKbps,
onChange,
}: {
label: string
hint: string
valueKbps: number | null
onChange: (kbps: number | null) => void
}) => (
<div style={styles.row}>
<span style={{ flex: '0 0 70px' }}>{label}</span>
<button
type="button"
style={styles.button}
aria-label={`Decrease ${label} bandwidth`}
onClick={() => onChange(stepBitrate(valueKbps, 'decrease'))}
>
</button>
<span
style={{
...styles.stepperValue,
...(valueKbps !== null ? { color: COLOR.down } : {}),
}}
>
{valueKbps === null ? 'unlimited' : `${valueKbps} kbps`}
</span>
<button
type="button"
style={styles.button}
aria-label={`Increase ${label} bandwidth`}
onClick={() => onChange(stepBitrate(valueKbps, 'increase'))}
>
+
</button>
<span style={styles.mono}>{hint}</span>
</div>
)
const TrackList = ({ title, rows }: { title: string; rows: TrackRow[] }) => (
<>
<div style={styles.sectionTitle}>
<span>
{title} ({rows.length})
</span>
<span>
{Math.round(rows.reduce((sum, t) => sum + t.kbps, 0))} kbps media
</span>
</div>
<div style={styles.list}>
{rows.map((track) => (
<div key={track.key} style={styles.trackRow}>
<span style={styles.trackLabel}>{track.label}</span>
<span style={styles.trackCodec}>{track.codec ?? ''}</span>
<span style={styles.trackKbps}>{Math.round(track.kbps)}</span>
<span style={styles.trackDetail}>
{[
track.res,
track.fps !== undefined && `${Math.round(track.fps)}fps`,
]
.filter(Boolean)
.join(' · ')}
</span>
</div>
))}
{rows.length === 0 && <span style={{ color: COLOR.faint }}>none</span>}
</div>
</>
)
const MeetDevtools = () => {
const room = useRoomContext()
const connState = useConnectionState(room)
const [open, setOpen] = useState(false)
const [firedScenario, setFiredScenario] = useState<string>()
const [uplinkCap, setUplinkCapState] = useState<number | null>(null)
const [downlinkCap, setDownlinkCapState] = useState<number | null>(null)
const { snapshot, history } = useWebRTCStats(room, open)
const transportMode: TransportMode = transportModeFromRoute(snapshot?.route)
const fireScenario = (scenario: Scenario) => {
setFiredScenario(scenario.id)
scenario.run(room).catch((e) => {
console.warn('[MeetDevtools] simulateScenario failed', e)
})
window.setTimeout(
() =>
setFiredScenario((current) =>
current === scenario.id ? undefined : current
),
1200
)
}
if (!open) {
return (
<button
type="button"
style={styles.toggle}
onClick={() => setOpen(true)}
aria-label="Open WebRTC devtools"
>
<span style={{ color: stateColor(connState) }}></span> rtc
</button>
)
}
const config = readRoomConfig(room)
const published = snapshot?.tracks.filter((t) => t.dir === 'up') ?? []
const subscribed = snapshot?.tracks.filter((t) => t.dir === 'down') ?? []
const turnProtocols = snapshot?.turnProtocols ?? []
const transports: Array<{
mode: TransportMode
label: string
requires?: string
title: string
}> = [
{
mode: 'auto',
label: 'auto (udp)',
title:
'clears the server-cached transport preference and relay-only policy, then full reconnect',
},
{
mode: 'tcp',
label: 'tcp',
title: 'force-tcp — server prefers TCP candidates (ICE/TCP)',
},
{
mode: 'turn-udp',
label: 'turn:udp',
requires: 'udp',
title:
'client-side: iceTransportPolicy relay + full reconnect (no server hook exists)',
},
{
mode: 'turn-tcp',
label: 'turn:tcp',
requires: 'tcp',
title: 'force-tcp — lands on TURN/TCP when it is the TCP path',
},
{
mode: 'turn-tls',
label: 'turn:tls',
requires: 'tls',
title: 'force-tls — server switches you to TURN over TLS',
},
]
return (
<section style={styles.panel} aria-label="WebRTC devtools">
<div style={styles.metricsLine}>
<span>
<span style={{ color: COLOR.down }}></span> {snapshot?.downKbps ?? 0}{' '}
kbps
</span>
<span>
<span style={{ color: COLOR.up }}></span> {snapshot?.upKbps ?? 0}{' '}
kbps
</span>
<span style={{ color: COLOR.muted }}>
rtt{' '}
<span style={{ color: COLOR.text }}>{snapshot?.rttMs ?? ''}</span> ms
</span>
<span style={{ color: COLOR.muted }}>
jitter{' '}
<span style={{ color: COLOR.text }}>{snapshot?.jitterMs ?? ''}</span>{' '}
ms
</span>
<span
style={{
color: stateColor(connState),
fontSize: 10,
marginLeft: 'auto',
}}
>
{connState}
</span>
<button
type="button"
style={styles.close}
onClick={() => setOpen(false)}
aria-label="Close WebRTC devtools"
>
</button>
</div>
<Sparkline history={history} />
<div style={styles.sectionTitle}>room configuration (live)</div>
<div style={styles.configList}>
{config.map((entry) => (
<div key={entry.label} style={styles.configRow}>
<span
style={{
color:
entry.on === undefined
? COLOR.faint
: entry.on
? COLOR.ok
: COLOR.bad,
}}
>
</span>
<span style={styles.configLabel}>{entry.label}</span>
<span style={styles.configValue} title={entry.value}>
{entry.value}
</span>
</div>
))}
</div>
<TrackList title="published ↑" rows={published} />
<TrackList title="subscribed ↓" rows={subscribed} />
<div style={styles.sectionTitle}>bandwidth</div>
<div style={{ ...styles.list, gap: 4 }}>
<CapStepper
label="uplink"
hint="encoder cap (setParameters)"
valueKbps={uplinkCap}
onChange={(kbps) => {
setUplinkCapState(kbps)
void setUplinkCap(room, kbps)
}}
/>
<CapStepper
label="downlink"
hint="SFU limit (subscriber-bandwidth)"
valueKbps={downlinkCap}
onChange={(kbps) => {
setDownlinkCapState(kbps)
void setDownlinkCap(room, kbps).catch((e) =>
console.warn('[MeetDevtools] downlink cap failed', e)
)
}}
/>
</div>
<div style={styles.sectionTitle}>transport</div>
<div style={styles.row}>
{transports.map(({ mode, label, requires, title }) => {
const available = !requires || turnProtocols.includes(requires)
const active = transportMode === mode
const clickable = available && !active
return (
<button
key={mode}
type="button"
aria-pressed={active}
disabled={!clickable}
title={available ? title : `${title} — not configured`}
style={{
...styles.button,
...(clickable || active ? {} : styles.buttonDisabled),
...(active ? styles.buttonActive : {}),
}}
onClick={() => {
if (!clickable) return
void (
mode === 'auto'
? releaseForcedTransport(room)
: forceTransport(
room,
mode as 'tcp' | 'turn-udp' | 'turn-tcp' | 'turn-tls'
)
).catch((e) =>
console.warn('[MeetDevtools] transport change failed', e)
)
}}
>
{requires && (
<span style={{ color: available ? COLOR.ok : COLOR.faint }}>
{' '}
</span>
)}
{label}
</button>
)
})}
<span style={styles.mono}>
route:{' '}
{snapshot?.route
? [
snapshot.route.protocol,
snapshot.route.type,
snapshot.route.relayProtocol &&
`relay:${snapshot.route.relayProtocol}`,
]
.filter(Boolean)
.join('·')
: ''}
</span>
</div>
<div style={styles.sectionTitle}>connection scenarios</div>
<div style={styles.row}>
{SCENARIOS.map((scenario) => (
<button
key={scenario.id}
type="button"
title={`${scenario.side}-side simulation — momentary, watch the state dot`}
style={{
...styles.button,
...(firedScenario === scenario.id ? styles.buttonActive : {}),
}}
onClick={() => fireScenario(scenario)}
>
{scenario.side === 'server' ? '☁ ' : ''}
{scenario.label}
</button>
))}
</div>
</section>
)
}
export default MeetDevtools
@@ -1,14 +0,0 @@
import { lazy, Suspense } from 'react'
const LazyPanel = import.meta.env.DEV
? lazy(() => import('./MeetDevtools'))
: null
export const MeetDevtools = () => {
if (!LazyPanel) return null
return (
<Suspense fallback={null}>
<LazyPanel />
</Suspense>
)
}
@@ -1,88 +0,0 @@
import { Room } from 'livekit-client'
export type ConfigEntry = {
label: string
value: string
/** true = feature actively on, false = off, undefined = informational */
on?: boolean
}
const formatBackupCodec = (backup: unknown): string => {
if (backup === undefined || backup === true) return 'auto'
if (backup === false) return 'off'
if (typeof backup === 'object' && backup !== null && 'codec' in backup) {
return String((backup as { codec: unknown }).codec)
}
return String(backup)
}
export const readRoomConfig = (room: Room): ConfigEntry[] => {
const options = room.options
const publish = options.publishDefaults
const adaptive = options.adaptiveStream
const entries: ConfigEntry[] = [
{
label: 'adaptiveStream',
value:
typeof adaptive === 'object'
? JSON.stringify(adaptive)
: String(!!adaptive),
on: !!adaptive,
},
{
label: 'dynacast',
value: String(!!options.dynacast),
on: !!options.dynacast,
},
{
label: 'e2ee',
value: String(room.isE2EEEnabled),
on: room.isE2EEEnabled,
},
{
label: 'videoCodec',
value: publish?.videoCodec ?? 'default',
},
{
label: 'backupCodec',
value: formatBackupCodec(publish?.backupCodec),
},
{
label: 'simulcast',
value: String(publish?.simulcast ?? true),
on: publish?.simulcast ?? true,
},
{
label: 'audio dtx',
value: String(publish?.dtx ?? true),
on: publish?.dtx ?? true,
},
{
label: 'audio red',
value: String(publish?.red ?? true),
on: publish?.red ?? true,
},
{
label: 'quality (local)',
value: room.localParticipant.connectionQuality,
},
]
const server = room.serverInfo
if (server) {
entries.push({
label: 'server',
value: [
server.version && `v${server.version}`,
server.region,
server.protocol !== undefined && `proto ${server.protocol}`,
server.edition !== undefined && `edition ${server.edition}`,
]
.filter(Boolean)
.join(' · '),
})
}
return entries
}
@@ -1,192 +0,0 @@
import { Room, Track } from 'livekit-client'
// Transitive dependency of livekit-client (pinned by it); used only to
// build the one signal request room.simulateScenario cannot express.
import { SimulateScenario } from '@livekit/protocol'
/** Shared bandwidth ladder for the /+ steppers; null = unlimited. */
export const BITRATE_LADDER_KBPS: Array<number | null> = [
null,
2000,
1000,
600,
300,
150,
]
export const stepBitrate = (
current: number | null,
direction: 'decrease' | 'increase'
): number | null => {
const index = BITRATE_LADDER_KBPS.indexOf(current)
const safeIndex = index === -1 ? 0 : index
const next =
direction === 'decrease'
? Math.min(safeIndex + 1, BITRATE_LADDER_KBPS.length - 1)
: Math.max(safeIndex - 1, 0)
return BITRATE_LADDER_KBPS[next]
}
const savedEncodings = new WeakMap<RTCRtpSender, Array<number | undefined>>()
export const setUplinkCap = async (
room: Room,
kbps: number | null
): Promise<void> => {
const senders: RTCRtpSender[] = []
room.localParticipant.trackPublications.forEach((pub) => {
const track = pub.track
if (track?.kind === Track.Kind.Video && track.sender) {
senders.push(track.sender)
}
})
for (const sender of senders) {
const params = sender.getParameters()
if (!params.encodings || params.encodings.length === 0) continue
if (kbps === null) {
const original = savedEncodings.get(sender)
params.encodings.forEach((encoding, i) => {
encoding.maxBitrate = original?.[i]
})
savedEncodings.delete(sender)
} else {
if (!savedEncodings.has(sender)) {
savedEncodings.set(
sender,
params.encodings.map((encoding) => encoding.maxBitrate)
)
}
const activeCount =
params.encodings.filter((encoding) => encoding.active !== false)
.length || 1
// Split the budget across active simulcast layers / SVC encoding.
const perEncoding = Math.max(
30_000,
Math.floor((kbps * 1000) / activeCount)
)
params.encodings.forEach((encoding) => {
encoding.maxBitrate = perEncoding
})
}
try {
await sender.setParameters(params)
} catch (e) {
console.warn('[MeetDevtools] setParameters failed', e)
}
}
}
export const setDownlinkCap = (
room: Room,
kbps: number | null
): Promise<void> =>
room.simulateScenario('subscriber-bandwidth', kbps === null ? 0 : kbps * 1000)
export type Scenario = {
id: string
label: string
/** where the simulation happens */
side: 'client' | 'server'
run: (room: Room) => Promise<void>
}
export const SCENARIOS: Scenario[] = [
{
id: 'resume',
label: 'reconnect (resume)',
side: 'client',
// Replays a signaling WebSocket loss; media keeps flowing, client resumes.
run: (room) => room.simulateScenario('signal-reconnect'),
},
{
id: 'resume-fail',
label: 'reconnect (resume fails)',
side: 'client',
// Same, but the next resume attempt fails → exercises the retry ladder.
run: (room) => room.simulateScenario('resume-reconnect'),
},
{
id: 'full-reconnect',
label: 'full reconnect',
side: 'client',
// Complete rejoin with brand-new peer connections.
run: (room) => room.simulateScenario('full-reconnect'),
},
{
id: 'migration',
label: 'server migration',
side: 'server',
run: (room) => room.simulateScenario('migration'),
},
{
id: 'node-failure',
label: 'SFU node failure',
side: 'server',
run: (room) => room.simulateScenario('node-failure'),
},
{
id: 'server-leave',
label: 'server disconnect',
side: 'server',
// Server-initiated leave: the closest thing to "you got kicked".
run: (room) => room.simulateScenario('server-leave'),
},
]
export type TransportMode =
| 'auto'
| 'tcp'
| 'turn-udp'
| 'turn-tcp'
| 'turn-tls'
const clearServerTransportPreference = (room: Room): Promise<void> =>
room.engine.client.sendSimulateScenario(
new SimulateScenario({
scenario: { case: 'switchCandidateProtocol', value: 0 },
})
)
const setRelayOnly = (room: Room, relay: boolean) => {
room.engine.rtcConfig = {
...room.engine.rtcConfig,
iceTransportPolicy: relay ? 'relay' : 'all',
}
}
const settle = () => new Promise((resolve) => setTimeout(resolve, 300))
export const forceTransport = async (
room: Room,
mode: 'tcp' | 'turn-udp' | 'turn-tcp' | 'turn-tls'
): Promise<void> => {
if (mode === 'turn-udp') {
await clearServerTransportPreference(room)
setRelayOnly(room, true)
await settle()
return room.simulateScenario('full-reconnect')
}
setRelayOnly(room, false)
return room.simulateScenario(mode === 'turn-tls' ? 'force-tls' : 'force-tcp')
}
export const releaseForcedTransport = async (room: Room): Promise<void> => {
setRelayOnly(room, false)
await clearServerTransportPreference(room)
await settle()
await room.simulateScenario('full-reconnect')
}
export const transportModeFromRoute = (route?: {
protocol?: string
relayProtocol?: string
}): TransportMode => {
// relayProtocol is the client→TURN leg; protocol alone means no relay.
if (route?.relayProtocol === 'tls') return 'turn-tls'
if (route?.relayProtocol === 'tcp') return 'turn-tcp'
if (route?.relayProtocol === 'udp') return 'turn-udp'
if (route?.protocol === 'tcp') return 'tcp'
return 'auto'
}
@@ -1,243 +0,0 @@
import { useEffect, useRef, useState } from 'react'
import { Room } from 'livekit-client'
export type TrackRow = {
key: string
dir: 'up' | 'down'
label: string
codec?: string
kbps: number
fps?: number
res?: string
}
export type StatsSnapshot = {
ts: number
/** wire totals from transport stats (includes headers, RTCP, FEC) */
upKbps: number
downKbps: number
rttMs?: number
/** worst inbound RTP jitter across subscribed tracks */
jitterMs?: number
availableOutKbps?: number
/** selected ICE route of the publisher transport (measured, not assumed) */
route?: { protocol?: string; type?: string; relayProtocol?: string }
/**
* relayProtocol values of gathered relay local candidates i.e. which
* clientTURN transports are actually configured (udp/tcp/tls). Empty
* when no TURN server is configured.
*/
turnProtocols: string[]
tracks: TrackRow[]
}
type StatDict = Record<string, unknown>
type Counters = Record<string, number>
const asNumber = (v: unknown): number | undefined =>
typeof v === 'number' && Number.isFinite(v) ? v : undefined
const asString = (v: unknown): string | undefined =>
typeof v === 'string' ? v : undefined
const shortCodec = (mimeType?: string) =>
mimeType ? mimeType.replace(/^(audio|video)\//, '') : undefined
const buildTrackLabels = (room: Room) => {
const labels = new Map<string, string>()
room.localParticipant.trackPublications.forEach((pub) => {
const id = pub.track?.mediaStreamTrack?.id
if (id) labels.set(id, `local ${pub.source}`)
})
room.remoteParticipants.forEach((participant) => {
// Keep rows scannable: participant names capped at 10 chars.
const rawName = participant.name || participant.identity
const name = rawName.length > 10 ? `${rawName.slice(0, 10)}.` : rawName
participant.trackPublications.forEach((pub) => {
const id = pub.track?.mediaStreamTrack?.id
if (id) labels.set(id, `${name} ${pub.source}`)
})
})
return labels
}
export const useWebRTCStats = (
room: Room,
enabled: boolean,
intervalMs = 1000
) => {
// Single source of truth: the snapshot is just the last history entry.
const [history, setHistory] = useState<StatsSnapshot[]>([])
const prevRef = useRef(new Map<string, Counters>())
useEffect(() => {
if (!enabled) return
let cancelled = false
const prev = prevRef.current
/** Per-stat counter deltas; returns 0 on the first sighting. */
const deltas = (key: string, now: Counters): Counters => {
const before = prev.get(key)
prev.set(key, now)
const out: Counters = {}
for (const [name, value] of Object.entries(now)) {
out[name] = before?.[name] !== undefined ? value - before[name] : 0
}
return out
}
const collect = async () => {
const reports: Array<{ pc: 'pub' | 'sub'; report: RTCStatsReport }> = []
try {
// Not public API — see file header.
const manager = room.engine?.pcManager
const pub = await manager?.publisher?.getStats()
if (pub) reports.push({ pc: 'pub', report: pub })
const sub = await manager?.subscriber?.getStats()
if (sub) reports.push({ pc: 'sub', report: sub })
} catch {
// Engine not ready or SDK internals changed; panel shows nothing.
}
if (cancelled || reports.length === 0) return
const labels = buildTrackLabels(room)
const tracks: TrackRow[] = []
let upKbps = 0
let downKbps = 0
let rttMs: number | undefined
let jitterMs: number | undefined
let availableOutKbps: number | undefined
let route: StatsSnapshot['route']
const turnProtocols = new Set<string>()
for (const { pc, report } of reports) {
const byId = new Map<string, StatDict>()
report.forEach((stat) => byId.set(stat.id as string, stat as StatDict))
report.forEach((raw) => {
const stat = raw as StatDict
const type = asString(stat.type)
const ts = asNumber(stat.timestamp) ?? Date.now()
const key = `${pc}:${asString(stat.id) ?? ''}`
if (
type === 'local-candidate' &&
asString(stat.candidateType) === 'relay'
) {
// Relay candidates are only gathered when a TURN server is
// configured and reachable; relayProtocol says how the client
// reaches it (udp/tcp/tls).
turnProtocols.add(
asString(stat.relayProtocol) ?? asString(stat.protocol) ?? 'udp'
)
}
if (type === 'transport') {
const d = deltas(key, {
sent: asNumber(stat.bytesSent) ?? 0,
received: asNumber(stat.bytesReceived) ?? 0,
ts,
})
if (d.ts > 0) {
upKbps += Math.max(0, (d.sent * 8) / d.ts)
downKbps += Math.max(0, (d.received * 8) / d.ts)
}
}
if (type === 'candidate-pair' && stat.nominated === true) {
const rtt = asNumber(stat.currentRoundTripTime)
if (rtt !== undefined) rttMs = Math.round(rtt * 1000)
const available = asNumber(stat.availableOutgoingBitrate)
if (available !== undefined && pc === 'pub') {
availableOutKbps = Math.round(available / 1000)
}
if (pc === 'pub') {
const local = byId.get(asString(stat.localCandidateId) ?? '')
route = {
protocol: asString(local?.protocol),
type: asString(local?.candidateType),
relayProtocol: asString(local?.relayProtocol),
}
}
}
if (type === 'outbound-rtp' || type === 'inbound-rtp') {
const isUp = type === 'outbound-rtp'
if (!isUp) {
const jitter = asNumber(stat.jitter)
if (jitter !== undefined) {
const ms = Math.round(jitter * 1000)
if (jitterMs === undefined || ms > jitterMs) jitterMs = ms
}
}
const d = deltas(key, {
bytes: asNumber(isUp ? stat.bytesSent : stat.bytesReceived) ?? 0,
ts,
})
const kbps = d.ts > 0 ? Math.max(0, (d.bytes * 8) / d.ts) : 0
// Resolve codec + source track.
const codecStat = byId.get(asString(stat.codecId) ?? '')
let msTrackId = asString(stat.trackIdentifier)
if (!msTrackId && isUp) {
const mediaSource = byId.get(asString(stat.mediaSourceId) ?? '')
msTrackId = asString(mediaSource?.trackIdentifier)
}
const rid = asString(stat.rid)
const baseLabel =
(msTrackId && labels.get(msTrackId)) ??
`${asString(stat.kind) ?? 'media'} ssrc ${asNumber(stat.ssrc) ?? '?'}`
const width = asNumber(stat.frameWidth)
const height = asNumber(stat.frameHeight)
tracks.push({
key,
dir: isUp ? 'up' : 'down',
label: rid ? `${baseLabel} [${rid}]` : baseLabel,
codec: shortCodec(asString(codecStat?.mimeType)),
kbps,
fps: asNumber(stat.framesPerSecond),
res: width && height ? `${width}x${height}` : undefined,
})
}
})
}
// Stable order (direction, then label): sorting by bitrate would
// reshuffle rows on every tick as kbps fluctuates.
tracks.sort((a, b) =>
a.dir === b.dir
? a.label.localeCompare(b.label)
: a.dir === 'up'
? -1
: 1
)
const next: StatsSnapshot = {
ts: Date.now(),
upKbps: Math.round(upKbps),
downKbps: Math.round(downKbps),
rttMs,
jitterMs,
availableOutKbps,
route,
turnProtocols: Array.from(turnProtocols).sort(),
tracks,
}
setHistory((h) => [...h.slice(-59), next])
}
void collect()
const id = window.setInterval(() => void collect(), intervalMs)
return () => {
cancelled = true
window.clearInterval(id)
}
}, [room, enabled, intervalMs])
return { snapshot: history[history.length - 1], history }
}
@@ -15,7 +15,6 @@ import { css } from '@/styled-system/css'
import { useConfig } from '@/api/useConfig' import { useConfig } from '@/api/useConfig'
import { LoginButton } from '@/components/LoginButton' import { LoginButton } from '@/components/LoginButton'
import { LoadingScreen } from '@/components/LoadingScreen' import { LoadingScreen } from '@/components/LoadingScreen'
import { captureEvent } from '@/features/analytics/telemetry'
const Columns = ({ children }: { children?: ReactNode }) => { const Columns = ({ children }: { children?: ReactNode }) => {
return ( return (
@@ -161,11 +160,7 @@ const Home = () => {
window.location.replace(data.external_home_url) window.location.replace(data.external_home_url)
} catch (error) { } catch (error) {
setRedirectFailed(true) setRedirectFailed(true)
captureEvent('external-home-unreachable', { console.error('Site is not reachable:', error)
error_name: error instanceof Error ? error.name : 'Unknown',
error_message:
error instanceof Error ? error.message : String(error),
})
} }
} }
} }
@@ -13,7 +13,7 @@ const controlBarRegion = cva({
mobile: { mobile: {
true: { true: {
justifyContent: 'center', justifyContent: 'center',
width: '100%', width: '330px',
}, },
}, },
}, },
@@ -1,5 +1,5 @@
import { useToast } from 'react-aria' import { useToast } from 'react-aria'
import { useMemo, 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'
import Source = Track.Source import Source = Track.Source
@@ -11,7 +11,6 @@ import { Div } from '@/primitives'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { StyledToastContainer } from './StyledToastContainer' import { StyledToastContainer } from './StyledToastContainer'
import { setPinnedTrack } from '@/stores/layout' import { setPinnedTrack } from '@/stores/layout'
import { useParticipantTracks } from '@livekit/components-react'
const ClickableToast = styled(RACButton, { const ClickableToast = styled(RACButton, {
base: { base: {
@@ -31,18 +30,14 @@ export function ToastJoined({ state, ...props }: Readonly<ToastProps>) {
) )
const participant = props.toast.content.participant const participant = props.toast.content.participant
const [cameraTrack] = useParticipantTracks(
[Source.Camera],
participant?.identity
)
const trackReference = useMemo(
() => cameraTrack ?? { participant, source: Source.Camera },
[cameraTrack, participant]
)
if (!participant) return if (!participant) return
const trackReference = {
participant,
publication: participant.getTrackPublication(Source.Camera),
source: Source.Camera,
}
return ( return (
<StyledToastContainer {...toastProps} ref={ref}> <StyledToastContainer {...toastProps} ref={ref}>
<ClickableToast <ClickableToast
@@ -6,7 +6,7 @@ import type { NotificationType } from '@/features/notifications/NotificationType
// fixme - handle dynamic audio output changes // fixme - handle dynamic audio output changes
export const useNotificationSound = () => { export const useNotificationSound = () => {
const notificationsSnap = useSnapshot(notificationsStore) const notificationsSnap = useSnapshot(notificationsStore)
const [play] = useSound('/sounds/notifications.mp3', { const [play] = useSound('./sounds/notifications.mp3', {
sprite: { sprite: {
participantJoined: [0, 1150], participantJoined: [0, 1150],
handRaised: [1400, 180], handRaised: [1400, 180],
@@ -4,7 +4,6 @@ import { NotificationDuration } from './NotificationDuration'
import type { Participant } from 'livekit-client' import type { Participant } from 'livekit-client'
import type { NotificationPayload } from './NotificationPayload' import type { NotificationPayload } from './NotificationPayload'
import type { RecordingMode } from '@/features/recording' import type { RecordingMode } from '@/features/recording'
import { reportError } from '@/features/analytics/telemetry'
export const notifyAutoMutedOnJoin = () => { export const notifyAutoMutedOnJoin = () => {
toastQueue.add( toastQueue.add(
@@ -56,9 +55,7 @@ export const decodeNotificationDataReceived = (
return parsed as NotificationPayload return parsed as NotificationPayload
} catch (error) { } catch (error) {
// Handle errors appropriately for your application // Handle errors appropriately for your application
reportError('generic_failure', error, { console.error('Failed to decode notification payload:', error)
context: 'Failed to decode notification payload:',
})
return return
} }
} }
@@ -1,9 +1,11 @@
import { Participant, Track } from 'livekit-client' import { Participant, Track } from 'livekit-client'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { useTrackMutedIndicator } from '@livekit/components-react' import { useTrackMutedIndicator } from '@livekit/components-react'
import { useMuteParticipant } from '@/features/rooms/api/muteParticipant'
import { useState } from 'react'
import { Button } from '@/primitives' import { Button } from '@/primitives'
import { RiMicLine, RiMicOffLine } from '@remixicon/react' import { RiMicLine, RiMicOffLine } from '@remixicon/react'
import { openMuteDialog } from '@/stores/muteDialog' import { MuteAlertDialog } from '@/features/rooms/livekit/components/MuteAlertDialog'
export const MuteButton = ({ participant }: { participant: Participant }) => { export const MuteButton = ({ participant }: { participant: Participant }) => {
const { t } = useTranslation('rooms', { keyPrefix: 'participantTileFocus' }) const { t } = useTranslation('rooms', { keyPrefix: 'participantTileFocus' })
@@ -13,18 +15,31 @@ export const MuteButton = ({ participant }: { participant: Participant }) => {
source: Track.Source.Microphone, source: Track.Source.Microphone,
}) })
const { muteParticipant } = useMuteParticipant()
const [isAlertOpen, setIsAlertOpen] = useState(false)
const name = participant.name || participant.identity const name = participant.name || participant.identity
return ( return (
<Button <>
isDisabled={isMuted} <Button
size={'sm'} isDisabled={isMuted}
variant={'primaryTextDark'} size={'sm'}
square variant={'primaryTextDark'}
onPress={() => openMuteDialog(participant)} square
tooltip={t('muteParticipant', { name })} onPress={() => setIsAlertOpen(true)}
> tooltip={t('muteParticipant', { name })}
{!isMuted ? <RiMicLine /> : <RiMicOffLine />} >
</Button> {!isMuted ? <RiMicLine /> : <RiMicOffLine />}
</Button>
<MuteAlertDialog
isOpen={isAlertOpen}
onSubmit={() =>
muteParticipant(participant).then(() => setIsAlertOpen(false))
}
onClose={() => setIsAlertOpen(false)}
name={name}
/>
</>
) )
} }
@@ -1,6 +1,5 @@
import type { Participant } from 'livekit-client' import type { Participant } from 'livekit-client'
import { useLowerHandParticipant } from './lowerHandParticipant' import { useLowerHandParticipant } from './lowerHandParticipant'
import { reportError } from '@/features/analytics/telemetry'
export const useLowerHandParticipants = () => { export const useLowerHandParticipants = () => {
const { lowerHandParticipant } = useLowerHandParticipant() const { lowerHandParticipant } = useLowerHandParticipant()
@@ -12,9 +11,7 @@ export const useLowerHandParticipants = () => {
) )
return Promise.all(promises) return Promise.all(promises)
} catch (error) { } catch (error) {
reportError('generic_failure', error, { console.error('An error occurred while lowering hands :', error)
context: 'An error occurred while lowering hands :',
})
throw new Error('An error occurred while lowering hands.', { throw new Error('An error occurred while lowering hands.', {
cause: error, cause: error,
}) })
@@ -1,7 +1,6 @@
import { fetchApi } from '@/api/fetchApi' import { fetchApi } from '@/api/fetchApi'
import { useRoomData } from '@/features/rooms/livekit/hooks/useRoomData' import { useRoomData } from '@/features/rooms/livekit/hooks/useRoomData'
import { AssignableParticipantRole } from '@/features/rooms/api/ApiRoom' import { AssignableParticipantRole } from '@/features/rooms/api/ApiRoom'
import { reportError } from '@/features/analytics/telemetry'
export const useParticipantRole = () => { export const useParticipantRole = () => {
const data = useRoomData() const data = useRoomData()
@@ -23,11 +22,8 @@ export const useParticipantRole = () => {
}), }),
}) })
} catch (error) { } catch (error) {
reportError( console.error(
'generic_failure', `Failed to update participant's role ${identity}: ${error instanceof Error ? error.message : 'Unknown error'}`
new Error(
`Failed to update participant's role ${identity}: ${error instanceof Error ? error.message : 'Unknown error'}`
)
) )
} }
} }
@@ -19,11 +19,13 @@ import {
import Source = Track.Source import Source = Track.Source
import { RiMicFill, RiMicOffFill } from '@remixicon/react' import { RiMicFill, RiMicOffFill } from '@remixicon/react'
import { Button } from '@/primitives' import { Button } from '@/primitives'
import { useState } from 'react'
import { useMuteParticipant } from '@/features/rooms/api/muteParticipant'
import { useCanMute } from '@/features/rooms/livekit/hooks/useCanMute' import { useCanMute } from '@/features/rooms/livekit/hooks/useCanMute'
import { ParticipantMenuButton } from './menu/ParticipantMenuButton' import { ParticipantMenuButton } from './menu/ParticipantMenuButton'
import { PinBadge } from './PinBadge' import { PinBadge } from './PinBadge'
import { UnauthenticatedBadge } from './UnauthenticatedBadge' import { UnauthenticatedBadge } from './UnauthenticatedBadge'
import { openMuteDialog } from '@/stores/muteDialog' import { MuteAlertDialog } from '@/features/rooms/livekit/components/MuteAlertDialog'
import { ParticipantName } from './ParticipantName' import { ParticipantName } from './ParticipantName'
type MicIndicatorProps = { type MicIndicatorProps = {
@@ -32,6 +34,7 @@ type MicIndicatorProps = {
const MicIndicator = ({ participant }: MicIndicatorProps) => { const MicIndicator = ({ participant }: MicIndicatorProps) => {
const { t } = useTranslation('rooms') const { t } = useTranslation('rooms')
const { muteParticipant } = useMuteParticipant()
const { isMuted } = useTrackMutedIndicator({ const { isMuted } = useTrackMutedIndicator({
participant: participant, participant: participant,
source: Source.Microphone, source: Source.Microphone,
@@ -39,6 +42,7 @@ const MicIndicator = ({ participant }: MicIndicatorProps) => {
const canMute = useCanMute(participant) const canMute = useCanMute(participant)
const isSpeaking = useIsSpeaking(participant) const isSpeaking = useIsSpeaking(participant)
const [isAlertOpen, setIsAlertOpen] = useState(false)
const name = participant.name || participant.identity const name = participant.name || participant.identity
const label = isLocal(participant) const label = isLocal(participant)
@@ -48,34 +52,46 @@ const MicIndicator = ({ participant }: MicIndicatorProps) => {
}) })
return ( return (
<Button <>
square <Button
variant="greyscale" square
size="sm" variant="greyscale"
tooltip={label} size="sm"
aria-label={label} tooltip={label}
isDisabled={isMuted || !canMute} aria-label={label}
onPress={async () => isDisabled={isMuted || !canMute}
!isMuted && isLocal(participant) onPress={async () =>
? await (participant as LocalParticipant)?.setMicrophoneEnabled(false) !isMuted && isLocal(participant)
: openMuteDialog(participant) ? await (participant as LocalParticipant)?.setMicrophoneEnabled(
} false
data-attr="participants-mute" )
> : setIsAlertOpen(true)
{isMuted ? ( }
<RiMicOffFill color={'gray'} aria-hidden={true} /> data-attr="participants-mute"
) : ( >
<RiMicFill {isMuted ? (
className={css({ <RiMicOffFill color={'gray'} aria-hidden={true} />
color: isSpeaking ? 'primaryDark.300' : 'primaryDark.50', ) : (
animation: isSpeaking <RiMicFill
? 'pulse_background 800ms infinite' className={css({
: undefined, color: isSpeaking ? 'primaryDark.300' : 'primaryDark.50',
})} animation: isSpeaking
aria-hidden={true} ? 'pulse_background 800ms infinite'
/> : undefined,
)} })}
</Button> aria-hidden={true}
/>
)}
</Button>
<MuteAlertDialog
isOpen={isAlertOpen}
onSubmit={() =>
muteParticipant(participant).then(() => setIsAlertOpen(false))
}
onClose={() => setIsAlertOpen(false)}
name={name}
/>
</>
) )
} }
@@ -1,31 +1,60 @@
import { useMemo } from 'react' import { useCallback, useEffect, useMemo, useState } from 'react'
import { useRoomContext } from '@livekit/components-react'
import { RoomEvent } from 'livekit-client'
import { useRoomData } from '@/features/rooms/livekit/hooks/useRoomData' import { useRoomData } from '@/features/rooms/livekit/hooks/useRoomData'
import { useCanManageLobby } from '@/features/rooms/livekit/hooks/useCanManageLobby' import { useIsAdminOrOwner } from '@/features/rooms/livekit/hooks/useIsAdminOrOwner'
import { useEnterRoom } from '../api/enterRoom' import { useEnterRoom } from '../api/enterRoom'
import { import {
useListWaitingParticipants, useListWaitingParticipants,
type WaitingParticipant, type WaitingParticipant,
} from '../../participants/api/listWaitingParticipants' } from '../../participants/api/listWaitingParticipants'
import { reportError } from '@/features/analytics/telemetry' import { decodeNotificationDataReceived } from '@/features/notifications/utils'
import { NotificationType } from '@/features/notifications/NotificationType'
export const POLL_INTERVAL_MS = 1000
export const useWaitingParticipants = () => { export const useWaitingParticipants = () => {
const [listEnabled, setListEnabled] = useState(true)
const roomData = useRoomData() const roomData = useRoomData()
const roomId = roomData?.id || '' // FIXME - bad practice const roomId = roomData?.id || '' // FIXME - bad practice
const canManageLobby = useCanManageLobby() const room = useRoomContext()
const isAdminOrOwner = useIsAdminOrOwner()
const handleDataReceived = useCallback((payload: Uint8Array) => {
const notification = decodeNotificationDataReceived(payload)
if (notification?.type === NotificationType.ParticipantWaiting) {
setListEnabled(true)
}
}, [])
useEffect(() => {
if (isAdminOrOwner) {
room.on(RoomEvent.DataReceived, handleDataReceived)
}
return () => {
room.off(RoomEvent.DataReceived, handleDataReceived)
}
}, [isAdminOrOwner, room, handleDataReceived])
const { data: waitingData, refetch: refetchWaiting } = const { data: waitingData, refetch: refetchWaiting } =
useListWaitingParticipants(roomId, { useListWaitingParticipants(roomId, {
retry: false, retry: false,
enabled: false, enabled: listEnabled && isAdminOrOwner,
refetchInterval: POLL_INTERVAL_MS,
refetchIntervalInBackground: true,
}) })
const waitingParticipants = useMemo( const waitingParticipants = useMemo(
() => (canManageLobby ? waitingData?.participants || [] : []), () => waitingData?.participants || [],
[waitingData, canManageLobby] [waitingData]
) )
useEffect(() => {
if (!waitingParticipants.length) setListEnabled(false)
}, [waitingParticipants])
const { mutateAsync: enterRoom } = useEnterRoom() const { mutateAsync: enterRoom } = useEnterRoom()
const handleParticipantEntry = async ( const handleParticipantEntry = async (
@@ -44,6 +73,8 @@ export const useWaitingParticipants = () => {
allowEntry: boolean allowEntry: boolean
): Promise<void> => { ): Promise<void> => {
try { try {
setListEnabled(false)
await Promise.all( await Promise.all(
waitingParticipants.map((participant) => waitingParticipants.map((participant) =>
enterRoom({ enterRoom({
@@ -56,7 +87,8 @@ export const useWaitingParticipants = () => {
await refetchWaiting() await refetchWaiting()
} catch (e) { } catch (e) {
reportError('generic_failure', e) console.error(e)
setListEnabled(true)
} }
} }
@@ -7,9 +7,7 @@ import { useEffect, useMemo } from 'react'
import { CrossDocumentOverlaysContext } from '@/primitives/CrossDocumentOverlaysContext' import { CrossDocumentOverlaysContext } from '@/primitives/CrossDocumentOverlaysContext'
const InternalPortal = ({ children }: { children: React.ReactNode }) => { const InternalPortal = ({ children }: { children: React.ReactNode }) => {
const pipStoreSnap = useSnapshot(documentPictureInPictureStore, { const pipStoreSnap = useSnapshot(documentPictureInPictureStore)
sync: true,
})
const container = useMemo(() => { const container = useMemo(() => {
return pipStoreSnap?.window?.document.getElementById('root') return pipStoreSnap?.window?.document.getElementById('root')
@@ -21,7 +19,7 @@ const InternalPortal = ({ children }: { children: React.ReactNode }) => {
} }
}, []) }, [])
if (!container || !container.isConnected) return null if (!container) return null
return createPortal( return createPortal(
/** /**
@@ -1,9 +1,7 @@
import { ref, useSnapshot } from 'valtio' import { ref, useSnapshot } from 'valtio'
import { useCallback, useMemo } from 'react' import { useCallback, useMemo } from 'react'
import { flushSync } from 'react-dom'
import { documentPictureInPictureStore } from '@/stores/documentPictureInPicture' import { documentPictureInPictureStore } from '@/stores/documentPictureInPicture'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { reportError } from '@/features/analytics/telemetry'
export const IS_PIP_SUPPORTED = export const IS_PIP_SUPPORTED =
typeof globalThis !== 'undefined' && 'documentPictureInPicture' in globalThis typeof globalThis !== 'undefined' && 'documentPictureInPicture' in globalThis
@@ -61,29 +59,21 @@ export const usePictureInPicture = () => {
if (!IS_PIP_SUPPORTED) return null if (!IS_PIP_SUPPORTED) return null
if (isOpen) return null if (isOpen) return null
let pipWindow: Window
try { try {
pipWindow = const pipWindow =
await // eslint-disable-next-line @typescript-eslint/no-explicit-any await // eslint-disable-next-line @typescript-eslint/no-explicit-any
(window as any).documentPictureInPicture.requestWindow({ (window as any).documentPictureInPicture.requestWindow({
width, width,
height, height,
}) })
} catch {
// Avoid unhandled rejections if the user blocks or closes the request.
return null
}
try {
initializeTitleAndLanguage(pipWindow, t('title')) initializeTitleAndLanguage(pipWindow, t('title'))
initializePortalContainer(pipWindow) initializePortalContainer(pipWindow)
syncStyles(pipWindow) syncStyles(pipWindow)
const cleanUp = () => { const cleanUp = () => {
if (documentPictureInPictureStore.window === pipWindow) { if (documentPictureInPictureStore.window === pipWindow) {
flushSync(() => { documentPictureInPictureStore.window = null
documentPictureInPictureStore.window = null
})
} }
} }
pipWindow.addEventListener('pagehide', () => cleanUp(), { once: true }) pipWindow.addEventListener('pagehide', () => cleanUp(), { once: true })
@@ -92,10 +82,8 @@ export const usePictureInPicture = () => {
}) })
documentPictureInPictureStore.window = ref(pipWindow) documentPictureInPictureStore.window = ref(pipWindow)
} catch (error) { } catch (error) {
reportError('generic_failure', error, { // Avoid unhandled rejections if the user blocks or closes the request.
context: 'pip_init_failure', console.error('Failed to open Picture-in-Picture window', error)
})
pipWindow.close()
return null return null
} }
}, },
@@ -1,3 +1,4 @@
import { useCallback } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { RiEmotionLine } from '@remixicon/react' import { RiEmotionLine } from '@remixicon/react'
import { ToggleButton } from '@/primitives' import { ToggleButton } from '@/primitives'
@@ -6,8 +7,6 @@ import { useRegisterKeyboardShortcut } from '@/features/shortcuts/useRegisterKey
import { REACTIONS_TOOLBAR_ID } from '../constants' import { REACTIONS_TOOLBAR_ID } from '../constants'
import { useReactionsToolbar } from '../hooks/useReactionsToolbar' import { useReactionsToolbar } from '../hooks/useReactionsToolbar'
import { layoutStore } from '@/stores/layout' import { layoutStore } from '@/stores/layout'
import { type ButtonRecipeProps } from '@/primitives/buttonRecipe'
import { ToggleButtonProps } from '@/primitives/ToggleButton'
const focusReactionsToolbar = () => { const focusReactionsToolbar = () => {
document document
@@ -18,44 +17,35 @@ const focusReactionsToolbar = () => {
export const REACTIONS_TOGGLE_ID = 'reactions-toggle' export const REACTIONS_TOGGLE_ID = 'reactions-toggle'
/* eslint-disable react-refresh/only-export-components */ export const ReactionsToggle = () => {
export const reactionShortcutHandler = () => {
if (layoutStore.showReactionsToolbar) {
focusReactionsToolbar()
} else {
layoutStore.showReactionsToolbar = true
}
}
type Props = Pick<NonNullable<ButtonRecipeProps>, 'variant'> & ToggleButtonProps
export const ReactionsToggle = ({
variant = 'primaryDark',
onPress,
...props
}: Props) => {
const { t } = useTranslation('rooms', { keyPrefix: 'controls.reactions' }) const { t } = useTranslation('rooms', { keyPrefix: 'controls.reactions' })
const { isOpen, toggle } = useReactionsToolbar() const { isOpen, toggle } = useReactionsToolbar()
const handleShortcut = useCallback(() => {
if (layoutStore.showReactionsToolbar) {
focusReactionsToolbar()
} else {
layoutStore.showReactionsToolbar = true
}
}, [])
useRegisterKeyboardShortcut({ useRegisterKeyboardShortcut({
id: 'reaction', id: 'reaction',
handler: reactionShortcutHandler, handler: handleShortcut,
}) })
return ( return (
<ToggleButton <ToggleButton
{...props}
id={REACTIONS_TOGGLE_ID} id={REACTIONS_TOGGLE_ID}
data-attr="reactions-toggle" data-attr="reactions-toggle"
square square
variant={variant} variant="primaryDark"
aria-label={t('button')} aria-label={t('button')}
aria-expanded={isOpen} aria-expanded={isOpen}
tooltip={t('button')} tooltip={t('button')}
isSelected={isOpen} isSelected={isOpen}
onChange={toggle} onChange={toggle}
onPress={onPress}
> >
<RiEmotionLine /> <RiEmotionLine />
</ToggleButton> </ToggleButton>
@@ -26,8 +26,8 @@ const StyledContainer = styled('div', {
backgroundColor: 'primaryDark.100', backgroundColor: 'primaryDark.100',
maxWidth: '100%', maxWidth: '100%',
opacity: 0, opacity: 0,
translate: '0 3.25rem', transform: 'translateY(3.25rem)',
transition: 'opacity, translate', transition: 'opacity, transform',
transitionDuration: '0.5s', transitionDuration: '0.5s',
transitionTimingFunction: 'cubic-bezier(0.4, 0, 0.2, 1)', transitionTimingFunction: 'cubic-bezier(0.4, 0, 0.2, 1)',
pointerEvents: 'none', pointerEvents: 'none',
@@ -36,7 +36,7 @@ const StyledContainer = styled('div', {
isVisible: { isVisible: {
true: { true: {
opacity: 1, opacity: 1,
translate: '0 0', transform: 'translateY(0)',
pointerEvents: 'auto', pointerEvents: 'auto',
}, },
}, },
@@ -84,7 +84,7 @@ export const ReactionButtonsContainer = ({
shouldBeCenteredWithToggleButton, shouldBeCenteredWithToggleButton,
setShouldBeCenteredWithToggleButton, setShouldBeCenteredWithToggleButton,
] = useState(false) ] = useState(false)
const [offsetX, setOffsetX] = useState(0) const [rightOffset, setRightOffset] = useState(0)
const updateArrows = useCallback(() => { const updateArrows = useCallback(() => {
const el = scrollRef.current const el = scrollRef.current
@@ -115,7 +115,7 @@ export const ReactionButtonsContainer = ({
useLayoutEffect(() => { useLayoutEffect(() => {
if (!shouldBeCenteredWithToggleButton || isMobile) { if (!shouldBeCenteredWithToggleButton || isMobile) {
setOffsetX(0) setRightOffset(0)
return return
} }
@@ -133,7 +133,7 @@ export const ReactionButtonsContainer = ({
const containerCenterX = containerRect.left + containerRect.width / 2 const containerCenterX = containerRect.left + containerRect.width / 2
const shift = toggleCenterX - containerCenterX const shift = toggleCenterX - containerCenterX
if (Math.abs(shift) < 0.5) return if (Math.abs(shift) < 0.5) return
setOffsetX((prev) => prev + shift) setRightOffset((prev) => prev - shift * 2)
} }
const schedule = () => { const schedule = () => {
@@ -182,7 +182,7 @@ export const ReactionButtonsContainer = ({
isVisible={isVisible} isVisible={isVisible}
style={ style={
shouldBeCenteredWithToggleButton && !isMobile && adjustedCentering shouldBeCenteredWithToggleButton && !isMobile && adjustedCentering
? { transform: `translateX(${offsetX}px)` } ? { marginRight: `${rightOffset}px` }
: { margin: '0 15px' } : { margin: '0 15px' }
} }
> >
@@ -98,6 +98,7 @@ export const NoAccessView = ({
)} )}
</Text> </Text>
</VStack> </VStack>
{/*todo - update the message here to adapt to room configuration */}
{!isLoggedIn && ( {!isLoggedIn && (
<LoginPrompt <LoginPrompt
heading={t(`${i18nKey}.login.heading`)} heading={t(`${i18nKey}.login.heading`)}

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