mirror of
https://github.com/suitenumerique/meet.git
synced 2026-09-09 17:05:56 +00:00
Compare commits
58 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 069b3d5b95 | |||
| e0ab7f191f | |||
| 455b315dbb | |||
| 3089b03062 | |||
| 60febb3b57 | |||
| bf76ab1ddf | |||
| 7565ede0a7 | |||
| 1a15e9f44e | |||
| 7838d8acfe | |||
| 3bb388b937 | |||
| e1cc8105db | |||
| 7844dfcc12 | |||
| ef71003721 | |||
| f74d23c57e | |||
| acedb21045 | |||
| 67e7d382e3 | |||
| 164ac8d948 | |||
| e3deb37fbe | |||
| 33929324d0 | |||
| adc74f846c | |||
| 78ba03a52b | |||
| ac4be27445 | |||
| eb6b3ba1df | |||
| 8473069670 | |||
| cf3960db95 | |||
| d80d31897c | |||
| 63a7751072 | |||
| 1ac1778521 | |||
| fcc58065d2 | |||
| 21c57bffb4 | |||
| bd81c99495 | |||
| 839cfa4b80 | |||
| f3673457c3 | |||
| 86797d004c | |||
| 02a355136f | |||
| fbeb035f50 | |||
| a0dbfa9357 | |||
| 0e9660ead3 | |||
| 1721eb0884 | |||
| 7538cd886b | |||
| aafbc752d5 | |||
| a24100aceb | |||
| 9e2383a341 | |||
| 80d37595ad | |||
| 2daa668075 | |||
| a1e7978348 | |||
| 4fa044fa3e | |||
| 2c5dd151f1 | |||
| a33e35111c | |||
| 02714c869a | |||
| 826cfe0c62 | |||
| ab62ae71ea | |||
| 3991235903 | |||
| d2a74a20fd | |||
| 0446d1e824 | |||
| a20a0a6b38 | |||
| 564b3dc595 | |||
| 0a0cdae896 |
@@ -1,4 +1,4 @@
|
||||
name: meet Workflow
|
||||
name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
@@ -18,7 +18,7 @@ jobs:
|
||||
contents: read
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: show
|
||||
@@ -26,17 +26,17 @@ jobs:
|
||||
- name: Enforce absence of print statements in code
|
||||
if: always()
|
||||
run: |
|
||||
! git diff origin/${{ github.event.pull_request.base.ref }}..HEAD -- . ':(exclude)**/meet.yml' | grep "print("
|
||||
! git diff origin/${{ github.event.pull_request.base.ref }}..HEAD -- . ':(exclude).github/workflows/**' | grep "print("
|
||||
- name: Check absence of fixup commits
|
||||
if: always()
|
||||
run: |
|
||||
! git log | grep 'fixup!'
|
||||
- name: Install gitlint
|
||||
- name: Install uv
|
||||
if: always()
|
||||
run: pip install --user requests gitlint
|
||||
uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7.6.0
|
||||
- name: Lint commit messages added to main
|
||||
if: always()
|
||||
run: ~/.local/bin/gitlint --commits origin/${{ github.event.pull_request.base.ref }}..HEAD
|
||||
run: uvx --no-build --from gitlint-core==0.19.1 gitlint --commits origin/${{ github.event.pull_request.base.ref }}..HEAD
|
||||
|
||||
check-changelog:
|
||||
runs-on: ubuntu-latest
|
||||
@@ -47,7 +47,7 @@ jobs:
|
||||
contents: read
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0
|
||||
with:
|
||||
fetch-depth: 50
|
||||
- name: Check that the CHANGELOG has been modified in the current branch
|
||||
@@ -59,7 +59,7 @@ jobs:
|
||||
contents: read
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0
|
||||
- name: Check CHANGELOG max line length
|
||||
run: |
|
||||
max_line_length=$(cat CHANGELOG.md | grep -Ev "^\[.*\]: https://github.com" | wc -L)
|
||||
@@ -77,15 +77,15 @@ jobs:
|
||||
working-directory: src/mail
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0
|
||||
|
||||
- name: Install Node.js
|
||||
uses: actions/setup-node@v6
|
||||
uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6.5.0
|
||||
with:
|
||||
node-version: "22"
|
||||
|
||||
- name: Restore the mail templates
|
||||
uses: actions/cache@v5
|
||||
uses: actions/cache@caa296126883cff596d87d8935842f9db880ef25 # v5.1.0
|
||||
id: mail-templates
|
||||
with:
|
||||
path: "src/backend/core/templates/mail"
|
||||
@@ -93,11 +93,11 @@ jobs:
|
||||
|
||||
- name: Install yarn
|
||||
if: steps.mail-templates.outputs.cache-hit != 'true'
|
||||
run: npm install -g yarn
|
||||
run: npm install -g --ignore-scripts yarn@1.22.22
|
||||
|
||||
- name: Install node dependencies
|
||||
if: steps.mail-templates.outputs.cache-hit != 'true'
|
||||
run: yarn install --frozen-lockfile
|
||||
run: yarn install --frozen-lockfile --ignore-scripts
|
||||
|
||||
- name: Build mails
|
||||
if: steps.mail-templates.outputs.cache-hit != 'true'
|
||||
@@ -105,7 +105,7 @@ jobs:
|
||||
|
||||
- name: Cache mail templates
|
||||
if: steps.mail-templates.outputs.cache-hit != 'true'
|
||||
uses: actions/cache@v5
|
||||
uses: actions/cache@caa296126883cff596d87d8935842f9db880ef25 # v5.1.0
|
||||
with:
|
||||
path: "src/backend/core/templates/mail"
|
||||
key: mail-templates-${{ hashFiles('src/mail/mjml') }}
|
||||
@@ -119,22 +119,22 @@ jobs:
|
||||
working-directory: src/backend
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0
|
||||
- name: Install Python
|
||||
uses: actions/setup-python@v6
|
||||
uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0
|
||||
with:
|
||||
python-version: "3.13"
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@v7
|
||||
uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7.6.0
|
||||
- name: Install the project
|
||||
run: uv sync --locked --all-extras
|
||||
|
||||
- name: Check code formatting with ruff
|
||||
run: uv run ruff format . --diff
|
||||
run: uv run --no-sync --no-build ruff format . --diff
|
||||
- name: Lint code with ruff
|
||||
run: uv run ruff check .
|
||||
run: uv run --no-sync --no-build ruff check .
|
||||
- name: Lint code with pylint
|
||||
run: uv run pylint meet demo core
|
||||
run: uv run --no-sync --no-build pylint meet demo core
|
||||
|
||||
lint-agents:
|
||||
runs-on: ubuntu-latest
|
||||
@@ -145,19 +145,19 @@ jobs:
|
||||
working-directory: src/agents
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0
|
||||
- name: Install Python
|
||||
uses: actions/setup-python@v6
|
||||
uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0
|
||||
with:
|
||||
python-version: "3.13"
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@v7
|
||||
uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7.6.0
|
||||
- name: Install the project
|
||||
run: uv sync --locked --all-extras
|
||||
run: uv sync --locked --all-extras --no-build
|
||||
- name: Check code formatting with ruff
|
||||
run: uv run ruff format . --diff
|
||||
run: uv run --no-sync --no-build ruff format . --diff
|
||||
- name: Lint code with ruff
|
||||
run: uv run ruff check .
|
||||
run: uv run --no-sync --no-build ruff check .
|
||||
|
||||
lint-summary:
|
||||
runs-on: ubuntu-latest
|
||||
@@ -168,18 +168,19 @@ jobs:
|
||||
working-directory: src/summary
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0
|
||||
- name: Install Python
|
||||
uses: actions/setup-python@v6
|
||||
uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0
|
||||
with:
|
||||
python-version: "3.13"
|
||||
cache: "pip"
|
||||
- name: Install development dependencies
|
||||
run: pip install --user .[dev]
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7.6.0
|
||||
- name: Install the project
|
||||
run: uv sync --locked --all-extras
|
||||
- name: Check code formatting with ruff
|
||||
run: ~/.local/bin/ruff format . --diff
|
||||
run: uv run --no-sync --no-build ruff format . --diff
|
||||
- name: Lint code with ruff
|
||||
run: ~/.local/bin/ruff check .
|
||||
run: uv run --no-sync --no-build ruff check .
|
||||
|
||||
test-back:
|
||||
runs-on: ubuntu-latest
|
||||
@@ -235,7 +236,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0
|
||||
|
||||
- name: Create writable /data
|
||||
run: |
|
||||
@@ -243,7 +244,7 @@ jobs:
|
||||
sudo mkdir -p /data/static
|
||||
|
||||
- name: Restore the mail templates
|
||||
uses: actions/cache@v5
|
||||
uses: actions/cache@caa296126883cff596d87d8935842f9db880ef25 # v5.1.0
|
||||
id: mail-templates
|
||||
with:
|
||||
path: "src/backend/core/templates/mail"
|
||||
@@ -262,7 +263,9 @@ jobs:
|
||||
# Tool to wait for a service to be ready
|
||||
- name: Install Dockerize
|
||||
run: |
|
||||
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
|
||||
curl --proto "=https" --proto-redir "=https" --tlsv1.2 -sSLf \
|
||||
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
|
||||
run: |
|
||||
@@ -277,11 +280,11 @@ jobs:
|
||||
mc mb meet/meet-media-storage"
|
||||
|
||||
- name: Install Python
|
||||
uses: actions/setup-python@v6
|
||||
uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0
|
||||
with:
|
||||
python-version: "3.13"
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@v7
|
||||
uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7.6.0
|
||||
- name: Install the dependencies
|
||||
run: uv sync --locked --all-extras
|
||||
|
||||
@@ -291,10 +294,10 @@ jobs:
|
||||
sudo apt-get install -y gettext
|
||||
|
||||
- name: Generate a MO file from strings extracted from the project
|
||||
run: uv run python manage.py compilemessages
|
||||
run: uv run --no-sync --no-build python manage.py compilemessages
|
||||
|
||||
- name: Run tests
|
||||
run: uv run pytest -n 2
|
||||
run: uv run --no-sync --no-build pytest -n 2
|
||||
|
||||
test-summary:
|
||||
runs-on: ubuntu-latest
|
||||
@@ -320,7 +323,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0
|
||||
|
||||
- name: Install ffmpeg
|
||||
run: |
|
||||
@@ -328,16 +331,18 @@ jobs:
|
||||
sudo apt-get install -y ffmpeg
|
||||
|
||||
- name: Install Python
|
||||
uses: actions/setup-python@v6
|
||||
uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0
|
||||
with:
|
||||
python-version: "3.13"
|
||||
cache: "pip"
|
||||
|
||||
- name: Install development dependencies
|
||||
run: pip install --user .[dev]
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7.6.0
|
||||
|
||||
- name: Install the project
|
||||
run: uv sync --locked --all-extras
|
||||
|
||||
- name: Run summary tests
|
||||
run: ~/.local/bin/pytest
|
||||
run: uv run --no-sync --no-build pytest
|
||||
|
||||
lint-front:
|
||||
runs-on: ubuntu-latest
|
||||
@@ -345,10 +350,10 @@ jobs:
|
||||
contents: read
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0
|
||||
|
||||
- name: Install dependencies
|
||||
run: cd src/frontend/ && npm ci
|
||||
run: cd src/frontend/ && npm ci --ignore-scripts
|
||||
|
||||
- name: Check linting
|
||||
run: cd src/frontend/ && npm run lint
|
||||
@@ -365,10 +370,10 @@ jobs:
|
||||
working-directory: src/sdk/library
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
run: npm ci --ignore-scripts
|
||||
|
||||
- name: Check linting
|
||||
run: npm run lint
|
||||
@@ -386,10 +391,10 @@ jobs:
|
||||
working-directory: src/sdk/library
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
run: npm ci --ignore-scripts
|
||||
|
||||
- name: Build SDK
|
||||
run: npm run build
|
||||
@@ -13,10 +13,10 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0
|
||||
|
||||
- name: Download Crowdin files
|
||||
uses: crowdin/github-action@v2
|
||||
uses: crowdin/github-action@c7af9bc98b01694653031fef2a0dc6c7888ce9bc # v2.17.0
|
||||
with:
|
||||
upload_sources: false
|
||||
upload_translations: false
|
||||
|
||||
@@ -30,36 +30,36 @@ jobs:
|
||||
steps:
|
||||
-
|
||||
name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0
|
||||
-
|
||||
name: Set up QEMU
|
||||
if: env.IS_MULTI_PLATFORM_BUILD == 'true'
|
||||
uses: docker/setup-qemu-action@v3
|
||||
uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 # v3.7.0
|
||||
-
|
||||
name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0
|
||||
-
|
||||
name: Docker meta
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5.10.0
|
||||
with:
|
||||
images: '${{ env.DOCKER_CONTAINER_REGISTRY_NAMESPACE }}/meet-backend'
|
||||
-
|
||||
name: Login to DockerHub
|
||||
if: github.event_name != 'pull_request' || startsWith(github.head_ref, 'integration/')
|
||||
uses: docker/login-action@v3
|
||||
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_HUB_USER }}
|
||||
password: ${{ secrets.DOCKER_HUB_PASSWORD }}
|
||||
-
|
||||
name: Run trivy scan
|
||||
uses: numerique-gouv/action-trivy-cache@main
|
||||
uses: numerique-gouv/action-trivy-cache@d6e94cfb488f03a0b3e8b8739aad94e74d24d8da # main
|
||||
with:
|
||||
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 }}'
|
||||
-
|
||||
name: Build and push
|
||||
uses: docker/build-push-action@v6
|
||||
uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6.19.2
|
||||
with:
|
||||
context: .
|
||||
target: backend-production
|
||||
@@ -76,36 +76,36 @@ jobs:
|
||||
steps:
|
||||
-
|
||||
name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0
|
||||
-
|
||||
name: Set up QEMU
|
||||
if: env.IS_MULTI_PLATFORM_BUILD == 'true'
|
||||
uses: docker/setup-qemu-action@v3
|
||||
uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 # v3.7.0
|
||||
-
|
||||
name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0
|
||||
-
|
||||
name: Docker meta
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5.10.0
|
||||
with:
|
||||
images: '${{ env.DOCKER_CONTAINER_REGISTRY_NAMESPACE }}/meet-frontend'
|
||||
-
|
||||
name: Login to DockerHub
|
||||
if: github.event_name != 'pull_request' || startsWith(github.head_ref, 'integration/')
|
||||
uses: docker/login-action@v3
|
||||
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_HUB_USER }}
|
||||
password: ${{ secrets.DOCKER_HUB_PASSWORD }}
|
||||
-
|
||||
name: Run trivy scan
|
||||
uses: numerique-gouv/action-trivy-cache@main
|
||||
uses: numerique-gouv/action-trivy-cache@d6e94cfb488f03a0b3e8b8739aad94e74d24d8da # main
|
||||
with:
|
||||
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 }}'
|
||||
-
|
||||
name: Build and push
|
||||
uses: docker/build-push-action@v6
|
||||
uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6.19.2
|
||||
with:
|
||||
context: .
|
||||
file: ./src/frontend/Dockerfile
|
||||
@@ -123,36 +123,36 @@ jobs:
|
||||
steps:
|
||||
-
|
||||
name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0
|
||||
-
|
||||
name: Set up QEMU
|
||||
if: env.IS_MULTI_PLATFORM_BUILD == 'true'
|
||||
uses: docker/setup-qemu-action@v3
|
||||
uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 # v3.7.0
|
||||
-
|
||||
name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0
|
||||
-
|
||||
name: Docker meta
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5.10.0
|
||||
with:
|
||||
images: '${{ env.DOCKER_CONTAINER_REGISTRY_NAMESPACE }}/meet-frontend-dinum'
|
||||
-
|
||||
name: Login to DockerHub
|
||||
if: github.event_name != 'pull_request' || startsWith(github.head_ref, 'integration/')
|
||||
uses: docker/login-action@v3
|
||||
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_HUB_USER }}
|
||||
password: ${{ secrets.DOCKER_HUB_PASSWORD }}
|
||||
-
|
||||
name: Run trivy scan
|
||||
uses: numerique-gouv/action-trivy-cache@main
|
||||
uses: numerique-gouv/action-trivy-cache@d6e94cfb488f03a0b3e8b8739aad94e74d24d8da # main
|
||||
with:
|
||||
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 }}'
|
||||
-
|
||||
name: Build and push
|
||||
uses: docker/build-push-action@v6
|
||||
uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6.19.2
|
||||
with:
|
||||
context: .
|
||||
file: ./docker/dinum-frontend/Dockerfile
|
||||
@@ -170,30 +170,30 @@ jobs:
|
||||
steps:
|
||||
-
|
||||
name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0
|
||||
-
|
||||
name: Set up QEMU
|
||||
if: env.IS_MULTI_PLATFORM_BUILD == 'true'
|
||||
uses: docker/setup-qemu-action@v3
|
||||
uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 # v3.7.0
|
||||
-
|
||||
name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0
|
||||
-
|
||||
name: Docker meta
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5.10.0
|
||||
with:
|
||||
images: '${{ env.DOCKER_CONTAINER_REGISTRY_NAMESPACE }}/meet-summary'
|
||||
-
|
||||
name: Login to DockerHub
|
||||
if: github.event_name != 'pull_request' || startsWith(github.head_ref, 'integration/')
|
||||
uses: docker/login-action@v3
|
||||
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_HUB_USER }}
|
||||
password: ${{ secrets.DOCKER_HUB_PASSWORD }}
|
||||
-
|
||||
name: Run trivy scan
|
||||
uses: numerique-gouv/action-trivy-cache@main
|
||||
uses: numerique-gouv/action-trivy-cache@d6e94cfb488f03a0b3e8b8739aad94e74d24d8da # main
|
||||
continue-on-error: true
|
||||
with:
|
||||
docker-build-args: '-f src/summary/Dockerfile --target production'
|
||||
@@ -201,7 +201,7 @@ jobs:
|
||||
docker-context: './src/summary'
|
||||
-
|
||||
name: Build and push
|
||||
uses: docker/build-push-action@v6
|
||||
uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6.19.2
|
||||
with:
|
||||
context: ./src/summary
|
||||
file: ./src/summary/Dockerfile
|
||||
@@ -219,30 +219,30 @@ jobs:
|
||||
steps:
|
||||
-
|
||||
name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0
|
||||
-
|
||||
name: Set up QEMU
|
||||
if: env.IS_MULTI_PLATFORM_BUILD == 'true'
|
||||
uses: docker/setup-qemu-action@v3
|
||||
uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 # v3.7.0
|
||||
-
|
||||
name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0
|
||||
-
|
||||
name: Docker meta
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5.10.0
|
||||
with:
|
||||
images: lasuite/meet-agents
|
||||
-
|
||||
name: Login to DockerHub
|
||||
if: github.event_name != 'pull_request' || startsWith(github.head_ref, 'integration/')
|
||||
uses: docker/login-action@v3
|
||||
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_HUB_USER }}
|
||||
password: ${{ secrets.DOCKER_HUB_PASSWORD }}
|
||||
-
|
||||
name: Run trivy scan
|
||||
uses: numerique-gouv/action-trivy-cache@main
|
||||
uses: numerique-gouv/action-trivy-cache@d6e94cfb488f03a0b3e8b8739aad94e74d24d8da # main
|
||||
continue-on-error: true
|
||||
with:
|
||||
docker-build-args: '-f src/agents/Dockerfile --target production'
|
||||
@@ -250,7 +250,7 @@ jobs:
|
||||
docker-context: './src/agents'
|
||||
-
|
||||
name: Build and push
|
||||
uses: docker/build-push-action@v6
|
||||
uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6.19.2
|
||||
with:
|
||||
context: ./src/agents
|
||||
file: ./src/agents/Dockerfile
|
||||
@@ -273,7 +273,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
if: github.event_name != 'pull_request'
|
||||
steps:
|
||||
- uses: numerique-gouv/action-argocd-webhook-notification@main
|
||||
- uses: numerique-gouv/action-argocd-webhook-notification@cac2ee67896eb13e84e804f60c4271370424eaa8 # main
|
||||
id: notify
|
||||
with:
|
||||
deployment_repo_path: "${{ secrets.DEPLOYMENT_REPO_URL }}"
|
||||
|
||||
@@ -13,7 +13,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
@@ -21,12 +21,12 @@ jobs:
|
||||
run: rm -rf ./src/helm/extra
|
||||
|
||||
- name: Install Helm
|
||||
uses: azure/setup-helm@v4
|
||||
uses: azure/setup-helm@1a275c3b69536ee54be43f2070a358922e12c8d4 # v4.3.1
|
||||
env:
|
||||
GITHUB_TOKEN: "${{ secrets.GITHUB_TOKEN }}"
|
||||
|
||||
- name: Publish Helm charts
|
||||
uses: numerique-gouv/helm-gh-pages@add-overwrite-option
|
||||
uses: numerique-gouv/helm-gh-pages@2cf477ae49d7c70037ceb1685803f4f7bad9b981 # add-overwrite-option
|
||||
with:
|
||||
charts_dir: ./src/helm
|
||||
linting: on
|
||||
|
||||
@@ -10,8 +10,67 @@ and this project adheres to
|
||||
|
||||
### Changed
|
||||
|
||||
- 📈(frontend) include LiveKit SIDs in the connection analytics event
|
||||
- 🔇(backend) silence expected 401 warnings on /me
|
||||
- 🔇(backend) silence noisy request summary info logs
|
||||
|
||||
### Fixed
|
||||
|
||||
- 🐛(backend) acknowledge unknown LiveKit webhook events instead of 422
|
||||
- 🔒️(backend) enforce display name setting on rename API
|
||||
- 🔒️(backend) reject inactive users in resource server backend
|
||||
|
||||
## [1.31.0] - 2026-09-08
|
||||
|
||||
### Added
|
||||
|
||||
- ✨(frontend) add 1080p sending resolution option #1660
|
||||
- ✨(backend) add Traefik support via configurable media-auth url header #1649
|
||||
- ✨(backend) update a room's attributes from the external API
|
||||
- 🔊(backend) log request duration in Gunicorn workers
|
||||
- 📈(frontend) track missing lobby participant on accept/reject
|
||||
- ✨(backend) sort waiting participants by their arrival time
|
||||
|
||||
### Changed
|
||||
|
||||
- ⬆️(dev) pin LiveKit server to v1.13.6
|
||||
- 🔒(frontend) upgrade base image to 1.30.4-alpine3.24
|
||||
|
||||
### Fixed
|
||||
|
||||
- 🐛(backend) allow any printable ASCII characters in user sub field #1673
|
||||
- 🐛(frontend) keep the sending resolution picked while the camera is off #1667
|
||||
- 🐛(frontend) restore automatic lower-hand on speaking
|
||||
- 🐛(frontend) center Avatar initials with a font-aware cap-height ratio
|
||||
- 🐛(frontend) keep feedback buttons on one line for fr/es/en
|
||||
- ⚡️(frontend) increase lobby polling interval on both sides
|
||||
- ⚡️(frontend) add trailing slash on the /me endpoint call
|
||||
- ⚡️(backend) refactor lobby storage to bound key lookups per room
|
||||
- ⚡️(backend) refactor presence cache to bound key lookups per room
|
||||
- 💄(frontend) position the login hint dynamically next to the button
|
||||
|
||||
## [1.30.0] - 2026-09-01
|
||||
|
||||
### 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
|
||||
|
||||
|
||||
@@ -39,14 +39,16 @@ DB_PORT = 5432
|
||||
DOCKER_UID = $(shell id -u)
|
||||
DOCKER_GID = $(shell id -g)
|
||||
DOCKER_USER = $(DOCKER_UID):$(DOCKER_GID)
|
||||
COMPOSE = DOCKER_USER=$(DOCKER_USER) docker compose
|
||||
COMPOSE_EXEC = $(COMPOSE) exec
|
||||
COMPOSE_EXEC_APP = $(COMPOSE_EXEC) app-dev
|
||||
COMPOSE_RUN = $(COMPOSE) run --rm
|
||||
COMPOSE_RUN_APP = $(COMPOSE_RUN) app-dev
|
||||
COMPOSE_RUN_LINT = $(COMPOSE_RUN) --no-deps app-dev
|
||||
COMPOSE_RUN_CROWDIN = $(COMPOSE_RUN) crowdin crowdin
|
||||
WAIT_DB = @$(COMPOSE_RUN) dockerize -wait tcp://$(DB_HOST):$(DB_PORT) -timeout 60s
|
||||
COMPOSE = DOCKER_USER=$(DOCKER_USER) docker compose
|
||||
COMPOSE_EXEC = $(COMPOSE) exec
|
||||
COMPOSE_EXEC_APP = $(COMPOSE_EXEC) app-dev
|
||||
COMPOSE_RUN = $(COMPOSE) run --rm
|
||||
COMPOSE_RUN_APP = $(COMPOSE_RUN) app-dev
|
||||
COMPOSE_RUN_LINT_BACK = $(COMPOSE_RUN) --no-deps app-dev
|
||||
COMPOSE_RUN_LINT_AGENTS = $(COMPOSE_RUN) --no-deps multi-user-transcriber-dev
|
||||
COMPOSE_RUN_LINT_SUMMARY = $(COMPOSE_RUN) --no-deps app-summary-dev
|
||||
COMPOSE_RUN_CROWDIN = $(COMPOSE_RUN) crowdin crowdin
|
||||
WAIT_DB = @$(COMPOSE_RUN) dockerize -wait tcp://$(DB_HOST):$(DB_PORT) -timeout 60s
|
||||
|
||||
# -- Backend
|
||||
MANAGE = $(COMPOSE_RUN_APP) python manage.py
|
||||
@@ -59,6 +61,10 @@ LINT_PYLINT = pylint meet demo core
|
||||
LINT_BACK = echo 'lint:ruff-format started…' && $(LINT_RUFF_FORMAT) \
|
||||
&& echo 'lint:ruff-check started…' && $(LINT_RUFF_CHECK) \
|
||||
&& 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
|
||||
PATH_FRONT = ./src/frontend
|
||||
@@ -198,23 +204,37 @@ demo: ## flush db then create a demo for load testing purpose
|
||||
@$(MANAGE) create_demo
|
||||
.PHONY: demo
|
||||
|
||||
lint: ## lint back-end python sources
|
||||
@$(COMPOSE_RUN_LINT) sh -c "$(LINT_BACK)"
|
||||
lint: ## lint all python sources (back-end, agents, summary)
|
||||
@$(MAKE) lint-back
|
||||
@$(MAKE) lint-agents
|
||||
@$(MAKE) lint-summary
|
||||
.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
|
||||
@echo 'lint:ruff-format started…'
|
||||
@$(COMPOSE_RUN_LINT) $(LINT_RUFF_FORMAT)
|
||||
@$(COMPOSE_RUN_LINT_BACK) $(LINT_RUFF_FORMAT)
|
||||
.PHONY: lint-ruff-format
|
||||
|
||||
lint-ruff-check: ## lint back-end python sources with ruff
|
||||
@echo 'lint:ruff-check started…'
|
||||
@$(COMPOSE_RUN_LINT) $(LINT_RUFF_CHECK)
|
||||
@$(COMPOSE_RUN_LINT_BACK) $(LINT_RUFF_CHECK)
|
||||
.PHONY: lint-ruff-check
|
||||
|
||||
lint-pylint: ## lint back-end python sources with pylint only on changed files from main
|
||||
@echo 'lint:pylint started…'
|
||||
@$(COMPOSE_RUN_LINT) $(LINT_PYLINT)
|
||||
@$(COMPOSE_RUN_LINT_BACK) $(LINT_PYLINT)
|
||||
.PHONY: lint-pylint
|
||||
|
||||
test: ## run project tests; pass extra pytest args via ARGS, e.g. `make test ARGS="-vv"`
|
||||
|
||||
@@ -16,6 +16,8 @@ the following command inside your docker container:
|
||||
|
||||
## [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.
|
||||
|
||||
@@ -110,6 +110,12 @@ cd -
|
||||
# Update summary pyproject.toml
|
||||
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_python_version "agents"
|
||||
|
||||
@@ -163,6 +169,7 @@ echo " - src/mail/package.json"
|
||||
echo " - src/backend/pyproject.toml"
|
||||
echo " - src/backend/uv.lock"
|
||||
echo " - src/summary/pyproject.toml"
|
||||
echo " - src/summary/uv.lock"
|
||||
echo " - src/agents/pyproject.toml"
|
||||
echo " - src/agents/uv.lock"
|
||||
echo " - CHANGELOG.md"
|
||||
|
||||
+6
-1
@@ -207,12 +207,14 @@ services:
|
||||
- kc_postgresql
|
||||
|
||||
livekit:
|
||||
image: livekit/livekit-server
|
||||
image: livekit/livekit-server:v1.13.6
|
||||
entrypoint: /livekit-server --dev --bind 0.0.0.0 --config ./config.yaml
|
||||
ports:
|
||||
- "7880:7880"
|
||||
- "7881:7881"
|
||||
- "7882:7882/udp"
|
||||
- "3478:3478/udp"
|
||||
- "30000-30100:30000-30100/udp"
|
||||
volumes:
|
||||
- ./docker/livekit/config/livekit-server.yaml:/config.yaml
|
||||
depends_on:
|
||||
@@ -233,6 +235,7 @@ services:
|
||||
build:
|
||||
context: ./src/agents
|
||||
target: development
|
||||
user: ${DOCKER_USER:-1000}
|
||||
command: ["python", "metadata_collector.py", "dev"]
|
||||
env_file:
|
||||
- env.d/development/metadata_collector
|
||||
@@ -251,6 +254,8 @@ services:
|
||||
build:
|
||||
context: ./src/agents
|
||||
target: development
|
||||
user: ${DOCKER_USER:-1000}
|
||||
command: ["python", "multi_user_transcriber.py", "dev"]
|
||||
env_file:
|
||||
- env.d/development/multi_user_transcriber
|
||||
volumes:
|
||||
|
||||
@@ -54,18 +54,11 @@ RUN npx webpack --mode production
|
||||
|
||||
|
||||
# ---- Front-end image ----
|
||||
FROM nginxinc/nginx-unprivileged:1.30.3-alpine3.23 AS frontend-production
|
||||
FROM nginxinc/nginx-unprivileged:1.30.4-alpine3.24 AS frontend-production
|
||||
|
||||
USER root
|
||||
|
||||
# Security patches for known CVEs
|
||||
RUN apk update && apk upgrade \
|
||||
libcrypto3>=3.5.7-r0 \
|
||||
libssl3>=3.5.7-r0 \
|
||||
musl \
|
||||
musl-utils \
|
||||
zlib>=1.3.2-r0 \
|
||||
&& apk del curl
|
||||
RUN apk del curl
|
||||
USER nginx
|
||||
|
||||
USER nginx
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
:root {
|
||||
--fonts-sans: 'Marianne', ui-sans-serif, system-ui, sans-serif;
|
||||
--avatar-cap-height: 0.7;
|
||||
}
|
||||
|
||||
.Header-beforeLogo {
|
||||
|
||||
@@ -14,3 +14,4 @@ accesslog = "-"
|
||||
# Using '-' for the error log file makes gunicorn log errors to stderr
|
||||
errorlog = "-"
|
||||
loglevel = "info"
|
||||
access_log_format = '%(h)s %(l)s %(u)s %(t)s "%(r)s" %(s)s %(b)s "%(f)s" "%(a)s" %(M)s'
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
FROM livekit/livekit-server:v1.9.4
|
||||
FROM livekit/livekit-server:v1.13.6
|
||||
|
||||
# We inject the nip.io certificate manually because the livekit chart doesn't support volume mounting
|
||||
COPY rootCA.pem /etc/ssl/certs/
|
||||
|
||||
@@ -8,3 +8,16 @@ webhook:
|
||||
api_key: devkey
|
||||
urls:
|
||||
- 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
|
||||
|
||||
|
||||
+78
-3
@@ -16,11 +16,11 @@ info:
|
||||
* `rooms:list` – List rooms accessible to the delegated user.
|
||||
* `rooms:retrieve` – Retrieve details of a specific room.
|
||||
* `rooms:create` – Create new rooms.
|
||||
* `rooms:update` – **Coming soon** Update existing rooms, e.g., add attendees to a room.
|
||||
* `rooms:update` – Update the access level and configuration of existing rooms.
|
||||
* `rooms:delete` – **Coming soon** Delete rooms generated by the application.
|
||||
|
||||
|
||||
#### Upcoming Features
|
||||
|
||||
|
||||
* **Add attendees to a room:** You will be able to update a room to include a list of attendees, allowing them to bypass the lobby system automatically.
|
||||
* **Delete application-generated rooms:** Rooms created via the application can be deleted when no longer needed.
|
||||
|
||||
@@ -310,6 +310,67 @@ paths:
|
||||
'404':
|
||||
$ref: '#/components/responses/RoomNotFoundError'
|
||||
|
||||
patch:
|
||||
tags:
|
||||
- Rooms
|
||||
summary: Update a room
|
||||
description: |
|
||||
Partially updates a room. Only the delegated user's rooms where they are
|
||||
administrator or owner can be updated; any other role gets a `403`.
|
||||
|
||||
**Updatable fields:** `access_level` and `configuration`. Every other field
|
||||
(`id`, `name`, `slug`, `pin_code`) is read-only and silently ignored when sent.
|
||||
|
||||
`configuration` is replaced as a whole, it is not merged with the stored one.
|
||||
Send the complete object you want the room to end up with.
|
||||
|
||||
Full replacement (`PUT`) is not supported. Use `PATCH` instead.
|
||||
operationId: updateRoom
|
||||
security:
|
||||
- BearerAuth: [rooms:update]
|
||||
parameters:
|
||||
- name: id
|
||||
in: path
|
||||
required: true
|
||||
description: Room UUID
|
||||
schema:
|
||||
type: string
|
||||
format: uuid
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RoomUpdate'
|
||||
examples:
|
||||
accessLevelOnly:
|
||||
summary: Change the access level
|
||||
value:
|
||||
access_level: "restricted"
|
||||
configurationOnly:
|
||||
summary: Replace the room configuration
|
||||
value:
|
||||
configuration:
|
||||
everyone_can_mute: true
|
||||
responses:
|
||||
'200':
|
||||
description: Room updated successfully
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/Room'
|
||||
'400':
|
||||
$ref: '#/components/responses/BadRequestError'
|
||||
'401':
|
||||
$ref: '#/components/responses/UnauthorizedError'
|
||||
'403':
|
||||
$ref: '#/components/responses/ForbiddenError'
|
||||
'404':
|
||||
$ref: '#/components/responses/RoomNotFoundError'
|
||||
'405':
|
||||
description: |
|
||||
Method not allowed, `PUT` is not supported on this endpoint.
|
||||
|
||||
components:
|
||||
securitySchemes:
|
||||
BearerAuth:
|
||||
@@ -386,6 +447,17 @@ components:
|
||||
configuration:
|
||||
$ref: '#/components/schemas/RoomConfiguration'
|
||||
|
||||
RoomUpdate:
|
||||
type: object
|
||||
description: |
|
||||
Fields that can be updated on an existing room. Both are optional, omitted
|
||||
fields keep their current value.
|
||||
properties:
|
||||
access_level:
|
||||
$ref: '#/components/schemas/RoomAccessLevel'
|
||||
configuration:
|
||||
$ref: '#/components/schemas/RoomConfiguration'
|
||||
|
||||
RoomConfiguration:
|
||||
type: object
|
||||
description: |
|
||||
@@ -427,6 +499,9 @@ components:
|
||||
- `public`: Anyone with the room link can join directly, no authentication required.
|
||||
- `trusted`: Authenticated users join directly. Unauthenticated users wait in the lobby for approval.
|
||||
- `restricted`: Only participants explicitly trusted by the owner bypass the lobby. Everyone else waits for approval regardless of authentication.
|
||||
|
||||
`public` is rejected with a `400` unless the deployment explicitly enables it
|
||||
for this API. This applies both when creating a room and when updating one.
|
||||
example: "trusted"
|
||||
|
||||
Room:
|
||||
|
||||
@@ -20,7 +20,7 @@ info:
|
||||
* `lasuite_visio:rooms:list` – List rooms accessible to the delegated user.
|
||||
* `lasuite_visio:rooms:retrieve` – Retrieve details of a specific room.
|
||||
* `lasuite_visio:rooms:create` – Create new rooms.
|
||||
* `lasuite_visio:rooms:update` – **Coming soon** Update existing rooms, e.g., add attendees to a room.
|
||||
* `lasuite_visio:rooms:update` – Update the access level and configuration of existing rooms.
|
||||
* `lasuite_visio:rooms:delete` – **Coming soon** Delete rooms generated by the application.
|
||||
|
||||
#### Upcoming Features
|
||||
@@ -206,6 +206,67 @@ paths:
|
||||
'404':
|
||||
$ref: '#/components/responses/RoomNotFoundError'
|
||||
|
||||
patch:
|
||||
tags:
|
||||
- Rooms
|
||||
summary: Update a room
|
||||
description: |
|
||||
Partially updates a room. Only rooms where the user is administrator or
|
||||
owner can be updated; any other role gets a `403`.
|
||||
|
||||
**Updatable fields:** `access_level` and `configuration`. Every other field
|
||||
(`id`, `name`, `slug`, `pin_code`) is read-only and silently ignored when sent.
|
||||
|
||||
`configuration` is replaced as a whole, it is not merged with the stored one.
|
||||
Send the complete object you want the room to end up with.
|
||||
|
||||
Full replacement (`PUT`) is not supported. Use `PATCH` instead.
|
||||
operationId: updateRoom
|
||||
security:
|
||||
- BearerAuth: [rooms:update]
|
||||
parameters:
|
||||
- name: id
|
||||
in: path
|
||||
required: true
|
||||
description: Room UUID
|
||||
schema:
|
||||
type: string
|
||||
format: uuid
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RoomUpdate'
|
||||
examples:
|
||||
accessLevelOnly:
|
||||
summary: Change the access level
|
||||
value:
|
||||
access_level: "restricted"
|
||||
configurationOnly:
|
||||
summary: Replace the room configuration
|
||||
value:
|
||||
configuration:
|
||||
everyone_can_mute: true
|
||||
responses:
|
||||
'200':
|
||||
description: Room updated successfully
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/Room'
|
||||
'400':
|
||||
$ref: '#/components/responses/BadRequestError'
|
||||
'401':
|
||||
$ref: '#/components/responses/UnauthorizedError'
|
||||
'403':
|
||||
$ref: '#/components/responses/ForbiddenError'
|
||||
'404':
|
||||
$ref: '#/components/responses/RoomNotFoundError'
|
||||
'405':
|
||||
description: |
|
||||
Method not allowed, `PUT` is not supported on this endpoint.
|
||||
|
||||
components:
|
||||
securitySchemes:
|
||||
BearerAuth:
|
||||
@@ -227,6 +288,17 @@ components:
|
||||
configuration:
|
||||
$ref: '#/components/schemas/RoomConfiguration'
|
||||
|
||||
RoomUpdate:
|
||||
type: object
|
||||
description: |
|
||||
Fields that can be updated on an existing room. Both are optional, omitted
|
||||
fields keep their current value.
|
||||
properties:
|
||||
access_level:
|
||||
$ref: '#/components/schemas/RoomAccessLevel'
|
||||
configuration:
|
||||
$ref: '#/components/schemas/RoomConfiguration'
|
||||
|
||||
RoomConfiguration:
|
||||
type: object
|
||||
description: |
|
||||
@@ -268,6 +340,9 @@ components:
|
||||
- `public`: Anyone with the room link can join directly, no authentication required.
|
||||
- `trusted`: Authenticated users join directly. Unauthenticated users wait in the lobby for approval.
|
||||
- `restricted`: Only participants explicitly trusted by the owner bypass the lobby. Everyone else waits for approval regardless of authentication.
|
||||
|
||||
`public` is rejected with a `400` unless the deployment explicitly enables it
|
||||
for this API. This applies both when creating a room and when updating one.
|
||||
example: "trusted"
|
||||
|
||||
Room:
|
||||
|
||||
@@ -34,6 +34,7 @@ Let's say you want to change the font of our application to a custom font. You c
|
||||
|
||||
:root {
|
||||
--fonts-sans: 'Roboto', ui-sans-serif, system-ui, sans-serif;
|
||||
--avatar-cap-height: 0.7;
|
||||
}
|
||||
```
|
||||
|
||||
|
||||
@@ -1,14 +1,23 @@
|
||||
AWS_S3_ENDPOINT_URL=minio:9000
|
||||
AWS_S3_ACCESS_KEY_ID=meet
|
||||
AWS_S3_SECRET_ACCESS_KEY=password
|
||||
|
||||
LIVEKIT_URL=ws://livekit:7880
|
||||
LIVEKIT_API_KEY=devkey
|
||||
LIVEKIT_API_SECRET=secret
|
||||
|
||||
STT_PROVIDER=kyutai # kyutai, deepgram
|
||||
STT_PROVIDER=voxtral-vllm # voxtral-vllm, kyutai, deepgram
|
||||
ENABLE_SILERO_VAD=False
|
||||
|
||||
DEEPGRAM_API_KEY=
|
||||
DEEPGRAM_API_KEY=your-deepgram-api-key
|
||||
|
||||
KYUTAI_STT_BASE_URL=
|
||||
KYUTAI_API_KEY=
|
||||
KYUTAI_STT_BASE_URL=url
|
||||
KYUTAI_API_KEY=your-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_ENVIRONMENT=
|
||||
|
||||
@@ -3,14 +3,14 @@ Gitlint extra rule to validate that the message title is of the form
|
||||
"<gitmoji>(<scope>) <subject>"
|
||||
"""
|
||||
|
||||
from __future__ import unicode_literals
|
||||
|
||||
import json
|
||||
import re
|
||||
|
||||
import requests
|
||||
import urllib.request
|
||||
|
||||
from gitlint.rules import CommitMessageTitle, LineRule, RuleViolation
|
||||
|
||||
GITMOJIS_URL = "https://raw.githubusercontent.com/carloscuesta/gitmoji/master/packages/gitmojis/src/gitmojis.json"
|
||||
|
||||
|
||||
class GitmojiTitle(LineRule):
|
||||
"""
|
||||
@@ -28,10 +28,9 @@ class GitmojiTitle(LineRule):
|
||||
Download the list possible gitmojis from the project's github repository and check that
|
||||
title contains one of them.
|
||||
"""
|
||||
gitmojis = requests.get(
|
||||
"https://raw.githubusercontent.com/carloscuesta/gitmoji/master/packages/gitmojis/src/gitmojis.json"
|
||||
).json()["gitmojis"]
|
||||
emojis = [item["emoji"] for item in gitmojis]
|
||||
with urllib.request.urlopen(GITMOJIS_URL, timeout=10) as response:
|
||||
gitmojis = json.load(response)["gitmojis"]
|
||||
emojis = [re.escape(item["emoji"]) for item in gitmojis]
|
||||
pattern = r"^({:s})\(.*\)\s[a-z].*$".format("|".join(emojis))
|
||||
if not re.search(pattern, title):
|
||||
violation_msg = 'Title does not match regex "<gitmoji>(<scope>) <subject>"'
|
||||
|
||||
Generated
+8
-5
@@ -9,8 +9,8 @@
|
||||
"version": "0.0.1",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"core-js": "3.49.0",
|
||||
"i18next": "^26.3.6",
|
||||
"core-js": "3.50.0",
|
||||
"i18next": "26.3.6",
|
||||
"i18next-browser-languagedetector": "8.2.1",
|
||||
"regenerator-runtime": "0.14.1"
|
||||
},
|
||||
@@ -6863,11 +6863,14 @@
|
||||
}
|
||||
},
|
||||
"node_modules/core-js": {
|
||||
"version": "3.49.0",
|
||||
"resolved": "https://registry.npmjs.org/core-js/-/core-js-3.49.0.tgz",
|
||||
"integrity": "sha512-es1U2+YTtzpwkxVLwAFdSpaIMyQaq0PBgm3YD1W3Qpsn1NAmO3KSgZfu+oGSWVu6NvLHoHCV/aYcsE5wiB7ALg==",
|
||||
"version": "3.50.0",
|
||||
"resolved": "https://registry.npmjs.org/core-js/-/core-js-3.50.0.tgz",
|
||||
"integrity": "sha512-BRWgOLKkFeCgRudR6zrs8p9XJZcE14grzKMMssoYrk6krtuEZ7MTKPIY5RzOnqsEKIR9kst7wNzphttraT+Yqw==",
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": "*"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/core-js"
|
||||
|
||||
@@ -26,7 +26,7 @@
|
||||
"watch": "webpack --mode development --watch"
|
||||
},
|
||||
"dependencies": {
|
||||
"core-js": "3.49.0",
|
||||
"core-js": "3.50.0",
|
||||
"i18next": "26.3.6",
|
||||
"i18next-browser-languagedetector": "8.2.1",
|
||||
"regenerator-runtime": "0.14.1"
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"""Multi user transcription agent."""
|
||||
|
||||
import asyncio
|
||||
import contextlib
|
||||
import logging
|
||||
import os
|
||||
|
||||
@@ -25,6 +26,7 @@ from livekit.agents import (
|
||||
)
|
||||
from livekit.plugins import deepgram, silero
|
||||
|
||||
import voxtral_vllm_stt
|
||||
from observability import configure_sentry, set_job_context
|
||||
from tasks import done_callback
|
||||
|
||||
@@ -36,9 +38,18 @@ TRANSCRIBER_AGENT_NAME = os.getenv("TRANSCRIBER_AGENT_NAME", "multi-user-transcr
|
||||
STT_PROVIDER = os.getenv("STT_PROVIDER", "deepgram")
|
||||
ENABLE_SILERO_VAD = os.getenv("ENABLE_SILERO_VAD", "true").lower() == "true"
|
||||
|
||||
SESSION_DRAIN_TIMEOUT_S = 15.0
|
||||
|
||||
def create_stt_provider():
|
||||
"""Create STT provider based on environment configuration."""
|
||||
|
||||
def create_stt_provider(vad: silero.VAD | None = None):
|
||||
"""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":
|
||||
# Note: Not all Deepgram API parameters are supported by the LiveKit plugin
|
||||
# detect_language is NOT supported for real-time streaming
|
||||
@@ -49,6 +60,9 @@ def create_stt_provider():
|
||||
)
|
||||
elif STT_PROVIDER == "kyutai":
|
||||
_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:
|
||||
raise ValueError(f"Unknown STT_PROVIDER: {STT_PROVIDER}")
|
||||
|
||||
@@ -58,9 +72,9 @@ def create_stt_provider():
|
||||
class Transcriber(Agent):
|
||||
"""Create a transcription agent for a specific participant."""
|
||||
|
||||
def __init__(self, *, participant_identity: str):
|
||||
def __init__(self, *, participant_identity: str, vad: silero.VAD | None = None):
|
||||
"""Init transcription agent."""
|
||||
stt = create_stt_provider()
|
||||
stt = create_stt_provider(vad=vad)
|
||||
|
||||
super().__init__(
|
||||
instructions="not-needed",
|
||||
@@ -76,6 +90,7 @@ class MultiUserTranscriber:
|
||||
"""Init multi user transcription agent."""
|
||||
self.ctx = ctx
|
||||
self._sessions: dict[str, AgentSession] = {}
|
||||
self._starting: dict[str, asyncio.Task] = {}
|
||||
self._tasks: set[asyncio.Task] = set()
|
||||
|
||||
def start(self):
|
||||
@@ -96,22 +111,30 @@ class MultiUserTranscriber:
|
||||
|
||||
def on_participant_connected(self, participant: rtc.RemoteParticipant):
|
||||
"""Handle new participant connection by starting transcription session."""
|
||||
if participant.identity in self._sessions:
|
||||
identity = participant.identity
|
||||
if identity in self._sessions or identity in self._starting:
|
||||
return
|
||||
|
||||
logger.info(f"starting session for {participant.identity}")
|
||||
logger.info(f"starting session for {identity}")
|
||||
task = asyncio.create_task(self._start_session(participant))
|
||||
self._starting[identity] = task
|
||||
self._tasks.add(task)
|
||||
task.add_done_callback(lambda t, i=identity: self._starting.pop(i, None))
|
||||
task.add_done_callback(
|
||||
done_callback(
|
||||
logger,
|
||||
self._tasks,
|
||||
f"start transcription session for {participant.identity}",
|
||||
f"start transcription session for {identity}",
|
||||
)
|
||||
)
|
||||
|
||||
def on_participant_disconnected(self, participant: rtc.RemoteParticipant):
|
||||
"""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:
|
||||
return
|
||||
|
||||
@@ -127,10 +150,12 @@ class MultiUserTranscriber:
|
||||
)
|
||||
|
||||
async def _start_session(self, participant: rtc.RemoteParticipant) -> AgentSession:
|
||||
"""Create and start transcription session for participant."""
|
||||
if participant.identity in self._sessions:
|
||||
return self._sessions[participant.identity]
|
||||
"""Create and start transcription session for participant.
|
||||
|
||||
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)
|
||||
session = AgentSession(vad=vad)
|
||||
room_io = RoomIO(
|
||||
@@ -141,18 +166,30 @@ class MultiUserTranscriber:
|
||||
text_input=False, audio_output=False, text_output=True
|
||||
),
|
||||
)
|
||||
await room_io.start()
|
||||
await session.start(
|
||||
agent=Transcriber(
|
||||
participant_identity=participant.identity,
|
||||
try:
|
||||
await room_io.start()
|
||||
await session.start(
|
||||
agent=Transcriber(
|
||||
participant_identity=participant.identity,
|
||||
vad=vad,
|
||||
)
|
||||
)
|
||||
)
|
||||
except BaseException:
|
||||
with contextlib.suppress(Exception):
|
||||
await session.aclose()
|
||||
raise
|
||||
self._sessions[participant.identity] = session
|
||||
return session
|
||||
|
||||
async def _close_session(self, sess: AgentSession) -> None:
|
||||
"""Close and cleanup transcription session."""
|
||||
await sess.drain()
|
||||
try:
|
||||
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()
|
||||
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
|
||||
[project]
|
||||
name = "agents"
|
||||
version = "1.29.0"
|
||||
version = "1.31.0"
|
||||
requires-python = ">=3.12"
|
||||
dependencies = [
|
||||
"livekit-agents==1.6.7",
|
||||
@@ -12,6 +12,8 @@ dependencies = [
|
||||
"protobuf==6.33.6",
|
||||
"minio==7.2.20",
|
||||
"sentry-sdk==2.66.1",
|
||||
"websockets==17.1",
|
||||
"httpx==0.28.1",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
|
||||
Generated
+916
-635
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,476 @@
|
||||
"""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)
|
||||
@@ -8,6 +8,7 @@ class AnalyticsEvent(StrEnum):
|
||||
|
||||
# Rooms
|
||||
ROOM_CREATED = "room_created"
|
||||
ROOM_UPDATED = "room_updated"
|
||||
|
||||
# Roomkit (meeting-room SIP devices)
|
||||
ROOMKIT_JOINED = "roomkit_joined"
|
||||
|
||||
@@ -75,11 +75,7 @@ from core.services.participants_management import (
|
||||
ParticipantsManagementException,
|
||||
)
|
||||
from core.services.room_creation import RoomCreation
|
||||
from core.services.room_management import (
|
||||
RoomManagement,
|
||||
RoomManagementException,
|
||||
RoomNotFoundException,
|
||||
)
|
||||
from core.services.room_management import RoomManagement
|
||||
from core.services.room_roles import (
|
||||
RoomRoleError,
|
||||
RoomRoleService,
|
||||
@@ -356,26 +352,7 @@ class RoomViewSet(
|
||||
):
|
||||
return
|
||||
|
||||
metadata = {
|
||||
"configuration": room.configuration,
|
||||
"access_level": room.access_level,
|
||||
}
|
||||
|
||||
try:
|
||||
RoomManagement().update_metadata(
|
||||
room_name=str(room.id),
|
||||
metadata=metadata,
|
||||
)
|
||||
except RoomNotFoundException:
|
||||
logger.info(
|
||||
"LiveKit room %s does not exist yet, skipping metadata sync",
|
||||
room.id,
|
||||
)
|
||||
except RoomManagementException:
|
||||
logger.warning(
|
||||
"Failed to sync metadata to LiveKit for room %s",
|
||||
room.id,
|
||||
)
|
||||
RoomManagement.sync_room_metadata(room)
|
||||
|
||||
@decorators.action(
|
||||
detail=True,
|
||||
@@ -932,6 +909,15 @@ class RoomViewSet(
|
||||
"""Rename the current participant in the room."""
|
||||
room = self.get_object()
|
||||
|
||||
if (
|
||||
not settings.AUTHENTICATED_PARTICIPANTS_CAN_EDIT_DISPLAY_NAME
|
||||
and request.user.is_authenticated
|
||||
):
|
||||
return drf_response.Response(
|
||||
{"error": "Authenticated participants cannot edit their display name"},
|
||||
status=drf_status.HTTP_403_FORBIDDEN,
|
||||
)
|
||||
|
||||
serializer = serializers.RenameParticipantSerializer(data=request.data)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
|
||||
@@ -1076,9 +1062,10 @@ class RecordingViewSet(
|
||||
|
||||
def _auth_get_original_url(self, request):
|
||||
"""
|
||||
Extracts and parses the original URL from the "HTTP_X_ORIGINAL_URL" header.
|
||||
Extracts and parses the original URL from the configured header.
|
||||
Raises PermissionDenied if the header is missing.
|
||||
The original url is passed by nginx in the "HTTP_X_ORIGINAL_URL" header.
|
||||
The original url is passed by the reverse proxy in the header named by the
|
||||
MEDIA_AUTH_ORIGINAL_URL_HEADER setting, which defaults to "HTTP_X_ORIGINAL_URL".
|
||||
See corresponding ingress configuration in Helm chart and read about the
|
||||
nginx.ingress.kubernetes.io/auth-url annotation to understand how the Nginx ingress
|
||||
is configured to do this.
|
||||
@@ -1088,9 +1075,13 @@ class RecordingViewSet(
|
||||
reasons.
|
||||
"""
|
||||
# Extract the original URL from the request header
|
||||
original_url = request.META.get("HTTP_X_ORIGINAL_URL")
|
||||
original_url = request.META.get(settings.MEDIA_AUTH_ORIGINAL_URL_HEADER)
|
||||
if not original_url:
|
||||
logger.warning("Missing HTTP_X_ORIGINAL_URL header in subrequest")
|
||||
logger.warning(
|
||||
"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()
|
||||
|
||||
logger.debug("Original url: '%s'", original_url)
|
||||
@@ -1415,7 +1406,8 @@ class FileViewSet(
|
||||
Authorize access based on the original URL of an Nginx subrequest
|
||||
and user permissions. Returns a dictionary of URL parameters if authorized.
|
||||
|
||||
The original url is passed by nginx in the "HTTP_X_ORIGINAL_URL" header.
|
||||
The original url is passed by the reverse proxy in the header named by the
|
||||
MEDIA_AUTH_ORIGINAL_URL_HEADER setting, which defaults to "HTTP_X_ORIGINAL_URL".
|
||||
See corresponding ingress configuration in Helm chart and read about the
|
||||
nginx.ingress.kubernetes.io/auth-url annotation to understand how the Nginx ingress
|
||||
is configured to do this.
|
||||
@@ -1434,9 +1426,13 @@ class FileViewSet(
|
||||
- PermissionDenied if authorization fails.
|
||||
"""
|
||||
# Extract the original URL from the request header
|
||||
original_url = request.META.get("HTTP_X_ORIGINAL_URL")
|
||||
original_url = request.META.get(settings.MEDIA_AUTH_ORIGINAL_URL_HEADER)
|
||||
if not original_url:
|
||||
logger.warning("Missing HTTP_X_ORIGINAL_URL header in subrequest")
|
||||
logger.warning(
|
||||
"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()
|
||||
|
||||
parsed_url = urlparse(original_url)
|
||||
|
||||
@@ -3,7 +3,11 @@
|
||||
import contextlib
|
||||
|
||||
from django.conf import settings
|
||||
from django.core.exceptions import ImproperlyConfigured, SuspiciousOperation
|
||||
from django.core.exceptions import (
|
||||
ImproperlyConfigured,
|
||||
SuspiciousOperation,
|
||||
ValidationError,
|
||||
)
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from lasuite.oidc_login.backends import (
|
||||
@@ -17,6 +21,7 @@ from core.services.marketing import (
|
||||
ContactData,
|
||||
get_marketing_service,
|
||||
)
|
||||
from core.validators import sub_validator
|
||||
|
||||
|
||||
class OIDCAuthenticationBackend(LaSuiteOIDCAuthenticationBackend):
|
||||
@@ -84,6 +89,19 @@ class OIDCAuthenticationBackend(LaSuiteOIDCAuthenticationBackend):
|
||||
|
||||
def get_existing_user(self, sub, 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:
|
||||
return User.objects.get(sub=sub)
|
||||
except User.DoesNotExist:
|
||||
|
||||
@@ -286,6 +286,10 @@ class ResourceServerBackend(LaSuiteBackend):
|
||||
if user is None and settings.OIDC_CREATE_USER:
|
||||
user = self.create_user(sub)
|
||||
|
||||
if user is not None and not user.is_active:
|
||||
logger.warning("Inactive user attempted authentication: %s", user.pk)
|
||||
raise SuspiciousOperation("User account is disabled.")
|
||||
|
||||
return user
|
||||
|
||||
def create_user(self, sub):
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"""External API endpoints"""
|
||||
|
||||
import copy
|
||||
from logging import getLogger
|
||||
|
||||
from django.conf import settings
|
||||
@@ -25,6 +26,7 @@ from rest_framework import (
|
||||
from core import analytics, api, models
|
||||
from core.api.feature_flag import FeatureFlag
|
||||
from core.services.jwt_token import JwtTokenService
|
||||
from core.services.room_management import RoomManagement
|
||||
|
||||
from ..services.provisional_user_service import (
|
||||
ProvisionalUserCreationDisabledError,
|
||||
@@ -142,6 +144,7 @@ class RoomViewSet(
|
||||
mixins.CreateModelMixin,
|
||||
mixins.RetrieveModelMixin,
|
||||
mixins.ListModelMixin,
|
||||
mixins.UpdateModelMixin,
|
||||
viewsets.GenericViewSet,
|
||||
):
|
||||
"""Application-delegated API for room management.
|
||||
@@ -154,8 +157,12 @@ class RoomViewSet(
|
||||
- list: List rooms the user has access to (requires 'rooms:list' scope)
|
||||
- retrieve: Get room details (requires 'rooms:retrieve' scope)
|
||||
- create: Create a new room owned by the user (requires 'rooms:create' scope)
|
||||
- partial_update: Update a room's access level and configuration, for
|
||||
administrators and owners only (requires 'rooms:update' scope)
|
||||
"""
|
||||
|
||||
http_method_names = ["get", "post", "patch", "head", "options"]
|
||||
|
||||
authentication_classes = [
|
||||
authentication.ApplicationJWTAuthentication,
|
||||
authentication.AddonsJWTAuthentication,
|
||||
@@ -189,7 +196,39 @@ class RoomViewSet(
|
||||
serializer = self.get_serializer(queryset, many=True)
|
||||
return drf_response.Response(serializer.data)
|
||||
|
||||
def perform_create(self, serializer):
|
||||
def _track_room_event(self, room, event, **extra_properties):
|
||||
"""Log a room operation for auditing and forward it to analytics."""
|
||||
|
||||
auth_method = type(self.request.successful_authenticator).__name__
|
||||
client_id = (self.request.auth or {}).get("client_id", "unknown")
|
||||
|
||||
# Log for auditing
|
||||
details = "".join(f", {key}={value}" for key, value in extra_properties.items())
|
||||
logger.info(
|
||||
"Room %s via application: room_id=%s, user_id=%s, client_id=%s, auth_method=%s%s",
|
||||
event.removeprefix("room_"),
|
||||
room.id,
|
||||
self.request.user.id,
|
||||
client_id,
|
||||
auth_method,
|
||||
details,
|
||||
)
|
||||
|
||||
analytics.capture(
|
||||
self.request.user,
|
||||
event,
|
||||
{
|
||||
"room_id": str(room.pk),
|
||||
"access_level": room.access_level,
|
||||
"client_id": client_id,
|
||||
"external_api": True,
|
||||
"auth_method": auth_method,
|
||||
**extra_properties,
|
||||
"$set": {"email": self.request.user.email},
|
||||
},
|
||||
)
|
||||
|
||||
def perform_create(self, serializer: serializers.RoomSerializer):
|
||||
"""Set the current user as owner of the newly created room."""
|
||||
room = serializer.save()
|
||||
models.ResourceAccess.objects.create(
|
||||
@@ -198,27 +237,31 @@ class RoomViewSet(
|
||||
role=models.RoleChoices.OWNER,
|
||||
)
|
||||
|
||||
auth_method = type(self.request.successful_authenticator).__name__
|
||||
client_id = (self.request.auth or {}).get("client_id", "unknown")
|
||||
self._track_room_event(room, analytics.AnalyticsEvent.ROOM_CREATED)
|
||||
|
||||
# Log for auditing
|
||||
logger.info(
|
||||
"Room created via application: room_id=%s, user_id=%s, client_id=%s, auth_method=%s",
|
||||
room.id,
|
||||
self.request.user.id,
|
||||
client_id,
|
||||
auth_method,
|
||||
def perform_update(self, serializer: serializers.RoomSerializer):
|
||||
"""Persist the room update, sync it to LiveKit, then log and track it."""
|
||||
|
||||
previous_values = {
|
||||
"access_level": serializer.instance.access_level,
|
||||
"configuration": copy.deepcopy(serializer.instance.configuration),
|
||||
}
|
||||
|
||||
room = serializer.save()
|
||||
|
||||
# Report the fields that actually changed, not the ones that were submitted.
|
||||
updated_fields = sorted(
|
||||
field
|
||||
for field, previous_value in previous_values.items()
|
||||
if getattr(room, field) != previous_value
|
||||
)
|
||||
|
||||
analytics.capture(
|
||||
self.request.user,
|
||||
analytics.AnalyticsEvent.ROOM_CREATED,
|
||||
{
|
||||
"room_id": str(room.pk),
|
||||
"access_level": room.access_level,
|
||||
"client_id": client_id,
|
||||
"external_api": True,
|
||||
"auth_method": auth_method,
|
||||
"$set": {"email": self.request.user.email},
|
||||
},
|
||||
if updated_fields:
|
||||
RoomManagement.sync_room_metadata(room)
|
||||
|
||||
self._track_room_event(
|
||||
room,
|
||||
analytics.AnalyticsEvent.ROOM_UPDATED,
|
||||
updated_fields=updated_fields,
|
||||
previous_access_level=previous_values["access_level"],
|
||||
)
|
||||
|
||||
@@ -48,8 +48,6 @@ class ResourceFactory(factory.django.DjangoModelFactory):
|
||||
else:
|
||||
UserResourceAccessFactory(resource=self, user=item[0], role=item[1])
|
||||
|
||||
self.save()
|
||||
|
||||
|
||||
class UserResourceAccessFactory(factory.django.DjangoModelFactory):
|
||||
"""Create fake resource user accesses for testing."""
|
||||
@@ -97,8 +95,6 @@ class RecordingFactory(factory.django.DjangoModelFactory):
|
||||
recording=self, user=item[0], role=item[1]
|
||||
)
|
||||
|
||||
self.save()
|
||||
|
||||
|
||||
class UserRecordingAccessFactory(factory.django.DjangoModelFactory):
|
||||
"""Create fake recording user accesses for testing."""
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
"""Logging filters for the core application."""
|
||||
|
||||
import logging
|
||||
|
||||
from django.conf import settings
|
||||
|
||||
|
||||
class SilenceExpected401(logging.Filter):
|
||||
"""Drop the expected 401 from anonymous hits on the /me endpoint.
|
||||
|
||||
The frontend probes `/users/me/` to check authentication; a 401 for
|
||||
anonymous users is normal, not a warning worth logging.
|
||||
"""
|
||||
|
||||
def filter(self, record):
|
||||
"""Return False for a 401 on a silenced path, True otherwise."""
|
||||
if getattr(record, "status_code", None) != 401:
|
||||
return True
|
||||
|
||||
request = getattr(record, "request", None)
|
||||
path = getattr(request, "path", None)
|
||||
if not path:
|
||||
return True
|
||||
|
||||
return path not in settings.LOGGING_SILENCED_401_PATHS
|
||||
@@ -8,6 +8,8 @@ import uuid
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
import core.validators
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
@@ -41,7 +43,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')),
|
||||
('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')),
|
||||
('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')),
|
||||
('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')),
|
||||
('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')),
|
||||
('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')),
|
||||
|
||||
@@ -27,6 +27,7 @@ from timezone_field import TimeZoneField
|
||||
|
||||
from . import fields, utils
|
||||
from .recording.enums import FileExtension
|
||||
from .validators import sub_validator
|
||||
|
||||
logger = getLogger(__name__)
|
||||
|
||||
@@ -145,19 +146,11 @@ class BaseModel(models.Model):
|
||||
class User(AbstractBaseUser, BaseModel, auth_models.PermissionsMixin):
|
||||
"""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"),
|
||||
help_text=_(
|
||||
"Optional for pending users; required upon account activation. "
|
||||
"255 characters or fewer. Letters, numbers, and @/./+/-/_ characters only."
|
||||
"255 characters or fewer. Printable ASCII characters only."
|
||||
),
|
||||
max_length=255,
|
||||
unique=True,
|
||||
|
||||
@@ -44,7 +44,7 @@ class RecordingEventsService:
|
||||
recording_status = status_mapping.get(egress_status)
|
||||
if recording_status:
|
||||
try:
|
||||
RoomManagement().update_metadata(
|
||||
RoomManagement.update_metadata(
|
||||
room_name, {"recording_status": recording_status}
|
||||
)
|
||||
except RoomNotFoundException:
|
||||
|
||||
@@ -68,7 +68,7 @@ class WorkerServiceMediator:
|
||||
mode = recording.options.get("original_mode", None) or recording.mode
|
||||
|
||||
try:
|
||||
RoomManagement().update_metadata(
|
||||
RoomManagement.update_metadata(
|
||||
room_name, {"recording_mode": mode, "recording_status": "starting"}
|
||||
)
|
||||
except RoomNotFoundException:
|
||||
|
||||
@@ -52,12 +52,6 @@ class InvalidPayloadError(LiveKitWebhookError):
|
||||
status_code = 400
|
||||
|
||||
|
||||
class UnsupportedEventTypeError(LiveKitWebhookError):
|
||||
"""Unsupported event type."""
|
||||
|
||||
status_code = 422
|
||||
|
||||
|
||||
class ActionFailedError(LiveKitWebhookError):
|
||||
"""Webhook action fails to process or complete."""
|
||||
|
||||
@@ -74,6 +68,7 @@ class LiveKitWebhookEventType(Enum):
|
||||
# Participant events
|
||||
PARTICIPANT_JOINED = "participant_joined"
|
||||
PARTICIPANT_LEFT = "participant_left"
|
||||
PARTICIPANT_CONNECTION_ABORTED = "participant_connection_aborted"
|
||||
|
||||
# Track events
|
||||
TRACK_PUBLISHED = "track_published"
|
||||
@@ -153,10 +148,13 @@ class LiveKitEventsService:
|
||||
|
||||
try:
|
||||
webhook_type = LiveKitWebhookEventType(data.event)
|
||||
except ValueError as e:
|
||||
raise UnsupportedEventTypeError(
|
||||
f"Unknown webhook type: {data.event}"
|
||||
) from e
|
||||
except ValueError:
|
||||
logger.warning(
|
||||
"Ignoring unknown LiveKit webhook event type '%s' for room '%s'",
|
||||
data.event,
|
||||
room_name,
|
||||
)
|
||||
return
|
||||
|
||||
# Handle according to received webhook type
|
||||
handler = self._webhook_handlers.get(webhook_type.value)
|
||||
@@ -192,7 +190,7 @@ class LiveKitEventsService:
|
||||
|
||||
try:
|
||||
room_name = str(recording.room.id)
|
||||
RoomManagement().update_metadata(
|
||||
RoomManagement.update_metadata(
|
||||
room_name, remove_keys=["recording_mode", "recording_status"]
|
||||
)
|
||||
except RoomNotFoundException:
|
||||
|
||||
@@ -4,11 +4,12 @@ import logging
|
||||
import uuid
|
||||
from dataclasses import dataclass
|
||||
from enum import Enum
|
||||
from typing import Dict, List, Optional, Tuple
|
||||
from typing import Dict, FrozenSet, Optional, Sequence, Tuple
|
||||
from uuid import UUID
|
||||
|
||||
from django.conf import settings
|
||||
from django.core.cache import cache
|
||||
from django.utils import timezone
|
||||
|
||||
from core import models, utils
|
||||
|
||||
@@ -46,6 +47,7 @@ class LobbyParticipant:
|
||||
username: str
|
||||
color: str
|
||||
id: str
|
||||
entered_at: str
|
||||
|
||||
def to_dict(self) -> Dict[str, str]:
|
||||
"""Serialize the participant object to a dict representation."""
|
||||
@@ -54,6 +56,7 @@ class LobbyParticipant:
|
||||
"username": self.username,
|
||||
"id": self.id,
|
||||
"color": self.color,
|
||||
"entered_at": self.entered_at,
|
||||
}
|
||||
|
||||
@classmethod
|
||||
@@ -68,6 +71,7 @@ class LobbyParticipant:
|
||||
username=data["username"],
|
||||
id=data["id"],
|
||||
color=data["color"],
|
||||
entered_at=data["entered_at"],
|
||||
)
|
||||
except (KeyError, ValueError) as e:
|
||||
logger.exception("Error creating Participant from dict:")
|
||||
@@ -86,6 +90,47 @@ class LobbyService:
|
||||
"""Generate cache key for participant(s) data."""
|
||||
return f"{settings.LOBBY_KEY_PREFIX}_{room_id!s}_{participant_id}"
|
||||
|
||||
@staticmethod
|
||||
def _get_index_key(room_id: UUID) -> str:
|
||||
"""Raw Redis key of the per-room participant index (a native SET)."""
|
||||
return cache.client.make_key(f"{settings.LOBBY_KEY_PREFIX}-index_{room_id!s}")
|
||||
|
||||
@staticmethod
|
||||
def _redis(write: bool = True):
|
||||
"""Raw redis-py client.
|
||||
|
||||
SADD/SREM/SMEMBERS are not exposed by the Django cache API; this is
|
||||
the documented django-redis escape hatch.
|
||||
"""
|
||||
return cache.client.get_client(write=write)
|
||||
|
||||
def _index_add(self, room_id: UUID, participant_id: str) -> None:
|
||||
"""Record a participant id in the room index."""
|
||||
index_key = self._get_index_key(room_id)
|
||||
pipe = self._redis().pipeline(transaction=False)
|
||||
pipe.sadd(index_key, participant_id)
|
||||
pipe.expire(index_key, settings.LOBBY_ACCEPTED_TIMEOUT)
|
||||
pipe.execute()
|
||||
|
||||
def _index_members(self, room_id: UUID) -> FrozenSet[str]:
|
||||
"""All participant ids currently indexed for the room."""
|
||||
members = self._redis(write=False).smembers(self._get_index_key(room_id))
|
||||
return frozenset(
|
||||
member.decode() if isinstance(member, bytes) else member
|
||||
for member in members
|
||||
)
|
||||
|
||||
def _index_touch(self, room_id: UUID) -> None:
|
||||
"""Re-arm the room index backstop TTL."""
|
||||
self._redis().expire(
|
||||
self._get_index_key(room_id), settings.LOBBY_ACCEPTED_TIMEOUT
|
||||
)
|
||||
|
||||
def _index_remove(self, room_id: UUID, *participant_ids: str) -> None:
|
||||
"""Drop participant ids from the room index."""
|
||||
if participant_ids:
|
||||
self._redis().srem(self._get_index_key(room_id), *participant_ids)
|
||||
|
||||
@staticmethod
|
||||
def _get_or_create_participant_id(request) -> str:
|
||||
"""Extract unique participant identifier from the request."""
|
||||
@@ -162,6 +207,7 @@ class LobbyService:
|
||||
username=username,
|
||||
id=participant_id,
|
||||
color=utils.generate_color(participant_id),
|
||||
entered_at=timezone.now().isoformat(),
|
||||
)
|
||||
else:
|
||||
participant.status = LobbyParticipantStatus.ACCEPTED
|
||||
@@ -209,15 +255,12 @@ class LobbyService:
|
||||
cache.touch(
|
||||
self._get_cache_key(room_id, participant_id), settings.LOBBY_WAITING_TIMEOUT
|
||||
)
|
||||
self._index_touch(room_id)
|
||||
|
||||
def enter(
|
||||
self, room_id: UUID, participant_id: str, username: str
|
||||
) -> LobbyParticipant:
|
||||
"""Add participant to waiting lobby.
|
||||
|
||||
Create a new participant entry in waiting status and notify room
|
||||
participants of the new entry request.
|
||||
"""
|
||||
"""Add participant to waiting lobby."""
|
||||
|
||||
color = utils.generate_color(participant_id)
|
||||
|
||||
@@ -226,6 +269,7 @@ class LobbyService:
|
||||
username=username,
|
||||
id=participant_id,
|
||||
color=color,
|
||||
entered_at=timezone.now().isoformat(),
|
||||
)
|
||||
|
||||
try:
|
||||
@@ -245,6 +289,7 @@ class LobbyService:
|
||||
participant.to_dict(),
|
||||
timeout=settings.LOBBY_WAITING_TIMEOUT,
|
||||
)
|
||||
self._index_add(room_id, participant_id)
|
||||
|
||||
return participant
|
||||
|
||||
@@ -266,28 +311,42 @@ class LobbyService:
|
||||
cache.delete(cache_key)
|
||||
return None
|
||||
|
||||
def list_waiting_participants(self, room_id: UUID) -> List[dict]:
|
||||
def list_waiting_participants(self, room_id: UUID) -> Sequence[dict]:
|
||||
"""List all waiting participants for a room."""
|
||||
|
||||
pattern = self._get_cache_key(room_id, "*")
|
||||
keys = list(cache.iter_keys(pattern, itersize=utils.CACHE_SCAN_ITERSIZE))
|
||||
member_ids = self._index_members(room_id)
|
||||
|
||||
if not keys:
|
||||
return []
|
||||
if not member_ids:
|
||||
return ()
|
||||
|
||||
data = cache.get_many(keys)
|
||||
keys_by_id = {
|
||||
participant_id: self._get_cache_key(room_id, participant_id)
|
||||
for participant_id in member_ids
|
||||
}
|
||||
data = cache.get_many(list(keys_by_id.values()))
|
||||
|
||||
dead_ids = []
|
||||
waiting_participants = []
|
||||
for cache_key, raw_participant in data.items():
|
||||
|
||||
for participant_id, cache_key in keys_by_id.items():
|
||||
raw_participant = data.get(cache_key)
|
||||
if raw_participant is None:
|
||||
dead_ids.append(participant_id)
|
||||
continue
|
||||
try:
|
||||
participant = LobbyParticipant.from_dict(raw_participant)
|
||||
except LobbyParticipantParsingError:
|
||||
cache.delete(cache_key)
|
||||
dead_ids.append(participant_id)
|
||||
continue
|
||||
if participant.status == LobbyParticipantStatus.WAITING:
|
||||
waiting_participants.append(participant.to_dict())
|
||||
|
||||
return waiting_participants
|
||||
self._index_remove(room_id, *dead_ids)
|
||||
|
||||
waiting_participants.sort(key=lambda p: p["entered_at"], reverse=True)
|
||||
|
||||
return tuple(waiting_participants)
|
||||
|
||||
def handle_participant_entry(
|
||||
self,
|
||||
@@ -341,16 +400,24 @@ class LobbyService:
|
||||
|
||||
participant.status = status
|
||||
cache.set(cache_key, participant.to_dict(), timeout=timeout)
|
||||
self._index_touch(room_id)
|
||||
|
||||
def clear_room_cache(self, room_id: UUID) -> None:
|
||||
"""Clear all participant entries from the cache for a specific room."""
|
||||
|
||||
cache.delete_pattern(
|
||||
self._get_cache_key(room_id, "*"), itersize=utils.CACHE_SCAN_ITERSIZE
|
||||
)
|
||||
member_ids = self._index_members(room_id)
|
||||
if member_ids:
|
||||
cache.delete_many(
|
||||
[
|
||||
self._get_cache_key(room_id, participant_id)
|
||||
for participant_id in member_ids
|
||||
]
|
||||
)
|
||||
self._redis().delete(self._get_index_key(room_id))
|
||||
|
||||
def clear_participant_cache(self, room_id: UUID, participant_id: str) -> None:
|
||||
"""Clear a given participant entry from the cache for a specific room."""
|
||||
|
||||
cache_key = self._get_cache_key(room_id, participant_id)
|
||||
cache.delete(cache_key)
|
||||
self._index_remove(room_id, participant_id)
|
||||
|
||||
@@ -1,25 +1,11 @@
|
||||
"""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.
|
||||
"""
|
||||
"""Presence cache."""
|
||||
|
||||
from typing import FrozenSet
|
||||
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."""
|
||||
@@ -29,24 +15,65 @@ class PresenceCache:
|
||||
"""Cache key for a (room, identity) presence entry."""
|
||||
return f"{settings.PRESENCE_KEY_PREFIX}_{room_id!s}_{identity}"
|
||||
|
||||
@staticmethod
|
||||
def _get_index_key(room_id: UUID | str) -> str:
|
||||
"""Raw Redis key of the per-room identity index (a native SET).
|
||||
|
||||
Built through django-redis' make_key so it lives under the same
|
||||
KEY_PREFIX/version namespace as the presence entries.
|
||||
"""
|
||||
return cache.client.make_key(
|
||||
f"{settings.PRESENCE_KEY_PREFIX}-index_{room_id!s}"
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _redis(write: bool = True):
|
||||
"""Raw redis-py client.
|
||||
|
||||
SADD/SREM/SMEMBERS are not exposed by the Django cache API; this is
|
||||
the documented django-redis escape hatch.
|
||||
"""
|
||||
return cache.client.get_client(write=write)
|
||||
|
||||
def _index_members(self, room_id: UUID | str) -> FrozenSet[str]:
|
||||
"""All identities currently indexed for the room."""
|
||||
members = self._redis(write=False).smembers(self._get_index_key(room_id))
|
||||
return frozenset(
|
||||
member.decode() if isinstance(member, bytes) else member
|
||||
for member in members
|
||||
)
|
||||
|
||||
def is_marked_present(self, room_id: UUID | str, identity: str) -> bool:
|
||||
"""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`."""
|
||||
"""Record that `identity` is in `room_id` and index it for the room."""
|
||||
cache.set(
|
||||
self._get_cache_key(room_id, identity),
|
||||
True,
|
||||
timeout=settings.PRESENCE_CACHE_TIMEOUT,
|
||||
)
|
||||
index_key = self._get_index_key(room_id)
|
||||
pipe = self._redis().pipeline(transaction=False)
|
||||
pipe.sadd(index_key, identity)
|
||||
pipe.expire(index_key, settings.PRESENCE_CACHE_TIMEOUT)
|
||||
pipe.execute()
|
||||
|
||||
def clear(self, room_id: UUID | str, identity: str) -> None:
|
||||
"""Forget presence for one participant (e.g. on participant_left)."""
|
||||
cache.delete(self._get_cache_key(room_id, identity))
|
||||
self._redis().srem(self._get_index_key(room_id), identity)
|
||||
|
||||
def clear_room(self, room_id: UUID | str) -> None:
|
||||
"""Forget presence for every participant of a room (on room_finished)."""
|
||||
cache.delete_pattern(
|
||||
self._get_cache_key(room_id, "*"), itersize=CACHE_SCAN_ITERSIZE
|
||||
)
|
||||
"""Forget presence for every participant of a room (on room_finished).
|
||||
|
||||
Deletes the indexed entries and the index itself with targeted
|
||||
commands instead of a full-keyspace pattern scan.
|
||||
"""
|
||||
identities = self._index_members(room_id)
|
||||
if identities:
|
||||
cache.delete_many(
|
||||
[self._get_cache_key(room_id, identity) for identity in identities]
|
||||
)
|
||||
self._redis().delete(self._get_index_key(room_id))
|
||||
|
||||
@@ -30,9 +30,10 @@ class RoomNotFoundException(RoomManagementException):
|
||||
class RoomManagement:
|
||||
"""Service for managing LiveKit rooms."""
|
||||
|
||||
@classmethod
|
||||
@async_to_sync
|
||||
async def update_metadata(
|
||||
self,
|
||||
cls,
|
||||
room_name: str,
|
||||
metadata: Optional[Dict] = None,
|
||||
remove_keys: Optional[list[str]] = None,
|
||||
@@ -90,8 +91,9 @@ class RoomManagement:
|
||||
finally:
|
||||
await lkapi.aclose()
|
||||
|
||||
@classmethod
|
||||
@async_to_sync
|
||||
async def delete_room(self, room_name: str):
|
||||
async def delete_room(cls, room_name: str):
|
||||
"""Delete a LiveKit room and disconnect all participants.
|
||||
|
||||
Raises:
|
||||
@@ -116,3 +118,32 @@ class RoomManagement:
|
||||
raise RoomManagementException("Could not delete room") from e
|
||||
finally:
|
||||
await lkapi.aclose()
|
||||
|
||||
@classmethod
|
||||
def sync_room_metadata(cls, room):
|
||||
"""Push a room's configuration and access level to its LiveKit room metadata.
|
||||
|
||||
Failures are swallowed: a room that is not live yet, or a LiveKit hiccup,
|
||||
should never fail the request that triggered the update.
|
||||
"""
|
||||
|
||||
metadata = {
|
||||
"configuration": room.configuration,
|
||||
"access_level": room.access_level,
|
||||
}
|
||||
|
||||
try:
|
||||
cls.update_metadata(
|
||||
room_name=str(room.id),
|
||||
metadata=metadata,
|
||||
)
|
||||
except RoomNotFoundException:
|
||||
logger.info(
|
||||
"LiveKit room %s does not exist yet, skipping metadata sync",
|
||||
room.id,
|
||||
)
|
||||
except RoomManagementException:
|
||||
logger.warning(
|
||||
"Failed to sync metadata to LiveKit for room %s",
|
||||
room.id,
|
||||
)
|
||||
|
||||
@@ -31,7 +31,7 @@ def delete_connection_test_room(room_name: str):
|
||||
return
|
||||
|
||||
try:
|
||||
RoomManagement().delete_room(room_name)
|
||||
RoomManagement.delete_room(room_name)
|
||||
except RoomNotFoundException:
|
||||
# Room may already be gone after empty/departure timeout.
|
||||
logger.info("Connection test room '%s' already gone.", room_name)
|
||||
|
||||
@@ -40,6 +40,111 @@ def test_authentication_getter_existing_user(monkeypatch):
|
||||
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):
|
||||
"""
|
||||
If no user matches, a user should be created.
|
||||
|
||||
@@ -7,6 +7,7 @@ from urllib.parse import quote, urlparse
|
||||
|
||||
from django.conf import settings
|
||||
from django.core.files.storage import default_storage
|
||||
from django.test import override_settings
|
||||
from django.utils import timezone
|
||||
|
||||
import pytest
|
||||
@@ -143,3 +144,59 @@ def test_api_files_media_auth_own_file_deleted():
|
||||
)
|
||||
|
||||
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
|
||||
|
||||
@@ -8,6 +8,7 @@ from uuid import uuid4
|
||||
|
||||
from django.conf import settings
|
||||
from django.core.files.storage import default_storage
|
||||
from django.test import override_settings
|
||||
from django.utils import timezone
|
||||
|
||||
import pytest
|
||||
@@ -282,3 +283,63 @@ def test_api_recordings_media_auth_success_administrator(mode):
|
||||
timeout=1,
|
||||
)
|
||||
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
|
||||
|
||||
@@ -9,6 +9,7 @@ from unittest import mock
|
||||
from django.core.cache import cache
|
||||
|
||||
import pytest
|
||||
from freezegun import freeze_time
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
from ... import utils
|
||||
@@ -24,6 +25,7 @@ pytestmark = pytest.mark.django_db
|
||||
# Tests for request_entry endpoint
|
||||
|
||||
|
||||
@freeze_time("2025-01-01 10:00:00")
|
||||
def test_request_entry_anonymous(settings):
|
||||
"""Anonymous users should be allowed to request entry to a room."""
|
||||
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
|
||||
@@ -59,6 +61,7 @@ def test_request_entry_anonymous(settings):
|
||||
"username": "test_user",
|
||||
"status": "waiting",
|
||||
"color": "mocked-color",
|
||||
"entered_at": "2025-01-01T10:00:00+00:00",
|
||||
"livekit": None,
|
||||
}
|
||||
|
||||
@@ -71,6 +74,7 @@ def test_request_entry_anonymous(settings):
|
||||
assert participant_data.get("username") == "test_user"
|
||||
|
||||
|
||||
@freeze_time("2025-01-01 10:00:00")
|
||||
def test_request_entry_authenticated_user(settings):
|
||||
"""Authenticated users should be allowed to request entry."""
|
||||
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
|
||||
@@ -108,6 +112,7 @@ def test_request_entry_authenticated_user(settings):
|
||||
"username": "test_user",
|
||||
"status": "waiting",
|
||||
"color": "mocked-color",
|
||||
"entered_at": "2025-01-01T10:00:00+00:00",
|
||||
"livekit": None,
|
||||
}
|
||||
|
||||
@@ -120,6 +125,7 @@ def test_request_entry_authenticated_user(settings):
|
||||
assert participant_data.get("username") == "test_user"
|
||||
|
||||
|
||||
@freeze_time("2025-01-01 10:00:00")
|
||||
def test_request_entry_with_existing_participants(settings):
|
||||
"""Anonymous users should be allowed to request entry to a room with existing participants."""
|
||||
# Create a restricted access room
|
||||
@@ -138,6 +144,7 @@ def test_request_entry_with_existing_participants(settings):
|
||||
"username": "user1",
|
||||
"status": "waiting",
|
||||
"color": "#123456",
|
||||
"entered_at": "2025-01-01T10:00:00+00:00",
|
||||
},
|
||||
)
|
||||
cache.set(
|
||||
@@ -147,6 +154,7 @@ def test_request_entry_with_existing_participants(settings):
|
||||
"username": "user2",
|
||||
"status": "accepted",
|
||||
"color": "#654321",
|
||||
"entered_at": "2025-01-01T10:00:00+00:00",
|
||||
},
|
||||
)
|
||||
|
||||
@@ -178,6 +186,7 @@ def test_request_entry_with_existing_participants(settings):
|
||||
assert response.json() == {
|
||||
"id": participant_id,
|
||||
"username": "test_user",
|
||||
"entered_at": "2025-01-01T10:00:00+00:00",
|
||||
"status": "waiting",
|
||||
"color": "mocked-color",
|
||||
"livekit": None,
|
||||
@@ -192,6 +201,7 @@ def test_request_entry_with_existing_participants(settings):
|
||||
assert participant_data.get("username") == "test_user"
|
||||
|
||||
|
||||
@freeze_time("2025-01-01 10:00:00")
|
||||
def test_request_entry_public_room(settings):
|
||||
"""Entry requests to public rooms should return ACCEPTED status with LiveKit config."""
|
||||
room = RoomFactory(access_level=RoomAccessLevel.PUBLIC)
|
||||
@@ -230,6 +240,7 @@ def test_request_entry_public_room(settings):
|
||||
assert response.json() == {
|
||||
"id": "123",
|
||||
"username": "test_user",
|
||||
"entered_at": "2025-01-01T10:00:00+00:00",
|
||||
"status": "accepted",
|
||||
"color": "mocked-color",
|
||||
"livekit": {"token": "test-token"},
|
||||
@@ -240,6 +251,7 @@ def test_request_entry_public_room(settings):
|
||||
assert not lobby_keys
|
||||
|
||||
|
||||
@freeze_time("2025-01-01 10:00:00")
|
||||
def test_request_entry_authenticated_user_public_room(settings):
|
||||
"""While authenticated, entry request to public rooms should get accepted."""
|
||||
room = RoomFactory(access_level=RoomAccessLevel.PUBLIC)
|
||||
@@ -282,6 +294,7 @@ def test_request_entry_authenticated_user_public_room(settings):
|
||||
assert response.json() == {
|
||||
"id": "2f7f162f-e7d1-421b-90e7-02bfbfbf8def",
|
||||
"username": "test_user",
|
||||
"entered_at": "2025-01-01T10:00:00+00:00",
|
||||
"status": "accepted",
|
||||
"color": "mocked-color",
|
||||
"livekit": {"token": "test-token"},
|
||||
@@ -292,6 +305,7 @@ def test_request_entry_authenticated_user_public_room(settings):
|
||||
assert not lobby_keys
|
||||
|
||||
|
||||
@freeze_time("2025-01-01 10:00:00")
|
||||
def test_request_entry_waiting_participant_public_room(settings):
|
||||
"""While waiting, entry request to public rooms should get accepted."""
|
||||
room = RoomFactory(access_level=RoomAccessLevel.PUBLIC)
|
||||
@@ -308,6 +322,7 @@ def test_request_entry_waiting_participant_public_room(settings):
|
||||
"username": "user1",
|
||||
"status": "waiting",
|
||||
"color": "#123456",
|
||||
"entered_at": "2025-01-01T10:00:00+00:00",
|
||||
},
|
||||
)
|
||||
|
||||
@@ -338,6 +353,7 @@ def test_request_entry_waiting_participant_public_room(settings):
|
||||
"username": "user1",
|
||||
"status": "accepted",
|
||||
"color": "#123456",
|
||||
"entered_at": "2025-01-01T10:00:00+00:00",
|
||||
"livekit": {"token": "test-token"},
|
||||
}
|
||||
|
||||
@@ -443,6 +459,7 @@ def test_allow_participant_to_enter_success(settings, allow_entry, updated_statu
|
||||
"status": "waiting",
|
||||
"username": "foo",
|
||||
"color": "123",
|
||||
"entered_at": "2025-01-01T10:00:00+00:00",
|
||||
},
|
||||
)
|
||||
|
||||
@@ -578,6 +595,7 @@ def test_list_waiting_participants_success(settings):
|
||||
"username": "user1",
|
||||
"status": "waiting",
|
||||
"color": "#123456",
|
||||
"entered_at": "2025-01-01T10:00:00+00:00",
|
||||
},
|
||||
)
|
||||
cache.set(
|
||||
@@ -587,28 +605,35 @@ def test_list_waiting_participants_success(settings):
|
||||
"username": "user2",
|
||||
"status": "waiting",
|
||||
"color": "#654321",
|
||||
"entered_at": "2025-01-01T10:05:00+00:00",
|
||||
},
|
||||
)
|
||||
lobby_service = LobbyService()
|
||||
lobby_service._index_add(room.id, "2f7f162f-e7d1-421b-90e7-02bfbfbf8def")
|
||||
lobby_service._index_add(room.id, "f4ca3ab8a6c04ad88097b8da33f60f10")
|
||||
|
||||
response = client.get(f"/api/v1.0/rooms/{room.id}/waiting-participants/")
|
||||
|
||||
assert response.status_code == 200
|
||||
|
||||
participants = response.json().get("participants")
|
||||
assert sorted(participants, key=lambda p: p["id"]) == [
|
||||
{
|
||||
"id": "2f7f162f-e7d1-421b-90e7-02bfbfbf8def",
|
||||
"username": "user1",
|
||||
"status": "waiting",
|
||||
"color": "#123456",
|
||||
},
|
||||
{
|
||||
"id": "f4ca3ab8a6c04ad88097b8da33f60f10",
|
||||
"username": "user2",
|
||||
"status": "waiting",
|
||||
"color": "#654321",
|
||||
},
|
||||
]
|
||||
assert response.json() == {
|
||||
"participants": [
|
||||
{
|
||||
"id": "f4ca3ab8a6c04ad88097b8da33f60f10",
|
||||
"username": "user2",
|
||||
"status": "waiting",
|
||||
"color": "#654321",
|
||||
"entered_at": "2025-01-01T10:05:00+00:00",
|
||||
},
|
||||
{
|
||||
"id": "2f7f162f-e7d1-421b-90e7-02bfbfbf8def",
|
||||
"username": "user1",
|
||||
"status": "waiting",
|
||||
"color": "#123456",
|
||||
"entered_at": "2025-01-01T10:00:00+00:00",
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
def test_list_waiting_participants_empty(settings):
|
||||
|
||||
@@ -372,6 +372,67 @@ def test_rename_participant_unexpected_twirp_error(mock_livekit_client, room, to
|
||||
mock_livekit_client.aclose.assert_called_once()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("name", ["John Doe", "Admin", "Room Owner"])
|
||||
def test_rename_participant_forbidden_when_display_name_edit_disabled(
|
||||
mock_livekit_client, settings, room, token, name
|
||||
):
|
||||
"""
|
||||
Test rename is rejected for authenticated users when the self-hoster
|
||||
disables AUTHENTICATED_PARTICIPANTS_CAN_EDIT_DISPLAY_NAME.
|
||||
"""
|
||||
settings.AUTHENTICATED_PARTICIPANTS_CAN_EDIT_DISPLAY_NAME = False
|
||||
|
||||
client = APIClient()
|
||||
url = reverse("rooms-rename", kwargs={"pk": room.id})
|
||||
response = client.post(
|
||||
url, {"name": name}, format="json", HTTP_AUTHORIZATION=f"Bearer {token}"
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_403_FORBIDDEN
|
||||
assert response.data == {
|
||||
"error": "Authenticated participants cannot edit their display name"
|
||||
}
|
||||
mock_livekit_client.room.update_participant.assert_not_called()
|
||||
|
||||
|
||||
def test_rename_participant_allowed_when_display_name_edit_enabled(
|
||||
mock_livekit_client, settings, room, token
|
||||
):
|
||||
"""Test rename still works for authenticated users when the setting is enabled."""
|
||||
settings.AUTHENTICATED_PARTICIPANTS_CAN_EDIT_DISPLAY_NAME = True
|
||||
|
||||
client = APIClient()
|
||||
url = reverse("rooms-rename", kwargs={"pk": room.id})
|
||||
response = client.post(
|
||||
url, {"name": "John Doe"}, format="json", HTTP_AUTHORIZATION=f"Bearer {token}"
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
mock_livekit_client.room.update_participant.assert_called_once()
|
||||
|
||||
|
||||
def test_rename_participant_anonymous_allowed_when_display_name_edit_disabled(
|
||||
mock_livekit_client, settings, room, anonymous_token
|
||||
):
|
||||
"""
|
||||
Test the setting only restricts authenticated users: anonymous participants
|
||||
have no account name to fall back on and can still rename themselves.
|
||||
"""
|
||||
settings.AUTHENTICATED_PARTICIPANTS_CAN_EDIT_DISPLAY_NAME = False
|
||||
|
||||
client = APIClient()
|
||||
url = reverse("rooms-rename", kwargs={"pk": room.id})
|
||||
response = client.post(
|
||||
url,
|
||||
{"name": "Guest User"},
|
||||
format="json",
|
||||
HTTP_AUTHORIZATION=f"Bearer {anonymous_token}",
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
mock_livekit_client.room.update_participant.assert_called_once()
|
||||
|
||||
|
||||
def test_rename_participant_success_anonymous(
|
||||
mock_livekit_client, room, anonymous_token
|
||||
):
|
||||
|
||||
@@ -381,38 +381,11 @@ def test_api_rooms_update_administrators_of_another():
|
||||
assert other_room.slug == "old-name"
|
||||
|
||||
|
||||
@patch.object(RoomManagement, "update_metadata", side_effect=RoomNotFoundException)
|
||||
def test_api_rooms_update_livekit_room_not_found(mock_update_metadata):
|
||||
"""Should not fail the API request when the LiveKit room does not exist yet."""
|
||||
user = UserFactory()
|
||||
room = RoomFactory(
|
||||
users=[(user, random.choice(["administrator", "owner"]))],
|
||||
configuration={},
|
||||
)
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
response = client.patch(
|
||||
f"/api/v1.0/rooms/{room.id!s}/",
|
||||
{"configuration": {"can_publish_sources": ["camera"]}},
|
||||
format="json",
|
||||
)
|
||||
assert response.status_code == 200
|
||||
room.refresh_from_db()
|
||||
assert room.configuration == {"can_publish_sources": ["camera"]}
|
||||
|
||||
mock_update_metadata.assert_called_once_with(
|
||||
room_name=str(room.id),
|
||||
metadata={
|
||||
"access_level": room.access_level,
|
||||
"configuration": {"can_publish_sources": ["camera"]},
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@patch.object(RoomManagement, "update_metadata", side_effect=RoomManagementException)
|
||||
def test_api_rooms_update_livekit_sync_failure(mock_update_metadata):
|
||||
@pytest.mark.parametrize("exception", [RoomNotFoundException, RoomManagementException])
|
||||
@patch.object(RoomManagement, "update_metadata")
|
||||
def test_api_rooms_update_livekit_sync_failure(mock_update_metadata, exception):
|
||||
"""Should not fail the API request when the LiveKit metadata sync fails."""
|
||||
mock_update_metadata.side_effect = exception
|
||||
user = UserFactory()
|
||||
room = RoomFactory(
|
||||
users=[(user, random.choice(["administrator", "owner"]))],
|
||||
|
||||
@@ -94,7 +94,7 @@ def test_invalid_payload(client, auth_token, mock_livekit_config):
|
||||
|
||||
|
||||
def test_unknown_event_type(client, mock_livekit_config):
|
||||
"""Should return 422 for unknown event type."""
|
||||
"""Should acknowledge (200) an unknown event type rather than reject it."""
|
||||
event_data = json.dumps({"event": "unknown_event_type"})
|
||||
|
||||
# Generate auth token for this specific payload
|
||||
@@ -112,10 +112,8 @@ def test_unknown_event_type(client, mock_livekit_config):
|
||||
HTTP_AUTHORIZATION=auth_token,
|
||||
)
|
||||
|
||||
assert response.status_code == 422
|
||||
assert response.json() == {
|
||||
"status": "error",
|
||||
}
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {"status": "success"}
|
||||
|
||||
|
||||
@mock.patch.object(LiveKitEventsService, "_handle_room_finished")
|
||||
|
||||
@@ -16,7 +16,6 @@ from core.services.livekit_events import (
|
||||
AuthenticationError,
|
||||
InvalidPayloadError,
|
||||
LiveKitEventsService,
|
||||
UnsupportedEventTypeError,
|
||||
api,
|
||||
)
|
||||
from core.services.lobby import LobbyService
|
||||
@@ -665,22 +664,27 @@ def test_receive_missing_auth(service):
|
||||
|
||||
|
||||
@mock.patch.object(api.WebhookReceiver, "receive")
|
||||
def test_receive_unsupported_event(mock_receive, service):
|
||||
"""Should raise LiveKitWebhookError for unsupported events."""
|
||||
def test_receive_unknown_event_is_acknowledged(mock_receive, service, caplog):
|
||||
"""Unknown event types are logged and ignored, not rejected.
|
||||
|
||||
LiveKit adds event types over time and does not retry 4xx responses, so
|
||||
raising here would silently drop the event.
|
||||
"""
|
||||
mock_request = mock.MagicMock()
|
||||
mock_request.headers = {"Authorization": "test_token"}
|
||||
mock_request.body = b"{}"
|
||||
|
||||
# Mock returned data with unsupported event type
|
||||
mock_data = mock.MagicMock()
|
||||
mock_data.room.name = str(uuid.uuid4())
|
||||
mock_data.event = "unsupported_event"
|
||||
mock_data.event = "some_future_event"
|
||||
mock_receive.return_value = mock_data
|
||||
|
||||
with pytest.raises(
|
||||
UnsupportedEventTypeError, match="Unknown webhook type: unsupported_event"
|
||||
):
|
||||
service.receive(mock_request)
|
||||
with caplog.at_level("WARNING", logger="core.services.livekit_events"):
|
||||
service.receive(mock_request) # must not raise
|
||||
|
||||
assert "Ignoring unknown LiveKit webhook event type 'some_future_event'" in (
|
||||
caplog.text
|
||||
)
|
||||
|
||||
|
||||
@mock.patch.object(api.WebhookReceiver, "receive")
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
Test lobby service.
|
||||
"""
|
||||
|
||||
# pylint: disable=W0621,W0613, W0212, R0913
|
||||
# pylint: disable=W0621,W0613, W0212, R0913, C0302
|
||||
# ruff: noqa: PLR0913, PLR0917
|
||||
|
||||
import uuid
|
||||
@@ -14,6 +14,7 @@ from django.core.cache import cache
|
||||
from django.http import HttpResponse
|
||||
|
||||
import pytest
|
||||
from freezegun import freeze_time
|
||||
|
||||
from core.factories import RoomFactory, UserFactory, UserResourceAccessFactory
|
||||
from core.models import RoleChoices, RoomAccessLevel
|
||||
@@ -24,7 +25,6 @@ from core.services.lobby import (
|
||||
LobbyParticipantStatus,
|
||||
LobbyService,
|
||||
)
|
||||
from core.services.presence import CACHE_SCAN_ITERSIZE
|
||||
from core.utils import NotificationError
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
@@ -56,6 +56,7 @@ def participant_dict():
|
||||
"username": "test-username",
|
||||
"id": "test-participant-id",
|
||||
"color": "#123456",
|
||||
"entered_at": "2025-01-01T10:00:00+00:00",
|
||||
}
|
||||
|
||||
|
||||
@@ -67,6 +68,7 @@ def participant_data():
|
||||
username="test-username",
|
||||
id="test-participant-id",
|
||||
color="#123456",
|
||||
entered_at="2025-01-01T10:00:00+00:00",
|
||||
)
|
||||
|
||||
|
||||
@@ -78,6 +80,7 @@ def test_lobby_participant_to_dict(participant_data):
|
||||
assert result["username"] == "test-username"
|
||||
assert result["id"] == "test-participant-id"
|
||||
assert result["color"] == "#123456"
|
||||
assert result["entered_at"] == "2025-01-01T10:00:00+00:00"
|
||||
|
||||
|
||||
def test_lobby_participant_from_dict_success(participant_dict):
|
||||
@@ -88,6 +91,20 @@ def test_lobby_participant_from_dict_success(participant_dict):
|
||||
assert participant.username == "test-username"
|
||||
assert participant.id == "test-participant-id"
|
||||
assert participant.color == "#123456"
|
||||
assert participant.entered_at == "2025-01-01T10:00:00+00:00"
|
||||
|
||||
|
||||
def test_lobby_participant_from_dict_missing_entered_at():
|
||||
"""`entered_at` is mandatory; data without it is rejected."""
|
||||
data = {
|
||||
"status": "waiting",
|
||||
"username": "test-username",
|
||||
"id": "test-participant-id",
|
||||
"color": "#123456",
|
||||
}
|
||||
|
||||
with pytest.raises(LobbyParticipantParsingError, match="Invalid participant data"):
|
||||
LobbyParticipant.from_dict(data)
|
||||
|
||||
|
||||
def test_lobby_participant_from_dict_default_status():
|
||||
@@ -96,6 +113,7 @@ def test_lobby_participant_from_dict_default_status():
|
||||
"username": "test-username",
|
||||
"id": "test-participant-id",
|
||||
"color": "#123456",
|
||||
"entered_at": "2025-01-01T10:00:00+00:00",
|
||||
}
|
||||
|
||||
participant = LobbyParticipant.from_dict(data_without_status)
|
||||
@@ -121,6 +139,7 @@ def test_lobby_participant_from_dict_invalid_status():
|
||||
"username": "test-username",
|
||||
"id": "test-participant-id",
|
||||
"color": "#123456",
|
||||
"entered_at": "2025-01-01T10:00:00+00:00",
|
||||
}
|
||||
|
||||
with pytest.raises(LobbyParticipantParsingError, match="Invalid participant data"):
|
||||
@@ -265,6 +284,7 @@ def test_request_entry_public_room(
|
||||
username=username,
|
||||
id=participant_id,
|
||||
color="#123456",
|
||||
entered_at="2025-01-01T10:00:00+00:00",
|
||||
)
|
||||
|
||||
lobby_service._get_or_create_participant_id = mock.Mock(return_value=participant_id)
|
||||
@@ -303,6 +323,7 @@ def test_request_entry_trusted_room(
|
||||
username=username,
|
||||
id=participant_id,
|
||||
color="#123456",
|
||||
entered_at="2025-01-01T10:00:00+00:00",
|
||||
)
|
||||
|
||||
lobby_service._get_or_create_participant_id = mock.Mock(return_value=participant_id)
|
||||
@@ -345,6 +366,7 @@ def test_request_entry_new_participant(
|
||||
username=username,
|
||||
id=participant_id,
|
||||
color="#123456",
|
||||
entered_at="2025-01-01T10:00:00+00:00",
|
||||
)
|
||||
mock_enter.return_value = participant_data
|
||||
|
||||
@@ -372,6 +394,7 @@ def test_request_entry_waiting_participant(
|
||||
username=username,
|
||||
id=participant_id,
|
||||
color="#123456",
|
||||
entered_at="2025-01-01T10:00:00+00:00",
|
||||
)
|
||||
lobby_service._get_or_create_participant_id = mock.Mock(return_value=participant_id)
|
||||
lobby_service._get_participant = mock.Mock(return_value=mocked_participant)
|
||||
@@ -400,6 +423,7 @@ def test_request_entry_accepted_participant(
|
||||
username=username,
|
||||
id=participant_id,
|
||||
color="#123456",
|
||||
entered_at="2025-01-01T10:00:00+00:00",
|
||||
)
|
||||
lobby_service._get_or_create_participant_id = mock.Mock(return_value=participant_id)
|
||||
lobby_service._get_participant = mock.Mock(return_value=mocked_participant)
|
||||
@@ -440,6 +464,7 @@ def test_request_entry_participant_with_role(
|
||||
username=username,
|
||||
id=participant_id,
|
||||
color="#123456",
|
||||
entered_at="2025-01-01T10:00:00+00:00",
|
||||
)
|
||||
lobby_service._get_or_create_participant_id = mock.Mock(return_value=participant_id)
|
||||
lobby_service._get_participant = mock.Mock(return_value=mocked_participant)
|
||||
@@ -466,18 +491,23 @@ def test_request_entry_participant_with_role(
|
||||
def test_refresh_waiting_status(mock_cache, lobby_service, participant_id):
|
||||
"""Test refreshing waiting status for a participant."""
|
||||
lobby_service._get_cache_key = mock.Mock(return_value="mocked_cache_key")
|
||||
lobby_service._index_touch = mock.Mock()
|
||||
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
|
||||
lobby_service.refresh_waiting_status(room.id, participant_id)
|
||||
mock_cache.touch.assert_called_once_with(
|
||||
"mocked_cache_key", settings.LOBBY_WAITING_TIMEOUT
|
||||
)
|
||||
lobby_service._index_touch.assert_called_once_with(room.id)
|
||||
|
||||
|
||||
# pylint: disable=R0917
|
||||
@mock.patch("core.services.lobby.cache")
|
||||
@mock.patch("core.utils.generate_color")
|
||||
@mock.patch("core.utils.notify_participants")
|
||||
@mock.patch("core.services.lobby.LobbyService._index_add")
|
||||
@freeze_time("2025-01-01 10:00:00")
|
||||
def test_enter_success(
|
||||
mock_index_add,
|
||||
mock_notify,
|
||||
mock_generate_color,
|
||||
mock_cache,
|
||||
@@ -497,6 +527,7 @@ def test_enter_success(
|
||||
assert participant.username == username
|
||||
assert participant.id == participant_id
|
||||
assert participant.color == "#123456"
|
||||
assert participant.entered_at == "2025-01-01T10:00:00+00:00"
|
||||
|
||||
lobby_service._get_cache_key.assert_called_once_with(room.id, participant_id)
|
||||
|
||||
@@ -508,13 +539,16 @@ def test_enter_success(
|
||||
mock_notify.assert_called_once_with(
|
||||
room_name=str(room.pk), notification_data={"type": "participantWaiting"}
|
||||
)
|
||||
mock_index_add.assert_called_once_with(room.id, participant_id)
|
||||
|
||||
|
||||
# pylint: disable=R0917
|
||||
@mock.patch("core.services.lobby.cache")
|
||||
@mock.patch("core.utils.generate_color")
|
||||
@mock.patch("core.utils.notify_participants")
|
||||
@mock.patch("core.services.lobby.LobbyService._index_add")
|
||||
def test_enter_with_notification_error(
|
||||
mock_index_add,
|
||||
mock_notify,
|
||||
mock_generate_color,
|
||||
mock_cache,
|
||||
@@ -541,6 +575,7 @@ def test_enter_with_notification_error(
|
||||
participant.to_dict(),
|
||||
timeout=settings.LOBBY_WAITING_TIMEOUT,
|
||||
)
|
||||
mock_index_add.assert_called_once_with(room.id, participant_id)
|
||||
|
||||
|
||||
@mock.patch("core.services.lobby.cache")
|
||||
@@ -579,14 +614,15 @@ def test_get_participant_parsing_error(
|
||||
@mock.patch("core.services.lobby.cache")
|
||||
def test_list_waiting_participants_empty(mock_cache, lobby_service):
|
||||
"""Test listing waiting participants when none exist."""
|
||||
mock_cache.iter_keys.return_value = []
|
||||
|
||||
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
|
||||
lobby_service._index_members = mock.Mock(return_value=[])
|
||||
lobby_service._index_remove = mock.Mock()
|
||||
|
||||
result = lobby_service.list_waiting_participants(room.id)
|
||||
|
||||
assert result == []
|
||||
pattern = f"{settings.LOBBY_KEY_PREFIX}_{room.id!s}_*"
|
||||
mock_cache.iter_keys.assert_called_once_with(pattern, itersize=CACHE_SCAN_ITERSIZE)
|
||||
assert result == ()
|
||||
lobby_service._index_members.assert_called_once_with(room.id)
|
||||
lobby_service._index_remove.assert_not_called()
|
||||
mock_cache.get_many.assert_not_called()
|
||||
|
||||
|
||||
@@ -595,7 +631,8 @@ def test_list_waiting_participants(mock_cache, lobby_service, participant_dict):
|
||||
"""Test listing waiting participants with valid data."""
|
||||
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
|
||||
cache_key = f"{settings.LOBBY_KEY_PREFIX}_{room.id!s}_participant1"
|
||||
mock_cache.iter_keys.return_value = [cache_key]
|
||||
lobby_service._index_members = mock.Mock(return_value=["participant1"])
|
||||
lobby_service._index_remove = mock.Mock()
|
||||
mock_cache.get_many.return_value = {cache_key: participant_dict}
|
||||
|
||||
result = lobby_service.list_waiting_participants(room.id)
|
||||
@@ -603,8 +640,8 @@ def test_list_waiting_participants(mock_cache, lobby_service, participant_dict):
|
||||
assert len(result) == 1
|
||||
assert result[0]["status"] == "waiting"
|
||||
assert result[0]["username"] == "test-username"
|
||||
pattern = f"{settings.LOBBY_KEY_PREFIX}_{room.id!s}_*"
|
||||
mock_cache.iter_keys.assert_called_once_with(pattern, itersize=CACHE_SCAN_ITERSIZE)
|
||||
lobby_service._index_members.assert_called_once_with(room.id)
|
||||
lobby_service._index_remove.assert_called_once_with(room.id)
|
||||
mock_cache.get_many.assert_called_once_with([cache_key])
|
||||
|
||||
|
||||
@@ -620,6 +657,7 @@ def test_list_waiting_participants_multiple(mock_cache, lobby_service):
|
||||
"username": "user1",
|
||||
"id": "participant1",
|
||||
"color": "#123456",
|
||||
"entered_at": "2025-01-01T10:00:00+00:00",
|
||||
}
|
||||
|
||||
participant2 = {
|
||||
@@ -627,9 +665,13 @@ def test_list_waiting_participants_multiple(mock_cache, lobby_service):
|
||||
"username": "user2",
|
||||
"id": "participant2",
|
||||
"color": "#654321",
|
||||
"entered_at": "2025-01-01T10:05:00+00:00",
|
||||
}
|
||||
|
||||
mock_cache.iter_keys.return_value = [cache_key1, cache_key2]
|
||||
lobby_service._index_members = mock.Mock(
|
||||
return_value=["participant1", "participant2"]
|
||||
)
|
||||
lobby_service._index_remove = mock.Mock()
|
||||
mock_cache.get_many.return_value = {
|
||||
cache_key1: participant1,
|
||||
cache_key2: participant2,
|
||||
@@ -639,15 +681,15 @@ def test_list_waiting_participants_multiple(mock_cache, lobby_service):
|
||||
|
||||
assert len(result) == 2
|
||||
|
||||
# Verify both participants are in the result
|
||||
assert any(p["id"] == "participant1" and p["username"] == "user1" for p in result)
|
||||
assert any(p["id"] == "participant2" and p["username"] == "user2" for p in result)
|
||||
# Most recent entry comes first
|
||||
assert [p["id"] for p in result] == ["participant2", "participant1"]
|
||||
assert result[0]["username"] == "user2"
|
||||
assert result[1]["username"] == "user1"
|
||||
|
||||
# Verify all participants have waiting status
|
||||
assert all(p["status"] == "waiting" for p in result)
|
||||
|
||||
pattern = f"{settings.LOBBY_KEY_PREFIX}_{room.id!s}_*"
|
||||
mock_cache.iter_keys.assert_called_once_with(pattern, itersize=CACHE_SCAN_ITERSIZE)
|
||||
lobby_service._index_members.assert_called_once_with(room.id)
|
||||
mock_cache.get_many.assert_called_once_with([cache_key1, cache_key2])
|
||||
|
||||
|
||||
@@ -656,12 +698,13 @@ def test_list_waiting_participants_corrupted_data(mock_cache, lobby_service):
|
||||
"""Test listing waiting participants with corrupted data."""
|
||||
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
|
||||
cache_key = f"{settings.LOBBY_KEY_PREFIX}_{room.id!s}_participant1"
|
||||
mock_cache.iter_keys.return_value = [cache_key]
|
||||
lobby_service._index_members = mock.Mock(return_value=["participant1"])
|
||||
lobby_service._index_remove = mock.Mock()
|
||||
mock_cache.get_many.return_value = {cache_key: {"invalid": "data"}}
|
||||
|
||||
result = lobby_service.list_waiting_participants(room.id)
|
||||
|
||||
assert result == []
|
||||
assert result == ()
|
||||
mock_cache.delete.assert_called_once_with(cache_key)
|
||||
|
||||
|
||||
@@ -677,11 +720,15 @@ def test_list_waiting_participants_partially_corrupted(mock_cache, lobby_service
|
||||
"username": "user2",
|
||||
"id": "participant2",
|
||||
"color": "#654321",
|
||||
"entered_at": "2025-01-01T10:00:00+00:00",
|
||||
}
|
||||
|
||||
corrupted_participant = {"invalid": "data"}
|
||||
|
||||
mock_cache.iter_keys.return_value = [cache_key1, cache_key2]
|
||||
lobby_service._index_members = mock.Mock(
|
||||
return_value=["participant1", "participant2"]
|
||||
)
|
||||
lobby_service._index_remove = mock.Mock()
|
||||
mock_cache.get_many.return_value = {
|
||||
cache_key1: corrupted_participant,
|
||||
cache_key2: valid_participant,
|
||||
@@ -699,8 +746,6 @@ def test_list_waiting_participants_partially_corrupted(mock_cache, lobby_service
|
||||
mock_cache.delete.assert_called_once_with(cache_key1)
|
||||
|
||||
# Verify both cache keys were queried
|
||||
pattern = f"{settings.LOBBY_KEY_PREFIX}_{room.id!s}_*"
|
||||
mock_cache.iter_keys.assert_called_once_with(pattern, itersize=CACHE_SCAN_ITERSIZE)
|
||||
mock_cache.get_many.assert_called_once_with([cache_key1, cache_key2])
|
||||
|
||||
|
||||
@@ -716,15 +761,20 @@ def test_list_waiting_participants_non_waiting(mock_cache, lobby_service):
|
||||
"username": "user1",
|
||||
"id": "participant1",
|
||||
"color": "#123456",
|
||||
"entered_at": "2025-01-01T10:00:00+00:00",
|
||||
}
|
||||
participant2 = {
|
||||
"status": "accepted",
|
||||
"username": "user2",
|
||||
"id": "participant2",
|
||||
"color": "#654321",
|
||||
"entered_at": "2025-01-01T10:00:00+00:00",
|
||||
}
|
||||
|
||||
mock_cache.iter_keys.return_value = [cache_key1, cache_key2]
|
||||
lobby_service._index_members = mock.Mock(
|
||||
return_value=["participant1", "participant2"]
|
||||
)
|
||||
lobby_service._index_remove = mock.Mock()
|
||||
mock_cache.get_many.return_value = {
|
||||
cache_key1: participant1,
|
||||
cache_key2: participant2,
|
||||
@@ -816,10 +866,12 @@ def test_update_participant_status_success(mock_cache, lobby_service, participan
|
||||
"username": "test-username",
|
||||
"id": participant_id,
|
||||
"color": "#123456",
|
||||
"entered_at": "2025-01-01T10:00:00+00:00",
|
||||
}
|
||||
|
||||
mock_cache.get.return_value = participant_dict
|
||||
lobby_service._get_cache_key = mock.Mock(return_value="mocked_cache_key")
|
||||
lobby_service._index_touch = mock.Mock()
|
||||
|
||||
lobby_service._update_participant_status(
|
||||
room.id,
|
||||
@@ -833,10 +885,12 @@ def test_update_participant_status_success(mock_cache, lobby_service, participan
|
||||
"username": "test-username",
|
||||
"id": participant_id,
|
||||
"color": "#123456",
|
||||
"entered_at": "2025-01-01T10:00:00+00:00",
|
||||
}
|
||||
mock_cache.set.assert_called_once_with(
|
||||
"mocked_cache_key", expected_data, timeout=60
|
||||
)
|
||||
lobby_service._index_touch.assert_called_once_with(room.id)
|
||||
lobby_service._get_cache_key.assert_called_once_with(room.id, participant_id)
|
||||
|
||||
|
||||
@@ -857,6 +911,7 @@ def test_clear_room_cache(settings, lobby_service):
|
||||
username="participant1",
|
||||
id="participant1",
|
||||
color="#123456",
|
||||
entered_at="2025-01-01T10:00:00+00:00",
|
||||
),
|
||||
timeout=settings.LOBBY_WAITING_TIMEOUT,
|
||||
)
|
||||
@@ -867,6 +922,7 @@ def test_clear_room_cache(settings, lobby_service):
|
||||
username="participant2",
|
||||
id="participant2",
|
||||
color="#123456",
|
||||
entered_at="2025-01-01T10:00:00+00:00",
|
||||
),
|
||||
timeout=settings.LOBBY_ACCEPTED_TIMEOUT,
|
||||
)
|
||||
@@ -877,13 +933,18 @@ def test_clear_room_cache(settings, lobby_service):
|
||||
username="participant3",
|
||||
id="participant3",
|
||||
color="#123456",
|
||||
entered_at="2025-01-01T10:00:00+00:00",
|
||||
),
|
||||
timeout=settings.LOBBY_DENIED_TIMEOUT,
|
||||
)
|
||||
|
||||
for participant_id in ("participant1", "participant2", "participant3"):
|
||||
lobby_service._index_add(room_id, participant_id)
|
||||
|
||||
lobby_service.clear_room_cache(room_id)
|
||||
|
||||
assert cache.keys(f"test-lobby_{room_id!s}_*") == []
|
||||
assert lobby_service._index_members(room_id) == frozenset()
|
||||
|
||||
|
||||
def test_clear_room_empty(settings, lobby_service):
|
||||
@@ -908,12 +969,16 @@ def test_clear_participant_cache(lobby_service):
|
||||
"username": "test-username",
|
||||
"id": participant_id,
|
||||
"color": "#123456",
|
||||
"entered_at": "2025-01-01T10:00:00+00:00",
|
||||
}
|
||||
cache.set(cache_key, participant_data, timeout=settings.LOBBY_WAITING_TIMEOUT)
|
||||
lobby_service._index_add(room_id, participant_id)
|
||||
assert cache.get(cache_key) is not None
|
||||
assert participant_id in lobby_service._index_members(room_id)
|
||||
|
||||
lobby_service.clear_participant_cache(room_id, participant_id)
|
||||
assert cache.get(cache_key) is None
|
||||
assert participant_id not in lobby_service._index_members(room_id)
|
||||
|
||||
|
||||
def test_clear_participant_cache_nonexistent(lobby_service):
|
||||
@@ -927,3 +992,85 @@ def test_clear_participant_cache_nonexistent(lobby_service):
|
||||
lobby_service.clear_participant_cache(room_id, participant_id)
|
||||
|
||||
assert cache.get(cache_key) is None
|
||||
|
||||
|
||||
def test_index_add_members_remove_roundtrip(lobby_service):
|
||||
"""The room index records, lists and forgets participant ids."""
|
||||
room_id = uuid.uuid4()
|
||||
|
||||
assert lobby_service._index_members(room_id) == frozenset()
|
||||
|
||||
lobby_service._index_add(room_id, "participant1")
|
||||
lobby_service._index_add(room_id, "participant2")
|
||||
|
||||
assert sorted(lobby_service._index_members(room_id)) == [
|
||||
"participant1",
|
||||
"participant2",
|
||||
]
|
||||
|
||||
# The index carries a backstop TTL so abandoned rooms cannot leak it.
|
||||
ttl = lobby_service._redis().ttl(lobby_service._get_index_key(room_id))
|
||||
assert 0 < ttl <= settings.LOBBY_ACCEPTED_TIMEOUT
|
||||
|
||||
lobby_service._index_remove(room_id, "participant1")
|
||||
assert lobby_service._index_members(room_id) == frozenset(["participant2"])
|
||||
|
||||
|
||||
@mock.patch("core.utils.notify_participants")
|
||||
def test_enter_registers_participant_in_room_index(
|
||||
mock_notify, lobby_service, participant_id, username
|
||||
):
|
||||
"""Entering the lobby must index the participant id for the room."""
|
||||
room_id = uuid.uuid4()
|
||||
|
||||
lobby_service.enter(room_id, participant_id, username)
|
||||
|
||||
assert lobby_service._index_members(room_id) == frozenset([participant_id])
|
||||
|
||||
|
||||
def test_list_waiting_participants_prunes_stale_index_ids(settings, lobby_service):
|
||||
"""Indexed ids whose cache entry expired are pruned and not listed."""
|
||||
settings.LOBBY_KEY_PREFIX = "test-lobby-prune"
|
||||
room_id = uuid.uuid4()
|
||||
|
||||
cache.set(
|
||||
f"test-lobby-prune_{room_id!s}_participant1",
|
||||
{
|
||||
"id": "participant1",
|
||||
"username": "user1",
|
||||
"status": "waiting",
|
||||
"color": "#123456",
|
||||
"entered_at": "2025-01-01T10:00:00+00:00",
|
||||
},
|
||||
timeout=100,
|
||||
)
|
||||
lobby_service._index_add(room_id, "participant1")
|
||||
# participant2 is indexed but its cache entry has expired.
|
||||
lobby_service._index_add(room_id, "participant2")
|
||||
|
||||
result = lobby_service.list_waiting_participants(room_id)
|
||||
|
||||
assert [participant["id"] for participant in result] == ["participant1"]
|
||||
assert lobby_service._index_members(room_id) == frozenset(["participant1"])
|
||||
|
||||
|
||||
def test_refresh_waiting_status_rearms_room_index_ttl(lobby_service, participant_id):
|
||||
"""A lone waiter's polling must keep the room index alive.
|
||||
|
||||
Regression test: the index backstop TTL is only armed at enter() time,
|
||||
so a participant whose rolling WAITING refreshes outlast it would keep
|
||||
their entry alive while silently vanishing from the moderator list.
|
||||
Refreshing the waiting status must therefore re-arm the index TTL.
|
||||
"""
|
||||
room_id = uuid.uuid4()
|
||||
lobby_service._index_add(room_id, participant_id)
|
||||
|
||||
index_key = lobby_service._get_index_key(room_id)
|
||||
redis_client = lobby_service._redis()
|
||||
redis_client.expire(index_key, 10)
|
||||
assert redis_client.ttl(index_key) <= 10
|
||||
|
||||
lobby_service.refresh_waiting_status(room_id, participant_id)
|
||||
|
||||
assert redis_client.ttl(index_key) > 10
|
||||
assert lobby_service._index_members(room_id) == frozenset([participant_id])
|
||||
|
||||
@@ -90,19 +90,18 @@ def test_presence_clear_and_clear_room():
|
||||
assert presence.is_marked_present(other_room, "a") is True
|
||||
|
||||
|
||||
def test_presence_clear_room_scans_in_pages():
|
||||
"""clear_room removes every match, even across several SCAN pages,
|
||||
and only within the room."""
|
||||
def test_presence_clear_room_removes_many_entries_and_the_index():
|
||||
"""clear_room removes every entry of the room through the index — never
|
||||
a keyspace scan — and leaves other rooms untouched."""
|
||||
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)
|
||||
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
|
||||
assert presence._index_members(room_id) == frozenset([])
|
||||
assert presence._index_members(other_room) == frozenset(["user-0"])
|
||||
|
||||
@@ -5,6 +5,8 @@ from unittest import mock
|
||||
import pytest
|
||||
from livekit.api import TwirpError
|
||||
|
||||
from core.factories import RoomFactory
|
||||
from core.models import RoomAccessLevel
|
||||
from core.services.room_management import (
|
||||
RoomManagement,
|
||||
RoomManagementException,
|
||||
@@ -20,7 +22,7 @@ def test_delete_room_calls_livekit(mock_create_livekit_client):
|
||||
mock_api.aclose = mock.AsyncMock()
|
||||
mock_create_livekit_client.return_value = mock_api
|
||||
|
||||
RoomManagement().delete_room("room-abc")
|
||||
RoomManagement.delete_room("room-abc")
|
||||
|
||||
mock_api.room.delete_room.assert_awaited_once()
|
||||
request = mock_api.room.delete_room.await_args.args[0]
|
||||
@@ -39,7 +41,7 @@ def test_delete_room_raises_not_found(mock_create_livekit_client):
|
||||
mock_create_livekit_client.return_value = mock_api
|
||||
|
||||
with pytest.raises(RoomNotFoundException):
|
||||
RoomManagement().delete_room("missing-room")
|
||||
RoomManagement.delete_room("missing-room")
|
||||
|
||||
mock_api.aclose.assert_awaited_once()
|
||||
|
||||
@@ -55,6 +57,25 @@ def test_delete_room_raises_management_exception(mock_create_livekit_client):
|
||||
mock_create_livekit_client.return_value = mock_api
|
||||
|
||||
with pytest.raises(RoomManagementException):
|
||||
RoomManagement().delete_room("room-abc")
|
||||
RoomManagement.delete_room("room-abc")
|
||||
|
||||
mock_api.aclose.assert_awaited_once()
|
||||
|
||||
|
||||
@mock.patch.object(RoomManagement, "update_metadata")
|
||||
def test_sync_room_metadata_pushes_configuration_and_access_level(mock_update_metadata):
|
||||
"""The room's configuration and access level are forwarded to LiveKit."""
|
||||
room = RoomFactory.build(
|
||||
access_level=RoomAccessLevel.RESTRICTED,
|
||||
configuration={"everyone_can_mute": True},
|
||||
)
|
||||
|
||||
RoomManagement.sync_room_metadata(room)
|
||||
|
||||
mock_update_metadata.assert_called_once_with(
|
||||
room_name=str(room.id),
|
||||
metadata={
|
||||
"configuration": {"everyone_can_mute": True},
|
||||
"access_level": RoomAccessLevel.RESTRICTED,
|
||||
},
|
||||
)
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
"""Tests for the external API ResourceServerBackend."""
|
||||
|
||||
from django.core.exceptions import SuspiciousOperation
|
||||
|
||||
import pytest
|
||||
import responses
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
from core.external_api.authentication import ResourceServerBackend
|
||||
from core.factories import UserFactory
|
||||
from core.models import User
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
def _payload(sub):
|
||||
return {"sub": sub, "active": True, "scope": "lasuite_meet", "client_id": "app"}
|
||||
|
||||
|
||||
def test_resource_server_backend_get_or_create_user_active():
|
||||
"""An existing active user matching the sub should be returned."""
|
||||
|
||||
user = UserFactory()
|
||||
|
||||
result = ResourceServerBackend().get_or_create_user(
|
||||
access_token="token", id_token=None, payload=_payload(user.sub)
|
||||
)
|
||||
|
||||
assert result == user
|
||||
|
||||
|
||||
def test_resource_server_backend_get_or_create_user_inactive():
|
||||
"""An inactive user should be rejected even with a valid token."""
|
||||
|
||||
user = UserFactory(is_active=False)
|
||||
|
||||
with pytest.raises(SuspiciousOperation, match="User account is disabled."):
|
||||
ResourceServerBackend().get_or_create_user(
|
||||
access_token="token", id_token=None, payload=_payload(user.sub)
|
||||
)
|
||||
|
||||
|
||||
def test_resource_server_backend_get_or_create_user_creates(settings):
|
||||
"""An unknown sub should create an active user when OIDC_CREATE_USER is set."""
|
||||
|
||||
settings.OIDC_CREATE_USER = True
|
||||
|
||||
result = ResourceServerBackend().get_or_create_user(
|
||||
access_token="token", id_token=None, payload=_payload("new-sub")
|
||||
)
|
||||
|
||||
assert result.sub == "new-sub"
|
||||
assert result.is_active is True
|
||||
assert User.objects.filter(sub="new-sub").exists()
|
||||
|
||||
|
||||
def test_resource_server_backend_get_or_create_user_no_creation(settings):
|
||||
"""An unknown sub should return None when OIDC_CREATE_USER is unset."""
|
||||
|
||||
settings.OIDC_CREATE_USER = False
|
||||
|
||||
result = ResourceServerBackend().get_or_create_user(
|
||||
access_token="token", id_token=None, payload=_payload("new-sub")
|
||||
)
|
||||
|
||||
assert result is None
|
||||
assert not User.objects.filter(sub="new-sub").exists()
|
||||
|
||||
|
||||
@responses.activate
|
||||
def test_api_rooms_list_resource_server_inactive_user(settings):
|
||||
"""End to end: a valid introspected token for an inactive user should get 401."""
|
||||
|
||||
settings.OIDC_OP_INTROSPECTION_ENDPOINT = "https://oidc.example.com/introspect"
|
||||
settings.OIDC_OP_URL = "https://oidc.example.com"
|
||||
|
||||
user = UserFactory(is_active=False)
|
||||
|
||||
responses.add(
|
||||
responses.POST,
|
||||
"https://oidc.example.com/introspect",
|
||||
json={
|
||||
"iss": "https://oidc.example.com",
|
||||
"active": True,
|
||||
"sub": user.sub,
|
||||
"scope": "openid lasuite_meet rooms:list",
|
||||
"client_id": "app",
|
||||
},
|
||||
)
|
||||
|
||||
client = APIClient()
|
||||
client.credentials(HTTP_AUTHORIZATION="Bearer rs-token")
|
||||
response = client.get("/external-api/v1.0/rooms/")
|
||||
|
||||
assert response.status_code == 401
|
||||
assert "login failed" in str(response.data).lower()
|
||||
@@ -16,8 +16,17 @@ import responses
|
||||
from lasuite.oidc_resource_server.authentication import ResourceServerAuthentication
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
from core.analytics import AnalyticsEvent
|
||||
from core.factories import ApplicationFactory, RoomFactory, UserFactory
|
||||
from core.models import ApplicationScope, RoleChoices, Room, RoomAccessLevel, User
|
||||
from core.models import (
|
||||
Application,
|
||||
ApplicationScope,
|
||||
RoleChoices,
|
||||
Room,
|
||||
RoomAccessLevel,
|
||||
User,
|
||||
)
|
||||
from core.services.room_management import RoomManagement
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
@@ -880,6 +889,509 @@ def test_api_rooms_create_public_access_level_when_default_is_public(settings):
|
||||
assert response.data["access_level"] == RoomAccessLevel.PUBLIC
|
||||
|
||||
|
||||
@mock.patch("core.external_api.viewsets.analytics.capture")
|
||||
def test_api_rooms_create_tracks_analytics(mock_capture):
|
||||
"""Creating a room should emit a ROOM_CREATED analytics event."""
|
||||
|
||||
user = UserFactory()
|
||||
token = generate_test_token(user, [ApplicationScope.ROOMS_CREATE])
|
||||
application = Application.objects.get()
|
||||
|
||||
client = APIClient()
|
||||
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
|
||||
response = client.post(
|
||||
"/external-api/v1.0/rooms/",
|
||||
{"access_level": RoomAccessLevel.RESTRICTED},
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == 201
|
||||
|
||||
mock_capture.assert_called_once()
|
||||
captured_user, event, properties = mock_capture.call_args[0]
|
||||
|
||||
assert captured_user == user
|
||||
assert event == AnalyticsEvent.ROOM_CREATED
|
||||
assert properties == {
|
||||
"room_id": response.data["id"],
|
||||
"access_level": RoomAccessLevel.RESTRICTED,
|
||||
"client_id": str(application.client_id),
|
||||
"external_api": True,
|
||||
"auth_method": "ApplicationJWTAuthentication",
|
||||
"$set": {"email": user.email},
|
||||
}
|
||||
|
||||
|
||||
def test_api_rooms_update_requires_authentication():
|
||||
"""Updating a room without authentication should return 401."""
|
||||
|
||||
room = RoomFactory(users=[(UserFactory(), RoleChoices.OWNER)])
|
||||
|
||||
client = APIClient()
|
||||
response = client.patch(
|
||||
f"/external-api/v1.0/rooms/{room.id}/",
|
||||
{"access_level": RoomAccessLevel.RESTRICTED},
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == 401
|
||||
|
||||
|
||||
def test_api_rooms_update_requires_scope():
|
||||
"""Updating a room requires the ROOMS_UPDATE scope."""
|
||||
|
||||
user = UserFactory()
|
||||
room = RoomFactory(users=[(user, RoleChoices.OWNER)])
|
||||
|
||||
# Token without ROOMS_UPDATE scope
|
||||
token = generate_test_token(user, [ApplicationScope.ROOMS_RETRIEVE])
|
||||
|
||||
client = APIClient()
|
||||
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
|
||||
response = client.patch(
|
||||
f"/external-api/v1.0/rooms/{room.id}/",
|
||||
{"access_level": RoomAccessLevel.RESTRICTED},
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == 403
|
||||
assert (
|
||||
"insufficient permissions. required scope: rooms:update"
|
||||
in str(response.data).lower()
|
||||
)
|
||||
|
||||
|
||||
def test_api_rooms_update_no_scope():
|
||||
"""Updating a room without any scope should return 403."""
|
||||
|
||||
user = UserFactory()
|
||||
room = RoomFactory(users=[(user, RoleChoices.OWNER)])
|
||||
|
||||
token = generate_test_token(user, [])
|
||||
|
||||
client = APIClient()
|
||||
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
|
||||
response = client.patch(
|
||||
f"/external-api/v1.0/rooms/{room.id}/",
|
||||
{"access_level": RoomAccessLevel.RESTRICTED},
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == 403
|
||||
assert "insufficient permissions." in str(response.data).lower()
|
||||
|
||||
|
||||
@mock.patch.object(RoomManagement, "update_metadata")
|
||||
def test_api_rooms_update_owner_success(mock_update_metadata, settings):
|
||||
"""An owner should be able to update the access level and the configuration."""
|
||||
|
||||
settings.APPLICATION_BASE_URL = "http://your-application.com"
|
||||
|
||||
user = UserFactory()
|
||||
room = RoomFactory(
|
||||
users=[(user, RoleChoices.OWNER)],
|
||||
access_level=RoomAccessLevel.TRUSTED,
|
||||
configuration={},
|
||||
)
|
||||
|
||||
token = generate_test_token(user, [ApplicationScope.ROOMS_UPDATE])
|
||||
|
||||
client = APIClient()
|
||||
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
|
||||
response = client.patch(
|
||||
f"/external-api/v1.0/rooms/{room.id}/",
|
||||
{
|
||||
"access_level": RoomAccessLevel.RESTRICTED,
|
||||
"configuration": {"everyone_can_mute": True},
|
||||
},
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.data["id"] == str(room.id)
|
||||
assert response.data["access_level"] == RoomAccessLevel.RESTRICTED
|
||||
assert response.data["configuration"] == {"everyone_can_mute": True}
|
||||
assert response.data["url"] == f"http://your-application.com/{room.slug}"
|
||||
|
||||
room.refresh_from_db()
|
||||
assert room.access_level == RoomAccessLevel.RESTRICTED
|
||||
assert room.configuration == {"everyone_can_mute": True}
|
||||
|
||||
mock_update_metadata.assert_called_once_with(
|
||||
room_name=str(room.id),
|
||||
metadata={
|
||||
"configuration": {"everyone_can_mute": True},
|
||||
"access_level": RoomAccessLevel.RESTRICTED,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@mock.patch.object(RoomManagement, "update_metadata")
|
||||
def test_api_rooms_update_replaces_configuration(mock_update_metadata):
|
||||
"""The configuration is replaced as a whole, it is not merged with the stored one."""
|
||||
|
||||
user = UserFactory()
|
||||
room = RoomFactory(
|
||||
users=[(user, RoleChoices.OWNER)],
|
||||
configuration={"can_publish_sources": ["camera"], "everyone_can_mute": True},
|
||||
)
|
||||
|
||||
token = generate_test_token(user, [ApplicationScope.ROOMS_UPDATE])
|
||||
|
||||
client = APIClient()
|
||||
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
|
||||
response = client.patch(
|
||||
f"/external-api/v1.0/rooms/{room.id}/",
|
||||
{"configuration": {"everyone_can_mute": False}},
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
|
||||
# The keys missing from the payload are dropped, not kept.
|
||||
assert response.data["configuration"] == {"everyone_can_mute": False}
|
||||
|
||||
room.refresh_from_db()
|
||||
assert room.configuration == {"everyone_can_mute": False}
|
||||
|
||||
mock_update_metadata.assert_called_once_with(
|
||||
room_name=str(room.id),
|
||||
metadata={
|
||||
"configuration": {"everyone_can_mute": False},
|
||||
"access_level": room.access_level,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@mock.patch.object(RoomManagement, "update_metadata")
|
||||
def test_api_rooms_update_administrator_success(mock_update_metadata):
|
||||
"""An administrator should be able to update a room."""
|
||||
|
||||
user = UserFactory()
|
||||
room = RoomFactory(
|
||||
users=[(user, RoleChoices.ADMIN)],
|
||||
access_level=RoomAccessLevel.TRUSTED,
|
||||
)
|
||||
|
||||
token = generate_test_token(user, [ApplicationScope.ROOMS_UPDATE])
|
||||
|
||||
client = APIClient()
|
||||
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
|
||||
response = client.patch(
|
||||
f"/external-api/v1.0/rooms/{room.id}/",
|
||||
{"access_level": RoomAccessLevel.RESTRICTED},
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
|
||||
room.refresh_from_db()
|
||||
assert room.access_level == RoomAccessLevel.RESTRICTED
|
||||
mock_update_metadata.assert_called_once()
|
||||
|
||||
|
||||
@mock.patch.object(RoomManagement, "update_metadata")
|
||||
def test_api_rooms_update_put_not_allowed(mock_update_metadata):
|
||||
"""PUT is not exposed: full replacement is not supported, only PATCH is."""
|
||||
|
||||
user = UserFactory()
|
||||
room = RoomFactory(
|
||||
users=[(user, RoleChoices.OWNER)],
|
||||
access_level=RoomAccessLevel.TRUSTED,
|
||||
configuration={},
|
||||
)
|
||||
|
||||
token = generate_test_token(user, [ApplicationScope.ROOMS_UPDATE])
|
||||
|
||||
client = APIClient()
|
||||
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
|
||||
response = client.put(
|
||||
f"/external-api/v1.0/rooms/{room.id}/",
|
||||
{
|
||||
"access_level": RoomAccessLevel.RESTRICTED,
|
||||
"configuration": {"everyone_can_mute": True},
|
||||
},
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == 405
|
||||
|
||||
room.refresh_from_db()
|
||||
assert room.access_level == RoomAccessLevel.TRUSTED
|
||||
assert room.configuration == {}
|
||||
mock_update_metadata.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("role", [RoleChoices.MEMBER, None])
|
||||
@mock.patch.object(RoomManagement, "update_metadata")
|
||||
def test_api_rooms_update_without_privileges(mock_update_metadata, role):
|
||||
"""Members and users without any role should not be able to update a room."""
|
||||
|
||||
user = UserFactory()
|
||||
users = [(user, role)] if role else []
|
||||
room = RoomFactory(users=users, access_level=RoomAccessLevel.TRUSTED)
|
||||
|
||||
token = generate_test_token(user, [ApplicationScope.ROOMS_UPDATE])
|
||||
|
||||
client = APIClient()
|
||||
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
|
||||
response = client.patch(
|
||||
f"/external-api/v1.0/rooms/{room.id}/",
|
||||
{"access_level": RoomAccessLevel.RESTRICTED},
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == 403
|
||||
|
||||
room.refresh_from_db()
|
||||
assert room.access_level == RoomAccessLevel.TRUSTED
|
||||
mock_update_metadata.assert_not_called()
|
||||
|
||||
|
||||
@mock.patch.object(RoomManagement, "update_metadata")
|
||||
def test_api_rooms_update_readonly_enforcement(mock_update_metadata):
|
||||
"""Read-only fields provided on update should be ignored, the slug stays immutable."""
|
||||
|
||||
user = UserFactory()
|
||||
room = RoomFactory(
|
||||
users=[(user, RoleChoices.OWNER)],
|
||||
access_level=RoomAccessLevel.TRUSTED,
|
||||
)
|
||||
expected_id, expected_name = str(room.id), room.name
|
||||
expected_slug, expected_pin_code = room.slug, room.pin_code
|
||||
|
||||
token = generate_test_token(user, [ApplicationScope.ROOMS_UPDATE])
|
||||
|
||||
client = APIClient()
|
||||
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
|
||||
response = client.patch(
|
||||
f"/external-api/v1.0/rooms/{room.id}/",
|
||||
{
|
||||
"id": str(uuid.uuid4()),
|
||||
"name": "fake-name",
|
||||
"slug": "fake-slug",
|
||||
"pin_code": "000000",
|
||||
"access_level": RoomAccessLevel.RESTRICTED,
|
||||
},
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.data["id"] == expected_id
|
||||
assert response.data["name"] == expected_name
|
||||
assert response.data["slug"] == expected_slug
|
||||
|
||||
room.refresh_from_db()
|
||||
assert str(room.id) == expected_id
|
||||
assert room.name == expected_name
|
||||
assert room.slug == expected_slug
|
||||
assert room.pin_code == expected_pin_code
|
||||
|
||||
# The one writable field in the payload was applied
|
||||
assert room.access_level == RoomAccessLevel.RESTRICTED
|
||||
mock_update_metadata.assert_called_once()
|
||||
|
||||
|
||||
@mock.patch.object(RoomManagement, "update_metadata")
|
||||
def test_api_rooms_update_rejects_invalid_configuration(mock_update_metadata):
|
||||
"""Updating a room with unsupported configuration keys should fail."""
|
||||
|
||||
user = UserFactory()
|
||||
room = RoomFactory(users=[(user, RoleChoices.OWNER)], configuration={})
|
||||
|
||||
token = generate_test_token(user, [ApplicationScope.ROOMS_UPDATE])
|
||||
|
||||
client = APIClient()
|
||||
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
|
||||
response = client.patch(
|
||||
f"/external-api/v1.0/rooms/{room.id}/",
|
||||
{"configuration": {"unsupported_flag": True}},
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == 400
|
||||
assert "extra inputs are not permitted" in str(response.data).lower()
|
||||
|
||||
room.refresh_from_db()
|
||||
assert room.configuration == {}
|
||||
mock_update_metadata.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"invalid_configuration",
|
||||
[
|
||||
{"can_publish_sources": ["invalid-source"]},
|
||||
{"everyone_can_mute": "invalid-value"},
|
||||
],
|
||||
)
|
||||
@mock.patch.object(RoomManagement, "update_metadata")
|
||||
def test_api_rooms_update_rejects_invalid_configuration_values(
|
||||
mock_update_metadata, invalid_configuration
|
||||
):
|
||||
"""Updating a room with invalid configuration values should fail."""
|
||||
|
||||
user = UserFactory()
|
||||
room = RoomFactory(users=[(user, RoleChoices.OWNER)], configuration={})
|
||||
|
||||
token = generate_test_token(user, [ApplicationScope.ROOMS_UPDATE])
|
||||
|
||||
client = APIClient()
|
||||
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
|
||||
response = client.patch(
|
||||
f"/external-api/v1.0/rooms/{room.id}/",
|
||||
{"configuration": invalid_configuration},
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == 400
|
||||
|
||||
room.refresh_from_db()
|
||||
assert room.configuration == {}
|
||||
mock_update_metadata.assert_not_called()
|
||||
|
||||
|
||||
@mock.patch.object(RoomManagement, "update_metadata")
|
||||
def test_api_rooms_update_public_access_disabled_by_default(mock_update_metadata):
|
||||
"""Switching a room to public should be disabled for the external API by default."""
|
||||
|
||||
user = UserFactory()
|
||||
room = RoomFactory(
|
||||
users=[(user, RoleChoices.OWNER)],
|
||||
access_level=RoomAccessLevel.TRUSTED,
|
||||
)
|
||||
|
||||
token = generate_test_token(user, [ApplicationScope.ROOMS_UPDATE])
|
||||
|
||||
client = APIClient()
|
||||
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
|
||||
response = client.patch(
|
||||
f"/external-api/v1.0/rooms/{room.id}/",
|
||||
{"access_level": RoomAccessLevel.PUBLIC},
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == 400
|
||||
assert "public rooms are disabled" in str(response.data).lower()
|
||||
|
||||
room.refresh_from_db()
|
||||
assert room.access_level == RoomAccessLevel.TRUSTED
|
||||
mock_update_metadata.assert_not_called()
|
||||
|
||||
|
||||
@mock.patch.object(RoomManagement, "update_metadata")
|
||||
def test_api_rooms_update_public_access_enabled_with_settings(
|
||||
mock_update_metadata, settings
|
||||
):
|
||||
"""Switching a room to public should be allowed when explicitly enabled."""
|
||||
|
||||
settings.EXTERNAL_API_ALLOW_PUBLIC_ACCESS = True
|
||||
|
||||
user = UserFactory()
|
||||
room = RoomFactory(
|
||||
users=[(user, RoleChoices.OWNER)],
|
||||
access_level=RoomAccessLevel.TRUSTED,
|
||||
)
|
||||
|
||||
token = generate_test_token(user, [ApplicationScope.ROOMS_UPDATE])
|
||||
|
||||
client = APIClient()
|
||||
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
|
||||
response = client.patch(
|
||||
f"/external-api/v1.0/rooms/{room.id}/",
|
||||
{"access_level": RoomAccessLevel.PUBLIC},
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.data["access_level"] == RoomAccessLevel.PUBLIC
|
||||
|
||||
room.refresh_from_db()
|
||||
assert room.access_level == RoomAccessLevel.PUBLIC
|
||||
mock_update_metadata.assert_called_once()
|
||||
|
||||
|
||||
@mock.patch("core.external_api.viewsets.analytics.capture")
|
||||
@mock.patch.object(RoomManagement, "update_metadata")
|
||||
def test_api_rooms_update_unchanged_skips_livekit_sync(
|
||||
mock_update_metadata, mock_capture
|
||||
):
|
||||
"""An update that changes nothing should not sync metadata nor report changes."""
|
||||
|
||||
user = UserFactory()
|
||||
room = RoomFactory(
|
||||
users=[(user, RoleChoices.OWNER)],
|
||||
access_level=RoomAccessLevel.TRUSTED,
|
||||
configuration={"everyone_can_mute": True},
|
||||
)
|
||||
|
||||
token = generate_test_token(user, [ApplicationScope.ROOMS_UPDATE])
|
||||
|
||||
client = APIClient()
|
||||
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
|
||||
response = client.patch(
|
||||
f"/external-api/v1.0/rooms/{room.id}/",
|
||||
{
|
||||
"access_level": RoomAccessLevel.TRUSTED,
|
||||
"configuration": {"everyone_can_mute": True},
|
||||
},
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
mock_update_metadata.assert_not_called()
|
||||
|
||||
# The event is still emitted for auditing, but reports an empty delta.
|
||||
_, _, properties = mock_capture.call_args[0]
|
||||
assert properties["updated_fields"] == []
|
||||
|
||||
|
||||
@mock.patch("core.external_api.viewsets.analytics.capture")
|
||||
@mock.patch.object(RoomManagement, "update_metadata")
|
||||
def test_api_rooms_update_tracks_analytics(mock_update_metadata, mock_capture):
|
||||
"""Updating a room should emit a ROOM_UPDATED analytics event."""
|
||||
|
||||
user = UserFactory()
|
||||
room = RoomFactory(
|
||||
users=[(user, RoleChoices.OWNER)],
|
||||
access_level=RoomAccessLevel.TRUSTED,
|
||||
configuration={},
|
||||
)
|
||||
|
||||
token = generate_test_token(user, [ApplicationScope.ROOMS_UPDATE])
|
||||
application = Application.objects.get()
|
||||
|
||||
client = APIClient()
|
||||
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
|
||||
response = client.patch(
|
||||
f"/external-api/v1.0/rooms/{room.id}/",
|
||||
{
|
||||
"access_level": RoomAccessLevel.RESTRICTED,
|
||||
"configuration": {"everyone_can_mute": True},
|
||||
},
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
|
||||
mock_capture.assert_called_once()
|
||||
captured_user, event, properties = mock_capture.call_args[0]
|
||||
|
||||
assert captured_user == user
|
||||
assert event == AnalyticsEvent.ROOM_UPDATED
|
||||
assert properties == {
|
||||
"room_id": str(room.pk),
|
||||
"access_level": RoomAccessLevel.RESTRICTED,
|
||||
"updated_fields": ["access_level", "configuration"],
|
||||
"previous_access_level": RoomAccessLevel.TRUSTED,
|
||||
"client_id": str(application.client_id),
|
||||
"external_api": True,
|
||||
"auth_method": "ApplicationJWTAuthentication",
|
||||
"$set": {"email": user.email},
|
||||
}
|
||||
|
||||
mock_update_metadata.assert_called_once()
|
||||
|
||||
|
||||
def test_api_rooms_response_no_url(settings):
|
||||
"""Response should not include url field when APPLICATION_BASE_URL is None."""
|
||||
settings.APPLICATION_BASE_URL = None
|
||||
@@ -1497,6 +2009,106 @@ def test_resource_server_denies_access_with_insufficient_scopes(settings):
|
||||
assert response.status_code == 403
|
||||
|
||||
|
||||
@responses.activate
|
||||
@mock.patch.object(RoomManagement, "update_metadata")
|
||||
def test_resource_server_updates_room_with_prefixed_scope(
|
||||
mock_update_metadata, settings
|
||||
):
|
||||
"""A resource server token carrying the prefixed update scope should be accepted."""
|
||||
|
||||
user = UserFactory(sub="very-specific-sub")
|
||||
room = RoomFactory(
|
||||
users=[(user, RoleChoices.OWNER)],
|
||||
access_level=RoomAccessLevel.TRUSTED,
|
||||
)
|
||||
|
||||
settings.OIDC_RS_CLIENT_ID = "some_client_id"
|
||||
settings.OIDC_RS_CLIENT_SECRET = "some_client_secret"
|
||||
settings.OIDC_RS_SCOPES_PREFIX = "lasuite_meet"
|
||||
|
||||
settings.OIDC_OP_URL = "https://oidc.example.com"
|
||||
settings.OIDC_VERIFY_SSL = False
|
||||
settings.OIDC_TIMEOUT = 5
|
||||
settings.OIDC_PROXY = None
|
||||
settings.OIDC_OP_JWKS_ENDPOINT = "https://oidc.example.com/jwks"
|
||||
settings.OIDC_OP_INTROSPECTION_ENDPOINT = "https://oidc.example.com/introspect"
|
||||
|
||||
responses.add(
|
||||
responses.POST,
|
||||
"https://oidc.example.com/introspect",
|
||||
json={
|
||||
"iss": "https://oidc.example.com",
|
||||
"aud": "some_client_id", # settings.OIDC_RS_CLIENT_ID
|
||||
"sub": "very-specific-sub",
|
||||
"client_id": "some_service_provider",
|
||||
"scope": "openid lasuite_meet lasuite_meet:rooms:update",
|
||||
"active": True,
|
||||
},
|
||||
)
|
||||
|
||||
client = APIClient()
|
||||
client.credentials(HTTP_AUTHORIZATION="Bearer some_token")
|
||||
response = client.patch(
|
||||
f"/external-api/v1.0/rooms/{room.id}/",
|
||||
{"access_level": RoomAccessLevel.RESTRICTED},
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
|
||||
room.refresh_from_db()
|
||||
assert room.access_level == RoomAccessLevel.RESTRICTED
|
||||
mock_update_metadata.assert_called_once()
|
||||
|
||||
|
||||
@responses.activate
|
||||
def test_resource_server_denies_room_update_without_update_scope(settings):
|
||||
"""A resource server token without the update scope should be denied."""
|
||||
|
||||
user = UserFactory(sub="very-specific-sub")
|
||||
room = RoomFactory(
|
||||
users=[(user, RoleChoices.OWNER)],
|
||||
access_level=RoomAccessLevel.TRUSTED,
|
||||
)
|
||||
|
||||
settings.OIDC_RS_CLIENT_ID = "some_client_id"
|
||||
settings.OIDC_RS_CLIENT_SECRET = "some_client_secret"
|
||||
settings.OIDC_RS_SCOPES_PREFIX = "lasuite_meet"
|
||||
|
||||
settings.OIDC_OP_URL = "https://oidc.example.com"
|
||||
settings.OIDC_VERIFY_SSL = False
|
||||
settings.OIDC_TIMEOUT = 5
|
||||
settings.OIDC_PROXY = None
|
||||
settings.OIDC_OP_JWKS_ENDPOINT = "https://oidc.example.com/jwks"
|
||||
settings.OIDC_OP_INTROSPECTION_ENDPOINT = "https://oidc.example.com/introspect"
|
||||
|
||||
responses.add(
|
||||
responses.POST,
|
||||
"https://oidc.example.com/introspect",
|
||||
json={
|
||||
"iss": "https://oidc.example.com",
|
||||
"aud": "some_client_id", # settings.OIDC_RS_CLIENT_ID
|
||||
"sub": "very-specific-sub",
|
||||
"client_id": "some_service_provider",
|
||||
"scope": "openid lasuite_meet lasuite_meet:rooms:retrieve",
|
||||
"active": True,
|
||||
},
|
||||
)
|
||||
|
||||
client = APIClient()
|
||||
client.credentials(HTTP_AUTHORIZATION="Bearer some_token")
|
||||
response = client.patch(
|
||||
f"/external-api/v1.0/rooms/{room.id}/",
|
||||
{"access_level": RoomAccessLevel.RESTRICTED},
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == 403
|
||||
|
||||
room.refresh_from_db()
|
||||
assert room.access_level == RoomAccessLevel.TRUSTED
|
||||
|
||||
|
||||
# ==============================
|
||||
# Addons
|
||||
# ==============================
|
||||
@@ -1548,6 +2160,32 @@ def test_api_rooms_create_with_valid_addons_token():
|
||||
assert room.get_role(user) == RoleChoices.OWNER
|
||||
|
||||
|
||||
@mock.patch.object(RoomManagement, "update_metadata")
|
||||
def test_api_rooms_update_with_valid_addons_token(mock_update_metadata):
|
||||
"""Updating a room with a valid addons token should succeed."""
|
||||
user = UserFactory()
|
||||
room = RoomFactory(
|
||||
users=[(user, RoleChoices.OWNER)],
|
||||
access_level=RoomAccessLevel.TRUSTED,
|
||||
)
|
||||
|
||||
token = generate_addons_test_token(user, [ApplicationScope.ROOMS_UPDATE])
|
||||
|
||||
client = APIClient()
|
||||
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
|
||||
response = client.patch(
|
||||
f"/external-api/v1.0/rooms/{room.id}/",
|
||||
{"access_level": RoomAccessLevel.RESTRICTED},
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
|
||||
room.refresh_from_db()
|
||||
assert room.access_level == RoomAccessLevel.RESTRICTED
|
||||
mock_update_metadata.assert_called_once()
|
||||
|
||||
|
||||
def test_api_rooms_addons_token_inactive_user():
|
||||
"""Addons token for an inactive user should return 401."""
|
||||
user = UserFactory(is_active=False)
|
||||
|
||||
@@ -26,6 +26,64 @@ def test_models_users_id_unique():
|
||||
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():
|
||||
"""The "email_user' method should send mail to the user's email address."""
|
||||
user = factories.UserFactory()
|
||||
|
||||
+27
-43
@@ -381,6 +381,31 @@ def detect_mimetype(file_buffer: bytes, filename: str | None = None) -> str:
|
||||
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):
|
||||
"""
|
||||
Generate a S3 upload policy for a given file.
|
||||
@@ -391,26 +416,7 @@ def generate_upload_policy(file):
|
||||
|
||||
key = file.temporary_file_key
|
||||
|
||||
# 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
|
||||
s3_client = _get_s3_client()
|
||||
|
||||
# Generate the policy
|
||||
policy = s3_client.generate_presigned_url(
|
||||
@@ -431,26 +437,7 @@ def generate_download_s3_url(
|
||||
if not key:
|
||||
raise ValueError("key cannot be empty")
|
||||
|
||||
# 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
|
||||
s3_client = _get_s3_client(override_domain=override_domain)
|
||||
|
||||
return s3_client.generate_presigned_url(
|
||||
ClientMethod="get_object",
|
||||
@@ -512,6 +499,3 @@ def build_telephony_config():
|
||||
"default_country": country,
|
||||
"international_phone_number": international,
|
||||
}
|
||||
|
||||
|
||||
CACHE_SCAN_ITERSIZE = 500
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
"""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.")
|
||||
)
|
||||
@@ -8,7 +8,7 @@ msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: PACKAGE VERSION\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2026-07-02 10:47+0000\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"
|
||||
@@ -51,11 +51,11 @@ msgstr ""
|
||||
msgid "File preview"
|
||||
msgstr ""
|
||||
|
||||
#: core/admin.py:300 core/admin.py:443
|
||||
#: core/admin.py:300 core/admin.py:450
|
||||
msgid "No owner"
|
||||
msgstr "Kein Eigentümer"
|
||||
|
||||
#: core/admin.py:303 core/admin.py:446
|
||||
#: core/admin.py:303 core/admin.py:453
|
||||
msgid "Multiple owners"
|
||||
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."
|
||||
msgstr "%(count)s abgelaufene Aufnahme(n) übersprungen."
|
||||
|
||||
#: core/admin.py:510
|
||||
#: core/admin.py:517
|
||||
msgid "No scopes"
|
||||
msgstr "Keine Scopes"
|
||||
|
||||
#: core/admin.py:512
|
||||
#: core/admin.py:519
|
||||
msgid "Scopes"
|
||||
msgstr "Scopes"
|
||||
|
||||
@@ -110,185 +110,201 @@ msgstr "Scopes"
|
||||
msgid "Creator is me"
|
||||
msgstr "Ersteller bin ich"
|
||||
|
||||
#: core/api/serializers.py:89
|
||||
#: core/api/serializers.py:108
|
||||
msgid "You must be administrator or owner of a room to add accesses to it."
|
||||
msgstr ""
|
||||
"Sie müssen Administrator oder Eigentümer eines Raums sein, um Zugriffe "
|
||||
"hinzuzufügen."
|
||||
|
||||
#: core/api/serializers.py:534
|
||||
#: core/api/serializers.py:560
|
||||
msgid "This file extension is not allowed."
|
||||
msgstr "Diese Dateiendung ist nicht erlaubt."
|
||||
|
||||
#: core/api/viewsets.py:1222
|
||||
#: core/api/viewsets.py:1268
|
||||
msgid "You have reached the maximum number of files for this type."
|
||||
msgstr "Sie haben die maximale Anzahl an Dateien dieses Typs erreicht."
|
||||
|
||||
#: core/models.py:37
|
||||
#: core/models.py:38
|
||||
msgid "Member"
|
||||
msgstr "Mitglied"
|
||||
|
||||
#: core/models.py:38
|
||||
#: core/models.py:39
|
||||
msgid "Administrator"
|
||||
msgstr "Administrator"
|
||||
|
||||
#: core/models.py:39
|
||||
#: core/models.py:40
|
||||
msgid "Owner"
|
||||
msgstr "Eigentümer"
|
||||
|
||||
#: core/models.py:55
|
||||
#: core/models.py:56
|
||||
msgid "Initiated"
|
||||
msgstr "Gestartet"
|
||||
|
||||
#: core/models.py:56
|
||||
#: core/models.py:57
|
||||
msgid "Active"
|
||||
msgstr "Aktiv"
|
||||
|
||||
#: core/models.py:57
|
||||
#: core/models.py:58
|
||||
msgid "Stopped"
|
||||
msgstr "Beendet"
|
||||
|
||||
#: core/models.py:58
|
||||
#: core/models.py:59
|
||||
msgid "Saved"
|
||||
msgstr "Gespeichert"
|
||||
|
||||
#: core/models.py:59
|
||||
#: core/models.py:60
|
||||
msgid "Aborted"
|
||||
msgstr "Abgebrochen"
|
||||
|
||||
#: core/models.py:60
|
||||
#: core/models.py:61
|
||||
msgid "Failed to Start"
|
||||
msgstr "Start fehlgeschlagen"
|
||||
|
||||
#: core/models.py:61
|
||||
#: core/models.py:62
|
||||
msgid "Failed to Stop"
|
||||
msgstr "Stopp fehlgeschlagen"
|
||||
|
||||
#: core/models.py:62
|
||||
#: core/models.py:63
|
||||
msgid "Notification succeeded"
|
||||
msgstr "Benachrichtigung erfolgreich"
|
||||
|
||||
#: core/models.py:65
|
||||
#: core/models.py:66
|
||||
msgid "External process successful"
|
||||
msgstr "Externer Prozess erfolgreich"
|
||||
|
||||
#: core/models.py:67
|
||||
#: core/models.py:68
|
||||
msgid "External process failed"
|
||||
msgstr "Externer Prozess fehlgeschlagen"
|
||||
|
||||
#: core/models.py:96
|
||||
#: core/models.py:97
|
||||
msgid "SCREEN_RECORDING"
|
||||
msgstr "BILDSCHIRMAUFZEICHNUNG"
|
||||
|
||||
#: core/models.py:97
|
||||
#: core/models.py:98
|
||||
msgid "TRANSCRIPT"
|
||||
msgstr "TRANSKRIPT"
|
||||
|
||||
#: core/models.py:103
|
||||
#: core/models.py:104
|
||||
msgid "Public Access"
|
||||
msgstr "Öffentlicher Zugriff"
|
||||
|
||||
#: core/models.py:104
|
||||
#: core/models.py:105
|
||||
msgid "Trusted Access"
|
||||
msgstr "Vertrauenswürdiger Zugriff"
|
||||
|
||||
#: core/models.py:105
|
||||
#: core/models.py:106
|
||||
msgid "Restricted Access"
|
||||
msgstr "Eingeschränkter Zugriff"
|
||||
|
||||
#: core/models.py:117
|
||||
#: core/models.py:118
|
||||
msgid "id"
|
||||
msgstr "ID"
|
||||
|
||||
#: core/models.py:118
|
||||
#: core/models.py:119
|
||||
msgid "primary key for the record as UUID"
|
||||
msgstr "Primärschlüssel des Eintrags als UUID"
|
||||
|
||||
#: core/models.py:124
|
||||
#: core/models.py:125
|
||||
msgid "created on"
|
||||
msgstr "erstellt am"
|
||||
|
||||
#: core/models.py:125
|
||||
#: core/models.py:126
|
||||
msgid "date and time at which a record was created"
|
||||
msgstr "Datum und Uhrzeit der Erstellung eines Eintrags"
|
||||
|
||||
#: core/models.py:130
|
||||
#: core/models.py:131
|
||||
msgid "updated on"
|
||||
msgstr "aktualisiert am"
|
||||
|
||||
#: core/models.py:131
|
||||
#: core/models.py:132
|
||||
msgid "date and time at which a record was last updated"
|
||||
msgstr "Datum und Uhrzeit der letzten Aktualisierung eines Eintrags"
|
||||
|
||||
#: 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
|
||||
#: core/models.py:150
|
||||
msgid "sub"
|
||||
msgstr "Sub"
|
||||
|
||||
#: core/models.py:159
|
||||
#: core/models.py:152
|
||||
msgid ""
|
||||
"Optional for pending users; required upon account activation. 255 characters "
|
||||
"or fewer. Letters, numbers, and @/./+/-/_ characters only."
|
||||
"or fewer. Printable ASCII characters only."
|
||||
msgstr ""
|
||||
"Optional für ausstehende Benutzer; erforderlich nach Kontoaktivierung. "
|
||||
"Maximal 255 Zeichen. Nur Buchstaben, Zahlen und @/./+/-/_ Zeichen erlaubt."
|
||||
"Maximal 255 Zeichen. Nur druckbare ASCII-Zeichen erlaubt."
|
||||
|
||||
#: core/models.py:168
|
||||
#: core/validators.py:22
|
||||
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"
|
||||
msgstr "Identitäts-E-Mail-Adresse"
|
||||
|
||||
#: core/models.py:173
|
||||
#: core/models.py:166
|
||||
msgid "admin email address"
|
||||
msgstr "Administrator-E-Mail-Adresse"
|
||||
|
||||
#: core/models.py:175
|
||||
#: core/models.py:168
|
||||
msgid "full name"
|
||||
msgstr "Vollständiger Name"
|
||||
|
||||
#: core/models.py:177
|
||||
#: core/models.py:170
|
||||
msgid "short name"
|
||||
msgstr "Kurzname"
|
||||
|
||||
#: core/models.py:183
|
||||
#: core/models.py:176
|
||||
msgid "language"
|
||||
msgstr "Sprache"
|
||||
|
||||
#: core/models.py:184
|
||||
#: core/models.py:177
|
||||
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."
|
||||
|
||||
#: core/models.py:190
|
||||
#: core/models.py:183
|
||||
msgid "The timezone in which the user wants to see times."
|
||||
msgstr "Die Zeitzone, in der der Benutzer die Zeiten sehen möchte."
|
||||
|
||||
#: core/models.py:193
|
||||
#: 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 "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"
|
||||
msgstr "Gerät"
|
||||
|
||||
#: core/models.py:195
|
||||
#: core/models.py:207
|
||||
msgid "Whether the user is a device or a real user."
|
||||
msgstr "Ob es sich um ein Gerät oder einen echten Benutzer handelt."
|
||||
|
||||
#: core/models.py:198
|
||||
#: core/models.py:210
|
||||
msgid "staff status"
|
||||
msgstr "Mitarbeiterstatus"
|
||||
|
||||
#: core/models.py:200
|
||||
#: core/models.py:212
|
||||
msgid "Whether the user can log into this admin site."
|
||||
msgstr "Ob der Benutzer sich bei dieser Admin-Seite anmelden kann."
|
||||
|
||||
#: core/models.py:203
|
||||
#: core/models.py:215
|
||||
msgid "active"
|
||||
msgstr "aktiv"
|
||||
|
||||
#: core/models.py:206
|
||||
#: core/models.py:218
|
||||
msgid ""
|
||||
"Whether this user should be treated as active. Unselect this instead of "
|
||||
"deleting accounts."
|
||||
@@ -296,66 +312,66 @@ msgstr ""
|
||||
"Ob dieser Benutzer als aktiv behandelt werden soll. Deaktivieren Sie dies "
|
||||
"anstelle des Löschens des Kontos."
|
||||
|
||||
#: core/models.py:219
|
||||
#: core/models.py:231
|
||||
msgid "user"
|
||||
msgstr "Benutzer"
|
||||
|
||||
#: core/models.py:220
|
||||
#: core/models.py:232
|
||||
msgid "users"
|
||||
msgstr "Benutzer"
|
||||
|
||||
#: core/models.py:286
|
||||
#: core/models.py:298
|
||||
msgid "Resource"
|
||||
msgstr "Ressource"
|
||||
|
||||
#: core/models.py:287
|
||||
#: core/models.py:299
|
||||
msgid "Resources"
|
||||
msgstr "Ressourcen"
|
||||
|
||||
#: core/models.py:345
|
||||
#: core/models.py:357
|
||||
msgid "Resource access"
|
||||
msgstr "Ressourcenzugriff"
|
||||
|
||||
#: core/models.py:346
|
||||
#: core/models.py:358
|
||||
msgid "Resource accesses"
|
||||
msgstr "Ressourcenzugriffe"
|
||||
|
||||
#: core/models.py:352
|
||||
#: core/models.py:364
|
||||
msgid "Resource access with this User and Resource already exists."
|
||||
msgstr ""
|
||||
"Ein Ressourcenzugriff mit diesem Benutzer und dieser Ressource existiert "
|
||||
"bereits."
|
||||
|
||||
#: core/models.py:409
|
||||
#: core/models.py:421
|
||||
msgid "Visio room configuration"
|
||||
msgstr "Visio-Raumkonfiguration"
|
||||
|
||||
#: core/models.py:410
|
||||
#: core/models.py:422
|
||||
msgid "Values for Visio parameters to configure the room."
|
||||
msgstr "Werte für Visio-Parameter zur Konfiguration des Raums."
|
||||
|
||||
#: core/models.py:417
|
||||
#: core/models.py:429
|
||||
msgid "Room PIN code"
|
||||
msgstr "PIN-Code für den Raum"
|
||||
|
||||
#: core/models.py:418
|
||||
#: core/models.py:430
|
||||
msgid "Unique n-digit code that identifies this room in telephony mode."
|
||||
msgstr ""
|
||||
"Eindeutiger n-stelliger Code, der diesen Raum im Telephonmodus identifiziert."
|
||||
|
||||
#: core/models.py:424 core/models.py:578
|
||||
#: core/models.py:436 core/models.py:597
|
||||
msgid "Room"
|
||||
msgstr "Raum"
|
||||
|
||||
#: core/models.py:425
|
||||
#: core/models.py:437
|
||||
msgid "Rooms"
|
||||
msgstr "Räume"
|
||||
|
||||
#: core/models.py:589
|
||||
#: core/models.py:608
|
||||
msgid "Worker ID"
|
||||
msgstr "Worker-ID"
|
||||
|
||||
#: core/models.py:591
|
||||
#: 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."
|
||||
@@ -364,153 +380,153 @@ msgstr ""
|
||||
"erhalten, auch wenn der Worker stoppt, was ein einfaches Nachverfolgen "
|
||||
"ermöglicht."
|
||||
|
||||
#: core/models.py:599
|
||||
#: core/models.py:618
|
||||
msgid "Recording mode"
|
||||
msgstr "Aufzeichnungsmodus"
|
||||
|
||||
#: core/models.py:600
|
||||
#: core/models.py:619
|
||||
msgid "Defines the mode of recording being called."
|
||||
msgstr "Definiert den aufgerufenen Aufzeichnungsmodus."
|
||||
|
||||
#: core/models.py:605 core/models.py:606
|
||||
#: core/models.py:624 core/models.py:625
|
||||
msgid "Recording options"
|
||||
msgstr "Aufnahmeoptionen"
|
||||
|
||||
#: core/models.py:613
|
||||
#: core/models.py:632
|
||||
msgid "External Process ID"
|
||||
msgstr "External Process ID"
|
||||
|
||||
#: core/models.py:614
|
||||
#: core/models.py:633
|
||||
msgid "ID of the external process associated with the recording."
|
||||
msgstr "ID des externen Prozesses, der mit der Aufzeichnung verknüpft ist"
|
||||
|
||||
#: core/models.py:620
|
||||
#: core/models.py:639
|
||||
msgid "Recording"
|
||||
msgstr "Aufzeichnung"
|
||||
|
||||
#: core/models.py:621
|
||||
#: core/models.py:640
|
||||
msgid "Recordings"
|
||||
msgstr "Aufzeichnungen"
|
||||
|
||||
#: core/models.py:731
|
||||
#: core/models.py:750
|
||||
msgid "Recording/user relation"
|
||||
msgstr "Beziehung Aufzeichnung/Benutzer"
|
||||
|
||||
#: core/models.py:732
|
||||
#: core/models.py:751
|
||||
msgid "Recording/user relations"
|
||||
msgstr "Beziehungen Aufzeichnung/Benutzer"
|
||||
|
||||
#: core/models.py:738
|
||||
#: core/models.py:757
|
||||
msgid "This user is already in this recording."
|
||||
msgstr "Dieser Benutzer ist bereits Teil dieser Aufzeichnung."
|
||||
|
||||
#: core/models.py:744
|
||||
#: core/models.py:763
|
||||
msgid "This team is already in this recording."
|
||||
msgstr "Dieses Team ist bereits Teil dieser Aufzeichnung."
|
||||
|
||||
#: core/models.py:750
|
||||
#: core/models.py:769
|
||||
msgid "Either user or team must be set, not both."
|
||||
msgstr "Entweder Benutzer oder Team muss festgelegt werden, nicht beides."
|
||||
|
||||
#: core/models.py:767
|
||||
#: core/models.py:786
|
||||
msgid "Create rooms"
|
||||
msgstr "Räume erstellen"
|
||||
|
||||
#: core/models.py:768
|
||||
#: core/models.py:787
|
||||
msgid "List rooms"
|
||||
msgstr "Räume auflisten"
|
||||
|
||||
#: core/models.py:769
|
||||
#: core/models.py:788
|
||||
msgid "Retrieve room details"
|
||||
msgstr "Raumdetails abrufen"
|
||||
|
||||
#: core/models.py:770
|
||||
#: core/models.py:789
|
||||
msgid "Update rooms"
|
||||
msgstr "Räume aktualisieren"
|
||||
|
||||
#: core/models.py:771
|
||||
#: core/models.py:790
|
||||
msgid "Delete rooms"
|
||||
msgstr "Räume löschen"
|
||||
|
||||
#: core/models.py:784
|
||||
#: core/models.py:803
|
||||
msgid "Application name"
|
||||
msgstr "Anwendungsname"
|
||||
|
||||
#: core/models.py:785
|
||||
#: core/models.py:804
|
||||
msgid "Descriptive name for this application."
|
||||
msgstr "Beschreibender Name für diese Anwendung."
|
||||
|
||||
#: core/models.py:795
|
||||
#: core/models.py:814
|
||||
msgid "Hashed on Save. Copy it now if this is a new secret."
|
||||
msgstr ""
|
||||
"Beim Speichern gehasht. Jetzt kopieren, wenn dies ein neues Geheimnis ist."
|
||||
|
||||
#: core/models.py:806
|
||||
#: core/models.py:825
|
||||
msgid "Application"
|
||||
msgstr "Anwendung"
|
||||
|
||||
#: core/models.py:807
|
||||
#: core/models.py:826
|
||||
msgid "Applications"
|
||||
msgstr "Anwendungen"
|
||||
|
||||
#: core/models.py:830
|
||||
#: core/models.py:849
|
||||
msgid "Enter a valid domain"
|
||||
msgstr "Geben Sie eine gültige Domain ein"
|
||||
|
||||
#: core/models.py:833
|
||||
#: core/models.py:852
|
||||
msgid "Domain"
|
||||
msgstr "Domain"
|
||||
|
||||
#: core/models.py:834
|
||||
#: core/models.py:853
|
||||
msgid "Email domain this application can act on behalf of."
|
||||
msgstr "E-Mail-Domain, im Namen der diese Anwendung handeln kann."
|
||||
|
||||
#: core/models.py:846
|
||||
#: core/models.py:865
|
||||
msgid "Application domain"
|
||||
msgstr "Anwendungsdomain"
|
||||
|
||||
#: core/models.py:847
|
||||
#: core/models.py:866
|
||||
msgid "Application domains"
|
||||
msgstr "Anwendungsdomains"
|
||||
|
||||
#: core/models.py:865
|
||||
#: core/models.py:884
|
||||
msgid "Pending"
|
||||
msgstr "Ausstehend"
|
||||
|
||||
#: core/models.py:866
|
||||
#: core/models.py:885
|
||||
msgid "Analyzing"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:873
|
||||
#: core/models.py:892
|
||||
msgid "Ready"
|
||||
msgstr "Bereit"
|
||||
|
||||
#: core/models.py:879
|
||||
#: core/models.py:898
|
||||
msgid "Background image"
|
||||
msgstr "Hintergrundbild"
|
||||
|
||||
#: core/models.py:891
|
||||
#: core/models.py:910
|
||||
msgid "title"
|
||||
msgstr "Titel"
|
||||
|
||||
#: core/models.py:915
|
||||
#: core/models.py:934
|
||||
msgid "Malware detection info when the analysis status is unsafe."
|
||||
msgstr ""
|
||||
"Informationen zur Malware-Erkennung, wenn der Analyse-Status unsicher ist."
|
||||
|
||||
#: core/models.py:920
|
||||
#: core/models.py:939
|
||||
msgid "File"
|
||||
msgstr "Datei"
|
||||
|
||||
#: core/models.py:921
|
||||
#: core/models.py:940
|
||||
msgid "Files"
|
||||
msgstr "Dateien"
|
||||
|
||||
#: core/models.py:1041
|
||||
#: core/models.py:1060
|
||||
msgid "This file is already hard deleted."
|
||||
msgstr "Diese Datei wurde bereits endgültig gelöscht."
|
||||
|
||||
#: core/models.py:1051
|
||||
#: core/models.py:1070
|
||||
#, 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."
|
||||
@@ -518,15 +534,15 @@ msgstr ""
|
||||
"Um eine Datei endgültig zu löschen, muss sie zuvor weich gelöscht worden "
|
||||
"sein."
|
||||
|
||||
#: core/recording/event/notification.py:123
|
||||
#: core/recording/event/notification.py:124
|
||||
msgid "Your recording is ready"
|
||||
msgstr "Ihre Aufzeichnung ist bereit"
|
||||
|
||||
#: core/recording/event/notification.py:194
|
||||
#: core/recording/event/notification.py:195
|
||||
msgid "Transcription"
|
||||
msgstr "Transkription"
|
||||
|
||||
#: core/recording/event/notification.py:204
|
||||
#: core/recording/event/notification.py:206
|
||||
#, python-brace-format
|
||||
msgid "Meeting \"{room}\" on {room_recording_date} at {room_recording_time}"
|
||||
msgstr ""
|
||||
@@ -537,25 +553,25 @@ msgstr ""
|
||||
msgid "Video call in progress: {sender.email} is waiting for you to connect"
|
||||
msgstr "Videoanruf läuft: {sender.email} wartet auf Ihre Teilnahme"
|
||||
|
||||
#: core/templates/mail/html/invitation.html:159
|
||||
#: core/templates/mail/html/screen_recording.html:159
|
||||
#: core/templates/mail/text/invitation.txt:3
|
||||
#: core/templates/mail/text/screen_recording.txt:3
|
||||
#: 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 "Logo-E-Mail"
|
||||
|
||||
#: core/templates/mail/html/invitation.html:189
|
||||
#: core/templates/mail/text/invitation.txt:5
|
||||
#: core/templates/mail/html/invitation.html:181
|
||||
#: core/templates/mail/text/invitation.txt:6
|
||||
msgid "invites you to join an ongoing video call"
|
||||
msgstr "lädt Sie zu einem laufenden Videoanruf ein"
|
||||
|
||||
#: core/templates/mail/html/invitation.html:200
|
||||
#: core/templates/mail/text/invitation.txt:7
|
||||
#: core/templates/mail/html/invitation.html:192
|
||||
#: core/templates/mail/text/invitation.txt:8
|
||||
msgid "JOIN THE CALL"
|
||||
msgstr "AM ANRUF TEILNEHMEN"
|
||||
|
||||
#: core/templates/mail/html/invitation.html:227
|
||||
#: core/templates/mail/text/invitation.txt:13
|
||||
#: 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."
|
||||
@@ -563,41 +579,47 @@ msgstr ""
|
||||
"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."
|
||||
|
||||
#: core/templates/mail/html/invitation.html:235
|
||||
#: core/templates/mail/text/invitation.txt:15
|
||||
#: core/templates/mail/html/invitation.html:227
|
||||
#: core/templates/mail/text/invitation.txt:16
|
||||
msgid "Tips for a better experience:"
|
||||
msgstr "Tipps für ein besseres Erlebnis:"
|
||||
|
||||
#: core/templates/mail/html/invitation.html:237
|
||||
#: core/templates/mail/text/invitation.txt:17
|
||||
#: core/templates/mail/html/invitation.html:229
|
||||
#: core/templates/mail/text/invitation.txt:18
|
||||
msgid "Use Chrome or Firefox for better call quality"
|
||||
msgstr "Verwenden Sie Chrome oder Firefox für eine bessere Anrufqualität"
|
||||
|
||||
#: core/templates/mail/html/invitation.html:238
|
||||
#: core/templates/mail/text/invitation.txt:18
|
||||
#: core/templates/mail/html/invitation.html:230
|
||||
#: core/templates/mail/text/invitation.txt:19
|
||||
msgid "Test your microphone and camera before joining"
|
||||
msgstr "Testen Sie Ihr Mikrofon und Ihre Kamera vor dem Beitritt"
|
||||
|
||||
#: core/templates/mail/html/invitation.html:239
|
||||
#: core/templates/mail/text/invitation.txt:19
|
||||
#: core/templates/mail/html/invitation.html:231
|
||||
#: core/templates/mail/text/invitation.txt:20
|
||||
msgid "Make sure you have a stable internet connection"
|
||||
msgstr "Stellen Sie sicher, dass Sie eine stabile Internetverbindung haben"
|
||||
|
||||
#: core/templates/mail/html/invitation.html:248
|
||||
#: core/templates/mail/html/screen_recording.html:245
|
||||
#: core/templates/mail/text/invitation.txt:21
|
||||
#: core/templates/mail/text/screen_recording.txt:23
|
||||
#: 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 " Vielen Dank für die Nutzung von %(brandname)s. "
|
||||
|
||||
#: core/templates/mail/html/screen_recording.html:188
|
||||
#: core/templates/mail/text/screen_recording.txt:6
|
||||
#: 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 "Ihre Aufzeichnung ist fertig!"
|
||||
|
||||
#: core/templates/mail/html/screen_recording.html:195
|
||||
#: core/templates/mail/text/screen_recording.txt:8
|
||||
#: 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 "
|
||||
@@ -606,14 +628,14 @@ msgstr ""
|
||||
" Ihre Aufzeichnung von \"%(room_name)s\" am %(recording_date)s um "
|
||||
"%(recording_time)s steht nun zum Herunterladen bereit. "
|
||||
|
||||
#: core/templates/mail/html/screen_recording.html:195
|
||||
#: core/templates/mail/text/screen_recording.txt:8
|
||||
#: 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 " Die Aufzeichnung wird in %(days)s Tagen ablaufen. "
|
||||
|
||||
#: core/templates/mail/html/screen_recording.html:200
|
||||
#: core/templates/mail/text/screen_recording.txt:9
|
||||
#: 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. "
|
||||
@@ -621,33 +643,33 @@ msgstr ""
|
||||
" Die Freigabe der Aufzeichnung per Link ist noch nicht verfügbar. Nur "
|
||||
"Organisatoren können sie herunterladen. "
|
||||
|
||||
#: core/templates/mail/html/screen_recording.html:206
|
||||
#: core/templates/mail/text/screen_recording.txt:11
|
||||
#: core/templates/mail/html/screen_recording.html:198
|
||||
#: core/templates/mail/text/screen_recording.txt:12
|
||||
msgid "To keep this recording permanently:"
|
||||
msgstr "So speichern Sie diese Aufzeichnung dauerhaft:"
|
||||
|
||||
#: core/templates/mail/html/screen_recording.html:208
|
||||
#: core/templates/mail/html/screen_recording.html:200
|
||||
#, python-format
|
||||
msgid "Click the \"<a href=\"%(link)s\">Open</a>\" link below "
|
||||
msgstr "Klicken Sie auf den Link „<a href=\"%(link)s\">Öffnen</a>\" unten "
|
||||
|
||||
#: core/templates/mail/html/screen_recording.html:209
|
||||
#: core/templates/mail/text/screen_recording.txt:14
|
||||
#: 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 "Verwenden Sie den Button „Herunterladen“ in der Oberfläche "
|
||||
|
||||
#: core/templates/mail/html/screen_recording.html:210
|
||||
#: core/templates/mail/text/screen_recording.txt:15
|
||||
#: 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 "Speichern Sie die Datei an einem gewünschten Ort"
|
||||
|
||||
#: core/templates/mail/html/screen_recording.html:221
|
||||
#: core/templates/mail/text/screen_recording.txt:17
|
||||
#: core/templates/mail/html/screen_recording.html:213
|
||||
#: core/templates/mail/text/screen_recording.txt:18
|
||||
msgid "Open"
|
||||
msgstr "Öffnen"
|
||||
|
||||
#: core/templates/mail/html/screen_recording.html:230
|
||||
#: core/templates/mail/text/screen_recording.txt:19
|
||||
#: 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 "
|
||||
@@ -656,24 +678,33 @@ msgstr ""
|
||||
" Wenn Sie Fragen haben oder Unterstützung benötigen, wenden Sie sich bitte "
|
||||
"an unser Support-Team unter %(support_email)s. "
|
||||
|
||||
#: core/templates/mail/text/screen_recording.txt:13
|
||||
#: 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
|
||||
#, fuzzy, python-format
|
||||
#| msgid "Click the \"<a href=\"%(link)s\">Open</a>\" link below "
|
||||
msgid "Click the \"Open [%(link)s]\" link below "
|
||||
msgstr "Klicken Sie auf den Link „<a href=\"%(link)s\">Öffnen</a>\" unten "
|
||||
|
||||
#: meet/settings.py:228
|
||||
#: meet/settings.py:238
|
||||
msgid "English"
|
||||
msgstr "Englisch"
|
||||
|
||||
#: meet/settings.py:229
|
||||
#: meet/settings.py:239
|
||||
msgid "French"
|
||||
msgstr "Französisch"
|
||||
|
||||
#: meet/settings.py:230
|
||||
#: meet/settings.py:240
|
||||
msgid "Dutch"
|
||||
msgstr "Niederländisch"
|
||||
|
||||
#: meet/settings.py:231
|
||||
#: meet/settings.py:241
|
||||
msgid "German"
|
||||
msgstr "Deutsch"
|
||||
|
||||
#: meet/settings.py:242
|
||||
msgid "Spanish"
|
||||
msgstr "Spanisch"
|
||||
|
||||
@@ -8,7 +8,7 @@ msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: PACKAGE VERSION\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2026-07-02 10:47+0000\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"
|
||||
@@ -51,11 +51,11 @@ msgstr ""
|
||||
msgid "File preview"
|
||||
msgstr ""
|
||||
|
||||
#: core/admin.py:300 core/admin.py:443
|
||||
#: core/admin.py:300 core/admin.py:450
|
||||
msgid "No owner"
|
||||
msgstr "No owner"
|
||||
|
||||
#: core/admin.py:303 core/admin.py:446
|
||||
#: core/admin.py:303 core/admin.py:453
|
||||
msgid "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."
|
||||
msgstr "Skipped %(count)s expired recording(s)."
|
||||
|
||||
#: core/admin.py:510
|
||||
#: core/admin.py:517
|
||||
msgid "No scopes"
|
||||
msgstr "No scopes"
|
||||
|
||||
#: core/admin.py:512
|
||||
#: core/admin.py:519
|
||||
msgid "Scopes"
|
||||
msgstr "Scopes"
|
||||
|
||||
@@ -110,183 +110,199 @@ msgstr "Scopes"
|
||||
msgid "Creator is me"
|
||||
msgstr "Creator is me"
|
||||
|
||||
#: core/api/serializers.py:89
|
||||
#: core/api/serializers.py:108
|
||||
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."
|
||||
|
||||
#: core/api/serializers.py:534
|
||||
#: core/api/serializers.py:560
|
||||
msgid "This file extension is not allowed."
|
||||
msgstr "This file extension is not allowed."
|
||||
|
||||
#: core/api/viewsets.py:1222
|
||||
#: core/api/viewsets.py:1268
|
||||
msgid "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:37
|
||||
#: core/models.py:38
|
||||
msgid "Member"
|
||||
msgstr "Member"
|
||||
|
||||
#: core/models.py:38
|
||||
#: core/models.py:39
|
||||
msgid "Administrator"
|
||||
msgstr "Administrator"
|
||||
|
||||
#: core/models.py:39
|
||||
#: core/models.py:40
|
||||
msgid "Owner"
|
||||
msgstr "Owner"
|
||||
|
||||
#: core/models.py:55
|
||||
#: core/models.py:56
|
||||
msgid "Initiated"
|
||||
msgstr "Initiated"
|
||||
|
||||
#: core/models.py:56
|
||||
#: core/models.py:57
|
||||
msgid "Active"
|
||||
msgstr "Active"
|
||||
|
||||
#: core/models.py:57
|
||||
#: core/models.py:58
|
||||
msgid "Stopped"
|
||||
msgstr "Stopped"
|
||||
|
||||
#: core/models.py:58
|
||||
#: core/models.py:59
|
||||
msgid "Saved"
|
||||
msgstr "Saved"
|
||||
|
||||
#: core/models.py:59
|
||||
#: core/models.py:60
|
||||
msgid "Aborted"
|
||||
msgstr "Aborted"
|
||||
|
||||
#: core/models.py:60
|
||||
#: core/models.py:61
|
||||
msgid "Failed to Start"
|
||||
msgstr "Failed to Start"
|
||||
|
||||
#: core/models.py:61
|
||||
#: core/models.py:62
|
||||
msgid "Failed to Stop"
|
||||
msgstr "Failed to Stop"
|
||||
|
||||
#: core/models.py:62
|
||||
#: core/models.py:63
|
||||
msgid "Notification succeeded"
|
||||
msgstr "Notification succeeded"
|
||||
|
||||
#: core/models.py:65
|
||||
#: core/models.py:66
|
||||
msgid "External process successful"
|
||||
msgstr "External process successful"
|
||||
|
||||
#: core/models.py:67
|
||||
#: core/models.py:68
|
||||
msgid "External process failed"
|
||||
msgstr "External process failed"
|
||||
|
||||
#: core/models.py:96
|
||||
#: core/models.py:97
|
||||
msgid "SCREEN_RECORDING"
|
||||
msgstr "SCREEN_RECORDING"
|
||||
|
||||
#: core/models.py:97
|
||||
#: core/models.py:98
|
||||
msgid "TRANSCRIPT"
|
||||
msgstr "TRANSCRIPT"
|
||||
|
||||
#: core/models.py:103
|
||||
#: core/models.py:104
|
||||
msgid "Public Access"
|
||||
msgstr "Public Access"
|
||||
|
||||
#: core/models.py:104
|
||||
#: core/models.py:105
|
||||
msgid "Trusted Access"
|
||||
msgstr "Trusted Access"
|
||||
|
||||
#: core/models.py:105
|
||||
#: core/models.py:106
|
||||
msgid "Restricted Access"
|
||||
msgstr "Restricted Access"
|
||||
|
||||
#: core/models.py:117
|
||||
#: core/models.py:118
|
||||
msgid "id"
|
||||
msgstr "id"
|
||||
|
||||
#: core/models.py:118
|
||||
#: core/models.py:119
|
||||
msgid "primary key for the record as UUID"
|
||||
msgstr "primary key for the record as UUID"
|
||||
|
||||
#: core/models.py:124
|
||||
#: core/models.py:125
|
||||
msgid "created on"
|
||||
msgstr "created on"
|
||||
|
||||
#: core/models.py:125
|
||||
#: core/models.py:126
|
||||
msgid "date and time at which a record was created"
|
||||
msgstr "date and time at which a record was created"
|
||||
|
||||
#: core/models.py:130
|
||||
#: core/models.py:131
|
||||
msgid "updated on"
|
||||
msgstr "updated on"
|
||||
|
||||
#: core/models.py:131
|
||||
#: core/models.py:132
|
||||
msgid "date and time at which a record was last updated"
|
||||
msgstr "date and time at which a record was last updated"
|
||||
|
||||
#: 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
|
||||
#: core/models.py:150
|
||||
msgid "sub"
|
||||
msgstr "sub"
|
||||
|
||||
#: core/models.py:159
|
||||
#: core/models.py:152
|
||||
msgid ""
|
||||
"Optional for pending users; required upon account activation. 255 characters "
|
||||
"or fewer. Letters, numbers, and @/./+/-/_ characters only."
|
||||
"or fewer. Printable ASCII characters only."
|
||||
msgstr ""
|
||||
"Required. 255 characters or fewer. Letters, numbers, and @/./+/-/_ "
|
||||
"characters only."
|
||||
"Optional for pending users; required upon account activation. 255 characters "
|
||||
"or fewer. Printable ASCII characters only."
|
||||
|
||||
#: core/models.py:168
|
||||
#: core/validators.py:22
|
||||
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"
|
||||
msgstr "identity email address"
|
||||
|
||||
#: core/models.py:173
|
||||
#: core/models.py:166
|
||||
msgid "admin email address"
|
||||
msgstr "admin email address"
|
||||
|
||||
#: core/models.py:175
|
||||
#: core/models.py:168
|
||||
msgid "full name"
|
||||
msgstr "full name"
|
||||
|
||||
#: core/models.py:177
|
||||
#: core/models.py:170
|
||||
msgid "short name"
|
||||
msgstr "short name"
|
||||
|
||||
#: core/models.py:183
|
||||
#: core/models.py:176
|
||||
msgid "language"
|
||||
msgstr "language"
|
||||
|
||||
#: core/models.py:184
|
||||
#: core/models.py:177
|
||||
msgid "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:190
|
||||
#: core/models.py:183
|
||||
msgid "The timezone in which the user wants to see times."
|
||||
msgstr "The timezone in which the user wants to see times."
|
||||
|
||||
#: core/models.py:193
|
||||
#: 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 "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"
|
||||
msgstr "device"
|
||||
|
||||
#: core/models.py:195
|
||||
#: core/models.py:207
|
||||
msgid "Whether the user is a device or a real user."
|
||||
msgstr "Whether the user is a device or a real user."
|
||||
|
||||
#: core/models.py:198
|
||||
#: core/models.py:210
|
||||
msgid "staff status"
|
||||
msgstr "staff status"
|
||||
|
||||
#: core/models.py:200
|
||||
#: core/models.py:212
|
||||
msgid "Whether the user can log into this admin site."
|
||||
msgstr "Whether the user can log into this admin site."
|
||||
|
||||
#: core/models.py:203
|
||||
#: core/models.py:215
|
||||
msgid "active"
|
||||
msgstr "active"
|
||||
|
||||
#: core/models.py:206
|
||||
#: core/models.py:218
|
||||
msgid ""
|
||||
"Whether this user should be treated as active. Unselect this instead of "
|
||||
"deleting accounts."
|
||||
@@ -294,63 +310,63 @@ msgstr ""
|
||||
"Whether this user should be treated as active. Unselect this instead of "
|
||||
"deleting accounts."
|
||||
|
||||
#: core/models.py:219
|
||||
#: core/models.py:231
|
||||
msgid "user"
|
||||
msgstr "user"
|
||||
|
||||
#: core/models.py:220
|
||||
#: core/models.py:232
|
||||
msgid "users"
|
||||
msgstr "users"
|
||||
|
||||
#: core/models.py:286
|
||||
#: core/models.py:298
|
||||
msgid "Resource"
|
||||
msgstr "Resource"
|
||||
|
||||
#: core/models.py:287
|
||||
#: core/models.py:299
|
||||
msgid "Resources"
|
||||
msgstr "Resources"
|
||||
|
||||
#: core/models.py:345
|
||||
#: core/models.py:357
|
||||
msgid "Resource access"
|
||||
msgstr "Resource access"
|
||||
|
||||
#: core/models.py:346
|
||||
#: core/models.py:358
|
||||
msgid "Resource accesses"
|
||||
msgstr "Resource accesses"
|
||||
|
||||
#: core/models.py:352
|
||||
#: core/models.py:364
|
||||
msgid "Resource access with this User and Resource already exists."
|
||||
msgstr "Resource access with this User and Resource already exists."
|
||||
|
||||
#: core/models.py:409
|
||||
#: core/models.py:421
|
||||
msgid "Visio room configuration"
|
||||
msgstr "Visio room configuration"
|
||||
|
||||
#: core/models.py:410
|
||||
#: core/models.py:422
|
||||
msgid "Values for Visio parameters to configure the room."
|
||||
msgstr "Values for Visio parameters to configure the room."
|
||||
|
||||
#: core/models.py:417
|
||||
#: core/models.py:429
|
||||
msgid "Room PIN code"
|
||||
msgstr "Room PIN code"
|
||||
|
||||
#: core/models.py:418
|
||||
#: core/models.py:430
|
||||
msgid "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:424 core/models.py:578
|
||||
#: core/models.py:436 core/models.py:597
|
||||
msgid "Room"
|
||||
msgstr "Room"
|
||||
|
||||
#: core/models.py:425
|
||||
#: core/models.py:437
|
||||
msgid "Rooms"
|
||||
msgstr "Rooms"
|
||||
|
||||
#: core/models.py:589
|
||||
#: core/models.py:608
|
||||
msgid "Worker ID"
|
||||
msgstr "Worker ID"
|
||||
|
||||
#: core/models.py:591
|
||||
#: 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."
|
||||
@@ -358,171 +374,171 @@ msgstr ""
|
||||
"Enter an identifier for the worker recording.This ID is retained even when "
|
||||
"the worker stops, allowing for easy tracking."
|
||||
|
||||
#: core/models.py:599
|
||||
#: core/models.py:618
|
||||
msgid "Recording mode"
|
||||
msgstr "Recording mode"
|
||||
|
||||
#: core/models.py:600
|
||||
#: core/models.py:619
|
||||
msgid "Defines the mode of recording being called."
|
||||
msgstr "Defines the mode of recording being called."
|
||||
|
||||
#: core/models.py:605 core/models.py:606
|
||||
#: core/models.py:624 core/models.py:625
|
||||
msgid "Recording options"
|
||||
msgstr "Recording options"
|
||||
|
||||
#: core/models.py:613
|
||||
#: core/models.py:632
|
||||
msgid "External Process ID"
|
||||
msgstr "External Process ID"
|
||||
|
||||
#: core/models.py:614
|
||||
#: core/models.py:633
|
||||
msgid "ID of the external process associated with the recording."
|
||||
msgstr "ID of the external process associated with the recording."
|
||||
|
||||
#: core/models.py:620
|
||||
#: core/models.py:639
|
||||
msgid "Recording"
|
||||
msgstr "Recording"
|
||||
|
||||
#: core/models.py:621
|
||||
#: core/models.py:640
|
||||
msgid "Recordings"
|
||||
msgstr "Recordings"
|
||||
|
||||
#: core/models.py:731
|
||||
#: core/models.py:750
|
||||
msgid "Recording/user relation"
|
||||
msgstr "Recording/user relation"
|
||||
|
||||
#: core/models.py:732
|
||||
#: core/models.py:751
|
||||
msgid "Recording/user relations"
|
||||
msgstr "Recording/user relations"
|
||||
|
||||
#: core/models.py:738
|
||||
#: core/models.py:757
|
||||
msgid "This user is already in this recording."
|
||||
msgstr "This user is already in this recording."
|
||||
|
||||
#: core/models.py:744
|
||||
#: core/models.py:763
|
||||
msgid "This team is already in this recording."
|
||||
msgstr "This team is already in this recording."
|
||||
|
||||
#: core/models.py:750
|
||||
#: core/models.py:769
|
||||
msgid "Either user or team must be set, not both."
|
||||
msgstr "Either user or team must be set, not both."
|
||||
|
||||
#: core/models.py:767
|
||||
#: core/models.py:786
|
||||
#, fuzzy
|
||||
#| msgid "created on"
|
||||
msgid "Create rooms"
|
||||
msgstr "Create rooms"
|
||||
|
||||
#: core/models.py:768
|
||||
#: core/models.py:787
|
||||
msgid "List rooms"
|
||||
msgstr "List rooms"
|
||||
|
||||
#: core/models.py:769
|
||||
#: core/models.py:788
|
||||
msgid "Retrieve room details"
|
||||
msgstr "Retrieve room details"
|
||||
|
||||
#: core/models.py:770
|
||||
#: core/models.py:789
|
||||
#, fuzzy
|
||||
#| msgid "updated on"
|
||||
msgid "Update rooms"
|
||||
msgstr "Update rooms"
|
||||
|
||||
#: core/models.py:771
|
||||
#: core/models.py:790
|
||||
msgid "Delete rooms"
|
||||
msgstr "Delete rooms"
|
||||
|
||||
#: core/models.py:784
|
||||
#: core/models.py:803
|
||||
msgid "Application name"
|
||||
msgstr "Application name"
|
||||
|
||||
#: core/models.py:785
|
||||
#: core/models.py:804
|
||||
msgid "Descriptive name for this application."
|
||||
msgstr "Descriptive name for this application."
|
||||
|
||||
#: core/models.py:795
|
||||
#: core/models.py:814
|
||||
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."
|
||||
|
||||
#: core/models.py:806
|
||||
#: core/models.py:825
|
||||
msgid "Application"
|
||||
msgstr "Application"
|
||||
|
||||
#: core/models.py:807
|
||||
#: core/models.py:826
|
||||
msgid "Applications"
|
||||
msgstr "Applications"
|
||||
|
||||
#: core/models.py:830
|
||||
#: core/models.py:849
|
||||
msgid "Enter a valid domain"
|
||||
msgstr "Enter a valid domain"
|
||||
|
||||
#: core/models.py:833
|
||||
#: core/models.py:852
|
||||
msgid "Domain"
|
||||
msgstr "Domain"
|
||||
|
||||
#: core/models.py:834
|
||||
#: core/models.py:853
|
||||
msgid "Email domain this application can act on behalf of."
|
||||
msgstr "Email domain this application can act on behalf of."
|
||||
|
||||
#: core/models.py:846
|
||||
#: core/models.py:865
|
||||
msgid "Application domain"
|
||||
msgstr "Application domain"
|
||||
|
||||
#: core/models.py:847
|
||||
#: core/models.py:866
|
||||
msgid "Application domains"
|
||||
msgstr "Application domains"
|
||||
|
||||
#: core/models.py:865
|
||||
#: core/models.py:884
|
||||
#, fuzzy
|
||||
#| msgid "Recording"
|
||||
msgid "Pending"
|
||||
msgstr "Pending"
|
||||
|
||||
#: core/models.py:866
|
||||
#: core/models.py:885
|
||||
msgid "Analyzing"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:873
|
||||
#: core/models.py:892
|
||||
msgid "Ready"
|
||||
msgstr "Ready"
|
||||
|
||||
#: core/models.py:879
|
||||
#: core/models.py:898
|
||||
msgid "Background image"
|
||||
msgstr "Background image"
|
||||
|
||||
#: core/models.py:891
|
||||
#: core/models.py:910
|
||||
msgid "title"
|
||||
msgstr "title"
|
||||
|
||||
#: core/models.py:915
|
||||
#: core/models.py:934
|
||||
msgid "Malware detection info when the analysis status is unsafe."
|
||||
msgstr "Malware detection info when the analysis status is unsafe."
|
||||
|
||||
#: core/models.py:920
|
||||
#: core/models.py:939
|
||||
msgid "File"
|
||||
msgstr "File"
|
||||
|
||||
#: core/models.py:921
|
||||
#: core/models.py:940
|
||||
msgid "Files"
|
||||
msgstr "Files"
|
||||
|
||||
#: core/models.py:1041
|
||||
#: core/models.py:1060
|
||||
#, fuzzy
|
||||
#| msgid "This user is already in this recording."
|
||||
msgid "This file is already hard deleted."
|
||||
msgstr "This file is already hard deleted."
|
||||
|
||||
#: core/models.py:1051
|
||||
#: core/models.py:1070
|
||||
msgid "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:123
|
||||
#: core/recording/event/notification.py:124
|
||||
msgid "Your recording is ready"
|
||||
msgstr "Your recording is ready"
|
||||
|
||||
#: core/recording/event/notification.py:194
|
||||
#: core/recording/event/notification.py:195
|
||||
msgid "Transcription"
|
||||
msgstr "Transcription"
|
||||
|
||||
#: core/recording/event/notification.py:204
|
||||
#: core/recording/event/notification.py:206
|
||||
#, python-brace-format
|
||||
msgid "Meeting \"{room}\" on {room_recording_date} at {room_recording_time}"
|
||||
msgstr "Meeting \"{room}\" on {room_recording_date} at {room_recording_time}"
|
||||
@@ -532,25 +548,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"
|
||||
msgstr "Video call in progress: {sender.email} is waiting for you to connect"
|
||||
|
||||
#: core/templates/mail/html/invitation.html:159
|
||||
#: core/templates/mail/html/screen_recording.html:159
|
||||
#: core/templates/mail/text/invitation.txt:3
|
||||
#: core/templates/mail/text/screen_recording.txt:3
|
||||
#: 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 "Logo email"
|
||||
|
||||
#: core/templates/mail/html/invitation.html:189
|
||||
#: core/templates/mail/text/invitation.txt:5
|
||||
#: core/templates/mail/html/invitation.html:181
|
||||
#: core/templates/mail/text/invitation.txt:6
|
||||
msgid "invites you to join an ongoing video call"
|
||||
msgstr "invites you to join an ongoing video call"
|
||||
|
||||
#: core/templates/mail/html/invitation.html:200
|
||||
#: core/templates/mail/text/invitation.txt:7
|
||||
#: core/templates/mail/html/invitation.html:192
|
||||
#: core/templates/mail/text/invitation.txt:8
|
||||
msgid "JOIN THE CALL"
|
||||
msgstr "JOIN THE CALL"
|
||||
|
||||
#: core/templates/mail/html/invitation.html:227
|
||||
#: core/templates/mail/text/invitation.txt:13
|
||||
#: 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."
|
||||
@@ -558,41 +574,47 @@ msgstr ""
|
||||
"If you can't click the button, copy and paste the URL into your browser to "
|
||||
"join the call."
|
||||
|
||||
#: core/templates/mail/html/invitation.html:235
|
||||
#: core/templates/mail/text/invitation.txt:15
|
||||
#: core/templates/mail/html/invitation.html:227
|
||||
#: core/templates/mail/text/invitation.txt:16
|
||||
msgid "Tips for a better experience:"
|
||||
msgstr "Tips for a better experience:"
|
||||
|
||||
#: core/templates/mail/html/invitation.html:237
|
||||
#: core/templates/mail/text/invitation.txt:17
|
||||
#: core/templates/mail/html/invitation.html:229
|
||||
#: core/templates/mail/text/invitation.txt:18
|
||||
msgid "Use Chrome or Firefox for better call quality"
|
||||
msgstr "Use Chrome or Firefox for better call quality"
|
||||
|
||||
#: core/templates/mail/html/invitation.html:238
|
||||
#: core/templates/mail/text/invitation.txt:18
|
||||
#: core/templates/mail/html/invitation.html:230
|
||||
#: core/templates/mail/text/invitation.txt:19
|
||||
msgid "Test your microphone and camera before joining"
|
||||
msgstr "Test your microphone and camera before joining"
|
||||
|
||||
#: core/templates/mail/html/invitation.html:239
|
||||
#: core/templates/mail/text/invitation.txt:19
|
||||
#: core/templates/mail/html/invitation.html:231
|
||||
#: core/templates/mail/text/invitation.txt:20
|
||||
msgid "Make sure you have a stable internet connection"
|
||||
msgstr "Make sure you have a stable internet connection"
|
||||
|
||||
#: core/templates/mail/html/invitation.html:248
|
||||
#: core/templates/mail/html/screen_recording.html:245
|
||||
#: core/templates/mail/text/invitation.txt:21
|
||||
#: core/templates/mail/text/screen_recording.txt:23
|
||||
#: 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 " Thank you for using %(brandname)s. "
|
||||
|
||||
#: core/templates/mail/html/screen_recording.html:188
|
||||
#: core/templates/mail/text/screen_recording.txt:6
|
||||
#: 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 "Your recording is ready!"
|
||||
|
||||
#: core/templates/mail/html/screen_recording.html:195
|
||||
#: core/templates/mail/text/screen_recording.txt:8
|
||||
#: 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 "
|
||||
@@ -601,14 +623,14 @@ msgstr ""
|
||||
" Your recording of \"%(room_name)s\" on %(recording_date)s at "
|
||||
"%(recording_time)s is now ready to download. "
|
||||
|
||||
#: core/templates/mail/html/screen_recording.html:195
|
||||
#: core/templates/mail/text/screen_recording.txt:8
|
||||
#: 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 " The recording will expire in %(days)s days. "
|
||||
|
||||
#: core/templates/mail/html/screen_recording.html:200
|
||||
#: core/templates/mail/text/screen_recording.txt:9
|
||||
#: 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. "
|
||||
@@ -616,33 +638,33 @@ msgstr ""
|
||||
" Sharing the recording via link is not yet available. Only organizers can "
|
||||
"download it. "
|
||||
|
||||
#: core/templates/mail/html/screen_recording.html:206
|
||||
#: core/templates/mail/text/screen_recording.txt:11
|
||||
#: core/templates/mail/html/screen_recording.html:198
|
||||
#: core/templates/mail/text/screen_recording.txt:12
|
||||
msgid "To keep this recording permanently:"
|
||||
msgstr "To keep this recording permanently:"
|
||||
|
||||
#: core/templates/mail/html/screen_recording.html:208
|
||||
#: core/templates/mail/html/screen_recording.html:200
|
||||
#, python-format
|
||||
msgid "Click the \"<a href=\"%(link)s\">Open</a>\" link below "
|
||||
msgstr "Click the \"<a href=\"%(link)s\">Open</a>\" link below "
|
||||
|
||||
#: core/templates/mail/html/screen_recording.html:209
|
||||
#: core/templates/mail/text/screen_recording.txt:14
|
||||
#: 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 "Use the \"Download\" button in the interface "
|
||||
|
||||
#: core/templates/mail/html/screen_recording.html:210
|
||||
#: core/templates/mail/text/screen_recording.txt:15
|
||||
#: 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 "Save the file to your preferred location"
|
||||
|
||||
#: core/templates/mail/html/screen_recording.html:221
|
||||
#: core/templates/mail/text/screen_recording.txt:17
|
||||
#: core/templates/mail/html/screen_recording.html:213
|
||||
#: core/templates/mail/text/screen_recording.txt:18
|
||||
msgid "Open"
|
||||
msgstr "Open"
|
||||
|
||||
#: core/templates/mail/html/screen_recording.html:230
|
||||
#: core/templates/mail/text/screen_recording.txt:19
|
||||
#: 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 "
|
||||
@@ -651,24 +673,33 @@ msgstr ""
|
||||
" If you have any questions or need assistance, please contact our support "
|
||||
"team at %(support_email)s. "
|
||||
|
||||
#: core/templates/mail/text/screen_recording.txt:13
|
||||
#: 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
|
||||
#, fuzzy, python-format
|
||||
#| msgid "Click the \"<a href=\"%(link)s\">Open</a>\" link below "
|
||||
msgid "Click the \"Open [%(link)s]\" link below "
|
||||
msgstr "Click the \"<a href=\"%(link)s\">Open</a>\" link below "
|
||||
|
||||
#: meet/settings.py:228
|
||||
#: meet/settings.py:238
|
||||
msgid "English"
|
||||
msgstr "English"
|
||||
|
||||
#: meet/settings.py:229
|
||||
#: meet/settings.py:239
|
||||
msgid "French"
|
||||
msgstr "French"
|
||||
|
||||
#: meet/settings.py:230
|
||||
#: meet/settings.py:240
|
||||
msgid "Dutch"
|
||||
msgstr "Dutch"
|
||||
|
||||
#: meet/settings.py:231
|
||||
#: meet/settings.py:241
|
||||
msgid "German"
|
||||
msgstr "German"
|
||||
|
||||
#: meet/settings.py:242
|
||||
msgid "Spanish"
|
||||
msgstr "Spanish"
|
||||
|
||||
@@ -0,0 +1,707 @@
|
||||
# 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"
|
||||
@@ -8,7 +8,7 @@ msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: PACKAGE VERSION\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2026-07-02 10:47+0000\n"
|
||||
"POT-Creation-Date: 2026-09-02 22:52+0000\n"
|
||||
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
|
||||
"Last-Translator: antoine.lebaud@mail.numerique.gouv.fr\n"
|
||||
"Language-Team: LANGUAGE <LL@li.org>\n"
|
||||
@@ -51,11 +51,11 @@ msgstr ""
|
||||
msgid "File preview"
|
||||
msgstr ""
|
||||
|
||||
#: core/admin.py:300 core/admin.py:443
|
||||
#: core/admin.py:300 core/admin.py:450
|
||||
msgid "No owner"
|
||||
msgstr "Pas de propriétaire"
|
||||
|
||||
#: core/admin.py:303 core/admin.py:446
|
||||
#: core/admin.py:303 core/admin.py:453
|
||||
msgid "Multiple owners"
|
||||
msgstr "Plusieurs propriétaires"
|
||||
|
||||
@@ -99,11 +99,11 @@ msgstr ""
|
||||
msgid "Skipped %(count)s recording(s) with an ineligible status."
|
||||
msgstr "%(count)s enregistrement(s) avec un statut inéligible ignoré(s)."
|
||||
|
||||
#: core/admin.py:510
|
||||
#: core/admin.py:517
|
||||
msgid "No scopes"
|
||||
msgstr "Aucun scopes"
|
||||
|
||||
#: core/admin.py:512
|
||||
#: core/admin.py:519
|
||||
msgid "Scopes"
|
||||
msgstr "Scopes"
|
||||
|
||||
@@ -111,187 +111,203 @@ msgstr "Scopes"
|
||||
msgid "Creator is me"
|
||||
msgstr "Je suis le créateur"
|
||||
|
||||
#: core/api/serializers.py:89
|
||||
#: core/api/serializers.py:108
|
||||
msgid "You must be administrator or owner of a room to add accesses to it."
|
||||
msgstr ""
|
||||
"Vous devez être administrateur ou propriétaire d'une salle pour y ajouter "
|
||||
"des accès."
|
||||
|
||||
#: core/api/serializers.py:534
|
||||
#: core/api/serializers.py:560
|
||||
msgid "This file extension is not allowed."
|
||||
msgstr "Cette extension n'est pas autorisée"
|
||||
|
||||
#: core/api/viewsets.py:1222
|
||||
#: core/api/viewsets.py:1268
|
||||
msgid "You have reached the maximum number of files for this type."
|
||||
msgstr "Vous avez atteint le nombre maximum de fichiers de ce type"
|
||||
|
||||
#: core/models.py:37
|
||||
#: core/models.py:38
|
||||
msgid "Member"
|
||||
msgstr "Membre"
|
||||
|
||||
#: core/models.py:38
|
||||
#: core/models.py:39
|
||||
msgid "Administrator"
|
||||
msgstr "Administrateur"
|
||||
|
||||
#: core/models.py:39
|
||||
#: core/models.py:40
|
||||
msgid "Owner"
|
||||
msgstr "Propriétaire"
|
||||
|
||||
#: core/models.py:55
|
||||
#: core/models.py:56
|
||||
msgid "Initiated"
|
||||
msgstr "Initié"
|
||||
|
||||
#: core/models.py:56
|
||||
#: core/models.py:57
|
||||
msgid "Active"
|
||||
msgstr "Actif"
|
||||
|
||||
#: core/models.py:57
|
||||
#: core/models.py:58
|
||||
msgid "Stopped"
|
||||
msgstr "Arrêté"
|
||||
|
||||
#: core/models.py:58
|
||||
#: core/models.py:59
|
||||
msgid "Saved"
|
||||
msgstr "Enregistré"
|
||||
|
||||
#: core/models.py:59
|
||||
#: core/models.py:60
|
||||
msgid "Aborted"
|
||||
msgstr "Abandonné"
|
||||
|
||||
#: core/models.py:60
|
||||
#: core/models.py:61
|
||||
msgid "Failed to Start"
|
||||
msgstr "Échec au démarrage"
|
||||
|
||||
#: core/models.py:61
|
||||
#: core/models.py:62
|
||||
msgid "Failed to Stop"
|
||||
msgstr "Échec à l'arrêt"
|
||||
|
||||
#: core/models.py:62
|
||||
#: core/models.py:63
|
||||
msgid "Notification succeeded"
|
||||
msgstr "Notification réussie"
|
||||
|
||||
#: core/models.py:65
|
||||
#: core/models.py:66
|
||||
msgid "External process successful"
|
||||
msgstr "Traitement externe : succès"
|
||||
|
||||
#: core/models.py:67
|
||||
#: core/models.py:68
|
||||
msgid "External process failed"
|
||||
msgstr "Traitement externe : erreur"
|
||||
|
||||
#: core/models.py:96
|
||||
#: core/models.py:97
|
||||
msgid "SCREEN_RECORDING"
|
||||
msgstr "ENREGISTREMENT_ÉCRAN"
|
||||
|
||||
#: core/models.py:97
|
||||
#: core/models.py:98
|
||||
msgid "TRANSCRIPT"
|
||||
msgstr "TRANSCRIPTION"
|
||||
|
||||
#: core/models.py:103
|
||||
#: core/models.py:104
|
||||
msgid "Public Access"
|
||||
msgstr "Accès public"
|
||||
|
||||
#: core/models.py:104
|
||||
#: core/models.py:105
|
||||
msgid "Trusted Access"
|
||||
msgstr "Accès de confiance"
|
||||
|
||||
#: core/models.py:105
|
||||
#: core/models.py:106
|
||||
msgid "Restricted Access"
|
||||
msgstr "Accès restreint"
|
||||
|
||||
#: core/models.py:117
|
||||
#: core/models.py:118
|
||||
msgid "id"
|
||||
msgstr "id"
|
||||
|
||||
#: core/models.py:118
|
||||
#: core/models.py:119
|
||||
msgid "primary key for the record as UUID"
|
||||
msgstr "clé primaire pour l'enregistrement sous forme d'UUID"
|
||||
|
||||
#: core/models.py:124
|
||||
#: core/models.py:125
|
||||
msgid "created on"
|
||||
msgstr "créé le"
|
||||
|
||||
#: core/models.py:125
|
||||
#: core/models.py:126
|
||||
msgid "date and time at which a record was created"
|
||||
msgstr "date et heure auxquelles un enregistrement a été créé"
|
||||
|
||||
#: core/models.py:130
|
||||
#: core/models.py:131
|
||||
msgid "updated on"
|
||||
msgstr "mis à jour le"
|
||||
|
||||
#: core/models.py:131
|
||||
#: core/models.py:132
|
||||
msgid "date and time at which a record was last updated"
|
||||
msgstr ""
|
||||
"date et heure auxquelles un enregistrement a été mis à jour pour la dernière "
|
||||
"fois"
|
||||
|
||||
#: 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
|
||||
#: core/models.py:150
|
||||
msgid "sub"
|
||||
msgstr "sub"
|
||||
|
||||
#: core/models.py:159
|
||||
#: core/models.py:152
|
||||
msgid ""
|
||||
"Optional for pending users; required upon account activation. 255 characters "
|
||||
"or fewer. Letters, numbers, and @/./+/-/_ characters only."
|
||||
"or fewer. Printable ASCII characters only."
|
||||
msgstr ""
|
||||
"Optionnel pour les utilisateurs en attente ; requis lors de l'activation du "
|
||||
"compte. 255 caractères maximum. Lettres, chiffres et @/./+/-/_ uniquement."
|
||||
"Facultatif pour les utilisateurs en attente ; obligatoire lors de l’activation "
|
||||
"du compte. 255 caractères maximum. Caractères ASCII imprimables uniquement."
|
||||
|
||||
#: core/models.py:168
|
||||
#: core/validators.py:22
|
||||
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"
|
||||
msgstr "adresse e-mail d'identité"
|
||||
|
||||
#: core/models.py:173
|
||||
#: core/models.py:166
|
||||
msgid "admin email address"
|
||||
msgstr "adresse e-mail d'administrateur"
|
||||
|
||||
#: core/models.py:175
|
||||
#: core/models.py:168
|
||||
msgid "full name"
|
||||
msgstr "nom complet"
|
||||
|
||||
#: core/models.py:177
|
||||
#: core/models.py:170
|
||||
msgid "short name"
|
||||
msgstr "nom court"
|
||||
|
||||
#: core/models.py:183
|
||||
#: core/models.py:176
|
||||
msgid "language"
|
||||
msgstr "langue"
|
||||
|
||||
#: core/models.py:184
|
||||
#: core/models.py:177
|
||||
msgid "The language in which the user wants to see the interface."
|
||||
msgstr "La langue dans laquelle l'utilisateur souhaite voir l'interface."
|
||||
|
||||
#: core/models.py:190
|
||||
#: core/models.py:183
|
||||
msgid "The timezone in which the user wants to see times."
|
||||
msgstr "Le fuseau horaire dans lequel l'utilisateur souhaite voir les heures."
|
||||
|
||||
#: core/models.py:193
|
||||
#: 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 "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"
|
||||
msgstr "appareil"
|
||||
|
||||
#: core/models.py:195
|
||||
#: core/models.py:207
|
||||
msgid "Whether the user is a device or a real user."
|
||||
msgstr "Si l'utilisateur est un appareil ou un utilisateur réel."
|
||||
|
||||
#: core/models.py:198
|
||||
#: core/models.py:210
|
||||
msgid "staff status"
|
||||
msgstr "statut du personnel"
|
||||
|
||||
#: core/models.py:200
|
||||
#: core/models.py:212
|
||||
msgid "Whether the user can log into this admin site."
|
||||
msgstr "Si l'utilisateur peut se connecter à ce site d'administration."
|
||||
|
||||
#: core/models.py:203
|
||||
#: core/models.py:215
|
||||
msgid "active"
|
||||
msgstr "actif"
|
||||
|
||||
#: core/models.py:206
|
||||
#: core/models.py:218
|
||||
msgid ""
|
||||
"Whether this user should be treated as active. Unselect this instead of "
|
||||
"deleting accounts."
|
||||
@@ -299,65 +315,65 @@ msgstr ""
|
||||
"Si cet utilisateur doit être traité comme actif. Désélectionnez cette option "
|
||||
"au lieu de supprimer des comptes."
|
||||
|
||||
#: core/models.py:219
|
||||
#: core/models.py:231
|
||||
msgid "user"
|
||||
msgstr "utilisateur"
|
||||
|
||||
#: core/models.py:220
|
||||
#: core/models.py:232
|
||||
msgid "users"
|
||||
msgstr "utilisateurs"
|
||||
|
||||
#: core/models.py:286
|
||||
#: core/models.py:298
|
||||
msgid "Resource"
|
||||
msgstr "Ressource"
|
||||
|
||||
#: core/models.py:287
|
||||
#: core/models.py:299
|
||||
msgid "Resources"
|
||||
msgstr "Ressources"
|
||||
|
||||
#: core/models.py:345
|
||||
#: core/models.py:357
|
||||
msgid "Resource access"
|
||||
msgstr "Accès aux ressources"
|
||||
|
||||
#: core/models.py:346
|
||||
#: core/models.py:358
|
||||
msgid "Resource accesses"
|
||||
msgstr "Accès aux ressources"
|
||||
|
||||
#: core/models.py:352
|
||||
#: core/models.py:364
|
||||
msgid "Resource access with this User and Resource already exists."
|
||||
msgstr ""
|
||||
"L'accès à la ressource avec cet utilisateur et cette ressource existe déjà."
|
||||
|
||||
#: core/models.py:409
|
||||
#: core/models.py:421
|
||||
msgid "Visio room configuration"
|
||||
msgstr "Configuration de la salle de visioconférence"
|
||||
|
||||
#: core/models.py:410
|
||||
#: core/models.py:422
|
||||
msgid "Values for Visio parameters to configure the room."
|
||||
msgstr "Valeurs des paramètres de visioconférence pour configurer la salle."
|
||||
|
||||
#: core/models.py:417
|
||||
#: core/models.py:429
|
||||
msgid "Room PIN code"
|
||||
msgstr "Code PIN de la salle"
|
||||
|
||||
#: core/models.py:418
|
||||
#: core/models.py:430
|
||||
msgid "Unique n-digit code that identifies this room in telephony mode."
|
||||
msgstr ""
|
||||
"Code unique à n chiffres qui identifie cette salle en mode téléphonique."
|
||||
|
||||
#: core/models.py:424 core/models.py:578
|
||||
#: core/models.py:436 core/models.py:597
|
||||
msgid "Room"
|
||||
msgstr "Salle"
|
||||
|
||||
#: core/models.py:425
|
||||
#: core/models.py:437
|
||||
msgid "Rooms"
|
||||
msgstr "Salles"
|
||||
|
||||
#: core/models.py:589
|
||||
#: core/models.py:608
|
||||
msgid "Worker ID"
|
||||
msgstr "ID du Worker"
|
||||
|
||||
#: core/models.py:591
|
||||
#: 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."
|
||||
@@ -365,170 +381,170 @@ msgstr ""
|
||||
"Entrez un identifiant pour l'enregistrement du Worker. Cet identifiant est "
|
||||
"conservé même lorsque le Worker s'arrête, permettant un suivi facile."
|
||||
|
||||
#: core/models.py:599
|
||||
#: core/models.py:618
|
||||
msgid "Recording mode"
|
||||
msgstr "Mode d'enregistrement"
|
||||
|
||||
#: core/models.py:600
|
||||
#: core/models.py:619
|
||||
msgid "Defines the mode of recording being called."
|
||||
msgstr "Définit le mode d'enregistrement appelé."
|
||||
|
||||
#: core/models.py:605 core/models.py:606
|
||||
#: core/models.py:624 core/models.py:625
|
||||
msgid "Recording options"
|
||||
msgstr "Options d'enregistrement"
|
||||
|
||||
#: core/models.py:613
|
||||
#: core/models.py:632
|
||||
msgid "External Process ID"
|
||||
msgstr "ID Traitement externe"
|
||||
|
||||
#: core/models.py:614
|
||||
#: core/models.py:633
|
||||
msgid "ID of the external process associated with the recording."
|
||||
msgstr "ID du traitement externe associé avec l'enregistrement."
|
||||
|
||||
#: core/models.py:620
|
||||
#: core/models.py:639
|
||||
msgid "Recording"
|
||||
msgstr "Enregistrement"
|
||||
|
||||
#: core/models.py:621
|
||||
#: core/models.py:640
|
||||
msgid "Recordings"
|
||||
msgstr "Enregistrements"
|
||||
|
||||
#: core/models.py:731
|
||||
#: core/models.py:750
|
||||
msgid "Recording/user relation"
|
||||
msgstr "Relation enregistrement/utilisateur"
|
||||
|
||||
#: core/models.py:732
|
||||
#: core/models.py:751
|
||||
msgid "Recording/user relations"
|
||||
msgstr "Relations enregistrement/utilisateur"
|
||||
|
||||
#: core/models.py:738
|
||||
#: core/models.py:757
|
||||
msgid "This user is already in this recording."
|
||||
msgstr "Cet utilisateur est déjà dans cet enregistrement."
|
||||
|
||||
#: core/models.py:744
|
||||
#: core/models.py:763
|
||||
msgid "This team is already in this recording."
|
||||
msgstr "Cette équipe est déjà dans cet enregistrement."
|
||||
|
||||
#: core/models.py:750
|
||||
#: core/models.py:769
|
||||
msgid "Either user or team must be set, not both."
|
||||
msgstr "Soit l'utilisateur, soit l'équipe doit être défini, pas les deux."
|
||||
|
||||
#: core/models.py:767
|
||||
#: core/models.py:786
|
||||
msgid "Create rooms"
|
||||
msgstr "Créer des salles"
|
||||
|
||||
#: core/models.py:768
|
||||
#: core/models.py:787
|
||||
msgid "List rooms"
|
||||
msgstr "Lister les salles"
|
||||
|
||||
#: core/models.py:769
|
||||
#: core/models.py:788
|
||||
msgid "Retrieve room details"
|
||||
msgstr "Afficher les détails d’une salle"
|
||||
|
||||
#: core/models.py:770
|
||||
#: core/models.py:789
|
||||
msgid "Update rooms"
|
||||
msgstr "Mettre à jour les salles"
|
||||
|
||||
#: core/models.py:771
|
||||
#: core/models.py:790
|
||||
msgid "Delete rooms"
|
||||
msgstr "Supprimer les salles"
|
||||
|
||||
#: core/models.py:784
|
||||
#: core/models.py:803
|
||||
msgid "Application name"
|
||||
msgstr "Nom de l’application"
|
||||
|
||||
#: core/models.py:785
|
||||
#: core/models.py:804
|
||||
msgid "Descriptive name for this application."
|
||||
msgstr "Nom descriptif de cette application."
|
||||
|
||||
#: core/models.py:795
|
||||
#: core/models.py:814
|
||||
msgid "Hashed on Save. Copy it now if this is a new secret."
|
||||
msgstr ""
|
||||
"Haché lors de l’enregistrement. Copiez-le maintenant s’il s’agit d’un "
|
||||
"nouveau secret."
|
||||
|
||||
#: core/models.py:806
|
||||
#: core/models.py:825
|
||||
msgid "Application"
|
||||
msgstr "Application"
|
||||
|
||||
#: core/models.py:807
|
||||
#: core/models.py:826
|
||||
msgid "Applications"
|
||||
msgstr "Applications"
|
||||
|
||||
#: core/models.py:830
|
||||
#: core/models.py:849
|
||||
msgid "Enter a valid domain"
|
||||
msgstr "Saisissez un domaine valide"
|
||||
|
||||
#: core/models.py:833
|
||||
#: core/models.py:852
|
||||
msgid "Domain"
|
||||
msgstr "Domaine"
|
||||
|
||||
#: core/models.py:834
|
||||
#: core/models.py:853
|
||||
msgid "Email domain this application can act on behalf of."
|
||||
msgstr "Domaine de messagerie au nom duquel cette application peut agir."
|
||||
|
||||
#: core/models.py:846
|
||||
#: core/models.py:865
|
||||
msgid "Application domain"
|
||||
msgstr "Domaine d’application"
|
||||
|
||||
#: core/models.py:847
|
||||
#: core/models.py:866
|
||||
msgid "Application domains"
|
||||
msgstr "Domaines d’application"
|
||||
|
||||
#: core/models.py:865
|
||||
#: core/models.py:884
|
||||
msgid "Pending"
|
||||
msgstr "En attente"
|
||||
|
||||
#: core/models.py:866
|
||||
#: core/models.py:885
|
||||
msgid "Analyzing"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:873
|
||||
#: core/models.py:892
|
||||
msgid "Ready"
|
||||
msgstr "Prêt"
|
||||
|
||||
#: core/models.py:879
|
||||
#: core/models.py:898
|
||||
msgid "Background image"
|
||||
msgstr "Image de fond"
|
||||
|
||||
#: core/models.py:891
|
||||
#: core/models.py:910
|
||||
msgid "title"
|
||||
msgstr "Titre"
|
||||
|
||||
#: core/models.py:915
|
||||
#: core/models.py:934
|
||||
msgid "Malware detection info when the analysis status is unsafe."
|
||||
msgstr ""
|
||||
"Information concernant la détection de Malware cand le statut n'est pas sain"
|
||||
|
||||
#: core/models.py:920
|
||||
#: core/models.py:939
|
||||
msgid "File"
|
||||
msgstr "Fichier"
|
||||
|
||||
#: core/models.py:921
|
||||
#: core/models.py:940
|
||||
msgid "Files"
|
||||
msgstr "Fichiers"
|
||||
|
||||
#: core/models.py:1041
|
||||
#: core/models.py:1060
|
||||
#, fuzzy
|
||||
#| msgid "This user is already in this recording."
|
||||
msgid "This file is already hard deleted."
|
||||
msgstr "Ce fichier a été supprimé."
|
||||
|
||||
#: core/models.py:1051
|
||||
#: core/models.py:1070
|
||||
msgid "To hard delete a file, it must first be soft deleted."
|
||||
msgstr ""
|
||||
"Pour supprimer définitivement un fichier il doit d'abord avoir été marqué "
|
||||
"comme supprimé (soft delete)"
|
||||
|
||||
#: core/recording/event/notification.py:123
|
||||
#: core/recording/event/notification.py:124
|
||||
msgid "Your recording is ready"
|
||||
msgstr "Votre enregistrement est prêt"
|
||||
|
||||
#: core/recording/event/notification.py:194
|
||||
#: core/recording/event/notification.py:195
|
||||
msgid "Transcription"
|
||||
msgstr "Transcription"
|
||||
|
||||
#: core/recording/event/notification.py:204
|
||||
#: core/recording/event/notification.py:206
|
||||
#, python-brace-format
|
||||
msgid "Meeting \"{room}\" on {room_recording_date} at {room_recording_time}"
|
||||
msgstr "Réunion \"{room}\" du {room_recording_date} à {room_recording_time}"
|
||||
@@ -538,25 +554,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"
|
||||
msgstr "Appel vidéo en cours : {sender.email} attend que vous vous connectiez"
|
||||
|
||||
#: core/templates/mail/html/invitation.html:159
|
||||
#: core/templates/mail/html/screen_recording.html:159
|
||||
#: core/templates/mail/text/invitation.txt:3
|
||||
#: core/templates/mail/text/screen_recording.txt:3
|
||||
#: 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 "Logo email"
|
||||
|
||||
#: core/templates/mail/html/invitation.html:189
|
||||
#: core/templates/mail/text/invitation.txt:5
|
||||
#: core/templates/mail/html/invitation.html:181
|
||||
#: core/templates/mail/text/invitation.txt:6
|
||||
msgid "invites you to join an ongoing video call"
|
||||
msgstr "vous invite à rejoindre un appel vidéo en cours"
|
||||
|
||||
#: core/templates/mail/html/invitation.html:200
|
||||
#: core/templates/mail/text/invitation.txt:7
|
||||
#: core/templates/mail/html/invitation.html:192
|
||||
#: core/templates/mail/text/invitation.txt:8
|
||||
msgid "JOIN THE CALL"
|
||||
msgstr "REJOINDRE L'APPEL"
|
||||
|
||||
#: core/templates/mail/html/invitation.html:227
|
||||
#: core/templates/mail/text/invitation.txt:13
|
||||
#: 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."
|
||||
@@ -564,41 +580,47 @@ msgstr ""
|
||||
"Si vous ne pouvez pas cliquer sur le bouton, copiez et collez l'URL dans "
|
||||
"votre navigateur pour rejoindre l'appel."
|
||||
|
||||
#: core/templates/mail/html/invitation.html:235
|
||||
#: core/templates/mail/text/invitation.txt:15
|
||||
#: core/templates/mail/html/invitation.html:227
|
||||
#: core/templates/mail/text/invitation.txt:16
|
||||
msgid "Tips for a better experience:"
|
||||
msgstr "Conseils pour une meilleure expérience :"
|
||||
|
||||
#: core/templates/mail/html/invitation.html:237
|
||||
#: core/templates/mail/text/invitation.txt:17
|
||||
#: core/templates/mail/html/invitation.html:229
|
||||
#: core/templates/mail/text/invitation.txt:18
|
||||
msgid "Use Chrome or Firefox for better call quality"
|
||||
msgstr "Utilisez Chrome ou Firefox pour une meilleure qualité d'appel"
|
||||
|
||||
#: core/templates/mail/html/invitation.html:238
|
||||
#: core/templates/mail/text/invitation.txt:18
|
||||
#: core/templates/mail/html/invitation.html:230
|
||||
#: core/templates/mail/text/invitation.txt:19
|
||||
msgid "Test your microphone and camera before joining"
|
||||
msgstr "Testez votre microphone et votre caméra avant de rejoindre"
|
||||
|
||||
#: core/templates/mail/html/invitation.html:239
|
||||
#: core/templates/mail/text/invitation.txt:19
|
||||
#: core/templates/mail/html/invitation.html:231
|
||||
#: core/templates/mail/text/invitation.txt:20
|
||||
msgid "Make sure you have a stable internet connection"
|
||||
msgstr "Assurez-vous d'avoir une connexion Internet stable"
|
||||
|
||||
#: core/templates/mail/html/invitation.html:248
|
||||
#: core/templates/mail/html/screen_recording.html:245
|
||||
#: core/templates/mail/text/invitation.txt:21
|
||||
#: core/templates/mail/text/screen_recording.txt:23
|
||||
#: 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 " Merci d'utiliser %(brandname)s. "
|
||||
|
||||
#: core/templates/mail/html/screen_recording.html:188
|
||||
#: core/templates/mail/text/screen_recording.txt:6
|
||||
#: 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 "Votre enregistrement est prêt !"
|
||||
|
||||
#: core/templates/mail/html/screen_recording.html:195
|
||||
#: core/templates/mail/text/screen_recording.txt:8
|
||||
#: 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 "
|
||||
@@ -607,14 +629,14 @@ msgstr ""
|
||||
" Votre enregistrement de \"%(room_name)s\" du %(recording_date)s à "
|
||||
"%(recording_time)s est maintenant prêt à être téléchargé. "
|
||||
|
||||
#: core/templates/mail/html/screen_recording.html:195
|
||||
#: core/templates/mail/text/screen_recording.txt:8
|
||||
#: 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 " L'enregistrement expirera dans %(days)s jours. "
|
||||
|
||||
#: core/templates/mail/html/screen_recording.html:200
|
||||
#: core/templates/mail/text/screen_recording.txt:9
|
||||
#: 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. "
|
||||
@@ -622,33 +644,33 @@ msgstr ""
|
||||
"Le partage de l'enregistrement via lien n'est pas encore disponible. Seuls "
|
||||
"les organisateurs peuvent le télécharger."
|
||||
|
||||
#: core/templates/mail/html/screen_recording.html:206
|
||||
#: core/templates/mail/text/screen_recording.txt:11
|
||||
#: core/templates/mail/html/screen_recording.html:198
|
||||
#: core/templates/mail/text/screen_recording.txt:12
|
||||
msgid "To keep this recording permanently:"
|
||||
msgstr "Pour conserver cet enregistrement de façon permanente :"
|
||||
|
||||
#: core/templates/mail/html/screen_recording.html:208
|
||||
#: core/templates/mail/html/screen_recording.html:200
|
||||
#, python-format
|
||||
msgid "Click the \"<a href=\"%(link)s\">Open</a>\" link below "
|
||||
msgstr "Cliquez sur le lien \"<a href=\"%(link)s\">Ouvrir</a>\" ci-dessous "
|
||||
|
||||
#: core/templates/mail/html/screen_recording.html:209
|
||||
#: core/templates/mail/text/screen_recording.txt:14
|
||||
#: 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 "Utilisez le bouton \"Télécharger\" dans l'interface "
|
||||
|
||||
#: core/templates/mail/html/screen_recording.html:210
|
||||
#: core/templates/mail/text/screen_recording.txt:15
|
||||
#: 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 "Enregistrez le fichier à l'emplacement de votre choix"
|
||||
|
||||
#: core/templates/mail/html/screen_recording.html:221
|
||||
#: core/templates/mail/text/screen_recording.txt:17
|
||||
#: core/templates/mail/html/screen_recording.html:213
|
||||
#: core/templates/mail/text/screen_recording.txt:18
|
||||
msgid "Open"
|
||||
msgstr "Ouvrir"
|
||||
|
||||
#: core/templates/mail/html/screen_recording.html:230
|
||||
#: core/templates/mail/text/screen_recording.txt:19
|
||||
#: 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 "
|
||||
@@ -657,24 +679,33 @@ msgstr ""
|
||||
" Si vous avez des questions ou besoin d'assistance, veuillez contacter notre "
|
||||
"équipe d'assistance à %(support_email)s. "
|
||||
|
||||
#: core/templates/mail/text/screen_recording.txt:13
|
||||
#: 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
|
||||
#, fuzzy, python-format
|
||||
#| msgid "Click the \"<a href=\"%(link)s\">Open</a>\" link below "
|
||||
msgid "Click the \"Open [%(link)s]\" link below "
|
||||
msgstr "Cliquez sur le lien \"<a href=\"%(link)s\">Ouvrir</a>\" ci-dessous "
|
||||
|
||||
#: meet/settings.py:228
|
||||
#: meet/settings.py:238
|
||||
msgid "English"
|
||||
msgstr "Anglais"
|
||||
|
||||
#: meet/settings.py:229
|
||||
#: meet/settings.py:239
|
||||
msgid "French"
|
||||
msgstr "Français"
|
||||
|
||||
#: meet/settings.py:230
|
||||
#: meet/settings.py:240
|
||||
msgid "Dutch"
|
||||
msgstr "Néerlandais"
|
||||
|
||||
#: meet/settings.py:231
|
||||
#: meet/settings.py:241
|
||||
msgid "German"
|
||||
msgstr "Allemand"
|
||||
|
||||
#: meet/settings.py:242
|
||||
msgid "Spanish"
|
||||
msgstr "Espagnol"
|
||||
|
||||
@@ -8,7 +8,7 @@ msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: PACKAGE VERSION\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2026-07-02 10:47+0000\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"
|
||||
@@ -51,11 +51,11 @@ msgstr ""
|
||||
msgid "File preview"
|
||||
msgstr ""
|
||||
|
||||
#: core/admin.py:300 core/admin.py:443
|
||||
#: core/admin.py:300 core/admin.py:450
|
||||
msgid "No owner"
|
||||
msgstr "Geen eigenaar"
|
||||
|
||||
#: core/admin.py:303 core/admin.py:446
|
||||
#: core/admin.py:303 core/admin.py:453
|
||||
msgid "Multiple owners"
|
||||
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."
|
||||
msgstr "%(count)s opname(s) met een niet-toegestane status overgeslagen."
|
||||
|
||||
#: core/admin.py:510
|
||||
#: core/admin.py:517
|
||||
msgid "No scopes"
|
||||
msgstr "Geen scopes"
|
||||
|
||||
#: core/admin.py:512
|
||||
#: core/admin.py:519
|
||||
msgid "Scopes"
|
||||
msgstr "Scopes"
|
||||
|
||||
@@ -110,184 +110,200 @@ msgstr "Scopes"
|
||||
msgid "Creator is me"
|
||||
msgstr "Maker ben ik"
|
||||
|
||||
#: core/api/serializers.py:89
|
||||
#: core/api/serializers.py:108
|
||||
msgid "You must be administrator or owner of a room to add accesses to it."
|
||||
msgstr ""
|
||||
"Je moet beheerder of eigenaar van een ruimte zijn om toegang toe te voegen."
|
||||
|
||||
#: core/api/serializers.py:534
|
||||
#: core/api/serializers.py:560
|
||||
msgid "This file extension is not allowed."
|
||||
msgstr "Deze bestandsextensie is niet toegestaan."
|
||||
|
||||
#: core/api/viewsets.py:1222
|
||||
#: core/api/viewsets.py:1268
|
||||
msgid "You have reached the maximum number of files for this type."
|
||||
msgstr "Het maximale aantal bestanden voor dit type is bereikt."
|
||||
|
||||
#: core/models.py:37
|
||||
#: core/models.py:38
|
||||
msgid "Member"
|
||||
msgstr "Lid"
|
||||
|
||||
#: core/models.py:38
|
||||
#: core/models.py:39
|
||||
msgid "Administrator"
|
||||
msgstr "Beheerder"
|
||||
|
||||
#: core/models.py:39
|
||||
#: core/models.py:40
|
||||
msgid "Owner"
|
||||
msgstr "Eigenaar"
|
||||
|
||||
#: core/models.py:55
|
||||
#: core/models.py:56
|
||||
msgid "Initiated"
|
||||
msgstr "Gestart"
|
||||
|
||||
#: core/models.py:56
|
||||
#: core/models.py:57
|
||||
msgid "Active"
|
||||
msgstr "Actief"
|
||||
|
||||
#: core/models.py:57
|
||||
#: core/models.py:58
|
||||
msgid "Stopped"
|
||||
msgstr "Gestopt"
|
||||
|
||||
#: core/models.py:58
|
||||
#: core/models.py:59
|
||||
msgid "Saved"
|
||||
msgstr "Opgeslagen"
|
||||
|
||||
#: core/models.py:59
|
||||
#: core/models.py:60
|
||||
msgid "Aborted"
|
||||
msgstr "Afgebroken"
|
||||
|
||||
#: core/models.py:60
|
||||
#: core/models.py:61
|
||||
msgid "Failed to Start"
|
||||
msgstr "Starten mislukt"
|
||||
|
||||
#: core/models.py:61
|
||||
#: core/models.py:62
|
||||
msgid "Failed to Stop"
|
||||
msgstr "Stoppen mislukt"
|
||||
|
||||
#: core/models.py:62
|
||||
#: core/models.py:63
|
||||
msgid "Notification succeeded"
|
||||
msgstr "Notificatie geslaagd"
|
||||
|
||||
#: core/models.py:65
|
||||
#: core/models.py:66
|
||||
msgid "External process successful"
|
||||
msgstr "Externe procedure succesvol"
|
||||
|
||||
#: core/models.py:67
|
||||
#: core/models.py:68
|
||||
msgid "External process failed"
|
||||
msgstr "Het externe proces is mislukt."
|
||||
|
||||
#: core/models.py:96
|
||||
#: core/models.py:97
|
||||
msgid "SCREEN_RECORDING"
|
||||
msgstr "SCHERM_OPNAME"
|
||||
|
||||
#: core/models.py:97
|
||||
#: core/models.py:98
|
||||
msgid "TRANSCRIPT"
|
||||
msgstr "TRANSCRIPT"
|
||||
|
||||
#: core/models.py:103
|
||||
#: core/models.py:104
|
||||
msgid "Public Access"
|
||||
msgstr "Openbare toegang"
|
||||
|
||||
#: core/models.py:104
|
||||
#: core/models.py:105
|
||||
msgid "Trusted Access"
|
||||
msgstr "Vertrouwde toegang"
|
||||
|
||||
#: core/models.py:105
|
||||
#: core/models.py:106
|
||||
msgid "Restricted Access"
|
||||
msgstr "Beperkte toegang"
|
||||
|
||||
#: core/models.py:117
|
||||
#: core/models.py:118
|
||||
msgid "id"
|
||||
msgstr "id"
|
||||
|
||||
#: core/models.py:118
|
||||
#: core/models.py:119
|
||||
msgid "primary key for the record as UUID"
|
||||
msgstr "primaire sleutel voor het record als UUID"
|
||||
|
||||
#: core/models.py:124
|
||||
#: core/models.py:125
|
||||
msgid "created on"
|
||||
msgstr "aangemaakt op"
|
||||
|
||||
#: core/models.py:125
|
||||
#: core/models.py:126
|
||||
msgid "date and time at which a record was created"
|
||||
msgstr "datum en tijd waarop een record werd aangemaakt"
|
||||
|
||||
#: core/models.py:130
|
||||
#: core/models.py:131
|
||||
msgid "updated on"
|
||||
msgstr "bijgewerkt op"
|
||||
|
||||
#: core/models.py:131
|
||||
#: core/models.py:132
|
||||
msgid "date and time at which a record was last updated"
|
||||
msgstr "datum en tijd waarop een record voor het laatst werd bijgewerkt"
|
||||
|
||||
#: 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
|
||||
#: core/models.py:150
|
||||
msgid "sub"
|
||||
msgstr "sub"
|
||||
|
||||
#: core/models.py:159
|
||||
#: core/models.py:152
|
||||
msgid ""
|
||||
"Optional for pending users; required upon account activation. 255 characters "
|
||||
"or fewer. Letters, numbers, and @/./+/-/_ characters only."
|
||||
"or fewer. Printable ASCII characters only."
|
||||
msgstr ""
|
||||
"Optioneel voor gebruikers in afwachting; vereist bij accountactivering. "
|
||||
"Maximum 255 tekens. Alleen letters, cijfers en @/./+/-/_ toegestaan."
|
||||
"Optioneel voor gebruikers in afwachting; verplicht bij het activeren van het "
|
||||
"account. Maximaal 255 tekens. Alleen afdrukbare ASCII-tekens."
|
||||
|
||||
#: core/models.py:168
|
||||
#: core/validators.py:22
|
||||
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"
|
||||
msgstr "identiteit e-mailadres"
|
||||
|
||||
#: core/models.py:173
|
||||
#: core/models.py:166
|
||||
msgid "admin email address"
|
||||
msgstr "beheerder e-mailadres"
|
||||
|
||||
#: core/models.py:175
|
||||
#: core/models.py:168
|
||||
msgid "full name"
|
||||
msgstr "volledige naam"
|
||||
|
||||
#: core/models.py:177
|
||||
#: core/models.py:170
|
||||
msgid "short name"
|
||||
msgstr "korte naam"
|
||||
|
||||
#: core/models.py:183
|
||||
#: core/models.py:176
|
||||
msgid "language"
|
||||
msgstr "taal"
|
||||
|
||||
#: core/models.py:184
|
||||
#: core/models.py:177
|
||||
msgid "The language in which the user wants to see the interface."
|
||||
msgstr "De taal waarin de gebruiker de interface wil zien."
|
||||
|
||||
#: core/models.py:190
|
||||
#: core/models.py:183
|
||||
msgid "The timezone in which the user wants to see times."
|
||||
msgstr "De tijdzone waarin de gebruiker tijden wil zien."
|
||||
|
||||
#: core/models.py:193
|
||||
#: 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 "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"
|
||||
msgstr "apparaat"
|
||||
|
||||
#: core/models.py:195
|
||||
#: core/models.py:207
|
||||
msgid "Whether the user is a device or a real user."
|
||||
msgstr "Of de gebruiker een apparaat is of een echte gebruiker."
|
||||
|
||||
#: core/models.py:198
|
||||
#: core/models.py:210
|
||||
msgid "staff status"
|
||||
msgstr "personeelsstatus"
|
||||
|
||||
#: core/models.py:200
|
||||
#: core/models.py:212
|
||||
msgid "Whether the user can log into this admin site."
|
||||
msgstr "Of de gebruiker kan inloggen op deze beheersite."
|
||||
|
||||
#: core/models.py:203
|
||||
#: core/models.py:215
|
||||
msgid "active"
|
||||
msgstr "actief"
|
||||
|
||||
#: core/models.py:206
|
||||
#: core/models.py:218
|
||||
msgid ""
|
||||
"Whether this user should be treated as active. Unselect this instead of "
|
||||
"deleting accounts."
|
||||
@@ -295,64 +311,64 @@ msgstr ""
|
||||
"Of deze gebruiker als actief moet worden behandeld. Deselecteer dit in "
|
||||
"plaats van accounts te verwijderen."
|
||||
|
||||
#: core/models.py:219
|
||||
#: core/models.py:231
|
||||
msgid "user"
|
||||
msgstr "gebruiker"
|
||||
|
||||
#: core/models.py:220
|
||||
#: core/models.py:232
|
||||
msgid "users"
|
||||
msgstr "gebruikers"
|
||||
|
||||
#: core/models.py:286
|
||||
#: core/models.py:298
|
||||
msgid "Resource"
|
||||
msgstr "Bron"
|
||||
|
||||
#: core/models.py:287
|
||||
#: core/models.py:299
|
||||
msgid "Resources"
|
||||
msgstr "Bronnen"
|
||||
|
||||
#: core/models.py:345
|
||||
#: core/models.py:357
|
||||
msgid "Resource access"
|
||||
msgstr "Brontoegang"
|
||||
|
||||
#: core/models.py:346
|
||||
#: core/models.py:358
|
||||
msgid "Resource accesses"
|
||||
msgstr "Brontoegangsrechten"
|
||||
|
||||
#: core/models.py:352
|
||||
#: core/models.py:364
|
||||
msgid "Resource access with this User and Resource already exists."
|
||||
msgstr "Brontoegang met deze gebruiker en bron bestaat al."
|
||||
|
||||
#: core/models.py:409
|
||||
#: core/models.py:421
|
||||
msgid "Visio room configuration"
|
||||
msgstr "Visio-ruimteconfiguratie"
|
||||
|
||||
#: core/models.py:410
|
||||
#: core/models.py:422
|
||||
msgid "Values for Visio parameters to configure the room."
|
||||
msgstr "Waarden voor Visio-parameters om de ruimte te configureren."
|
||||
|
||||
#: core/models.py:417
|
||||
#: core/models.py:429
|
||||
msgid "Room PIN code"
|
||||
msgstr "Pincode van de kamer"
|
||||
|
||||
#: core/models.py:418
|
||||
#: core/models.py:430
|
||||
msgid "Unique n-digit code that identifies this room in telephony mode."
|
||||
msgstr ""
|
||||
"Unieke n-cijferige code die deze kamer identificeert in telefonie-modus."
|
||||
|
||||
#: core/models.py:424 core/models.py:578
|
||||
#: core/models.py:436 core/models.py:597
|
||||
msgid "Room"
|
||||
msgstr "Ruimte"
|
||||
|
||||
#: core/models.py:425
|
||||
#: core/models.py:437
|
||||
msgid "Rooms"
|
||||
msgstr "Ruimtes"
|
||||
|
||||
#: core/models.py:589
|
||||
#: core/models.py:608
|
||||
msgid "Worker ID"
|
||||
msgstr "Worker ID"
|
||||
|
||||
#: core/models.py:591
|
||||
#: 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."
|
||||
@@ -360,152 +376,152 @@ msgstr ""
|
||||
"Voer een identificatie in voor de worker-opname. Deze ID blijft behouden, "
|
||||
"zelfs wanneer de worker stopt, waardoor eenvoudige tracking mogelijk is."
|
||||
|
||||
#: core/models.py:599
|
||||
#: core/models.py:618
|
||||
msgid "Recording mode"
|
||||
msgstr "Opnamemodus"
|
||||
|
||||
#: core/models.py:600
|
||||
#: core/models.py:619
|
||||
msgid "Defines the mode of recording being called."
|
||||
msgstr "Definieert de modus van opname die wordt aangeroepen."
|
||||
|
||||
#: core/models.py:605 core/models.py:606
|
||||
#: core/models.py:624 core/models.py:625
|
||||
msgid "Recording options"
|
||||
msgstr "Opnameopties"
|
||||
|
||||
#: core/models.py:613
|
||||
#: core/models.py:632
|
||||
msgid "External Process ID"
|
||||
msgstr "Externe proces-ID"
|
||||
|
||||
#: core/models.py:614
|
||||
#: core/models.py:633
|
||||
msgid "ID of the external process associated with the recording."
|
||||
msgstr "ID van het externe proces dat aan de opname is gekoppeld."
|
||||
|
||||
#: core/models.py:620
|
||||
#: core/models.py:639
|
||||
msgid "Recording"
|
||||
msgstr "Opname"
|
||||
|
||||
#: core/models.py:621
|
||||
#: core/models.py:640
|
||||
msgid "Recordings"
|
||||
msgstr "Opnames"
|
||||
|
||||
#: core/models.py:731
|
||||
#: core/models.py:750
|
||||
msgid "Recording/user relation"
|
||||
msgstr "Opname/gebruiker-relatie"
|
||||
|
||||
#: core/models.py:732
|
||||
#: core/models.py:751
|
||||
msgid "Recording/user relations"
|
||||
msgstr "Opname/gebruiker-relaties"
|
||||
|
||||
#: core/models.py:738
|
||||
#: core/models.py:757
|
||||
msgid "This user is already in this recording."
|
||||
msgstr "Deze gebruiker is al in deze opname."
|
||||
|
||||
#: core/models.py:744
|
||||
#: core/models.py:763
|
||||
msgid "This team is already in this recording."
|
||||
msgstr "Dit team is al in deze opname."
|
||||
|
||||
#: core/models.py:750
|
||||
#: core/models.py:769
|
||||
msgid "Either user or team must be set, not both."
|
||||
msgstr "Ofwel gebruiker of team moet worden ingesteld, niet beide."
|
||||
|
||||
#: core/models.py:767
|
||||
#: core/models.py:786
|
||||
msgid "Create rooms"
|
||||
msgstr "Ruimtes aanmaken"
|
||||
|
||||
#: core/models.py:768
|
||||
#: core/models.py:787
|
||||
msgid "List rooms"
|
||||
msgstr "Ruimtes weergeven"
|
||||
|
||||
#: core/models.py:769
|
||||
#: core/models.py:788
|
||||
msgid "Retrieve room details"
|
||||
msgstr "Details van een ruimte ophalen"
|
||||
|
||||
#: core/models.py:770
|
||||
#: core/models.py:789
|
||||
msgid "Update rooms"
|
||||
msgstr "Ruimtes bijwerken"
|
||||
|
||||
#: core/models.py:771
|
||||
#: core/models.py:790
|
||||
msgid "Delete rooms"
|
||||
msgstr "Ruimtes verwijderen"
|
||||
|
||||
#: core/models.py:784
|
||||
#: core/models.py:803
|
||||
msgid "Application name"
|
||||
msgstr "Naam van de applicatie"
|
||||
|
||||
#: core/models.py:785
|
||||
#: core/models.py:804
|
||||
msgid "Descriptive name for this application."
|
||||
msgstr "Beschrijvende naam voor deze applicatie."
|
||||
|
||||
#: core/models.py:795
|
||||
#: core/models.py:814
|
||||
msgid "Hashed on Save. Copy it now if this is a new secret."
|
||||
msgstr ""
|
||||
"Wordt gehasht bij het opslaan. Kopieer het nu als dit een nieuw geheim is."
|
||||
|
||||
#: core/models.py:806
|
||||
#: core/models.py:825
|
||||
msgid "Application"
|
||||
msgstr "Applicatie"
|
||||
|
||||
#: core/models.py:807
|
||||
#: core/models.py:826
|
||||
msgid "Applications"
|
||||
msgstr "Applicaties"
|
||||
|
||||
#: core/models.py:830
|
||||
#: core/models.py:849
|
||||
msgid "Enter a valid domain"
|
||||
msgstr "Voer een geldig domein in"
|
||||
|
||||
#: core/models.py:833
|
||||
#: core/models.py:852
|
||||
msgid "Domain"
|
||||
msgstr "Domein"
|
||||
|
||||
#: core/models.py:834
|
||||
#: core/models.py:853
|
||||
msgid "Email domain this application can act on behalf of."
|
||||
msgstr "E-maildomein namens welke deze applicatie kan handelen."
|
||||
|
||||
#: core/models.py:846
|
||||
#: core/models.py:865
|
||||
msgid "Application domain"
|
||||
msgstr "Applicatiedomein"
|
||||
|
||||
#: core/models.py:847
|
||||
#: core/models.py:866
|
||||
msgid "Application domains"
|
||||
msgstr "Applicatiedomeinen"
|
||||
|
||||
#: core/models.py:865
|
||||
#: core/models.py:884
|
||||
msgid "Pending"
|
||||
msgstr "In afwachting"
|
||||
|
||||
#: core/models.py:866
|
||||
#: core/models.py:885
|
||||
msgid "Analyzing"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:873
|
||||
#: core/models.py:892
|
||||
msgid "Ready"
|
||||
msgstr "Klaar"
|
||||
|
||||
#: core/models.py:879
|
||||
#: core/models.py:898
|
||||
msgid "Background image"
|
||||
msgstr "Achtergrondafbeelding"
|
||||
|
||||
#: core/models.py:891
|
||||
#: core/models.py:910
|
||||
msgid "title"
|
||||
msgstr "Titel"
|
||||
|
||||
#: core/models.py:915
|
||||
#: core/models.py:934
|
||||
msgid "Malware detection info when the analysis status is unsafe."
|
||||
msgstr "Informatie over malwaredetectie wanneer de analysestatus onveilig is."
|
||||
|
||||
#: core/models.py:920
|
||||
#: core/models.py:939
|
||||
msgid "File"
|
||||
msgstr "Bestand"
|
||||
|
||||
#: core/models.py:921
|
||||
#: core/models.py:940
|
||||
msgid "Files"
|
||||
msgstr "Bestanden"
|
||||
|
||||
#: core/models.py:1041
|
||||
#: core/models.py:1060
|
||||
msgid "This file is already hard deleted."
|
||||
msgstr "Dit bestand is al definitief verwijderd."
|
||||
|
||||
#: core/models.py:1051
|
||||
#: core/models.py:1070
|
||||
#, 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."
|
||||
@@ -513,15 +529,15 @@ msgstr ""
|
||||
"Om een bestand definitief te verwijderen, moet het eerst zacht verwijderd "
|
||||
"zijn."
|
||||
|
||||
#: core/recording/event/notification.py:123
|
||||
#: core/recording/event/notification.py:124
|
||||
msgid "Your recording is ready"
|
||||
msgstr "Je opname is klaar"
|
||||
|
||||
#: core/recording/event/notification.py:194
|
||||
#: core/recording/event/notification.py:195
|
||||
msgid "Transcription"
|
||||
msgstr "Transcriptie"
|
||||
|
||||
#: core/recording/event/notification.py:204
|
||||
#: core/recording/event/notification.py:206
|
||||
#, python-brace-format
|
||||
msgid "Meeting \"{room}\" on {room_recording_date} at {room_recording_time}"
|
||||
msgstr ""
|
||||
@@ -532,25 +548,25 @@ msgstr ""
|
||||
msgid "Video call in progress: {sender.email} is waiting for you to connect"
|
||||
msgstr "Video-oproep bezig: {sender.email} wacht op je verbinding"
|
||||
|
||||
#: core/templates/mail/html/invitation.html:159
|
||||
#: core/templates/mail/html/screen_recording.html:159
|
||||
#: core/templates/mail/text/invitation.txt:3
|
||||
#: core/templates/mail/text/screen_recording.txt:3
|
||||
#: 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 "Logo e-mail"
|
||||
|
||||
#: core/templates/mail/html/invitation.html:189
|
||||
#: core/templates/mail/text/invitation.txt:5
|
||||
#: core/templates/mail/html/invitation.html:181
|
||||
#: core/templates/mail/text/invitation.txt:6
|
||||
msgid "invites you to join an ongoing video call"
|
||||
msgstr "nodigt je uit om deel te nemen aan een lopende video-oproep"
|
||||
|
||||
#: core/templates/mail/html/invitation.html:200
|
||||
#: core/templates/mail/text/invitation.txt:7
|
||||
#: core/templates/mail/html/invitation.html:192
|
||||
#: core/templates/mail/text/invitation.txt:8
|
||||
msgid "JOIN THE CALL"
|
||||
msgstr "NEEM DEEL AAN DE OPROEP"
|
||||
|
||||
#: core/templates/mail/html/invitation.html:227
|
||||
#: core/templates/mail/text/invitation.txt:13
|
||||
#: 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."
|
||||
@@ -558,41 +574,47 @@ msgstr ""
|
||||
"Als je niet op de knop kunt klikken, kopieer en plak dan de URL in je "
|
||||
"browser om deel te nemen aan de oproep."
|
||||
|
||||
#: core/templates/mail/html/invitation.html:235
|
||||
#: core/templates/mail/text/invitation.txt:15
|
||||
#: core/templates/mail/html/invitation.html:227
|
||||
#: core/templates/mail/text/invitation.txt:16
|
||||
msgid "Tips for a better experience:"
|
||||
msgstr "Tips voor een betere ervaring:"
|
||||
|
||||
#: core/templates/mail/html/invitation.html:237
|
||||
#: core/templates/mail/text/invitation.txt:17
|
||||
#: core/templates/mail/html/invitation.html:229
|
||||
#: core/templates/mail/text/invitation.txt:18
|
||||
msgid "Use Chrome or Firefox for better call quality"
|
||||
msgstr "Gebruik Chrome of Firefox voor betere gesprekskwaliteit"
|
||||
|
||||
#: core/templates/mail/html/invitation.html:238
|
||||
#: core/templates/mail/text/invitation.txt:18
|
||||
#: core/templates/mail/html/invitation.html:230
|
||||
#: core/templates/mail/text/invitation.txt:19
|
||||
msgid "Test your microphone and camera before joining"
|
||||
msgstr "Test je microfoon en camera voordat je deelneemt"
|
||||
|
||||
#: core/templates/mail/html/invitation.html:239
|
||||
#: core/templates/mail/text/invitation.txt:19
|
||||
#: core/templates/mail/html/invitation.html:231
|
||||
#: core/templates/mail/text/invitation.txt:20
|
||||
msgid "Make sure you have a stable internet connection"
|
||||
msgstr "Zorg ervoor dat je een stabiele internetverbinding hebt"
|
||||
|
||||
#: core/templates/mail/html/invitation.html:248
|
||||
#: core/templates/mail/html/screen_recording.html:245
|
||||
#: core/templates/mail/text/invitation.txt:21
|
||||
#: core/templates/mail/text/screen_recording.txt:23
|
||||
#: 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 " Bedankt voor het gebruik van %(brandname)s. "
|
||||
|
||||
#: core/templates/mail/html/screen_recording.html:188
|
||||
#: core/templates/mail/text/screen_recording.txt:6
|
||||
#: 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 "Je opname is klaar!"
|
||||
|
||||
#: core/templates/mail/html/screen_recording.html:195
|
||||
#: core/templates/mail/text/screen_recording.txt:8
|
||||
#: 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 "
|
||||
@@ -601,14 +623,14 @@ msgstr ""
|
||||
" Je opname van \"%(room_name)s\" op %(recording_date)s om %(recording_time)s "
|
||||
"is nu klaar om te downloaden. "
|
||||
|
||||
#: core/templates/mail/html/screen_recording.html:195
|
||||
#: core/templates/mail/text/screen_recording.txt:8
|
||||
#: 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 " De opname verloopt over %(days)s dagen. "
|
||||
|
||||
#: core/templates/mail/html/screen_recording.html:200
|
||||
#: core/templates/mail/text/screen_recording.txt:9
|
||||
#: 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. "
|
||||
@@ -616,33 +638,33 @@ msgstr ""
|
||||
"Het delen van de opname via een link is nog niet beschikbaar. Alleen "
|
||||
"organisatoren kunnen deze downloaden."
|
||||
|
||||
#: core/templates/mail/html/screen_recording.html:206
|
||||
#: core/templates/mail/text/screen_recording.txt:11
|
||||
#: core/templates/mail/html/screen_recording.html:198
|
||||
#: core/templates/mail/text/screen_recording.txt:12
|
||||
msgid "To keep this recording permanently:"
|
||||
msgstr "Om deze opname permanent te bewaren:"
|
||||
|
||||
#: core/templates/mail/html/screen_recording.html:208
|
||||
#: core/templates/mail/html/screen_recording.html:200
|
||||
#, python-format
|
||||
msgid "Click the \"<a href=\"%(link)s\">Open</a>\" link below "
|
||||
msgstr "Klik op de \"<a href=\"%(link)s\">Openen</a>\"-link hieronder "
|
||||
|
||||
#: core/templates/mail/html/screen_recording.html:209
|
||||
#: core/templates/mail/text/screen_recording.txt:14
|
||||
#: 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 "Gebruik de \"Download\"-knop in de interface "
|
||||
|
||||
#: core/templates/mail/html/screen_recording.html:210
|
||||
#: core/templates/mail/text/screen_recording.txt:15
|
||||
#: 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 "Sla het bestand op naar je gewenste locatie"
|
||||
|
||||
#: core/templates/mail/html/screen_recording.html:221
|
||||
#: core/templates/mail/text/screen_recording.txt:17
|
||||
#: core/templates/mail/html/screen_recording.html:213
|
||||
#: core/templates/mail/text/screen_recording.txt:18
|
||||
msgid "Open"
|
||||
msgstr "Openen"
|
||||
|
||||
#: core/templates/mail/html/screen_recording.html:230
|
||||
#: core/templates/mail/text/screen_recording.txt:19
|
||||
#: 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 "
|
||||
@@ -651,24 +673,33 @@ msgstr ""
|
||||
" Als je vragen hebt of hulp nodig hebt, neem dan contact op met ons support "
|
||||
"team via %(support_email)s. "
|
||||
|
||||
#: core/templates/mail/text/screen_recording.txt:13
|
||||
#: 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
|
||||
#, fuzzy, python-format
|
||||
#| msgid "Click the \"<a href=\"%(link)s\">Open</a>\" link below "
|
||||
msgid "Click the \"Open [%(link)s]\" link below "
|
||||
msgstr "Klik op de \"<a href=\"%(link)s\">Openen</a>\"-link hieronder "
|
||||
|
||||
#: meet/settings.py:228
|
||||
#: meet/settings.py:238
|
||||
msgid "English"
|
||||
msgstr "Engels"
|
||||
|
||||
#: meet/settings.py:229
|
||||
#: meet/settings.py:239
|
||||
msgid "French"
|
||||
msgstr "Frans"
|
||||
|
||||
#: meet/settings.py:230
|
||||
#: meet/settings.py:240
|
||||
msgid "Dutch"
|
||||
msgstr "Nederlands"
|
||||
|
||||
#: meet/settings.py:231
|
||||
#: meet/settings.py:241
|
||||
msgid "German"
|
||||
msgstr "Duits"
|
||||
|
||||
#: meet/settings.py:242
|
||||
msgid "Spanish"
|
||||
msgstr "Spaans"
|
||||
|
||||
@@ -129,6 +129,15 @@ class Base(Configuration):
|
||||
MEDIA_BASE_URL = values.Value(
|
||||
"", 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
|
||||
|
||||
@@ -230,6 +239,7 @@ class Base(Configuration):
|
||||
("fr-fr", _("French")),
|
||||
("nl-nl", _("Dutch")),
|
||||
("de-de", _("German")),
|
||||
("es-es", _("Spanish")),
|
||||
)
|
||||
)
|
||||
|
||||
@@ -855,7 +865,7 @@ class Base(Configuration):
|
||||
"room_lobby", environ_name="LOBBY_KEY_PREFIX", environ_prefix=None
|
||||
)
|
||||
LOBBY_WAITING_TIMEOUT = values.PositiveIntegerValue(
|
||||
3, environ_name="LOBBY_WAITING_TIMEOUT", environ_prefix=None
|
||||
6, environ_name="LOBBY_WAITING_TIMEOUT", environ_prefix=None
|
||||
)
|
||||
LOBBY_DENIED_TIMEOUT = values.PositiveIntegerValue(
|
||||
5, environ_name="LOBBY_DENIED_TIMEOUT", environ_prefix=None
|
||||
@@ -1100,6 +1110,12 @@ class Base(Configuration):
|
||||
environ_prefix=None,
|
||||
)
|
||||
|
||||
LOGGING_SILENCED_401_PATHS = values.ListValue(
|
||||
default=["/api/v1.0/users/me/"],
|
||||
environ_name="LOGGING_SILENCED_401_PATHS",
|
||||
environ_prefix=None,
|
||||
)
|
||||
|
||||
# Logging
|
||||
# We want to make it easy to log to console but by default we log production
|
||||
# to Sentry and don't want to log to console.
|
||||
@@ -1112,10 +1128,16 @@ class Base(Configuration):
|
||||
"style": "{",
|
||||
},
|
||||
},
|
||||
"filters": {
|
||||
"silence_expected_401": {
|
||||
"()": "core.logging_filters.SilenceExpected401",
|
||||
},
|
||||
},
|
||||
"handlers": {
|
||||
"console": {
|
||||
"class": "logging.StreamHandler",
|
||||
"formatter": "simple",
|
||||
"filters": ["silence_expected_401"],
|
||||
},
|
||||
},
|
||||
# Override root logger to send it to console
|
||||
@@ -1126,6 +1148,13 @@ class Base(Configuration):
|
||||
),
|
||||
},
|
||||
"loggers": {
|
||||
"request.summary": {
|
||||
"level": values.Value(
|
||||
"WARNING",
|
||||
environ_name="LOGGING_LEVEL_REQUEST_SUMMARY",
|
||||
environ_prefix="",
|
||||
)
|
||||
},
|
||||
"core": {
|
||||
"handlers": ["console"],
|
||||
"level": values.Value(
|
||||
|
||||
@@ -7,7 +7,7 @@ build-backend = "uv_build"
|
||||
|
||||
[project]
|
||||
name = "meet"
|
||||
version = "1.29.0"
|
||||
version = "1.31.0"
|
||||
authors = [{ "name" = "DINUM", "email" = "dev@mail.numerique.gouv.fr" }]
|
||||
classifiers = [
|
||||
"Development Status :: 5 - Production/Stable",
|
||||
|
||||
Generated
+1
-1
@@ -1187,7 +1187,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "meet"
|
||||
version = "1.29.0"
|
||||
version = "1.31.0"
|
||||
source = { editable = "." }
|
||||
dependencies = [
|
||||
{ name = "aiohttp" },
|
||||
|
||||
+2
-11
@@ -42,19 +42,10 @@ ENV VITE_APP_TITLE=${VITE_APP_TITLE}
|
||||
RUN npm run build
|
||||
|
||||
# ---- Front-end image ----
|
||||
FROM nginxinc/nginx-unprivileged:1.30.3-alpine3.23 AS frontend-production
|
||||
FROM nginxinc/nginx-unprivileged:1.30.4-alpine3.24 AS frontend-production
|
||||
|
||||
USER root
|
||||
|
||||
# Security patches for known CVEs
|
||||
RUN apk update && apk upgrade \
|
||||
libcrypto3>=3.5.7-r0 \
|
||||
libssl3>=3.5.7-r0 \
|
||||
musl \
|
||||
musl-utils \
|
||||
zlib>=1.3.2-r0 \
|
||||
&& apk del curl
|
||||
|
||||
RUN apk del curl
|
||||
USER nginx
|
||||
|
||||
# Un-privileged user running the application
|
||||
|
||||
@@ -3,6 +3,6 @@
|
||||
"input": ["src/**/*.{ts,tsx}", "!src/styled-system/**/*", "!src/**/*.d.ts"],
|
||||
"output": "src/locales/$LOCALE/$NAMESPACE.json",
|
||||
"createOldCatalogs": false,
|
||||
"locales": ["en", "fr", "de", "nl"],
|
||||
"locales": ["en", "fr", "de", "nl", "es"],
|
||||
"sort": true
|
||||
}
|
||||
|
||||
Generated
+42
-37
@@ -1,16 +1,16 @@
|
||||
{
|
||||
"name": "meet",
|
||||
"version": "1.29.0",
|
||||
"version": "1.31.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "meet",
|
||||
"version": "1.29.0",
|
||||
"version": "1.31.0",
|
||||
"dependencies": {
|
||||
"@fontsource-variable/atkinson-hyperlegible-next": "5.3.0",
|
||||
"@fontsource-variable/lexend": "5.2.11",
|
||||
"@fontsource/opendyslexic": "5.2.5",
|
||||
"@fontsource-variable/lexend": "5.3.0",
|
||||
"@fontsource/opendyslexic": "5.3.0",
|
||||
"@libreaudio/la-call": "0.1.4",
|
||||
"@livekit/components-react": "2.9.23",
|
||||
"@livekit/components-styles": "1.2.0",
|
||||
@@ -29,7 +29,7 @@
|
||||
"i18next-parser": "9.4.0",
|
||||
"i18next-resources-to-backend": "1.2.3",
|
||||
"livekit-client": "2.21.0",
|
||||
"posthog-js": "1.409.5",
|
||||
"posthog-js": "1.414.0",
|
||||
"react": "18.3.1",
|
||||
"react-aria": "3.50.0",
|
||||
"react-aria-components": "1.19.0",
|
||||
@@ -776,18 +776,18 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@fontsource-variable/lexend": {
|
||||
"version": "5.2.11",
|
||||
"resolved": "https://registry.npmjs.org/@fontsource-variable/lexend/-/lexend-5.2.11.tgz",
|
||||
"integrity": "sha512-0hgEQ4O7Nh8fxL/WWmspJf0BErbocRkZwtLRGey/V4mUUqxfF7QUwqhcdzwpjom3NYCniY4uzQ5wYD7r9/92tQ==",
|
||||
"version": "5.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@fontsource-variable/lexend/-/lexend-5.3.0.tgz",
|
||||
"integrity": "sha512-3SXtiZ8rFbT0oaPzEboCG5RM3jtyhpNxandh1jWNLqvrJRfV/eP3R+GGw+o5e3hS1uqmAGutKxSvaWG5oyrimA==",
|
||||
"license": "OFL-1.1",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ayuhito"
|
||||
}
|
||||
},
|
||||
"node_modules/@fontsource/opendyslexic": {
|
||||
"version": "5.2.5",
|
||||
"resolved": "https://registry.npmjs.org/@fontsource/opendyslexic/-/opendyslexic-5.2.5.tgz",
|
||||
"integrity": "sha512-NNS9aaPQx2TlaTvb3vTEjw3xz8lKj23mBc+6rM00mSNFDygdoll0/nLMHFtDKKrBT6sMfY6TFFPOR0D9ktdspg==",
|
||||
"version": "5.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@fontsource/opendyslexic/-/opendyslexic-5.3.0.tgz",
|
||||
"integrity": "sha512-BVYIW/ghc1U2iAghhYTN+GFWiZ3lnCy8Jf2KkZC23aHQHBNcMqKT718zckfqVxRx4qsXJmIMPWvczirRuhmtsg==",
|
||||
"license": "OFL-1.1",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ayuhito"
|
||||
@@ -1720,28 +1720,28 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@posthog/browser-common": {
|
||||
"version": "0.3.1",
|
||||
"resolved": "https://registry.npmjs.org/@posthog/browser-common/-/browser-common-0.3.1.tgz",
|
||||
"integrity": "sha512-1nhMVY1wnHADTg8tR9yvm+lPAz5ROxznfQlBtLzB2FFo4lp/LU8lk9KyFPsARDkBxCfYachsJfvRjocL1G/AJQ==",
|
||||
"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.0",
|
||||
"@posthog/types": "^1.399.0"
|
||||
"@posthog/core": "^1.46.8",
|
||||
"@posthog/types": "^1.402.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@posthog/core": {
|
||||
"version": "1.48.3",
|
||||
"resolved": "https://registry.npmjs.org/@posthog/core/-/core-1.48.3.tgz",
|
||||
"integrity": "sha512-kwVDVvwtCTXctApA2tpnwDjDDan8LrkwCW1Wv6PABaVs/s5ahbQ9W3pvdXcqr71HCuqukSA1jp7MySczebubGg==",
|
||||
"version": "1.49.1",
|
||||
"resolved": "https://registry.npmjs.org/@posthog/core/-/core-1.49.1.tgz",
|
||||
"integrity": "sha512-jdZh85tG56OXLH881CVwBZyiXCPPaZasfYeWwm9kVUvxC/Rb+lz7wYN9GuqSEmNPJgVnK4v8wS0bCaFc3OmVEA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@posthog/types": "^1.405.0"
|
||||
"@posthog/types": "^1.407.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@posthog/types": {
|
||||
"version": "1.405.0",
|
||||
"resolved": "https://registry.npmjs.org/@posthog/types/-/types-1.405.0.tgz",
|
||||
"integrity": "sha512-4rZ/taVXKQxs9Jrf7ZjlCRgrOSL69oKAgIWJQa5kRNJ6wll1UANbrJTSY+Su1e88LIG4zZVjKKKjyQHCkHdHcw==",
|
||||
"version": "1.407.1",
|
||||
"resolved": "https://registry.npmjs.org/@posthog/types/-/types-1.407.1.tgz",
|
||||
"integrity": "sha512-WhbkXPC2rgylXqmxHqv70ffI3k+KxyR6s7DBIfr5NvIqHkxp6v0pk31D/jbz0DNVbzwkLjyll2pxr4FNbJiYzg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@react-aria/overlays": {
|
||||
@@ -4794,13 +4794,10 @@
|
||||
}
|
||||
},
|
||||
"node_modules/dompurify": {
|
||||
"version": "3.3.2",
|
||||
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.3.2.tgz",
|
||||
"integrity": "sha512-6obghkliLdmKa56xdbLOpUZ43pAR6xFy1uOrxBaIDjT+yaRuuybLjGS9eVBoSR/UPU5fq3OXClEHLJNGvbxKpQ==",
|
||||
"version": "3.4.14",
|
||||
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.14.tgz",
|
||||
"integrity": "sha512-dVoH9z+MY+C9IilgGCk3YfFqjLi3fChm2OiKJMzh6axrJ5qwxqWaZamgmHrpv22CN/KdbZJuGEGgfQoL00LTdg==",
|
||||
"license": "(MPL-2.0 OR Apache-2.0)",
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@types/trusted-types": "^2.0.7"
|
||||
}
|
||||
@@ -9134,20 +9131,21 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/posthog-js": {
|
||||
"version": "1.409.5",
|
||||
"resolved": "https://registry.npmjs.org/posthog-js/-/posthog-js-1.409.5.tgz",
|
||||
"integrity": "sha512-s1iJz+vq0YAluUD4OwLjUFIJt/9pqiiB6MITvHWIS16a7pqTJqbSLXsd5/8kJcIgfrOSZHe+mdYAXLYcJCS4LQ==",
|
||||
"version": "1.414.0",
|
||||
"resolved": "https://registry.npmjs.org/posthog-js/-/posthog-js-1.414.0.tgz",
|
||||
"integrity": "sha512-dtZd4asdskr8lNyltAEX6zyn48uO1pO0EMvx6AXJU65PFhu6yn2LPbKtQcyLysjcN57FJPNT9QYL6St5SBJHqw==",
|
||||
"license": "(Apache-2.0 AND MIT)",
|
||||
"dependencies": {
|
||||
"@posthog/browser-common": "^0.3.1",
|
||||
"@posthog/core": "^1.46.1",
|
||||
"@posthog/types": "^1.399.0",
|
||||
"@posthog/browser-common": "^0.4.0",
|
||||
"@posthog/core": "^1.46.9",
|
||||
"@posthog/types": "^1.402.2",
|
||||
"core-js": "^3.49.0",
|
||||
"dompurify": "^3.3.2",
|
||||
"dompurify": "^3.4.12",
|
||||
"fflate": "^0.4.8",
|
||||
"preact": "^10.29.3",
|
||||
"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": {
|
||||
@@ -11659,6 +11657,13 @@
|
||||
"integrity": "sha512-q6LWsLatGYZp5VGBIOvbTj6JBV2nOmC8KvWztXBmwJcfFAzhwKwbOxhUH306XY3CcaZDUlSmSuNPBsCn0bFu+g==",
|
||||
"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": {
|
||||
"version": "9.0.6",
|
||||
"resolved": "https://registry.npmjs.org/webrtc-adapter/-/webrtc-adapter-9.0.6.tgz",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "meet",
|
||||
"private": true,
|
||||
"version": "1.29.0",
|
||||
"version": "1.31.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "panda codegen && vite",
|
||||
@@ -16,8 +16,8 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@fontsource-variable/atkinson-hyperlegible-next": "5.3.0",
|
||||
"@fontsource-variable/lexend": "5.2.11",
|
||||
"@fontsource/opendyslexic": "5.2.5",
|
||||
"@fontsource-variable/lexend": "5.3.0",
|
||||
"@fontsource/opendyslexic": "5.3.0",
|
||||
"@libreaudio/la-call": "0.1.4",
|
||||
"@livekit/components-react": "2.9.23",
|
||||
"@livekit/components-styles": "1.2.0",
|
||||
@@ -36,7 +36,7 @@
|
||||
"i18next-parser": "9.4.0",
|
||||
"i18next-resources-to-backend": "1.2.3",
|
||||
"livekit-client": "2.21.0",
|
||||
"posthog-js": "1.409.5",
|
||||
"posthog-js": "1.414.0",
|
||||
"react": "18.3.1",
|
||||
"react-aria": "3.50.0",
|
||||
"react-aria-components": "1.19.0",
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { css, cva, RecipeVariantProps } from '@/styled-system/css'
|
||||
import React, { useLayoutEffect, useMemo } from 'react'
|
||||
import React, { useMemo } from 'react'
|
||||
|
||||
const avatar = cva({
|
||||
base: {
|
||||
@@ -28,24 +28,17 @@ const avatar = cva({
|
||||
},
|
||||
})
|
||||
|
||||
// Instantiating a segmenter is expensive; create it once and reuse it.
|
||||
const graphemeSegmenter =
|
||||
typeof Intl !== 'undefined' && 'Segmenter' in Intl
|
||||
? new Intl.Segmenter(undefined, { granularity: 'grapheme' })
|
||||
: undefined
|
||||
|
||||
/**
|
||||
* Returns the first user-perceived character. Some Unicode characters span
|
||||
* multiple UTF-16 code units, so a naive index into the string can split them
|
||||
* and yield a broken glyph.
|
||||
*/
|
||||
const getFirstGrapheme = (value: string): string => {
|
||||
if (!value) return ''
|
||||
if (graphemeSegmenter) {
|
||||
const [first] = graphemeSegmenter.segment(value)
|
||||
return first?.segment ?? ''
|
||||
}
|
||||
// Fallback: keeps single code points intact (including surrogate pairs).
|
||||
return Array.from(value)[0] ?? ''
|
||||
}
|
||||
|
||||
@@ -66,36 +59,6 @@ export type AvatarProps = React.HTMLAttributes<HTMLDivElement> & {
|
||||
export const Avatar = React.memo(
|
||||
({ name, bgColor, context, notification, style, ...props }: AvatarProps) => {
|
||||
const initials = useMemo(() => getInitials(name), [name])
|
||||
const textRef = React.useRef<SVGTextElement>(null)
|
||||
const [offsetY, setOffsetY] = React.useState(0)
|
||||
|
||||
// Optically center the initials: measure the ink bounding box of the
|
||||
// rendered glyphs and shift them so the box's center sits at the middle
|
||||
// of the viewBox. Works for any font, weight or glyph shape, unlike a
|
||||
// hand-tuned dy offset. getBBox() is in local (pre-transform)
|
||||
// coordinates, so applying the translation never changes the measure.
|
||||
useLayoutEffect(() => {
|
||||
const text = textRef.current
|
||||
if (!text) return
|
||||
|
||||
const center = () => {
|
||||
const box = text.getBBox()
|
||||
// A hidden element measures as an empty box; keep the default then.
|
||||
if (box.height === 0) return
|
||||
setOffsetY(50 - (box.y + box.height / 2))
|
||||
}
|
||||
|
||||
center()
|
||||
// Glyph metrics can change once webfonts finish loading.
|
||||
let cancelled = false
|
||||
document.fonts?.ready.then(() => {
|
||||
if (!cancelled) center()
|
||||
})
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [initials])
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{ backgroundColor: bgColor, ...style }}
|
||||
@@ -108,15 +71,16 @@ export const Avatar = React.memo(
|
||||
className={css({ width: '100%', height: '100%', display: 'block' })}
|
||||
>
|
||||
<text
|
||||
ref={textRef}
|
||||
x="50"
|
||||
y="50"
|
||||
transform={`translate(0 ${offsetY})`}
|
||||
y={50}
|
||||
textAnchor="middle"
|
||||
dominantBaseline="central"
|
||||
fontSize="52"
|
||||
fontWeight="500"
|
||||
fill="currentColor"
|
||||
className={css({
|
||||
transform:
|
||||
'translateY(calc(var(--avatar-cap-height, 0.7) * 0.5em))',
|
||||
})}
|
||||
>
|
||||
{initials}
|
||||
</text>
|
||||
|
||||
@@ -18,7 +18,7 @@ export const fetchUser = (
|
||||
}
|
||||
): Promise<ApiUser | false> => {
|
||||
return new Promise((resolve, reject) => {
|
||||
fetchApi<ApiUser>('/users/me')
|
||||
fetchApi<ApiUser>('/users/me/')
|
||||
.then(resolve)
|
||||
.catch((error) => {
|
||||
// we assume that a 401 means the user is not logged in
|
||||
|
||||
@@ -21,7 +21,9 @@ const StyledContainer = styled('div', {
|
||||
})
|
||||
|
||||
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' })
|
||||
|
||||
@@ -49,7 +51,7 @@ export const ChatTextArea = () => {
|
||||
const isDisabled = !textAreaValue.trim() || isSending
|
||||
|
||||
const onKeyDown = async (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
e.stopPropagation()
|
||||
if (e.key !== 'Escape') e.stopPropagation()
|
||||
if (e.key !== 'Enter' || (e.key === 'Enter' && e.shiftKey) || isDisabled)
|
||||
return
|
||||
e.preventDefault()
|
||||
|
||||
@@ -0,0 +1,595 @@
|
||||
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
|
||||
@@ -0,0 +1,14 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
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'
|
||||
}
|
||||
@@ -0,0 +1,243 @@
|
||||
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
|
||||
* client→TURN 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 }
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { ApiError } from '@/api/ApiError'
|
||||
import { fetchApi } from '@/api/fetchApi'
|
||||
import { captureEvent } from '@/features/analytics/telemetry'
|
||||
import { useMutation, type UseMutationOptions } from '@tanstack/react-query'
|
||||
|
||||
export interface EnterRoomParams {
|
||||
@@ -17,13 +18,23 @@ export const enterRoom = async ({
|
||||
allowEntry,
|
||||
participantId,
|
||||
}: EnterRoomParams): Promise<EnterRoomResponse> => {
|
||||
return await fetchApi<EnterRoomResponse>(`/rooms/${roomId}/enter/`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
participant_id: participantId,
|
||||
allow_entry: allowEntry,
|
||||
}),
|
||||
})
|
||||
try {
|
||||
return await fetchApi<EnterRoomResponse>(`/rooms/${roomId}/enter/`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
participant_id: participantId,
|
||||
allow_entry: allowEntry,
|
||||
}),
|
||||
})
|
||||
} catch (error) {
|
||||
if (error instanceof ApiError && error.statusCode === 404) {
|
||||
captureEvent('lobby_entry_participant_gone', {
|
||||
room_id: roomId,
|
||||
allow_entry: allowEntry,
|
||||
})
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
export function useEnterRoom(
|
||||
|
||||
@@ -8,6 +8,7 @@ export type WaitingParticipant = {
|
||||
status: string
|
||||
username: string
|
||||
color: string
|
||||
entered_at: string
|
||||
}
|
||||
|
||||
export type WaitingParticipantsResponse = {
|
||||
|
||||
@@ -9,6 +9,14 @@ import {
|
||||
} from '../../participants/api/listWaitingParticipants'
|
||||
import { reportError } from '@/features/analytics/telemetry'
|
||||
|
||||
const toTimestamp = (participant: WaitingParticipant): number =>
|
||||
Date.parse(participant.entered_at)
|
||||
|
||||
export const sortWaitingParticipants = (
|
||||
participants: WaitingParticipant[]
|
||||
): WaitingParticipant[] =>
|
||||
[...participants].sort((a, b) => toTimestamp(a) - toTimestamp(b))
|
||||
|
||||
export const useWaitingParticipants = () => {
|
||||
const roomData = useRoomData()
|
||||
const roomId = roomData?.id || '' // FIXME - bad practice
|
||||
@@ -22,7 +30,10 @@ export const useWaitingParticipants = () => {
|
||||
})
|
||||
|
||||
const waitingParticipants = useMemo(
|
||||
() => (canManageLobby ? waitingData?.participants || [] : []),
|
||||
() =>
|
||||
canManageLobby
|
||||
? sortWaitingParticipants(waitingData?.participants || [])
|
||||
: [],
|
||||
[waitingData, canManageLobby]
|
||||
)
|
||||
|
||||
|
||||
@@ -43,6 +43,7 @@ import { useSnapshot } from 'valtio'
|
||||
import { userPreferencesStore } from '@/stores/userPreferences'
|
||||
import { userStore } from '@/stores/user'
|
||||
import { WatchMediaDeviceErrors } from './WatchMediaDeviceErrors'
|
||||
import { MeetDevtools } from '@/features/devtools'
|
||||
import { VOICE_AUDIO_CONSTRAINTS } from '@/features/rooms/livekit/utils/constants'
|
||||
|
||||
export const Conference = ({
|
||||
@@ -298,6 +299,7 @@ export const Conference = ({
|
||||
<VideoConference />
|
||||
{!isMobile && <InviteDialog mode={mode} />}
|
||||
<PictureInPictureConference />
|
||||
<MeetDevtools />
|
||||
</LiveKitRoom>
|
||||
</Screen>
|
||||
</QueryAware>
|
||||
|
||||
@@ -12,8 +12,8 @@ import { keys } from '@/api/queryKeys'
|
||||
import { queryClient } from '@/api/queryClient'
|
||||
import { ApiError } from '@/api/ApiError'
|
||||
|
||||
export const POLL_INTERVAL_MS = 1000
|
||||
export const LAZY_POLL_INTERVAL_MS = 10_000
|
||||
export const POLL_INTERVAL_MS = 4_000
|
||||
export const LAZY_POLL_INTERVAL_MS = 15_000
|
||||
|
||||
export const LobbyProvider = () => {
|
||||
const room = useRoomContext()
|
||||
@@ -79,7 +79,7 @@ export const LobbyProvider = () => {
|
||||
// 3. Rights regained.
|
||||
const prevCanManageLobby = usePrevious(canManageLobby)
|
||||
useEffect(() => {
|
||||
if (!prevCanManageLobby && canManageLobby && isConnected) {
|
||||
if (prevCanManageLobby != canManageLobby && isConnected) {
|
||||
fetchIfManager()
|
||||
}
|
||||
}, [
|
||||
|
||||
@@ -16,7 +16,7 @@ const Card = styled('div', {
|
||||
borderRadius: '0.25rem',
|
||||
boxShadow: '',
|
||||
width: '100%',
|
||||
maxWidth: '380px',
|
||||
maxWidth: '410px',
|
||||
minHeight: '196px',
|
||||
},
|
||||
})
|
||||
@@ -229,7 +229,7 @@ const ConfirmationMessage = ({ onNext }: { onNext: () => void }) => {
|
||||
return (
|
||||
<Card
|
||||
style={{
|
||||
maxWidth: '380px',
|
||||
maxWidth: '410px',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
justifyContent: 'center',
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
} from '../api/requestEntry'
|
||||
|
||||
export const WAIT_TIMEOUT_MS = 600000 // 10 minutes
|
||||
export const POLL_INTERVAL_MS = 1000
|
||||
export const POLL_INTERVAL_MS = 3_000
|
||||
|
||||
export const useLobby = ({
|
||||
roomId,
|
||||
|
||||
@@ -71,20 +71,30 @@ export const ConnectionObserver = () => {
|
||||
useEffect(() => {
|
||||
if (!isAnalyticsEnabled) return
|
||||
|
||||
const handleConnection = () => {
|
||||
const handleConnection = async () => {
|
||||
// Preserve original connection timestamp across reconnections to measure
|
||||
// total session duration from first connect to final disconnect.
|
||||
if (connectionStartTimeRef.current != null) return
|
||||
connectionStartTimeRef.current = Date.now()
|
||||
void captureMediaEvent('connection-event', {})
|
||||
const participantSid = room.localParticipant.sid
|
||||
const roomSid = await room.getSid().catch(() => undefined)
|
||||
void captureMediaEvent('connection-event', {
|
||||
livekit_room_sid: roomSid,
|
||||
livekit_participant_sid: participantSid,
|
||||
})
|
||||
}
|
||||
|
||||
const handleReconnect = () => {
|
||||
captureEvent('reconnect-event')
|
||||
}
|
||||
|
||||
const handleReconnected = () => {
|
||||
captureEvent('reconnected-event')
|
||||
const handleReconnected = async () => {
|
||||
const participantSid = room.localParticipant.sid
|
||||
const roomSid = await room.getSid().catch(() => undefined)
|
||||
captureEvent('reconnected-event', {
|
||||
livekit_room_sid: roomSid,
|
||||
livekit_participant_sid: participantSid,
|
||||
})
|
||||
}
|
||||
|
||||
const handleSignalingConnect = () => {
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { useLocalParticipant } from '@livekit/components-react'
|
||||
import { TrackSource } from '@livekit/protocol'
|
||||
import { useEffect } from 'react'
|
||||
import { useCanPublishTrack } from '@/features/rooms/livekit/hooks/useCanPublishTrack'
|
||||
|
||||
export const MEDIA_STATE_ELEMENT_ID = 'media-state'
|
||||
export const MEDIA_STATE_CHANGED_EVENT = 'media-state-changed'
|
||||
@@ -7,6 +9,8 @@ export const MEDIA_STATE_CHANGED_EVENT = 'media-state-changed'
|
||||
export type MediaStateChangedDetail = {
|
||||
microphoneEnabled: boolean
|
||||
cameraEnabled: boolean
|
||||
canPublishMicrophone: boolean
|
||||
canPublishCamera: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -16,9 +20,15 @@ export type MediaStateChangedDetail = {
|
||||
*
|
||||
* const el = document.getElementById('media-state')
|
||||
* new MutationObserver(...).observe(el, { attributes: true })
|
||||
*
|
||||
* The publish permissions are exposed alongside the state: an external tool
|
||||
* cannot tell a muted microphone from one it is not allowed to unmute, and
|
||||
* would otherwise offer a control that silently does nothing.
|
||||
*/
|
||||
export const MediaStateObserver = () => {
|
||||
const { isMicrophoneEnabled, isCameraEnabled } = useLocalParticipant()
|
||||
const canPublishMicrophone = useCanPublishTrack(TrackSource.MICROPHONE)
|
||||
const canPublishCamera = useCanPublishTrack(TrackSource.CAMERA)
|
||||
|
||||
useEffect(() => {
|
||||
window.dispatchEvent(
|
||||
@@ -26,10 +36,17 @@ export const MediaStateObserver = () => {
|
||||
detail: {
|
||||
microphoneEnabled: isMicrophoneEnabled,
|
||||
cameraEnabled: isCameraEnabled,
|
||||
canPublishMicrophone,
|
||||
canPublishCamera,
|
||||
},
|
||||
})
|
||||
)
|
||||
}, [isMicrophoneEnabled, isCameraEnabled])
|
||||
}, [
|
||||
isMicrophoneEnabled,
|
||||
isCameraEnabled,
|
||||
canPublishMicrophone,
|
||||
canPublishCamera,
|
||||
])
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -37,6 +54,8 @@ export const MediaStateObserver = () => {
|
||||
style={{ display: 'none' }}
|
||||
data-microphone-enabled={isMicrophoneEnabled ? 'true' : 'false'}
|
||||
data-camera-enabled={isCameraEnabled ? 'true' : 'false'}
|
||||
data-can-publish-microphone={canPublishMicrophone ? 'true' : 'false'}
|
||||
data-can-publish-camera={canPublishCamera ? 'true' : 'false'}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -16,6 +16,8 @@ import { Info } from './Info'
|
||||
import { HStack } from '@/styled-system/jsx'
|
||||
import { useReactionsToolbar } from '@/features/reactions/hooks/useReactionsToolbar'
|
||||
import { useRestoreFocus } from '@/hooks/useRestoreFocus'
|
||||
import { useEscapeToClose } from '@/hooks/useEscapeToClose'
|
||||
import { srOnly } from '@/styles/a11y'
|
||||
|
||||
type StyledSidePanelProps = {
|
||||
title: string
|
||||
@@ -24,6 +26,7 @@ type StyledSidePanelProps = {
|
||||
onClose: () => void
|
||||
isClosed: boolean
|
||||
closeButtonTooltip: string
|
||||
escapeHint: string
|
||||
isSubmenu: boolean
|
||||
onBack: () => void
|
||||
backButtonLabel: string
|
||||
@@ -40,6 +43,7 @@ const StyledSidePanel = React.forwardRef<HTMLElement, StyledSidePanelProps>(
|
||||
isClosed,
|
||||
isReactionToolbarOpen,
|
||||
closeButtonTooltip,
|
||||
escapeHint,
|
||||
isSubmenu = false,
|
||||
onBack,
|
||||
backButtonLabel,
|
||||
@@ -84,7 +88,11 @@ const StyledSidePanel = React.forwardRef<HTMLElement, StyledSidePanelProps>(
|
||||
}}
|
||||
aria-hidden={isClosed}
|
||||
aria-label={ariaLabel}
|
||||
aria-describedby="side-panel-escape-hint"
|
||||
>
|
||||
<span id="side-panel-escape-hint" className={srOnly}>
|
||||
{escapeHint}
|
||||
</span>
|
||||
<HStack alignItems="center">
|
||||
{isSubmenu && (
|
||||
<Button
|
||||
@@ -194,6 +202,8 @@ export const SidePanel = () => {
|
||||
activeKey: activePanelId,
|
||||
})
|
||||
|
||||
useEscapeToClose(isSidePanelOpen, asideRef, closeSidePanel)
|
||||
|
||||
return (
|
||||
<StyledSidePanel
|
||||
ref={asideRef}
|
||||
@@ -203,6 +213,7 @@ export const SidePanel = () => {
|
||||
closeButtonTooltip={t('closeButton', {
|
||||
content: t(`content.${activeSubPanelId || activePanelId}`),
|
||||
})}
|
||||
escapeHint={t('escapeHint')}
|
||||
isClosed={!isSidePanelOpen}
|
||||
isSubmenu={isSubPanelOpen}
|
||||
isReactionToolbarOpen={isReactionToolbarOpen}
|
||||
|
||||
+6
-2
@@ -2,7 +2,7 @@ import { useTranslation } from 'react-i18next'
|
||||
import { useTrackToggle, UseTrackToggleProps } from '@livekit/components-react'
|
||||
import { Button, Popover } from '@/primitives'
|
||||
import { RiArrowUpSLine, RiImageCircleAiFill } from '@remixicon/react'
|
||||
import { Track, type VideoCaptureOptions } from 'livekit-client'
|
||||
import { Track, type VideoCaptureOptions, VideoPresets } from 'livekit-client'
|
||||
|
||||
import { ToggleDevice } from './ToggleDevice'
|
||||
import { css } from '@/styled-system/css'
|
||||
@@ -57,7 +57,8 @@ export const VideoDeviceControl = ({
|
||||
}: VideoDeviceControlProps) => {
|
||||
const { t } = useTranslation('rooms', { keyPrefix: 'selectDevice' })
|
||||
|
||||
const { videoDeviceId, processorConfig } = useSnapshot(userChoicesStore)
|
||||
const { videoDeviceId, processorConfig, videoPublishResolution } =
|
||||
useSnapshot(userChoicesStore)
|
||||
|
||||
const onChange = React.useCallback(
|
||||
(enabled: boolean, isUserInitiated: boolean) =>
|
||||
@@ -97,6 +98,9 @@ export const VideoDeviceControl = ({
|
||||
|
||||
await toggle(!trackProps.enabled, {
|
||||
processor: processor,
|
||||
...(videoPublishResolution && {
|
||||
resolution: VideoPresets[videoPublishResolution].resolution,
|
||||
}),
|
||||
} as VideoCaptureOptions)
|
||||
}
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ import { useTranslation } from 'react-i18next'
|
||||
import { RiHand } from '@remixicon/react'
|
||||
import { ToggleButton } from '@/primitives'
|
||||
import { css } from '@/styled-system/css'
|
||||
import { useRoomContext } from '@livekit/components-react'
|
||||
import { useIsSpeaking, useRoomContext } from '@livekit/components-react'
|
||||
import { useRaisedHand } from '@/features/rooms/livekit/hooks/useRaisedHand'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import {
|
||||
@@ -25,11 +25,11 @@ export const HandToggle = ({
|
||||
const { t } = useTranslation('rooms', { keyPrefix: 'controls.hand' })
|
||||
|
||||
const room = useRoomContext()
|
||||
const { isHandRaised, toggleRaisedHand } = useRaisedHand({
|
||||
const { isHandRaised, toggleRaisedHand, lowerHand } = useRaisedHand({
|
||||
participant: room.localParticipant,
|
||||
})
|
||||
|
||||
const isSpeaking = room.localParticipant.isSpeaking
|
||||
const isSpeaking = useIsSpeaking(room.localParticipant)
|
||||
const speakingTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
const [hasShownToast, setHasShownToast] = useState(false)
|
||||
|
||||
@@ -57,9 +57,10 @@ export const HandToggle = ({
|
||||
|
||||
if (shouldShowToast && !speakingTimerRef.current) {
|
||||
speakingTimerRef.current = setTimeout(() => {
|
||||
speakingTimerRef.current = null
|
||||
setHasShownToast(true)
|
||||
const onClose = () => {
|
||||
if (isHandRaised) toggleRaisedHand()
|
||||
lowerHand()
|
||||
resetToastState()
|
||||
}
|
||||
showLowerHandToast(room.localParticipant, onClose)
|
||||
@@ -70,7 +71,17 @@ export const HandToggle = ({
|
||||
speakingTimerRef.current = null
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [isSpeaking, isHandRaised, hasShownToast, toggleRaisedHand])
|
||||
}, [isSpeaking, isHandRaised, hasShownToast, lowerHand])
|
||||
|
||||
// Clear any pending timer on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (speakingTimerRef.current) {
|
||||
clearTimeout(speakingTimerRef.current)
|
||||
speakingTimerRef.current = null
|
||||
}
|
||||
}
|
||||
}, [])
|
||||
|
||||
const tooltipLabel = isHandRaised ? 'lower' : 'raise'
|
||||
|
||||
|
||||
@@ -86,5 +86,16 @@ export function useRaisedHand({ participant }: useRaisedHandProps) {
|
||||
}
|
||||
}
|
||||
|
||||
return { isHandRaised, toggleRaisedHand }
|
||||
const lowerHand = async () => {
|
||||
if (!isLocal(participant)) return
|
||||
try {
|
||||
await raiseHand(false)
|
||||
} catch (e) {
|
||||
reportError('generic_failure', e, {
|
||||
context: 'lower_raised_hand',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return { isHandRaised, toggleRaisedHand, lowerHand }
|
||||
}
|
||||
|
||||
@@ -24,7 +24,8 @@ const Heading = styled('h1', {
|
||||
})
|
||||
|
||||
const buttonClass = css({
|
||||
width: { base: '100%', xsm: 'auto' },
|
||||
width: '100%',
|
||||
flex: 1,
|
||||
})
|
||||
|
||||
enum DisconnectReasonKey {
|
||||
@@ -64,8 +65,8 @@ const FeedbackRoute = () => {
|
||||
<Heading>{t(`feedback.heading.${reasonKey || 'normal'}`)}</Heading>
|
||||
<Stack
|
||||
direction={{ base: 'column', xsm: 'row' }}
|
||||
width={{ base: '100%', xsm: 'auto' }}
|
||||
maxWidth="380px"
|
||||
width="100%"
|
||||
maxWidth="410px"
|
||||
>
|
||||
{showBackButton && (
|
||||
<Button
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
saveVideoPublishResolution,
|
||||
saveVideoSubscribeQuality,
|
||||
userChoicesStore,
|
||||
VIDEO_RESOLUTIONS,
|
||||
VideoResolution,
|
||||
} from '@/stores/userChoices'
|
||||
import { RowWrapper } from './layout/RowWrapper'
|
||||
@@ -32,7 +33,8 @@ const EMPTY_PROPS = {}
|
||||
|
||||
export const VideoTab = ({ id }: VideoTabProps) => {
|
||||
const { t } = useTranslation('settings', { keyPrefix: 'video' })
|
||||
const { localParticipant, remoteParticipants } = useRoomContext()
|
||||
const room = useRoomContext()
|
||||
const { localParticipant, remoteParticipants } = room
|
||||
|
||||
const {
|
||||
videoDeviceId,
|
||||
@@ -69,20 +71,21 @@ export const VideoTab = ({ id }: VideoTabProps) => {
|
||||
isDisabled: true,
|
||||
}
|
||||
|
||||
const handleVideoResolutionChange = async (key: 'h720' | 'h360' | 'h180') => {
|
||||
const videoPublication = localParticipant.getTrackPublication(
|
||||
const handleVideoResolutionChange = async (key: VideoResolution) => {
|
||||
saveVideoPublishResolution(key)
|
||||
const videoTrack = localParticipant.getTrackPublication(
|
||||
Track.Source.Camera
|
||||
)
|
||||
const videoTrack = videoPublication?.track
|
||||
if (videoTrack) {
|
||||
saveVideoPublishResolution(key)
|
||||
await videoTrack.restartTrack({
|
||||
resolution: VideoPresets[key].resolution,
|
||||
deviceId: { exact: videoDeviceId },
|
||||
processor:
|
||||
BackgroundProcessorFactory.fromProcessorConfig(processorConfig),
|
||||
})
|
||||
)?.track
|
||||
if (!videoTrack) {
|
||||
return
|
||||
}
|
||||
|
||||
await videoTrack.restartTrack({
|
||||
resolution: VideoPresets[key].resolution,
|
||||
deviceId: { exact: videoDeviceId },
|
||||
processor:
|
||||
BackgroundProcessorFactory.fromProcessorConfig(processorConfig),
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -122,20 +125,13 @@ export const VideoTab = ({ id }: VideoTabProps) => {
|
||||
}, [videoDeviceId, videoElement])
|
||||
|
||||
const resolutionItems = useMemo(() => {
|
||||
return [
|
||||
{
|
||||
value: 'h720',
|
||||
label: `${t('resolution.publish.items.high')} (720p)`,
|
||||
},
|
||||
{
|
||||
value: 'h360',
|
||||
label: `${t('resolution.publish.items.medium')} (360p)`,
|
||||
},
|
||||
{
|
||||
value: 'h180',
|
||||
label: `${t('resolution.publish.items.low')} (180p)`,
|
||||
},
|
||||
]
|
||||
const labels: Record<VideoResolution, string> = {
|
||||
h1080: `${t('resolution.publish.items.veryHigh')} (1080p)`,
|
||||
h720: `${t('resolution.publish.items.high')} (720p)`,
|
||||
h360: `${t('resolution.publish.items.medium')} (360p)`,
|
||||
h180: `${t('resolution.publish.items.low')} (180p)`,
|
||||
}
|
||||
return VIDEO_RESOLUTIONS.map((value) => ({ value, label: labels[value] }))
|
||||
}, [t])
|
||||
|
||||
const videoQualityItems = useMemo(() => {
|
||||
|
||||
@@ -75,15 +75,15 @@ const useTranscriptionState = () => {
|
||||
const segment = segments[0]
|
||||
|
||||
setTranscriptionSegments((prevSegments) => {
|
||||
const existingSegmentIds = new Set(prevSegments.map((s) => s.id))
|
||||
if (existingSegmentIds.has(segment.id)) return prevSegments
|
||||
return [
|
||||
...prevSegments,
|
||||
{
|
||||
participant: participant,
|
||||
...segment,
|
||||
},
|
||||
]
|
||||
const existingIndex = prevSegments.findIndex(
|
||||
(s: TranscriptionSegmentWithParticipant) => s.id === segment.id
|
||||
)
|
||||
if (existingIndex === -1) {
|
||||
return [...prevSegments, { participant, ...segment }]
|
||||
}
|
||||
const next = prevSegments.slice()
|
||||
next[existingIndex] = { ...next[existingIndex], ...segment }
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import { useEffect, useRef, type RefObject } from 'react'
|
||||
|
||||
export const useEscapeToClose = (
|
||||
isActive: boolean,
|
||||
containerRef: RefObject<HTMLElement | null>,
|
||||
onClose: () => void
|
||||
) => {
|
||||
const onCloseRef = useRef(onClose)
|
||||
useEffect(() => {
|
||||
onCloseRef.current = onClose
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
if (!isActive) return
|
||||
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key !== 'Escape') return
|
||||
if (!containerRef.current?.contains(document.activeElement)) return
|
||||
e.stopPropagation()
|
||||
onCloseRef.current()
|
||||
}
|
||||
|
||||
document.addEventListener('keydown', handleKeyDown)
|
||||
return () => document.removeEventListener('keydown', handleKeyDown)
|
||||
}, [isActive, containerRef])
|
||||
}
|
||||
@@ -15,7 +15,7 @@ i18n
|
||||
.use(initReactI18next)
|
||||
.use(LanguageDetector)
|
||||
.init({
|
||||
supportedLngs: ['en', 'fr', 'nl', 'de'],
|
||||
supportedLngs: ['en', 'fr', 'nl', 'de', 'es'],
|
||||
fallbackLng,
|
||||
ns: i18nDefaultNamespace,
|
||||
detection: {
|
||||
|
||||
@@ -5,6 +5,7 @@ const languageLabels: Record<string, string> = {
|
||||
fr: 'Français',
|
||||
de: 'Deutsch',
|
||||
nl: 'Nederlands',
|
||||
es: 'Español',
|
||||
}
|
||||
|
||||
export const useLanguageLabels = () => {
|
||||
|
||||
@@ -35,30 +35,21 @@ const LoginHint = () => {
|
||||
<div
|
||||
className={css({
|
||||
position: 'absolute',
|
||||
top: '103px',
|
||||
right: '110px',
|
||||
top: 'calc(100% + 12px)',
|
||||
right: 0,
|
||||
zIndex: '100',
|
||||
outline: 'none',
|
||||
padding: '1.25rem',
|
||||
maxWidth: '350px',
|
||||
width: 'max-content',
|
||||
maxWidth: 'min(350px, calc(100vw - 2rem))',
|
||||
boxShadow: '0 2px 5px rgba(0 0 0 / 0.1)',
|
||||
borderRadius: '1rem',
|
||||
backgroundColor: 'primary.200',
|
||||
display: 'none',
|
||||
xsm: {
|
||||
display: 'block',
|
||||
},
|
||||
sm: {
|
||||
top: '131px',
|
||||
right: '100px',
|
||||
zIndex: '100',
|
||||
},
|
||||
_after: {
|
||||
content: '""',
|
||||
position: 'absolute',
|
||||
top: '-10px',
|
||||
right: '20%',
|
||||
marginLeft: '-10px',
|
||||
right: '1.5rem',
|
||||
borderWidth: '0 10px 10px 10px',
|
||||
borderStyle: 'solid',
|
||||
borderColor: 'transparent transparent #E3E3FB transparent',
|
||||
@@ -171,12 +162,13 @@ export const Header = () => {
|
||||
<>
|
||||
<div
|
||||
className={css({
|
||||
position: 'relative',
|
||||
display: { base: 'none', xsm: 'block' },
|
||||
})}
|
||||
>
|
||||
<LoginButton proConnectHint={false} />
|
||||
<LoginHint />
|
||||
</div>
|
||||
<LoginHint />
|
||||
</>
|
||||
)}
|
||||
{!!user && (
|
||||
|
||||
@@ -435,7 +435,8 @@
|
||||
"tools": "Weitere Tools",
|
||||
"info": "Meeting-Informationen"
|
||||
},
|
||||
"closeButton": "{{content}} ausblenden"
|
||||
"closeButton": "{{content}} ausblenden (Escape)",
|
||||
"escapeHint": "Escape drücken zum Schließen"
|
||||
},
|
||||
"chat": {
|
||||
"disclaimer": "Die Nachrichten sind nur für Teilnehmende zum Zeitpunkt des Sendens sichtbar. Alle Nachrichten werden am Ende des Meetings gelöscht.",
|
||||
|
||||
@@ -56,6 +56,7 @@
|
||||
"publish": {
|
||||
"label": "Wähle die maximale Auflösung beim Senden",
|
||||
"items": {
|
||||
"veryHigh": "Sehr hohe Auflösung",
|
||||
"high": "Hohe Auflösung",
|
||||
"medium": "Mittlere Auflösung",
|
||||
"low": "Niedrige Auflösung"
|
||||
|
||||
@@ -435,7 +435,8 @@
|
||||
"tools": "more tools",
|
||||
"info": "meeting information"
|
||||
},
|
||||
"closeButton": "Hide {{content}}"
|
||||
"closeButton": "Hide {{content}} (Escape)",
|
||||
"escapeHint": "Press Escape to close"
|
||||
},
|
||||
"chat": {
|
||||
"disclaimer": "The messages are visible to participants only at the time they are sent. All messages are deleted at the end of the call.",
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user