mirror of
https://github.com/suitenumerique/meet.git
synced 2026-09-02 13:48:26 +00:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 0e2bd8e4b3 |
@@ -13,10 +13,10 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0
|
uses: actions/checkout@v6
|
||||||
|
|
||||||
- name: Download Crowdin files
|
- name: Download Crowdin files
|
||||||
uses: crowdin/github-action@c7af9bc98b01694653031fef2a0dc6c7888ce9bc # v2.17.0
|
uses: crowdin/github-action@v2
|
||||||
with:
|
with:
|
||||||
upload_sources: false
|
upload_sources: false
|
||||||
upload_translations: false
|
upload_translations: false
|
||||||
|
|||||||
@@ -30,36 +30,36 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
-
|
-
|
||||||
name: Checkout repository
|
name: Checkout repository
|
||||||
uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0
|
uses: actions/checkout@v6
|
||||||
-
|
-
|
||||||
name: Set up QEMU
|
name: Set up QEMU
|
||||||
if: env.IS_MULTI_PLATFORM_BUILD == 'true'
|
if: env.IS_MULTI_PLATFORM_BUILD == 'true'
|
||||||
uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 # v3.7.0
|
uses: docker/setup-qemu-action@v3
|
||||||
-
|
-
|
||||||
name: Set up Docker Buildx
|
name: Set up Docker Buildx
|
||||||
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0
|
uses: docker/setup-buildx-action@v3
|
||||||
-
|
-
|
||||||
name: Docker meta
|
name: Docker meta
|
||||||
id: meta
|
id: meta
|
||||||
uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5.10.0
|
uses: docker/metadata-action@v5
|
||||||
with:
|
with:
|
||||||
images: '${{ env.DOCKER_CONTAINER_REGISTRY_NAMESPACE }}/meet-backend'
|
images: '${{ env.DOCKER_CONTAINER_REGISTRY_NAMESPACE }}/meet-backend'
|
||||||
-
|
-
|
||||||
name: Login to DockerHub
|
name: Login to DockerHub
|
||||||
if: github.event_name != 'pull_request' || startsWith(github.head_ref, 'integration/')
|
if: github.event_name != 'pull_request' || startsWith(github.head_ref, 'integration/')
|
||||||
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
|
uses: docker/login-action@v3
|
||||||
with:
|
with:
|
||||||
username: ${{ secrets.DOCKER_HUB_USER }}
|
username: ${{ secrets.DOCKER_HUB_USER }}
|
||||||
password: ${{ secrets.DOCKER_HUB_PASSWORD }}
|
password: ${{ secrets.DOCKER_HUB_PASSWORD }}
|
||||||
-
|
-
|
||||||
name: Run trivy scan
|
name: Run trivy scan
|
||||||
uses: numerique-gouv/action-trivy-cache@d6e94cfb488f03a0b3e8b8739aad94e74d24d8da # main
|
uses: numerique-gouv/action-trivy-cache@main
|
||||||
with:
|
with:
|
||||||
docker-build-args: '--target backend-production -f Dockerfile'
|
docker-build-args: '--target backend-production -f Dockerfile'
|
||||||
docker-image-name: '${{ env.DOCKER_CONTAINER_REGISTRY_HOSTNAME }}/${{ env.DOCKER_CONTAINER_REGISTRY_NAMESPACE }}/meet-backend:${{ github.sha }}'
|
docker-image-name: '${{ env.DOCKER_CONTAINER_REGISTRY_HOSTNAME }}/${{ env.DOCKER_CONTAINER_REGISTRY_NAMESPACE }}/meet-backend:${{ github.sha }}'
|
||||||
-
|
-
|
||||||
name: Build and push
|
name: Build and push
|
||||||
uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6.19.2
|
uses: docker/build-push-action@v6
|
||||||
with:
|
with:
|
||||||
context: .
|
context: .
|
||||||
target: backend-production
|
target: backend-production
|
||||||
@@ -76,36 +76,36 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
-
|
-
|
||||||
name: Checkout repository
|
name: Checkout repository
|
||||||
uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0
|
uses: actions/checkout@v6
|
||||||
-
|
-
|
||||||
name: Set up QEMU
|
name: Set up QEMU
|
||||||
if: env.IS_MULTI_PLATFORM_BUILD == 'true'
|
if: env.IS_MULTI_PLATFORM_BUILD == 'true'
|
||||||
uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 # v3.7.0
|
uses: docker/setup-qemu-action@v3
|
||||||
-
|
-
|
||||||
name: Set up Docker Buildx
|
name: Set up Docker Buildx
|
||||||
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0
|
uses: docker/setup-buildx-action@v3
|
||||||
-
|
-
|
||||||
name: Docker meta
|
name: Docker meta
|
||||||
id: meta
|
id: meta
|
||||||
uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5.10.0
|
uses: docker/metadata-action@v5
|
||||||
with:
|
with:
|
||||||
images: '${{ env.DOCKER_CONTAINER_REGISTRY_NAMESPACE }}/meet-frontend'
|
images: '${{ env.DOCKER_CONTAINER_REGISTRY_NAMESPACE }}/meet-frontend'
|
||||||
-
|
-
|
||||||
name: Login to DockerHub
|
name: Login to DockerHub
|
||||||
if: github.event_name != 'pull_request' || startsWith(github.head_ref, 'integration/')
|
if: github.event_name != 'pull_request' || startsWith(github.head_ref, 'integration/')
|
||||||
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
|
uses: docker/login-action@v3
|
||||||
with:
|
with:
|
||||||
username: ${{ secrets.DOCKER_HUB_USER }}
|
username: ${{ secrets.DOCKER_HUB_USER }}
|
||||||
password: ${{ secrets.DOCKER_HUB_PASSWORD }}
|
password: ${{ secrets.DOCKER_HUB_PASSWORD }}
|
||||||
-
|
-
|
||||||
name: Run trivy scan
|
name: Run trivy scan
|
||||||
uses: numerique-gouv/action-trivy-cache@d6e94cfb488f03a0b3e8b8739aad94e74d24d8da # main
|
uses: numerique-gouv/action-trivy-cache@main
|
||||||
with:
|
with:
|
||||||
docker-build-args: '-f src/frontend/Dockerfile --target frontend-production'
|
docker-build-args: '-f src/frontend/Dockerfile --target frontend-production'
|
||||||
docker-image-name: '${{ env.DOCKER_CONTAINER_REGISTRY_HOSTNAME }}/${{ env.DOCKER_CONTAINER_REGISTRY_NAMESPACE }}/meet-frontend:${{ github.sha }}'
|
docker-image-name: '${{ env.DOCKER_CONTAINER_REGISTRY_HOSTNAME }}/${{ env.DOCKER_CONTAINER_REGISTRY_NAMESPACE }}/meet-frontend:${{ github.sha }}'
|
||||||
-
|
-
|
||||||
name: Build and push
|
name: Build and push
|
||||||
uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6.19.2
|
uses: docker/build-push-action@v6
|
||||||
with:
|
with:
|
||||||
context: .
|
context: .
|
||||||
file: ./src/frontend/Dockerfile
|
file: ./src/frontend/Dockerfile
|
||||||
@@ -123,36 +123,36 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
-
|
-
|
||||||
name: Checkout repository
|
name: Checkout repository
|
||||||
uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0
|
uses: actions/checkout@v6
|
||||||
-
|
-
|
||||||
name: Set up QEMU
|
name: Set up QEMU
|
||||||
if: env.IS_MULTI_PLATFORM_BUILD == 'true'
|
if: env.IS_MULTI_PLATFORM_BUILD == 'true'
|
||||||
uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 # v3.7.0
|
uses: docker/setup-qemu-action@v3
|
||||||
-
|
-
|
||||||
name: Set up Docker Buildx
|
name: Set up Docker Buildx
|
||||||
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0
|
uses: docker/setup-buildx-action@v3
|
||||||
-
|
-
|
||||||
name: Docker meta
|
name: Docker meta
|
||||||
id: meta
|
id: meta
|
||||||
uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5.10.0
|
uses: docker/metadata-action@v5
|
||||||
with:
|
with:
|
||||||
images: '${{ env.DOCKER_CONTAINER_REGISTRY_NAMESPACE }}/meet-frontend-dinum'
|
images: '${{ env.DOCKER_CONTAINER_REGISTRY_NAMESPACE }}/meet-frontend-dinum'
|
||||||
-
|
-
|
||||||
name: Login to DockerHub
|
name: Login to DockerHub
|
||||||
if: github.event_name != 'pull_request' || startsWith(github.head_ref, 'integration/')
|
if: github.event_name != 'pull_request' || startsWith(github.head_ref, 'integration/')
|
||||||
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
|
uses: docker/login-action@v3
|
||||||
with:
|
with:
|
||||||
username: ${{ secrets.DOCKER_HUB_USER }}
|
username: ${{ secrets.DOCKER_HUB_USER }}
|
||||||
password: ${{ secrets.DOCKER_HUB_PASSWORD }}
|
password: ${{ secrets.DOCKER_HUB_PASSWORD }}
|
||||||
-
|
-
|
||||||
name: Run trivy scan
|
name: Run trivy scan
|
||||||
uses: numerique-gouv/action-trivy-cache@d6e94cfb488f03a0b3e8b8739aad94e74d24d8da # main
|
uses: numerique-gouv/action-trivy-cache@main
|
||||||
with:
|
with:
|
||||||
docker-build-args: '-f docker/dinum-frontend/Dockerfile --target frontend-production'
|
docker-build-args: '-f docker/dinum-frontend/Dockerfile --target frontend-production'
|
||||||
docker-image-name: '${{ env.DOCKER_CONTAINER_REGISTRY_HOSTNAME }}/${{ env.DOCKER_CONTAINER_REGISTRY_NAMESPACE }}/meet-frontend-dinum:${{ github.sha }}'
|
docker-image-name: '${{ env.DOCKER_CONTAINER_REGISTRY_HOSTNAME }}/${{ env.DOCKER_CONTAINER_REGISTRY_NAMESPACE }}/meet-frontend-dinum:${{ github.sha }}'
|
||||||
-
|
-
|
||||||
name: Build and push
|
name: Build and push
|
||||||
uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6.19.2
|
uses: docker/build-push-action@v6
|
||||||
with:
|
with:
|
||||||
context: .
|
context: .
|
||||||
file: ./docker/dinum-frontend/Dockerfile
|
file: ./docker/dinum-frontend/Dockerfile
|
||||||
@@ -170,30 +170,30 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
-
|
-
|
||||||
name: Checkout repository
|
name: Checkout repository
|
||||||
uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0
|
uses: actions/checkout@v6
|
||||||
-
|
-
|
||||||
name: Set up QEMU
|
name: Set up QEMU
|
||||||
if: env.IS_MULTI_PLATFORM_BUILD == 'true'
|
if: env.IS_MULTI_PLATFORM_BUILD == 'true'
|
||||||
uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 # v3.7.0
|
uses: docker/setup-qemu-action@v3
|
||||||
-
|
-
|
||||||
name: Set up Docker Buildx
|
name: Set up Docker Buildx
|
||||||
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0
|
uses: docker/setup-buildx-action@v3
|
||||||
-
|
-
|
||||||
name: Docker meta
|
name: Docker meta
|
||||||
id: meta
|
id: meta
|
||||||
uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5.10.0
|
uses: docker/metadata-action@v5
|
||||||
with:
|
with:
|
||||||
images: '${{ env.DOCKER_CONTAINER_REGISTRY_NAMESPACE }}/meet-summary'
|
images: '${{ env.DOCKER_CONTAINER_REGISTRY_NAMESPACE }}/meet-summary'
|
||||||
-
|
-
|
||||||
name: Login to DockerHub
|
name: Login to DockerHub
|
||||||
if: github.event_name != 'pull_request' || startsWith(github.head_ref, 'integration/')
|
if: github.event_name != 'pull_request' || startsWith(github.head_ref, 'integration/')
|
||||||
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
|
uses: docker/login-action@v3
|
||||||
with:
|
with:
|
||||||
username: ${{ secrets.DOCKER_HUB_USER }}
|
username: ${{ secrets.DOCKER_HUB_USER }}
|
||||||
password: ${{ secrets.DOCKER_HUB_PASSWORD }}
|
password: ${{ secrets.DOCKER_HUB_PASSWORD }}
|
||||||
-
|
-
|
||||||
name: Run trivy scan
|
name: Run trivy scan
|
||||||
uses: numerique-gouv/action-trivy-cache@d6e94cfb488f03a0b3e8b8739aad94e74d24d8da # main
|
uses: numerique-gouv/action-trivy-cache@main
|
||||||
continue-on-error: true
|
continue-on-error: true
|
||||||
with:
|
with:
|
||||||
docker-build-args: '-f src/summary/Dockerfile --target production'
|
docker-build-args: '-f src/summary/Dockerfile --target production'
|
||||||
@@ -201,7 +201,7 @@ jobs:
|
|||||||
docker-context: './src/summary'
|
docker-context: './src/summary'
|
||||||
-
|
-
|
||||||
name: Build and push
|
name: Build and push
|
||||||
uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6.19.2
|
uses: docker/build-push-action@v6
|
||||||
with:
|
with:
|
||||||
context: ./src/summary
|
context: ./src/summary
|
||||||
file: ./src/summary/Dockerfile
|
file: ./src/summary/Dockerfile
|
||||||
@@ -219,30 +219,30 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
-
|
-
|
||||||
name: Checkout repository
|
name: Checkout repository
|
||||||
uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0
|
uses: actions/checkout@v6
|
||||||
-
|
-
|
||||||
name: Set up QEMU
|
name: Set up QEMU
|
||||||
if: env.IS_MULTI_PLATFORM_BUILD == 'true'
|
if: env.IS_MULTI_PLATFORM_BUILD == 'true'
|
||||||
uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 # v3.7.0
|
uses: docker/setup-qemu-action@v3
|
||||||
-
|
-
|
||||||
name: Set up Docker Buildx
|
name: Set up Docker Buildx
|
||||||
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0
|
uses: docker/setup-buildx-action@v3
|
||||||
-
|
-
|
||||||
name: Docker meta
|
name: Docker meta
|
||||||
id: meta
|
id: meta
|
||||||
uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5.10.0
|
uses: docker/metadata-action@v5
|
||||||
with:
|
with:
|
||||||
images: lasuite/meet-agents
|
images: lasuite/meet-agents
|
||||||
-
|
-
|
||||||
name: Login to DockerHub
|
name: Login to DockerHub
|
||||||
if: github.event_name != 'pull_request' || startsWith(github.head_ref, 'integration/')
|
if: github.event_name != 'pull_request' || startsWith(github.head_ref, 'integration/')
|
||||||
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
|
uses: docker/login-action@v3
|
||||||
with:
|
with:
|
||||||
username: ${{ secrets.DOCKER_HUB_USER }}
|
username: ${{ secrets.DOCKER_HUB_USER }}
|
||||||
password: ${{ secrets.DOCKER_HUB_PASSWORD }}
|
password: ${{ secrets.DOCKER_HUB_PASSWORD }}
|
||||||
-
|
-
|
||||||
name: Run trivy scan
|
name: Run trivy scan
|
||||||
uses: numerique-gouv/action-trivy-cache@d6e94cfb488f03a0b3e8b8739aad94e74d24d8da # main
|
uses: numerique-gouv/action-trivy-cache@main
|
||||||
continue-on-error: true
|
continue-on-error: true
|
||||||
with:
|
with:
|
||||||
docker-build-args: '-f src/agents/Dockerfile --target production'
|
docker-build-args: '-f src/agents/Dockerfile --target production'
|
||||||
@@ -250,7 +250,7 @@ jobs:
|
|||||||
docker-context: './src/agents'
|
docker-context: './src/agents'
|
||||||
-
|
-
|
||||||
name: Build and push
|
name: Build and push
|
||||||
uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6.19.2
|
uses: docker/build-push-action@v6
|
||||||
with:
|
with:
|
||||||
context: ./src/agents
|
context: ./src/agents
|
||||||
file: ./src/agents/Dockerfile
|
file: ./src/agents/Dockerfile
|
||||||
@@ -273,7 +273,7 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
if: github.event_name != 'pull_request'
|
if: github.event_name != 'pull_request'
|
||||||
steps:
|
steps:
|
||||||
- uses: numerique-gouv/action-argocd-webhook-notification@cac2ee67896eb13e84e804f60c4271370424eaa8 # main
|
- uses: numerique-gouv/action-argocd-webhook-notification@main
|
||||||
id: notify
|
id: notify
|
||||||
with:
|
with:
|
||||||
deployment_repo_path: "${{ secrets.DEPLOYMENT_REPO_URL }}"
|
deployment_repo_path: "${{ secrets.DEPLOYMENT_REPO_URL }}"
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
name: CI
|
name: meet Workflow
|
||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
@@ -18,7 +18,7 @@ jobs:
|
|||||||
contents: read
|
contents: read
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0
|
uses: actions/checkout@v6
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
- name: show
|
- name: show
|
||||||
@@ -26,17 +26,17 @@ jobs:
|
|||||||
- name: Enforce absence of print statements in code
|
- name: Enforce absence of print statements in code
|
||||||
if: always()
|
if: always()
|
||||||
run: |
|
run: |
|
||||||
! git diff origin/${{ github.event.pull_request.base.ref }}..HEAD -- . ':(exclude).github/workflows/**' | grep "print("
|
! git diff origin/${{ github.event.pull_request.base.ref }}..HEAD -- . ':(exclude)**/meet.yml' | grep "print("
|
||||||
- name: Check absence of fixup commits
|
- name: Check absence of fixup commits
|
||||||
if: always()
|
if: always()
|
||||||
run: |
|
run: |
|
||||||
! git log | grep 'fixup!'
|
! git log | grep 'fixup!'
|
||||||
- name: Install uv
|
- name: Install gitlint
|
||||||
if: always()
|
if: always()
|
||||||
uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7.6.0
|
run: pip install --user requests gitlint
|
||||||
- name: Lint commit messages added to main
|
- name: Lint commit messages added to main
|
||||||
if: always()
|
if: always()
|
||||||
run: uvx --no-build --from gitlint-core==0.19.1 gitlint --commits origin/${{ github.event.pull_request.base.ref }}..HEAD
|
run: ~/.local/bin/gitlint --commits origin/${{ github.event.pull_request.base.ref }}..HEAD
|
||||||
|
|
||||||
check-changelog:
|
check-changelog:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
@@ -47,7 +47,7 @@ jobs:
|
|||||||
contents: read
|
contents: read
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0
|
uses: actions/checkout@v6
|
||||||
with:
|
with:
|
||||||
fetch-depth: 50
|
fetch-depth: 50
|
||||||
- name: Check that the CHANGELOG has been modified in the current branch
|
- name: Check that the CHANGELOG has been modified in the current branch
|
||||||
@@ -59,7 +59,7 @@ jobs:
|
|||||||
contents: read
|
contents: read
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0
|
uses: actions/checkout@v6
|
||||||
- name: Check CHANGELOG max line length
|
- name: Check CHANGELOG max line length
|
||||||
run: |
|
run: |
|
||||||
max_line_length=$(cat CHANGELOG.md | grep -Ev "^\[.*\]: https://github.com" | wc -L)
|
max_line_length=$(cat CHANGELOG.md | grep -Ev "^\[.*\]: https://github.com" | wc -L)
|
||||||
@@ -77,15 +77,15 @@ jobs:
|
|||||||
working-directory: src/mail
|
working-directory: src/mail
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0
|
uses: actions/checkout@v6
|
||||||
|
|
||||||
- name: Install Node.js
|
- name: Install Node.js
|
||||||
uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6.5.0
|
uses: actions/setup-node@v6
|
||||||
with:
|
with:
|
||||||
node-version: "22"
|
node-version: "22"
|
||||||
|
|
||||||
- name: Restore the mail templates
|
- name: Restore the mail templates
|
||||||
uses: actions/cache@caa296126883cff596d87d8935842f9db880ef25 # v5.1.0
|
uses: actions/cache@v5
|
||||||
id: mail-templates
|
id: mail-templates
|
||||||
with:
|
with:
|
||||||
path: "src/backend/core/templates/mail"
|
path: "src/backend/core/templates/mail"
|
||||||
@@ -93,11 +93,11 @@ jobs:
|
|||||||
|
|
||||||
- name: Install yarn
|
- name: Install yarn
|
||||||
if: steps.mail-templates.outputs.cache-hit != 'true'
|
if: steps.mail-templates.outputs.cache-hit != 'true'
|
||||||
run: npm install -g --ignore-scripts yarn@1.22.22
|
run: npm install -g yarn
|
||||||
|
|
||||||
- name: Install node dependencies
|
- name: Install node dependencies
|
||||||
if: steps.mail-templates.outputs.cache-hit != 'true'
|
if: steps.mail-templates.outputs.cache-hit != 'true'
|
||||||
run: yarn install --frozen-lockfile --ignore-scripts
|
run: yarn install --frozen-lockfile
|
||||||
|
|
||||||
- name: Build mails
|
- name: Build mails
|
||||||
if: steps.mail-templates.outputs.cache-hit != 'true'
|
if: steps.mail-templates.outputs.cache-hit != 'true'
|
||||||
@@ -105,7 +105,7 @@ jobs:
|
|||||||
|
|
||||||
- name: Cache mail templates
|
- name: Cache mail templates
|
||||||
if: steps.mail-templates.outputs.cache-hit != 'true'
|
if: steps.mail-templates.outputs.cache-hit != 'true'
|
||||||
uses: actions/cache@caa296126883cff596d87d8935842f9db880ef25 # v5.1.0
|
uses: actions/cache@v5
|
||||||
with:
|
with:
|
||||||
path: "src/backend/core/templates/mail"
|
path: "src/backend/core/templates/mail"
|
||||||
key: mail-templates-${{ hashFiles('src/mail/mjml') }}
|
key: mail-templates-${{ hashFiles('src/mail/mjml') }}
|
||||||
@@ -119,22 +119,22 @@ jobs:
|
|||||||
working-directory: src/backend
|
working-directory: src/backend
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0
|
uses: actions/checkout@v6
|
||||||
- name: Install Python
|
- name: Install Python
|
||||||
uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0
|
uses: actions/setup-python@v6
|
||||||
with:
|
with:
|
||||||
python-version: "3.13"
|
python-version: "3.13"
|
||||||
- name: Install uv
|
- name: Install uv
|
||||||
uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7.6.0
|
uses: astral-sh/setup-uv@v7
|
||||||
- name: Install the project
|
- name: Install the project
|
||||||
run: uv sync --locked --all-extras
|
run: uv sync --locked --all-extras
|
||||||
|
|
||||||
- name: Check code formatting with ruff
|
- name: Check code formatting with ruff
|
||||||
run: uv run --no-sync --no-build ruff format . --diff
|
run: uv run ruff format . --diff
|
||||||
- name: Lint code with ruff
|
- name: Lint code with ruff
|
||||||
run: uv run --no-sync --no-build ruff check .
|
run: uv run ruff check .
|
||||||
- name: Lint code with pylint
|
- name: Lint code with pylint
|
||||||
run: uv run --no-sync --no-build pylint meet demo core
|
run: uv run pylint meet demo core
|
||||||
|
|
||||||
lint-agents:
|
lint-agents:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
@@ -145,19 +145,19 @@ jobs:
|
|||||||
working-directory: src/agents
|
working-directory: src/agents
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0
|
uses: actions/checkout@v6
|
||||||
- name: Install Python
|
- name: Install Python
|
||||||
uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0
|
uses: actions/setup-python@v6
|
||||||
with:
|
with:
|
||||||
python-version: "3.13"
|
python-version: "3.13"
|
||||||
- name: Install uv
|
- name: Install uv
|
||||||
uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7.6.0
|
uses: astral-sh/setup-uv@v7
|
||||||
- name: Install the project
|
- name: Install the project
|
||||||
run: uv sync --locked --all-extras --no-build
|
run: uv sync --locked --all-extras
|
||||||
- name: Check code formatting with ruff
|
- name: Check code formatting with ruff
|
||||||
run: uv run --no-sync --no-build ruff format . --diff
|
run: uv run ruff format . --diff
|
||||||
- name: Lint code with ruff
|
- name: Lint code with ruff
|
||||||
run: uv run --no-sync --no-build ruff check .
|
run: uv run ruff check .
|
||||||
|
|
||||||
lint-summary:
|
lint-summary:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
@@ -168,19 +168,18 @@ jobs:
|
|||||||
working-directory: src/summary
|
working-directory: src/summary
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0
|
uses: actions/checkout@v6
|
||||||
- name: Install Python
|
- name: Install Python
|
||||||
uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0
|
uses: actions/setup-python@v6
|
||||||
with:
|
with:
|
||||||
python-version: "3.13"
|
python-version: "3.13"
|
||||||
- name: Install uv
|
cache: "pip"
|
||||||
uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7.6.0
|
- name: Install development dependencies
|
||||||
- name: Install the project
|
run: pip install --user .[dev]
|
||||||
run: uv sync --locked --all-extras
|
|
||||||
- name: Check code formatting with ruff
|
- name: Check code formatting with ruff
|
||||||
run: uv run --no-sync --no-build ruff format . --diff
|
run: ~/.local/bin/ruff format . --diff
|
||||||
- name: Lint code with ruff
|
- name: Lint code with ruff
|
||||||
run: uv run --no-sync --no-build ruff check .
|
run: ~/.local/bin/ruff check .
|
||||||
|
|
||||||
test-back:
|
test-back:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
@@ -236,7 +235,7 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0
|
uses: actions/checkout@v6
|
||||||
|
|
||||||
- name: Create writable /data
|
- name: Create writable /data
|
||||||
run: |
|
run: |
|
||||||
@@ -244,7 +243,7 @@ jobs:
|
|||||||
sudo mkdir -p /data/static
|
sudo mkdir -p /data/static
|
||||||
|
|
||||||
- name: Restore the mail templates
|
- name: Restore the mail templates
|
||||||
uses: actions/cache@caa296126883cff596d87d8935842f9db880ef25 # v5.1.0
|
uses: actions/cache@v5
|
||||||
id: mail-templates
|
id: mail-templates
|
||||||
with:
|
with:
|
||||||
path: "src/backend/core/templates/mail"
|
path: "src/backend/core/templates/mail"
|
||||||
@@ -263,9 +262,7 @@ jobs:
|
|||||||
# Tool to wait for a service to be ready
|
# Tool to wait for a service to be ready
|
||||||
- name: Install Dockerize
|
- name: Install Dockerize
|
||||||
run: |
|
run: |
|
||||||
curl --proto "=https" --proto-redir "=https" --tlsv1.2 -sSLf \
|
curl -sSL https://github.com/jwilder/dockerize/releases/download/v0.8.0/dockerize-linux-amd64-v0.8.0.tar.gz | sudo tar -C /usr/local/bin -xzv
|
||||||
https://github.com/jwilder/dockerize/releases/download/v0.8.0/dockerize-linux-amd64-v0.8.0.tar.gz |
|
|
||||||
sudo tar -C /usr/local/bin -xzv
|
|
||||||
|
|
||||||
- name: Wait for MinIO to be ready
|
- name: Wait for MinIO to be ready
|
||||||
run: |
|
run: |
|
||||||
@@ -280,11 +277,11 @@ jobs:
|
|||||||
mc mb meet/meet-media-storage"
|
mc mb meet/meet-media-storage"
|
||||||
|
|
||||||
- name: Install Python
|
- name: Install Python
|
||||||
uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0
|
uses: actions/setup-python@v6
|
||||||
with:
|
with:
|
||||||
python-version: "3.13"
|
python-version: "3.13"
|
||||||
- name: Install uv
|
- name: Install uv
|
||||||
uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7.6.0
|
uses: astral-sh/setup-uv@v7
|
||||||
- name: Install the dependencies
|
- name: Install the dependencies
|
||||||
run: uv sync --locked --all-extras
|
run: uv sync --locked --all-extras
|
||||||
|
|
||||||
@@ -294,10 +291,10 @@ jobs:
|
|||||||
sudo apt-get install -y gettext
|
sudo apt-get install -y gettext
|
||||||
|
|
||||||
- name: Generate a MO file from strings extracted from the project
|
- name: Generate a MO file from strings extracted from the project
|
||||||
run: uv run --no-sync --no-build python manage.py compilemessages
|
run: uv run python manage.py compilemessages
|
||||||
|
|
||||||
- name: Run tests
|
- name: Run tests
|
||||||
run: uv run --no-sync --no-build pytest -n 2
|
run: uv run pytest -n 2
|
||||||
|
|
||||||
test-summary:
|
test-summary:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
@@ -323,7 +320,7 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0
|
uses: actions/checkout@v6
|
||||||
|
|
||||||
- name: Install ffmpeg
|
- name: Install ffmpeg
|
||||||
run: |
|
run: |
|
||||||
@@ -331,18 +328,16 @@ jobs:
|
|||||||
sudo apt-get install -y ffmpeg
|
sudo apt-get install -y ffmpeg
|
||||||
|
|
||||||
- name: Install Python
|
- name: Install Python
|
||||||
uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0
|
uses: actions/setup-python@v6
|
||||||
with:
|
with:
|
||||||
python-version: "3.13"
|
python-version: "3.13"
|
||||||
|
cache: "pip"
|
||||||
|
|
||||||
- name: Install uv
|
- name: Install development dependencies
|
||||||
uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7.6.0
|
run: pip install --user .[dev]
|
||||||
|
|
||||||
- name: Install the project
|
|
||||||
run: uv sync --locked --all-extras
|
|
||||||
|
|
||||||
- name: Run summary tests
|
- name: Run summary tests
|
||||||
run: uv run --no-sync --no-build pytest
|
run: ~/.local/bin/pytest
|
||||||
|
|
||||||
lint-front:
|
lint-front:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
@@ -350,10 +345,10 @@ jobs:
|
|||||||
contents: read
|
contents: read
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0
|
uses: actions/checkout@v6
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: cd src/frontend/ && npm ci --ignore-scripts
|
run: cd src/frontend/ && npm ci
|
||||||
|
|
||||||
- name: Check linting
|
- name: Check linting
|
||||||
run: cd src/frontend/ && npm run lint
|
run: cd src/frontend/ && npm run lint
|
||||||
@@ -370,10 +365,10 @@ jobs:
|
|||||||
working-directory: src/sdk/library
|
working-directory: src/sdk/library
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0
|
uses: actions/checkout@v6
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: npm ci --ignore-scripts
|
run: npm ci
|
||||||
|
|
||||||
- name: Check linting
|
- name: Check linting
|
||||||
run: npm run lint
|
run: npm run lint
|
||||||
@@ -391,10 +386,10 @@ jobs:
|
|||||||
working-directory: src/sdk/library
|
working-directory: src/sdk/library
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0
|
uses: actions/checkout@v6
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: npm ci --ignore-scripts
|
run: npm ci
|
||||||
|
|
||||||
- name: Build SDK
|
- name: Build SDK
|
||||||
run: npm run build
|
run: npm run build
|
||||||
@@ -13,7 +13,7 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0
|
uses: actions/checkout@v6
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
|
|
||||||
@@ -21,12 +21,12 @@ jobs:
|
|||||||
run: rm -rf ./src/helm/extra
|
run: rm -rf ./src/helm/extra
|
||||||
|
|
||||||
- name: Install Helm
|
- name: Install Helm
|
||||||
uses: azure/setup-helm@1a275c3b69536ee54be43f2070a358922e12c8d4 # v4.3.1
|
uses: azure/setup-helm@v4
|
||||||
env:
|
env:
|
||||||
GITHUB_TOKEN: "${{ secrets.GITHUB_TOKEN }}"
|
GITHUB_TOKEN: "${{ secrets.GITHUB_TOKEN }}"
|
||||||
|
|
||||||
- name: Publish Helm charts
|
- name: Publish Helm charts
|
||||||
uses: numerique-gouv/helm-gh-pages@2cf477ae49d7c70037ceb1685803f4f7bad9b981 # add-overwrite-option
|
uses: numerique-gouv/helm-gh-pages@add-overwrite-option
|
||||||
with:
|
with:
|
||||||
charts_dir: ./src/helm
|
charts_dir: ./src/helm
|
||||||
linting: on
|
linting: on
|
||||||
|
|||||||
+2
-37
@@ -8,38 +8,9 @@ and this project adheres to
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
### Fixed
|
|
||||||
|
|
||||||
- 🐛(frontend) keep the sending resolution picked while the camera is off #1667
|
|
||||||
|
|
||||||
## [1.30.0] - 2026-09-01
|
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|
||||||
- ✨(agent) support Voxtral realtime as inference engine
|
- ✨(backend) update a room's access level and configuration from the external API
|
||||||
- 🌐(i18n) add Spanish language support
|
|
||||||
- ✨(frontend) expose publish permissions on the media state element #1661
|
|
||||||
|
|
||||||
### Changed
|
|
||||||
|
|
||||||
- 🔥(backend) remove the S3 storage-event webhook for recordings
|
|
||||||
- ♻️(backend) always finalize recordings using the LiveKit egress_ended webhook
|
|
||||||
- ⬆️(frontend) upgrade posthog-js from 1.409.5 to 1.414.0
|
|
||||||
- ⬆️(frontend) upgrade @fontsource-variable/lexend from 5.2.11 to 5.3.0
|
|
||||||
- ⬆️(frontend) upgrade @fontsource/opendyslexic from 5.2.5 to 5.3.0
|
|
||||||
- ⬆️(addons) upgrade core-js from 3.49.0 to 3.50.0
|
|
||||||
- ♻️(backend) factorize s3 client creation in utils
|
|
||||||
- ♿️(frontend) close side panel with Escape key #1507
|
|
||||||
|
|
||||||
### Fixed
|
|
||||||
|
|
||||||
- 🐛(frontend) fix chat text-area bug
|
|
||||||
|
|
||||||
## [1.29.0] - 2026-08-25
|
|
||||||
|
|
||||||
### Added
|
|
||||||
|
|
||||||
- ✨(any) let any authenticated user manage the lobby on trusted rooms
|
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
|
|
||||||
@@ -51,9 +22,6 @@ and this project adheres to
|
|||||||
- ⬆️(frontend) upgrade @tanstack/react-query from 5.101.1 to 5.101.4
|
- ⬆️(frontend) upgrade @tanstack/react-query from 5.101.1 to 5.101.4
|
||||||
- ⬆️(frontend) upgrade @pandacss/preset-panda from 1.11.3 to 1.12.0
|
- ⬆️(frontend) upgrade @pandacss/preset-panda from 1.11.3 to 1.12.0
|
||||||
- ⬆️(frontend) upgrade posthog-js from 1.404.1 to 1.409.5
|
- ⬆️(frontend) upgrade posthog-js from 1.404.1 to 1.409.5
|
||||||
- ⚡️(frontend) apply frugal constraint to the active meeting audio track
|
|
||||||
- ⚡️(backend) replace blocking Redis KEYS with cursor-based SCAN
|
|
||||||
- ✨(summary) add hostname to analytics properties
|
|
||||||
|
|
||||||
## [1.28.0] - 2026-08-24
|
## [1.28.0] - 2026-08-24
|
||||||
|
|
||||||
@@ -92,6 +60,7 @@ and this project adheres to
|
|||||||
|
|
||||||
- 🔥(frontend) drop unused vendored ConnectionObserver
|
- 🔥(frontend) drop unused vendored ConnectionObserver
|
||||||
- 🐛(frontend) vendor formatChatMessageLinks and trim surrounding newlines
|
- 🐛(frontend) vendor formatChatMessageLinks and trim surrounding newlines
|
||||||
|
- ✨(summary) add hostname to analytics properties
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|
||||||
@@ -222,10 +191,6 @@ and this project adheres to
|
|||||||
- ♿️(frontend) focus side panel container on open #1452
|
- ♿️(frontend) focus side panel container on open #1452
|
||||||
- 🐛(summary) whisper call error handling
|
- 🐛(summary) whisper call error handling
|
||||||
|
|
||||||
### Added
|
|
||||||
|
|
||||||
- ✨(frontend) add screen share zoom controls #1498
|
|
||||||
|
|
||||||
## [1.23.0] - 2026-07-08
|
## [1.23.0] - 2026-07-08
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
@@ -44,9 +44,7 @@ COMPOSE_EXEC = $(COMPOSE) exec
|
|||||||
COMPOSE_EXEC_APP = $(COMPOSE_EXEC) app-dev
|
COMPOSE_EXEC_APP = $(COMPOSE_EXEC) app-dev
|
||||||
COMPOSE_RUN = $(COMPOSE) run --rm
|
COMPOSE_RUN = $(COMPOSE) run --rm
|
||||||
COMPOSE_RUN_APP = $(COMPOSE_RUN) app-dev
|
COMPOSE_RUN_APP = $(COMPOSE_RUN) app-dev
|
||||||
COMPOSE_RUN_LINT_BACK = $(COMPOSE_RUN) --no-deps app-dev
|
COMPOSE_RUN_LINT = $(COMPOSE_RUN) --no-deps app-dev
|
||||||
COMPOSE_RUN_LINT_AGENTS = $(COMPOSE_RUN) --no-deps multi-user-transcriber-dev
|
|
||||||
COMPOSE_RUN_LINT_SUMMARY = $(COMPOSE_RUN) --no-deps app-summary-dev
|
|
||||||
COMPOSE_RUN_CROWDIN = $(COMPOSE_RUN) crowdin crowdin
|
COMPOSE_RUN_CROWDIN = $(COMPOSE_RUN) crowdin crowdin
|
||||||
WAIT_DB = @$(COMPOSE_RUN) dockerize -wait tcp://$(DB_HOST):$(DB_PORT) -timeout 60s
|
WAIT_DB = @$(COMPOSE_RUN) dockerize -wait tcp://$(DB_HOST):$(DB_PORT) -timeout 60s
|
||||||
|
|
||||||
@@ -61,10 +59,6 @@ LINT_PYLINT = pylint meet demo core
|
|||||||
LINT_BACK = echo 'lint:ruff-format started…' && $(LINT_RUFF_FORMAT) \
|
LINT_BACK = echo 'lint:ruff-format started…' && $(LINT_RUFF_FORMAT) \
|
||||||
&& echo 'lint:ruff-check started…' && $(LINT_RUFF_CHECK) \
|
&& echo 'lint:ruff-check started…' && $(LINT_RUFF_CHECK) \
|
||||||
&& echo 'lint:pylint started…' && $(LINT_PYLINT)
|
&& echo 'lint:pylint started…' && $(LINT_PYLINT)
|
||||||
LINT_AGENTS = echo 'lint:ruff-format started…' && $(LINT_RUFF_FORMAT) \
|
|
||||||
&& echo 'lint:ruff-check started…' && $(LINT_RUFF_CHECK)
|
|
||||||
LINT_SUMMARY = echo 'lint:ruff-format started…' && $(LINT_RUFF_FORMAT) \
|
|
||||||
&& echo 'lint:ruff-check started…' && $(LINT_RUFF_CHECK)
|
|
||||||
|
|
||||||
# -- Frontend
|
# -- Frontend
|
||||||
PATH_FRONT = ./src/frontend
|
PATH_FRONT = ./src/frontend
|
||||||
@@ -204,37 +198,23 @@ demo: ## flush db then create a demo for load testing purpose
|
|||||||
@$(MANAGE) create_demo
|
@$(MANAGE) create_demo
|
||||||
.PHONY: demo
|
.PHONY: demo
|
||||||
|
|
||||||
lint: ## lint all python sources (back-end, agents, summary)
|
lint: ## lint back-end python sources
|
||||||
@$(MAKE) lint-back
|
@$(COMPOSE_RUN_LINT) sh -c "$(LINT_BACK)"
|
||||||
@$(MAKE) lint-agents
|
|
||||||
@$(MAKE) lint-summary
|
|
||||||
.PHONY: lint
|
.PHONY: lint
|
||||||
|
|
||||||
lint-back: ## lint back-end python sources
|
|
||||||
@$(COMPOSE_RUN_LINT_BACK) sh -c "$(LINT_BACK)"
|
|
||||||
.PHONY: lint-back
|
|
||||||
|
|
||||||
lint-agents: ## lint agents python sources
|
|
||||||
@$(COMPOSE_RUN_LINT_AGENTS) sh -c "$(LINT_AGENTS)"
|
|
||||||
.PHONY: lint-agents
|
|
||||||
|
|
||||||
lint-summary: ## lint summary python sources
|
|
||||||
@$(COMPOSE_RUN_LINT_SUMMARY) sh -c "$(LINT_SUMMARY)"
|
|
||||||
.PHONY: lint-summary
|
|
||||||
|
|
||||||
lint-ruff-format: ## format back-end python sources with ruff
|
lint-ruff-format: ## format back-end python sources with ruff
|
||||||
@echo 'lint:ruff-format started…'
|
@echo 'lint:ruff-format started…'
|
||||||
@$(COMPOSE_RUN_LINT_BACK) $(LINT_RUFF_FORMAT)
|
@$(COMPOSE_RUN_LINT) $(LINT_RUFF_FORMAT)
|
||||||
.PHONY: lint-ruff-format
|
.PHONY: lint-ruff-format
|
||||||
|
|
||||||
lint-ruff-check: ## lint back-end python sources with ruff
|
lint-ruff-check: ## lint back-end python sources with ruff
|
||||||
@echo 'lint:ruff-check started…'
|
@echo 'lint:ruff-check started…'
|
||||||
@$(COMPOSE_RUN_LINT_BACK) $(LINT_RUFF_CHECK)
|
@$(COMPOSE_RUN_LINT) $(LINT_RUFF_CHECK)
|
||||||
.PHONY: lint-ruff-check
|
.PHONY: lint-ruff-check
|
||||||
|
|
||||||
lint-pylint: ## lint back-end python sources with pylint only on changed files from main
|
lint-pylint: ## lint back-end python sources with pylint only on changed files from main
|
||||||
@echo 'lint:pylint started…'
|
@echo 'lint:pylint started…'
|
||||||
@$(COMPOSE_RUN_LINT_BACK) $(LINT_PYLINT)
|
@$(COMPOSE_RUN_LINT) $(LINT_PYLINT)
|
||||||
.PHONY: lint-pylint
|
.PHONY: lint-pylint
|
||||||
|
|
||||||
test: ## run project tests; pass extra pytest args via ARGS, e.g. `make test ARGS="-vv"`
|
test: ## run project tests; pass extra pytest args via ARGS, e.g. `make test ARGS="-vv"`
|
||||||
|
|||||||
-19
@@ -16,25 +16,6 @@ the following command inside your docker container:
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
## v1.30.0
|
|
||||||
|
|
||||||
### Removing S3 storage-event webhooks for recordings
|
|
||||||
|
|
||||||
Recordings were previously confirmed as saved by an S3 storage-event webhook posting to `/api/v1.0/recordings/storage-hook/`. That endpoint has been removed: recordings are now always finalized from LiveKit's own `egress_ended` webhook, which has been the default path since v1.22.0.
|
|
||||||
|
|
||||||
**Required for every deployment:** LiveKit must be able to deliver webhooks to the backend at `/api/v1.0/rooms/webhooks-livekit/`. This is now the only way a recording reaches a saved state; if `egress_ended` is never delivered, recordings stay in the `active` state.
|
|
||||||
|
|
||||||
For hosters who had configured storage-event webhooks:
|
|
||||||
- Recordings reach the same final state, but they are now finalized when LiveKit reports the egress as ended rather than when the storage backend reports the upload.
|
|
||||||
- Remove the event notification from your bucket configuration: it now targets a non-existent endpoint and will fail on every delivery.
|
|
||||||
|
|
||||||
For hosters who had **not** configured storage-event webhooks:
|
|
||||||
- Nothing changes. Recordings have been finalized from the `egress_ended` webhook since v1.22.0.
|
|
||||||
|
|
||||||
In both cases, the following settings are no longer used and can be removed from your env: `RECORDING_EVENT_PARSER_CLASS`, `RECORDING_ENABLE_STORAGE_EVENT_AUTH`, `RECORDING_STORAGE_EVENT_ENABLE`, `RECORDING_STORAGE_EVENT_TOKEN`.
|
|
||||||
|
|
||||||
On completion of the egress, a recording moves to `notification_succeeded`, or to `saved` if notifying external services failed.
|
|
||||||
|
|
||||||
## v1.23.0
|
## v1.23.0
|
||||||
|
|
||||||
As part of the 1.23.0 release, the legacy `api/v1` implementation has been removed from the _experimental_ Summary service and Meet has been migrated to the new `api/v2`.
|
As part of the 1.23.0 release, the legacy `api/v1` implementation has been removed from the _experimental_ Summary service and Meet has been migrated to the new `api/v2`.
|
||||||
|
|||||||
@@ -110,12 +110,6 @@ cd -
|
|||||||
# Update summary pyproject.toml
|
# Update summary pyproject.toml
|
||||||
update_python_version "summary"
|
update_python_version "summary"
|
||||||
|
|
||||||
# Run uv lock in summary
|
|
||||||
print_info "Running uv lock in summary..."
|
|
||||||
cd "src/summary"
|
|
||||||
uv lock
|
|
||||||
cd -
|
|
||||||
|
|
||||||
# Update agents pyproject.toml
|
# Update agents pyproject.toml
|
||||||
update_python_version "agents"
|
update_python_version "agents"
|
||||||
|
|
||||||
@@ -169,7 +163,6 @@ echo " - src/mail/package.json"
|
|||||||
echo " - src/backend/pyproject.toml"
|
echo " - src/backend/pyproject.toml"
|
||||||
echo " - src/backend/uv.lock"
|
echo " - src/backend/uv.lock"
|
||||||
echo " - src/summary/pyproject.toml"
|
echo " - src/summary/pyproject.toml"
|
||||||
echo " - src/summary/uv.lock"
|
|
||||||
echo " - src/agents/pyproject.toml"
|
echo " - src/agents/pyproject.toml"
|
||||||
echo " - src/agents/uv.lock"
|
echo " - src/agents/uv.lock"
|
||||||
echo " - CHANGELOG.md"
|
echo " - CHANGELOG.md"
|
||||||
|
|||||||
+16
-5
@@ -46,6 +46,21 @@ services:
|
|||||||
/usr/bin/mc mb meet/meet-media-storage && \
|
/usr/bin/mc mb meet/meet-media-storage && \
|
||||||
exit 0;"
|
exit 0;"
|
||||||
|
|
||||||
|
createwebhook:
|
||||||
|
image: minio/mc
|
||||||
|
depends_on:
|
||||||
|
minio:
|
||||||
|
condition: service_healthy
|
||||||
|
restart: true
|
||||||
|
entrypoint: >
|
||||||
|
sh -c "
|
||||||
|
/usr/bin/mc alias set meet http://minio:9000 meet password &&
|
||||||
|
/usr/bin/mc admin config set meet notify_webhook:meet-webhook endpoint='http://app-dev:8000/api/v1.0/recordings/storage-hook/' auth_token='Bearer password' &&
|
||||||
|
/usr/bin/mc admin service restart meet --wait --json &&
|
||||||
|
sleep 15 &&
|
||||||
|
/usr/bin/mc event add meet/meet-media-storage arn:minio:sqs::meet-webhook:webhook --event put --prefix "recordings" &&
|
||||||
|
exit 0;"
|
||||||
|
|
||||||
app-dev:
|
app-dev:
|
||||||
build:
|
build:
|
||||||
context: .
|
context: .
|
||||||
@@ -71,6 +86,7 @@ services:
|
|||||||
- mailcatcher
|
- mailcatcher
|
||||||
- redis
|
- redis
|
||||||
- createbuckets
|
- createbuckets
|
||||||
|
- createwebhook
|
||||||
extra_hosts:
|
extra_hosts:
|
||||||
- "127.0.0.1.nip.io:host-gateway"
|
- "127.0.0.1.nip.io:host-gateway"
|
||||||
networks:
|
networks:
|
||||||
@@ -213,8 +229,6 @@ services:
|
|||||||
- "7880:7880"
|
- "7880:7880"
|
||||||
- "7881:7881"
|
- "7881:7881"
|
||||||
- "7882:7882/udp"
|
- "7882:7882/udp"
|
||||||
- "3478:3478/udp"
|
|
||||||
- "30000-30100:30000-30100/udp"
|
|
||||||
volumes:
|
volumes:
|
||||||
- ./docker/livekit/config/livekit-server.yaml:/config.yaml
|
- ./docker/livekit/config/livekit-server.yaml:/config.yaml
|
||||||
depends_on:
|
depends_on:
|
||||||
@@ -235,7 +249,6 @@ services:
|
|||||||
build:
|
build:
|
||||||
context: ./src/agents
|
context: ./src/agents
|
||||||
target: development
|
target: development
|
||||||
user: ${DOCKER_USER:-1000}
|
|
||||||
command: ["python", "metadata_collector.py", "dev"]
|
command: ["python", "metadata_collector.py", "dev"]
|
||||||
env_file:
|
env_file:
|
||||||
- env.d/development/metadata_collector
|
- env.d/development/metadata_collector
|
||||||
@@ -254,8 +267,6 @@ services:
|
|||||||
build:
|
build:
|
||||||
context: ./src/agents
|
context: ./src/agents
|
||||||
target: development
|
target: development
|
||||||
user: ${DOCKER_USER:-1000}
|
|
||||||
command: ["python", "multi_user_transcriber.py", "dev"]
|
|
||||||
env_file:
|
env_file:
|
||||||
- env.d/development/multi_user_transcriber
|
- env.d/development/multi_user_transcriber
|
||||||
volumes:
|
volumes:
|
||||||
|
|||||||
@@ -8,16 +8,3 @@ webhook:
|
|||||||
api_key: devkey
|
api_key: devkey
|
||||||
urls:
|
urls:
|
||||||
- http://app-dev:8000/api/v1.0/rooms/webhooks-livekit/
|
- http://app-dev:8000/api/v1.0/rooms/webhooks-livekit/
|
||||||
|
|
||||||
turn:
|
|
||||||
enabled: true
|
|
||||||
domain: turn.127.0.0.1.nip.io
|
|
||||||
udp_port: 3478
|
|
||||||
tls_port: 0
|
|
||||||
external_tls: false
|
|
||||||
relay_range_start: 30000
|
|
||||||
relay_range_end: 30100
|
|
||||||
allow_restricted_peer_cidrs:
|
|
||||||
- 192.168.0.0/16
|
|
||||||
- 172.16.0.0/12
|
|
||||||
|
|
||||||
|
|||||||
@@ -23,11 +23,14 @@ It uses LiveKit Egress to record room sessions. For reference, see the [LiveKit
|
|||||||
To use the room recording feature, the following components are required:
|
To use the room recording feature, the following components are required:
|
||||||
|
|
||||||
- A running [LiveKit Egress](https://github.com/livekit/egress) server capable of handling room composite recordings.
|
- A running [LiveKit Egress](https://github.com/livekit/egress) server capable of handling room composite recordings.
|
||||||
- A S3-compatible object storage where the egress uploads the recorded files.
|
- A S3-compatible object storage that supports webhook events to notify the backend when recordings are uploaded.
|
||||||
- An email service to notify room owners when a recording is available for download.
|
- An email service to notify room owners when a recording is available for download.
|
||||||
- Webhook events configured between LiveKit Server and the backend.
|
- Webhook events configured between LiveKit Server and the backend.
|
||||||
|
|
||||||
|
|
||||||
|
> [!CAUTION]
|
||||||
|
> Minio supports lifecycle events; other providers may not work out of the box. There is currently a dependency on Minio, which is planned to be refactored in the future.
|
||||||
|
|
||||||
> [!NOTE]
|
> [!NOTE]
|
||||||
> Celery isn’t in use for these async tasks yet. It’s something we’d like to add, but it’s not planned at this stage.
|
> Celery isn’t in use for these async tasks yet. It’s something we’d like to add, but it’s not planned at this stage.
|
||||||
|
|
||||||
@@ -72,7 +75,7 @@ sequenceDiagram
|
|||||||
LiveKit->>Egress: Stop recording
|
LiveKit->>Egress: Stop recording
|
||||||
Egress->>Storage: Upload recorded file
|
Egress->>Storage: Upload recorded file
|
||||||
|
|
||||||
LiveKit->>Backend: POST /api/v1.0/rooms/webhooks-livekit/ (egress_ended)
|
Storage->>Backend: Storage event notification
|
||||||
Backend->>Backend: Update Recording status to SAVED
|
Backend->>Backend: Update Recording status to SAVED
|
||||||
Backend->>Email: Send notification to room owner
|
Backend->>Email: Send notification to room owner
|
||||||
|
|
||||||
@@ -91,6 +94,10 @@ sequenceDiagram
|
|||||||
| **RECORDING_ENABLE** | Boolean | `False` | Enable or disable the room recording feature. |
|
| **RECORDING_ENABLE** | Boolean | `False` | Enable or disable the room recording feature. |
|
||||||
| **RECORDING_OUTPUT_FOLDER** | String | `"recordings"` | Folder/prefix where recordings are stored in the object storage. |
|
| **RECORDING_OUTPUT_FOLDER** | String | `"recordings"` | Folder/prefix where recordings are stored in the object storage. |
|
||||||
| **RECORDING_WORKER_CLASSES** | Dict | `{ "screen_recording": "core.recording.worker.services.VideoCompositeEgressService", "transcript": "core.recording.worker.services.AudioCompositeEgressService" }` | Maps recording types to their worker service classes. |
|
| **RECORDING_WORKER_CLASSES** | Dict | `{ "screen_recording": "core.recording.worker.services.VideoCompositeEgressService", "transcript": "core.recording.worker.services.AudioCompositeEgressService" }` | Maps recording types to their worker service classes. |
|
||||||
|
| **RECORDING_EVENT_PARSER_CLASS** | String | `"core.recording.event.parsers.MinioParser"` | Class responsible for parsing storage events and updating the backend. |
|
||||||
|
| **RECORDING_ENABLE_STORAGE_EVENT_AUTH** | Boolean | `True` | Enable authentication for storage event webhook requests. |
|
||||||
|
| **RECORDING_STORAGE_EVENT_ENABLE** | Boolean | `False` | Enable handling of storage events (must configure webhook in storage). If `False`, fallback to LiveKit egress complete webhook. |
|
||||||
|
| **RECORDING_STORAGE_EVENT_TOKEN** | Secret/File | `None` | Token used to authenticate storage webhook requests, if `RECORDING_ENABLE_STORAGE_EVENT_AUTH` is enabled. |
|
||||||
| **RECORDING_EXPIRATION_DAYS** | Integer | `None` | Number of days before recordings expire. Should match bucket lifecycle policy. Set to `None` for no expiration. |
|
| **RECORDING_EXPIRATION_DAYS** | Integer | `None` | Number of days before recordings expire. Should match bucket lifecycle policy. Set to `None` for no expiration. |
|
||||||
| **RECORDING_MAX_DURATION** | Integer | `None` | Maximum duration of a recording in milliseconds. Must be synced with the LiveKit Egress configuration. Set to None for unlimited duration. When the maximum duration is reached, the recording is automatically stopped and saved, and the user is prompted in the frontend with an alert message. |
|
| **RECORDING_MAX_DURATION** | Integer | `None` | Maximum duration of a recording in milliseconds. Must be synced with the LiveKit Egress configuration. Set to None for unlimited duration. When the maximum duration is reached, the recording is automatically stopped and saved, and the user is prompted in the frontend with an alert message. |
|
||||||
| **RECORDING_ENCODING_ENABLED** | Boolean | `False` | When `False`, LiveKit Egress uses its built-in `H264_720P_30` preset. When `True`, the `RECORDING_ENCODING_*` values below are sent to LiveKit as advanced `EncodingOptions`. See [Tuning recording encoding](#tuning-recording-encoding). |
|
| **RECORDING_ENCODING_ENABLED** | Boolean | `False` | When `False`, LiveKit Egress uses its built-in `H264_720P_30` preset. When `True`, the `RECORDING_ENCODING_*` values below are sent to LiveKit as advanced `EncodingOptions`. See [Tuning recording encoding](#tuning-recording-encoding). |
|
||||||
@@ -102,6 +109,19 @@ sequenceDiagram
|
|||||||
| **RECORDING_ENCODING_KEY_FRAME_INTERVAL_S** | Float | `4.0` | Keyframe interval in seconds. Drives seek granularity in the recorded MP4 (a player can only seek to keyframe boundaries). Larger values give the encoder slightly more bits for non-keyframe content at a fixed bitrate. `4.0` is a standard VOD value. Only applied when `RECORDING_ENCODING_ENABLED` is `True`. |
|
| **RECORDING_ENCODING_KEY_FRAME_INTERVAL_S** | Float | `4.0` | Keyframe interval in seconds. Drives seek granularity in the recorded MP4 (a player can only seek to keyframe boundaries). Larger values give the encoder slightly more bits for non-keyframe content at a fixed bitrate. `4.0` is a standard VOD value. Only applied when `RECORDING_ENCODING_ENABLED` is `True`. |
|
||||||
|
|
||||||
|
|
||||||
|
### Manual Storage Webhook
|
||||||
|
|
||||||
|
Storage events must be configured manually; the Kubernetes chart does not do this automatically.
|
||||||
|
|
||||||
|
1. Configure your S3 bucket to send file creation events to the backend webhook.
|
||||||
|
2. Enable events and token in settings:
|
||||||
|
|
||||||
|
```python
|
||||||
|
RECORDING_STORAGE_EVENT_ENABLE = True
|
||||||
|
RECORDING_ENABLE_STORAGE_EVENT_AUTH = True
|
||||||
|
RECORDING_STORAGE_EVENT_TOKEN = <token>
|
||||||
|
```
|
||||||
|
|
||||||
> [!NOTE]
|
> [!NOTE]
|
||||||
> Questions? Open an issue on [GitHub](https://github.com/suitenumerique/meet/issues/new?assignees=&labels=bug&template=Bug_report.md) or join our [Matrix community](https://matrix.to/#/#meet-official:matrix.org).
|
> Questions? Open an issue on [GitHub](https://github.com/suitenumerique/meet/issues/new?assignees=&labels=bug&template=Bug_report.md) or join our [Matrix community](https://matrix.to/#/#meet-official:matrix.org).
|
||||||
|
|
||||||
|
|||||||
@@ -406,6 +406,10 @@ These are the environmental options available on meet backend.
|
|||||||
| RECORDING_ENABLE | Record meeting option | false |
|
| RECORDING_ENABLE | Record meeting option | false |
|
||||||
| RECORDING_OUTPUT_FOLDER | Folder to store meetings | recordings |
|
| RECORDING_OUTPUT_FOLDER | Folder to store meetings | recordings |
|
||||||
| RECORDING_WORKER_CLASSES | Worker classes for recording | {"screen_recording": "core.recording.worker.services.VideoCompositeEgressService","transcript": "core.recording.worker.services.AudioCompositeEgressService"} |
|
| RECORDING_WORKER_CLASSES | Worker classes for recording | {"screen_recording": "core.recording.worker.services.VideoCompositeEgressService","transcript": "core.recording.worker.services.AudioCompositeEgressService"} |
|
||||||
|
| RECORDING_EVENT_PARSER_CLASS | Storage event engine for recording | core.recording.event.parsers.MinioParser |
|
||||||
|
| RECORDING_ENABLE_STORAGE_EVENT_AUTH | Enable storage event authorization | true |
|
||||||
|
| RECORDING_STORAGE_EVENT_ENABLE | Enable recording storage events. If false, fallback to egress webhook. | false |
|
||||||
|
| RECORDING_STORAGE_EVENT_TOKEN | Recording storage event token | |
|
||||||
| RECORDING_EXPIRATION_DAYS | Recording expiration in days | |
|
| RECORDING_EXPIRATION_DAYS | Recording expiration in days | |
|
||||||
| RECORDING_MAX_DURATION | Maximum recording duration in milliseconds. Must match LiveKit Egress configuration exactly. | |
|
| RECORDING_MAX_DURATION | Maximum recording duration in milliseconds. Must match LiveKit Egress configuration exactly. | |
|
||||||
| SCREEN_RECORDING_BASE_URL | Screen recording base URL | |
|
| SCREEN_RECORDING_BASE_URL | Screen recording base URL | |
|
||||||
|
|||||||
+76
-1
@@ -16,7 +16,7 @@ info:
|
|||||||
* `rooms:list` – List rooms accessible to the delegated user.
|
* `rooms:list` – List rooms accessible to the delegated user.
|
||||||
* `rooms:retrieve` – Retrieve details of a specific room.
|
* `rooms:retrieve` – Retrieve details of a specific room.
|
||||||
* `rooms:create` – Create new rooms.
|
* `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.
|
* `rooms:delete` – **Coming soon** Delete rooms generated by the application.
|
||||||
|
|
||||||
#### Upcoming Features
|
#### Upcoming Features
|
||||||
@@ -310,6 +310,67 @@ paths:
|
|||||||
'404':
|
'404':
|
||||||
$ref: '#/components/responses/RoomNotFoundError'
|
$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:
|
components:
|
||||||
securitySchemes:
|
securitySchemes:
|
||||||
BearerAuth:
|
BearerAuth:
|
||||||
@@ -386,6 +447,17 @@ components:
|
|||||||
configuration:
|
configuration:
|
||||||
$ref: '#/components/schemas/RoomConfiguration'
|
$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:
|
RoomConfiguration:
|
||||||
type: object
|
type: object
|
||||||
description: |
|
description: |
|
||||||
@@ -427,6 +499,9 @@ components:
|
|||||||
- `public`: Anyone with the room link can join directly, no authentication required.
|
- `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.
|
- `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.
|
- `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"
|
example: "trusted"
|
||||||
|
|
||||||
Room:
|
Room:
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ info:
|
|||||||
* `lasuite_visio:rooms:list` – List rooms accessible to the delegated user.
|
* `lasuite_visio:rooms:list` – List rooms accessible to the delegated user.
|
||||||
* `lasuite_visio:rooms:retrieve` – Retrieve details of a specific room.
|
* `lasuite_visio:rooms:retrieve` – Retrieve details of a specific room.
|
||||||
* `lasuite_visio:rooms:create` – Create new rooms.
|
* `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.
|
* `lasuite_visio:rooms:delete` – **Coming soon** Delete rooms generated by the application.
|
||||||
|
|
||||||
#### Upcoming Features
|
#### Upcoming Features
|
||||||
@@ -206,6 +206,67 @@ paths:
|
|||||||
'404':
|
'404':
|
||||||
$ref: '#/components/responses/RoomNotFoundError'
|
$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:
|
components:
|
||||||
securitySchemes:
|
securitySchemes:
|
||||||
BearerAuth:
|
BearerAuth:
|
||||||
@@ -227,6 +288,17 @@ components:
|
|||||||
configuration:
|
configuration:
|
||||||
$ref: '#/components/schemas/RoomConfiguration'
|
$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:
|
RoomConfiguration:
|
||||||
type: object
|
type: object
|
||||||
description: |
|
description: |
|
||||||
@@ -268,6 +340,9 @@ components:
|
|||||||
- `public`: Anyone with the room link can join directly, no authentication required.
|
- `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.
|
- `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.
|
- `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"
|
example: "trusted"
|
||||||
|
|
||||||
Room:
|
Room:
|
||||||
|
|||||||
@@ -63,6 +63,8 @@ ALLOW_UNREGISTERED_ROOMS=False
|
|||||||
|
|
||||||
# Recording
|
# Recording
|
||||||
RECORDING_ENABLE=True
|
RECORDING_ENABLE=True
|
||||||
|
RECORDING_STORAGE_EVENT_ENABLE=False
|
||||||
|
RECORDING_STORAGE_EVENT_TOKEN=password
|
||||||
SUMMARY_SERVICE_ENDPOINT=http://app-summary-dev:8000/api/v2/async-jobs/transcribe/
|
SUMMARY_SERVICE_ENDPOINT=http://app-summary-dev:8000/api/v2/async-jobs/transcribe/
|
||||||
SUMMARY_SERVICE_API_TOKEN=password
|
SUMMARY_SERVICE_API_TOKEN=password
|
||||||
SUMMARY_SERVICE_WEBHOOK_API_TOKEN=webhook-password
|
SUMMARY_SERVICE_WEBHOOK_API_TOKEN=webhook-password
|
||||||
|
|||||||
@@ -1,23 +1,14 @@
|
|||||||
AWS_S3_ENDPOINT_URL=minio:9000
|
|
||||||
AWS_S3_ACCESS_KEY_ID=meet
|
|
||||||
AWS_S3_SECRET_ACCESS_KEY=password
|
|
||||||
|
|
||||||
LIVEKIT_URL=ws://livekit:7880
|
LIVEKIT_URL=ws://livekit:7880
|
||||||
LIVEKIT_API_KEY=devkey
|
LIVEKIT_API_KEY=devkey
|
||||||
LIVEKIT_API_SECRET=secret
|
LIVEKIT_API_SECRET=secret
|
||||||
|
|
||||||
STT_PROVIDER=voxtral-vllm # voxtral-vllm, kyutai, deepgram
|
STT_PROVIDER=kyutai # kyutai, deepgram
|
||||||
ENABLE_SILERO_VAD=False
|
ENABLE_SILERO_VAD=False
|
||||||
|
|
||||||
DEEPGRAM_API_KEY=your-deepgram-api-key
|
DEEPGRAM_API_KEY=
|
||||||
|
|
||||||
KYUTAI_STT_BASE_URL=url
|
KYUTAI_STT_BASE_URL=
|
||||||
KYUTAI_API_KEY=your-kyutai-api-key
|
KYUTAI_API_KEY=
|
||||||
|
|
||||||
VOXTRAL_VLLM_BASE_URL=wss://<host>/v1/realtime
|
|
||||||
VOXTRAL_VLLM_MODEL=voxtral-mini-4b-realtime-2602
|
|
||||||
VOXTRAL_VLLM_API_KEY=your-vllm-api-key
|
|
||||||
VOXTRAL_VLLM_TARGET_STREAMING_DELAY_MS=480
|
|
||||||
|
|
||||||
SENTRY_DSN=
|
SENTRY_DSN=
|
||||||
SENTRY_ENVIRONMENT=
|
SENTRY_ENVIRONMENT=
|
||||||
|
|||||||
@@ -3,14 +3,14 @@ Gitlint extra rule to validate that the message title is of the form
|
|||||||
"<gitmoji>(<scope>) <subject>"
|
"<gitmoji>(<scope>) <subject>"
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import json
|
from __future__ import unicode_literals
|
||||||
|
|
||||||
import re
|
import re
|
||||||
import urllib.request
|
|
||||||
|
import requests
|
||||||
|
|
||||||
from gitlint.rules import CommitMessageTitle, LineRule, RuleViolation
|
from gitlint.rules import CommitMessageTitle, LineRule, RuleViolation
|
||||||
|
|
||||||
GITMOJIS_URL = "https://raw.githubusercontent.com/carloscuesta/gitmoji/master/packages/gitmojis/src/gitmojis.json"
|
|
||||||
|
|
||||||
|
|
||||||
class GitmojiTitle(LineRule):
|
class GitmojiTitle(LineRule):
|
||||||
"""
|
"""
|
||||||
@@ -28,9 +28,10 @@ class GitmojiTitle(LineRule):
|
|||||||
Download the list possible gitmojis from the project's github repository and check that
|
Download the list possible gitmojis from the project's github repository and check that
|
||||||
title contains one of them.
|
title contains one of them.
|
||||||
"""
|
"""
|
||||||
with urllib.request.urlopen(GITMOJIS_URL, timeout=10) as response:
|
gitmojis = requests.get(
|
||||||
gitmojis = json.load(response)["gitmojis"]
|
"https://raw.githubusercontent.com/carloscuesta/gitmoji/master/packages/gitmojis/src/gitmojis.json"
|
||||||
emojis = [re.escape(item["emoji"]) for item in gitmojis]
|
).json()["gitmojis"]
|
||||||
|
emojis = [item["emoji"] for item in gitmojis]
|
||||||
pattern = r"^({:s})\(.*\)\s[a-z].*$".format("|".join(emojis))
|
pattern = r"^({:s})\(.*\)\s[a-z].*$".format("|".join(emojis))
|
||||||
if not re.search(pattern, title):
|
if not re.search(pattern, title):
|
||||||
violation_msg = 'Title does not match regex "<gitmoji>(<scope>) <subject>"'
|
violation_msg = 'Title does not match regex "<gitmoji>(<scope>) <subject>"'
|
||||||
|
|||||||
Generated
+5
-8
@@ -9,8 +9,8 @@
|
|||||||
"version": "0.0.1",
|
"version": "0.0.1",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"core-js": "3.50.0",
|
"core-js": "3.49.0",
|
||||||
"i18next": "26.3.6",
|
"i18next": "^26.3.6",
|
||||||
"i18next-browser-languagedetector": "8.2.1",
|
"i18next-browser-languagedetector": "8.2.1",
|
||||||
"regenerator-runtime": "0.14.1"
|
"regenerator-runtime": "0.14.1"
|
||||||
},
|
},
|
||||||
@@ -6863,14 +6863,11 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/core-js": {
|
"node_modules/core-js": {
|
||||||
"version": "3.50.0",
|
"version": "3.49.0",
|
||||||
"resolved": "https://registry.npmjs.org/core-js/-/core-js-3.50.0.tgz",
|
"resolved": "https://registry.npmjs.org/core-js/-/core-js-3.49.0.tgz",
|
||||||
"integrity": "sha512-BRWgOLKkFeCgRudR6zrs8p9XJZcE14grzKMMssoYrk6krtuEZ7MTKPIY5RzOnqsEKIR9kst7wNzphttraT+Yqw==",
|
"integrity": "sha512-es1U2+YTtzpwkxVLwAFdSpaIMyQaq0PBgm3YD1W3Qpsn1NAmO3KSgZfu+oGSWVu6NvLHoHCV/aYcsE5wiB7ALg==",
|
||||||
"hasInstallScript": true,
|
"hasInstallScript": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
|
||||||
"node": "*"
|
|
||||||
},
|
|
||||||
"funding": {
|
"funding": {
|
||||||
"type": "opencollective",
|
"type": "opencollective",
|
||||||
"url": "https://opencollective.com/core-js"
|
"url": "https://opencollective.com/core-js"
|
||||||
|
|||||||
@@ -26,7 +26,7 @@
|
|||||||
"watch": "webpack --mode development --watch"
|
"watch": "webpack --mode development --watch"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"core-js": "3.50.0",
|
"core-js": "3.49.0",
|
||||||
"i18next": "26.3.6",
|
"i18next": "26.3.6",
|
||||||
"i18next-browser-languagedetector": "8.2.1",
|
"i18next-browser-languagedetector": "8.2.1",
|
||||||
"regenerator-runtime": "0.14.1"
|
"regenerator-runtime": "0.14.1"
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
"""Multi user transcription agent."""
|
"""Multi user transcription agent."""
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import contextlib
|
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
|
|
||||||
@@ -26,7 +25,6 @@ from livekit.agents import (
|
|||||||
)
|
)
|
||||||
from livekit.plugins import deepgram, silero
|
from livekit.plugins import deepgram, silero
|
||||||
|
|
||||||
import voxtral_vllm_stt
|
|
||||||
from observability import configure_sentry, set_job_context
|
from observability import configure_sentry, set_job_context
|
||||||
from tasks import done_callback
|
from tasks import done_callback
|
||||||
|
|
||||||
@@ -38,18 +36,9 @@ TRANSCRIBER_AGENT_NAME = os.getenv("TRANSCRIBER_AGENT_NAME", "multi-user-transcr
|
|||||||
STT_PROVIDER = os.getenv("STT_PROVIDER", "deepgram")
|
STT_PROVIDER = os.getenv("STT_PROVIDER", "deepgram")
|
||||||
ENABLE_SILERO_VAD = os.getenv("ENABLE_SILERO_VAD", "true").lower() == "true"
|
ENABLE_SILERO_VAD = os.getenv("ENABLE_SILERO_VAD", "true").lower() == "true"
|
||||||
|
|
||||||
SESSION_DRAIN_TIMEOUT_S = 15.0
|
|
||||||
|
|
||||||
|
def create_stt_provider():
|
||||||
def create_stt_provider(vad: silero.VAD | None = None):
|
"""Create STT provider based on environment configuration."""
|
||||||
"""Create STT provider based on environment configuration.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
vad: Shared, prewarmed VAD instance. Required in practice for
|
|
||||||
voxtral-vllm (no server-side endpointing): if omitted, the plugin
|
|
||||||
loads its own Silero model synchronously on the event loop, once
|
|
||||||
per participant, freezing all active sessions for the duration.
|
|
||||||
"""
|
|
||||||
if STT_PROVIDER == "deepgram":
|
if STT_PROVIDER == "deepgram":
|
||||||
# Note: Not all Deepgram API parameters are supported by the LiveKit plugin
|
# Note: Not all Deepgram API parameters are supported by the LiveKit plugin
|
||||||
# detect_language is NOT supported for real-time streaming
|
# detect_language is NOT supported for real-time streaming
|
||||||
@@ -60,9 +49,6 @@ def create_stt_provider(vad: silero.VAD | None = None):
|
|||||||
)
|
)
|
||||||
elif STT_PROVIDER == "kyutai":
|
elif STT_PROVIDER == "kyutai":
|
||||||
_stt_instance = kyutai.STT(base_url=os.getenv("KYUTAI_STT_BASE_URL"))
|
_stt_instance = kyutai.STT(base_url=os.getenv("KYUTAI_STT_BASE_URL"))
|
||||||
elif STT_PROVIDER == "voxtral-vllm":
|
|
||||||
# The plugin resolves base_url / model / api_key from the environment.
|
|
||||||
_stt_instance = voxtral_vllm_stt.STT(vad=vad)
|
|
||||||
else:
|
else:
|
||||||
raise ValueError(f"Unknown STT_PROVIDER: {STT_PROVIDER}")
|
raise ValueError(f"Unknown STT_PROVIDER: {STT_PROVIDER}")
|
||||||
|
|
||||||
@@ -72,9 +58,9 @@ def create_stt_provider(vad: silero.VAD | None = None):
|
|||||||
class Transcriber(Agent):
|
class Transcriber(Agent):
|
||||||
"""Create a transcription agent for a specific participant."""
|
"""Create a transcription agent for a specific participant."""
|
||||||
|
|
||||||
def __init__(self, *, participant_identity: str, vad: silero.VAD | None = None):
|
def __init__(self, *, participant_identity: str):
|
||||||
"""Init transcription agent."""
|
"""Init transcription agent."""
|
||||||
stt = create_stt_provider(vad=vad)
|
stt = create_stt_provider()
|
||||||
|
|
||||||
super().__init__(
|
super().__init__(
|
||||||
instructions="not-needed",
|
instructions="not-needed",
|
||||||
@@ -90,7 +76,6 @@ class MultiUserTranscriber:
|
|||||||
"""Init multi user transcription agent."""
|
"""Init multi user transcription agent."""
|
||||||
self.ctx = ctx
|
self.ctx = ctx
|
||||||
self._sessions: dict[str, AgentSession] = {}
|
self._sessions: dict[str, AgentSession] = {}
|
||||||
self._starting: dict[str, asyncio.Task] = {}
|
|
||||||
self._tasks: set[asyncio.Task] = set()
|
self._tasks: set[asyncio.Task] = set()
|
||||||
|
|
||||||
def start(self):
|
def start(self):
|
||||||
@@ -111,30 +96,22 @@ class MultiUserTranscriber:
|
|||||||
|
|
||||||
def on_participant_connected(self, participant: rtc.RemoteParticipant):
|
def on_participant_connected(self, participant: rtc.RemoteParticipant):
|
||||||
"""Handle new participant connection by starting transcription session."""
|
"""Handle new participant connection by starting transcription session."""
|
||||||
identity = participant.identity
|
if participant.identity in self._sessions:
|
||||||
if identity in self._sessions or identity in self._starting:
|
|
||||||
return
|
return
|
||||||
|
|
||||||
logger.info(f"starting session for {identity}")
|
logger.info(f"starting session for {participant.identity}")
|
||||||
task = asyncio.create_task(self._start_session(participant))
|
task = asyncio.create_task(self._start_session(participant))
|
||||||
self._starting[identity] = task
|
|
||||||
self._tasks.add(task)
|
self._tasks.add(task)
|
||||||
task.add_done_callback(lambda t, i=identity: self._starting.pop(i, None))
|
|
||||||
task.add_done_callback(
|
task.add_done_callback(
|
||||||
done_callback(
|
done_callback(
|
||||||
logger,
|
logger,
|
||||||
self._tasks,
|
self._tasks,
|
||||||
f"start transcription session for {identity}",
|
f"start transcription session for {participant.identity}",
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
def on_participant_disconnected(self, participant: rtc.RemoteParticipant):
|
def on_participant_disconnected(self, participant: rtc.RemoteParticipant):
|
||||||
"""Handle participant disconnection by closing transcription session."""
|
"""Handle participant disconnection by closing transcription session."""
|
||||||
if (start_task := self._starting.pop(participant.identity, None)) is not None:
|
|
||||||
logger.info(f"cancelling pending session start for {participant.identity}")
|
|
||||||
start_task.cancel()
|
|
||||||
return
|
|
||||||
|
|
||||||
if (session := self._sessions.pop(participant.identity, None)) is None:
|
if (session := self._sessions.pop(participant.identity, None)) is None:
|
||||||
return
|
return
|
||||||
|
|
||||||
@@ -150,12 +127,10 @@ class MultiUserTranscriber:
|
|||||||
)
|
)
|
||||||
|
|
||||||
async def _start_session(self, participant: rtc.RemoteParticipant) -> AgentSession:
|
async def _start_session(self, participant: rtc.RemoteParticipant) -> AgentSession:
|
||||||
"""Create and start transcription session for participant.
|
"""Create and start transcription session for participant."""
|
||||||
|
if participant.identity in self._sessions:
|
||||||
|
return self._sessions[participant.identity]
|
||||||
|
|
||||||
Deduplication happens synchronously in on_participant_connected via
|
|
||||||
self._starting; by the time this coroutine runs, the identity is
|
|
||||||
already reserved.
|
|
||||||
"""
|
|
||||||
vad = self.ctx.proc.userdata.get("vad", None)
|
vad = self.ctx.proc.userdata.get("vad", None)
|
||||||
session = AgentSession(vad=vad)
|
session = AgentSession(vad=vad)
|
||||||
room_io = RoomIO(
|
room_io = RoomIO(
|
||||||
@@ -166,30 +141,18 @@ class MultiUserTranscriber:
|
|||||||
text_input=False, audio_output=False, text_output=True
|
text_input=False, audio_output=False, text_output=True
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
try:
|
|
||||||
await room_io.start()
|
await room_io.start()
|
||||||
await session.start(
|
await session.start(
|
||||||
agent=Transcriber(
|
agent=Transcriber(
|
||||||
participant_identity=participant.identity,
|
participant_identity=participant.identity,
|
||||||
vad=vad,
|
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
except BaseException:
|
|
||||||
with contextlib.suppress(Exception):
|
|
||||||
await session.aclose()
|
|
||||||
raise
|
|
||||||
self._sessions[participant.identity] = session
|
self._sessions[participant.identity] = session
|
||||||
return session
|
return session
|
||||||
|
|
||||||
async def _close_session(self, sess: AgentSession) -> None:
|
async def _close_session(self, sess: AgentSession) -> None:
|
||||||
"""Close and cleanup transcription session."""
|
"""Close and cleanup transcription session."""
|
||||||
try:
|
await sess.drain()
|
||||||
await asyncio.wait_for(sess.drain(), timeout=SESSION_DRAIN_TIMEOUT_S)
|
|
||||||
except (TimeoutError, asyncio.TimeoutError):
|
|
||||||
logger.warning(
|
|
||||||
"session drain timed out after %.0fs; forcing close",
|
|
||||||
SESSION_DRAIN_TIMEOUT_S,
|
|
||||||
)
|
|
||||||
await sess.aclose()
|
await sess.aclose()
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "agents"
|
name = "agents"
|
||||||
version = "1.29.0"
|
version = "1.28.0"
|
||||||
requires-python = ">=3.12"
|
requires-python = ">=3.12"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"livekit-agents==1.6.7",
|
"livekit-agents==1.6.7",
|
||||||
@@ -12,8 +12,6 @@ dependencies = [
|
|||||||
"protobuf==6.33.6",
|
"protobuf==6.33.6",
|
||||||
"minio==7.2.20",
|
"minio==7.2.20",
|
||||||
"sentry-sdk==2.66.1",
|
"sentry-sdk==2.66.1",
|
||||||
"websockets==17.1",
|
|
||||||
"httpx==0.28.1",
|
|
||||||
]
|
]
|
||||||
|
|
||||||
[project.optional-dependencies]
|
[project.optional-dependencies]
|
||||||
|
|||||||
Generated
+634
-915
File diff suppressed because it is too large
Load Diff
@@ -1,476 +0,0 @@
|
|||||||
"""LiveKit STT plugin for Voxtral Realtime served via vLLM (/v1/realtime).
|
|
||||||
|
|
||||||
vLLM exposes Voxtral Realtime over a WebSocket that follows the OpenAI Realtime
|
|
||||||
API protocol (not Mistral's proprietary realtime protocol).
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import asyncio
|
|
||||||
import base64
|
|
||||||
import json
|
|
||||||
import logging
|
|
||||||
import os
|
|
||||||
import weakref
|
|
||||||
from collections import deque
|
|
||||||
from dataclasses import dataclass, field
|
|
||||||
|
|
||||||
import websockets
|
|
||||||
from livekit.agents import (
|
|
||||||
DEFAULT_API_CONNECT_OPTIONS,
|
|
||||||
APIConnectionError,
|
|
||||||
APIConnectOptions,
|
|
||||||
APIStatusError,
|
|
||||||
stt,
|
|
||||||
utils,
|
|
||||||
)
|
|
||||||
from livekit.agents import (
|
|
||||||
vad as vad_module,
|
|
||||||
)
|
|
||||||
from livekit.agents.types import NOT_GIVEN, NotGivenOr
|
|
||||||
from livekit.agents.utils import is_given
|
|
||||||
|
|
||||||
logger = logging.getLogger("voxtral-vllm-stt")
|
|
||||||
|
|
||||||
SAMPLE_RATE = 16000
|
|
||||||
NUM_CHANNELS = 1
|
|
||||||
CHUNK_SAMPLES = 1600 # 100 ms @ 16 kHz mono
|
|
||||||
PREROLL_CHUNKS = 5 # keep 500 ms of audio before start of speech as detected by VAD
|
|
||||||
|
|
||||||
# Reconnect policy: exponential backoff capped at MAX, give up after MAX_ATTEMPTS
|
|
||||||
# consecutive failures (a successful handshake resets the counter).
|
|
||||||
RECONNECT_BACKOFF_BASE_S = 0.5
|
|
||||||
RECONNECT_BACKOFF_MAX_S = 8.0
|
|
||||||
RECONNECT_MAX_ATTEMPTS = 5
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class _STTOptions:
|
|
||||||
base_url: str
|
|
||||||
model: str
|
|
||||||
api_key: str | None
|
|
||||||
target_streaming_delay_ms: int | None
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class _PendingUtterance:
|
|
||||||
"""An utterance in flight on the shared websocket used for reconnect.
|
|
||||||
|
|
||||||
`sent_chunks` holds every chunk we have already enqueued for send on this
|
|
||||||
or a prior connection; on reconnect we replay them before resuming reads
|
|
||||||
from `queue`. vLLM concatenates `input_audio_buffer.append` events into a
|
|
||||||
single audio buffer per generation, so duplicates from a partial prior send
|
|
||||||
are harmless.
|
|
||||||
"""
|
|
||||||
|
|
||||||
queue: asyncio.Queue[bytes | None]
|
|
||||||
sent_chunks: list[bytes] = field(default_factory=list)
|
|
||||||
ended: bool = False
|
|
||||||
|
|
||||||
|
|
||||||
class STT(stt.STT):
|
|
||||||
"""LiveKit STT speaking the OpenAI Realtime protocol served by vLLM."""
|
|
||||||
|
|
||||||
def __init__(
|
|
||||||
self,
|
|
||||||
*,
|
|
||||||
base_url: NotGivenOr[str] = NOT_GIVEN,
|
|
||||||
model: NotGivenOr[str] = NOT_GIVEN,
|
|
||||||
api_key: NotGivenOr[str] = NOT_GIVEN,
|
|
||||||
target_streaming_delay_ms: NotGivenOr[int] = NOT_GIVEN,
|
|
||||||
vad: vad_module.VAD | None = None,
|
|
||||||
) -> None:
|
|
||||||
"""Build the STT.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
base_url: WebSocket URL of the vLLM realtime endpoint, e.g.
|
|
||||||
ws://example:8000/v1/realtime. Falls back to $VOXTRAL_VLLM_BASE_URL.
|
|
||||||
model: Model name exposed by vLLM, default
|
|
||||||
mistralai/Voxtral-Mini-4B-Realtime-2602.
|
|
||||||
api_key: Optional bearer token. Falls back to $VOXTRAL_VLLM_API_KEY.
|
|
||||||
target_streaming_delay_ms: Target streaming delay in ms forwarded to
|
|
||||||
vLLM via session.update. Falls back to
|
|
||||||
$VOXTRAL_VLLM_TARGET_STREAMING_DELAY_MS, else server default.
|
|
||||||
vad: Voice Activity Detector. If omitted, Silero VAD is loaded.
|
|
||||||
"""
|
|
||||||
super().__init__(
|
|
||||||
capabilities=stt.STTCapabilities(streaming=True, interim_results=True)
|
|
||||||
)
|
|
||||||
|
|
||||||
resolved_url = (
|
|
||||||
base_url
|
|
||||||
if is_given(base_url)
|
|
||||||
else os.environ.get(
|
|
||||||
"VOXTRAL_VLLM_BASE_URL", "ws://127.0.0.1:8000/v1/realtime"
|
|
||||||
)
|
|
||||||
)
|
|
||||||
resolved_model = (
|
|
||||||
model
|
|
||||||
if is_given(model)
|
|
||||||
else os.environ.get(
|
|
||||||
"VOXTRAL_VLLM_MODEL", "mistralai/Voxtral-Mini-4B-Realtime-2602"
|
|
||||||
)
|
|
||||||
)
|
|
||||||
resolved_key = (
|
|
||||||
api_key if is_given(api_key) else os.environ.get("VOXTRAL_VLLM_API_KEY")
|
|
||||||
)
|
|
||||||
resolved_delay = (
|
|
||||||
target_streaming_delay_ms
|
|
||||||
if is_given(target_streaming_delay_ms)
|
|
||||||
else (
|
|
||||||
int(os.environ["VOXTRAL_VLLM_TARGET_STREAMING_DELAY_MS"])
|
|
||||||
if os.environ.get("VOXTRAL_VLLM_TARGET_STREAMING_DELAY_MS")
|
|
||||||
else None
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
if vad is None:
|
|
||||||
try:
|
|
||||||
from livekit.plugins.silero import VAD as SileroVAD # noqa: PLC0415
|
|
||||||
except ImportError as exc:
|
|
||||||
raise ImportError(
|
|
||||||
"livekit-plugins-silero is required for vLLM Voxtral realtime "
|
|
||||||
"(no server-side endpointing)."
|
|
||||||
) from exc
|
|
||||||
vad = SileroVAD.load()
|
|
||||||
self._vad = vad
|
|
||||||
|
|
||||||
self._opts = _STTOptions(
|
|
||||||
base_url=resolved_url,
|
|
||||||
model=resolved_model,
|
|
||||||
api_key=resolved_key,
|
|
||||||
target_streaming_delay_ms=resolved_delay,
|
|
||||||
)
|
|
||||||
self._streams: weakref.WeakSet[SpeechStream] = weakref.WeakSet()
|
|
||||||
|
|
||||||
@property
|
|
||||||
def model(self) -> str:
|
|
||||||
"""Return the configured vLLM model name."""
|
|
||||||
return self._opts.model
|
|
||||||
|
|
||||||
@property
|
|
||||||
def provider(self) -> str:
|
|
||||||
"""Return the provider identifier."""
|
|
||||||
return "vllm-voxtral-realtime"
|
|
||||||
|
|
||||||
async def _recognize_impl(self, *_args, **_kwargs) -> stt.SpeechEvent:
|
|
||||||
raise NotImplementedError(
|
|
||||||
"vLLM Voxtral Realtime STT only supports streaming recognition."
|
|
||||||
)
|
|
||||||
|
|
||||||
def stream(
|
|
||||||
self,
|
|
||||||
*,
|
|
||||||
conn_options: APIConnectOptions = DEFAULT_API_CONNECT_OPTIONS,
|
|
||||||
) -> SpeechStream:
|
|
||||||
"""Open a new streaming recognition stream."""
|
|
||||||
s = SpeechStream(
|
|
||||||
stt=self,
|
|
||||||
opts=self._opts,
|
|
||||||
vad_instance=self._vad,
|
|
||||||
conn_options=conn_options,
|
|
||||||
)
|
|
||||||
self._streams.add(s)
|
|
||||||
return s
|
|
||||||
|
|
||||||
|
|
||||||
class SpeechStream(stt.RecognizeStream):
|
|
||||||
"""Voxtral realtime handler."""
|
|
||||||
|
|
||||||
def __init__(
|
|
||||||
self,
|
|
||||||
*,
|
|
||||||
stt: STT,
|
|
||||||
opts: _STTOptions,
|
|
||||||
vad_instance: vad_module.VAD,
|
|
||||||
conn_options: APIConnectOptions,
|
|
||||||
) -> None:
|
|
||||||
"""Init the speech stream."""
|
|
||||||
super().__init__(stt=stt, conn_options=conn_options, sample_rate=SAMPLE_RATE)
|
|
||||||
self._opts = opts
|
|
||||||
self._vad = vad_instance
|
|
||||||
self._utterance_q: asyncio.Queue[bytes | None] | None = None
|
|
||||||
self._speaking = False
|
|
||||||
self._preroll: deque[bytes] = deque(maxlen=PREROLL_CHUNKS)
|
|
||||||
# Voxtral realtime is strictly sequential: only one generation runs at a
|
|
||||||
# time, and a new `commit` is ignored while the previous one is still
|
|
||||||
# producing. We queue per-utterance audio buffers here and let the
|
|
||||||
# pipeline process them one by one on the shared websocket.
|
|
||||||
self._utterance_chan: asyncio.Queue[asyncio.Queue[bytes | None] | None] = (
|
|
||||||
asyncio.Queue()
|
|
||||||
)
|
|
||||||
|
|
||||||
@utils.log_exceptions(logger=logger)
|
|
||||||
async def _run(self) -> None:
|
|
||||||
vad_stream = self._vad.stream()
|
|
||||||
|
|
||||||
bstream = utils.audio.AudioByteStream(
|
|
||||||
sample_rate=SAMPLE_RATE,
|
|
||||||
num_channels=NUM_CHANNELS,
|
|
||||||
samples_per_channel=CHUNK_SAMPLES,
|
|
||||||
)
|
|
||||||
|
|
||||||
async def input_task() -> None:
|
|
||||||
async for data in self._input_ch:
|
|
||||||
if isinstance(data, self._FlushSentinel):
|
|
||||||
for frame in bstream.flush():
|
|
||||||
self._handle_chunk(frame.data.tobytes())
|
|
||||||
continue
|
|
||||||
|
|
||||||
vad_stream.push_frame(data)
|
|
||||||
for frame in bstream.write(data.data.tobytes()):
|
|
||||||
self._handle_chunk(frame.data.tobytes())
|
|
||||||
|
|
||||||
vad_stream.end_input()
|
|
||||||
|
|
||||||
async def vad_task() -> None:
|
|
||||||
async for ev in vad_stream:
|
|
||||||
if ev.type == vad_module.VADEventType.START_OF_SPEECH:
|
|
||||||
self._on_start_of_speech()
|
|
||||||
elif ev.type == vad_module.VADEventType.END_OF_SPEECH:
|
|
||||||
self._on_end_of_speech()
|
|
||||||
|
|
||||||
pipeline_t = asyncio.create_task(self._utterance_pipeline())
|
|
||||||
try:
|
|
||||||
await asyncio.gather(input_task(), vad_task())
|
|
||||||
# signal end-of-stream; pipeline finishes pending utterances first
|
|
||||||
self._utterance_chan.put_nowait(None)
|
|
||||||
await pipeline_t
|
|
||||||
except (APIStatusError, APIConnectionError, asyncio.CancelledError):
|
|
||||||
raise
|
|
||||||
except Exception as exc:
|
|
||||||
logger.exception("vLLM realtime stream failed")
|
|
||||||
raise APIConnectionError() from exc
|
|
||||||
finally:
|
|
||||||
if not pipeline_t.done():
|
|
||||||
pipeline_t.cancel()
|
|
||||||
try:
|
|
||||||
await pipeline_t
|
|
||||||
except asyncio.CancelledError:
|
|
||||||
# CancelledError is the expected flow on cancel()
|
|
||||||
pass
|
|
||||||
except Exception:
|
|
||||||
logger.exception("utterance pipeline failed during finalize")
|
|
||||||
await vad_stream.aclose()
|
|
||||||
|
|
||||||
def _handle_chunk(self, chunk: bytes) -> None:
|
|
||||||
self._preroll.append(chunk)
|
|
||||||
if self._speaking and self._utterance_q is not None:
|
|
||||||
self._utterance_q.put_nowait(chunk)
|
|
||||||
|
|
||||||
def _on_start_of_speech(self) -> None:
|
|
||||||
if self._speaking:
|
|
||||||
return
|
|
||||||
self._speaking = True
|
|
||||||
q: asyncio.Queue[bytes | None] = asyncio.Queue()
|
|
||||||
for chunk in self._preroll:
|
|
||||||
q.put_nowait(chunk)
|
|
||||||
self._utterance_q = q
|
|
||||||
self._utterance_chan.put_nowait(q)
|
|
||||||
self._event_ch.send_nowait(
|
|
||||||
stt.SpeechEvent(type=stt.SpeechEventType.START_OF_SPEECH)
|
|
||||||
)
|
|
||||||
|
|
||||||
def _on_end_of_speech(self) -> None:
|
|
||||||
if not self._speaking:
|
|
||||||
return
|
|
||||||
self._speaking = False
|
|
||||||
if self._utterance_q is not None:
|
|
||||||
self._utterance_q.put_nowait(None)
|
|
||||||
self._utterance_q = None
|
|
||||||
self._event_ch.send_nowait(
|
|
||||||
stt.SpeechEvent(type=stt.SpeechEventType.END_OF_SPEECH)
|
|
||||||
)
|
|
||||||
|
|
||||||
async def _handshake(self, ws: websockets.ClientConnection) -> str:
|
|
||||||
created = json.loads(await ws.recv())
|
|
||||||
if created.get("type") != "session.created":
|
|
||||||
raise APIStatusError(
|
|
||||||
f"expected session.created, got {created}",
|
|
||||||
status_code=500,
|
|
||||||
body=created,
|
|
||||||
)
|
|
||||||
session_update: dict = {"type": "session.update", "model": self._opts.model}
|
|
||||||
if self._opts.target_streaming_delay_ms is not None:
|
|
||||||
session_update["target_streaming_delay_ms"] = (
|
|
||||||
self._opts.target_streaming_delay_ms
|
|
||||||
)
|
|
||||||
await ws.send(json.dumps(session_update))
|
|
||||||
return created.get("id", "")
|
|
||||||
|
|
||||||
def _auth_headers(self) -> dict[str, str]:
|
|
||||||
if self._opts.api_key:
|
|
||||||
return {"Authorization": f"Bearer {self._opts.api_key}"}
|
|
||||||
return {}
|
|
||||||
|
|
||||||
async def _utterance_pipeline(self) -> None:
|
|
||||||
# Owns the websocket lifecycle. On drop, reopens and resumes the
|
|
||||||
# in-flight utterance (if any) by replaying its already-sent chunks.
|
|
||||||
pending: _PendingUtterance | None = None
|
|
||||||
attempt = 0
|
|
||||||
while True:
|
|
||||||
try:
|
|
||||||
async with websockets.connect(
|
|
||||||
self._opts.base_url,
|
|
||||||
additional_headers=self._auth_headers(),
|
|
||||||
open_timeout=self._conn_options.timeout,
|
|
||||||
) as ws:
|
|
||||||
request_id = await self._handshake(ws)
|
|
||||||
attempt = 0
|
|
||||||
while True:
|
|
||||||
if pending is None:
|
|
||||||
q = await self._utterance_chan.get()
|
|
||||||
if q is None:
|
|
||||||
return
|
|
||||||
pending = _PendingUtterance(queue=q)
|
|
||||||
await self._process_utterance(ws, pending, request_id)
|
|
||||||
pending = None
|
|
||||||
except (websockets.WebSocketException, OSError, TimeoutError) as exc:
|
|
||||||
attempt += 1
|
|
||||||
if attempt > RECONNECT_MAX_ATTEMPTS:
|
|
||||||
logger.exception(
|
|
||||||
"vLLM realtime: giving up after %d reconnect attempts",
|
|
||||||
RECONNECT_MAX_ATTEMPTS,
|
|
||||||
)
|
|
||||||
raise APIConnectionError() from exc
|
|
||||||
backoff = min(
|
|
||||||
RECONNECT_BACKOFF_BASE_S * (2 ** (attempt - 1)),
|
|
||||||
RECONNECT_BACKOFF_MAX_S,
|
|
||||||
)
|
|
||||||
if pending is None:
|
|
||||||
logger.warning(
|
|
||||||
"vLLM WS connection lost between utterances "
|
|
||||||
"(attempt %d/%d): %s; retrying in %.1fs",
|
|
||||||
attempt,
|
|
||||||
RECONNECT_MAX_ATTEMPTS,
|
|
||||||
exc,
|
|
||||||
backoff,
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
logger.warning(
|
|
||||||
"vLLM WS dropped mid-utterance (%d chunks buffered, "
|
|
||||||
"ended=%s, attempt %d/%d): %s; retrying in %.1fs",
|
|
||||||
len(pending.sent_chunks),
|
|
||||||
pending.ended,
|
|
||||||
attempt,
|
|
||||||
RECONNECT_MAX_ATTEMPTS,
|
|
||||||
exc,
|
|
||||||
backoff,
|
|
||||||
)
|
|
||||||
await asyncio.sleep(backoff)
|
|
||||||
|
|
||||||
async def _process_utterance(
|
|
||||||
self,
|
|
||||||
ws: websockets.ClientConnection,
|
|
||||||
pending: _PendingUtterance,
|
|
||||||
request_id: str,
|
|
||||||
) -> None:
|
|
||||||
# Start a fresh generation. Safe to send here: the previous utterance's
|
|
||||||
# transcription.done has already been received (we await it below), so
|
|
||||||
# the server-side generation_task is done and won't ignore this commit.
|
|
||||||
await ws.send(json.dumps({"type": "input_audio_buffer.commit"}))
|
|
||||||
send_t = asyncio.create_task(self._send_audio(ws, pending))
|
|
||||||
try:
|
|
||||||
await self._receive_one_transcription(ws, request_id)
|
|
||||||
finally:
|
|
||||||
if not send_t.done():
|
|
||||||
send_t.cancel()
|
|
||||||
try:
|
|
||||||
await send_t
|
|
||||||
except (asyncio.CancelledError, websockets.WebSocketException):
|
|
||||||
pass
|
|
||||||
except Exception:
|
|
||||||
logger.exception("send-audio task failed during finalize")
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
async def _send_audio(
|
|
||||||
ws: websockets.ClientConnection, pending: _PendingUtterance
|
|
||||||
) -> None:
|
|
||||||
# Replay anything already sent on a previous (now-dead) connection.
|
|
||||||
# sent_chunks is appended before send, so a chunk that failed to send
|
|
||||||
# last time is still present and gets retried here.
|
|
||||||
for chunk in pending.sent_chunks:
|
|
||||||
await ws.send(
|
|
||||||
json.dumps(
|
|
||||||
{
|
|
||||||
"type": "input_audio_buffer.append",
|
|
||||||
"audio": base64.b64encode(chunk).decode("ascii"),
|
|
||||||
}
|
|
||||||
)
|
|
||||||
)
|
|
||||||
if pending.ended:
|
|
||||||
await ws.send(
|
|
||||||
json.dumps({"type": "input_audio_buffer.commit", "final": True})
|
|
||||||
)
|
|
||||||
return
|
|
||||||
while True:
|
|
||||||
chunk = await pending.queue.get()
|
|
||||||
if chunk is None:
|
|
||||||
pending.ended = True
|
|
||||||
await ws.send(
|
|
||||||
json.dumps({"type": "input_audio_buffer.commit", "final": True})
|
|
||||||
)
|
|
||||||
return
|
|
||||||
pending.sent_chunks.append(chunk)
|
|
||||||
await ws.send(
|
|
||||||
json.dumps(
|
|
||||||
{
|
|
||||||
"type": "input_audio_buffer.append",
|
|
||||||
"audio": base64.b64encode(chunk).decode("ascii"),
|
|
||||||
}
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
async def _receive_one_transcription(
|
|
||||||
self, ws: websockets.ClientConnection, request_id: str
|
|
||||||
) -> None:
|
|
||||||
# Use recv() rather than `async for`: the latter swallows
|
|
||||||
# ConnectionClosed on close-mid-iteration, which would let a dropped
|
|
||||||
# WS look like a clean "no transcription" return.
|
|
||||||
current_text = ""
|
|
||||||
while True:
|
|
||||||
raw = await ws.recv()
|
|
||||||
data = json.loads(raw)
|
|
||||||
event_type = data.get("type")
|
|
||||||
|
|
||||||
if event_type == "transcription.delta":
|
|
||||||
delta = data.get("delta", "")
|
|
||||||
if not delta:
|
|
||||||
continue
|
|
||||||
current_text += delta
|
|
||||||
self._event_ch.send_nowait(
|
|
||||||
stt.SpeechEvent(
|
|
||||||
type=stt.SpeechEventType.INTERIM_TRANSCRIPT,
|
|
||||||
request_id=request_id,
|
|
||||||
alternatives=[stt.SpeechData(text=current_text, language="")],
|
|
||||||
)
|
|
||||||
)
|
|
||||||
elif event_type == "transcription.done":
|
|
||||||
final_text = data.get("text") or current_text
|
|
||||||
self._event_ch.send_nowait(
|
|
||||||
stt.SpeechEvent(
|
|
||||||
type=stt.SpeechEventType.FINAL_TRANSCRIPT,
|
|
||||||
request_id=request_id,
|
|
||||||
alternatives=[stt.SpeechData(text=final_text, language="")],
|
|
||||||
)
|
|
||||||
)
|
|
||||||
usage = data.get("usage") or {}
|
|
||||||
self._event_ch.send_nowait(
|
|
||||||
stt.SpeechEvent(
|
|
||||||
type=stt.SpeechEventType.RECOGNITION_USAGE,
|
|
||||||
request_id=request_id,
|
|
||||||
recognition_usage=stt.RecognitionUsage(
|
|
||||||
audio_duration=float(
|
|
||||||
usage.get("audio_seconds")
|
|
||||||
or usage.get("prompt_audio_seconds")
|
|
||||||
or 0
|
|
||||||
),
|
|
||||||
input_tokens=int(usage.get("prompt_tokens") or 0),
|
|
||||||
output_tokens=int(usage.get("completion_tokens") or 0),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
)
|
|
||||||
return
|
|
||||||
elif event_type == "error":
|
|
||||||
err = data.get("error")
|
|
||||||
raise APIStatusError(str(err), status_code=500, body=data)
|
|
||||||
@@ -8,6 +8,7 @@ class AnalyticsEvent(StrEnum):
|
|||||||
|
|
||||||
# Rooms
|
# Rooms
|
||||||
ROOM_CREATED = "room_created"
|
ROOM_CREATED = "room_created"
|
||||||
|
ROOM_UPDATED = "room_updated"
|
||||||
|
|
||||||
# Roomkit (meeting-room SIP devices)
|
# Roomkit (meeting-room SIP devices)
|
||||||
ROOMKIT_JOINED = "roomkit_joined"
|
ROOMKIT_JOINED = "roomkit_joined"
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ class FeatureFlag:
|
|||||||
|
|
||||||
FLAGS = {
|
FLAGS = {
|
||||||
"recording": "RECORDING_ENABLE",
|
"recording": "RECORDING_ENABLE",
|
||||||
|
"storage_event": "RECORDING_STORAGE_EVENT_ENABLE",
|
||||||
"subtitle": "ROOM_SUBTITLE_ENABLED",
|
"subtitle": "ROOM_SUBTITLE_ENABLED",
|
||||||
"file_upload": "FILE_UPLOAD_ENABLED",
|
"file_upload": "FILE_UPLOAD_ENABLED",
|
||||||
"addons": "ADDONS_ENABLED",
|
"addons": "ADDONS_ENABLED",
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ from django.http import Http404
|
|||||||
|
|
||||||
from rest_framework import permissions
|
from rest_framework import permissions
|
||||||
|
|
||||||
from ..models import RoleChoices, RoomAccessLevel
|
from ..models import RoleChoices
|
||||||
from ..services.participants_management import (
|
from ..services.participants_management import (
|
||||||
ParticipantNotFoundException,
|
ParticipantNotFoundException,
|
||||||
ParticipantsManagement,
|
ParticipantsManagement,
|
||||||
@@ -198,48 +198,3 @@ class IsPresentInMeeting(permissions.BasePermission):
|
|||||||
return False
|
return False
|
||||||
except ParticipantsManagementException:
|
except ParticipantsManagementException:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
class CanManageLobby(permissions.BasePermission):
|
|
||||||
"""Grant lobby management (list/accept/deny waiting participants).
|
|
||||||
|
|
||||||
- Room admins/owners can always manage the lobby.
|
|
||||||
- When the room access level is TRUSTED, any authenticated user who is
|
|
||||||
currently connected to the meeting can manage the lobby. Presence is
|
|
||||||
verified cache-first (Redis), falling back to the LiveKit API.
|
|
||||||
|
|
||||||
Access level is always read fresh from the DB; only presence is cached,
|
|
||||||
so changing the room to RESTRICTED takes effect immediately.
|
|
||||||
"""
|
|
||||||
|
|
||||||
message = "You are not allowed to manage this room's lobby."
|
|
||||||
|
|
||||||
# pylint: disable=too-many-return-statements
|
|
||||||
def has_object_permission(self, request, view, obj): # noqa: PLR0911
|
|
||||||
"""Check privileges first, then the trusted-room presence path."""
|
|
||||||
user = request.user
|
|
||||||
|
|
||||||
if not user or not user.is_authenticated:
|
|
||||||
return False
|
|
||||||
|
|
||||||
# Product choice: lobby management is reserved for session-authenticated
|
|
||||||
# users with a real account, not holders of a LiveKit room token.
|
|
||||||
if request.auth and hasattr(request.auth, "video"):
|
|
||||||
return False
|
|
||||||
|
|
||||||
if obj.is_administrator_or_owner(user):
|
|
||||||
return True
|
|
||||||
|
|
||||||
if obj.access_level != RoomAccessLevel.TRUSTED:
|
|
||||||
return False
|
|
||||||
|
|
||||||
self.message = "You must be connected to the meeting to manage its lobby."
|
|
||||||
|
|
||||||
try:
|
|
||||||
return ParticipantsManagement().check_if_in_meeting_cached(
|
|
||||||
room_name=str(obj.pk), identity=str(user.sub)
|
|
||||||
)
|
|
||||||
except ParticipantNotFoundException:
|
|
||||||
return False
|
|
||||||
except ParticipantsManagementException:
|
|
||||||
return False
|
|
||||||
|
|||||||
@@ -45,11 +45,25 @@ from core.api import throttling
|
|||||||
from core.api.filters import ListFileFilter
|
from core.api.filters import ListFileFilter
|
||||||
from core.enums import MEDIA_STORAGE_URL_PATTERN
|
from core.enums import MEDIA_STORAGE_URL_PATTERN
|
||||||
from core.recording.enums import FileExtension
|
from core.recording.enums import FileExtension
|
||||||
from core.recording.event.authentication import RecordingProcessWebhookAuthentication
|
from core.recording.event.authentication import (
|
||||||
|
RecordingProcessWebhookAuthentication,
|
||||||
|
StorageEventAuthentication,
|
||||||
|
)
|
||||||
|
from core.recording.event.exceptions import (
|
||||||
|
InvalidBucketError,
|
||||||
|
InvalidFilepathError,
|
||||||
|
InvalidFileTypeError,
|
||||||
|
ParsingEventDataError,
|
||||||
|
)
|
||||||
|
from core.recording.event.parsers import get_parser
|
||||||
from core.recording.services.metadata_collector import (
|
from core.recording.services.metadata_collector import (
|
||||||
MetadataCollectorException,
|
MetadataCollectorException,
|
||||||
MetadataCollectorService,
|
MetadataCollectorService,
|
||||||
)
|
)
|
||||||
|
from core.recording.services.recording_events import (
|
||||||
|
RecordingEventsService,
|
||||||
|
RecordingNotSavableError,
|
||||||
|
)
|
||||||
from core.recording.worker.exceptions import (
|
from core.recording.worker.exceptions import (
|
||||||
RecordingStartError,
|
RecordingStartError,
|
||||||
RecordingStopError,
|
RecordingStopError,
|
||||||
@@ -75,11 +89,7 @@ from core.services.participants_management import (
|
|||||||
ParticipantsManagementException,
|
ParticipantsManagementException,
|
||||||
)
|
)
|
||||||
from core.services.room_creation import RoomCreation
|
from core.services.room_creation import RoomCreation
|
||||||
from core.services.room_management import (
|
from core.services.room_management import sync_room_metadata
|
||||||
RoomManagement,
|
|
||||||
RoomManagementException,
|
|
||||||
RoomNotFoundException,
|
|
||||||
)
|
|
||||||
from core.services.room_roles import (
|
from core.services.room_roles import (
|
||||||
RoomRoleError,
|
RoomRoleError,
|
||||||
RoomRoleService,
|
RoomRoleService,
|
||||||
@@ -356,26 +366,7 @@ class RoomViewSet(
|
|||||||
):
|
):
|
||||||
return
|
return
|
||||||
|
|
||||||
metadata = {
|
sync_room_metadata(room)
|
||||||
"configuration": room.configuration,
|
|
||||||
"access_level": room.access_level,
|
|
||||||
}
|
|
||||||
|
|
||||||
try:
|
|
||||||
RoomManagement().update_metadata(
|
|
||||||
room_name=str(room.id),
|
|
||||||
metadata=metadata,
|
|
||||||
)
|
|
||||||
except RoomNotFoundException:
|
|
||||||
logger.info(
|
|
||||||
"LiveKit room %s does not exist yet, skipping metadata sync",
|
|
||||||
room.id,
|
|
||||||
)
|
|
||||||
except RoomManagementException:
|
|
||||||
logger.warning(
|
|
||||||
"Failed to sync metadata to LiveKit for room %s",
|
|
||||||
room.id,
|
|
||||||
)
|
|
||||||
|
|
||||||
@decorators.action(
|
@decorators.action(
|
||||||
detail=True,
|
detail=True,
|
||||||
@@ -522,7 +513,7 @@ class RoomViewSet(
|
|||||||
methods=["post"],
|
methods=["post"],
|
||||||
url_path="enter",
|
url_path="enter",
|
||||||
permission_classes=[
|
permission_classes=[
|
||||||
permissions.CanManageLobby,
|
permissions.HasPrivilegesOnRoom,
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
def allow_participant_to_enter(self, request, pk=None): # pylint: disable=unused-argument
|
def allow_participant_to_enter(self, request, pk=None): # pylint: disable=unused-argument
|
||||||
@@ -560,7 +551,7 @@ class RoomViewSet(
|
|||||||
methods=["GET"],
|
methods=["GET"],
|
||||||
url_path="waiting-participants",
|
url_path="waiting-participants",
|
||||||
permission_classes=[
|
permission_classes=[
|
||||||
permissions.CanManageLobby,
|
permissions.HasPrivilegesOnRoom,
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
def list_waiting_participants(self, request, pk=None): # pylint: disable=unused-argument
|
def list_waiting_participants(self, request, pk=None): # pylint: disable=unused-argument
|
||||||
@@ -1020,6 +1011,56 @@ class RecordingViewSet(
|
|||||||
.filter(Q(accesses__user=user) | Q(accesses__team__in=user.get_teams()))
|
.filter(Q(accesses__user=user) | Q(accesses__team__in=user.get_teams()))
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@decorators.action(
|
||||||
|
detail=False,
|
||||||
|
methods=["post"],
|
||||||
|
url_path="storage-hook",
|
||||||
|
authentication_classes=[StorageEventAuthentication],
|
||||||
|
)
|
||||||
|
@FeatureFlag.require("storage_event")
|
||||||
|
def on_storage_event_received(self, request, pk=None): # pylint: disable=unused-argument
|
||||||
|
"""Handle incoming storage hook events for recordings."""
|
||||||
|
|
||||||
|
parser = get_parser()
|
||||||
|
|
||||||
|
try:
|
||||||
|
recording_id = parser.get_recording_id(request.data)
|
||||||
|
|
||||||
|
except ParsingEventDataError as e:
|
||||||
|
raise drf_exceptions.PermissionDenied("Invalid request data.") from e
|
||||||
|
|
||||||
|
except InvalidBucketError as e:
|
||||||
|
raise drf_exceptions.PermissionDenied("Invalid bucket specified.") from e
|
||||||
|
|
||||||
|
except InvalidFilepathError:
|
||||||
|
return drf_response.Response(
|
||||||
|
{"message": "Notification ignored."},
|
||||||
|
)
|
||||||
|
|
||||||
|
except InvalidFileTypeError:
|
||||||
|
return drf_response.Response(
|
||||||
|
{"message": "Notification ignored."},
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
recording = models.Recording.objects.get(id=recording_id)
|
||||||
|
except models.Recording.DoesNotExist as e:
|
||||||
|
raise drf_exceptions.NotFound("No recording found for this event.") from e
|
||||||
|
|
||||||
|
# Save recording
|
||||||
|
recording_events_service = RecordingEventsService()
|
||||||
|
try:
|
||||||
|
recording_events_service.handle_complete(recording)
|
||||||
|
except RecordingNotSavableError:
|
||||||
|
raise drf_exceptions.PermissionDenied(
|
||||||
|
f"Recording with ID {recording_id} cannot be saved because it is either,"
|
||||||
|
" in an error state or has already been saved."
|
||||||
|
) from None
|
||||||
|
|
||||||
|
return drf_response.Response(
|
||||||
|
{"message": "Event processed."},
|
||||||
|
)
|
||||||
|
|
||||||
@decorators.action(
|
@decorators.action(
|
||||||
detail=False,
|
detail=False,
|
||||||
methods=["post"],
|
methods=["post"],
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ from rest_framework import (
|
|||||||
from core import analytics, api, models
|
from core import analytics, api, models
|
||||||
from core.api.feature_flag import FeatureFlag
|
from core.api.feature_flag import FeatureFlag
|
||||||
from core.services.jwt_token import JwtTokenService
|
from core.services.jwt_token import JwtTokenService
|
||||||
|
from core.services.room_management import sync_room_metadata
|
||||||
|
|
||||||
from ..services.provisional_user_service import (
|
from ..services.provisional_user_service import (
|
||||||
ProvisionalUserCreationDisabledError,
|
ProvisionalUserCreationDisabledError,
|
||||||
@@ -142,6 +143,7 @@ class RoomViewSet(
|
|||||||
mixins.CreateModelMixin,
|
mixins.CreateModelMixin,
|
||||||
mixins.RetrieveModelMixin,
|
mixins.RetrieveModelMixin,
|
||||||
mixins.ListModelMixin,
|
mixins.ListModelMixin,
|
||||||
|
mixins.UpdateModelMixin,
|
||||||
viewsets.GenericViewSet,
|
viewsets.GenericViewSet,
|
||||||
):
|
):
|
||||||
"""Application-delegated API for room management.
|
"""Application-delegated API for room management.
|
||||||
@@ -154,8 +156,12 @@ class RoomViewSet(
|
|||||||
- list: List rooms the user has access to (requires 'rooms:list' scope)
|
- list: List rooms the user has access to (requires 'rooms:list' scope)
|
||||||
- retrieve: Get room details (requires 'rooms:retrieve' scope)
|
- retrieve: Get room details (requires 'rooms:retrieve' scope)
|
||||||
- create: Create a new room owned by the user (requires 'rooms:create' 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_classes = [
|
||||||
authentication.ApplicationJWTAuthentication,
|
authentication.ApplicationJWTAuthentication,
|
||||||
authentication.AddonsJWTAuthentication,
|
authentication.AddonsJWTAuthentication,
|
||||||
@@ -189,6 +195,38 @@ class RoomViewSet(
|
|||||||
serializer = self.get_serializer(queryset, many=True)
|
serializer = self.get_serializer(queryset, many=True)
|
||||||
return drf_response.Response(serializer.data)
|
return drf_response.Response(serializer.data)
|
||||||
|
|
||||||
|
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):
|
def perform_create(self, serializer):
|
||||||
"""Set the current user as owner of the newly created room."""
|
"""Set the current user as owner of the newly created room."""
|
||||||
room = serializer.save()
|
room = serializer.save()
|
||||||
@@ -198,27 +236,31 @@ class RoomViewSet(
|
|||||||
role=models.RoleChoices.OWNER,
|
role=models.RoleChoices.OWNER,
|
||||||
)
|
)
|
||||||
|
|
||||||
auth_method = type(self.request.successful_authenticator).__name__
|
self._track_room_event(room, analytics.AnalyticsEvent.ROOM_CREATED)
|
||||||
client_id = (self.request.auth or {}).get("client_id", "unknown")
|
|
||||||
|
|
||||||
# Log for auditing
|
def perform_update(self, serializer):
|
||||||
logger.info(
|
"""Persist the room update, sync it to LiveKit, then log and track it."""
|
||||||
"Room created via application: room_id=%s, user_id=%s, client_id=%s, auth_method=%s",
|
|
||||||
room.id,
|
previous_values = {
|
||||||
self.request.user.id,
|
"access_level": serializer.instance.access_level,
|
||||||
client_id,
|
"configuration": serializer.instance.configuration,
|
||||||
auth_method,
|
}
|
||||||
|
|
||||||
|
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(
|
if updated_fields:
|
||||||
self.request.user,
|
sync_room_metadata(room)
|
||||||
analytics.AnalyticsEvent.ROOM_CREATED,
|
|
||||||
{
|
self._track_room_event(
|
||||||
"room_id": str(room.pk),
|
room,
|
||||||
"access_level": room.access_level,
|
analytics.AnalyticsEvent.ROOM_UPDATED,
|
||||||
"client_id": client_id,
|
updated_fields=updated_fields,
|
||||||
"external_api": True,
|
previous_access_level=previous_values["access_level"],
|
||||||
"auth_method": auth_method,
|
|
||||||
"$set": {"email": self.request.user.email},
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
"""Authentication classes for server-to-server webhook token validation."""
|
"""Authentication class for storage event token validation."""
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
import secrets
|
import secrets
|
||||||
@@ -12,9 +12,9 @@ logger = logging.getLogger(__name__)
|
|||||||
|
|
||||||
|
|
||||||
class MachineUser:
|
class MachineUser:
|
||||||
"""Represent a non-interactive system user for automated operations."""
|
"""Represent a non-interactive system user for automated storage operations."""
|
||||||
|
|
||||||
def __init__(self, username: str = "machine_user") -> None:
|
def __init__(self, username: str = "storage_event_user") -> None:
|
||||||
self.pk = None
|
self.pk = None
|
||||||
self.username = username
|
self.username = username
|
||||||
self.is_active = True
|
self.is_active = True
|
||||||
@@ -41,17 +41,24 @@ class HeaderBasedAuthentication(BaseAuthentication):
|
|||||||
TOKEN_TYPE = "Bearer" # noqa S105
|
TOKEN_TYPE = "Bearer" # noqa S105
|
||||||
REALM = ""
|
REALM = ""
|
||||||
|
|
||||||
|
IS_ENFORCED_SETTINGS_KEY = None
|
||||||
EXPECTED_TOKEN_SETTINGS_KEY = None
|
EXPECTED_TOKEN_SETTINGS_KEY = None
|
||||||
|
|
||||||
def authenticate(self, request):
|
def authenticate(self, request):
|
||||||
"""Validate the Bearer token from the Authorization header."""
|
"""Validate the Bearer token from the Authorization header."""
|
||||||
|
|
||||||
|
if self.IS_ENFORCED_SETTINGS_KEY is not None:
|
||||||
|
if not getattr(settings, self.IS_ENFORCED_SETTINGS_KEY):
|
||||||
|
return MachineUser(), None
|
||||||
|
|
||||||
if (
|
if (
|
||||||
self.EXPECTED_TOKEN_SETTINGS_KEY is None
|
self.EXPECTED_TOKEN_SETTINGS_KEY is None
|
||||||
or (required_token := getattr(settings, self.EXPECTED_TOKEN_SETTINGS_KEY))
|
or (required_token := getattr(settings, self.EXPECTED_TOKEN_SETTINGS_KEY))
|
||||||
is None
|
is None
|
||||||
):
|
):
|
||||||
raise AuthenticationFailed("Authentication token is not configured.")
|
raise AuthenticationFailed(
|
||||||
|
"Authentication is enabled but token is not configured."
|
||||||
|
)
|
||||||
|
|
||||||
auth_header = request.headers.get(self.AUTH_HEADER)
|
auth_header = request.headers.get(self.AUTH_HEADER)
|
||||||
if not auth_header:
|
if not auth_header:
|
||||||
@@ -81,6 +88,18 @@ class HeaderBasedAuthentication(BaseAuthentication):
|
|||||||
return f"{self.TOKEN_TYPE} realm='{self.REALM}'"
|
return f"{self.TOKEN_TYPE} realm='{self.REALM}'"
|
||||||
|
|
||||||
|
|
||||||
|
class StorageEventAuthentication(HeaderBasedAuthentication):
|
||||||
|
"""Authenticate requests using a Bearer token for storage event integration.
|
||||||
|
This class validates Bearer tokens for storage events that don't map to database users.
|
||||||
|
It's designed for S3-compatible storage integrations and similar use cases.
|
||||||
|
Events are submitted when a webhook is configured on some bucket's events.
|
||||||
|
"""
|
||||||
|
|
||||||
|
REALM = "Storage event API"
|
||||||
|
IS_ENFORCED_SETTINGS_KEY = "RECORDING_ENABLE_STORAGE_EVENT_AUTH"
|
||||||
|
EXPECTED_TOKEN_SETTINGS_KEY = "RECORDING_STORAGE_EVENT_TOKEN" # noqa S105
|
||||||
|
|
||||||
|
|
||||||
class RecordingProcessWebhookAuthentication(HeaderBasedAuthentication):
|
class RecordingProcessWebhookAuthentication(HeaderBasedAuthentication):
|
||||||
"""
|
"""
|
||||||
Custom authentication class for recording process webhook requests.
|
Custom authentication class for recording process webhook requests.
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
"""Storage parsers specific exceptions."""
|
||||||
|
|
||||||
|
|
||||||
|
class ParsingEventDataError(Exception):
|
||||||
|
"""Raised when the request data is malformed, incomplete, or missing."""
|
||||||
|
|
||||||
|
|
||||||
|
class InvalidBucketError(Exception):
|
||||||
|
"""Raised when the bucket name in the request does not match the expected one."""
|
||||||
|
|
||||||
|
|
||||||
|
class InvalidFileTypeError(Exception):
|
||||||
|
"""Raised when the file type in the request is not supported."""
|
||||||
|
|
||||||
|
|
||||||
|
class InvalidFilepathError(Exception):
|
||||||
|
"""Raised when the filepath in the request is invalid."""
|
||||||
@@ -0,0 +1,178 @@
|
|||||||
|
"""Meet storage event parser classes."""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import mimetypes
|
||||||
|
import re
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from functools import lru_cache
|
||||||
|
from typing import Any, Dict, Optional, Protocol
|
||||||
|
from urllib.parse import quote
|
||||||
|
|
||||||
|
from django.conf import settings
|
||||||
|
from django.utils.module_loading import import_string
|
||||||
|
|
||||||
|
from core.enums import FILE_EXT_REGEX, UUID_REGEX
|
||||||
|
|
||||||
|
from .exceptions import (
|
||||||
|
InvalidBucketError,
|
||||||
|
InvalidFilepathError,
|
||||||
|
InvalidFileTypeError,
|
||||||
|
ParsingEventDataError,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Additional MIME type mapping
|
||||||
|
mimetypes.add_type("audio/ogg", ".ogg")
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class StorageEvent:
|
||||||
|
"""Represents a storage event with relevant metadata.
|
||||||
|
Attributes:
|
||||||
|
filepath: Identifier for the affected recording
|
||||||
|
filetype: Type of storage event
|
||||||
|
bucket_name: When the event occurred
|
||||||
|
metadata: Additional event data
|
||||||
|
"""
|
||||||
|
|
||||||
|
filepath: str
|
||||||
|
filetype: str
|
||||||
|
bucket_name: str
|
||||||
|
metadata: Optional[Dict[str, Any]]
|
||||||
|
|
||||||
|
def __post_init__(self):
|
||||||
|
if self.filepath is None:
|
||||||
|
raise TypeError("filepath cannot be None")
|
||||||
|
if self.filetype is None:
|
||||||
|
raise TypeError("filetype cannot be None")
|
||||||
|
if self.bucket_name is None:
|
||||||
|
raise TypeError("bucket_name cannot be None")
|
||||||
|
|
||||||
|
|
||||||
|
class EventParser(Protocol):
|
||||||
|
"""Interface for parsing storage events."""
|
||||||
|
|
||||||
|
def __init__(self, bucket_name, allowed_filetypes=None):
|
||||||
|
"""Initialize parser with bucket name and optional allowed filetypes."""
|
||||||
|
|
||||||
|
def parse(self, data: Dict) -> StorageEvent:
|
||||||
|
"""Extract storage event data from raw dictionary input."""
|
||||||
|
|
||||||
|
def validate(self, data: StorageEvent) -> str:
|
||||||
|
"""Verify storage event data meets all requirements."""
|
||||||
|
|
||||||
|
def get_recording_id(self, data: Dict) -> str:
|
||||||
|
"""Extract recording ID from event dictionary."""
|
||||||
|
|
||||||
|
|
||||||
|
@lru_cache(maxsize=1)
|
||||||
|
def get_parser() -> EventParser:
|
||||||
|
"""Return cached instance of configured event parser.
|
||||||
|
Uses function memoization instead of a factory class since the only
|
||||||
|
varying parameter is the parser class from settings. A factory class
|
||||||
|
would add unnecessary complexity when a cached function provides the
|
||||||
|
same singleton behavior with simpler code.
|
||||||
|
"""
|
||||||
|
|
||||||
|
event_parser_cls = import_string(settings.RECORDING_EVENT_PARSER_CLASS)
|
||||||
|
return event_parser_cls(bucket_name=settings.AWS_STORAGE_BUCKET_NAME)
|
||||||
|
|
||||||
|
|
||||||
|
class BaseS3Parser:
|
||||||
|
"""Base class for handling parsing and validation of S3-compatible storage events."""
|
||||||
|
|
||||||
|
def __init__(self, bucket_name: str, allowed_filetypes=None):
|
||||||
|
"""Initialize parser with target bucket name and accepted filetypes."""
|
||||||
|
|
||||||
|
if not bucket_name:
|
||||||
|
raise ValueError("Bucket name cannot be None or empty")
|
||||||
|
|
||||||
|
self._bucket_name = bucket_name
|
||||||
|
self._allowed_filetypes = allowed_filetypes or {"audio/ogg", "video/mp4"}
|
||||||
|
|
||||||
|
# pylint: disable=line-too-long
|
||||||
|
self._filepath_regex = re.compile(
|
||||||
|
rf"(?P<url_encoded_folder_path>(?:[^%]+%2F)+)?{settings.RECORDING_OUTPUT_FOLDER}%2F(?P<recording_id>{UUID_REGEX})\.(?P<extension>{FILE_EXT_REGEX})"
|
||||||
|
)
|
||||||
|
|
||||||
|
def validate(self, event_data: StorageEvent) -> str:
|
||||||
|
"""Verify StorageEvent matches bucket, filetype and filepath requirements."""
|
||||||
|
|
||||||
|
if event_data.bucket_name != self._bucket_name:
|
||||||
|
raise InvalidBucketError(
|
||||||
|
f"Invalid bucket: expected {self._bucket_name}, got {event_data.bucket_name}"
|
||||||
|
)
|
||||||
|
|
||||||
|
if event_data.filetype not in self._allowed_filetypes:
|
||||||
|
raise InvalidFileTypeError(
|
||||||
|
f"Invalid file type, expected {self._allowed_filetypes},"
|
||||||
|
f"got '{event_data.filetype}'"
|
||||||
|
)
|
||||||
|
|
||||||
|
match = self._filepath_regex.match(event_data.filepath)
|
||||||
|
if not match:
|
||||||
|
raise InvalidFilepathError(
|
||||||
|
f"Invalid filepath structure: {event_data.filepath}"
|
||||||
|
)
|
||||||
|
|
||||||
|
recording_id = match.group("recording_id")
|
||||||
|
return recording_id
|
||||||
|
|
||||||
|
def get_recording_id(self, data):
|
||||||
|
"""Extract recording ID from S3 event through parsing and validation."""
|
||||||
|
|
||||||
|
event_data = self.parse(data)
|
||||||
|
return self.validate(event_data)
|
||||||
|
|
||||||
|
def parse(self, data: Dict) -> StorageEvent:
|
||||||
|
"""To be implemented by subclasses."""
|
||||||
|
raise NotImplementedError("Subclasses must implement parse()")
|
||||||
|
|
||||||
|
|
||||||
|
class MinioParser(BaseS3Parser):
|
||||||
|
"""Minio specific event parsing."""
|
||||||
|
|
||||||
|
def parse(self, data: Dict) -> StorageEvent:
|
||||||
|
if not data:
|
||||||
|
raise ParsingEventDataError("Received empty data.")
|
||||||
|
try:
|
||||||
|
record = data["Records"][0]
|
||||||
|
s3 = record["s3"]
|
||||||
|
return StorageEvent(
|
||||||
|
filepath=s3["object"]["key"],
|
||||||
|
filetype=s3["object"]["contentType"], # Minio-specific field
|
||||||
|
bucket_name=s3["bucket"]["name"],
|
||||||
|
metadata=None,
|
||||||
|
)
|
||||||
|
except (KeyError, IndexError) as e:
|
||||||
|
raise ParsingEventDataError(f"Malformed Minio event: {e}") from e
|
||||||
|
except TypeError as e:
|
||||||
|
raise ParsingEventDataError(f"Missing essential data fields: {e}") from e
|
||||||
|
|
||||||
|
|
||||||
|
class S3Parser(BaseS3Parser):
|
||||||
|
"""AWS S3 specific event parsing."""
|
||||||
|
|
||||||
|
def parse(self, data: Dict) -> StorageEvent:
|
||||||
|
if not data:
|
||||||
|
raise ParsingEventDataError("Received empty data.")
|
||||||
|
try:
|
||||||
|
# AWS S3 structure can slightly differ from Minio implementation
|
||||||
|
record = data["Records"][0]
|
||||||
|
s3 = record["s3"]
|
||||||
|
filepath = s3["object"]["key"]
|
||||||
|
if not filepath:
|
||||||
|
raise ParsingEventDataError("Missing object key name")
|
||||||
|
filetype, _ = mimetypes.guess_type(filepath)
|
||||||
|
# Normalize raw S3-compatible object keys without re-encoding
|
||||||
|
# already encoded AWS S3 notification keys.
|
||||||
|
filepath = quote(filepath, safe="%+")
|
||||||
|
return StorageEvent(
|
||||||
|
filepath=filepath,
|
||||||
|
filetype=filetype,
|
||||||
|
bucket_name=s3["bucket"]["name"],
|
||||||
|
metadata=None,
|
||||||
|
)
|
||||||
|
except (KeyError, IndexError) as e:
|
||||||
|
raise ParsingEventDataError(f"Malformed S3 event: {e}") from e
|
||||||
@@ -23,7 +23,6 @@ from core.recording.services.recording_events import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
from .lobby import LobbyService
|
from .lobby import LobbyService
|
||||||
from .presence import PresenceCache
|
|
||||||
from .room_management import (
|
from .room_management import (
|
||||||
RoomManagement,
|
RoomManagement,
|
||||||
RoomManagementException,
|
RoomManagementException,
|
||||||
@@ -100,7 +99,6 @@ class LiveKitEventsService:
|
|||||||
"egress_ended": self._handle_egress_ended,
|
"egress_ended": self._handle_egress_ended,
|
||||||
"room_started": self._handle_room_started,
|
"room_started": self._handle_room_started,
|
||||||
"room_finished": self._handle_room_finished,
|
"room_finished": self._handle_room_finished,
|
||||||
"participant_left": self._handle_participant_left,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
token_verifier = api.TokenVerifier(
|
token_verifier = api.TokenVerifier(
|
||||||
@@ -109,7 +107,6 @@ class LiveKitEventsService:
|
|||||||
)
|
)
|
||||||
self.webhook_receiver = api.WebhookReceiver(token_verifier)
|
self.webhook_receiver = api.WebhookReceiver(token_verifier)
|
||||||
self.lobby_service = LobbyService()
|
self.lobby_service = LobbyService()
|
||||||
self.presence_cache = PresenceCache()
|
|
||||||
self.sip_management = SIPManagement()
|
self.sip_management = SIPManagement()
|
||||||
self.recording_events = RecordingEventsService()
|
self.recording_events = RecordingEventsService()
|
||||||
|
|
||||||
@@ -220,8 +217,10 @@ class LiveKitEventsService:
|
|||||||
f"Failed to process limit reached event for recording {recording}"
|
f"Failed to process limit reached event for recording {recording}"
|
||||||
) from e
|
) from e
|
||||||
|
|
||||||
# Finalize the recording, the egress has uploaded the file to the storage
|
# Fallback for completion when no MinIO/S3 webhooks are configured
|
||||||
if data.egress_info.status in [
|
if (
|
||||||
|
not settings.RECORDING_STORAGE_EVENT_ENABLE
|
||||||
|
) and data.egress_info.status in [
|
||||||
api.EgressStatus.EGRESS_COMPLETE,
|
api.EgressStatus.EGRESS_COMPLETE,
|
||||||
api.EgressStatus.EGRESS_LIMIT_REACHED,
|
api.EgressStatus.EGRESS_LIMIT_REACHED,
|
||||||
]:
|
]:
|
||||||
@@ -286,31 +285,9 @@ class LiveKitEventsService:
|
|||||||
f"Failed to delete sip dispatch rule for room {room_id}"
|
f"Failed to delete sip dispatch rule for room {room_id}"
|
||||||
) from e
|
) from e
|
||||||
|
|
||||||
self.presence_cache.clear_room(room_id)
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
self.lobby_service.clear_room_cache(room_id)
|
self.lobby_service.clear_room_cache(room_id)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
raise ActionFailedError(
|
raise ActionFailedError(
|
||||||
f"Failed to clear room cache for room {room_id}"
|
f"Failed to clear room cache for room {room_id}"
|
||||||
) from e
|
) from e
|
||||||
|
|
||||||
def _handle_participant_left(self, data):
|
|
||||||
"""Handle 'participant_left': invalidate the presence cache.
|
|
||||||
|
|
||||||
Presence entries are created lazily (only for users who administrate
|
|
||||||
the lobby of a trusted room), so for most participants this delete is
|
|
||||||
a no-op DEL on a key that never existed. Eager invalidation shrinks
|
|
||||||
the window during which a departed participant could still act on a
|
|
||||||
trusted room's lobby (cache hit until TTL expiry). It is gated behind
|
|
||||||
`PRESENCE_CLEAR_ON_PARTICIPANT_LEFT` so its production impact can be
|
|
||||||
measured and the behaviour reverted independently of the feature.
|
|
||||||
When disabled, invalidation relies on `room_finished` and the TTL.
|
|
||||||
"""
|
|
||||||
if not settings.PRESENCE_CLEAR_ON_PARTICIPANT_LEFT:
|
|
||||||
return
|
|
||||||
|
|
||||||
identity = data.participant.identity
|
|
||||||
if not identity:
|
|
||||||
return
|
|
||||||
self.presence_cache.clear(data.room.name, identity)
|
|
||||||
|
|||||||
@@ -270,7 +270,7 @@ class LobbyService:
|
|||||||
"""List all waiting participants for a room."""
|
"""List all waiting participants for a room."""
|
||||||
|
|
||||||
pattern = self._get_cache_key(room_id, "*")
|
pattern = self._get_cache_key(room_id, "*")
|
||||||
keys = list(cache.iter_keys(pattern, itersize=utils.CACHE_SCAN_ITERSIZE))
|
keys = cache.keys(pattern)
|
||||||
|
|
||||||
if not keys:
|
if not keys:
|
||||||
return []
|
return []
|
||||||
@@ -345,9 +345,13 @@ class LobbyService:
|
|||||||
def clear_room_cache(self, room_id: UUID) -> None:
|
def clear_room_cache(self, room_id: UUID) -> None:
|
||||||
"""Clear all participant entries from the cache for a specific room."""
|
"""Clear all participant entries from the cache for a specific room."""
|
||||||
|
|
||||||
cache.delete_pattern(
|
pattern = self._get_cache_key(room_id, "*")
|
||||||
self._get_cache_key(room_id, "*"), itersize=utils.CACHE_SCAN_ITERSIZE
|
keys = cache.keys(pattern)
|
||||||
)
|
|
||||||
|
if not keys:
|
||||||
|
return
|
||||||
|
|
||||||
|
cache.delete_many(keys)
|
||||||
|
|
||||||
def clear_participant_cache(self, room_id: UUID, participant_id: str) -> None:
|
def clear_participant_cache(self, room_id: UUID, participant_id: str) -> None:
|
||||||
"""Clear a given participant entry from the cache for a specific room."""
|
"""Clear a given participant entry from the cache for a specific room."""
|
||||||
|
|||||||
@@ -20,7 +20,6 @@ from livekit.protocol.models import ParticipantInfo
|
|||||||
from core import utils
|
from core import utils
|
||||||
|
|
||||||
from .lobby import LobbyService
|
from .lobby import LobbyService
|
||||||
from .presence import PresenceCache
|
|
||||||
|
|
||||||
logger = getLogger(__name__)
|
logger = getLogger(__name__)
|
||||||
|
|
||||||
@@ -73,9 +72,7 @@ class ParticipantsManagement:
|
|||||||
|
|
||||||
@async_to_sync
|
@async_to_sync
|
||||||
async def remove(self, room_name: str, identity: str):
|
async def remove(self, room_name: str, identity: str):
|
||||||
"""Remove a participant from a room and clear their lobby/presence cache."""
|
"""Remove a participant from a room and clear their lobby cache."""
|
||||||
|
|
||||||
PresenceCache().clear(room_name, identity)
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
LobbyService().clear_participant_cache(
|
LobbyService().clear_participant_cache(
|
||||||
@@ -159,30 +156,6 @@ class ParticipantsManagement:
|
|||||||
finally:
|
finally:
|
||||||
await lkapi.aclose()
|
await lkapi.aclose()
|
||||||
|
|
||||||
def check_if_in_meeting_cached(self, room_name: str, identity: str) -> bool:
|
|
||||||
"""Cache-first variant of `check_if_in_meeting`.
|
|
||||||
|
|
||||||
Cache hit -> True without touching LiveKit.
|
|
||||||
Cache miss -> ask LiveKit; memoize only positive answers.
|
|
||||||
|
|
||||||
Raises the same exceptions as `check_if_in_meeting` so callers keep
|
|
||||||
failing closed the same way.
|
|
||||||
"""
|
|
||||||
if not room_name or not identity:
|
|
||||||
return False
|
|
||||||
|
|
||||||
presence_cache = PresenceCache()
|
|
||||||
|
|
||||||
if presence_cache.is_marked_present(room_name, identity):
|
|
||||||
return True
|
|
||||||
|
|
||||||
present = self.check_if_in_meeting(room_name=room_name, identity=identity)
|
|
||||||
|
|
||||||
if present:
|
|
||||||
presence_cache.mark_present(room_name, identity)
|
|
||||||
|
|
||||||
return present
|
|
||||||
|
|
||||||
@async_to_sync
|
@async_to_sync
|
||||||
async def check_if_in_meeting(self, room_name: str, identity: str) -> bool:
|
async def check_if_in_meeting(self, room_name: str, identity: str) -> bool:
|
||||||
"""Check whether `identity` is currently a participant in `room_name`.
|
"""Check whether `identity` is currently a participant in `room_name`.
|
||||||
|
|||||||
@@ -1,52 +0,0 @@
|
|||||||
"""Presence cache.
|
|
||||||
|
|
||||||
Redis-backed memo of "this identity is currently connected to this room".
|
|
||||||
|
|
||||||
This module is intentionally a *pure cache store* with no dependency on other
|
|
||||||
services, so that `participants_management` (which talks to LiveKit) can
|
|
||||||
import it without creating an import cycle. The composition of "check cache,
|
|
||||||
fall back to LiveKit" lives in
|
|
||||||
`ParticipantsManagement.check_if_in_meeting_cached`.
|
|
||||||
|
|
||||||
Only positive answers are stored: a sticky negative would lock out someone
|
|
||||||
who joins right after a miss for the whole TTL. The TTL is a safety net in
|
|
||||||
case an invalidation webhook is lost.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from uuid import UUID
|
|
||||||
|
|
||||||
from django.conf import settings
|
|
||||||
from django.core.cache import cache
|
|
||||||
|
|
||||||
from core.utils import CACHE_SCAN_ITERSIZE
|
|
||||||
|
|
||||||
|
|
||||||
class PresenceCache:
|
|
||||||
"""Store and invalidate (room, identity) presence entries."""
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _get_cache_key(room_id: UUID | str, identity: str) -> str:
|
|
||||||
"""Cache key for a (room, identity) presence entry."""
|
|
||||||
return f"{settings.PRESENCE_KEY_PREFIX}_{room_id!s}_{identity}"
|
|
||||||
|
|
||||||
def is_marked_present(self, room_id: UUID | str, identity: str) -> bool:
|
|
||||||
"""Return True if a positive presence entry exists in cache."""
|
|
||||||
return bool(cache.get(self._get_cache_key(room_id, identity)))
|
|
||||||
|
|
||||||
def mark_present(self, room_id: UUID | str, identity: str) -> None:
|
|
||||||
"""Record that `identity` is in `room_id`."""
|
|
||||||
cache.set(
|
|
||||||
self._get_cache_key(room_id, identity),
|
|
||||||
True,
|
|
||||||
timeout=settings.PRESENCE_CACHE_TIMEOUT,
|
|
||||||
)
|
|
||||||
|
|
||||||
def clear(self, room_id: UUID | str, identity: str) -> None:
|
|
||||||
"""Forget presence for one participant (e.g. on participant_left)."""
|
|
||||||
cache.delete(self._get_cache_key(room_id, identity))
|
|
||||||
|
|
||||||
def clear_room(self, room_id: UUID | str) -> None:
|
|
||||||
"""Forget presence for every participant of a room (on room_finished)."""
|
|
||||||
cache.delete_pattern(
|
|
||||||
self._get_cache_key(room_id, "*"), itersize=CACHE_SCAN_ITERSIZE
|
|
||||||
)
|
|
||||||
@@ -116,3 +116,32 @@ class RoomManagement:
|
|||||||
raise RoomManagementException("Could not delete room") from e
|
raise RoomManagementException("Could not delete room") from e
|
||||||
finally:
|
finally:
|
||||||
await lkapi.aclose()
|
await lkapi.aclose()
|
||||||
|
|
||||||
|
|
||||||
|
def sync_room_metadata(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:
|
||||||
|
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,
|
||||||
|
)
|
||||||
|
|||||||
@@ -11,98 +11,135 @@ from rest_framework.exceptions import AuthenticationFailed
|
|||||||
|
|
||||||
from core.recording.event.authentication import (
|
from core.recording.event.authentication import (
|
||||||
MachineUser,
|
MachineUser,
|
||||||
RecordingProcessWebhookAuthentication,
|
StorageEventAuthentication,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def test_successful_authentication(settings):
|
def test_successful_authentication(settings):
|
||||||
"""Test successful authentication with valid token."""
|
"""Test successful authentication with valid token."""
|
||||||
settings.SUMMARY_SERVICE_WEBHOOK_API_TOKEN = "valid-test-token"
|
settings.RECORDING_STORAGE_EVENT_TOKEN = "valid-test-token"
|
||||||
request = RequestFactory().get("/")
|
request = RequestFactory().get("/")
|
||||||
request.headers = {"Authorization": "Bearer valid-test-token"}
|
request.headers = {"Authorization": "Bearer valid-test-token"}
|
||||||
|
|
||||||
user, token = RecordingProcessWebhookAuthentication().authenticate(request)
|
user, token = StorageEventAuthentication().authenticate(request)
|
||||||
assert token == "valid-test-token"
|
assert token == "valid-test-token"
|
||||||
assert isinstance(user, MachineUser)
|
assert isinstance(user, MachineUser)
|
||||||
|
|
||||||
|
|
||||||
def test_authentication_fails_when_token_not_configured(settings):
|
def test_disabled_authentication_with_header(settings):
|
||||||
"""Authentication should fail when no token is configured."""
|
"""Authentication should pass when no auth is configured, and header is present."""
|
||||||
|
settings.RECORDING_STORAGE_EVENT_TOKEN = None
|
||||||
|
settings.RECORDING_ENABLE_STORAGE_EVENT_AUTH = False
|
||||||
|
|
||||||
settings.SUMMARY_SERVICE_WEBHOOK_API_TOKEN = None
|
request = RequestFactory().get("/")
|
||||||
|
request.headers = {"Authorization": "Bearer some-token"}
|
||||||
|
|
||||||
|
user, token = StorageEventAuthentication().authenticate(request)
|
||||||
|
assert token is None
|
||||||
|
assert isinstance(user, MachineUser)
|
||||||
|
|
||||||
|
|
||||||
|
def test_disabled_authentication_without_header(settings):
|
||||||
|
"""Authentication should pass when no auth is configured, and no header is present."""
|
||||||
|
settings.RECORDING_STORAGE_EVENT_TOKEN = None
|
||||||
|
settings.RECORDING_ENABLE_STORAGE_EVENT_AUTH = False
|
||||||
|
|
||||||
|
request = RequestFactory().get("/")
|
||||||
|
|
||||||
|
user, token = StorageEventAuthentication().authenticate(request)
|
||||||
|
assert token is None
|
||||||
|
assert isinstance(user, MachineUser)
|
||||||
|
|
||||||
|
|
||||||
|
def test_authentication_when_disabled(settings):
|
||||||
|
"""Authentication should pass when disabled, regardless of token configuration."""
|
||||||
|
settings.RECORDING_STORAGE_EVENT_TOKEN = "some-token"
|
||||||
|
settings.RECORDING_ENABLE_STORAGE_EVENT_AUTH = False
|
||||||
|
|
||||||
|
request = RequestFactory().get("/")
|
||||||
|
|
||||||
|
user, token = StorageEventAuthentication().authenticate(request)
|
||||||
|
assert token is None
|
||||||
|
assert isinstance(user, MachineUser)
|
||||||
|
|
||||||
|
|
||||||
|
def test_authentication_fails_when_token_not_configured(settings):
|
||||||
|
"""Authentication should fail when authentication is enabled but no token is configured."""
|
||||||
|
|
||||||
|
# By default RECORDING_ENABLE_STORAGE_EVENT_AUTH should be True
|
||||||
|
settings.RECORDING_STORAGE_EVENT_TOKEN = None
|
||||||
|
|
||||||
request = RequestFactory().get("/")
|
request = RequestFactory().get("/")
|
||||||
|
|
||||||
with pytest.raises(
|
with pytest.raises(
|
||||||
AuthenticationFailed,
|
AuthenticationFailed,
|
||||||
match="Authentication token is not configured",
|
match="Authentication is enabled but token is not configured",
|
||||||
):
|
):
|
||||||
RecordingProcessWebhookAuthentication().authenticate(request)
|
StorageEventAuthentication().authenticate(request)
|
||||||
|
|
||||||
|
|
||||||
def test_missing_auth_header(settings):
|
def test_missing_auth_header(settings):
|
||||||
"""Test failure when Authorization header is missing."""
|
"""Test failure when Authorization header is missing."""
|
||||||
settings.SUMMARY_SERVICE_WEBHOOK_API_TOKEN = "valid-test-token"
|
settings.RECORDING_STORAGE_EVENT_TOKEN = "valid-test-token"
|
||||||
request = RequestFactory().get("/")
|
request = RequestFactory().get("/")
|
||||||
request.headers = {}
|
request.headers = {}
|
||||||
|
|
||||||
with pytest.raises(AuthenticationFailed, match="Authorization header is required"):
|
with pytest.raises(AuthenticationFailed, match="Authorization header is required"):
|
||||||
RecordingProcessWebhookAuthentication().authenticate(request)
|
StorageEventAuthentication().authenticate(request)
|
||||||
|
|
||||||
|
|
||||||
def test_invalid_auth_header_format(settings):
|
def test_invalid_auth_header_format(settings):
|
||||||
"""Test failure when Authorization header has invalid format."""
|
"""Test failure when Authorization header has invalid format."""
|
||||||
settings.SUMMARY_SERVICE_WEBHOOK_API_TOKEN = "valid-test-token"
|
settings.RECORDING_STORAGE_EVENT_TOKEN = "valid-test-token"
|
||||||
request = RequestFactory().get("/")
|
request = RequestFactory().get("/")
|
||||||
request.headers = {"Authorization": "InvalidFormat"}
|
request.headers = {"Authorization": "InvalidFormat"}
|
||||||
|
|
||||||
with pytest.raises(AuthenticationFailed, match="Invalid authorization header"):
|
with pytest.raises(AuthenticationFailed, match="Invalid authorization header"):
|
||||||
RecordingProcessWebhookAuthentication().authenticate(request)
|
StorageEventAuthentication().authenticate(request)
|
||||||
|
|
||||||
|
|
||||||
def test_invalid_token_type(settings):
|
def test_invalid_token_type(settings):
|
||||||
"""Test failure when token type is not Bearer."""
|
"""Test failure when token type is not Bearer."""
|
||||||
settings.SUMMARY_SERVICE_WEBHOOK_API_TOKEN = "valid-test-token"
|
settings.RECORDING_STORAGE_EVENT_TOKEN = "valid-test-token"
|
||||||
request = RequestFactory().get("/")
|
request = RequestFactory().get("/")
|
||||||
request.headers = {"Authorization": "Basic some-token"}
|
request.headers = {"Authorization": "Basic some-token"}
|
||||||
|
|
||||||
with pytest.raises(AuthenticationFailed, match="Invalid authorization header"):
|
with pytest.raises(AuthenticationFailed, match="Invalid authorization header"):
|
||||||
RecordingProcessWebhookAuthentication().authenticate(request)
|
StorageEventAuthentication().authenticate(request)
|
||||||
|
|
||||||
|
|
||||||
def test_invalid_token(settings):
|
def test_invalid_token(settings):
|
||||||
"""Test failure when token is invalid."""
|
"""Test failure when token is invalid."""
|
||||||
settings.SUMMARY_SERVICE_WEBHOOK_API_TOKEN = "valid-test-token"
|
settings.RECORDING_STORAGE_EVENT_TOKEN = "valid-test-token"
|
||||||
request = RequestFactory().get("/")
|
request = RequestFactory().get("/")
|
||||||
request.headers = {"Authorization": "Bearer wrong-token"}
|
request.headers = {"Authorization": "Bearer wrong-token"}
|
||||||
|
|
||||||
with pytest.raises(AuthenticationFailed, match="Invalid token"):
|
with pytest.raises(AuthenticationFailed, match="Invalid token"):
|
||||||
RecordingProcessWebhookAuthentication().authenticate(request)
|
StorageEventAuthentication().authenticate(request)
|
||||||
|
|
||||||
|
|
||||||
def test_malformed_auth_header(settings):
|
def test_malformed_auth_header(settings):
|
||||||
"""Test failure when Authorization header is malformed."""
|
"""Test failure when Authorization header is malformed."""
|
||||||
settings.SUMMARY_SERVICE_WEBHOOK_API_TOKEN = "valid-test-token"
|
settings.RECORDING_STORAGE_EVENT_TOKEN = "valid-test-token"
|
||||||
request = RequestFactory().get("/")
|
request = RequestFactory().get("/")
|
||||||
request.headers = {"Authorization": "Bearer"} # Missing token part
|
request.headers = {"Authorization": "Bearer"} # Missing token part
|
||||||
|
|
||||||
with pytest.raises(AuthenticationFailed, match="Invalid authorization header"):
|
with pytest.raises(AuthenticationFailed, match="Invalid authorization header"):
|
||||||
RecordingProcessWebhookAuthentication().authenticate(request)
|
StorageEventAuthentication().authenticate(request)
|
||||||
|
|
||||||
|
|
||||||
def test_authenticate_header():
|
def test_authenticate_header():
|
||||||
"""Test the WWW-Authenticate header value."""
|
"""Test the WWW-Authenticate header value."""
|
||||||
request = RequestFactory().get("/")
|
request = RequestFactory().get("/")
|
||||||
header = RecordingProcessWebhookAuthentication().authenticate_header(request)
|
header = StorageEventAuthentication().authenticate_header(request)
|
||||||
assert header == "Bearer realm='External process webhook API'"
|
assert header == "Bearer realm='Storage event API'"
|
||||||
|
|
||||||
|
|
||||||
def test_multiple_spaces_in_auth_header(settings):
|
def test_multiple_spaces_in_auth_header(settings):
|
||||||
"""Extra spaces between the scheme and the token should be tolerated."""
|
"""Test success when Authorization header contains multiple spaces."""
|
||||||
settings.SUMMARY_SERVICE_WEBHOOK_API_TOKEN = "extra-spaces-token"
|
settings.RECORDING_STORAGE_EVENT_TOKEN = "valid-test-token"
|
||||||
request = RequestFactory().get("/")
|
request = RequestFactory().get("/")
|
||||||
request.headers = {"Authorization": "Bearer extra-spaces-token"}
|
request.headers = {"Authorization": "Bearer extra-spaces-token"}
|
||||||
|
|
||||||
user, token = RecordingProcessWebhookAuthentication().authenticate(request)
|
header = StorageEventAuthentication().authenticate_header(request)
|
||||||
assert token == "extra-spaces-token"
|
assert header == "Bearer realm='Storage event API'"
|
||||||
assert isinstance(user, MachineUser)
|
|
||||||
|
|||||||
@@ -0,0 +1,512 @@
|
|||||||
|
"""
|
||||||
|
Test event parsers.
|
||||||
|
"""
|
||||||
|
|
||||||
|
# pylint: disable=protected-access,redefined-outer-name,unused-argument
|
||||||
|
|
||||||
|
from unittest import mock
|
||||||
|
|
||||||
|
from django.conf import settings
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from core.recording.event.exceptions import (
|
||||||
|
InvalidBucketError,
|
||||||
|
InvalidFilepathError,
|
||||||
|
InvalidFileTypeError,
|
||||||
|
ParsingEventDataError,
|
||||||
|
)
|
||||||
|
from core.recording.event.parsers import (
|
||||||
|
MinioParser,
|
||||||
|
S3Parser,
|
||||||
|
StorageEvent,
|
||||||
|
get_parser,
|
||||||
|
)
|
||||||
|
|
||||||
|
# MinioParser
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def valid_minio_event():
|
||||||
|
"""Mock a valid Minio event."""
|
||||||
|
return {
|
||||||
|
"Records": [
|
||||||
|
{
|
||||||
|
"s3": {
|
||||||
|
"bucket": {"name": "test-bucket"},
|
||||||
|
"object": {
|
||||||
|
"key": "recordings%2F46d1a121-2426-484d-8fb3-09b5d886f7a8.ogg",
|
||||||
|
"contentType": "audio/ogg",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def minio_parser():
|
||||||
|
"""Mock a Minio parser."""
|
||||||
|
return MinioParser(bucket_name="test-bucket")
|
||||||
|
|
||||||
|
|
||||||
|
def test_minio_parse_valid_event(minio_parser, valid_minio_event):
|
||||||
|
"""Test parsing a valid Minio event."""
|
||||||
|
event = minio_parser.parse(valid_minio_event)
|
||||||
|
assert isinstance(event, StorageEvent)
|
||||||
|
assert event.filepath == "recordings%2F46d1a121-2426-484d-8fb3-09b5d886f7a8.ogg"
|
||||||
|
assert event.filetype == "audio/ogg"
|
||||||
|
assert event.bucket_name == "test-bucket"
|
||||||
|
assert event.metadata is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_minio_parse_with_video_type(minio_parser):
|
||||||
|
"""Test parsing event with video file type."""
|
||||||
|
video_event = {
|
||||||
|
"Records": [
|
||||||
|
{
|
||||||
|
"s3": {
|
||||||
|
"bucket": {"name": "test-bucket"},
|
||||||
|
"object": {
|
||||||
|
"key": "46d1a121-2426-484d-8fb3-09b5d886f7a8.mp4",
|
||||||
|
"contentType": "video/mp4",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
event = minio_parser.parse(video_event)
|
||||||
|
assert event.filetype == "video/mp4"
|
||||||
|
assert event.filepath.endswith(".mp4")
|
||||||
|
|
||||||
|
|
||||||
|
def test_minio_parse_empty_data(minio_parser):
|
||||||
|
"""Test parsing empty event data raises error."""
|
||||||
|
with pytest.raises(ParsingEventDataError, match="Received empty data."):
|
||||||
|
minio_parser.parse({})
|
||||||
|
|
||||||
|
|
||||||
|
def test_minio_parse_missing_keys(minio_parser):
|
||||||
|
"""Test parsing event with missing key."""
|
||||||
|
|
||||||
|
invalid_minio_event = {
|
||||||
|
"Records": [
|
||||||
|
{
|
||||||
|
"s3": {
|
||||||
|
"bucket": {"name": None},
|
||||||
|
# Missing 'object' key
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
with pytest.raises(ParsingEventDataError, match="Malformed Minio event:"):
|
||||||
|
minio_parser.parse(invalid_minio_event)
|
||||||
|
|
||||||
|
|
||||||
|
def test_minio_parse_none_key(minio_parser):
|
||||||
|
"""Test parsing event with None field."""
|
||||||
|
|
||||||
|
invalid_minio_event = {
|
||||||
|
"Records": [
|
||||||
|
{
|
||||||
|
"s3": {
|
||||||
|
"bucket": {"name": "test-bucket"},
|
||||||
|
"object": {
|
||||||
|
"key": "recording%2F46d1a121-2426-484d-8fb3-09b5d886f7a8.ogg",
|
||||||
|
"contentType": None, # 'contentType' should not be None
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
with pytest.raises(ParsingEventDataError, match="Missing essential data fields"):
|
||||||
|
minio_parser.parse(invalid_minio_event)
|
||||||
|
|
||||||
|
|
||||||
|
def test_minio_validate_invalid_bucket(minio_parser):
|
||||||
|
"""Test validation with wrong bucket name."""
|
||||||
|
event = StorageEvent(
|
||||||
|
filepath="recording%2F46d1a121-2426-484d-8fb3-09b5d886f7a8.ogg",
|
||||||
|
filetype="audio/ogg",
|
||||||
|
bucket_name="wrong-bucket",
|
||||||
|
metadata=None,
|
||||||
|
)
|
||||||
|
with pytest.raises(InvalidBucketError):
|
||||||
|
minio_parser.validate(event)
|
||||||
|
|
||||||
|
|
||||||
|
def test_minio_validate_invalid_filetype(minio_parser):
|
||||||
|
"""Test validation with unsupported file type."""
|
||||||
|
event = StorageEvent(
|
||||||
|
filepath="recording%2F46d1a121-2426-484d-8fb3-09b5d886f7a8.txt",
|
||||||
|
filetype="text/plain", # Not included in the default allowed filetypes
|
||||||
|
bucket_name="test-bucket",
|
||||||
|
metadata=None,
|
||||||
|
)
|
||||||
|
with pytest.raises(InvalidFileTypeError):
|
||||||
|
minio_parser.validate(event)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"invalid_filepath",
|
||||||
|
[
|
||||||
|
"invalid_filepath", # totally invalid string
|
||||||
|
"recordings/46d1a121-2426-484d-8fb3-09b5d886f7a8.ogg",
|
||||||
|
"recordings/46d1a121-2426-484d-8fb3-09b5d886f7a8", # missing extension
|
||||||
|
"46d1a121-2426-484d-8fb3-09b5d886f7a8", # missing url_encoded_folder_path and extension
|
||||||
|
"", # empty string
|
||||||
|
"46d1a121-2426-484d-8fb3-09b5d886f7a8.ogg", # no folder at all
|
||||||
|
"uploads%2F46d1a121-2426-484d-8fb3-09b5d886f7a8.ogg", # wrong folder name
|
||||||
|
"folder%2Fuploads%2F46d1a121-2426-484d-8fb3-09b5d886f7a8.ogg", # nested but no recordings/
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_minio_validate_invalid_filepath(invalid_filepath, minio_parser):
|
||||||
|
"""Test validation with malformed filepath."""
|
||||||
|
event = StorageEvent(
|
||||||
|
filepath=invalid_filepath,
|
||||||
|
filetype="audio/ogg",
|
||||||
|
bucket_name="test-bucket",
|
||||||
|
metadata=None,
|
||||||
|
)
|
||||||
|
with pytest.raises(InvalidFilepathError):
|
||||||
|
minio_parser.validate(event)
|
||||||
|
|
||||||
|
|
||||||
|
def test_minio_validate_valid_event(minio_parser):
|
||||||
|
"""Test validation with valid event data."""
|
||||||
|
event = StorageEvent(
|
||||||
|
filepath="recordings%2F46d1a121-2426-484d-8fb3-09b5d886f7a8.ogg",
|
||||||
|
filetype="audio/ogg",
|
||||||
|
bucket_name="test-bucket",
|
||||||
|
metadata=None,
|
||||||
|
)
|
||||||
|
recording_id = minio_parser.validate(event)
|
||||||
|
assert recording_id == "46d1a121-2426-484d-8fb3-09b5d886f7a8"
|
||||||
|
|
||||||
|
|
||||||
|
def test_minio_get_recording_id_success(minio_parser, valid_minio_event):
|
||||||
|
"""Test successful extraction of recording ID."""
|
||||||
|
recording_id = minio_parser.get_recording_id(valid_minio_event)
|
||||||
|
assert recording_id == "46d1a121-2426-484d-8fb3-09b5d886f7a8"
|
||||||
|
|
||||||
|
|
||||||
|
def test_minio_validate_filepath_with_folder(minio_parser):
|
||||||
|
"""Test validation of filepath with folder structure."""
|
||||||
|
event = StorageEvent(
|
||||||
|
filepath="parent_folder%2Frecordings%2F46d1a121-2426-484d-8fb3-09b5d886f7a8.ogg",
|
||||||
|
filetype="audio/ogg",
|
||||||
|
bucket_name="test-bucket",
|
||||||
|
metadata=None,
|
||||||
|
)
|
||||||
|
recording_id = minio_parser.validate(event)
|
||||||
|
assert recording_id == "46d1a121-2426-484d-8fb3-09b5d886f7a8"
|
||||||
|
|
||||||
|
|
||||||
|
def test_minio_empty_allowed_filetypes():
|
||||||
|
"""Test MinioParser with empty allowed_filetypes."""
|
||||||
|
empty_types = set()
|
||||||
|
parser = MinioParser(bucket_name="test-bucket", allowed_filetypes=empty_types)
|
||||||
|
assert parser._allowed_filetypes == {"audio/ogg", "video/mp4"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_minio_custom_allowed_filetypes():
|
||||||
|
"""Test MinioParser with empty allowed_filetypes."""
|
||||||
|
custom_types = {"audio/mp3", "video/mov"}
|
||||||
|
parser = MinioParser(bucket_name="test-bucket", allowed_filetypes=custom_types)
|
||||||
|
assert parser._allowed_filetypes == {"audio/mp3", "video/mov"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_minio_validate_custom_filetypes():
|
||||||
|
"""Test validation of filepath with folder structure."""
|
||||||
|
|
||||||
|
parser = MinioParser(bucket_name="test-bucket", allowed_filetypes={"audio/mp3"})
|
||||||
|
|
||||||
|
event = StorageEvent(
|
||||||
|
filepath="parent_folder%2Frecordings%2F46d1a121-2426-484d-8fb3-09b5d886f7a8.ogg",
|
||||||
|
filetype="audio/mp3",
|
||||||
|
bucket_name="test-bucket",
|
||||||
|
metadata=None,
|
||||||
|
)
|
||||||
|
parser.validate(event)
|
||||||
|
|
||||||
|
|
||||||
|
def test_minio_constructor_none_bucket():
|
||||||
|
"""Test MinioParser constructor with None bucket name."""
|
||||||
|
with pytest.raises(ValueError, match="Bucket name cannot be None or empty"):
|
||||||
|
MinioParser(bucket_name=None)
|
||||||
|
|
||||||
|
|
||||||
|
def test_minio_constructor_empty_bucket():
|
||||||
|
"""Test MinioParser constructor with empty bucket name."""
|
||||||
|
with pytest.raises(ValueError, match="Bucket name cannot be None or empty"):
|
||||||
|
MinioParser(bucket_name="")
|
||||||
|
|
||||||
|
|
||||||
|
# S3Parser
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def valid_s3_event():
|
||||||
|
"""Mock a valid S3 event."""
|
||||||
|
return {
|
||||||
|
"Records": [
|
||||||
|
{
|
||||||
|
"s3": {
|
||||||
|
"bucket": {"name": "test-bucket"},
|
||||||
|
"object": {
|
||||||
|
"key": "recordings%2F46d1a121-2426-484d-8fb3-09b5d886f7a8.ogg",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def s3_parser():
|
||||||
|
"""Mock an S3 parser."""
|
||||||
|
return S3Parser(bucket_name="test-bucket")
|
||||||
|
|
||||||
|
|
||||||
|
def test_s3_parse_valid_event(s3_parser, valid_s3_event):
|
||||||
|
"""Test parsing a valid S3 event."""
|
||||||
|
event = s3_parser.parse(valid_s3_event)
|
||||||
|
assert isinstance(event, StorageEvent)
|
||||||
|
assert event.filepath == "recordings%2F46d1a121-2426-484d-8fb3-09b5d886f7a8.ogg"
|
||||||
|
assert event.filetype == "audio/ogg"
|
||||||
|
assert event.bucket_name == "test-bucket"
|
||||||
|
assert event.metadata is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_s3_parse_empty_data(s3_parser):
|
||||||
|
"""Test parsing empty S3 event data raises error."""
|
||||||
|
with pytest.raises(ParsingEventDataError, match="Received empty data."):
|
||||||
|
s3_parser.parse({})
|
||||||
|
|
||||||
|
|
||||||
|
def test_s3_parse_missing_keys(s3_parser):
|
||||||
|
"""Test parsing S3 event with missing key."""
|
||||||
|
invalid_s3_event = {
|
||||||
|
"Records": [
|
||||||
|
{
|
||||||
|
"s3": {
|
||||||
|
"bucket": {"name": "test-bucket"},
|
||||||
|
# Missing 'object' key
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
with pytest.raises(ParsingEventDataError, match="Malformed S3 event:"):
|
||||||
|
s3_parser.parse(invalid_s3_event)
|
||||||
|
|
||||||
|
|
||||||
|
def test_s3_parse_none_key(s3_parser):
|
||||||
|
"""Test parsing S3 event with None field."""
|
||||||
|
invalid_s3_event = {
|
||||||
|
"Records": [
|
||||||
|
{
|
||||||
|
"s3": {
|
||||||
|
"bucket": {"name": "test-bucket"},
|
||||||
|
"object": {
|
||||||
|
"key": None,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
with pytest.raises(ParsingEventDataError, match="Missing object key name"):
|
||||||
|
s3_parser.parse(invalid_s3_event)
|
||||||
|
|
||||||
|
|
||||||
|
def test_s3_parse_with_video_type(s3_parser):
|
||||||
|
"""Test parsing S3 event with mp4 file extension."""
|
||||||
|
video_event = {
|
||||||
|
"Records": [
|
||||||
|
{
|
||||||
|
"s3": {
|
||||||
|
"bucket": {"name": "test-bucket"},
|
||||||
|
"object": {
|
||||||
|
"key": "recordings%2F46d1a121-2426-484d-8fb3-09b5d886f7a8.mp4",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
event = s3_parser.parse(video_event)
|
||||||
|
assert event.filetype == "video/mp4"
|
||||||
|
assert event.filepath.endswith(".mp4")
|
||||||
|
|
||||||
|
|
||||||
|
def test_s3_parse_unrecognized_extension(s3_parser):
|
||||||
|
"""Test parsing S3 event with unrecognized file extension."""
|
||||||
|
event_with_unknown_ext = {
|
||||||
|
"Records": [
|
||||||
|
{
|
||||||
|
"s3": {
|
||||||
|
"bucket": {"name": "test-bucket"},
|
||||||
|
"object": {
|
||||||
|
"key": "recordings%2F46d1a121-2426-484d-8fb3-09b5d886f7a8.zzunknown999",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
with pytest.raises(TypeError, match="filetype cannot be None"):
|
||||||
|
s3_parser.parse(event_with_unknown_ext)
|
||||||
|
|
||||||
|
|
||||||
|
def test_s3_parser_keeps_encoded_filepath_compatible(settings):
|
||||||
|
"""Test S3 parser keeps already encoded object keys compatible."""
|
||||||
|
settings.RECORDING_OUTPUT_FOLDER = "recordings"
|
||||||
|
|
||||||
|
recording_id = "80ae9fe5-639a-438b-b86e-9e3dd2d55f4d"
|
||||||
|
parser = S3Parser(bucket_name="recordings-bucket")
|
||||||
|
|
||||||
|
data = {
|
||||||
|
"Records": [
|
||||||
|
{
|
||||||
|
"s3": {
|
||||||
|
"bucket": {"name": "recordings-bucket"},
|
||||||
|
"object": {
|
||||||
|
"key": f"recordings%2F{recording_id}.mp4",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
assert parser.get_recording_id(data) == recording_id
|
||||||
|
|
||||||
|
|
||||||
|
def test_s3_parser_accepts_unencoded_filepath(settings):
|
||||||
|
"""Test S3 parser accepts raw object keys with slash separators."""
|
||||||
|
settings.RECORDING_OUTPUT_FOLDER = "recordings"
|
||||||
|
|
||||||
|
recording_id = "80ae9fe5-639a-438b-b86e-9e3dd2d55f4d"
|
||||||
|
parser = S3Parser(bucket_name="recordings-bucket")
|
||||||
|
|
||||||
|
data = {
|
||||||
|
"Records": [
|
||||||
|
{
|
||||||
|
"s3": {
|
||||||
|
"bucket": {"name": "recordings-bucket"},
|
||||||
|
"object": {
|
||||||
|
"key": f"recordings/{recording_id}.mp4",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
assert parser.get_recording_id(data) == recording_id
|
||||||
|
|
||||||
|
|
||||||
|
def test_s3_parser_preserves_plus_signs_in_encoded_filepath(settings):
|
||||||
|
"""Test S3 parser preserves plus signs in already encoded object keys."""
|
||||||
|
settings.RECORDING_OUTPUT_FOLDER = "recordings"
|
||||||
|
|
||||||
|
recording_id = "80ae9fe5-639a-438b-b86e-9e3dd2d55f4d"
|
||||||
|
parser = S3Parser(bucket_name="recordings-bucket")
|
||||||
|
|
||||||
|
data = {
|
||||||
|
"Records": [
|
||||||
|
{
|
||||||
|
"s3": {
|
||||||
|
"bucket": {"name": "recordings-bucket"},
|
||||||
|
"object": {
|
||||||
|
"key": f"folder+name%2Frecordings%2F{recording_id}.mp4",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
assert parser.get_recording_id(data) == recording_id
|
||||||
|
|
||||||
|
|
||||||
|
def test_s3_get_recording_id_success(s3_parser, valid_s3_event):
|
||||||
|
"""Test successful extraction of recording ID from S3 event."""
|
||||||
|
recording_id = s3_parser.get_recording_id(valid_s3_event)
|
||||||
|
assert recording_id == "46d1a121-2426-484d-8fb3-09b5d886f7a8"
|
||||||
|
|
||||||
|
|
||||||
|
# get_parser
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def clear_lru_cache():
|
||||||
|
"""Fixture to clear the LRU cache between tests."""
|
||||||
|
get_parser.cache_clear()
|
||||||
|
yield
|
||||||
|
get_parser.cache_clear()
|
||||||
|
|
||||||
|
|
||||||
|
def test_returns_correct_instance(clear_lru_cache):
|
||||||
|
"""Test if get_parser returns the correct parser instance."""
|
||||||
|
settings.AWS_STORAGE_BUCKET_NAME = "test-bucket"
|
||||||
|
parser = get_parser()
|
||||||
|
assert isinstance(parser, MinioParser)
|
||||||
|
assert parser._bucket_name == "test-bucket"
|
||||||
|
|
||||||
|
|
||||||
|
def test_caching_behavior(clear_lru_cache):
|
||||||
|
"""Test if the function properly caches the parser instance."""
|
||||||
|
settings.AWS_STORAGE_BUCKET_NAME = "test-bucket"
|
||||||
|
parser1 = get_parser()
|
||||||
|
parser2 = get_parser()
|
||||||
|
assert parser1 is parser2 # Check object identity
|
||||||
|
|
||||||
|
|
||||||
|
def test_different_settings_new_instance():
|
||||||
|
"""Test if changing settings creates a new instance."""
|
||||||
|
settings.AWS_STORAGE_BUCKET_NAME = "different-bucket"
|
||||||
|
parser = get_parser()
|
||||||
|
assert parser._bucket_name == "different-bucket"
|
||||||
|
|
||||||
|
|
||||||
|
def test_import_error_handling(clear_lru_cache):
|
||||||
|
"""Test handling of import errors for invalid parser class."""
|
||||||
|
settings.RECORDING_EVENT_PARSER_CLASS = "invalid.parser.path"
|
||||||
|
with pytest.raises(ImportError):
|
||||||
|
get_parser()
|
||||||
|
|
||||||
|
|
||||||
|
@mock.patch("core.recording.event.parsers.import_string")
|
||||||
|
def test_parser_instantiation_called_once(mock_import_string, clear_lru_cache):
|
||||||
|
"""Test that parser class is instantiated only once due to caching."""
|
||||||
|
mock_parser_cls = type(
|
||||||
|
"MockParser",
|
||||||
|
(),
|
||||||
|
{
|
||||||
|
"__init__": lambda self, bucket_name: setattr(
|
||||||
|
self, "_bucket_name", bucket_name
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
mock_import_string.return_value = mock_parser_cls
|
||||||
|
|
||||||
|
# First call
|
||||||
|
parser1 = get_parser()
|
||||||
|
# Second call
|
||||||
|
parser2 = get_parser()
|
||||||
|
|
||||||
|
# Verify import_string was called only once
|
||||||
|
mock_import_string.assert_called_once_with(settings.RECORDING_EVENT_PARSER_CLASS)
|
||||||
|
assert parser1 is parser2
|
||||||
|
|
||||||
|
|
||||||
|
def test_cache_clear_behavior(clear_lru_cache, settings):
|
||||||
|
"""Test that cache clearing creates new instance."""
|
||||||
|
|
||||||
|
settings.RECORDING_EVENT_PARSER_CLASS = "core.recording.event.parsers.MinioParser"
|
||||||
|
|
||||||
|
parser1 = get_parser()
|
||||||
|
get_parser.cache_clear()
|
||||||
|
parser2 = get_parser()
|
||||||
|
|
||||||
|
assert parser1 is not parser2 # Should be different instances after cache clear
|
||||||
@@ -12,7 +12,6 @@ from core.factories import RecordingFactory
|
|||||||
from core.recording.services.recording_events import (
|
from core.recording.services.recording_events import (
|
||||||
RecordingEventsError,
|
RecordingEventsError,
|
||||||
RecordingEventsService,
|
RecordingEventsService,
|
||||||
RecordingNotSavableError,
|
|
||||||
)
|
)
|
||||||
from core.utils import NotificationError
|
from core.utils import NotificationError
|
||||||
|
|
||||||
@@ -71,56 +70,3 @@ def test_handle_limit_reached_error(mock_notify, mode, notification_type, servic
|
|||||||
mock_notify.assert_called_once_with(
|
mock_notify.assert_called_once_with(
|
||||||
room_name=str(recording.room.id), notification_data={"type": notification_type}
|
room_name=str(recording.room.id), notification_data={"type": notification_type}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("status", ["active", "stopped"])
|
|
||||||
@pytest.mark.parametrize(
|
|
||||||
("notify_return_value", "expected_status"),
|
|
||||||
((True, "notification_succeeded"), (False, "saved")),
|
|
||||||
)
|
|
||||||
@mock.patch(
|
|
||||||
"core.recording.services.recording_events.notification_service."
|
|
||||||
"notify_external_services"
|
|
||||||
)
|
|
||||||
def test_handle_complete_saves_recording( # pylint: disable=too-many-arguments, too-many-positional-arguments
|
|
||||||
mock_notify_external_services,
|
|
||||||
notify_return_value,
|
|
||||||
expected_status,
|
|
||||||
status,
|
|
||||||
service,
|
|
||||||
):
|
|
||||||
"""Test handle_complete notifies external services and saves a savable recording."""
|
|
||||||
|
|
||||||
mock_notify_external_services.return_value = notify_return_value
|
|
||||||
|
|
||||||
recording = RecordingFactory(status=status)
|
|
||||||
service.handle_complete(recording)
|
|
||||||
|
|
||||||
mock_notify_external_services.assert_called_once_with(recording)
|
|
||||||
|
|
||||||
recording.refresh_from_db()
|
|
||||||
assert recording.status == expected_status
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
|
||||||
"status",
|
|
||||||
["initiated", "saved", "notification_succeeded", "aborted", "failed_to_start"],
|
|
||||||
)
|
|
||||||
@mock.patch(
|
|
||||||
"core.recording.services.recording_events.notification_service."
|
|
||||||
"notify_external_services"
|
|
||||||
)
|
|
||||||
def test_handle_complete_non_savable_recording(
|
|
||||||
mock_notify_external_services, status, service
|
|
||||||
):
|
|
||||||
"""Test handle_complete refuses recordings that are already saved or in error."""
|
|
||||||
|
|
||||||
recording = RecordingFactory(status=status)
|
|
||||||
|
|
||||||
with pytest.raises(RecordingNotSavableError):
|
|
||||||
service.handle_complete(recording)
|
|
||||||
|
|
||||||
mock_notify_external_services.assert_not_called()
|
|
||||||
|
|
||||||
recording.refresh_from_db()
|
|
||||||
assert recording.status == status
|
|
||||||
|
|||||||
@@ -0,0 +1,267 @@
|
|||||||
|
"""
|
||||||
|
Test recordings API endpoints in the Meet core app: save recording.
|
||||||
|
"""
|
||||||
|
|
||||||
|
# pylint: disable=redefined-outer-name,unused-argument
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
from unittest import mock
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from rest_framework.test import APIClient
|
||||||
|
|
||||||
|
from ...factories import RecordingFactory
|
||||||
|
from ...models import Recording, RecordingStatusChoices
|
||||||
|
from ...recording.event.exceptions import (
|
||||||
|
InvalidBucketError,
|
||||||
|
InvalidFilepathError,
|
||||||
|
InvalidFileTypeError,
|
||||||
|
ParsingEventDataError,
|
||||||
|
)
|
||||||
|
|
||||||
|
pytestmark = pytest.mark.django_db
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def recording_settings(settings):
|
||||||
|
"""Configure recording-related and storage event Django settings."""
|
||||||
|
settings.RECORDING_STORAGE_EVENT_TOKEN = "testAuthToken"
|
||||||
|
settings.RECORDING_STORAGE_EVENT_ENABLE = True
|
||||||
|
return settings
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mock_get_parser():
|
||||||
|
"""Mock 'get_parser' factory function."""
|
||||||
|
with mock.patch("core.api.viewsets.get_parser") as mock_parser:
|
||||||
|
yield mock_parser
|
||||||
|
|
||||||
|
|
||||||
|
def test_save_recording_anonymous(settings, client):
|
||||||
|
"""Anonymous users should not be allowed to save room recordings."""
|
||||||
|
settings.RECORDING_STORAGE_EVENT_TOKEN = "testAuthToken"
|
||||||
|
|
||||||
|
RecordingFactory(status="active")
|
||||||
|
|
||||||
|
response = client.post(
|
||||||
|
"/api/v1.0/recordings/storage-hook/",
|
||||||
|
{"recording_data": "valid-data"},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 401
|
||||||
|
assert Recording.objects.count() == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_save_recording_wrong_bearer(settings, client):
|
||||||
|
"""Requests with incorrect bearer token should be rejected when auth is required."""
|
||||||
|
|
||||||
|
settings.RECORDING_STORAGE_EVENT_TOKEN = "testAuthToken"
|
||||||
|
|
||||||
|
response = client.post(
|
||||||
|
"/api/v1.0/recordings/storage-hook/",
|
||||||
|
{"recording_data": "valid-data"},
|
||||||
|
HTTP_AUTHORIZATION="Bearer wrongAuthToken",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 401
|
||||||
|
|
||||||
|
|
||||||
|
def test_save_recording_permission_needed(settings, client):
|
||||||
|
"""Recordings should not be saved when feature is disabled."""
|
||||||
|
|
||||||
|
settings.RECORDING_STORAGE_EVENT_TOKEN = "testAuthToken"
|
||||||
|
settings.RECORDING_STORAGE_EVENT_ENABLE = False
|
||||||
|
|
||||||
|
response = client.post(
|
||||||
|
"/api/v1.0/recordings/storage-hook/",
|
||||||
|
{"recording_data": "valid-data"},
|
||||||
|
HTTP_AUTHORIZATION="Bearer testAuthToken",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 404
|
||||||
|
assert response.json() == {"detail": "Not found."}
|
||||||
|
|
||||||
|
|
||||||
|
def test_save_recording_parsing_error(recording_settings, mock_get_parser, client):
|
||||||
|
"""Test handling of parsing errors in recording event data."""
|
||||||
|
mock_parser = mock.Mock()
|
||||||
|
mock_parser.get_recording_id.side_effect = ParsingEventDataError("Error message")
|
||||||
|
mock_get_parser.return_value = mock_parser
|
||||||
|
|
||||||
|
response = client.post(
|
||||||
|
"/api/v1.0/recordings/storage-hook/",
|
||||||
|
{"recording_data": "valid-data"},
|
||||||
|
HTTP_AUTHORIZATION="Bearer testAuthToken",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 403
|
||||||
|
assert response.json() == {"detail": "Invalid request data."}
|
||||||
|
|
||||||
|
|
||||||
|
def test_save_recording_bucket_error(recording_settings, mock_get_parser, client):
|
||||||
|
"""Test handling of invalid storage bucket errors in recording event data."""
|
||||||
|
|
||||||
|
mock_parser = mock.Mock()
|
||||||
|
mock_parser.get_recording_id.side_effect = InvalidBucketError("Error message")
|
||||||
|
mock_get_parser.return_value = mock_parser
|
||||||
|
|
||||||
|
response = client.post(
|
||||||
|
"/api/v1.0/recordings/storage-hook/",
|
||||||
|
{"recording_data": "valid-data"},
|
||||||
|
HTTP_AUTHORIZATION="Bearer testAuthToken",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 403
|
||||||
|
assert response.json() == {"detail": "Invalid bucket specified."}
|
||||||
|
|
||||||
|
|
||||||
|
def test_save_recording_filetype_error(recording_settings, mock_get_parser):
|
||||||
|
"""Test handling of unsupported file types in recording event data."""
|
||||||
|
|
||||||
|
mock_parser = mock.Mock()
|
||||||
|
mock_parser.get_recording_id.side_effect = InvalidFileTypeError(
|
||||||
|
"unsupported '.json'"
|
||||||
|
)
|
||||||
|
mock_get_parser.return_value = mock_parser
|
||||||
|
|
||||||
|
client = APIClient()
|
||||||
|
|
||||||
|
response = client.post(
|
||||||
|
"/api/v1.0/recordings/storage-hook/",
|
||||||
|
{"recording_data": "valid-data"},
|
||||||
|
HTTP_AUTHORIZATION="Bearer testAuthToken",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.json() == {"message": "Notification ignored."}
|
||||||
|
|
||||||
|
|
||||||
|
def test_save_recording_filepath_error(recording_settings, mock_get_parser):
|
||||||
|
"""Test handling of unsupported filepath in recording event data."""
|
||||||
|
|
||||||
|
mock_parser = mock.Mock()
|
||||||
|
mock_parser.get_recording_id.side_effect = InvalidFilepathError(
|
||||||
|
"Invalid filepath structure: parent/folder/recording.jpeg"
|
||||||
|
)
|
||||||
|
mock_get_parser.return_value = mock_parser
|
||||||
|
|
||||||
|
client = APIClient()
|
||||||
|
|
||||||
|
response = client.post(
|
||||||
|
"/api/v1.0/recordings/storage-hook/",
|
||||||
|
{"recording_data": "valid-data"},
|
||||||
|
HTTP_AUTHORIZATION="Bearer testAuthToken",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.json() == {"message": "Notification ignored."}
|
||||||
|
|
||||||
|
|
||||||
|
def test_save_recording_unknown_recording(recording_settings, mock_get_parser, client):
|
||||||
|
"""Test handling of events for non-existent recordings."""
|
||||||
|
|
||||||
|
RecordingFactory(status="active")
|
||||||
|
|
||||||
|
mock_parser = mock.Mock()
|
||||||
|
mock_parser.get_recording_id.return_value = uuid.uuid4()
|
||||||
|
mock_get_parser.return_value = mock_parser
|
||||||
|
|
||||||
|
response = client.post(
|
||||||
|
"/api/v1.0/recordings/storage-hook/",
|
||||||
|
{"recording_data": "valid-data"},
|
||||||
|
HTTP_AUTHORIZATION="Bearer testAuthToken",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 404
|
||||||
|
assert response.json() == {"detail": "No recording found for this event."}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"status", ["failed_to_start", "aborted", "failed_to_stop", "saved", "initiated"]
|
||||||
|
)
|
||||||
|
def test_save_recording_non_savable_recording(
|
||||||
|
recording_settings, mock_get_parser, client, status
|
||||||
|
):
|
||||||
|
"""Test that recordings in non-savable states cannot be saved."""
|
||||||
|
|
||||||
|
recording = RecordingFactory(status=status)
|
||||||
|
|
||||||
|
mock_parser = mock.Mock()
|
||||||
|
mock_parser.get_recording_id.return_value = recording.id
|
||||||
|
mock_get_parser.return_value = mock_parser
|
||||||
|
|
||||||
|
response = client.post(
|
||||||
|
"/api/v1.0/recordings/storage-hook/",
|
||||||
|
{"recording_data": "valid-data"},
|
||||||
|
HTTP_AUTHORIZATION="Bearer testAuthToken",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 403
|
||||||
|
assert response.json() == {
|
||||||
|
"detail": f"Recording with ID {recording.id} cannot be saved because it is either,"
|
||||||
|
" in an error state or has already been saved."
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("status", ["active", "stopped"])
|
||||||
|
def test_save_recording_success(recording_settings, mock_get_parser, client, status):
|
||||||
|
"""Test successful saving of recordings in valid states."""
|
||||||
|
|
||||||
|
recording = RecordingFactory(status=status)
|
||||||
|
|
||||||
|
mock_parser = mock.Mock()
|
||||||
|
mock_parser.get_recording_id.return_value = recording.id
|
||||||
|
mock_get_parser.return_value = mock_parser
|
||||||
|
|
||||||
|
response = client.post(
|
||||||
|
"/api/v1.0/recordings/storage-hook/",
|
||||||
|
{"recording_data": "valid-data"},
|
||||||
|
HTTP_AUTHORIZATION="Bearer testAuthToken",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.json() == {"message": "Event processed."}
|
||||||
|
|
||||||
|
recording.refresh_from_db()
|
||||||
|
assert recording.status == RecordingStatusChoices.SAVED
|
||||||
|
|
||||||
|
|
||||||
|
@mock.patch(
|
||||||
|
"core.recording.services.recording_events.notification_service."
|
||||||
|
"notify_external_services"
|
||||||
|
)
|
||||||
|
@pytest.mark.parametrize("notification_succeeded", [True, False])
|
||||||
|
def test_save_recording_notifies_external_services(
|
||||||
|
mock_notify_external_services,
|
||||||
|
recording_settings,
|
||||||
|
mock_get_parser,
|
||||||
|
client,
|
||||||
|
notification_succeeded,
|
||||||
|
):
|
||||||
|
"""External services should be notified when a recording is saved."""
|
||||||
|
|
||||||
|
recording = RecordingFactory(status="active")
|
||||||
|
|
||||||
|
mock_parser = mock.Mock()
|
||||||
|
mock_parser.get_recording_id.return_value = recording.id
|
||||||
|
mock_get_parser.return_value = mock_parser
|
||||||
|
|
||||||
|
mock_notify_external_services.return_value = notification_succeeded
|
||||||
|
|
||||||
|
response = client.post(
|
||||||
|
"/api/v1.0/recordings/storage-hook/",
|
||||||
|
{"recording_data": "valid-data"},
|
||||||
|
HTTP_AUTHORIZATION="Bearer testAuthToken",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.json() == {"message": "Event processed."}
|
||||||
|
|
||||||
|
mock_notify_external_services.assert_called_once_with(recording)
|
||||||
|
|
||||||
|
recording.refresh_from_db()
|
||||||
|
assert recording.status == (
|
||||||
|
RecordingStatusChoices.NOTIFICATION_SUCCEEDED
|
||||||
|
if notification_succeeded
|
||||||
|
else RecordingStatusChoices.SAVED
|
||||||
|
)
|
||||||
@@ -389,7 +389,7 @@ def test_allow_participant_to_enter_anonymous():
|
|||||||
|
|
||||||
def test_allow_participant_to_enter_non_owner():
|
def test_allow_participant_to_enter_non_owner():
|
||||||
"""Non-privileged users should not be allowed to manage entry requests."""
|
"""Non-privileged users should not be allowed to manage entry requests."""
|
||||||
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
|
room = RoomFactory()
|
||||||
user = UserFactory()
|
user = UserFactory()
|
||||||
client = APIClient()
|
client = APIClient()
|
||||||
client.force_login(user)
|
client.force_login(user)
|
||||||
@@ -522,7 +522,7 @@ def test_list_waiting_participants_anonymous():
|
|||||||
|
|
||||||
def test_list_waiting_participants_non_owner():
|
def test_list_waiting_participants_non_owner():
|
||||||
"""Non-privileged users should not be allowed to list waiting participants."""
|
"""Non-privileged users should not be allowed to list waiting participants."""
|
||||||
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
|
room = RoomFactory()
|
||||||
user = UserFactory()
|
user = UserFactory()
|
||||||
client = APIClient()
|
client = APIClient()
|
||||||
client.force_login(user)
|
client.force_login(user)
|
||||||
|
|||||||
@@ -1,106 +0,0 @@
|
|||||||
"""Trusted rooms: any authenticated participant present in the meeting can manage the lobby."""
|
|
||||||
|
|
||||||
from unittest import mock
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
from rest_framework.test import APIClient
|
|
||||||
|
|
||||||
from core.factories import RoomFactory, UserFactory
|
|
||||||
from core.models import RoomAccessLevel
|
|
||||||
from core.services.presence import PresenceCache
|
|
||||||
|
|
||||||
pytestmark = pytest.mark.django_db
|
|
||||||
|
|
||||||
|
|
||||||
@mock.patch(
|
|
||||||
"core.services.participants_management.ParticipantsManagement.check_if_in_meeting"
|
|
||||||
)
|
|
||||||
def test_trusted_room_present_user_can_list_waiting(mock_check):
|
|
||||||
"""Authenticated + present in a trusted room -> 200, LiveKit asked once then cached."""
|
|
||||||
mock_check.return_value = True
|
|
||||||
user = UserFactory()
|
|
||||||
room = RoomFactory(access_level=RoomAccessLevel.TRUSTED)
|
|
||||||
client = APIClient()
|
|
||||||
client.force_login(user)
|
|
||||||
|
|
||||||
url = f"/api/v1.0/rooms/{room.id}/waiting-participants/"
|
|
||||||
assert client.get(url).status_code == 200
|
|
||||||
assert client.get(url).status_code == 200
|
|
||||||
assert mock_check.call_count == 1
|
|
||||||
|
|
||||||
|
|
||||||
@mock.patch(
|
|
||||||
"core.services.participants_management.ParticipantsManagement.check_if_in_meeting"
|
|
||||||
)
|
|
||||||
def test_trusted_room_absent_user_forbidden(mock_check):
|
|
||||||
"""Authenticated but not connected to the meeting -> 403."""
|
|
||||||
mock_check.return_value = False
|
|
||||||
user = UserFactory()
|
|
||||||
room = RoomFactory(access_level=RoomAccessLevel.TRUSTED)
|
|
||||||
client = APIClient()
|
|
||||||
client.force_login(user)
|
|
||||||
|
|
||||||
response = client.get(f"/api/v1.0/rooms/{room.id}/waiting-participants/")
|
|
||||||
assert response.status_code == 403
|
|
||||||
|
|
||||||
|
|
||||||
@mock.patch(
|
|
||||||
"core.services.participants_management.ParticipantsManagement.check_if_in_meeting"
|
|
||||||
)
|
|
||||||
def test_restricted_room_present_user_forbidden(mock_check):
|
|
||||||
"""Presence is not enough on a restricted room; LiveKit must not even be asked."""
|
|
||||||
user = UserFactory()
|
|
||||||
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
|
|
||||||
client = APIClient()
|
|
||||||
client.force_login(user)
|
|
||||||
|
|
||||||
response = client.get(f"/api/v1.0/rooms/{room.id}/waiting-participants/")
|
|
||||||
assert response.status_code == 403
|
|
||||||
mock_check.assert_not_called()
|
|
||||||
|
|
||||||
|
|
||||||
@mock.patch(
|
|
||||||
"core.services.participants_management.ParticipantsManagement.check_if_in_meeting"
|
|
||||||
)
|
|
||||||
def test_trusted_room_presence_cleared_after_leave(mock_check):
|
|
||||||
"""Once the presence cache is cleared (participant_left), LiveKit is re-checked."""
|
|
||||||
mock_check.return_value = True
|
|
||||||
user = UserFactory()
|
|
||||||
room = RoomFactory(access_level=RoomAccessLevel.TRUSTED)
|
|
||||||
client = APIClient()
|
|
||||||
client.force_login(user)
|
|
||||||
url = f"/api/v1.0/rooms/{room.id}/waiting-participants/"
|
|
||||||
|
|
||||||
assert client.get(url).status_code == 200
|
|
||||||
PresenceCache().clear(room.id, str(user.sub))
|
|
||||||
|
|
||||||
mock_check.return_value = False
|
|
||||||
assert client.get(url).status_code == 403
|
|
||||||
assert mock_check.call_count == 2
|
|
||||||
|
|
||||||
|
|
||||||
def test_trusted_room_anonymous_forbidden():
|
|
||||||
"""Anonymous users never manage the lobby."""
|
|
||||||
room = RoomFactory(access_level=RoomAccessLevel.TRUSTED)
|
|
||||||
response = APIClient().get(f"/api/v1.0/rooms/{room.id}/waiting-participants/")
|
|
||||||
assert response.status_code == 401
|
|
||||||
|
|
||||||
|
|
||||||
@mock.patch(
|
|
||||||
"core.services.participants_management.ParticipantsManagement.check_if_in_meeting"
|
|
||||||
)
|
|
||||||
def test_trusted_room_present_user_can_accept_entry(mock_check):
|
|
||||||
"""Authenticated + present in a trusted room can accept a waiting participant."""
|
|
||||||
mock_check.return_value = True
|
|
||||||
user = UserFactory()
|
|
||||||
room = RoomFactory(access_level=RoomAccessLevel.TRUSTED)
|
|
||||||
client = APIClient()
|
|
||||||
client.force_login(user)
|
|
||||||
|
|
||||||
response = client.post(
|
|
||||||
f"/api/v1.0/rooms/{room.id}/enter/",
|
|
||||||
{"participant_id": "2f7f162f-e7d1-421b-90e7-02bfbfbf8def", "allow_entry": True},
|
|
||||||
)
|
|
||||||
# Permission passed; 404 because that participant isn't actually waiting.
|
|
||||||
assert response.status_code == 404
|
|
||||||
assert response.json() == {"message": "Participant not found."}
|
|
||||||
@@ -381,38 +381,11 @@ def test_api_rooms_update_administrators_of_another():
|
|||||||
assert other_room.slug == "old-name"
|
assert other_room.slug == "old-name"
|
||||||
|
|
||||||
|
|
||||||
@patch.object(RoomManagement, "update_metadata", side_effect=RoomNotFoundException)
|
@pytest.mark.parametrize("exception", [RoomNotFoundException, RoomManagementException])
|
||||||
def test_api_rooms_update_livekit_room_not_found(mock_update_metadata):
|
@patch.object(RoomManagement, "update_metadata")
|
||||||
"""Should not fail the API request when the LiveKit room does not exist yet."""
|
def test_api_rooms_update_livekit_sync_failure(mock_update_metadata, exception):
|
||||||
user = UserFactory()
|
|
||||||
room = RoomFactory(
|
|
||||||
users=[(user, random.choice(["administrator", "owner"]))],
|
|
||||||
configuration={},
|
|
||||||
)
|
|
||||||
client = APIClient()
|
|
||||||
client.force_login(user)
|
|
||||||
|
|
||||||
response = client.patch(
|
|
||||||
f"/api/v1.0/rooms/{room.id!s}/",
|
|
||||||
{"configuration": {"can_publish_sources": ["camera"]}},
|
|
||||||
format="json",
|
|
||||||
)
|
|
||||||
assert response.status_code == 200
|
|
||||||
room.refresh_from_db()
|
|
||||||
assert room.configuration == {"can_publish_sources": ["camera"]}
|
|
||||||
|
|
||||||
mock_update_metadata.assert_called_once_with(
|
|
||||||
room_name=str(room.id),
|
|
||||||
metadata={
|
|
||||||
"access_level": room.access_level,
|
|
||||||
"configuration": {"can_publish_sources": ["camera"]},
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@patch.object(RoomManagement, "update_metadata", side_effect=RoomManagementException)
|
|
||||||
def test_api_rooms_update_livekit_sync_failure(mock_update_metadata):
|
|
||||||
"""Should not fail the API request when the LiveKit metadata sync fails."""
|
"""Should not fail the API request when the LiveKit metadata sync fails."""
|
||||||
|
mock_update_metadata.side_effect = exception
|
||||||
user = UserFactory()
|
user = UserFactory()
|
||||||
room = RoomFactory(
|
room = RoomFactory(
|
||||||
users=[(user, random.choice(["administrator", "owner"]))],
|
users=[(user, random.choice(["administrator", "owner"]))],
|
||||||
|
|||||||
@@ -75,11 +75,12 @@ def test_initialization(
|
|||||||
)
|
)
|
||||||
@mock.patch("core.utils.notify_participants")
|
@mock.patch("core.utils.notify_participants")
|
||||||
@mock.patch("core.services.room_management.RoomManagement.update_metadata")
|
@mock.patch("core.services.room_management.RoomManagement.update_metadata")
|
||||||
def test_handle_egress_ended_success( # pylint: disable=too-many-arguments, too-many-positional-arguments
|
def test_handle_egress_ended_success( # noqa: PLR0913, PLR0917 # pylint: disable=too-many-arguments, too-many-positional-arguments
|
||||||
mock_update_metadata, mock_notify, mode, notification_type, service
|
mock_update_metadata, mock_notify, mode, notification_type, service, settings
|
||||||
):
|
):
|
||||||
"""Should successfully stop recording and notifies all participant."""
|
"""Should successfully stop recording and notifies all participant."""
|
||||||
|
|
||||||
|
settings.RECORDING_STORAGE_EVENT_ENABLE = False
|
||||||
recording = RecordingFactory(worker_id="worker-1", mode=mode, status="active")
|
recording = RecordingFactory(worker_id="worker-1", mode=mode, status="active")
|
||||||
mock_data = mock.MagicMock()
|
mock_data = mock.MagicMock()
|
||||||
mock_data.egress_info.egress_id = recording.worker_id
|
mock_data.egress_info.egress_id = recording.worker_id
|
||||||
@@ -158,11 +159,12 @@ def test_handle_egress_updated_non_handled(
|
|||||||
)
|
)
|
||||||
@mock.patch("core.utils.notify_participants")
|
@mock.patch("core.utils.notify_participants")
|
||||||
@mock.patch("core.services.room_management.RoomManagement.update_metadata")
|
@mock.patch("core.services.room_management.RoomManagement.update_metadata")
|
||||||
def test_handle_egress_ended_metadata_update_fails( # pylint: disable=too-many-arguments, too-many-positional-arguments
|
def test_handle_egress_ended_metadata_update_fails( # noqa: PLR0913, PLR0917 # pylint: disable=too-many-arguments, too-many-positional-arguments
|
||||||
mock_update_metadata, mock_notify, mode, notification_type, service
|
mock_update_metadata, mock_notify, mode, notification_type, service, settings
|
||||||
):
|
):
|
||||||
"""Should successfully stop and save recording when metadata's update fails."""
|
"""Should successfully stop and save recording when metadata's update fails."""
|
||||||
|
|
||||||
|
settings.RECORDING_STORAGE_EVENT_ENABLE = False
|
||||||
recording = RecordingFactory(worker_id="worker-1", mode=mode, status="active")
|
recording = RecordingFactory(worker_id="worker-1", mode=mode, status="active")
|
||||||
mock_data = mock.MagicMock()
|
mock_data = mock.MagicMock()
|
||||||
mock_data.egress_info.egress_id = recording.worker_id
|
mock_data.egress_info.egress_id = recording.worker_id
|
||||||
@@ -356,10 +358,12 @@ def test_handle_egress_ended_finalizes_recording( # noqa: PLR0913, PLR0917
|
|||||||
recording_status,
|
recording_status,
|
||||||
egress_status,
|
egress_status,
|
||||||
service,
|
service,
|
||||||
|
settings,
|
||||||
): # pylint: disable=too-many-arguments,too-many-positional-arguments
|
): # pylint: disable=too-many-arguments,too-many-positional-arguments
|
||||||
"""Should notify external services and save the recording on egress completion
|
"""Should notify external services and save the recording on egress completion
|
||||||
(EGRESS_COMPLETE or EGRESS_LIMIT_REACHED).
|
(EGRESS_COMPLETE or EGRESS_LIMIT_REACHED) when RECORDING_STORAGE_EVENT_ENABLE is False.
|
||||||
"""
|
"""
|
||||||
|
settings.RECORDING_STORAGE_EVENT_ENABLE = False
|
||||||
mock_notify_external_services.return_value = notify_return_value
|
mock_notify_external_services.return_value = notify_return_value
|
||||||
|
|
||||||
recording = RecordingFactory(worker_id="worker-1", status="active")
|
recording = RecordingFactory(worker_id="worker-1", status="active")
|
||||||
@@ -375,6 +379,47 @@ def test_handle_egress_ended_finalizes_recording( # noqa: PLR0913, PLR0917
|
|||||||
assert recording.status == recording_status
|
assert recording.status == recording_status
|
||||||
|
|
||||||
|
|
||||||
|
@mock.patch(
|
||||||
|
"core.recording.services.recording_events.notification_service."
|
||||||
|
"notify_external_services"
|
||||||
|
)
|
||||||
|
@mock.patch("core.utils.notify_participants")
|
||||||
|
@mock.patch("core.services.room_management.RoomManagement.update_metadata")
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"egress_status, expected_status",
|
||||||
|
[
|
||||||
|
(EgressStatus.EGRESS_COMPLETE, "active"),
|
||||||
|
(EgressStatus.EGRESS_LIMIT_REACHED, "stopped"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_handle_egress_ended_does_not_finalize_when_webhooks_enabled( # noqa: PLR0913, PLR0917
|
||||||
|
mock_update_metadata,
|
||||||
|
mock_notify,
|
||||||
|
mock_notify_external_services,
|
||||||
|
egress_status,
|
||||||
|
expected_status,
|
||||||
|
service,
|
||||||
|
settings,
|
||||||
|
): # pylint: disable=too-many-arguments,too-many-positional-arguments
|
||||||
|
"""When storage event webhooks are enabled, egress_ended must not finalize the
|
||||||
|
recording: external services are never notified. EGRESS_LIMIT_REACHED still stops
|
||||||
|
the recording, EGRESS_COMPLETE leaves it active.
|
||||||
|
"""
|
||||||
|
settings.RECORDING_STORAGE_EVENT_ENABLE = True
|
||||||
|
|
||||||
|
recording = RecordingFactory(worker_id="worker-1", status="active")
|
||||||
|
mock_data = mock.MagicMock()
|
||||||
|
mock_data.egress_info.egress_id = recording.worker_id
|
||||||
|
mock_data.egress_info.status = egress_status
|
||||||
|
|
||||||
|
service._handle_egress_ended(mock_data)
|
||||||
|
|
||||||
|
mock_notify_external_services.assert_not_called()
|
||||||
|
|
||||||
|
recording.refresh_from_db()
|
||||||
|
assert recording.status == expected_status
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
@pytest.mark.parametrize(
|
||||||
"egress_status",
|
"egress_status",
|
||||||
[
|
[
|
||||||
@@ -387,9 +432,10 @@ def test_handle_egress_ended_finalizes_recording( # noqa: PLR0913, PLR0917
|
|||||||
)
|
)
|
||||||
@mock.patch("core.services.room_management.RoomManagement.update_metadata")
|
@mock.patch("core.services.room_management.RoomManagement.update_metadata")
|
||||||
def test_handle_egress_ended_does_not_save_on_wrong_status(
|
def test_handle_egress_ended_does_not_save_on_wrong_status(
|
||||||
mock_update_metadata, egress_status, service
|
mock_update_metadata, egress_status, service, settings
|
||||||
):
|
):
|
||||||
"""Shouldn't save on invalid status."""
|
"""Shouldn't save on invalid status."""
|
||||||
|
settings.RECORDING_STORAGE_EVENT_ENABLE = False
|
||||||
|
|
||||||
recording = RecordingFactory(worker_id="worker-1", status="active")
|
recording = RecordingFactory(worker_id="worker-1", status="active")
|
||||||
mock_data = mock.MagicMock()
|
mock_data = mock.MagicMock()
|
||||||
@@ -407,13 +453,14 @@ def test_handle_egress_ended_does_not_save_on_wrong_status(
|
|||||||
)
|
)
|
||||||
@mock.patch("core.services.room_management.RoomManagement.update_metadata")
|
@mock.patch("core.services.room_management.RoomManagement.update_metadata")
|
||||||
def test_handle_egress_ended_ignores_non_savable_recording(
|
def test_handle_egress_ended_ignores_non_savable_recording(
|
||||||
mock_update_metadata, status, service
|
mock_update_metadata, status, service, settings
|
||||||
):
|
):
|
||||||
"""Should handle non-savable recordings idempotently without raising.
|
"""Should handle non-savable recordings idempotently without raising.
|
||||||
|
|
||||||
'egress_ended' may be redelivered (e.g. for an already-saved recording);
|
'egress_ended' may be redelivered (e.g. for an already-saved recording);
|
||||||
this must not raise, otherwise the webhook would 500 and LiveKit would retry.
|
this must not raise, otherwise the webhook would 500 and LiveKit would retry.
|
||||||
"""
|
"""
|
||||||
|
settings.RECORDING_STORAGE_EVENT_ENABLE = False
|
||||||
|
|
||||||
recording = RecordingFactory(worker_id="worker-1", status=status)
|
recording = RecordingFactory(worker_id="worker-1", status=status)
|
||||||
mock_data = mock.MagicMock()
|
mock_data = mock.MagicMock()
|
||||||
@@ -807,31 +854,3 @@ def test_receive_ignores_connection_test_room(
|
|||||||
|
|
||||||
mock_handle_room_started.assert_not_called()
|
mock_handle_room_started.assert_not_called()
|
||||||
mock_handle_room_finished.assert_not_called()
|
mock_handle_room_finished.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
@mock.patch("core.services.presence.cache.delete")
|
|
||||||
def test_participant_left_clearing_gated_by_setting(mock_delete, service, settings):
|
|
||||||
"""PRESENCE_CLEAR_ON_PARTICIPANT_LEFT toggles eager presence invalidation."""
|
|
||||||
data = mock.Mock()
|
|
||||||
data.room.name = "room-name"
|
|
||||||
data.participant.identity = "user-sub"
|
|
||||||
|
|
||||||
settings.PRESENCE_CLEAR_ON_PARTICIPANT_LEFT = False
|
|
||||||
service._handle_participant_left(data) # pylint: disable=protected-access
|
|
||||||
mock_delete.assert_not_called()
|
|
||||||
|
|
||||||
settings.PRESENCE_CLEAR_ON_PARTICIPANT_LEFT = True
|
|
||||||
service._handle_participant_left(data) # pylint: disable=protected-access
|
|
||||||
mock_delete.assert_called_once()
|
|
||||||
|
|
||||||
|
|
||||||
@mock.patch("core.services.presence.cache.delete")
|
|
||||||
def test_participant_left_without_identity_is_ignored(mock_delete, service, settings):
|
|
||||||
"""No cache operation when the webhook carries no identity."""
|
|
||||||
settings.PRESENCE_CLEAR_ON_PARTICIPANT_LEFT = True
|
|
||||||
data = mock.Mock()
|
|
||||||
data.room.name = "room-name"
|
|
||||||
data.participant.identity = ""
|
|
||||||
|
|
||||||
service._handle_participant_left(data) # pylint: disable=protected-access
|
|
||||||
mock_delete.assert_not_called()
|
|
||||||
|
|||||||
@@ -24,7 +24,6 @@ from core.services.lobby import (
|
|||||||
LobbyParticipantStatus,
|
LobbyParticipantStatus,
|
||||||
LobbyService,
|
LobbyService,
|
||||||
)
|
)
|
||||||
from core.services.presence import CACHE_SCAN_ITERSIZE
|
|
||||||
from core.utils import NotificationError
|
from core.utils import NotificationError
|
||||||
|
|
||||||
pytestmark = pytest.mark.django_db
|
pytestmark = pytest.mark.django_db
|
||||||
@@ -579,14 +578,14 @@ def test_get_participant_parsing_error(
|
|||||||
@mock.patch("core.services.lobby.cache")
|
@mock.patch("core.services.lobby.cache")
|
||||||
def test_list_waiting_participants_empty(mock_cache, lobby_service):
|
def test_list_waiting_participants_empty(mock_cache, lobby_service):
|
||||||
"""Test listing waiting participants when none exist."""
|
"""Test listing waiting participants when none exist."""
|
||||||
mock_cache.iter_keys.return_value = []
|
mock_cache.keys.return_value = []
|
||||||
|
|
||||||
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
|
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
|
||||||
result = lobby_service.list_waiting_participants(room.id)
|
result = lobby_service.list_waiting_participants(room.id)
|
||||||
|
|
||||||
assert result == []
|
assert result == []
|
||||||
pattern = f"{settings.LOBBY_KEY_PREFIX}_{room.id!s}_*"
|
pattern = f"{settings.LOBBY_KEY_PREFIX}_{room.id!s}_*"
|
||||||
mock_cache.iter_keys.assert_called_once_with(pattern, itersize=CACHE_SCAN_ITERSIZE)
|
mock_cache.keys.assert_called_once_with(pattern)
|
||||||
mock_cache.get_many.assert_not_called()
|
mock_cache.get_many.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
@@ -595,7 +594,7 @@ def test_list_waiting_participants(mock_cache, lobby_service, participant_dict):
|
|||||||
"""Test listing waiting participants with valid data."""
|
"""Test listing waiting participants with valid data."""
|
||||||
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
|
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
|
||||||
cache_key = f"{settings.LOBBY_KEY_PREFIX}_{room.id!s}_participant1"
|
cache_key = f"{settings.LOBBY_KEY_PREFIX}_{room.id!s}_participant1"
|
||||||
mock_cache.iter_keys.return_value = [cache_key]
|
mock_cache.keys.return_value = [cache_key]
|
||||||
mock_cache.get_many.return_value = {cache_key: participant_dict}
|
mock_cache.get_many.return_value = {cache_key: participant_dict}
|
||||||
|
|
||||||
result = lobby_service.list_waiting_participants(room.id)
|
result = lobby_service.list_waiting_participants(room.id)
|
||||||
@@ -604,7 +603,7 @@ def test_list_waiting_participants(mock_cache, lobby_service, participant_dict):
|
|||||||
assert result[0]["status"] == "waiting"
|
assert result[0]["status"] == "waiting"
|
||||||
assert result[0]["username"] == "test-username"
|
assert result[0]["username"] == "test-username"
|
||||||
pattern = f"{settings.LOBBY_KEY_PREFIX}_{room.id!s}_*"
|
pattern = f"{settings.LOBBY_KEY_PREFIX}_{room.id!s}_*"
|
||||||
mock_cache.iter_keys.assert_called_once_with(pattern, itersize=CACHE_SCAN_ITERSIZE)
|
mock_cache.keys.assert_called_once_with(pattern)
|
||||||
mock_cache.get_many.assert_called_once_with([cache_key])
|
mock_cache.get_many.assert_called_once_with([cache_key])
|
||||||
|
|
||||||
|
|
||||||
@@ -629,7 +628,7 @@ def test_list_waiting_participants_multiple(mock_cache, lobby_service):
|
|||||||
"color": "#654321",
|
"color": "#654321",
|
||||||
}
|
}
|
||||||
|
|
||||||
mock_cache.iter_keys.return_value = [cache_key1, cache_key2]
|
mock_cache.keys.return_value = [cache_key1, cache_key2]
|
||||||
mock_cache.get_many.return_value = {
|
mock_cache.get_many.return_value = {
|
||||||
cache_key1: participant1,
|
cache_key1: participant1,
|
||||||
cache_key2: participant2,
|
cache_key2: participant2,
|
||||||
@@ -647,7 +646,7 @@ def test_list_waiting_participants_multiple(mock_cache, lobby_service):
|
|||||||
assert all(p["status"] == "waiting" for p in result)
|
assert all(p["status"] == "waiting" for p in result)
|
||||||
|
|
||||||
pattern = f"{settings.LOBBY_KEY_PREFIX}_{room.id!s}_*"
|
pattern = f"{settings.LOBBY_KEY_PREFIX}_{room.id!s}_*"
|
||||||
mock_cache.iter_keys.assert_called_once_with(pattern, itersize=CACHE_SCAN_ITERSIZE)
|
mock_cache.keys.assert_called_once_with(pattern)
|
||||||
mock_cache.get_many.assert_called_once_with([cache_key1, cache_key2])
|
mock_cache.get_many.assert_called_once_with([cache_key1, cache_key2])
|
||||||
|
|
||||||
|
|
||||||
@@ -656,7 +655,7 @@ def test_list_waiting_participants_corrupted_data(mock_cache, lobby_service):
|
|||||||
"""Test listing waiting participants with corrupted data."""
|
"""Test listing waiting participants with corrupted data."""
|
||||||
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
|
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
|
||||||
cache_key = f"{settings.LOBBY_KEY_PREFIX}_{room.id!s}_participant1"
|
cache_key = f"{settings.LOBBY_KEY_PREFIX}_{room.id!s}_participant1"
|
||||||
mock_cache.iter_keys.return_value = [cache_key]
|
mock_cache.keys.return_value = [cache_key]
|
||||||
mock_cache.get_many.return_value = {cache_key: {"invalid": "data"}}
|
mock_cache.get_many.return_value = {cache_key: {"invalid": "data"}}
|
||||||
|
|
||||||
result = lobby_service.list_waiting_participants(room.id)
|
result = lobby_service.list_waiting_participants(room.id)
|
||||||
@@ -681,7 +680,7 @@ def test_list_waiting_participants_partially_corrupted(mock_cache, lobby_service
|
|||||||
|
|
||||||
corrupted_participant = {"invalid": "data"}
|
corrupted_participant = {"invalid": "data"}
|
||||||
|
|
||||||
mock_cache.iter_keys.return_value = [cache_key1, cache_key2]
|
mock_cache.keys.return_value = [cache_key1, cache_key2]
|
||||||
mock_cache.get_many.return_value = {
|
mock_cache.get_many.return_value = {
|
||||||
cache_key1: corrupted_participant,
|
cache_key1: corrupted_participant,
|
||||||
cache_key2: valid_participant,
|
cache_key2: valid_participant,
|
||||||
@@ -700,7 +699,7 @@ def test_list_waiting_participants_partially_corrupted(mock_cache, lobby_service
|
|||||||
|
|
||||||
# Verify both cache keys were queried
|
# Verify both cache keys were queried
|
||||||
pattern = f"{settings.LOBBY_KEY_PREFIX}_{room.id!s}_*"
|
pattern = f"{settings.LOBBY_KEY_PREFIX}_{room.id!s}_*"
|
||||||
mock_cache.iter_keys.assert_called_once_with(pattern, itersize=CACHE_SCAN_ITERSIZE)
|
mock_cache.keys.assert_called_once_with(pattern)
|
||||||
mock_cache.get_many.assert_called_once_with([cache_key1, cache_key2])
|
mock_cache.get_many.assert_called_once_with([cache_key1, cache_key2])
|
||||||
|
|
||||||
|
|
||||||
@@ -724,7 +723,7 @@ def test_list_waiting_participants_non_waiting(mock_cache, lobby_service):
|
|||||||
"color": "#654321",
|
"color": "#654321",
|
||||||
}
|
}
|
||||||
|
|
||||||
mock_cache.iter_keys.return_value = [cache_key1, cache_key2]
|
mock_cache.keys.return_value = [cache_key1, cache_key2]
|
||||||
mock_cache.get_many.return_value = {
|
mock_cache.get_many.return_value = {
|
||||||
cache_key1: participant1,
|
cache_key1: participant1,
|
||||||
cache_key2: participant2,
|
cache_key2: participant2,
|
||||||
|
|||||||
@@ -1,108 +0,0 @@
|
|||||||
"""Tests for the presence cache and the cached presence check."""
|
|
||||||
|
|
||||||
# pylint: disable=W0212
|
|
||||||
|
|
||||||
from unittest import mock
|
|
||||||
from uuid import uuid4
|
|
||||||
|
|
||||||
from django.core.cache import cache
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
from core.services.participants_management import (
|
|
||||||
ParticipantNotFoundException,
|
|
||||||
ParticipantsManagement,
|
|
||||||
ParticipantsManagementException,
|
|
||||||
)
|
|
||||||
from core.services.presence import PresenceCache
|
|
||||||
|
|
||||||
|
|
||||||
@mock.patch(
|
|
||||||
"core.services.participants_management.ParticipantsManagement.check_if_in_meeting"
|
|
||||||
)
|
|
||||||
def test_presence_cache_hit_skips_livekit(mock_check):
|
|
||||||
"""A cached positive answer must not call LiveKit."""
|
|
||||||
room_id, identity = str(uuid4()), "user-sub"
|
|
||||||
PresenceCache().mark_present(room_id, identity)
|
|
||||||
|
|
||||||
assert (
|
|
||||||
ParticipantsManagement().check_if_in_meeting_cached(room_id, identity) is True
|
|
||||||
)
|
|
||||||
mock_check.assert_not_called()
|
|
||||||
|
|
||||||
|
|
||||||
@mock.patch(
|
|
||||||
"core.services.participants_management.ParticipantsManagement.check_if_in_meeting"
|
|
||||||
)
|
|
||||||
def test_presence_cache_miss_calls_livekit_and_caches_positive(mock_check):
|
|
||||||
"""On a miss, LiveKit is asked once and a positive answer is memoized."""
|
|
||||||
mock_check.return_value = True
|
|
||||||
room_id, identity = str(uuid4()), "user-sub"
|
|
||||||
service = ParticipantsManagement()
|
|
||||||
|
|
||||||
assert service.check_if_in_meeting_cached(room_id, identity) is True
|
|
||||||
assert service.check_if_in_meeting_cached(room_id, identity) is True
|
|
||||||
assert mock_check.call_count == 1
|
|
||||||
|
|
||||||
|
|
||||||
@mock.patch(
|
|
||||||
"core.services.participants_management.ParticipantsManagement.check_if_in_meeting"
|
|
||||||
)
|
|
||||||
def test_presence_negative_not_cached(mock_check):
|
|
||||||
"""Negative answers are never memoized."""
|
|
||||||
mock_check.return_value = False
|
|
||||||
room_id, identity = str(uuid4()), "user-sub"
|
|
||||||
service = ParticipantsManagement()
|
|
||||||
|
|
||||||
assert service.check_if_in_meeting_cached(room_id, identity) is False
|
|
||||||
assert service.check_if_in_meeting_cached(room_id, identity) is False
|
|
||||||
assert mock_check.call_count == 2
|
|
||||||
|
|
||||||
|
|
||||||
@mock.patch(
|
|
||||||
"core.services.participants_management.ParticipantsManagement.check_if_in_meeting"
|
|
||||||
)
|
|
||||||
def test_presence_errors_propagate_and_cache_nothing(mock_check):
|
|
||||||
"""LiveKit errors propagate to the caller (which fails closed); nothing cached."""
|
|
||||||
room_id, identity = str(uuid4()), "user-sub"
|
|
||||||
|
|
||||||
for exc in (ParticipantNotFoundException(), ParticipantsManagementException()):
|
|
||||||
mock_check.side_effect = exc
|
|
||||||
with pytest.raises(type(exc)):
|
|
||||||
ParticipantsManagement().check_if_in_meeting_cached(room_id, identity)
|
|
||||||
assert cache.get(PresenceCache._get_cache_key(room_id, identity)) is None
|
|
||||||
|
|
||||||
|
|
||||||
def test_presence_clear_and_clear_room():
|
|
||||||
"""clear() removes one entry, clear_room() removes all entries of a room."""
|
|
||||||
room_id, other_room = str(uuid4()), str(uuid4())
|
|
||||||
presence = PresenceCache()
|
|
||||||
presence.mark_present(room_id, "a")
|
|
||||||
presence.mark_present(room_id, "b")
|
|
||||||
presence.mark_present(other_room, "a")
|
|
||||||
|
|
||||||
presence.clear(room_id, "a")
|
|
||||||
assert presence.is_marked_present(room_id, "a") is False
|
|
||||||
assert presence.is_marked_present(room_id, "b") is True
|
|
||||||
|
|
||||||
presence.clear_room(room_id)
|
|
||||||
assert presence.is_marked_present(room_id, "b") is False
|
|
||||||
assert presence.is_marked_present(other_room, "a") is True
|
|
||||||
|
|
||||||
|
|
||||||
def test_presence_clear_room_scans_in_pages():
|
|
||||||
"""clear_room removes every match, even across several SCAN pages,
|
|
||||||
and only within the room."""
|
|
||||||
room_id, other_room = str(uuid4()), str(uuid4())
|
|
||||||
presence = PresenceCache()
|
|
||||||
for i in range(7):
|
|
||||||
presence.mark_present(room_id, f"user-{i}")
|
|
||||||
presence.mark_present(other_room, "user-0")
|
|
||||||
|
|
||||||
# An itersize smaller than the match count forces delete_pattern to
|
|
||||||
# page through several SCAN cursors rather than finish in one pass.
|
|
||||||
with mock.patch("core.utils.CACHE_SCAN_ITERSIZE", 3):
|
|
||||||
presence.clear_room(room_id)
|
|
||||||
|
|
||||||
assert all(not presence.is_marked_present(room_id, f"user-{i}") for i in range(7))
|
|
||||||
assert presence.is_marked_present(other_room, "user-0") is True
|
|
||||||
@@ -5,10 +5,13 @@ from unittest import mock
|
|||||||
import pytest
|
import pytest
|
||||||
from livekit.api import TwirpError
|
from livekit.api import TwirpError
|
||||||
|
|
||||||
|
from core.factories import RoomFactory
|
||||||
|
from core.models import RoomAccessLevel
|
||||||
from core.services.room_management import (
|
from core.services.room_management import (
|
||||||
RoomManagement,
|
RoomManagement,
|
||||||
RoomManagementException,
|
RoomManagementException,
|
||||||
RoomNotFoundException,
|
RoomNotFoundException,
|
||||||
|
sync_room_metadata,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -58,3 +61,22 @@ def test_delete_room_raises_management_exception(mock_create_livekit_client):
|
|||||||
RoomManagement().delete_room("room-abc")
|
RoomManagement().delete_room("room-abc")
|
||||||
|
|
||||||
mock_api.aclose.assert_awaited_once()
|
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},
|
||||||
|
)
|
||||||
|
|
||||||
|
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,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|||||||
@@ -16,8 +16,17 @@ import responses
|
|||||||
from lasuite.oidc_resource_server.authentication import ResourceServerAuthentication
|
from lasuite.oidc_resource_server.authentication import ResourceServerAuthentication
|
||||||
from rest_framework.test import APIClient
|
from rest_framework.test import APIClient
|
||||||
|
|
||||||
|
from core.analytics import AnalyticsEvent
|
||||||
from core.factories import ApplicationFactory, RoomFactory, UserFactory
|
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
|
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
|
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):
|
def test_api_rooms_response_no_url(settings):
|
||||||
"""Response should not include url field when APPLICATION_BASE_URL is None."""
|
"""Response should not include url field when APPLICATION_BASE_URL is None."""
|
||||||
settings.APPLICATION_BASE_URL = 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
|
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
|
# Addons
|
||||||
# ==============================
|
# ==============================
|
||||||
@@ -1548,6 +2160,32 @@ def test_api_rooms_create_with_valid_addons_token():
|
|||||||
assert room.get_role(user) == RoleChoices.OWNER
|
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():
|
def test_api_rooms_addons_token_inactive_user():
|
||||||
"""Addons token for an inactive user should return 401."""
|
"""Addons token for an inactive user should return 401."""
|
||||||
user = UserFactory(is_active=False)
|
user = UserFactory(is_active=False)
|
||||||
|
|||||||
+40
-30
@@ -381,31 +381,6 @@ def detect_mimetype(file_buffer: bytes, filename: str | None = None) -> str:
|
|||||||
return mimetype_from_content or "application/octet-stream"
|
return mimetype_from_content or "application/octet-stream"
|
||||||
|
|
||||||
|
|
||||||
def _get_s3_client(*, override_domain: bool = True):
|
|
||||||
"""Return an S3 client, honoring the AWS_S3_DOMAIN_REPLACE endpoint override.
|
|
||||||
|
|
||||||
AWS_S3_DOMAIN_REPLACE is used when the backend and frontend reach object
|
|
||||||
storage under different domains (this is the case in the docker compose stack
|
|
||||||
used in development: the frontend connects to the object storage on localhost
|
|
||||||
while the backend uses the object storage service name declared in the stack).
|
|
||||||
The domain name is used to compute the signature, so it can't be changed
|
|
||||||
dynamically by the frontend; we build a dedicated boto3 client pointed at that
|
|
||||||
endpoint. Otherwise we reuse the default storage client.
|
|
||||||
"""
|
|
||||||
if settings.AWS_S3_DOMAIN_REPLACE and override_domain:
|
|
||||||
return boto3.client(
|
|
||||||
"s3",
|
|
||||||
aws_access_key_id=settings.AWS_S3_ACCESS_KEY_ID,
|
|
||||||
aws_secret_access_key=settings.AWS_S3_SECRET_ACCESS_KEY,
|
|
||||||
endpoint_url=settings.AWS_S3_DOMAIN_REPLACE,
|
|
||||||
config=botocore.client.Config(
|
|
||||||
region_name=settings.AWS_S3_REGION_NAME,
|
|
||||||
signature_version=settings.AWS_S3_SIGNATURE_VERSION,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
return default_storage.connection.meta.client
|
|
||||||
|
|
||||||
|
|
||||||
def generate_upload_policy(file):
|
def generate_upload_policy(file):
|
||||||
"""
|
"""
|
||||||
Generate a S3 upload policy for a given file.
|
Generate a S3 upload policy for a given file.
|
||||||
@@ -416,7 +391,26 @@ def generate_upload_policy(file):
|
|||||||
|
|
||||||
key = file.temporary_file_key
|
key = file.temporary_file_key
|
||||||
|
|
||||||
s3_client = _get_s3_client()
|
# This settings should be used if the backend application and the frontend application
|
||||||
|
# can't connect to the object storage with the same domain. This is the case in the
|
||||||
|
# docker compose stack used in development. The frontend application will use localhost
|
||||||
|
# to connect to the object storage while the backend application will use the object storage
|
||||||
|
# service name declared in the docker compose stack.
|
||||||
|
# This is needed because the domain name is used to compute the signature. So it can't be
|
||||||
|
# changed dynamically by the frontend application.
|
||||||
|
if settings.AWS_S3_DOMAIN_REPLACE:
|
||||||
|
s3_client = boto3.client(
|
||||||
|
"s3",
|
||||||
|
aws_access_key_id=settings.AWS_S3_ACCESS_KEY_ID,
|
||||||
|
aws_secret_access_key=settings.AWS_S3_SECRET_ACCESS_KEY,
|
||||||
|
endpoint_url=settings.AWS_S3_DOMAIN_REPLACE,
|
||||||
|
config=botocore.client.Config(
|
||||||
|
region_name=settings.AWS_S3_REGION_NAME,
|
||||||
|
signature_version=settings.AWS_S3_SIGNATURE_VERSION,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
s3_client = default_storage.connection.meta.client
|
||||||
|
|
||||||
# Generate the policy
|
# Generate the policy
|
||||||
policy = s3_client.generate_presigned_url(
|
policy = s3_client.generate_presigned_url(
|
||||||
@@ -437,7 +431,26 @@ def generate_download_s3_url(
|
|||||||
if not key:
|
if not key:
|
||||||
raise ValueError("key cannot be empty")
|
raise ValueError("key cannot be empty")
|
||||||
|
|
||||||
s3_client = _get_s3_client(override_domain=override_domain)
|
# This setting should be used if the backend application and the frontend application
|
||||||
|
# can't connect to the object storage with the same domain. This is the case in the
|
||||||
|
# docker compose stack used in development. The frontend application will use localhost
|
||||||
|
# to connect to the object storage while the backend application will use the object storage
|
||||||
|
# service name declared in the docker compose stack.
|
||||||
|
# This is needed because the domain name is used to compute the signature. So it can't be
|
||||||
|
# changed dynamically by the frontend application.
|
||||||
|
if settings.AWS_S3_DOMAIN_REPLACE and override_domain:
|
||||||
|
s3_client = boto3.client(
|
||||||
|
"s3",
|
||||||
|
aws_access_key_id=settings.AWS_S3_ACCESS_KEY_ID,
|
||||||
|
aws_secret_access_key=settings.AWS_S3_SECRET_ACCESS_KEY,
|
||||||
|
endpoint_url=settings.AWS_S3_DOMAIN_REPLACE,
|
||||||
|
config=botocore.client.Config(
|
||||||
|
region_name=settings.AWS_S3_REGION_NAME,
|
||||||
|
signature_version=settings.AWS_S3_SIGNATURE_VERSION,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
s3_client = default_storage.connection.meta.client
|
||||||
|
|
||||||
return s3_client.generate_presigned_url(
|
return s3_client.generate_presigned_url(
|
||||||
ClientMethod="get_object",
|
ClientMethod="get_object",
|
||||||
@@ -499,6 +512,3 @@ def build_telephony_config():
|
|||||||
"default_country": country,
|
"default_country": country,
|
||||||
"international_phone_number": international,
|
"international_phone_number": international,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
CACHE_SCAN_ITERSIZE = 500
|
|
||||||
|
|||||||
@@ -677,7 +677,3 @@ msgstr "Niederländisch"
|
|||||||
#: meet/settings.py:231
|
#: meet/settings.py:231
|
||||||
msgid "German"
|
msgid "German"
|
||||||
msgstr "Deutsch"
|
msgstr "Deutsch"
|
||||||
|
|
||||||
#: meet/settings.py:233
|
|
||||||
msgid "Spanish"
|
|
||||||
msgstr "Spanisch"
|
|
||||||
|
|||||||
@@ -672,7 +672,3 @@ msgstr "Dutch"
|
|||||||
#: meet/settings.py:231
|
#: meet/settings.py:231
|
||||||
msgid "German"
|
msgid "German"
|
||||||
msgstr "German"
|
msgstr "German"
|
||||||
|
|
||||||
#: meet/settings.py:233
|
|
||||||
msgid "Spanish"
|
|
||||||
msgstr "Spanish"
|
|
||||||
|
|||||||
@@ -1,652 +0,0 @@
|
|||||||
# SOME DESCRIPTIVE TITLE.
|
|
||||||
# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER
|
|
||||||
# This file is distributed under the same license as the PACKAGE package.
|
|
||||||
# FIRST AUTHOR <EMAIL@ADDRESS>, YEAR.
|
|
||||||
#
|
|
||||||
#, fuzzy
|
|
||||||
msgid ""
|
|
||||||
msgstr ""
|
|
||||||
"Project-Id-Version: PACKAGE VERSION\n"
|
|
||||||
"Report-Msgid-Bugs-To: \n"
|
|
||||||
"POT-Creation-Date: 2026-07-02 10:47+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 ""
|
|
||||||
|
|
||||||
#: core/admin.py:237
|
|
||||||
# 'Marcas de tiempo' is a bad translation for spanish
|
|
||||||
msgid "Timestamps"
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
#: core/admin.py:240
|
|
||||||
msgid "File preview"
|
|
||||||
msgstr "Vista previa"
|
|
||||||
|
|
||||||
#: core/admin.py:300 core/admin.py:443
|
|
||||||
msgid "No owner"
|
|
||||||
msgstr "Sin propietario"
|
|
||||||
|
|
||||||
#: core/admin.py:303 core/admin.py:446
|
|
||||||
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:510
|
|
||||||
msgid "No scopes"
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
#: core/admin.py:512
|
|
||||||
msgid "Scopes"
|
|
||||||
msgstr "Ámbitos"
|
|
||||||
|
|
||||||
#: core/api/filters.py:25
|
|
||||||
msgid "Creator is me"
|
|
||||||
msgstr "Yo soy el creador"
|
|
||||||
|
|
||||||
#: core/api/serializers.py:89
|
|
||||||
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:534
|
|
||||||
msgid "This file extension is not allowed."
|
|
||||||
msgstr "Esta extensión de archivo no está permitida."
|
|
||||||
|
|
||||||
#: core/api/viewsets.py:1222
|
|
||||||
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:37
|
|
||||||
msgid "Member"
|
|
||||||
msgstr "Miembro"
|
|
||||||
|
|
||||||
#: core/models.py:38
|
|
||||||
msgid "Administrator"
|
|
||||||
msgstr "Administrador"
|
|
||||||
|
|
||||||
#: core/models.py:39
|
|
||||||
msgid "Owner"
|
|
||||||
msgstr "Propietario"
|
|
||||||
|
|
||||||
#: core/models.py:55
|
|
||||||
# To check here and following lines for gender agreement (Masculine/Feminine)
|
|
||||||
msgid "Initiated"
|
|
||||||
msgstr "Iniciada"
|
|
||||||
|
|
||||||
#: core/models.py:56
|
|
||||||
msgid "Active"
|
|
||||||
msgstr "Activa"
|
|
||||||
|
|
||||||
#: core/models.py:57
|
|
||||||
msgid "Stopped"
|
|
||||||
msgstr "Detenida"
|
|
||||||
|
|
||||||
#: core/models.py:58
|
|
||||||
msgid "Saved"
|
|
||||||
msgstr "Guardada"
|
|
||||||
|
|
||||||
#: core/models.py:59
|
|
||||||
msgid "Aborted"
|
|
||||||
msgstr "Cancelada"
|
|
||||||
|
|
||||||
#: core/models.py:60
|
|
||||||
msgid "Failed to Start"
|
|
||||||
msgstr "Error al iniciar"
|
|
||||||
|
|
||||||
#: core/models.py:61
|
|
||||||
msgid "Failed to Stop"
|
|
||||||
msgstr "Error al detener"
|
|
||||||
|
|
||||||
#: core/models.py:62
|
|
||||||
msgid "Notification succeeded"
|
|
||||||
msgstr "Notificado correctamente"
|
|
||||||
|
|
||||||
#: core/models.py:65
|
|
||||||
msgid "External process successful"
|
|
||||||
msgstr "Proceso externo finalizado correctamente"
|
|
||||||
|
|
||||||
#: core/models.py:67
|
|
||||||
msgid "External process failed"
|
|
||||||
msgstr "Error en el Proceso externo"
|
|
||||||
|
|
||||||
#: core/models.py:96
|
|
||||||
msgid "SCREEN_RECORDING"
|
|
||||||
msgstr "GRABACIÓN_DE_PANTALLA"
|
|
||||||
|
|
||||||
#: core/models.py:97
|
|
||||||
msgid "TRANSCRIPT"
|
|
||||||
msgstr "TRANSCRIPCIÓN"
|
|
||||||
|
|
||||||
#: core/models.py:103
|
|
||||||
msgid "Public Access"
|
|
||||||
msgstr "Acceso público"
|
|
||||||
|
|
||||||
#: core/models.py:104
|
|
||||||
msgid "Trusted Access"
|
|
||||||
msgstr "Acceso usuarios autorizados"
|
|
||||||
|
|
||||||
#: core/models.py:105
|
|
||||||
msgid "Restricted Access"
|
|
||||||
msgstr "Acceso restringido"
|
|
||||||
|
|
||||||
#: core/models.py:117
|
|
||||||
msgid "id"
|
|
||||||
msgstr "id"
|
|
||||||
|
|
||||||
#: core/models.py:118
|
|
||||||
msgid "primary key for the record as UUID"
|
|
||||||
msgstr "clave primaria del registro en forma de UUID"
|
|
||||||
|
|
||||||
#: core/models.py:124
|
|
||||||
msgid "created on"
|
|
||||||
msgstr "creado el"
|
|
||||||
|
|
||||||
#: core/models.py:125
|
|
||||||
msgid "date and time at which a record was created"
|
|
||||||
msgstr "fecha y hora en que se creó un registro"
|
|
||||||
|
|
||||||
#: core/models.py:130
|
|
||||||
msgid "updated on"
|
|
||||||
msgstr "actualizado el"
|
|
||||||
|
|
||||||
#: core/models.py:131
|
|
||||||
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:151
|
|
||||||
msgid ""
|
|
||||||
"Enter a valid sub. This value may contain only letters, numbers, and @/./+/-/"
|
|
||||||
"_ characters."
|
|
||||||
msgstr "Introduce un sub válido. Este valor solo puede contener letras, números y los caracteres @/./+/-/_."
|
|
||||||
|
|
||||||
#: core/models.py:157
|
|
||||||
msgid "sub"
|
|
||||||
msgstr "sub"
|
|
||||||
|
|
||||||
#: core/models.py:159
|
|
||||||
msgid ""
|
|
||||||
"Optional for pending users; required upon account activation. 255 characters "
|
|
||||||
"or fewer. Letters, numbers, and @/./+/-/_ characters only."
|
|
||||||
msgstr "Opcional para los usuarios pendientes; obligatorio al activar la cuenta. 255 caracteres como máximo. Solo letras, números y los caracteres @/./+/-/_."
|
|
||||||
|
|
||||||
#: core/models.py:168
|
|
||||||
msgid "identity email address"
|
|
||||||
msgstr "dirección de correo electrónico"
|
|
||||||
|
|
||||||
#: core/models.py:173
|
|
||||||
msgid "admin email address"
|
|
||||||
msgstr "dirección de correo electrónico de administrador"
|
|
||||||
|
|
||||||
#: core/models.py:175
|
|
||||||
msgid "full name"
|
|
||||||
msgstr "nombre completo"
|
|
||||||
|
|
||||||
#: core/models.py:177
|
|
||||||
msgid "short name"
|
|
||||||
msgstr "nombre corto"
|
|
||||||
|
|
||||||
#: core/models.py:183
|
|
||||||
msgid "language"
|
|
||||||
msgstr "idioma"
|
|
||||||
|
|
||||||
#: core/models.py:184
|
|
||||||
msgid "The language in which the user wants to see the interface."
|
|
||||||
msgstr "El idioma preferido de interfaz."
|
|
||||||
|
|
||||||
#: core/models.py:190
|
|
||||||
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:193
|
|
||||||
msgid "device"
|
|
||||||
msgstr "dispositivo"
|
|
||||||
|
|
||||||
#: core/models.py:195
|
|
||||||
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:198
|
|
||||||
msgid "staff status"
|
|
||||||
msgstr "estado del personal"
|
|
||||||
|
|
||||||
#: core/models.py:200
|
|
||||||
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:203
|
|
||||||
msgid "active"
|
|
||||||
msgstr "activo"
|
|
||||||
|
|
||||||
#: core/models.py:206
|
|
||||||
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:219
|
|
||||||
msgid "user"
|
|
||||||
msgstr "usuario"
|
|
||||||
|
|
||||||
#: core/models.py:220
|
|
||||||
msgid "users"
|
|
||||||
msgstr "usuarios"
|
|
||||||
|
|
||||||
#: core/models.py:286
|
|
||||||
msgid "Resource"
|
|
||||||
msgstr "Recurso"
|
|
||||||
|
|
||||||
#: core/models.py:287
|
|
||||||
msgid "Resources"
|
|
||||||
msgstr "Recursos"
|
|
||||||
|
|
||||||
#: core/models.py:345
|
|
||||||
msgid "Resource access"
|
|
||||||
msgstr "Acceso a recursos"
|
|
||||||
|
|
||||||
#: core/models.py:346
|
|
||||||
msgid "Resource accesses"
|
|
||||||
msgstr "Accesos a recursos"
|
|
||||||
|
|
||||||
#: core/models.py:352
|
|
||||||
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:409
|
|
||||||
msgid "Visio room configuration"
|
|
||||||
msgstr "Configuración de la videoconferencia"
|
|
||||||
|
|
||||||
#: core/models.py:410
|
|
||||||
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:417
|
|
||||||
msgid "Room PIN code"
|
|
||||||
msgstr "Código PIN de la reunión"
|
|
||||||
|
|
||||||
#: core/models.py:418
|
|
||||||
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:424 core/models.py:578
|
|
||||||
msgid "Room"
|
|
||||||
msgstr "Reunión"
|
|
||||||
|
|
||||||
#: core/models.py:425
|
|
||||||
msgid "Rooms"
|
|
||||||
msgstr "Reuniones"
|
|
||||||
|
|
||||||
#: core/models.py:589
|
|
||||||
msgid "Worker ID"
|
|
||||||
msgstr "ID del worker"
|
|
||||||
|
|
||||||
#: core/models.py:591
|
|
||||||
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:599
|
|
||||||
msgid "Recording mode"
|
|
||||||
msgstr "Modo de grabación"
|
|
||||||
|
|
||||||
#: core/models.py:600
|
|
||||||
msgid "Defines the mode of recording being called."
|
|
||||||
msgstr "Define el modo de grabación a utilizar."
|
|
||||||
|
|
||||||
#: core/models.py:605 core/models.py:606
|
|
||||||
msgid "Recording options"
|
|
||||||
msgstr "Opciones de grabación"
|
|
||||||
|
|
||||||
#: core/models.py:613
|
|
||||||
msgid "External Process ID"
|
|
||||||
msgstr "ID del proceso externo"
|
|
||||||
|
|
||||||
#: core/models.py:614
|
|
||||||
msgid "ID of the external process associated with the recording."
|
|
||||||
msgstr "ID del proceso externo asociado a la grabación."
|
|
||||||
|
|
||||||
#: core/models.py:620
|
|
||||||
msgid "Recording"
|
|
||||||
msgstr "Grabación"
|
|
||||||
|
|
||||||
#: core/models.py:621
|
|
||||||
msgid "Recordings"
|
|
||||||
msgstr "Grabaciones"
|
|
||||||
|
|
||||||
#: core/models.py:731
|
|
||||||
msgid "Recording/user relation"
|
|
||||||
msgstr "Relación grabación/usuario"
|
|
||||||
|
|
||||||
#: core/models.py:732
|
|
||||||
msgid "Recording/user relations"
|
|
||||||
msgstr "Relaciones grabación/usuario"
|
|
||||||
|
|
||||||
#: core/models.py:738
|
|
||||||
msgid "This user is already in this recording."
|
|
||||||
msgstr "Este usuario ya está en esta grabación."
|
|
||||||
|
|
||||||
#: core/models.py:744
|
|
||||||
msgid "This team is already in this recording."
|
|
||||||
msgstr "Este equipo ya está en esta grabación."
|
|
||||||
|
|
||||||
#: core/models.py:750
|
|
||||||
msgid "Either user or team must be set, not both."
|
|
||||||
msgstr "Debe definirse el usuario o el equipo, pero no ambos."
|
|
||||||
|
|
||||||
#: core/models.py:767
|
|
||||||
msgid "Create rooms"
|
|
||||||
msgstr "Crear reunión"
|
|
||||||
|
|
||||||
#: core/models.py:768
|
|
||||||
msgid "List rooms"
|
|
||||||
msgstr "Listar reuniones"
|
|
||||||
|
|
||||||
#: core/models.py:769
|
|
||||||
msgid "Retrieve room details"
|
|
||||||
msgstr "Ver los detalles de una reunión"
|
|
||||||
|
|
||||||
#: core/models.py:770
|
|
||||||
msgid "Update rooms"
|
|
||||||
msgstr "Actualizar las reuniones"
|
|
||||||
|
|
||||||
#: core/models.py:771
|
|
||||||
msgid "Delete rooms"
|
|
||||||
msgstr "Eliminar las reuniones"
|
|
||||||
|
|
||||||
#: core/models.py:784
|
|
||||||
msgid "Application name"
|
|
||||||
msgstr "Nombre de la aplicación"
|
|
||||||
|
|
||||||
#: core/models.py:785
|
|
||||||
msgid "Descriptive name for this application."
|
|
||||||
msgstr "Nombre descriptivo de esta aplicación."
|
|
||||||
|
|
||||||
#: core/models.py:795
|
|
||||||
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:806
|
|
||||||
msgid "Application"
|
|
||||||
msgstr "Aplicación"
|
|
||||||
|
|
||||||
#: core/models.py:807
|
|
||||||
msgid "Applications"
|
|
||||||
msgstr "Aplicaciones"
|
|
||||||
|
|
||||||
#: core/models.py:830
|
|
||||||
msgid "Enter a valid domain"
|
|
||||||
msgstr "Introduce un dominio válido"
|
|
||||||
|
|
||||||
#: core/models.py:833
|
|
||||||
msgid "Domain"
|
|
||||||
msgstr "Dominio"
|
|
||||||
|
|
||||||
#: core/models.py:834
|
|
||||||
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:846
|
|
||||||
msgid "Application domain"
|
|
||||||
msgstr "Dominio de aplicación"
|
|
||||||
|
|
||||||
#: core/models.py:847
|
|
||||||
msgid "Application domains"
|
|
||||||
msgstr "Dominios de aplicación"
|
|
||||||
|
|
||||||
#: core/models.py:865
|
|
||||||
msgid "Pending"
|
|
||||||
msgstr "Pendiente"
|
|
||||||
|
|
||||||
#: core/models.py:866
|
|
||||||
msgid "Analyzing"
|
|
||||||
msgstr "Analizando"
|
|
||||||
|
|
||||||
#: core/models.py:873
|
|
||||||
msgid "Ready"
|
|
||||||
msgstr "Listo"
|
|
||||||
|
|
||||||
#: core/models.py:879
|
|
||||||
msgid "Background image"
|
|
||||||
msgstr "Imagen de fondo"
|
|
||||||
|
|
||||||
#: core/models.py:891
|
|
||||||
msgid "title"
|
|
||||||
msgstr "título"
|
|
||||||
|
|
||||||
#: core/models.py:915
|
|
||||||
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:920
|
|
||||||
msgid "File"
|
|
||||||
msgstr "Archivo"
|
|
||||||
|
|
||||||
#: core/models.py:921
|
|
||||||
msgid "Files"
|
|
||||||
msgstr "Archivos"
|
|
||||||
|
|
||||||
#: core/models.py:1041
|
|
||||||
msgid "This file is already hard deleted."
|
|
||||||
msgstr "Este archivo ya se ha eliminado definitivamente."
|
|
||||||
|
|
||||||
#: core/models.py:1051
|
|
||||||
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:123
|
|
||||||
msgid "Your recording is ready"
|
|
||||||
msgstr "Tu grabación está lista"
|
|
||||||
|
|
||||||
#: core/recording/event/notification.py:194
|
|
||||||
msgid "Transcription"
|
|
||||||
msgstr "Transcripción"
|
|
||||||
|
|
||||||
#: core/recording/event/notification.py:204
|
|
||||||
#, 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:159
|
|
||||||
#: core/templates/mail/html/screen_recording.html:159
|
|
||||||
#: core/templates/mail/text/invitation.txt:3
|
|
||||||
#: core/templates/mail/text/screen_recording.txt:3
|
|
||||||
msgid "Logo email"
|
|
||||||
msgstr "Logotipo del correo"
|
|
||||||
|
|
||||||
#: core/templates/mail/html/invitation.html:189
|
|
||||||
#: core/templates/mail/text/invitation.txt:5
|
|
||||||
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:200
|
|
||||||
#: core/templates/mail/text/invitation.txt:7
|
|
||||||
msgid "JOIN THE CALL"
|
|
||||||
msgstr "UNIRSE A LA LLAMADA"
|
|
||||||
|
|
||||||
#: core/templates/mail/html/invitation.html:227
|
|
||||||
#: core/templates/mail/text/invitation.txt:13
|
|
||||||
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:235
|
|
||||||
#: core/templates/mail/text/invitation.txt:15
|
|
||||||
msgid "Tips for a better experience:"
|
|
||||||
msgstr "Consejos para una mejor experiencia:"
|
|
||||||
|
|
||||||
#: core/templates/mail/html/invitation.html:237
|
|
||||||
#: core/templates/mail/text/invitation.txt:17
|
|
||||||
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:238
|
|
||||||
#: core/templates/mail/text/invitation.txt:18
|
|
||||||
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:239
|
|
||||||
#: core/templates/mail/text/invitation.txt:19
|
|
||||||
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:248
|
|
||||||
#: core/templates/mail/html/screen_recording.html:245
|
|
||||||
#: core/templates/mail/text/invitation.txt:21
|
|
||||||
#: core/templates/mail/text/screen_recording.txt:23
|
|
||||||
#, python-format
|
|
||||||
msgid " Thank you for using %(brandname)s. "
|
|
||||||
msgstr " Gracias por usar %(brandname)s. "
|
|
||||||
|
|
||||||
#: core/templates/mail/html/screen_recording.html:188
|
|
||||||
#: core/templates/mail/text/screen_recording.txt:6
|
|
||||||
msgid "Your recording is ready!"
|
|
||||||
msgstr "¡Tu grabación está lista!"
|
|
||||||
|
|
||||||
#: core/templates/mail/html/screen_recording.html:195
|
|
||||||
#: core/templates/mail/text/screen_recording.txt:8
|
|
||||||
#, 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:195
|
|
||||||
#: core/templates/mail/text/screen_recording.txt:8
|
|
||||||
#, 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:200
|
|
||||||
#: core/templates/mail/text/screen_recording.txt:9
|
|
||||||
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:206
|
|
||||||
#: core/templates/mail/text/screen_recording.txt:11
|
|
||||||
msgid "To keep this recording permanently:"
|
|
||||||
msgstr "Para conservar esta grabación de forma permanente:"
|
|
||||||
|
|
||||||
#: core/templates/mail/html/screen_recording.html:208
|
|
||||||
#, 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:209
|
|
||||||
#: core/templates/mail/text/screen_recording.txt:14
|
|
||||||
msgid "Use the \"Download\" button in the interface "
|
|
||||||
msgstr "Usa el botón \"Descargar\" de la interfaz "
|
|
||||||
|
|
||||||
#: core/templates/mail/html/screen_recording.html:210
|
|
||||||
#: core/templates/mail/text/screen_recording.txt:15
|
|
||||||
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:221
|
|
||||||
#: core/templates/mail/text/screen_recording.txt:17
|
|
||||||
msgid "Open"
|
|
||||||
msgstr "Abrir"
|
|
||||||
|
|
||||||
#: core/templates/mail/html/screen_recording.html:230
|
|
||||||
#: core/templates/mail/text/screen_recording.txt:19
|
|
||||||
#, 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/screen_recording.txt:13
|
|
||||||
#, 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:228
|
|
||||||
msgid "English"
|
|
||||||
msgstr "Inglés"
|
|
||||||
|
|
||||||
#: meet/settings.py:229
|
|
||||||
msgid "French"
|
|
||||||
msgstr "Francés"
|
|
||||||
|
|
||||||
#: meet/settings.py:230
|
|
||||||
msgid "Dutch"
|
|
||||||
msgstr "Neerlandés"
|
|
||||||
|
|
||||||
#: meet/settings.py:231
|
|
||||||
msgid "German"
|
|
||||||
msgstr "Alemán"
|
|
||||||
|
|
||||||
#: meet/settings.py:233
|
|
||||||
msgid "Spanish"
|
|
||||||
msgstr "Español"
|
|
||||||
@@ -678,7 +678,3 @@ msgstr "Néerlandais"
|
|||||||
#: meet/settings.py:231
|
#: meet/settings.py:231
|
||||||
msgid "German"
|
msgid "German"
|
||||||
msgstr "Allemand"
|
msgstr "Allemand"
|
||||||
|
|
||||||
#: meet/settings.py:233
|
|
||||||
msgid "Spanish"
|
|
||||||
msgstr "Espagnol"
|
|
||||||
|
|||||||
@@ -672,7 +672,3 @@ msgstr "Nederlands"
|
|||||||
#: meet/settings.py:231
|
#: meet/settings.py:231
|
||||||
msgid "German"
|
msgid "German"
|
||||||
msgstr "Duits"
|
msgstr "Duits"
|
||||||
|
|
||||||
#: meet/settings.py:233
|
|
||||||
msgid "Spanish"
|
|
||||||
msgstr "Spaans"
|
|
||||||
|
|||||||
@@ -230,7 +230,6 @@ class Base(Configuration):
|
|||||||
("fr-fr", _("French")),
|
("fr-fr", _("French")),
|
||||||
("nl-nl", _("Dutch")),
|
("nl-nl", _("Dutch")),
|
||||||
("de-de", _("German")),
|
("de-de", _("German")),
|
||||||
("es-es", _("Spanish")),
|
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -730,6 +729,20 @@ class Base(Configuration):
|
|||||||
environ_name="RECORDING_WORKER_CLASSES",
|
environ_name="RECORDING_WORKER_CLASSES",
|
||||||
environ_prefix=None,
|
environ_prefix=None,
|
||||||
)
|
)
|
||||||
|
RECORDING_EVENT_PARSER_CLASS = values.Value(
|
||||||
|
"core.recording.event.parsers.MinioParser",
|
||||||
|
environ_name="RECORDING_EVENT_PARSER_CLASS",
|
||||||
|
environ_prefix=None,
|
||||||
|
)
|
||||||
|
RECORDING_ENABLE_STORAGE_EVENT_AUTH = values.BooleanValue(
|
||||||
|
True, environ_name="RECORDING_ENABLE_STORAGE_EVENT_AUTH", environ_prefix=None
|
||||||
|
)
|
||||||
|
RECORDING_STORAGE_EVENT_ENABLE = values.BooleanValue(
|
||||||
|
False, environ_name="RECORDING_STORAGE_EVENT_ENABLE", environ_prefix=None
|
||||||
|
)
|
||||||
|
RECORDING_STORAGE_EVENT_TOKEN = SecretFileValue(
|
||||||
|
None, environ_name="RECORDING_STORAGE_EVENT_TOKEN", environ_prefix=None
|
||||||
|
)
|
||||||
# Number of days before recordings expire - must be synced with bucket lifecycle policy
|
# Number of days before recordings expire - must be synced with bucket lifecycle policy
|
||||||
# Set to None for no expiration
|
# Set to None for no expiration
|
||||||
RECORDING_EXPIRATION_DAYS = values.IntegerValue(
|
RECORDING_EXPIRATION_DAYS = values.IntegerValue(
|
||||||
@@ -843,15 +856,6 @@ class Base(Configuration):
|
|||||||
)
|
)
|
||||||
|
|
||||||
# Lobby configurations
|
# Lobby configurations
|
||||||
PRESENCE_KEY_PREFIX = values.Value(
|
|
||||||
"room_presence", environ_name="PRESENCE_KEY_PREFIX", environ_prefix=None
|
|
||||||
)
|
|
||||||
PRESENCE_CACHE_TIMEOUT = values.PositiveIntegerValue(
|
|
||||||
3600, environ_name="PRESENCE_CACHE_TIMEOUT", environ_prefix=None
|
|
||||||
)
|
|
||||||
PRESENCE_CLEAR_ON_PARTICIPANT_LEFT = values.BooleanValue(
|
|
||||||
True, environ_name="PRESENCE_CLEAR_ON_PARTICIPANT_LEFT", environ_prefix=None
|
|
||||||
)
|
|
||||||
LOBBY_KEY_PREFIX = values.Value(
|
LOBBY_KEY_PREFIX = values.Value(
|
||||||
"room_lobby", environ_name="LOBBY_KEY_PREFIX", environ_prefix=None
|
"room_lobby", environ_name="LOBBY_KEY_PREFIX", environ_prefix=None
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ build-backend = "uv_build"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "meet"
|
name = "meet"
|
||||||
version = "1.30.0"
|
version = "1.28.0"
|
||||||
authors = [{ "name" = "DINUM", "email" = "dev@mail.numerique.gouv.fr" }]
|
authors = [{ "name" = "DINUM", "email" = "dev@mail.numerique.gouv.fr" }]
|
||||||
classifiers = [
|
classifiers = [
|
||||||
"Development Status :: 5 - Production/Stable",
|
"Development Status :: 5 - Production/Stable",
|
||||||
|
|||||||
Generated
+1
-1
@@ -1187,7 +1187,7 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "meet"
|
name = "meet"
|
||||||
version = "1.30.0"
|
version = "1.28.0"
|
||||||
source = { editable = "." }
|
source = { editable = "." }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "aiohttp" },
|
{ name = "aiohttp" },
|
||||||
|
|||||||
@@ -3,6 +3,6 @@
|
|||||||
"input": ["src/**/*.{ts,tsx}", "!src/styled-system/**/*", "!src/**/*.d.ts"],
|
"input": ["src/**/*.{ts,tsx}", "!src/styled-system/**/*", "!src/**/*.d.ts"],
|
||||||
"output": "src/locales/$LOCALE/$NAMESPACE.json",
|
"output": "src/locales/$LOCALE/$NAMESPACE.json",
|
||||||
"createOldCatalogs": false,
|
"createOldCatalogs": false,
|
||||||
"locales": ["en", "fr", "de", "nl", "es"],
|
"locales": ["en", "fr", "de", "nl"],
|
||||||
"sort": true
|
"sort": true
|
||||||
}
|
}
|
||||||
|
|||||||
Generated
+37
-42
@@ -1,16 +1,16 @@
|
|||||||
{
|
{
|
||||||
"name": "meet",
|
"name": "meet",
|
||||||
"version": "1.30.0",
|
"version": "1.28.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "meet",
|
"name": "meet",
|
||||||
"version": "1.30.0",
|
"version": "1.28.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@fontsource-variable/atkinson-hyperlegible-next": "5.3.0",
|
"@fontsource-variable/atkinson-hyperlegible-next": "5.3.0",
|
||||||
"@fontsource-variable/lexend": "5.3.0",
|
"@fontsource-variable/lexend": "5.2.11",
|
||||||
"@fontsource/opendyslexic": "5.3.0",
|
"@fontsource/opendyslexic": "5.2.5",
|
||||||
"@libreaudio/la-call": "0.1.4",
|
"@libreaudio/la-call": "0.1.4",
|
||||||
"@livekit/components-react": "2.9.23",
|
"@livekit/components-react": "2.9.23",
|
||||||
"@livekit/components-styles": "1.2.0",
|
"@livekit/components-styles": "1.2.0",
|
||||||
@@ -29,7 +29,7 @@
|
|||||||
"i18next-parser": "9.4.0",
|
"i18next-parser": "9.4.0",
|
||||||
"i18next-resources-to-backend": "1.2.3",
|
"i18next-resources-to-backend": "1.2.3",
|
||||||
"livekit-client": "2.21.0",
|
"livekit-client": "2.21.0",
|
||||||
"posthog-js": "1.414.0",
|
"posthog-js": "1.409.5",
|
||||||
"react": "18.3.1",
|
"react": "18.3.1",
|
||||||
"react-aria": "3.50.0",
|
"react-aria": "3.50.0",
|
||||||
"react-aria-components": "1.19.0",
|
"react-aria-components": "1.19.0",
|
||||||
@@ -776,18 +776,18 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@fontsource-variable/lexend": {
|
"node_modules/@fontsource-variable/lexend": {
|
||||||
"version": "5.3.0",
|
"version": "5.2.11",
|
||||||
"resolved": "https://registry.npmjs.org/@fontsource-variable/lexend/-/lexend-5.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/@fontsource-variable/lexend/-/lexend-5.2.11.tgz",
|
||||||
"integrity": "sha512-3SXtiZ8rFbT0oaPzEboCG5RM3jtyhpNxandh1jWNLqvrJRfV/eP3R+GGw+o5e3hS1uqmAGutKxSvaWG5oyrimA==",
|
"integrity": "sha512-0hgEQ4O7Nh8fxL/WWmspJf0BErbocRkZwtLRGey/V4mUUqxfF7QUwqhcdzwpjom3NYCniY4uzQ5wYD7r9/92tQ==",
|
||||||
"license": "OFL-1.1",
|
"license": "OFL-1.1",
|
||||||
"funding": {
|
"funding": {
|
||||||
"url": "https://github.com/sponsors/ayuhito"
|
"url": "https://github.com/sponsors/ayuhito"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@fontsource/opendyslexic": {
|
"node_modules/@fontsource/opendyslexic": {
|
||||||
"version": "5.3.0",
|
"version": "5.2.5",
|
||||||
"resolved": "https://registry.npmjs.org/@fontsource/opendyslexic/-/opendyslexic-5.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/@fontsource/opendyslexic/-/opendyslexic-5.2.5.tgz",
|
||||||
"integrity": "sha512-BVYIW/ghc1U2iAghhYTN+GFWiZ3lnCy8Jf2KkZC23aHQHBNcMqKT718zckfqVxRx4qsXJmIMPWvczirRuhmtsg==",
|
"integrity": "sha512-NNS9aaPQx2TlaTvb3vTEjw3xz8lKj23mBc+6rM00mSNFDygdoll0/nLMHFtDKKrBT6sMfY6TFFPOR0D9ktdspg==",
|
||||||
"license": "OFL-1.1",
|
"license": "OFL-1.1",
|
||||||
"funding": {
|
"funding": {
|
||||||
"url": "https://github.com/sponsors/ayuhito"
|
"url": "https://github.com/sponsors/ayuhito"
|
||||||
@@ -1720,28 +1720,28 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/@posthog/browser-common": {
|
"node_modules/@posthog/browser-common": {
|
||||||
"version": "0.4.0",
|
"version": "0.3.1",
|
||||||
"resolved": "https://registry.npmjs.org/@posthog/browser-common/-/browser-common-0.4.0.tgz",
|
"resolved": "https://registry.npmjs.org/@posthog/browser-common/-/browser-common-0.3.1.tgz",
|
||||||
"integrity": "sha512-W9DCGVks15docUMPvJ2nd8NS16Gn74bsGWuaeg31beEKFSjdW8wvnQ1ETY6WSql5pYxZb3GdJmEUZVVstKSrBQ==",
|
"integrity": "sha512-1nhMVY1wnHADTg8tR9yvm+lPAz5ROxznfQlBtLzB2FFo4lp/LU8lk9KyFPsARDkBxCfYachsJfvRjocL1G/AJQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@posthog/core": "^1.46.8",
|
"@posthog/core": "^1.46.0",
|
||||||
"@posthog/types": "^1.402.0"
|
"@posthog/types": "^1.399.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@posthog/core": {
|
"node_modules/@posthog/core": {
|
||||||
"version": "1.49.1",
|
"version": "1.48.3",
|
||||||
"resolved": "https://registry.npmjs.org/@posthog/core/-/core-1.49.1.tgz",
|
"resolved": "https://registry.npmjs.org/@posthog/core/-/core-1.48.3.tgz",
|
||||||
"integrity": "sha512-jdZh85tG56OXLH881CVwBZyiXCPPaZasfYeWwm9kVUvxC/Rb+lz7wYN9GuqSEmNPJgVnK4v8wS0bCaFc3OmVEA==",
|
"integrity": "sha512-kwVDVvwtCTXctApA2tpnwDjDDan8LrkwCW1Wv6PABaVs/s5ahbQ9W3pvdXcqr71HCuqukSA1jp7MySczebubGg==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@posthog/types": "^1.407.0"
|
"@posthog/types": "^1.405.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@posthog/types": {
|
"node_modules/@posthog/types": {
|
||||||
"version": "1.407.1",
|
"version": "1.405.0",
|
||||||
"resolved": "https://registry.npmjs.org/@posthog/types/-/types-1.407.1.tgz",
|
"resolved": "https://registry.npmjs.org/@posthog/types/-/types-1.405.0.tgz",
|
||||||
"integrity": "sha512-WhbkXPC2rgylXqmxHqv70ffI3k+KxyR6s7DBIfr5NvIqHkxp6v0pk31D/jbz0DNVbzwkLjyll2pxr4FNbJiYzg==",
|
"integrity": "sha512-4rZ/taVXKQxs9Jrf7ZjlCRgrOSL69oKAgIWJQa5kRNJ6wll1UANbrJTSY+Su1e88LIG4zZVjKKKjyQHCkHdHcw==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/@react-aria/overlays": {
|
"node_modules/@react-aria/overlays": {
|
||||||
@@ -4794,10 +4794,13 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/dompurify": {
|
"node_modules/dompurify": {
|
||||||
"version": "3.4.14",
|
"version": "3.3.2",
|
||||||
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.14.tgz",
|
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.3.2.tgz",
|
||||||
"integrity": "sha512-dVoH9z+MY+C9IilgGCk3YfFqjLi3fChm2OiKJMzh6axrJ5qwxqWaZamgmHrpv22CN/KdbZJuGEGgfQoL00LTdg==",
|
"integrity": "sha512-6obghkliLdmKa56xdbLOpUZ43pAR6xFy1uOrxBaIDjT+yaRuuybLjGS9eVBoSR/UPU5fq3OXClEHLJNGvbxKpQ==",
|
||||||
"license": "(MPL-2.0 OR Apache-2.0)",
|
"license": "(MPL-2.0 OR Apache-2.0)",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20"
|
||||||
|
},
|
||||||
"optionalDependencies": {
|
"optionalDependencies": {
|
||||||
"@types/trusted-types": "^2.0.7"
|
"@types/trusted-types": "^2.0.7"
|
||||||
}
|
}
|
||||||
@@ -9131,21 +9134,20 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/posthog-js": {
|
"node_modules/posthog-js": {
|
||||||
"version": "1.414.0",
|
"version": "1.409.5",
|
||||||
"resolved": "https://registry.npmjs.org/posthog-js/-/posthog-js-1.414.0.tgz",
|
"resolved": "https://registry.npmjs.org/posthog-js/-/posthog-js-1.409.5.tgz",
|
||||||
"integrity": "sha512-dtZd4asdskr8lNyltAEX6zyn48uO1pO0EMvx6AXJU65PFhu6yn2LPbKtQcyLysjcN57FJPNT9QYL6St5SBJHqw==",
|
"integrity": "sha512-s1iJz+vq0YAluUD4OwLjUFIJt/9pqiiB6MITvHWIS16a7pqTJqbSLXsd5/8kJcIgfrOSZHe+mdYAXLYcJCS4LQ==",
|
||||||
"license": "(Apache-2.0 AND MIT)",
|
"license": "(Apache-2.0 AND MIT)",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@posthog/browser-common": "^0.4.0",
|
"@posthog/browser-common": "^0.3.1",
|
||||||
"@posthog/core": "^1.46.9",
|
"@posthog/core": "^1.46.1",
|
||||||
"@posthog/types": "^1.402.2",
|
"@posthog/types": "^1.399.0",
|
||||||
"core-js": "^3.49.0",
|
"core-js": "^3.49.0",
|
||||||
"dompurify": "^3.4.12",
|
"dompurify": "^3.3.2",
|
||||||
"fflate": "^0.4.8",
|
"fflate": "^0.4.8",
|
||||||
"preact": "^10.29.3",
|
"preact": "^10.29.3",
|
||||||
"query-selector-shadow-dom": "^1.0.1",
|
"query-selector-shadow-dom": "^1.0.1",
|
||||||
"web-vitals": "^5.3.0",
|
"web-vitals": "^5.3.0"
|
||||||
"web-vitals-soft-navs": "npm:web-vitals@6.0.0"
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/powershell-utils": {
|
"node_modules/powershell-utils": {
|
||||||
@@ -11657,13 +11659,6 @@
|
|||||||
"integrity": "sha512-q6LWsLatGYZp5VGBIOvbTj6JBV2nOmC8KvWztXBmwJcfFAzhwKwbOxhUH306XY3CcaZDUlSmSuNPBsCn0bFu+g==",
|
"integrity": "sha512-q6LWsLatGYZp5VGBIOvbTj6JBV2nOmC8KvWztXBmwJcfFAzhwKwbOxhUH306XY3CcaZDUlSmSuNPBsCn0bFu+g==",
|
||||||
"license": "Apache-2.0"
|
"license": "Apache-2.0"
|
||||||
},
|
},
|
||||||
"node_modules/web-vitals-soft-navs": {
|
|
||||||
"name": "web-vitals",
|
|
||||||
"version": "6.0.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/web-vitals/-/web-vitals-6.0.0.tgz",
|
|
||||||
"integrity": "sha512-Guaibvy/+uNtL6Bsu4jmMJGzuSl91oeRH5iO9pPRbYftnFUr3yqT1TUNX/OE4o9HexuEMU3Kb/Wg7iKhlffZUA==",
|
|
||||||
"license": "Apache-2.0"
|
|
||||||
},
|
|
||||||
"node_modules/webrtc-adapter": {
|
"node_modules/webrtc-adapter": {
|
||||||
"version": "9.0.6",
|
"version": "9.0.6",
|
||||||
"resolved": "https://registry.npmjs.org/webrtc-adapter/-/webrtc-adapter-9.0.6.tgz",
|
"resolved": "https://registry.npmjs.org/webrtc-adapter/-/webrtc-adapter-9.0.6.tgz",
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "meet",
|
"name": "meet",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "1.30.0",
|
"version": "1.28.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "panda codegen && vite",
|
"dev": "panda codegen && vite",
|
||||||
@@ -16,8 +16,8 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@fontsource-variable/atkinson-hyperlegible-next": "5.3.0",
|
"@fontsource-variable/atkinson-hyperlegible-next": "5.3.0",
|
||||||
"@fontsource-variable/lexend": "5.3.0",
|
"@fontsource-variable/lexend": "5.2.11",
|
||||||
"@fontsource/opendyslexic": "5.3.0",
|
"@fontsource/opendyslexic": "5.2.5",
|
||||||
"@libreaudio/la-call": "0.1.4",
|
"@libreaudio/la-call": "0.1.4",
|
||||||
"@livekit/components-react": "2.9.23",
|
"@livekit/components-react": "2.9.23",
|
||||||
"@livekit/components-styles": "1.2.0",
|
"@livekit/components-styles": "1.2.0",
|
||||||
@@ -36,7 +36,7 @@
|
|||||||
"i18next-parser": "9.4.0",
|
"i18next-parser": "9.4.0",
|
||||||
"i18next-resources-to-backend": "1.2.3",
|
"i18next-resources-to-backend": "1.2.3",
|
||||||
"livekit-client": "2.21.0",
|
"livekit-client": "2.21.0",
|
||||||
"posthog-js": "1.414.0",
|
"posthog-js": "1.409.5",
|
||||||
"react": "18.3.1",
|
"react": "18.3.1",
|
||||||
"react-aria": "3.50.0",
|
"react-aria": "3.50.0",
|
||||||
"react-aria-components": "1.19.0",
|
"react-aria-components": "1.19.0",
|
||||||
|
|||||||
@@ -21,9 +21,7 @@ const StyledContainer = styled('div', {
|
|||||||
})
|
})
|
||||||
|
|
||||||
export const ChatTextArea = () => {
|
export const ChatTextArea = () => {
|
||||||
const { isSending, send, textAreaValue } = useSnapshot(chatStore, {
|
const { isSending, send, textAreaValue } = useSnapshot(chatStore)
|
||||||
sync: true,
|
|
||||||
})
|
|
||||||
|
|
||||||
const { t } = useTranslation('rooms', { keyPrefix: 'controls.chat.input' })
|
const { t } = useTranslation('rooms', { keyPrefix: 'controls.chat.input' })
|
||||||
|
|
||||||
@@ -51,7 +49,7 @@ export const ChatTextArea = () => {
|
|||||||
const isDisabled = !textAreaValue.trim() || isSending
|
const isDisabled = !textAreaValue.trim() || isSending
|
||||||
|
|
||||||
const onKeyDown = async (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
const onKeyDown = async (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||||
if (e.key !== 'Escape') e.stopPropagation()
|
e.stopPropagation()
|
||||||
if (e.key !== 'Enter' || (e.key === 'Enter' && e.shiftKey) || isDisabled)
|
if (e.key !== 'Enter' || (e.key === 'Enter' && e.shiftKey) || isDisabled)
|
||||||
return
|
return
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
|
|||||||
@@ -1,595 +0,0 @@
|
|||||||
import { CSSProperties, useState } from 'react'
|
|
||||||
import { useConnectionState, useRoomContext } from '@livekit/components-react'
|
|
||||||
import { ConnectionState } from 'livekit-client'
|
|
||||||
import { StatsSnapshot, TrackRow, useWebRTCStats } from './useWebRTCStats'
|
|
||||||
import { readRoomConfig } from './roomConfig'
|
|
||||||
import {
|
|
||||||
forceTransport,
|
|
||||||
releaseForcedTransport,
|
|
||||||
Scenario,
|
|
||||||
SCENARIOS,
|
|
||||||
setDownlinkCap,
|
|
||||||
setUplinkCap,
|
|
||||||
stepBitrate,
|
|
||||||
TransportMode,
|
|
||||||
transportModeFromRoute,
|
|
||||||
} from './simulation'
|
|
||||||
|
|
||||||
const SANS =
|
|
||||||
"-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif"
|
|
||||||
const MONO = 'ui-monospace, SFMono-Regular, Menlo, Consolas, monospace'
|
|
||||||
|
|
||||||
const COLOR = {
|
|
||||||
text: '#e8e8e8',
|
|
||||||
muted: '#9a9a9a',
|
|
||||||
faint: '#6f6f6f',
|
|
||||||
hairline: '#2c2d31',
|
|
||||||
border: '#3d3e44',
|
|
||||||
surface: '#1b1c1e',
|
|
||||||
chartBg: '#141517',
|
|
||||||
down: '#f6821f',
|
|
||||||
up: '#5a9cf8',
|
|
||||||
ok: '#10b981',
|
|
||||||
bad: '#f05a4a',
|
|
||||||
busy: '#e5a13c',
|
|
||||||
}
|
|
||||||
|
|
||||||
const stateColor = (state: ConnectionState): string => {
|
|
||||||
switch (state) {
|
|
||||||
case ConnectionState.Connected:
|
|
||||||
return COLOR.ok
|
|
||||||
case ConnectionState.Reconnecting:
|
|
||||||
case ConnectionState.SignalReconnecting:
|
|
||||||
return COLOR.busy
|
|
||||||
case ConnectionState.Connecting:
|
|
||||||
return COLOR.up
|
|
||||||
case ConnectionState.Disconnected:
|
|
||||||
return COLOR.bad
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const styles: Record<string, CSSProperties> = {
|
|
||||||
toggle: {
|
|
||||||
position: 'fixed',
|
|
||||||
bottom: 12,
|
|
||||||
right: 12,
|
|
||||||
zIndex: 9999,
|
|
||||||
fontFamily: SANS,
|
|
||||||
fontSize: 12,
|
|
||||||
lineHeight: 1,
|
|
||||||
padding: '8px 12px',
|
|
||||||
borderRadius: 999,
|
|
||||||
borderWidth: 1,
|
|
||||||
borderStyle: 'solid',
|
|
||||||
borderColor: COLOR.border,
|
|
||||||
background: COLOR.surface,
|
|
||||||
color: COLOR.text,
|
|
||||||
cursor: 'pointer',
|
|
||||||
boxShadow: '0 2px 10px #0006',
|
|
||||||
},
|
|
||||||
panel: {
|
|
||||||
position: 'fixed',
|
|
||||||
bottom: 12,
|
|
||||||
right: 12,
|
|
||||||
zIndex: 9999,
|
|
||||||
width: 460,
|
|
||||||
maxWidth: 'calc(100vw - 24px)',
|
|
||||||
maxHeight: 'calc(100vh - 24px)',
|
|
||||||
overflowY: 'auto',
|
|
||||||
fontFamily: SANS,
|
|
||||||
fontSize: 11,
|
|
||||||
lineHeight: 1.5,
|
|
||||||
color: COLOR.text,
|
|
||||||
background: COLOR.surface,
|
|
||||||
borderWidth: 1,
|
|
||||||
borderStyle: 'solid',
|
|
||||||
borderColor: COLOR.border,
|
|
||||||
borderRadius: 10,
|
|
||||||
padding: 14,
|
|
||||||
boxShadow: '0 10px 34px #00000080',
|
|
||||||
},
|
|
||||||
sectionTitle: {
|
|
||||||
margin: '14px 0 5px',
|
|
||||||
color: COLOR.faint,
|
|
||||||
textTransform: 'uppercase',
|
|
||||||
letterSpacing: 1.2,
|
|
||||||
fontSize: 9,
|
|
||||||
fontWeight: 600,
|
|
||||||
display: 'flex',
|
|
||||||
justifyContent: 'space-between',
|
|
||||||
},
|
|
||||||
metricsLine: {
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
gap: 16,
|
|
||||||
margin: '0 0 10px',
|
|
||||||
paddingBottom: 10,
|
|
||||||
borderBottom: `1px solid ${COLOR.hairline}`,
|
|
||||||
fontVariantNumeric: 'tabular-nums',
|
|
||||||
},
|
|
||||||
legend: {
|
|
||||||
display: 'flex',
|
|
||||||
gap: 12,
|
|
||||||
marginTop: 3,
|
|
||||||
color: COLOR.faint,
|
|
||||||
fontSize: 10,
|
|
||||||
},
|
|
||||||
list: {
|
|
||||||
display: 'flex',
|
|
||||||
flexDirection: 'column',
|
|
||||||
maxHeight: '400px',
|
|
||||||
overflowY: 'auto',
|
|
||||||
},
|
|
||||||
configList: { display: 'flex', flexWrap: 'wrap' },
|
|
||||||
configRow: {
|
|
||||||
display: 'flex',
|
|
||||||
gap: 6,
|
|
||||||
flex: '0 0 50%',
|
|
||||||
minWidth: 0,
|
|
||||||
boxSizing: 'border-box',
|
|
||||||
padding: '1px 8px 1px 0',
|
|
||||||
},
|
|
||||||
configLabel: {
|
|
||||||
color: COLOR.muted,
|
|
||||||
flex: '0 0 45%',
|
|
||||||
whiteSpace: 'nowrap',
|
|
||||||
overflow: 'hidden',
|
|
||||||
textOverflow: 'ellipsis',
|
|
||||||
},
|
|
||||||
configValue: {
|
|
||||||
flex: 1,
|
|
||||||
minWidth: 0,
|
|
||||||
whiteSpace: 'nowrap',
|
|
||||||
overflow: 'hidden',
|
|
||||||
textOverflow: 'ellipsis',
|
|
||||||
},
|
|
||||||
trackRow: {
|
|
||||||
display: 'flex',
|
|
||||||
gap: 8,
|
|
||||||
padding: '3px 0',
|
|
||||||
borderBottom: `1px solid ${COLOR.hairline}`,
|
|
||||||
alignItems: 'baseline',
|
|
||||||
},
|
|
||||||
trackLabel: {
|
|
||||||
flex: '0 0 34%',
|
|
||||||
whiteSpace: 'nowrap',
|
|
||||||
overflow: 'hidden',
|
|
||||||
textOverflow: 'ellipsis',
|
|
||||||
},
|
|
||||||
trackCodec: {
|
|
||||||
flex: '0 0 11%',
|
|
||||||
fontFamily: MONO,
|
|
||||||
fontSize: 10,
|
|
||||||
color: COLOR.muted,
|
|
||||||
},
|
|
||||||
trackKbps: {
|
|
||||||
flex: '0 0 9%',
|
|
||||||
textAlign: 'right',
|
|
||||||
fontVariantNumeric: 'tabular-nums',
|
|
||||||
},
|
|
||||||
trackDetail: {
|
|
||||||
flex: 1,
|
|
||||||
minWidth: 0,
|
|
||||||
whiteSpace: 'nowrap',
|
|
||||||
overflow: 'hidden',
|
|
||||||
textOverflow: 'ellipsis',
|
|
||||||
},
|
|
||||||
mono: { fontFamily: MONO, fontSize: 10, color: COLOR.muted },
|
|
||||||
button: {
|
|
||||||
fontFamily: SANS,
|
|
||||||
fontSize: 11,
|
|
||||||
padding: '3px 9px',
|
|
||||||
borderRadius: 5,
|
|
||||||
borderWidth: 1,
|
|
||||||
borderStyle: 'solid',
|
|
||||||
borderColor: COLOR.border,
|
|
||||||
background: '#232428',
|
|
||||||
color: COLOR.text,
|
|
||||||
cursor: 'pointer',
|
|
||||||
},
|
|
||||||
buttonActive: { borderColor: COLOR.down, color: COLOR.down },
|
|
||||||
buttonDisabled: { color: COLOR.faint, cursor: 'not-allowed' },
|
|
||||||
row: { display: 'flex', alignItems: 'center', gap: 6, flexWrap: 'wrap' },
|
|
||||||
stepperValue: {
|
|
||||||
minWidth: 92,
|
|
||||||
textAlign: 'center',
|
|
||||||
borderWidth: 1,
|
|
||||||
borderStyle: 'solid',
|
|
||||||
borderColor: COLOR.hairline,
|
|
||||||
borderRadius: 4,
|
|
||||||
padding: '2px 6px',
|
|
||||||
background: COLOR.chartBg,
|
|
||||||
fontVariantNumeric: 'tabular-nums',
|
|
||||||
},
|
|
||||||
close: {
|
|
||||||
background: 'none',
|
|
||||||
border: 'none',
|
|
||||||
color: COLOR.faint,
|
|
||||||
cursor: 'pointer',
|
|
||||||
fontSize: 14,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
const Sparkline = ({ history }: { history: StatsSnapshot[] }) => {
|
|
||||||
const width = 430
|
|
||||||
const height = 54
|
|
||||||
const max = Math.max(...history.map((s) => Math.max(s.upKbps, s.downKbps)), 1)
|
|
||||||
const points = (pick: (s: StatsSnapshot) => number) =>
|
|
||||||
history
|
|
||||||
.map(
|
|
||||||
(s, i) =>
|
|
||||||
`${((i / Math.max(history.length - 1, 1)) * width).toFixed(1)},${(
|
|
||||||
height -
|
|
||||||
(pick(s) / max) * (height - 6) -
|
|
||||||
3
|
|
||||||
).toFixed(1)}`
|
|
||||||
)
|
|
||||||
.join(' ')
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<svg
|
|
||||||
width={width}
|
|
||||||
height={height}
|
|
||||||
role="img"
|
|
||||||
aria-label="Bandwidth over the last minute"
|
|
||||||
style={{
|
|
||||||
display: 'block',
|
|
||||||
background: COLOR.chartBg,
|
|
||||||
borderRadius: 6,
|
|
||||||
border: `1px solid ${COLOR.hairline}`,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{history.length >= 2 && (
|
|
||||||
<>
|
|
||||||
<polyline
|
|
||||||
points={points((s) => s.downKbps)}
|
|
||||||
fill="none"
|
|
||||||
stroke={COLOR.down}
|
|
||||||
strokeWidth="1.5"
|
|
||||||
/>
|
|
||||||
<polyline
|
|
||||||
points={points((s) => s.upKbps)}
|
|
||||||
fill="none"
|
|
||||||
stroke={COLOR.up}
|
|
||||||
strokeWidth="1.5"
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</svg>
|
|
||||||
{/* Legend outside the plot: text over the lines was unreadable. */}
|
|
||||||
<div style={styles.legend}>
|
|
||||||
<span>
|
|
||||||
<span style={{ color: COLOR.down }}>●</span> down
|
|
||||||
</span>
|
|
||||||
<span>
|
|
||||||
<span style={{ color: COLOR.up }}>●</span> up
|
|
||||||
</span>
|
|
||||||
<span>last 60s · max {Math.round(max)} kbps</span>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const CapStepper = ({
|
|
||||||
label,
|
|
||||||
hint,
|
|
||||||
valueKbps,
|
|
||||||
onChange,
|
|
||||||
}: {
|
|
||||||
label: string
|
|
||||||
hint: string
|
|
||||||
valueKbps: number | null
|
|
||||||
onChange: (kbps: number | null) => void
|
|
||||||
}) => (
|
|
||||||
<div style={styles.row}>
|
|
||||||
<span style={{ flex: '0 0 70px' }}>{label}</span>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
style={styles.button}
|
|
||||||
aria-label={`Decrease ${label} bandwidth`}
|
|
||||||
onClick={() => onChange(stepBitrate(valueKbps, 'decrease'))}
|
|
||||||
>
|
|
||||||
−
|
|
||||||
</button>
|
|
||||||
<span
|
|
||||||
style={{
|
|
||||||
...styles.stepperValue,
|
|
||||||
...(valueKbps !== null ? { color: COLOR.down } : {}),
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{valueKbps === null ? 'unlimited' : `≤ ${valueKbps} kbps`}
|
|
||||||
</span>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
style={styles.button}
|
|
||||||
aria-label={`Increase ${label} bandwidth`}
|
|
||||||
onClick={() => onChange(stepBitrate(valueKbps, 'increase'))}
|
|
||||||
>
|
|
||||||
+
|
|
||||||
</button>
|
|
||||||
<span style={styles.mono}>{hint}</span>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
|
|
||||||
const TrackList = ({ title, rows }: { title: string; rows: TrackRow[] }) => (
|
|
||||||
<>
|
|
||||||
<div style={styles.sectionTitle}>
|
|
||||||
<span>
|
|
||||||
{title} ({rows.length})
|
|
||||||
</span>
|
|
||||||
<span>
|
|
||||||
{Math.round(rows.reduce((sum, t) => sum + t.kbps, 0))} kbps media
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div style={styles.list}>
|
|
||||||
{rows.map((track) => (
|
|
||||||
<div key={track.key} style={styles.trackRow}>
|
|
||||||
<span style={styles.trackLabel}>{track.label}</span>
|
|
||||||
<span style={styles.trackCodec}>{track.codec ?? '–'}</span>
|
|
||||||
<span style={styles.trackKbps}>{Math.round(track.kbps)}</span>
|
|
||||||
<span style={styles.trackDetail}>
|
|
||||||
{[
|
|
||||||
track.res,
|
|
||||||
track.fps !== undefined && `${Math.round(track.fps)}fps`,
|
|
||||||
]
|
|
||||||
.filter(Boolean)
|
|
||||||
.join(' · ')}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
{rows.length === 0 && <span style={{ color: COLOR.faint }}>none</span>}
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
|
|
||||||
const MeetDevtools = () => {
|
|
||||||
const room = useRoomContext()
|
|
||||||
const connState = useConnectionState(room)
|
|
||||||
const [open, setOpen] = useState(false)
|
|
||||||
const [firedScenario, setFiredScenario] = useState<string>()
|
|
||||||
const [uplinkCap, setUplinkCapState] = useState<number | null>(null)
|
|
||||||
const [downlinkCap, setDownlinkCapState] = useState<number | null>(null)
|
|
||||||
const { snapshot, history } = useWebRTCStats(room, open)
|
|
||||||
|
|
||||||
const transportMode: TransportMode = transportModeFromRoute(snapshot?.route)
|
|
||||||
|
|
||||||
const fireScenario = (scenario: Scenario) => {
|
|
||||||
setFiredScenario(scenario.id)
|
|
||||||
scenario.run(room).catch((e) => {
|
|
||||||
console.warn('[MeetDevtools] simulateScenario failed', e)
|
|
||||||
})
|
|
||||||
window.setTimeout(
|
|
||||||
() =>
|
|
||||||
setFiredScenario((current) =>
|
|
||||||
current === scenario.id ? undefined : current
|
|
||||||
),
|
|
||||||
1200
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!open) {
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
style={styles.toggle}
|
|
||||||
onClick={() => setOpen(true)}
|
|
||||||
aria-label="Open WebRTC devtools"
|
|
||||||
>
|
|
||||||
<span style={{ color: stateColor(connState) }}>●</span> rtc
|
|
||||||
</button>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const config = readRoomConfig(room)
|
|
||||||
const published = snapshot?.tracks.filter((t) => t.dir === 'up') ?? []
|
|
||||||
const subscribed = snapshot?.tracks.filter((t) => t.dir === 'down') ?? []
|
|
||||||
const turnProtocols = snapshot?.turnProtocols ?? []
|
|
||||||
const transports: Array<{
|
|
||||||
mode: TransportMode
|
|
||||||
label: string
|
|
||||||
requires?: string
|
|
||||||
title: string
|
|
||||||
}> = [
|
|
||||||
{
|
|
||||||
mode: 'auto',
|
|
||||||
label: 'auto (udp)',
|
|
||||||
title:
|
|
||||||
'clears the server-cached transport preference and relay-only policy, then full reconnect',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
mode: 'tcp',
|
|
||||||
label: 'tcp',
|
|
||||||
title: 'force-tcp — server prefers TCP candidates (ICE/TCP)',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
mode: 'turn-udp',
|
|
||||||
label: 'turn:udp',
|
|
||||||
requires: 'udp',
|
|
||||||
title:
|
|
||||||
'client-side: iceTransportPolicy relay + full reconnect (no server hook exists)',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
mode: 'turn-tcp',
|
|
||||||
label: 'turn:tcp',
|
|
||||||
requires: 'tcp',
|
|
||||||
title: 'force-tcp — lands on TURN/TCP when it is the TCP path',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
mode: 'turn-tls',
|
|
||||||
label: 'turn:tls',
|
|
||||||
requires: 'tls',
|
|
||||||
title: 'force-tls — server switches you to TURN over TLS',
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
return (
|
|
||||||
<section style={styles.panel} aria-label="WebRTC devtools">
|
|
||||||
<div style={styles.metricsLine}>
|
|
||||||
<span>
|
|
||||||
<span style={{ color: COLOR.down }}>↓</span> {snapshot?.downKbps ?? 0}{' '}
|
|
||||||
kbps
|
|
||||||
</span>
|
|
||||||
<span>
|
|
||||||
<span style={{ color: COLOR.up }}>↑</span> {snapshot?.upKbps ?? 0}{' '}
|
|
||||||
kbps
|
|
||||||
</span>
|
|
||||||
<span style={{ color: COLOR.muted }}>
|
|
||||||
rtt{' '}
|
|
||||||
<span style={{ color: COLOR.text }}>{snapshot?.rttMs ?? '–'}</span> ms
|
|
||||||
</span>
|
|
||||||
<span style={{ color: COLOR.muted }}>
|
|
||||||
jitter{' '}
|
|
||||||
<span style={{ color: COLOR.text }}>{snapshot?.jitterMs ?? '–'}</span>{' '}
|
|
||||||
ms
|
|
||||||
</span>
|
|
||||||
<span
|
|
||||||
style={{
|
|
||||||
color: stateColor(connState),
|
|
||||||
fontSize: 10,
|
|
||||||
marginLeft: 'auto',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
● {connState}
|
|
||||||
</span>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
style={styles.close}
|
|
||||||
onClick={() => setOpen(false)}
|
|
||||||
aria-label="Close WebRTC devtools"
|
|
||||||
>
|
|
||||||
✕
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Sparkline history={history} />
|
|
||||||
|
|
||||||
<div style={styles.sectionTitle}>room configuration (live)</div>
|
|
||||||
<div style={styles.configList}>
|
|
||||||
{config.map((entry) => (
|
|
||||||
<div key={entry.label} style={styles.configRow}>
|
|
||||||
<span
|
|
||||||
style={{
|
|
||||||
color:
|
|
||||||
entry.on === undefined
|
|
||||||
? COLOR.faint
|
|
||||||
: entry.on
|
|
||||||
? COLOR.ok
|
|
||||||
: COLOR.bad,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
●
|
|
||||||
</span>
|
|
||||||
<span style={styles.configLabel}>{entry.label}</span>
|
|
||||||
<span style={styles.configValue} title={entry.value}>
|
|
||||||
{entry.value}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<TrackList title="published ↑" rows={published} />
|
|
||||||
<TrackList title="subscribed ↓" rows={subscribed} />
|
|
||||||
|
|
||||||
<div style={styles.sectionTitle}>bandwidth</div>
|
|
||||||
<div style={{ ...styles.list, gap: 4 }}>
|
|
||||||
<CapStepper
|
|
||||||
label="uplink"
|
|
||||||
hint="encoder cap (setParameters)"
|
|
||||||
valueKbps={uplinkCap}
|
|
||||||
onChange={(kbps) => {
|
|
||||||
setUplinkCapState(kbps)
|
|
||||||
void setUplinkCap(room, kbps)
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<CapStepper
|
|
||||||
label="downlink"
|
|
||||||
hint="SFU limit (subscriber-bandwidth)"
|
|
||||||
valueKbps={downlinkCap}
|
|
||||||
onChange={(kbps) => {
|
|
||||||
setDownlinkCapState(kbps)
|
|
||||||
void setDownlinkCap(room, kbps).catch((e) =>
|
|
||||||
console.warn('[MeetDevtools] downlink cap failed', e)
|
|
||||||
)
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div style={styles.sectionTitle}>transport</div>
|
|
||||||
<div style={styles.row}>
|
|
||||||
{transports.map(({ mode, label, requires, title }) => {
|
|
||||||
const available = !requires || turnProtocols.includes(requires)
|
|
||||||
const active = transportMode === mode
|
|
||||||
const clickable = available && !active
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
key={mode}
|
|
||||||
type="button"
|
|
||||||
aria-pressed={active}
|
|
||||||
disabled={!clickable}
|
|
||||||
title={available ? title : `${title} — not configured`}
|
|
||||||
style={{
|
|
||||||
...styles.button,
|
|
||||||
...(clickable || active ? {} : styles.buttonDisabled),
|
|
||||||
...(active ? styles.buttonActive : {}),
|
|
||||||
}}
|
|
||||||
onClick={() => {
|
|
||||||
if (!clickable) return
|
|
||||||
void (
|
|
||||||
mode === 'auto'
|
|
||||||
? releaseForcedTransport(room)
|
|
||||||
: forceTransport(
|
|
||||||
room,
|
|
||||||
mode as 'tcp' | 'turn-udp' | 'turn-tcp' | 'turn-tls'
|
|
||||||
)
|
|
||||||
).catch((e) =>
|
|
||||||
console.warn('[MeetDevtools] transport change failed', e)
|
|
||||||
)
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{requires && (
|
|
||||||
<span style={{ color: available ? COLOR.ok : COLOR.faint }}>
|
|
||||||
●{' '}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
{label}
|
|
||||||
</button>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
<span style={styles.mono}>
|
|
||||||
route:{' '}
|
|
||||||
{snapshot?.route
|
|
||||||
? [
|
|
||||||
snapshot.route.protocol,
|
|
||||||
snapshot.route.type,
|
|
||||||
snapshot.route.relayProtocol &&
|
|
||||||
`relay:${snapshot.route.relayProtocol}`,
|
|
||||||
]
|
|
||||||
.filter(Boolean)
|
|
||||||
.join('·')
|
|
||||||
: '–'}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div style={styles.sectionTitle}>connection scenarios</div>
|
|
||||||
<div style={styles.row}>
|
|
||||||
{SCENARIOS.map((scenario) => (
|
|
||||||
<button
|
|
||||||
key={scenario.id}
|
|
||||||
type="button"
|
|
||||||
title={`${scenario.side}-side simulation — momentary, watch the state dot`}
|
|
||||||
style={{
|
|
||||||
...styles.button,
|
|
||||||
...(firedScenario === scenario.id ? styles.buttonActive : {}),
|
|
||||||
}}
|
|
||||||
onClick={() => fireScenario(scenario)}
|
|
||||||
>
|
|
||||||
{scenario.side === 'server' ? '☁ ' : ''}
|
|
||||||
{scenario.label}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export default MeetDevtools
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
import { lazy, Suspense } from 'react'
|
|
||||||
|
|
||||||
const LazyPanel = import.meta.env.DEV
|
|
||||||
? lazy(() => import('./MeetDevtools'))
|
|
||||||
: null
|
|
||||||
|
|
||||||
export const MeetDevtools = () => {
|
|
||||||
if (!LazyPanel) return null
|
|
||||||
return (
|
|
||||||
<Suspense fallback={null}>
|
|
||||||
<LazyPanel />
|
|
||||||
</Suspense>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,88 +0,0 @@
|
|||||||
import { Room } from 'livekit-client'
|
|
||||||
|
|
||||||
export type ConfigEntry = {
|
|
||||||
label: string
|
|
||||||
value: string
|
|
||||||
/** true = feature actively on, false = off, undefined = informational */
|
|
||||||
on?: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
const formatBackupCodec = (backup: unknown): string => {
|
|
||||||
if (backup === undefined || backup === true) return 'auto'
|
|
||||||
if (backup === false) return 'off'
|
|
||||||
if (typeof backup === 'object' && backup !== null && 'codec' in backup) {
|
|
||||||
return String((backup as { codec: unknown }).codec)
|
|
||||||
}
|
|
||||||
return String(backup)
|
|
||||||
}
|
|
||||||
|
|
||||||
export const readRoomConfig = (room: Room): ConfigEntry[] => {
|
|
||||||
const options = room.options
|
|
||||||
const publish = options.publishDefaults
|
|
||||||
const adaptive = options.adaptiveStream
|
|
||||||
|
|
||||||
const entries: ConfigEntry[] = [
|
|
||||||
{
|
|
||||||
label: 'adaptiveStream',
|
|
||||||
value:
|
|
||||||
typeof adaptive === 'object'
|
|
||||||
? JSON.stringify(adaptive)
|
|
||||||
: String(!!adaptive),
|
|
||||||
on: !!adaptive,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'dynacast',
|
|
||||||
value: String(!!options.dynacast),
|
|
||||||
on: !!options.dynacast,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'e2ee',
|
|
||||||
value: String(room.isE2EEEnabled),
|
|
||||||
on: room.isE2EEEnabled,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'videoCodec',
|
|
||||||
value: publish?.videoCodec ?? 'default',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'backupCodec',
|
|
||||||
value: formatBackupCodec(publish?.backupCodec),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'simulcast',
|
|
||||||
value: String(publish?.simulcast ?? true),
|
|
||||||
on: publish?.simulcast ?? true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'audio dtx',
|
|
||||||
value: String(publish?.dtx ?? true),
|
|
||||||
on: publish?.dtx ?? true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'audio red',
|
|
||||||
value: String(publish?.red ?? true),
|
|
||||||
on: publish?.red ?? true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'quality (local)',
|
|
||||||
value: room.localParticipant.connectionQuality,
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
const server = room.serverInfo
|
|
||||||
if (server) {
|
|
||||||
entries.push({
|
|
||||||
label: 'server',
|
|
||||||
value: [
|
|
||||||
server.version && `v${server.version}`,
|
|
||||||
server.region,
|
|
||||||
server.protocol !== undefined && `proto ${server.protocol}`,
|
|
||||||
server.edition !== undefined && `edition ${server.edition}`,
|
|
||||||
]
|
|
||||||
.filter(Boolean)
|
|
||||||
.join(' · '),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
return entries
|
|
||||||
}
|
|
||||||
@@ -1,192 +0,0 @@
|
|||||||
import { Room, Track } from 'livekit-client'
|
|
||||||
// Transitive dependency of livekit-client (pinned by it); used only to
|
|
||||||
// build the one signal request room.simulateScenario cannot express.
|
|
||||||
import { SimulateScenario } from '@livekit/protocol'
|
|
||||||
|
|
||||||
/** Shared bandwidth ladder for the −/+ steppers; null = unlimited. */
|
|
||||||
export const BITRATE_LADDER_KBPS: Array<number | null> = [
|
|
||||||
null,
|
|
||||||
2000,
|
|
||||||
1000,
|
|
||||||
600,
|
|
||||||
300,
|
|
||||||
150,
|
|
||||||
]
|
|
||||||
|
|
||||||
export const stepBitrate = (
|
|
||||||
current: number | null,
|
|
||||||
direction: 'decrease' | 'increase'
|
|
||||||
): number | null => {
|
|
||||||
const index = BITRATE_LADDER_KBPS.indexOf(current)
|
|
||||||
const safeIndex = index === -1 ? 0 : index
|
|
||||||
const next =
|
|
||||||
direction === 'decrease'
|
|
||||||
? Math.min(safeIndex + 1, BITRATE_LADDER_KBPS.length - 1)
|
|
||||||
: Math.max(safeIndex - 1, 0)
|
|
||||||
return BITRATE_LADDER_KBPS[next]
|
|
||||||
}
|
|
||||||
|
|
||||||
const savedEncodings = new WeakMap<RTCRtpSender, Array<number | undefined>>()
|
|
||||||
|
|
||||||
export const setUplinkCap = async (
|
|
||||||
room: Room,
|
|
||||||
kbps: number | null
|
|
||||||
): Promise<void> => {
|
|
||||||
const senders: RTCRtpSender[] = []
|
|
||||||
room.localParticipant.trackPublications.forEach((pub) => {
|
|
||||||
const track = pub.track
|
|
||||||
if (track?.kind === Track.Kind.Video && track.sender) {
|
|
||||||
senders.push(track.sender)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
for (const sender of senders) {
|
|
||||||
const params = sender.getParameters()
|
|
||||||
if (!params.encodings || params.encodings.length === 0) continue
|
|
||||||
|
|
||||||
if (kbps === null) {
|
|
||||||
const original = savedEncodings.get(sender)
|
|
||||||
params.encodings.forEach((encoding, i) => {
|
|
||||||
encoding.maxBitrate = original?.[i]
|
|
||||||
})
|
|
||||||
savedEncodings.delete(sender)
|
|
||||||
} else {
|
|
||||||
if (!savedEncodings.has(sender)) {
|
|
||||||
savedEncodings.set(
|
|
||||||
sender,
|
|
||||||
params.encodings.map((encoding) => encoding.maxBitrate)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
const activeCount =
|
|
||||||
params.encodings.filter((encoding) => encoding.active !== false)
|
|
||||||
.length || 1
|
|
||||||
// Split the budget across active simulcast layers / SVC encoding.
|
|
||||||
const perEncoding = Math.max(
|
|
||||||
30_000,
|
|
||||||
Math.floor((kbps * 1000) / activeCount)
|
|
||||||
)
|
|
||||||
params.encodings.forEach((encoding) => {
|
|
||||||
encoding.maxBitrate = perEncoding
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
await sender.setParameters(params)
|
|
||||||
} catch (e) {
|
|
||||||
console.warn('[MeetDevtools] setParameters failed', e)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export const setDownlinkCap = (
|
|
||||||
room: Room,
|
|
||||||
kbps: number | null
|
|
||||||
): Promise<void> =>
|
|
||||||
room.simulateScenario('subscriber-bandwidth', kbps === null ? 0 : kbps * 1000)
|
|
||||||
|
|
||||||
export type Scenario = {
|
|
||||||
id: string
|
|
||||||
label: string
|
|
||||||
/** where the simulation happens */
|
|
||||||
side: 'client' | 'server'
|
|
||||||
run: (room: Room) => Promise<void>
|
|
||||||
}
|
|
||||||
|
|
||||||
export const SCENARIOS: Scenario[] = [
|
|
||||||
{
|
|
||||||
id: 'resume',
|
|
||||||
label: 'reconnect (resume)',
|
|
||||||
side: 'client',
|
|
||||||
// Replays a signaling WebSocket loss; media keeps flowing, client resumes.
|
|
||||||
run: (room) => room.simulateScenario('signal-reconnect'),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'resume-fail',
|
|
||||||
label: 'reconnect (resume fails)',
|
|
||||||
side: 'client',
|
|
||||||
// Same, but the next resume attempt fails → exercises the retry ladder.
|
|
||||||
run: (room) => room.simulateScenario('resume-reconnect'),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'full-reconnect',
|
|
||||||
label: 'full reconnect',
|
|
||||||
side: 'client',
|
|
||||||
// Complete rejoin with brand-new peer connections.
|
|
||||||
run: (room) => room.simulateScenario('full-reconnect'),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'migration',
|
|
||||||
label: 'server migration',
|
|
||||||
side: 'server',
|
|
||||||
run: (room) => room.simulateScenario('migration'),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'node-failure',
|
|
||||||
label: 'SFU node failure',
|
|
||||||
side: 'server',
|
|
||||||
run: (room) => room.simulateScenario('node-failure'),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'server-leave',
|
|
||||||
label: 'server disconnect',
|
|
||||||
side: 'server',
|
|
||||||
// Server-initiated leave: the closest thing to "you got kicked".
|
|
||||||
run: (room) => room.simulateScenario('server-leave'),
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
export type TransportMode =
|
|
||||||
| 'auto'
|
|
||||||
| 'tcp'
|
|
||||||
| 'turn-udp'
|
|
||||||
| 'turn-tcp'
|
|
||||||
| 'turn-tls'
|
|
||||||
|
|
||||||
const clearServerTransportPreference = (room: Room): Promise<void> =>
|
|
||||||
room.engine.client.sendSimulateScenario(
|
|
||||||
new SimulateScenario({
|
|
||||||
scenario: { case: 'switchCandidateProtocol', value: 0 },
|
|
||||||
})
|
|
||||||
)
|
|
||||||
|
|
||||||
const setRelayOnly = (room: Room, relay: boolean) => {
|
|
||||||
room.engine.rtcConfig = {
|
|
||||||
...room.engine.rtcConfig,
|
|
||||||
iceTransportPolicy: relay ? 'relay' : 'all',
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const settle = () => new Promise((resolve) => setTimeout(resolve, 300))
|
|
||||||
|
|
||||||
export const forceTransport = async (
|
|
||||||
room: Room,
|
|
||||||
mode: 'tcp' | 'turn-udp' | 'turn-tcp' | 'turn-tls'
|
|
||||||
): Promise<void> => {
|
|
||||||
if (mode === 'turn-udp') {
|
|
||||||
await clearServerTransportPreference(room)
|
|
||||||
setRelayOnly(room, true)
|
|
||||||
await settle()
|
|
||||||
return room.simulateScenario('full-reconnect')
|
|
||||||
}
|
|
||||||
setRelayOnly(room, false)
|
|
||||||
return room.simulateScenario(mode === 'turn-tls' ? 'force-tls' : 'force-tcp')
|
|
||||||
}
|
|
||||||
|
|
||||||
export const releaseForcedTransport = async (room: Room): Promise<void> => {
|
|
||||||
setRelayOnly(room, false)
|
|
||||||
await clearServerTransportPreference(room)
|
|
||||||
await settle()
|
|
||||||
await room.simulateScenario('full-reconnect')
|
|
||||||
}
|
|
||||||
|
|
||||||
export const transportModeFromRoute = (route?: {
|
|
||||||
protocol?: string
|
|
||||||
relayProtocol?: string
|
|
||||||
}): TransportMode => {
|
|
||||||
// relayProtocol is the client→TURN leg; protocol alone means no relay.
|
|
||||||
if (route?.relayProtocol === 'tls') return 'turn-tls'
|
|
||||||
if (route?.relayProtocol === 'tcp') return 'turn-tcp'
|
|
||||||
if (route?.relayProtocol === 'udp') return 'turn-udp'
|
|
||||||
if (route?.protocol === 'tcp') return 'tcp'
|
|
||||||
return 'auto'
|
|
||||||
}
|
|
||||||
@@ -1,243 +0,0 @@
|
|||||||
import { useEffect, useRef, useState } from 'react'
|
|
||||||
import { Room } from 'livekit-client'
|
|
||||||
|
|
||||||
export type TrackRow = {
|
|
||||||
key: string
|
|
||||||
dir: 'up' | 'down'
|
|
||||||
label: string
|
|
||||||
codec?: string
|
|
||||||
kbps: number
|
|
||||||
fps?: number
|
|
||||||
res?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export type StatsSnapshot = {
|
|
||||||
ts: number
|
|
||||||
/** wire totals from transport stats (includes headers, RTCP, FEC) */
|
|
||||||
upKbps: number
|
|
||||||
downKbps: number
|
|
||||||
rttMs?: number
|
|
||||||
/** worst inbound RTP jitter across subscribed tracks */
|
|
||||||
jitterMs?: number
|
|
||||||
availableOutKbps?: number
|
|
||||||
/** selected ICE route of the publisher transport (measured, not assumed) */
|
|
||||||
route?: { protocol?: string; type?: string; relayProtocol?: string }
|
|
||||||
/**
|
|
||||||
* relayProtocol values of gathered relay local candidates — i.e. which
|
|
||||||
* 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 }
|
|
||||||
}
|
|
||||||
@@ -20,7 +20,6 @@ import { Track } from 'livekit-client'
|
|||||||
import { ParticipantPlaceholder } from './ParticipantPlaceholder'
|
import { ParticipantPlaceholder } from './ParticipantPlaceholder'
|
||||||
import { ParticipantTileFocus } from './participantTileFocus/ParticipantTileFocus'
|
import { ParticipantTileFocus } from './participantTileFocus/ParticipantTileFocus'
|
||||||
import { FullScreenShareWarning } from './FullScreenShareWarning'
|
import { FullScreenShareWarning } from './FullScreenShareWarning'
|
||||||
import { ScreenShareZoomableVideo } from '@/features/rooms/livekit/components/ScreenShareZoomableVideo'
|
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { getShortcutDescriptorById } from '@/features/shortcuts/catalog'
|
import { getShortcutDescriptorById } from '@/features/shortcuts/catalog'
|
||||||
import { formatShortcutLabel } from '@/features/shortcuts/formatLabels'
|
import { formatShortcutLabel } from '@/features/shortcuts/formatLabels'
|
||||||
@@ -49,8 +48,6 @@ interface ParticipantTileExtendedProps extends ParticipantTileProps {
|
|||||||
disableTileControls?: boolean
|
disableTileControls?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
const MOUSE_IDLE_TIME = 3000
|
|
||||||
|
|
||||||
export const ParticipantTile: (
|
export const ParticipantTile: (
|
||||||
props: ParticipantTileExtendedProps & React.RefAttributes<HTMLDivElement>
|
props: ParticipantTileExtendedProps & React.RefAttributes<HTMLDivElement>
|
||||||
) => React.ReactNode = /* @__PURE__ */ React.forwardRef<
|
) => React.ReactNode = /* @__PURE__ */ React.forwardRef<
|
||||||
@@ -92,8 +89,6 @@ export const ParticipantTile: (
|
|||||||
)
|
)
|
||||||
|
|
||||||
const isScreenShare = trackReference.source != Track.Source.Camera
|
const isScreenShare = trackReference.source != Track.Source.Camera
|
||||||
const isRemoteScreenShare =
|
|
||||||
isScreenShare && !trackReference.participant.isLocal
|
|
||||||
const [hasKeyboardFocus, setHasKeyboardFocus] = React.useState(false)
|
const [hasKeyboardFocus, setHasKeyboardFocus] = React.useState(false)
|
||||||
|
|
||||||
const participantColor = getParticipantColor(trackReference.participant)
|
const participantColor = getParticipantColor(trackReference.participant)
|
||||||
@@ -103,38 +98,11 @@ export const ParticipantTile: (
|
|||||||
})
|
})
|
||||||
const participantName = name || identity || 'Unknown'
|
const participantName = name || identity || 'Unknown'
|
||||||
|
|
||||||
// Hover + idle tracking for the focus overlay (pin, effects, mute buttons).
|
|
||||||
const [isTileHovered, setIsTileHovered] = React.useState(false)
|
|
||||||
const [isIdle, setIsIdle] = React.useState(false)
|
|
||||||
const idleTimerRef = React.useRef<number | null>(null)
|
|
||||||
|
|
||||||
const handleTileMouseMove = React.useCallback(() => {
|
|
||||||
if (idleTimerRef.current) window.clearTimeout(idleTimerRef.current)
|
|
||||||
idleTimerRef.current = window.setTimeout(
|
|
||||||
() => setIsIdle(true),
|
|
||||||
MOUSE_IDLE_TIME
|
|
||||||
)
|
|
||||||
setIsIdle(false)
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
const isOverlayVisible = hasKeyboardFocus || (isTileHovered && !isIdle)
|
|
||||||
|
|
||||||
// tileRef: fullscreen target. setRefs merges it with the forwarded ref on the same node.
|
|
||||||
const tileRef = React.useRef<HTMLDivElement>(null)
|
|
||||||
const setRefs = React.useCallback(
|
|
||||||
(node: HTMLDivElement | null) => {
|
|
||||||
;(tileRef as React.MutableRefObject<HTMLDivElement | null>).current = node
|
|
||||||
if (typeof ref === 'function') ref(node)
|
|
||||||
else if (ref)
|
|
||||||
(ref as React.MutableRefObject<HTMLDivElement | null>).current = node
|
|
||||||
},
|
|
||||||
[ref]
|
|
||||||
)
|
|
||||||
|
|
||||||
const { t } = useTranslation('rooms', { keyPrefix: 'participantTileFocus' })
|
const { t } = useTranslation('rooms', { keyPrefix: 'participantTileFocus' })
|
||||||
|
|
||||||
const interactiveProps = {
|
const interactiveProps = {
|
||||||
...elementProps,
|
...elementProps,
|
||||||
|
// Ensure the tile is focusable to expose contextual controls to keyboard users.
|
||||||
tabIndex: 0,
|
tabIndex: 0,
|
||||||
'aria-label': t('containerLabel', { name: participantName }),
|
'aria-label': t('containerLabel', { name: participantName }),
|
||||||
onFocus: (event: React.FocusEvent<HTMLDivElement>) => {
|
onFocus: (event: React.FocusEvent<HTMLDivElement>) => {
|
||||||
@@ -152,57 +120,8 @@ export const ParticipantTile: (
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
const isVideoTrack =
|
|
||||||
isTrackReference(trackReference) &&
|
|
||||||
(trackReference.publication?.kind === 'video' ||
|
|
||||||
trackReference.source === Track.Source.Camera ||
|
|
||||||
trackReference.source === Track.Source.ScreenShare)
|
|
||||||
|
|
||||||
let trackMedia: React.ReactNode = null
|
|
||||||
if (isVideoTrack) {
|
|
||||||
if (isRemoteScreenShare) {
|
|
||||||
trackMedia = (
|
|
||||||
<ScreenShareZoomableVideo
|
|
||||||
trackRef={trackReference}
|
|
||||||
tileRef={tileRef}
|
|
||||||
onSubscriptionStatusChanged={handleSubscribe}
|
|
||||||
manageSubscription={autoManageSubscription}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
trackMedia = (
|
|
||||||
<VideoTrack
|
|
||||||
trackRef={trackReference}
|
|
||||||
onSubscriptionStatusChanged={handleSubscribe}
|
|
||||||
manageSubscription={autoManageSubscription}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
} else if (isTrackReference(trackReference)) {
|
|
||||||
trackMedia = (
|
|
||||||
<AudioTrack
|
|
||||||
trackRef={trackReference}
|
|
||||||
onSubscriptionStatusChanged={handleSubscribe}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div ref={ref} style={{ position: 'relative' }} {...interactiveProps}>
|
||||||
ref={setRefs}
|
|
||||||
style={{ position: 'relative' }}
|
|
||||||
{...interactiveProps}
|
|
||||||
onMouseEnter={() => setIsTileHovered(true)}
|
|
||||||
onMouseLeave={() => {
|
|
||||||
setIsTileHovered(false)
|
|
||||||
setIsIdle(false)
|
|
||||||
if (idleTimerRef.current) {
|
|
||||||
window.clearTimeout(idleTimerRef.current)
|
|
||||||
idleTimerRef.current = null
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
onMouseMove={handleTileMouseMove}
|
|
||||||
>
|
|
||||||
<TrackRefContextIfNeeded trackRef={trackReference}>
|
<TrackRefContextIfNeeded trackRef={trackReference}>
|
||||||
<ParticipantContextIfNeeded participant={trackReference.participant}>
|
<ParticipantContextIfNeeded participant={trackReference.participant}>
|
||||||
{trackReference.participant.isLocal && (
|
{trackReference.participant.isLocal && (
|
||||||
@@ -210,7 +129,23 @@ export const ParticipantTile: (
|
|||||||
)}
|
)}
|
||||||
{children ?? (
|
{children ?? (
|
||||||
<>
|
<>
|
||||||
{trackMedia}
|
{isTrackReference(trackReference) &&
|
||||||
|
(trackReference.publication?.kind === 'video' ||
|
||||||
|
trackReference.source === Track.Source.Camera ||
|
||||||
|
trackReference.source === Track.Source.ScreenShare) ? (
|
||||||
|
<VideoTrack
|
||||||
|
trackRef={trackReference}
|
||||||
|
onSubscriptionStatusChanged={handleSubscribe}
|
||||||
|
manageSubscription={autoManageSubscription}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
isTrackReference(trackReference) && (
|
||||||
|
<AudioTrack
|
||||||
|
trackRef={trackReference}
|
||||||
|
onSubscriptionStatusChanged={handleSubscribe}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
)}
|
||||||
<div className="lk-participant-placeholder">
|
<div className="lk-participant-placeholder">
|
||||||
<ParticipantPlaceholder
|
<ParticipantPlaceholder
|
||||||
color={participantColor}
|
color={participantColor}
|
||||||
@@ -229,7 +164,7 @@ export const ParticipantTile: (
|
|||||||
{!disableMetadata && !disableTileControls && (
|
{!disableMetadata && !disableTileControls && (
|
||||||
<ParticipantTileFocus
|
<ParticipantTileFocus
|
||||||
trackRef={trackReference}
|
trackRef={trackReference}
|
||||||
isVisible={isOverlayVisible}
|
hasKeyboardFocus={hasKeyboardFocus}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</ParticipantContextIfNeeded>
|
</ParticipantContextIfNeeded>
|
||||||
|
|||||||
+40
-12
@@ -1,22 +1,44 @@
|
|||||||
import { css } from '@/styled-system/css'
|
import { css } from '@/styled-system/css'
|
||||||
import { HStack } from '@/styled-system/jsx'
|
import { HStack } from '@/styled-system/jsx'
|
||||||
import { TrackReferenceOrPlaceholder } from '@livekit/components-core'
|
import { TrackReferenceOrPlaceholder } from '@livekit/components-core'
|
||||||
import { ReactNode } from 'react'
|
import { ReactNode, useEffect, useRef, useState } from 'react'
|
||||||
import { Track } from 'livekit-client'
|
import { Track } from 'livekit-client'
|
||||||
import { useCanMute } from '@/features/rooms/livekit/hooks/useCanMute'
|
import { useCanMute } from '@/features/rooms/livekit/hooks/useCanMute'
|
||||||
import { FocusButton } from './FocusButton'
|
import { FocusButton } from './FocusButton'
|
||||||
import { EffectsButton } from './EffectsButton'
|
import { EffectsButton } from './EffectsButton'
|
||||||
import { MuteButton } from './MuteButton'
|
import { MuteButton } from './MuteButton'
|
||||||
|
import { ZoomButton } from './ZoomButton'
|
||||||
|
|
||||||
|
const MOUSE_IDLE_TIME = 3000
|
||||||
|
|
||||||
type FadeOverlayProps = {
|
type FadeOverlayProps = {
|
||||||
children: ReactNode
|
children: ReactNode
|
||||||
isVisible: boolean
|
hasKeyboardFocus: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
// Pointer-events none so this overlay doesn't block the zoom surface below.
|
const FadeOverlay = ({ children, hasKeyboardFocus }: FadeOverlayProps) => {
|
||||||
// Hover and idle tracking therefore lives on the tile, which still receives
|
const [active, setActive] = useState(false)
|
||||||
// the pointer events, and comes back in as isVisible.
|
const idleTimerRef = useRef<number | null>(null)
|
||||||
const FadeOverlay = ({ children, isVisible }: FadeOverlayProps) => {
|
|
||||||
|
const clearIdleTimer = () => {
|
||||||
|
if (idleTimerRef.current) window.clearTimeout(idleTimerRef.current)
|
||||||
|
}
|
||||||
|
|
||||||
|
const armIdleTimer = () => {
|
||||||
|
clearIdleTimer()
|
||||||
|
idleTimerRef.current = window.setTimeout(() => {
|
||||||
|
setActive(false)
|
||||||
|
}, MOUSE_IDLE_TIME)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleActivity = () => {
|
||||||
|
setActive(true)
|
||||||
|
armIdleTimer()
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => clearIdleTimer, [])
|
||||||
|
|
||||||
|
const isVisible = hasKeyboardFocus || active
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={css({
|
className={css({
|
||||||
@@ -28,10 +50,15 @@ const FadeOverlay = ({ children, isVisible }: FadeOverlayProps) => {
|
|||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
width: '100%',
|
width: '100%',
|
||||||
height: '100%',
|
height: '100%',
|
||||||
pointerEvents: 'none',
|
|
||||||
})}
|
})}
|
||||||
data-visible={isVisible || undefined}
|
data-visible={isVisible || undefined}
|
||||||
aria-hidden={!isVisible}
|
aria-hidden={!isVisible}
|
||||||
|
onMouseEnter={handleActivity}
|
||||||
|
onMouseMove={handleActivity}
|
||||||
|
onMouseLeave={() => {
|
||||||
|
clearIdleTimer()
|
||||||
|
setActive(false)
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{isVisible && children}
|
{isVisible && children}
|
||||||
</div>
|
</div>
|
||||||
@@ -40,10 +67,10 @@ const FadeOverlay = ({ children, isVisible }: FadeOverlayProps) => {
|
|||||||
|
|
||||||
export const ParticipantTileFocus = ({
|
export const ParticipantTileFocus = ({
|
||||||
trackRef,
|
trackRef,
|
||||||
isVisible,
|
hasKeyboardFocus,
|
||||||
}: {
|
}: {
|
||||||
trackRef: TrackReferenceOrPlaceholder
|
trackRef: TrackReferenceOrPlaceholder
|
||||||
isVisible: boolean
|
hasKeyboardFocus: boolean
|
||||||
}) => {
|
}) => {
|
||||||
const participant = trackRef.participant
|
const participant = trackRef.participant
|
||||||
const isScreenShare = trackRef.source == Track.Source.ScreenShare
|
const isScreenShare = trackRef.source == Track.Source.ScreenShare
|
||||||
@@ -51,7 +78,7 @@ export const ParticipantTileFocus = ({
|
|||||||
const canMute = useCanMute(participant)
|
const canMute = useCanMute(participant)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<FadeOverlay isVisible={isVisible}>
|
<FadeOverlay hasKeyboardFocus={hasKeyboardFocus}>
|
||||||
<div
|
<div
|
||||||
className={css({
|
className={css({
|
||||||
backgroundColor: 'primaryDark.50',
|
backgroundColor: 'primaryDark.50',
|
||||||
@@ -60,7 +87,6 @@ export const ParticipantTileFocus = ({
|
|||||||
display: 'flex',
|
display: 'flex',
|
||||||
opacity: 0.6,
|
opacity: 0.6,
|
||||||
animation: 'overlayIn 200ms linear 300ms backwards',
|
animation: 'overlayIn 200ms linear 300ms backwards',
|
||||||
pointerEvents: 'auto',
|
|
||||||
_hover: {
|
_hover: {
|
||||||
opacity: 0.95,
|
opacity: 0.95,
|
||||||
},
|
},
|
||||||
@@ -68,7 +94,7 @@ export const ParticipantTileFocus = ({
|
|||||||
>
|
>
|
||||||
<HStack gap={0.5} padding={0.5}>
|
<HStack gap={0.5} padding={0.5}>
|
||||||
<FocusButton trackRef={trackRef} />
|
<FocusButton trackRef={trackRef} />
|
||||||
{!isScreenShare && (
|
{!isScreenShare ? (
|
||||||
<>
|
<>
|
||||||
{isLocal ? (
|
{isLocal ? (
|
||||||
<EffectsButton />
|
<EffectsButton />
|
||||||
@@ -76,6 +102,8 @@ export const ParticipantTileFocus = ({
|
|||||||
canMute && <MuteButton participant={participant} />
|
canMute && <MuteButton participant={participant} />
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
|
) : (
|
||||||
|
!isLocal && <ZoomButton trackRef={trackRef} />
|
||||||
)}
|
)}
|
||||||
</HStack>
|
</HStack>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+32
@@ -0,0 +1,32 @@
|
|||||||
|
import { TrackReferenceOrPlaceholder } from '@livekit/components-core'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import { useFullScreen } from '@/features/rooms/livekit/hooks/useFullScreen'
|
||||||
|
import { Button } from '@/primitives'
|
||||||
|
import { RiFullscreenLine } from '@remixicon/react'
|
||||||
|
|
||||||
|
export const ZoomButton = ({
|
||||||
|
trackRef,
|
||||||
|
}: {
|
||||||
|
trackRef: TrackReferenceOrPlaceholder
|
||||||
|
}) => {
|
||||||
|
const { t } = useTranslation('rooms', { keyPrefix: 'participantTileFocus' })
|
||||||
|
const { toggleFullScreen, isFullscreenAvailable } = useFullScreen({
|
||||||
|
trackRef,
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!isFullscreenAvailable) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="primaryTextDark"
|
||||||
|
square
|
||||||
|
tooltip={t('fullScreen')}
|
||||||
|
onPress={() => toggleFullScreen()}
|
||||||
|
>
|
||||||
|
<RiFullscreenLine />
|
||||||
|
</Button>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,31 +1,61 @@
|
|||||||
import { useMemo } from 'react'
|
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||||
|
import { useRoomContext } from '@livekit/components-react'
|
||||||
|
import { RoomEvent } from 'livekit-client'
|
||||||
import { useRoomData } from '@/features/rooms/livekit/hooks/useRoomData'
|
import { useRoomData } from '@/features/rooms/livekit/hooks/useRoomData'
|
||||||
import { useCanManageLobby } from '@/features/rooms/livekit/hooks/useCanManageLobby'
|
import { useIsAdminOrOwner } from '@/features/rooms/livekit/hooks/useIsAdminOrOwner'
|
||||||
import { useEnterRoom } from '../api/enterRoom'
|
import { useEnterRoom } from '../api/enterRoom'
|
||||||
import {
|
import {
|
||||||
useListWaitingParticipants,
|
useListWaitingParticipants,
|
||||||
type WaitingParticipant,
|
type WaitingParticipant,
|
||||||
} from '../../participants/api/listWaitingParticipants'
|
} from '../../participants/api/listWaitingParticipants'
|
||||||
|
import { decodeNotificationDataReceived } from '@/features/notifications/utils'
|
||||||
|
import { NotificationType } from '@/features/notifications/NotificationType'
|
||||||
import { reportError } from '@/features/analytics/telemetry'
|
import { reportError } from '@/features/analytics/telemetry'
|
||||||
|
|
||||||
|
export const POLL_INTERVAL_MS = 1000
|
||||||
|
|
||||||
export const useWaitingParticipants = () => {
|
export const useWaitingParticipants = () => {
|
||||||
|
const [listEnabled, setListEnabled] = useState(true)
|
||||||
|
|
||||||
const roomData = useRoomData()
|
const roomData = useRoomData()
|
||||||
const roomId = roomData?.id || '' // FIXME - bad practice
|
const roomId = roomData?.id || '' // FIXME - bad practice
|
||||||
|
|
||||||
const canManageLobby = useCanManageLobby()
|
const room = useRoomContext()
|
||||||
|
const isAdminOrOwner = useIsAdminOrOwner()
|
||||||
|
|
||||||
|
const handleDataReceived = useCallback((payload: Uint8Array) => {
|
||||||
|
const notification = decodeNotificationDataReceived(payload)
|
||||||
|
if (notification?.type === NotificationType.ParticipantWaiting) {
|
||||||
|
setListEnabled(true)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isAdminOrOwner) {
|
||||||
|
room.on(RoomEvent.DataReceived, handleDataReceived)
|
||||||
|
}
|
||||||
|
return () => {
|
||||||
|
room.off(RoomEvent.DataReceived, handleDataReceived)
|
||||||
|
}
|
||||||
|
}, [isAdminOrOwner, room, handleDataReceived])
|
||||||
|
|
||||||
const { data: waitingData, refetch: refetchWaiting } =
|
const { data: waitingData, refetch: refetchWaiting } =
|
||||||
useListWaitingParticipants(roomId, {
|
useListWaitingParticipants(roomId, {
|
||||||
retry: false,
|
retry: false,
|
||||||
enabled: false,
|
enabled: listEnabled && isAdminOrOwner,
|
||||||
|
refetchInterval: POLL_INTERVAL_MS,
|
||||||
|
refetchIntervalInBackground: true,
|
||||||
})
|
})
|
||||||
|
|
||||||
const waitingParticipants = useMemo(
|
const waitingParticipants = useMemo(
|
||||||
() => (canManageLobby ? waitingData?.participants || [] : []),
|
() => waitingData?.participants || [],
|
||||||
[waitingData, canManageLobby]
|
[waitingData]
|
||||||
)
|
)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!waitingParticipants.length) setListEnabled(false)
|
||||||
|
}, [waitingParticipants])
|
||||||
|
|
||||||
const { mutateAsync: enterRoom } = useEnterRoom()
|
const { mutateAsync: enterRoom } = useEnterRoom()
|
||||||
|
|
||||||
const handleParticipantEntry = async (
|
const handleParticipantEntry = async (
|
||||||
@@ -44,6 +74,8 @@ export const useWaitingParticipants = () => {
|
|||||||
allowEntry: boolean
|
allowEntry: boolean
|
||||||
): Promise<void> => {
|
): Promise<void> => {
|
||||||
try {
|
try {
|
||||||
|
setListEnabled(false)
|
||||||
|
|
||||||
await Promise.all(
|
await Promise.all(
|
||||||
waitingParticipants.map((participant) =>
|
waitingParticipants.map((participant) =>
|
||||||
enterRoom({
|
enterRoom({
|
||||||
@@ -57,6 +89,7 @@ export const useWaitingParticipants = () => {
|
|||||||
await refetchWaiting()
|
await refetchWaiting()
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
reportError('generic_failure', e)
|
reportError('generic_failure', e)
|
||||||
|
setListEnabled(true)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -43,8 +43,6 @@ import { useSnapshot } from 'valtio'
|
|||||||
import { userPreferencesStore } from '@/stores/userPreferences'
|
import { userPreferencesStore } from '@/stores/userPreferences'
|
||||||
import { userStore } from '@/stores/user'
|
import { userStore } from '@/stores/user'
|
||||||
import { WatchMediaDeviceErrors } from './WatchMediaDeviceErrors'
|
import { WatchMediaDeviceErrors } from './WatchMediaDeviceErrors'
|
||||||
import { MeetDevtools } from '@/features/devtools'
|
|
||||||
import { VOICE_AUDIO_CONSTRAINTS } from '@/features/rooms/livekit/utils/constants'
|
|
||||||
|
|
||||||
export const Conference = ({
|
export const Conference = ({
|
||||||
roomId,
|
roomId,
|
||||||
@@ -117,7 +115,6 @@ export const Conference = ({
|
|||||||
},
|
},
|
||||||
audioCaptureDefaults: {
|
audioCaptureDefaults: {
|
||||||
deviceId: userConfig.audioDeviceId ?? undefined,
|
deviceId: userConfig.audioDeviceId ?? undefined,
|
||||||
...VOICE_AUDIO_CONSTRAINTS,
|
|
||||||
},
|
},
|
||||||
audioOutput: {
|
audioOutput: {
|
||||||
deviceId: userConfig.audioOutputDeviceId ?? undefined,
|
deviceId: userConfig.audioOutputDeviceId ?? undefined,
|
||||||
@@ -299,7 +296,6 @@ export const Conference = ({
|
|||||||
<VideoConference />
|
<VideoConference />
|
||||||
{!isMobile && <InviteDialog mode={mode} />}
|
{!isMobile && <InviteDialog mode={mode} />}
|
||||||
<PictureInPictureConference />
|
<PictureInPictureConference />
|
||||||
<MeetDevtools />
|
|
||||||
</LiveKitRoom>
|
</LiveKitRoom>
|
||||||
</Screen>
|
</Screen>
|
||||||
</QueryAware>
|
</QueryAware>
|
||||||
|
|||||||
@@ -1,114 +0,0 @@
|
|||||||
import { useCallback, useEffect } from 'react'
|
|
||||||
import { useConnectionState, useRoomContext } from '@livekit/components-react'
|
|
||||||
import { ConnectionState, RoomEvent } from 'livekit-client'
|
|
||||||
import { useCanManageLobby } from '@/features/rooms/livekit/hooks/useCanManageLobby'
|
|
||||||
import { useRoomData } from '@/features/rooms/livekit/hooks/useRoomData'
|
|
||||||
import { useListWaitingParticipants } from '@/features/participants/api/listWaitingParticipants'
|
|
||||||
import { useSidePanel } from '@/features/rooms/livekit/hooks/useSidePanel'
|
|
||||||
import { decodeNotificationDataReceived } from '@/features/notifications/utils'
|
|
||||||
import { NotificationType } from '@/features/notifications'
|
|
||||||
import { usePrevious } from '@/hooks/usePrevious'
|
|
||||||
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 LobbyProvider = () => {
|
|
||||||
const room = useRoomContext()
|
|
||||||
|
|
||||||
const canManageLobby = useCanManageLobby()
|
|
||||||
const roomData = useRoomData()
|
|
||||||
const { isParticipantsOpen } = useSidePanel()
|
|
||||||
const isConnected = useConnectionState(room) === ConnectionState.Connected
|
|
||||||
|
|
||||||
const roomId = roomData?.id || '' // FIXME - bad practice
|
|
||||||
|
|
||||||
const { error: waitingError, refetch: refetchWaiting } =
|
|
||||||
useListWaitingParticipants(roomId, {
|
|
||||||
retry: false,
|
|
||||||
enabled: canManageLobby && isConnected && !!roomId,
|
|
||||||
refetchOnMount: false,
|
|
||||||
refetchOnWindowFocus: false,
|
|
||||||
refetchOnReconnect: false,
|
|
||||||
refetchInterval: (query) => {
|
|
||||||
if (!query.state.data?.participants?.length) return false
|
|
||||||
if (isParticipantsOpen) return POLL_INTERVAL_MS
|
|
||||||
return LAZY_POLL_INTERVAL_MS
|
|
||||||
},
|
|
||||||
refetchIntervalInBackground: true,
|
|
||||||
})
|
|
||||||
|
|
||||||
// Triggers: each one-shot, idempotent, deduped by React Query if
|
|
||||||
// concurrent. The interval takes over whenever a fetch finds waiters.
|
|
||||||
const fetchIfManager = useCallback(() => {
|
|
||||||
if (canManageLobby) refetchWaiting()
|
|
||||||
}, [canManageLobby, refetchWaiting])
|
|
||||||
|
|
||||||
// 1. Connection established (join or reconnect)
|
|
||||||
useEffect(() => {
|
|
||||||
room.on(RoomEvent.Connected, fetchIfManager)
|
|
||||||
room.on(RoomEvent.Reconnected, fetchIfManager)
|
|
||||||
return () => {
|
|
||||||
room.off(RoomEvent.Connected, fetchIfManager)
|
|
||||||
room.off(RoomEvent.Reconnected, fetchIfManager)
|
|
||||||
}
|
|
||||||
}, [room, fetchIfManager])
|
|
||||||
|
|
||||||
// 2. Someone started waiting (LiveKit broadcast).
|
|
||||||
const handleDataReceived = useCallback(
|
|
||||||
(payload: Uint8Array) => {
|
|
||||||
const notification = decodeNotificationDataReceived(payload)
|
|
||||||
if (notification?.type === NotificationType.ParticipantWaiting) {
|
|
||||||
fetchIfManager()
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[fetchIfManager]
|
|
||||||
)
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (canManageLobby) {
|
|
||||||
room.on(RoomEvent.DataReceived, handleDataReceived)
|
|
||||||
}
|
|
||||||
return () => {
|
|
||||||
room.off(RoomEvent.DataReceived, handleDataReceived)
|
|
||||||
}
|
|
||||||
}, [canManageLobby, room, handleDataReceived])
|
|
||||||
|
|
||||||
// 3. Rights regained.
|
|
||||||
const prevCanManageLobby = usePrevious(canManageLobby)
|
|
||||||
useEffect(() => {
|
|
||||||
if (!prevCanManageLobby && canManageLobby && isConnected) {
|
|
||||||
fetchIfManager()
|
|
||||||
}
|
|
||||||
}, [
|
|
||||||
prevCanManageLobby,
|
|
||||||
canManageLobby,
|
|
||||||
isParticipantsOpen,
|
|
||||||
fetchIfManager,
|
|
||||||
isConnected,
|
|
||||||
])
|
|
||||||
|
|
||||||
const clearWaitingList = useCallback(() => {
|
|
||||||
const queryKey = [keys.waitingParticipants, roomId]
|
|
||||||
queryClient.cancelQueries({ queryKey })
|
|
||||||
queryClient.setQueryData(queryKey, { participants: [] })
|
|
||||||
}, [roomId])
|
|
||||||
|
|
||||||
// Rights lost mid-meeting (covers trusted -> restricted/public).
|
|
||||||
useEffect(() => {
|
|
||||||
if (prevCanManageLobby && !canManageLobby) clearWaitingList()
|
|
||||||
}, [prevCanManageLobby, canManageLobby, clearWaitingList])
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (
|
|
||||||
waitingError instanceof ApiError &&
|
|
||||||
[401, 403].includes(waitingError.statusCode)
|
|
||||||
) {
|
|
||||||
clearWaitingList()
|
|
||||||
}
|
|
||||||
}, [waitingError, clearWaitingList])
|
|
||||||
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
@@ -1,7 +1,5 @@
|
|||||||
import { useLocalParticipant } from '@livekit/components-react'
|
import { useLocalParticipant } from '@livekit/components-react'
|
||||||
import { TrackSource } from '@livekit/protocol'
|
|
||||||
import { useEffect } from 'react'
|
import { useEffect } from 'react'
|
||||||
import { useCanPublishTrack } from '@/features/rooms/livekit/hooks/useCanPublishTrack'
|
|
||||||
|
|
||||||
export const MEDIA_STATE_ELEMENT_ID = 'media-state'
|
export const MEDIA_STATE_ELEMENT_ID = 'media-state'
|
||||||
export const MEDIA_STATE_CHANGED_EVENT = 'media-state-changed'
|
export const MEDIA_STATE_CHANGED_EVENT = 'media-state-changed'
|
||||||
@@ -9,8 +7,6 @@ export const MEDIA_STATE_CHANGED_EVENT = 'media-state-changed'
|
|||||||
export type MediaStateChangedDetail = {
|
export type MediaStateChangedDetail = {
|
||||||
microphoneEnabled: boolean
|
microphoneEnabled: boolean
|
||||||
cameraEnabled: boolean
|
cameraEnabled: boolean
|
||||||
canPublishMicrophone: boolean
|
|
||||||
canPublishCamera: boolean
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -20,15 +16,9 @@ export type MediaStateChangedDetail = {
|
|||||||
*
|
*
|
||||||
* const el = document.getElementById('media-state')
|
* const el = document.getElementById('media-state')
|
||||||
* new MutationObserver(...).observe(el, { attributes: true })
|
* 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 = () => {
|
export const MediaStateObserver = () => {
|
||||||
const { isMicrophoneEnabled, isCameraEnabled } = useLocalParticipant()
|
const { isMicrophoneEnabled, isCameraEnabled } = useLocalParticipant()
|
||||||
const canPublishMicrophone = useCanPublishTrack(TrackSource.MICROPHONE)
|
|
||||||
const canPublishCamera = useCanPublishTrack(TrackSource.CAMERA)
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
window.dispatchEvent(
|
window.dispatchEvent(
|
||||||
@@ -36,17 +26,10 @@ export const MediaStateObserver = () => {
|
|||||||
detail: {
|
detail: {
|
||||||
microphoneEnabled: isMicrophoneEnabled,
|
microphoneEnabled: isMicrophoneEnabled,
|
||||||
cameraEnabled: isCameraEnabled,
|
cameraEnabled: isCameraEnabled,
|
||||||
canPublishMicrophone,
|
|
||||||
canPublishCamera,
|
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
}, [
|
}, [isMicrophoneEnabled, isCameraEnabled])
|
||||||
isMicrophoneEnabled,
|
|
||||||
isCameraEnabled,
|
|
||||||
canPublishMicrophone,
|
|
||||||
canPublishCamera,
|
|
||||||
])
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -54,8 +37,6 @@ export const MediaStateObserver = () => {
|
|||||||
style={{ display: 'none' }}
|
style={{ display: 'none' }}
|
||||||
data-microphone-enabled={isMicrophoneEnabled ? 'true' : 'false'}
|
data-microphone-enabled={isMicrophoneEnabled ? 'true' : 'false'}
|
||||||
data-camera-enabled={isCameraEnabled ? 'true' : 'false'}
|
data-camera-enabled={isCameraEnabled ? 'true' : 'false'}
|
||||||
data-can-publish-microphone={canPublishMicrophone ? 'true' : 'false'}
|
|
||||||
data-can-publish-camera={canPublishCamera ? 'true' : 'false'}
|
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,26 +0,0 @@
|
|||||||
import { VideoTrack } from '@livekit/components-react'
|
|
||||||
import { type TrackReference } from '@livekit/components-core'
|
|
||||||
import { memo } from 'react'
|
|
||||||
|
|
||||||
interface ScreenShareVideoTrackProps {
|
|
||||||
trackRef: TrackReference
|
|
||||||
onSubscriptionStatusChanged: (subscribed: boolean) => void
|
|
||||||
manageSubscription?: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
// Zoom/pan updates the wrapper transform only; skip VideoTrack re-renders.
|
|
||||||
export const ScreenShareVideoTrack = memo(
|
|
||||||
({
|
|
||||||
trackRef,
|
|
||||||
onSubscriptionStatusChanged,
|
|
||||||
manageSubscription,
|
|
||||||
}: ScreenShareVideoTrackProps) => (
|
|
||||||
<VideoTrack
|
|
||||||
trackRef={trackRef}
|
|
||||||
onSubscriptionStatusChanged={onSubscriptionStatusChanged}
|
|
||||||
manageSubscription={manageSubscription}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
ScreenShareVideoTrack.displayName = 'ScreenShareVideoTrack'
|
|
||||||
@@ -1,223 +0,0 @@
|
|||||||
import { css } from '@/styled-system/css'
|
|
||||||
import { Button } from '@/primitives'
|
|
||||||
import {
|
|
||||||
RiCollapseDiagonalLine,
|
|
||||||
RiExpandDiagonalLine,
|
|
||||||
RiFullscreenExitLine,
|
|
||||||
RiZoomInLine,
|
|
||||||
RiZoomOutLine,
|
|
||||||
} from '@remixicon/react'
|
|
||||||
import { useTranslation } from 'react-i18next'
|
|
||||||
import { Toolbar } from 'react-aria-components'
|
|
||||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
|
||||||
import { useScreenReaderAnnounce } from '@/hooks/useScreenReaderAnnounce'
|
|
||||||
import { isMacintosh } from '@/utils/livekit'
|
|
||||||
import { srOnly } from '@/styles/a11y'
|
|
||||||
|
|
||||||
interface ScreenShareZoomControlsProps {
|
|
||||||
containerRef: React.RefObject<HTMLDivElement | null>
|
|
||||||
isZoomed: boolean
|
|
||||||
zoomPercentage: number
|
|
||||||
canZoomIn: boolean
|
|
||||||
canZoomOut: boolean
|
|
||||||
onZoomIn: () => void
|
|
||||||
onZoomOut: () => void
|
|
||||||
onResetZoom: () => void
|
|
||||||
}
|
|
||||||
|
|
||||||
export const ScreenShareZoomControls = ({
|
|
||||||
containerRef,
|
|
||||||
isZoomed,
|
|
||||||
zoomPercentage,
|
|
||||||
canZoomIn,
|
|
||||||
canZoomOut,
|
|
||||||
onZoomIn,
|
|
||||||
onZoomOut,
|
|
||||||
onResetZoom,
|
|
||||||
}: ScreenShareZoomControlsProps) => {
|
|
||||||
const { t } = useTranslation('rooms', { keyPrefix: 'screenShareZoom' })
|
|
||||||
const announce = useScreenReaderAnnounce()
|
|
||||||
|
|
||||||
const zoomInButtonRef = useRef<HTMLButtonElement>(null)
|
|
||||||
const hadFocusInCollapsibleRef = useRef(false)
|
|
||||||
|
|
||||||
const [isFullscreen, setIsFullscreen] = useState(false)
|
|
||||||
// Tracks whether this tile's container triggered fullscreen (vs another share's).
|
|
||||||
const wasThisTileFullscreen = useRef(false)
|
|
||||||
const isFullscreenAvailable = document.fullscreenEnabled
|
|
||||||
|
|
||||||
// Covers Esc and browser UI exits, not just the toolbar button.
|
|
||||||
// Only this tile's instance announces to avoid duplicates with multiple shares.
|
|
||||||
useEffect(() => {
|
|
||||||
const onChange = () => {
|
|
||||||
const isThisTileFullscreen =
|
|
||||||
document.fullscreenElement === containerRef.current
|
|
||||||
setIsFullscreen(isThisTileFullscreen)
|
|
||||||
|
|
||||||
if (isThisTileFullscreen) {
|
|
||||||
wasThisTileFullscreen.current = true
|
|
||||||
announce(t('fullScreenEntered'), 'assertive')
|
|
||||||
} else if (wasThisTileFullscreen.current) {
|
|
||||||
wasThisTileFullscreen.current = false
|
|
||||||
announce(t('fullScreenExited'), 'assertive')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
document.addEventListener('fullscreenchange', onChange)
|
|
||||||
return () => document.removeEventListener('fullscreenchange', onChange)
|
|
||||||
}, [announce, t, containerRef])
|
|
||||||
|
|
||||||
// Back at 100 % the collapsible controls are disabled and hidden, which drops
|
|
||||||
// keyboard focus on the body. Hand it to the zoom in button instead, the only
|
|
||||||
// control of that group still reachable.
|
|
||||||
useEffect(() => {
|
|
||||||
if (isZoomed || !hadFocusInCollapsibleRef.current) return
|
|
||||||
hadFocusInCollapsibleRef.current = false
|
|
||||||
zoomInButtonRef.current?.focus()
|
|
||||||
}, [isZoomed])
|
|
||||||
|
|
||||||
const toggleFullScreen = useCallback(async () => {
|
|
||||||
try {
|
|
||||||
if (document.fullscreenElement === containerRef.current) {
|
|
||||||
await document.exitFullscreen()
|
|
||||||
} else {
|
|
||||||
// Tile container so zoom controls stay visible in fullscreen.
|
|
||||||
await containerRef.current?.requestFullscreen()
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error toggling fullscreen:', error)
|
|
||||||
}
|
|
||||||
}, [containerRef])
|
|
||||||
|
|
||||||
const wheelShortcutVisual = isMacintosh() ? '⌘+scroll' : 'Ctrl+scroll'
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className={css({
|
|
||||||
position: 'absolute',
|
|
||||||
bottom: '12px',
|
|
||||||
right: '12px',
|
|
||||||
zIndex: 2,
|
|
||||||
pointerEvents: 'auto',
|
|
||||||
})}
|
|
||||||
>
|
|
||||||
{/* react-aria Toolbar: left/right arrows move between the controls and
|
|
||||||
Tab leaves the group as a whole, as the toolbar role implies. */}
|
|
||||||
<Toolbar
|
|
||||||
aria-label={t('toolbarLabel')}
|
|
||||||
className={css({
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
backgroundColor: 'primaryDark.50',
|
|
||||||
borderRadius: '2rem',
|
|
||||||
padding: '0.5rem',
|
|
||||||
opacity: 0.7,
|
|
||||||
transition: 'opacity 200ms linear',
|
|
||||||
_hover: {
|
|
||||||
opacity: 0.95,
|
|
||||||
},
|
|
||||||
})}
|
|
||||||
>
|
|
||||||
<span className={srOnly}>
|
|
||||||
{t(isMacintosh() ? 'wheelShortcutHintMac' : 'wheelShortcutHint')}
|
|
||||||
</span>
|
|
||||||
{/* Animated wrapper: collapses to 0 when not zoomed. padding/margin
|
|
||||||
trick keeps overflow:hidden from clipping focus rings. */}
|
|
||||||
<div
|
|
||||||
className={css({
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
overflow: 'hidden',
|
|
||||||
transition: 'max-width 200ms ease-out, opacity 200ms ease-out',
|
|
||||||
padding: '3px',
|
|
||||||
margin: '-3px',
|
|
||||||
})}
|
|
||||||
style={{
|
|
||||||
maxWidth: isZoomed ? '12rem' : '0',
|
|
||||||
opacity: isZoomed ? 1 : 0,
|
|
||||||
}}
|
|
||||||
aria-hidden={!isZoomed}
|
|
||||||
onFocus={() => {
|
|
||||||
hadFocusInCollapsibleRef.current = true
|
|
||||||
}}
|
|
||||||
onBlur={(e) => {
|
|
||||||
// Disabling a focused button blurs it with no relatedTarget, so the
|
|
||||||
// flag must survive that case for the effect above to rescue focus.
|
|
||||||
if (e.relatedTarget) hadFocusInCollapsibleRef.current = false
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
variant="primaryTextDark"
|
|
||||||
square
|
|
||||||
tooltip={t('fitToWindow')}
|
|
||||||
aria-label={t('fitToWindow')}
|
|
||||||
isDisabled={!isZoomed}
|
|
||||||
onPress={onResetZoom}
|
|
||||||
>
|
|
||||||
<RiFullscreenExitLine size={20} />
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
variant="primaryTextDark"
|
|
||||||
square
|
|
||||||
tooltip={t('zoomOutWithShortcut', {
|
|
||||||
shortcut: wheelShortcutVisual,
|
|
||||||
})}
|
|
||||||
aria-label={t('zoomOut')}
|
|
||||||
isDisabled={!isZoomed || !canZoomOut}
|
|
||||||
onPress={onZoomOut}
|
|
||||||
>
|
|
||||||
<RiZoomOutLine size={20} />
|
|
||||||
</Button>
|
|
||||||
{/* Visual only - zoom level is announced via useScreenReaderAnnounce. */}
|
|
||||||
<span
|
|
||||||
aria-hidden="true"
|
|
||||||
className={css({
|
|
||||||
color: 'white',
|
|
||||||
fontSize: '0.8125rem',
|
|
||||||
fontWeight: 500,
|
|
||||||
minWidth: '3.25rem',
|
|
||||||
textAlign: 'center',
|
|
||||||
userSelect: 'none',
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'center',
|
|
||||||
padding: '0 0.25rem',
|
|
||||||
whiteSpace: 'nowrap',
|
|
||||||
})}
|
|
||||||
>
|
|
||||||
{zoomPercentage} %
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<Button
|
|
||||||
ref={zoomInButtonRef}
|
|
||||||
size="sm"
|
|
||||||
variant="primaryTextDark"
|
|
||||||
square
|
|
||||||
tooltip={t('zoomInWithShortcut', { shortcut: wheelShortcutVisual })}
|
|
||||||
aria-label={t('zoomIn')}
|
|
||||||
isDisabled={!canZoomIn}
|
|
||||||
onPress={onZoomIn}
|
|
||||||
>
|
|
||||||
<RiZoomInLine size={20} />
|
|
||||||
</Button>
|
|
||||||
{isFullscreenAvailable && (
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
variant="primaryTextDark"
|
|
||||||
square
|
|
||||||
tooltip={isFullscreen ? t('exitFullScreen') : t('fullScreen')}
|
|
||||||
aria-label={isFullscreen ? t('exitFullScreen') : t('fullScreen')}
|
|
||||||
onPress={toggleFullScreen}
|
|
||||||
>
|
|
||||||
{isFullscreen ? (
|
|
||||||
<RiCollapseDiagonalLine size={20} />
|
|
||||||
) : (
|
|
||||||
<RiExpandDiagonalLine size={20} />
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</Toolbar>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,106 +0,0 @@
|
|||||||
import { css } from '@/styled-system/css'
|
|
||||||
import { type TrackReference } from '@livekit/components-core'
|
|
||||||
import { useEffect, useRef } from 'react'
|
|
||||||
import { useTranslation } from 'react-i18next'
|
|
||||||
import { useScreenShareZoom } from '../hooks/useScreenShareZoom'
|
|
||||||
import { useScreenReaderAnnounce } from '@/hooks/useScreenReaderAnnounce'
|
|
||||||
import { ScreenShareZoomControls } from './ScreenShareZoomControls'
|
|
||||||
import { ScreenShareVideoTrack } from './ScreenShareVideoTrack'
|
|
||||||
|
|
||||||
interface ScreenShareZoomableVideoProps {
|
|
||||||
trackRef: TrackReference
|
|
||||||
tileRef: React.RefObject<HTMLDivElement | null>
|
|
||||||
onSubscriptionStatusChanged: (subscribed: boolean) => void
|
|
||||||
manageSubscription?: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
export const ScreenShareZoomableVideo = ({
|
|
||||||
trackRef,
|
|
||||||
tileRef,
|
|
||||||
onSubscriptionStatusChanged,
|
|
||||||
manageSubscription,
|
|
||||||
}: ScreenShareZoomableVideoProps) => {
|
|
||||||
const zoom = useScreenShareZoom()
|
|
||||||
const { t } = useTranslation('rooms', { keyPrefix: 'screenShareZoom' })
|
|
||||||
const announce = useScreenReaderAnnounce()
|
|
||||||
|
|
||||||
// SR announcement: announce zoom level on change, with a one-time pan hint
|
|
||||||
// on the first zoom above 100 % per session.
|
|
||||||
const prevZoomRef = useRef(zoom.zoomPercentage)
|
|
||||||
const hasAnnouncedPanHint = useRef(false)
|
|
||||||
useEffect(() => {
|
|
||||||
if (prevZoomRef.current === zoom.zoomPercentage) return
|
|
||||||
const wasAtDefault = prevZoomRef.current <= 100
|
|
||||||
prevZoomRef.current = zoom.zoomPercentage
|
|
||||||
|
|
||||||
if (wasAtDefault && zoom.isZoomed && !hasAnnouncedPanHint.current) {
|
|
||||||
hasAnnouncedPanHint.current = true
|
|
||||||
announce(t('panHint', { level: zoom.zoomPercentage }), 'polite')
|
|
||||||
} else {
|
|
||||||
announce(t('currentZoomLevel', { level: zoom.zoomPercentage }), 'polite')
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!zoom.isZoomed) hasAnnouncedPanHint.current = false
|
|
||||||
}, [zoom.zoomPercentage, zoom.isZoomed, announce, t])
|
|
||||||
|
|
||||||
// Attach keyboard listener on the tile container (has tabIndex=0).
|
|
||||||
useEffect(() => {
|
|
||||||
const el = tileRef.current
|
|
||||||
if (!el) return
|
|
||||||
el.addEventListener('keydown', zoom.handleKeyDown)
|
|
||||||
return () => el.removeEventListener('keydown', zoom.handleKeyDown)
|
|
||||||
}, [tileRef, zoom.handleKeyDown])
|
|
||||||
|
|
||||||
// Native wheel listener with { passive: false } so preventDefault works.
|
|
||||||
useEffect(() => {
|
|
||||||
const el = zoom.surfaceElRef.current
|
|
||||||
if (!el) return
|
|
||||||
el.addEventListener('wheel', zoom.handleWheel, { passive: false })
|
|
||||||
return () => el.removeEventListener('wheel', zoom.handleWheel)
|
|
||||||
}, [zoom.handleWheel, zoom.surfaceElRef])
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<div
|
|
||||||
ref={zoom.surfaceElRef}
|
|
||||||
className={css({
|
|
||||||
width: '100%',
|
|
||||||
height: '100%',
|
|
||||||
overflow: 'hidden',
|
|
||||||
position: 'relative',
|
|
||||||
userSelect: 'none',
|
|
||||||
// Leaves the browser's native pinch-zoom available on touch devices
|
|
||||||
// while still routing single-pointer drags to useMove for panning.
|
|
||||||
touchAction: 'pinch-zoom',
|
|
||||||
})}
|
|
||||||
{...zoom.moveProps}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
ref={zoom.transformElRef}
|
|
||||||
style={{
|
|
||||||
width: '100%',
|
|
||||||
height: '100%',
|
|
||||||
pointerEvents: 'none',
|
|
||||||
transformOrigin: 'center center',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<ScreenShareVideoTrack
|
|
||||||
trackRef={trackRef}
|
|
||||||
onSubscriptionStatusChanged={onSubscriptionStatusChanged}
|
|
||||||
manageSubscription={manageSubscription}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<ScreenShareZoomControls
|
|
||||||
containerRef={tileRef}
|
|
||||||
isZoomed={zoom.isZoomed}
|
|
||||||
zoomPercentage={zoom.zoomPercentage}
|
|
||||||
canZoomIn={zoom.canZoomIn}
|
|
||||||
canZoomOut={zoom.canZoomOut}
|
|
||||||
onZoomIn={zoom.zoomIn}
|
|
||||||
onZoomOut={zoom.zoomOut}
|
|
||||||
onResetZoom={zoom.resetZoom}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -16,8 +16,6 @@ import { Info } from './Info'
|
|||||||
import { HStack } from '@/styled-system/jsx'
|
import { HStack } from '@/styled-system/jsx'
|
||||||
import { useReactionsToolbar } from '@/features/reactions/hooks/useReactionsToolbar'
|
import { useReactionsToolbar } from '@/features/reactions/hooks/useReactionsToolbar'
|
||||||
import { useRestoreFocus } from '@/hooks/useRestoreFocus'
|
import { useRestoreFocus } from '@/hooks/useRestoreFocus'
|
||||||
import { useEscapeToClose } from '@/hooks/useEscapeToClose'
|
|
||||||
import { srOnly } from '@/styles/a11y'
|
|
||||||
|
|
||||||
type StyledSidePanelProps = {
|
type StyledSidePanelProps = {
|
||||||
title: string
|
title: string
|
||||||
@@ -26,7 +24,6 @@ type StyledSidePanelProps = {
|
|||||||
onClose: () => void
|
onClose: () => void
|
||||||
isClosed: boolean
|
isClosed: boolean
|
||||||
closeButtonTooltip: string
|
closeButtonTooltip: string
|
||||||
escapeHint: string
|
|
||||||
isSubmenu: boolean
|
isSubmenu: boolean
|
||||||
onBack: () => void
|
onBack: () => void
|
||||||
backButtonLabel: string
|
backButtonLabel: string
|
||||||
@@ -43,7 +40,6 @@ const StyledSidePanel = React.forwardRef<HTMLElement, StyledSidePanelProps>(
|
|||||||
isClosed,
|
isClosed,
|
||||||
isReactionToolbarOpen,
|
isReactionToolbarOpen,
|
||||||
closeButtonTooltip,
|
closeButtonTooltip,
|
||||||
escapeHint,
|
|
||||||
isSubmenu = false,
|
isSubmenu = false,
|
||||||
onBack,
|
onBack,
|
||||||
backButtonLabel,
|
backButtonLabel,
|
||||||
@@ -88,11 +84,7 @@ const StyledSidePanel = React.forwardRef<HTMLElement, StyledSidePanelProps>(
|
|||||||
}}
|
}}
|
||||||
aria-hidden={isClosed}
|
aria-hidden={isClosed}
|
||||||
aria-label={ariaLabel}
|
aria-label={ariaLabel}
|
||||||
aria-describedby="side-panel-escape-hint"
|
|
||||||
>
|
>
|
||||||
<span id="side-panel-escape-hint" className={srOnly}>
|
|
||||||
{escapeHint}
|
|
||||||
</span>
|
|
||||||
<HStack alignItems="center">
|
<HStack alignItems="center">
|
||||||
{isSubmenu && (
|
{isSubmenu && (
|
||||||
<Button
|
<Button
|
||||||
@@ -202,8 +194,6 @@ export const SidePanel = () => {
|
|||||||
activeKey: activePanelId,
|
activeKey: activePanelId,
|
||||||
})
|
})
|
||||||
|
|
||||||
useEscapeToClose(isSidePanelOpen, asideRef, closeSidePanel)
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<StyledSidePanel
|
<StyledSidePanel
|
||||||
ref={asideRef}
|
ref={asideRef}
|
||||||
@@ -213,7 +203,6 @@ export const SidePanel = () => {
|
|||||||
closeButtonTooltip={t('closeButton', {
|
closeButtonTooltip={t('closeButton', {
|
||||||
content: t(`content.${activeSubPanelId || activePanelId}`),
|
content: t(`content.${activeSubPanelId || activePanelId}`),
|
||||||
})}
|
})}
|
||||||
escapeHint={t('escapeHint')}
|
|
||||||
isClosed={!isSidePanelOpen}
|
isClosed={!isSidePanelOpen}
|
||||||
isSubmenu={isSubPanelOpen}
|
isSubmenu={isSubPanelOpen}
|
||||||
isReactionToolbarOpen={isReactionToolbarOpen}
|
isReactionToolbarOpen={isReactionToolbarOpen}
|
||||||
|
|||||||
+2
-6
@@ -2,7 +2,7 @@ import { useTranslation } from 'react-i18next'
|
|||||||
import { useTrackToggle, UseTrackToggleProps } from '@livekit/components-react'
|
import { useTrackToggle, UseTrackToggleProps } from '@livekit/components-react'
|
||||||
import { Button, Popover } from '@/primitives'
|
import { Button, Popover } from '@/primitives'
|
||||||
import { RiArrowUpSLine, RiImageCircleAiFill } from '@remixicon/react'
|
import { RiArrowUpSLine, RiImageCircleAiFill } from '@remixicon/react'
|
||||||
import { Track, type VideoCaptureOptions, VideoPresets } from 'livekit-client'
|
import { Track, type VideoCaptureOptions } from 'livekit-client'
|
||||||
|
|
||||||
import { ToggleDevice } from './ToggleDevice'
|
import { ToggleDevice } from './ToggleDevice'
|
||||||
import { css } from '@/styled-system/css'
|
import { css } from '@/styled-system/css'
|
||||||
@@ -57,8 +57,7 @@ export const VideoDeviceControl = ({
|
|||||||
}: VideoDeviceControlProps) => {
|
}: VideoDeviceControlProps) => {
|
||||||
const { t } = useTranslation('rooms', { keyPrefix: 'selectDevice' })
|
const { t } = useTranslation('rooms', { keyPrefix: 'selectDevice' })
|
||||||
|
|
||||||
const { videoDeviceId, processorConfig, videoPublishResolution } =
|
const { videoDeviceId, processorConfig } = useSnapshot(userChoicesStore)
|
||||||
useSnapshot(userChoicesStore)
|
|
||||||
|
|
||||||
const onChange = React.useCallback(
|
const onChange = React.useCallback(
|
||||||
(enabled: boolean, isUserInitiated: boolean) =>
|
(enabled: boolean, isUserInitiated: boolean) =>
|
||||||
@@ -98,9 +97,6 @@ export const VideoDeviceControl = ({
|
|||||||
|
|
||||||
await toggle(!trackProps.enabled, {
|
await toggle(!trackProps.enabled, {
|
||||||
processor: processor,
|
processor: processor,
|
||||||
...(videoPublishResolution && {
|
|
||||||
resolution: VideoPresets[videoPublishResolution].resolution,
|
|
||||||
}),
|
|
||||||
} as VideoCaptureOptions)
|
} as VideoCaptureOptions)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,17 +0,0 @@
|
|||||||
import { useUser } from '@/features/auth/api/useUser'
|
|
||||||
import { ApiAccessLevel } from '@/features/rooms/api/ApiRoom'
|
|
||||||
import { useIsAdminOrOwner } from './useIsAdminOrOwner'
|
|
||||||
import { useRoomData } from './useRoomData'
|
|
||||||
|
|
||||||
export const useCanManageLobby = () => {
|
|
||||||
const isAdminOrOwner = useIsAdminOrOwner()
|
|
||||||
const { isLoggedIn } = useUser()
|
|
||||||
const roomData = useRoomData()
|
|
||||||
|
|
||||||
return (
|
|
||||||
(isAdminOrOwner ||
|
|
||||||
(isLoggedIn === true &&
|
|
||||||
roomData?.access_level === ApiAccessLevel.TRUSTED)) &&
|
|
||||||
roomData?.access_level !== ApiAccessLevel.PUBLIC
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -18,7 +18,6 @@ import {
|
|||||||
noteDeviceReady,
|
noteDeviceReady,
|
||||||
onMediaPermissionError,
|
onMediaPermissionError,
|
||||||
} from '../utils/mediaPermissions'
|
} from '../utils/mediaPermissions'
|
||||||
import { VOICE_AUDIO_CONSTRAINTS } from '../utils/constants'
|
|
||||||
import {
|
import {
|
||||||
saveAudioInputDeviceId,
|
saveAudioInputDeviceId,
|
||||||
saveAudioInputEnabled,
|
saveAudioInputEnabled,
|
||||||
@@ -28,6 +27,16 @@ import {
|
|||||||
} from '@/stores/userChoices'
|
} from '@/stores/userChoices'
|
||||||
import { useSyncTrackDeviceId } from './useSyncTrackDeviceId'
|
import { useSyncTrackDeviceId } from './useSyncTrackDeviceId'
|
||||||
|
|
||||||
|
const VOICE_AUDIO_CONSTRAINTS = {
|
||||||
|
noiseSuppression: true,
|
||||||
|
echoCancellation: true,
|
||||||
|
autoGainControl: true,
|
||||||
|
voiceIsolation: false,
|
||||||
|
sampleRate: 48000,
|
||||||
|
channelCount: 1,
|
||||||
|
sampleSize: 16,
|
||||||
|
} as const
|
||||||
|
|
||||||
// Module-level: effect dependencies, must be referentially stable.
|
// Module-level: effect dependencies, must be referentially stable.
|
||||||
const disableAudio = () => saveAudioInputEnabled(false)
|
const disableAudio = () => saveAudioInputEnabled(false)
|
||||||
const disableVideo = () => saveVideoInputEnabled(false)
|
const disableVideo = () => saveVideoInputEnabled(false)
|
||||||
|
|||||||
@@ -1,284 +0,0 @@
|
|||||||
import { useCallback, useRef, useSyncExternalStore } from 'react'
|
|
||||||
import { useMove } from 'react-aria'
|
|
||||||
import type { MoveMoveEvent } from '@react-types/shared'
|
|
||||||
import {
|
|
||||||
FULL_PICTURE_RATIO,
|
|
||||||
MIN_ZOOM,
|
|
||||||
PAN_STEP,
|
|
||||||
WHEEL_ZOOM_SPEED,
|
|
||||||
ZOOM_STEP,
|
|
||||||
type PanOffset,
|
|
||||||
type ZoomSnapshot,
|
|
||||||
buildZoomSnapshot,
|
|
||||||
clampPan,
|
|
||||||
clampZoom,
|
|
||||||
getCursorFromZoomState,
|
|
||||||
getCursorPercentsFromWheelEvent,
|
|
||||||
getPanDeltaPercentsFromMove,
|
|
||||||
getPictureRatio,
|
|
||||||
getWheelPanOffset,
|
|
||||||
getZoomTransform,
|
|
||||||
} from '../utils/screenShareZoom'
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Manages zoom and pan state for a remote screen share.
|
|
||||||
*
|
|
||||||
* Performance: zoom/pan live in refs and are applied imperatively to the DOM
|
|
||||||
* (via transformElRef / surfaceElRef) so the hot path (drag, wheel) never
|
|
||||||
* triggers a React re-render. A useSyncExternalStore snapshot is flushed only
|
|
||||||
* when the toolbar UI needs to update (zoom level change, drag end).
|
|
||||||
*
|
|
||||||
* Drag/touch panning is handled by react-aria's useMove (moveProps).
|
|
||||||
* Ctrl/Cmd + wheel zoom is a native listener (must be non-passive to
|
|
||||||
* preventDefault and block browser page zoom).
|
|
||||||
* Arrow key panning and +/-/0 zoom are on a keydown listener attached to the
|
|
||||||
* tile container (which has tabIndex=0 and focus).
|
|
||||||
*/
|
|
||||||
export const useScreenShareZoom = () => {
|
|
||||||
const zoomRef = useRef(MIN_ZOOM)
|
|
||||||
const panRef = useRef<PanOffset>({ x: 0, y: 0 })
|
|
||||||
const draggingRef = useRef(false)
|
|
||||||
|
|
||||||
// The consumer binds these to the inner transform div and the outer drag surface.
|
|
||||||
const transformElRef = useRef<HTMLDivElement | null>(null)
|
|
||||||
const surfaceElRef = useRef<HTMLDivElement | null>(null)
|
|
||||||
|
|
||||||
// Snapshot store: subscribers are notified only on explicit flush() calls.
|
|
||||||
const snapshotRef = useRef<ZoomSnapshot>(
|
|
||||||
buildZoomSnapshot(MIN_ZOOM, { x: 0, y: 0 }, false)
|
|
||||||
)
|
|
||||||
const listenersRef = useRef(new Set<() => void>())
|
|
||||||
|
|
||||||
const subscribe = useCallback((cb: () => void) => {
|
|
||||||
listenersRef.current.add(cb)
|
|
||||||
return () => {
|
|
||||||
listenersRef.current.delete(cb)
|
|
||||||
}
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
const getSnapshot = useCallback(() => snapshotRef.current, [])
|
|
||||||
|
|
||||||
const snapshot = useSyncExternalStore(subscribe, getSnapshot, getSnapshot)
|
|
||||||
|
|
||||||
const flush = useCallback(() => {
|
|
||||||
snapshotRef.current = buildZoomSnapshot(
|
|
||||||
zoomRef.current,
|
|
||||||
panRef.current,
|
|
||||||
draggingRef.current
|
|
||||||
)
|
|
||||||
listenersRef.current.forEach((cb) => cb())
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
const applyTransform = useCallback(() => {
|
|
||||||
const el = transformElRef.current
|
|
||||||
if (!el) return
|
|
||||||
el.style.transform = getZoomTransform(zoomRef.current, panRef.current)
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
// The video is letterboxed inside the surface by object-fit: contain, so the
|
|
||||||
// pan bounds depend on how much of the surface the picture actually covers.
|
|
||||||
// Read live rather than cached: both the tile and the shared resolution can
|
|
||||||
// change at any time.
|
|
||||||
const readPictureRatio = useCallback(() => {
|
|
||||||
const surface = surfaceElRef.current
|
|
||||||
const video = transformElRef.current?.querySelector('video')
|
|
||||||
if (!surface || !video) return FULL_PICTURE_RATIO
|
|
||||||
return getPictureRatio(
|
|
||||||
surface.clientWidth,
|
|
||||||
surface.clientHeight,
|
|
||||||
video.videoWidth,
|
|
||||||
video.videoHeight
|
|
||||||
)
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
const applyCursor = useCallback(() => {
|
|
||||||
const el = surfaceElRef.current
|
|
||||||
if (!el) return
|
|
||||||
el.style.cursor = getCursorFromZoomState(
|
|
||||||
zoomRef.current,
|
|
||||||
draggingRef.current
|
|
||||||
)
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
const zoomIn = useCallback(() => {
|
|
||||||
const next = clampZoom(zoomRef.current + ZOOM_STEP)
|
|
||||||
zoomRef.current = next
|
|
||||||
panRef.current = clampPan(panRef.current, next, readPictureRatio())
|
|
||||||
applyTransform()
|
|
||||||
applyCursor()
|
|
||||||
flush()
|
|
||||||
}, [applyTransform, applyCursor, flush, readPictureRatio])
|
|
||||||
|
|
||||||
const zoomOut = useCallback(() => {
|
|
||||||
const next = clampZoom(zoomRef.current - ZOOM_STEP)
|
|
||||||
zoomRef.current = next
|
|
||||||
panRef.current =
|
|
||||||
next <= MIN_ZOOM
|
|
||||||
? { x: 0, y: 0 }
|
|
||||||
: clampPan(panRef.current, next, readPictureRatio())
|
|
||||||
applyTransform()
|
|
||||||
applyCursor()
|
|
||||||
flush()
|
|
||||||
}, [applyTransform, applyCursor, flush, readPictureRatio])
|
|
||||||
|
|
||||||
const resetZoom = useCallback(() => {
|
|
||||||
zoomRef.current = MIN_ZOOM
|
|
||||||
panRef.current = { x: 0, y: 0 }
|
|
||||||
applyTransform()
|
|
||||||
applyCursor()
|
|
||||||
flush()
|
|
||||||
}, [applyTransform, applyCursor, flush])
|
|
||||||
|
|
||||||
// Must be attached with { passive: false } so preventDefault() blocks
|
|
||||||
// the browser's native Ctrl+scroll page zoom.
|
|
||||||
const handleWheel = useCallback(
|
|
||||||
(e: WheelEvent) => {
|
|
||||||
if (!e.ctrlKey && !e.metaKey) return
|
|
||||||
|
|
||||||
e.preventDefault()
|
|
||||||
e.stopPropagation()
|
|
||||||
|
|
||||||
const target = e.currentTarget as HTMLElement
|
|
||||||
const prev = zoomRef.current
|
|
||||||
const delta = -e.deltaY * WHEEL_ZOOM_SPEED
|
|
||||||
const next = clampZoom(prev + delta)
|
|
||||||
|
|
||||||
if (next <= MIN_ZOOM) {
|
|
||||||
zoomRef.current = MIN_ZOOM
|
|
||||||
panRef.current = { x: 0, y: 0 }
|
|
||||||
} else {
|
|
||||||
const { cursorXPercent, cursorYPercent } =
|
|
||||||
getCursorPercentsFromWheelEvent(e, target)
|
|
||||||
zoomRef.current = next
|
|
||||||
panRef.current = getWheelPanOffset({
|
|
||||||
pan: panRef.current,
|
|
||||||
prevZoom: prev,
|
|
||||||
nextZoom: next,
|
|
||||||
cursorXPercent,
|
|
||||||
cursorYPercent,
|
|
||||||
ratio: readPictureRatio(),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
applyTransform()
|
|
||||||
applyCursor()
|
|
||||||
flush()
|
|
||||||
},
|
|
||||||
[applyTransform, applyCursor, flush, readPictureRatio]
|
|
||||||
)
|
|
||||||
|
|
||||||
// useMove handles mouse drag + touch pan. Keyboard arrows are not handled
|
|
||||||
// here because moveProps is on the zoom surface, while focus is on the tile
|
|
||||||
// container, see handleKeyDown below.
|
|
||||||
const { moveProps } = useMove({
|
|
||||||
onMoveStart() {
|
|
||||||
if (zoomRef.current <= MIN_ZOOM) return
|
|
||||||
draggingRef.current = true
|
|
||||||
applyCursor()
|
|
||||||
flush()
|
|
||||||
},
|
|
||||||
onMove(e: MoveMoveEvent) {
|
|
||||||
if (zoomRef.current <= MIN_ZOOM) return
|
|
||||||
|
|
||||||
const el = surfaceElRef.current
|
|
||||||
if (!el) return
|
|
||||||
|
|
||||||
const { deltaXPercent, deltaYPercent } = getPanDeltaPercentsFromMove(
|
|
||||||
e.deltaX,
|
|
||||||
e.deltaY,
|
|
||||||
el
|
|
||||||
)
|
|
||||||
|
|
||||||
panRef.current = clampPan(
|
|
||||||
{
|
|
||||||
x: panRef.current.x + deltaXPercent,
|
|
||||||
y: panRef.current.y + deltaYPercent,
|
|
||||||
},
|
|
||||||
zoomRef.current,
|
|
||||||
readPictureRatio()
|
|
||||||
)
|
|
||||||
|
|
||||||
applyTransform()
|
|
||||||
// Mouse drag: skip flush (imperative-only) to avoid re-renders per frame.
|
|
||||||
// Keyboard: flush so the toolbar reflects the updated position.
|
|
||||||
if (e.pointerType === 'keyboard') {
|
|
||||||
flush()
|
|
||||||
}
|
|
||||||
},
|
|
||||||
onMoveEnd() {
|
|
||||||
draggingRef.current = false
|
|
||||||
applyTransform()
|
|
||||||
applyCursor()
|
|
||||||
flush()
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
const panBy = useCallback(
|
|
||||||
(dx: number, dy: number) => {
|
|
||||||
panRef.current = clampPan(
|
|
||||||
{ x: panRef.current.x + dx, y: panRef.current.y + dy },
|
|
||||||
zoomRef.current,
|
|
||||||
readPictureRatio()
|
|
||||||
)
|
|
||||||
applyTransform()
|
|
||||||
flush()
|
|
||||||
},
|
|
||||||
[applyTransform, flush, readPictureRatio]
|
|
||||||
)
|
|
||||||
|
|
||||||
// Attached to the tile container (not the zoom surface) where keyboard
|
|
||||||
// focus lives. Arrows pan, +/-/0 zoom.
|
|
||||||
const handleKeyDown = useCallback(
|
|
||||||
(e: KeyboardEvent) => {
|
|
||||||
const isZoomed = zoomRef.current > MIN_ZOOM
|
|
||||||
if (!isZoomed && e.key !== '+' && e.key !== '=') return
|
|
||||||
|
|
||||||
if (e.key.startsWith('Arrow') && e.target !== e.currentTarget) return
|
|
||||||
|
|
||||||
switch (e.key) {
|
|
||||||
case 'ArrowLeft':
|
|
||||||
e.preventDefault()
|
|
||||||
panBy(PAN_STEP, 0)
|
|
||||||
break
|
|
||||||
case 'ArrowRight':
|
|
||||||
e.preventDefault()
|
|
||||||
panBy(-PAN_STEP, 0)
|
|
||||||
break
|
|
||||||
case 'ArrowUp':
|
|
||||||
e.preventDefault()
|
|
||||||
panBy(0, PAN_STEP)
|
|
||||||
break
|
|
||||||
case 'ArrowDown':
|
|
||||||
e.preventDefault()
|
|
||||||
panBy(0, -PAN_STEP)
|
|
||||||
break
|
|
||||||
case '+':
|
|
||||||
case '=':
|
|
||||||
e.preventDefault()
|
|
||||||
zoomIn()
|
|
||||||
break
|
|
||||||
case '-':
|
|
||||||
e.preventDefault()
|
|
||||||
zoomOut()
|
|
||||||
break
|
|
||||||
case '0':
|
|
||||||
e.preventDefault()
|
|
||||||
resetZoom()
|
|
||||||
break
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[panBy, zoomIn, zoomOut, resetZoom]
|
|
||||||
)
|
|
||||||
|
|
||||||
return {
|
|
||||||
...snapshot,
|
|
||||||
transformElRef,
|
|
||||||
surfaceElRef,
|
|
||||||
moveProps,
|
|
||||||
zoomIn,
|
|
||||||
zoomOut,
|
|
||||||
resetZoom,
|
|
||||||
handleWheel,
|
|
||||||
handleKeyDown,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -31,7 +31,6 @@ import { PinAnnouncer } from '@/features/layout/components/PinAnnouncer'
|
|||||||
import { ChatProvider } from '@/features/chat/components/ChatProvider'
|
import { ChatProvider } from '@/features/chat/components/ChatProvider'
|
||||||
import { SyncDevicePreferences } from '@/features/rooms/livekit/components/SyncDevicePreferences'
|
import { SyncDevicePreferences } from '@/features/rooms/livekit/components/SyncDevicePreferences'
|
||||||
import { RoomSilentMicDetector } from '@/features/rooms/components/SilentMicDetector'
|
import { RoomSilentMicDetector } from '@/features/rooms/components/SilentMicDetector'
|
||||||
import { LobbyProvider } from '@/features/rooms/components/LobbyProvider'
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @public
|
* @public
|
||||||
@@ -121,7 +120,6 @@ export function VideoConference({ ...props }: VideoConferenceProps) {
|
|||||||
<RoomSilentMicDetector />
|
<RoomSilentMicDetector />
|
||||||
<MediaStateObserver />
|
<MediaStateObserver />
|
||||||
<ChatProvider />
|
<ChatProvider />
|
||||||
<LobbyProvider />
|
|
||||||
<VideoResolutionSubscription />
|
<VideoResolutionSubscription />
|
||||||
<div
|
<div
|
||||||
className="lk-video-conference"
|
className="lk-video-conference"
|
||||||
|
|||||||
@@ -1,9 +0,0 @@
|
|||||||
export const VOICE_AUDIO_CONSTRAINTS = {
|
|
||||||
noiseSuppression: true,
|
|
||||||
echoCancellation: true,
|
|
||||||
autoGainControl: true,
|
|
||||||
voiceIsolation: false,
|
|
||||||
sampleRate: 48000,
|
|
||||||
channelCount: 1,
|
|
||||||
sampleSize: 16,
|
|
||||||
} as const
|
|
||||||
@@ -1,154 +0,0 @@
|
|||||||
export const MIN_ZOOM = 1
|
|
||||||
export const MAX_ZOOM = 4
|
|
||||||
export const ZOOM_STEP = 0.1
|
|
||||||
export const WHEEL_ZOOM_SPEED = 0.002
|
|
||||||
export const PAN_STEP = 5
|
|
||||||
|
|
||||||
// Half of a 100 % axis. Geometry, not a tunable: it is both the centre the
|
|
||||||
// cursor offset is measured from and the half extent the pan is clamped
|
|
||||||
// against, so the two stay consistent by construction.
|
|
||||||
export const HALF_EXTENT_PERCENT = 50
|
|
||||||
|
|
||||||
export interface PanOffset {
|
|
||||||
x: number
|
|
||||||
y: number
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fraction of the surface each axis of the picture covers, in [0, 1].
|
|
||||||
export interface PictureRatio {
|
|
||||||
x: number
|
|
||||||
y: number
|
|
||||||
}
|
|
||||||
|
|
||||||
export const FULL_PICTURE_RATIO: PictureRatio = { x: 1, y: 1 }
|
|
||||||
|
|
||||||
export interface ZoomSnapshot {
|
|
||||||
zoomLevel: number
|
|
||||||
zoomPercentage: number
|
|
||||||
panOffset: PanOffset
|
|
||||||
isZoomed: boolean
|
|
||||||
isDragging: boolean
|
|
||||||
canZoomIn: boolean
|
|
||||||
canZoomOut: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
export const clampZoom = (value: number) => {
|
|
||||||
return Math.max(MIN_ZOOM, Math.min(MAX_ZOOM, value))
|
|
||||||
}
|
|
||||||
|
|
||||||
// Restrict pan so the picture always covers the view. Pan is a % of the
|
|
||||||
// surface, in which object-fit: contain letterboxes the picture: its half
|
|
||||||
// extent is `ratio * 50` against a view half extent of 50, and scaling by
|
|
||||||
// `zoom` must keep `zoom * (ratio * 50 - |pan|) >= 50`. An axis whose picture
|
|
||||||
// is still smaller than the view is pinned to 0, keeping the bars symmetric.
|
|
||||||
export const clampPan = (
|
|
||||||
pan: PanOffset,
|
|
||||||
zoom: number,
|
|
||||||
ratio: PictureRatio
|
|
||||||
): PanOffset => {
|
|
||||||
const maxPanX = Math.max(0, (ratio.x - 1 / zoom) * HALF_EXTENT_PERCENT)
|
|
||||||
const maxPanY = Math.max(0, (ratio.y - 1 / zoom) * HALF_EXTENT_PERCENT)
|
|
||||||
return {
|
|
||||||
x: Math.max(-maxPanX, Math.min(maxPanX, pan.x)),
|
|
||||||
y: Math.max(-maxPanY, Math.min(maxPanY, pan.y)),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Per-axis fraction of the surface covered by an object-fit: contain picture.
|
|
||||||
export const getPictureRatio = (
|
|
||||||
surfaceWidth: number,
|
|
||||||
surfaceHeight: number,
|
|
||||||
videoWidth: number,
|
|
||||||
videoHeight: number
|
|
||||||
): PictureRatio => {
|
|
||||||
if (!surfaceWidth || !surfaceHeight || !videoWidth || !videoHeight) {
|
|
||||||
return FULL_PICTURE_RATIO
|
|
||||||
}
|
|
||||||
const surfaceRatio = surfaceWidth / surfaceHeight
|
|
||||||
const videoRatio = videoWidth / videoHeight
|
|
||||||
return surfaceRatio > videoRatio
|
|
||||||
? { x: videoRatio / surfaceRatio, y: 1 }
|
|
||||||
: { x: 1, y: surfaceRatio / videoRatio }
|
|
||||||
}
|
|
||||||
|
|
||||||
export const buildZoomSnapshot = (
|
|
||||||
zoom: number,
|
|
||||||
pan: PanOffset,
|
|
||||||
dragging: boolean
|
|
||||||
): ZoomSnapshot => {
|
|
||||||
return {
|
|
||||||
zoomLevel: zoom,
|
|
||||||
zoomPercentage: Math.round(zoom * 100),
|
|
||||||
panOffset: pan,
|
|
||||||
isZoomed: zoom > MIN_ZOOM,
|
|
||||||
isDragging: dragging,
|
|
||||||
canZoomIn: zoom < MAX_ZOOM,
|
|
||||||
canZoomOut: zoom > MIN_ZOOM,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export const getZoomTransform = (zoom: number, pan: PanOffset) => {
|
|
||||||
return `scale(${zoom}) translate(${pan.x}%, ${pan.y}%)`
|
|
||||||
}
|
|
||||||
|
|
||||||
export const getCursorFromZoomState = (zoom: number, dragging: boolean) => {
|
|
||||||
if (zoom <= MIN_ZOOM) return 'default'
|
|
||||||
return dragging ? 'grabbing' : 'grab'
|
|
||||||
}
|
|
||||||
|
|
||||||
// Keep the content point under the cursor anchored while zooming. With
|
|
||||||
// `scale(z) translate(pan%)`, a point at `offset` from the center renders at
|
|
||||||
// `z * (offset + pan)`, so holding it still gives:
|
|
||||||
// pan' = pan + cursor * (1 / zoom' - 1 / zoom).
|
|
||||||
export const getWheelPanOffset = ({
|
|
||||||
pan,
|
|
||||||
prevZoom,
|
|
||||||
nextZoom,
|
|
||||||
cursorXPercent,
|
|
||||||
cursorYPercent,
|
|
||||||
ratio,
|
|
||||||
}: {
|
|
||||||
pan: PanOffset
|
|
||||||
prevZoom: number
|
|
||||||
nextZoom: number
|
|
||||||
cursorXPercent: number
|
|
||||||
cursorYPercent: number
|
|
||||||
ratio: PictureRatio
|
|
||||||
}): PanOffset => {
|
|
||||||
const panShift = 1 / nextZoom - 1 / prevZoom
|
|
||||||
return clampPan(
|
|
||||||
{
|
|
||||||
x: pan.x + cursorXPercent * panShift,
|
|
||||||
y: pan.y + cursorYPercent * panShift,
|
|
||||||
},
|
|
||||||
nextZoom,
|
|
||||||
ratio
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Convert cursor pixel position to a % offset from the surface center.
|
|
||||||
export const getCursorPercentsFromWheelEvent = (
|
|
||||||
e: WheelEvent,
|
|
||||||
target: HTMLElement
|
|
||||||
) => {
|
|
||||||
const rect = target.getBoundingClientRect()
|
|
||||||
return {
|
|
||||||
cursorXPercent:
|
|
||||||
((e.clientX - rect.left) / rect.width) * 100 - HALF_EXTENT_PERCENT,
|
|
||||||
cursorYPercent:
|
|
||||||
((e.clientY - rect.top) / rect.height) * 100 - HALF_EXTENT_PERCENT,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Convert useMove pixel deltas to % of the surface dimensions.
|
|
||||||
export const getPanDeltaPercentsFromMove = (
|
|
||||||
deltaX: number,
|
|
||||||
deltaY: number,
|
|
||||||
surface: HTMLElement
|
|
||||||
) => {
|
|
||||||
const rect = surface.getBoundingClientRect()
|
|
||||||
return {
|
|
||||||
deltaXPercent: (deltaX / rect.width) * 100,
|
|
||||||
deltaYPercent: (deltaY / rect.height) * 100,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -32,8 +32,7 @@ const EMPTY_PROPS = {}
|
|||||||
|
|
||||||
export const VideoTab = ({ id }: VideoTabProps) => {
|
export const VideoTab = ({ id }: VideoTabProps) => {
|
||||||
const { t } = useTranslation('settings', { keyPrefix: 'video' })
|
const { t } = useTranslation('settings', { keyPrefix: 'video' })
|
||||||
const room = useRoomContext()
|
const { localParticipant, remoteParticipants } = useRoomContext()
|
||||||
const { localParticipant, remoteParticipants } = room
|
|
||||||
|
|
||||||
const {
|
const {
|
||||||
videoDeviceId,
|
videoDeviceId,
|
||||||
@@ -71,14 +70,12 @@ export const VideoTab = ({ id }: VideoTabProps) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const handleVideoResolutionChange = async (key: 'h720' | 'h360' | 'h180') => {
|
const handleVideoResolutionChange = async (key: 'h720' | 'h360' | 'h180') => {
|
||||||
saveVideoPublishResolution(key)
|
const videoPublication = localParticipant.getTrackPublication(
|
||||||
const videoTrack = localParticipant.getTrackPublication(
|
|
||||||
Track.Source.Camera
|
Track.Source.Camera
|
||||||
)?.track
|
)
|
||||||
if (!videoTrack) {
|
const videoTrack = videoPublication?.track
|
||||||
return
|
if (videoTrack) {
|
||||||
}
|
saveVideoPublishResolution(key)
|
||||||
|
|
||||||
await videoTrack.restartTrack({
|
await videoTrack.restartTrack({
|
||||||
resolution: VideoPresets[key].resolution,
|
resolution: VideoPresets[key].resolution,
|
||||||
deviceId: { exact: videoDeviceId },
|
deviceId: { exact: videoDeviceId },
|
||||||
@@ -86,6 +83,7 @@ export const VideoTab = ({ id }: VideoTabProps) => {
|
|||||||
BackgroundProcessorFactory.fromProcessorConfig(processorConfig),
|
BackgroundProcessorFactory.fromProcessorConfig(processorConfig),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Updates video quality for all existing remote video tracks when user preference changes.
|
* Updates video quality for all existing remote video tracks when user preference changes.
|
||||||
|
|||||||
@@ -16,10 +16,6 @@ export type ShortcutId =
|
|||||||
| 'recording'
|
| 'recording'
|
||||||
| 'reaction'
|
| 'reaction'
|
||||||
| 'fullscreen'
|
| 'fullscreen'
|
||||||
| 'zoom-in'
|
|
||||||
| 'zoom-out'
|
|
||||||
| 'zoom-reset'
|
|
||||||
| 'zoom-pan'
|
|
||||||
|
|
||||||
export const getShortcutDescriptorById = (id: ShortcutId) =>
|
export const getShortcutDescriptorById = (id: ShortcutId) =>
|
||||||
shortcutCatalog.find((item) => item.id === id)
|
shortcutCatalog.find((item) => item.id === id)
|
||||||
@@ -28,7 +24,7 @@ export type ShortcutDescriptor = {
|
|||||||
id: ShortcutId
|
id: ShortcutId
|
||||||
category: ShortcutCategory
|
category: ShortcutCategory
|
||||||
shortcut?: Shortcut
|
shortcut?: Shortcut
|
||||||
kind?: 'press' | 'longPress' | 'arrows'
|
kind?: 'press' | 'longPress'
|
||||||
code?: string // used when kind === 'longPress' (KeyboardEvent.code)
|
code?: string // used when kind === 'longPress' (KeyboardEvent.code)
|
||||||
description?: string
|
description?: string
|
||||||
}
|
}
|
||||||
@@ -90,27 +86,4 @@ export const shortcutCatalog: ShortcutDescriptor[] = [
|
|||||||
category: 'interaction',
|
category: 'interaction',
|
||||||
shortcut: { key: 'P', ctrlKey: true, shiftKey: true },
|
shortcut: { key: 'P', ctrlKey: true, shiftKey: true },
|
||||||
},
|
},
|
||||||
// Screen share zoom keys are unmodified, so they are bound on the focused
|
|
||||||
// tile instead of being registered globally. They are listed here so the
|
|
||||||
// shortcuts panel stays exhaustive.
|
|
||||||
{
|
|
||||||
id: 'zoom-in',
|
|
||||||
category: 'interaction',
|
|
||||||
shortcut: { key: '+' },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'zoom-out',
|
|
||||||
category: 'interaction',
|
|
||||||
shortcut: { key: '-' },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'zoom-reset',
|
|
||||||
category: 'interaction',
|
|
||||||
shortcut: { key: '0' },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'zoom-pan',
|
|
||||||
category: 'interaction',
|
|
||||||
kind: 'arrows',
|
|
||||||
},
|
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -25,7 +25,6 @@ export const formatShortcutLabelForSR = (
|
|||||||
shiftLabel,
|
shiftLabel,
|
||||||
plusLabel,
|
plusLabel,
|
||||||
noShortcutLabel,
|
noShortcutLabel,
|
||||||
keyLabels,
|
|
||||||
}: {
|
}: {
|
||||||
controlLabel: string
|
controlLabel: string
|
||||||
commandLabel: string
|
commandLabel: string
|
||||||
@@ -34,12 +33,10 @@ export const formatShortcutLabelForSR = (
|
|||||||
shiftLabel: string
|
shiftLabel: string
|
||||||
plusLabel: string
|
plusLabel: string
|
||||||
noShortcutLabel: string
|
noShortcutLabel: string
|
||||||
// Spelled-out names for keys screen readers may skip or mispronounce.
|
|
||||||
keyLabels?: Record<string, string>
|
|
||||||
}
|
}
|
||||||
) => {
|
) => {
|
||||||
if (!shortcut) return noShortcutLabel
|
if (!shortcut) return noShortcutLabel
|
||||||
const key = keyLabels?.[shortcut.key] ?? shortcut.key?.toUpperCase()
|
const key = shortcut.key?.toUpperCase()
|
||||||
if (!key) return noShortcutLabel
|
if (!key) return noShortcutLabel
|
||||||
const ctrlWord = isMacintosh() ? commandLabel : controlLabel
|
const ctrlWord = isMacintosh() ? commandLabel : controlLabel
|
||||||
const altWord = isMacintosh() ? optionLabel : altLabel
|
const altWord = isMacintosh() ? optionLabel : altLabel
|
||||||
|
|||||||
@@ -12,9 +12,6 @@ export const useShortcutFormatting = () => {
|
|||||||
|
|
||||||
const formatVisual = useCallback(
|
const formatVisual = useCallback(
|
||||||
(shortcut?: Shortcut, code?: string, kind?: string) => {
|
(shortcut?: Shortcut, code?: string, kind?: string) => {
|
||||||
if (kind === 'arrows') {
|
|
||||||
return t('shortcutsPanel.visual.arrows')
|
|
||||||
}
|
|
||||||
if (code && kind === 'longPress') {
|
if (code && kind === 'longPress') {
|
||||||
const label = getKeyLabelFromCode(code)
|
const label = getKeyLabelFromCode(code)
|
||||||
return t('shortcutsPanel.visual.hold', { key: label || '?' })
|
return t('shortcutsPanel.visual.hold', { key: label || '?' })
|
||||||
@@ -26,9 +23,6 @@ export const useShortcutFormatting = () => {
|
|||||||
|
|
||||||
const formatForSR = useCallback(
|
const formatForSR = useCallback(
|
||||||
(shortcut?: Shortcut, code?: string, kind?: string) => {
|
(shortcut?: Shortcut, code?: string, kind?: string) => {
|
||||||
if (kind === 'arrows') {
|
|
||||||
return t('shortcutsPanel.sr.arrows')
|
|
||||||
}
|
|
||||||
if (code && kind === 'longPress') {
|
if (code && kind === 'longPress') {
|
||||||
const label = getKeyLabelFromCode(code)
|
const label = getKeyLabelFromCode(code)
|
||||||
return t('shortcutsPanel.sr.hold', { key: label || '?' })
|
return t('shortcutsPanel.sr.hold', { key: label || '?' })
|
||||||
@@ -41,10 +35,6 @@ export const useShortcutFormatting = () => {
|
|||||||
shiftLabel: t('shortcutsPanel.sr.shift'),
|
shiftLabel: t('shortcutsPanel.sr.shift'),
|
||||||
plusLabel: t('shortcutsPanel.sr.plus'),
|
plusLabel: t('shortcutsPanel.sr.plus'),
|
||||||
noShortcutLabel: t('shortcutsPanel.sr.noShortcut'),
|
noShortcutLabel: t('shortcutsPanel.sr.noShortcut'),
|
||||||
keyLabels: {
|
|
||||||
'+': t('shortcutsPanel.sr.plusKey'),
|
|
||||||
'-': t('shortcutsPanel.sr.minusKey'),
|
|
||||||
},
|
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
[t]
|
[t]
|
||||||
|
|||||||
@@ -75,15 +75,15 @@ const useTranscriptionState = () => {
|
|||||||
const segment = segments[0]
|
const segment = segments[0]
|
||||||
|
|
||||||
setTranscriptionSegments((prevSegments) => {
|
setTranscriptionSegments((prevSegments) => {
|
||||||
const existingIndex = prevSegments.findIndex(
|
const existingSegmentIds = new Set(prevSegments.map((s) => s.id))
|
||||||
(s: TranscriptionSegmentWithParticipant) => s.id === segment.id
|
if (existingSegmentIds.has(segment.id)) return prevSegments
|
||||||
)
|
return [
|
||||||
if (existingIndex === -1) {
|
...prevSegments,
|
||||||
return [...prevSegments, { participant, ...segment }]
|
{
|
||||||
}
|
participant: participant,
|
||||||
const next = prevSegments.slice()
|
...segment,
|
||||||
next[existingIndex] = { ...next[existingIndex], ...segment }
|
},
|
||||||
return next
|
]
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,26 +0,0 @@
|
|||||||
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(initReactI18next)
|
||||||
.use(LanguageDetector)
|
.use(LanguageDetector)
|
||||||
.init({
|
.init({
|
||||||
supportedLngs: ['en', 'fr', 'nl', 'de', 'es'],
|
supportedLngs: ['en', 'fr', 'nl', 'de'],
|
||||||
fallbackLng,
|
fallbackLng,
|
||||||
ns: i18nDefaultNamespace,
|
ns: i18nDefaultNamespace,
|
||||||
detection: {
|
detection: {
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ const languageLabels: Record<string, string> = {
|
|||||||
fr: 'Français',
|
fr: 'Français',
|
||||||
de: 'Deutsch',
|
de: 'Deutsch',
|
||||||
nl: 'Nederlands',
|
nl: 'Nederlands',
|
||||||
es: 'Español',
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const useLanguageLabels = () => {
|
export const useLanguageLabels = () => {
|
||||||
|
|||||||
@@ -435,8 +435,7 @@
|
|||||||
"tools": "Weitere Tools",
|
"tools": "Weitere Tools",
|
||||||
"info": "Meeting-Informationen"
|
"info": "Meeting-Informationen"
|
||||||
},
|
},
|
||||||
"closeButton": "{{content}} ausblenden (Escape)",
|
"closeButton": "{{content}} ausblenden"
|
||||||
"escapeHint": "Escape drücken zum Schließen"
|
|
||||||
},
|
},
|
||||||
"chat": {
|
"chat": {
|
||||||
"disclaimer": "Die Nachrichten sind nur für Teilnehmende zum Zeitpunkt des Sendens sichtbar. Alle Nachrichten werden am Ende des Meetings gelöscht.",
|
"disclaimer": "Die Nachrichten sind nur für Teilnehmende zum Zeitpunkt des Sendens sichtbar. Alle Nachrichten werden am Ende des Meetings gelöscht.",
|
||||||
@@ -756,24 +755,6 @@
|
|||||||
"muteParticipant": "{{name}} stummschalten",
|
"muteParticipant": "{{name}} stummschalten",
|
||||||
"fullScreen": "Vollbild"
|
"fullScreen": "Vollbild"
|
||||||
},
|
},
|
||||||
"screenShareZoom": {
|
|
||||||
"toolbarLabel": "Zoom-Steuerung für Bildschirmfreigabe",
|
|
||||||
"zoomIn": "Vergrößern",
|
|
||||||
"zoomInWithShortcut": "Vergrößern ({{shortcut}})",
|
|
||||||
"zoomOut": "Verkleinern",
|
|
||||||
"zoomOutWithShortcut": "Verkleinern ({{shortcut}})",
|
|
||||||
"wheelShortcut": "Steuerung plus Mausrad",
|
|
||||||
"wheelShortcutMac": "Befehl plus Mausrad",
|
|
||||||
"wheelShortcutHint": "Tastenkürzel: Steuerung plus Mausrad zum Vergrößern oder Verkleinern.",
|
|
||||||
"wheelShortcutHintMac": "Tastenkürzel: Befehl plus Mausrad zum Vergrößern oder Verkleinern.",
|
|
||||||
"fitToWindow": "An Fenster anpassen",
|
|
||||||
"fullScreen": "Vollbild",
|
|
||||||
"exitFullScreen": "Vollbild beenden",
|
|
||||||
"currentZoomLevel": "Zoom {{level}} %",
|
|
||||||
"panHint": "Zoom {{level}} %. Mit der Maus ziehen oder den Fokus zurück auf die Bildschirmfreigabe setzen und mit den Pfeiltasten im Bild navigieren.",
|
|
||||||
"fullScreenEntered": "Vollbild aktiviert",
|
|
||||||
"fullScreenExited": "Vollbild deaktiviert"
|
|
||||||
},
|
|
||||||
"shortcutsPanel": {
|
"shortcutsPanel": {
|
||||||
"title": "Tastenkürzel",
|
"title": "Tastenkürzel",
|
||||||
"categories": {
|
"categories": {
|
||||||
@@ -793,11 +774,7 @@
|
|||||||
"raise-hand": "Hand heben oder senken",
|
"raise-hand": "Hand heben oder senken",
|
||||||
"toggle-chat": "Chat anzeigen/ausblenden",
|
"toggle-chat": "Chat anzeigen/ausblenden",
|
||||||
"toggle-participants": "Teilnehmende anzeigen/ausblenden",
|
"toggle-participants": "Teilnehmende anzeigen/ausblenden",
|
||||||
"open-shortcuts-settings": "Tastenkürzel-Einstellungen öffnen",
|
"open-shortcuts-settings": "Tastenkürzel-Einstellungen öffnen"
|
||||||
"zoom-in": "In einen geteilten Bildschirm hineinzoomen",
|
|
||||||
"zoom-out": "Aus einem geteilten Bildschirm herauszoomen",
|
|
||||||
"zoom-reset": "Zoom des geteilten Bildschirms zurücksetzen",
|
|
||||||
"zoom-pan": "Im gezoomten geteilten Bildschirm navigieren"
|
|
||||||
},
|
},
|
||||||
"sr": {
|
"sr": {
|
||||||
"control": "Steuerung",
|
"control": "Steuerung",
|
||||||
@@ -807,14 +784,10 @@
|
|||||||
"shift": "Umschalt",
|
"shift": "Umschalt",
|
||||||
"plus": "plus",
|
"plus": "plus",
|
||||||
"hold": "Halte {{key}} gedrückt",
|
"hold": "Halte {{key}} gedrückt",
|
||||||
"arrows": "Pfeiltasten",
|
|
||||||
"plusKey": "Plus-Taste",
|
|
||||||
"minusKey": "Minus-Taste",
|
|
||||||
"noShortcut": "Kein Tastenkürzel"
|
"noShortcut": "Kein Tastenkürzel"
|
||||||
},
|
},
|
||||||
"visual": {
|
"visual": {
|
||||||
"hold": "Halte {{key}} gedrückt",
|
"hold": "Halte {{key}} gedrückt"
|
||||||
"arrows": "↑ ↓ ← →"
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"fullScreenWarning": {
|
"fullScreenWarning": {
|
||||||
|
|||||||
@@ -435,8 +435,7 @@
|
|||||||
"tools": "more tools",
|
"tools": "more tools",
|
||||||
"info": "meeting information"
|
"info": "meeting information"
|
||||||
},
|
},
|
||||||
"closeButton": "Hide {{content}} (Escape)",
|
"closeButton": "Hide {{content}}"
|
||||||
"escapeHint": "Press Escape to close"
|
|
||||||
},
|
},
|
||||||
"chat": {
|
"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.",
|
"disclaimer": "The messages are visible to participants only at the time they are sent. All messages are deleted at the end of the call.",
|
||||||
@@ -756,24 +755,6 @@
|
|||||||
"muteParticipant": "Mute {{name}}",
|
"muteParticipant": "Mute {{name}}",
|
||||||
"fullScreen": "Full screen"
|
"fullScreen": "Full screen"
|
||||||
},
|
},
|
||||||
"screenShareZoom": {
|
|
||||||
"toolbarLabel": "Screen share zoom controls",
|
|
||||||
"zoomIn": "Zoom in",
|
|
||||||
"zoomInWithShortcut": "Zoom in ({{shortcut}})",
|
|
||||||
"zoomOut": "Zoom out",
|
|
||||||
"zoomOutWithShortcut": "Zoom out ({{shortcut}})",
|
|
||||||
"wheelShortcut": "Control plus scroll wheel",
|
|
||||||
"wheelShortcutMac": "Command plus scroll wheel",
|
|
||||||
"wheelShortcutHint": "Shortcut: Control plus scroll wheel to zoom in or out.",
|
|
||||||
"wheelShortcutHintMac": "Shortcut: Command plus scroll wheel to zoom in or out.",
|
|
||||||
"fitToWindow": "Fit to window",
|
|
||||||
"fullScreen": "Full screen",
|
|
||||||
"exitFullScreen": "Exit full screen",
|
|
||||||
"currentZoomLevel": "Zoom {{level}} %",
|
|
||||||
"panHint": "Zoom {{level}} %. Drag with mouse, or move focus back to the screen share and use arrow keys to navigate the picture.",
|
|
||||||
"fullScreenEntered": "Full screen enabled",
|
|
||||||
"fullScreenExited": "Full screen disabled"
|
|
||||||
},
|
|
||||||
"shortcutsPanel": {
|
"shortcutsPanel": {
|
||||||
"title": "Keyboard shortcuts",
|
"title": "Keyboard shortcuts",
|
||||||
"categories": {
|
"categories": {
|
||||||
@@ -793,11 +774,7 @@
|
|||||||
"raise-hand": "Raise or lower hand",
|
"raise-hand": "Raise or lower hand",
|
||||||
"toggle-chat": "Toggle chat",
|
"toggle-chat": "Toggle chat",
|
||||||
"toggle-participants": "Toggle participants",
|
"toggle-participants": "Toggle participants",
|
||||||
"open-shortcuts-settings": "Open shortcuts settings",
|
"open-shortcuts-settings": "Open shortcuts settings"
|
||||||
"zoom-in": "Zoom in on a shared screen",
|
|
||||||
"zoom-out": "Zoom out on a shared screen",
|
|
||||||
"zoom-reset": "Reset the shared screen zoom",
|
|
||||||
"zoom-pan": "Move around a zoomed shared screen"
|
|
||||||
},
|
},
|
||||||
"sr": {
|
"sr": {
|
||||||
"control": "Control",
|
"control": "Control",
|
||||||
@@ -807,14 +784,10 @@
|
|||||||
"shift": "Shift",
|
"shift": "Shift",
|
||||||
"plus": "plus",
|
"plus": "plus",
|
||||||
"hold": "Hold {{key}}",
|
"hold": "Hold {{key}}",
|
||||||
"arrows": "Arrow keys",
|
|
||||||
"plusKey": "Plus key",
|
|
||||||
"minusKey": "Minus key",
|
|
||||||
"noShortcut": "No shortcut"
|
"noShortcut": "No shortcut"
|
||||||
},
|
},
|
||||||
"visual": {
|
"visual": {
|
||||||
"hold": "Hold {{key}}",
|
"hold": "Hold {{key}}"
|
||||||
"arrows": "↑ ↓ ← →"
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"fullScreenWarning": {
|
"fullScreenWarning": {
|
||||||
|
|||||||
@@ -1,34 +0,0 @@
|
|||||||
{
|
|
||||||
"accessibility": {
|
|
||||||
"title": "Accesibilidad",
|
|
||||||
"introduction": "<i>Visio</i> se compromete a hacer accesibles sus servicios digitales, de conformidad con el artículo 47 de la ley n.º 2005-102 de 11 de febrero de 2005.",
|
|
||||||
"declaration": {
|
|
||||||
"title": "Declaración de accesibilidad",
|
|
||||||
"date": "Redactada el 4 de diciembre de 2024."
|
|
||||||
},
|
|
||||||
"scope": "Esta declaración de accesibilidad se aplica al sitio visio.numerique.gouv.fr",
|
|
||||||
"complianceStatus": {
|
|
||||||
"title": "Estado de conformidad",
|
|
||||||
"body": "visio.numerique.gouv.fr no es conforme con el RGAA 4.1. El sitio todavía no ha sido auditado. No obstante, el equipo trabaja para crear un sitio accesible para todo el mundo siguiendo las recomendaciones del RGAA."
|
|
||||||
},
|
|
||||||
"improvement": {
|
|
||||||
"title": "Mejora y contacto",
|
|
||||||
"body": "Si no consigues acceder a un contenido o a un servicio, puedes ponerte en contacto con el responsable de lasuite.numerique.gouv.fr para que te oriente hacia una alternativa accesible u obtener el contenido en otro formato.",
|
|
||||||
"contact": {
|
|
||||||
"email": "Correo electrónico: visio@numerique.gouv.fr",
|
|
||||||
"address": "Dirección: DINUM, 20 avenue de Ségur 75007 París"
|
|
||||||
},
|
|
||||||
"response": "Intentamos responder en un plazo de 2 días laborales."
|
|
||||||
},
|
|
||||||
"recourse": {
|
|
||||||
"title": "Vía de recurso",
|
|
||||||
"introduction": "Este procedimiento debe utilizarse en el siguiente caso: has comunicado al responsable del sitio web un defecto de accesibilidad que te impide acceder a un contenido o a uno de los servicios del portal y no has obtenido una respuesta satisfactoria.",
|
|
||||||
"options": {
|
|
||||||
"intro": "Puedes:",
|
|
||||||
"option1": "Escribir un mensaje al Defensor de Derechos",
|
|
||||||
"option2": "Ponerte en contacto con el delegado del Defensor de Derechos de tu región",
|
|
||||||
"option3": "Enviar una carta por correo postal (gratuito, sin sello): </br> Défenseur des droits Libre réponse 71120 75342 Paris CEDEX 07"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,56 +0,0 @@
|
|||||||
{
|
|
||||||
"title": "Prueba tu configuración",
|
|
||||||
"runTest": "Iniciar la prueba",
|
|
||||||
"runAgain": "Repetir",
|
|
||||||
"cancel": "Cancelar",
|
|
||||||
"detailsFor": "Detalles del paso {{step}}",
|
|
||||||
"downloadReport": "Descargar el informe",
|
|
||||||
"homeLink": "Prueba tu configuración",
|
|
||||||
"progress": "{{done}}/{{total}}",
|
|
||||||
"progressLabel": "Progreso de la prueba de conexión",
|
|
||||||
"groups": {
|
|
||||||
"local": "Navegador y dispositivos",
|
|
||||||
"network": "Conexión al servidor"
|
|
||||||
},
|
|
||||||
"steps": {
|
|
||||||
"browser": "Navegador",
|
|
||||||
"microphone": "Micrófono",
|
|
||||||
"camera": "Cámara",
|
|
||||||
"devices": "Dispositivos multimedia",
|
|
||||||
"websocket": "WebSocket",
|
|
||||||
"webrtc": "WebRTC",
|
|
||||||
"turn": "TURN",
|
|
||||||
"reconnect": "Reconexión",
|
|
||||||
"selectedCandidate": "Ruta seleccionada",
|
|
||||||
"publishAudio": "Publicación de audio",
|
|
||||||
"publishVideo": "Publicación de vídeo"
|
|
||||||
},
|
|
||||||
"status": {
|
|
||||||
"pending": "En espera",
|
|
||||||
"running": "En curso…",
|
|
||||||
"success": "Correcto",
|
|
||||||
"failed": "Error",
|
|
||||||
"skipped": "Omitido"
|
|
||||||
},
|
|
||||||
"counts": {
|
|
||||||
"passed": "correctas",
|
|
||||||
"failed": "con errores",
|
|
||||||
"skipped": "omitidas"
|
|
||||||
},
|
|
||||||
"summary": {
|
|
||||||
"idle": "Listo para la prueba",
|
|
||||||
"idleHint": "La prueba dura aproximadamente un minuto. Tu cámara y tu micrófono solo se utilizan durante la prueba.",
|
|
||||||
"running": "Prueba en curso…",
|
|
||||||
"runningHint": "Mantén esta página abierta hasta que terminen las comprobaciones.",
|
|
||||||
"passed": "Todo funciona",
|
|
||||||
"passedHint": "Tu navegador, tus dispositivos y tu red están listos para una reunión.",
|
|
||||||
"partial": "Prueba parcial",
|
|
||||||
"partialHint": "Se han omitido algunas comprobaciones. Autoriza el acceso a tu cámara y a tu micrófono para probarlos.",
|
|
||||||
"failed_one": "{{count}} verificación en error",
|
|
||||||
"failed_other": "{{count}} verificaciones en error",
|
|
||||||
"failedHint": "Abre las verificaciones en error para ver el detalle y transmite después el informe a tu servicio informático."
|
|
||||||
},
|
|
||||||
"help": {
|
|
||||||
"firewall": "Si las pruebas de red fallan, comprueba los permisos de tu navegador y las reglas de filtrado de red (WebRTC, WebSocket, TURN) con tu servicio informático."
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,62 +0,0 @@
|
|||||||
{
|
|
||||||
"backToHome": "Volver al inicio",
|
|
||||||
"cancel": "Cancelar",
|
|
||||||
"closeDialog": "Cerrar la ventana modal",
|
|
||||||
"error": {
|
|
||||||
"heading": "Se ha producido un error al cargar la página"
|
|
||||||
},
|
|
||||||
"feedback": {
|
|
||||||
"context": "¡Tu opinión es fundamental!",
|
|
||||||
"cta": "Comparte tu opinión - nueva ventana"
|
|
||||||
},
|
|
||||||
"forbidden": {
|
|
||||||
"heading": "Acceso denegado"
|
|
||||||
},
|
|
||||||
"loading": "Cargando…",
|
|
||||||
"loggedInUserTooltip": "Con la sesión iniciada como ",
|
|
||||||
"login": {
|
|
||||||
"buttonLabel": "Iniciar sesión",
|
|
||||||
"proconnectButtonLabel": "Identificarse con ProConnect",
|
|
||||||
"proconnectLinkLabel": "¿Qué es ProConnect? - nueva ventana",
|
|
||||||
"proconnectLink": "¿Qué es ProConnect?"
|
|
||||||
},
|
|
||||||
"logout": "Cerrar sesión",
|
|
||||||
"notFound": {
|
|
||||||
"heading": "Comprueba tu código de reunión",
|
|
||||||
"body": "Comprueba que has introducido el código de reunión correcto en la URL. Ejemplo:"
|
|
||||||
},
|
|
||||||
"selected": "seleccionado",
|
|
||||||
"submit": "Aceptar",
|
|
||||||
"footer": {
|
|
||||||
"links": {
|
|
||||||
"legifrance": "legifrance.gouv.fr",
|
|
||||||
"infogouv": "info.gouv.fr",
|
|
||||||
"servicepublic": "service-public.fr",
|
|
||||||
"datagouv": "data.gouv.fr",
|
|
||||||
"legalsTerms": "Aviso legal",
|
|
||||||
"data": "Datos personales y cookies",
|
|
||||||
"accessibility": "Accesibilidad: no conforme",
|
|
||||||
"connectionTest": "Prueba tu configuración",
|
|
||||||
"ariaLabel": "nueva ventana",
|
|
||||||
"codeAnnotation": "Nuestro código es abierto y está disponible en este",
|
|
||||||
"code": "repositorio de código abierto",
|
|
||||||
"technicalDetails": "Ficha técnica",
|
|
||||||
"termsOfService": "Condiciones de uso"
|
|
||||||
},
|
|
||||||
"mentions": "Salvo que se indique lo contrario, los contenidos de este sitio están disponibles bajo",
|
|
||||||
"license": "licencia etalab 2.0"
|
|
||||||
},
|
|
||||||
"loginHint": {
|
|
||||||
"title": "Inicia sesión con tu cuenta",
|
|
||||||
"body": "En lugar de esperar, inicia sesión con tu cuenta.",
|
|
||||||
"button": {
|
|
||||||
"ariaLabel": "Cerrar la sugerencia",
|
|
||||||
"label": "Aceptar"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"skipLink": "Ir al contenido principal",
|
|
||||||
"clipboardContent": {
|
|
||||||
"url": "Para participar en la videoconferencia, haz clic en este enlace: {{roomUrl}}",
|
|
||||||
"numberAndPin": "Para participar por teléfono, marca el {{phoneNumber}} e introduce este código: {{pinCode}}"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,63 +0,0 @@
|
|||||||
{
|
|
||||||
"createMeeting": "Crear una reunión",
|
|
||||||
"heading": "Videoconferencias sencillas y seguras",
|
|
||||||
"intro": "Comunícate y trabaja con facilidad, manteniendo siempre el control de tus datos",
|
|
||||||
"joinInputError": "Introduce un enlace o un código de reunión. Ejemplos:",
|
|
||||||
"joinInputExample": "Un código de reunión tiene este aspecto: abc-defg-hij",
|
|
||||||
"joinInputLabel": "Enlace completo o código de la reunión",
|
|
||||||
"joinInputSubmit": "Unirse a la reunión",
|
|
||||||
"joinMeeting": "Unirse a una reunión",
|
|
||||||
"joinMeetingTipContent": "Puedes unirte a una reunión copiando directamente su enlace completo en la barra de direcciones del navegador.",
|
|
||||||
"joinMeetingTipHeading": "Consejo",
|
|
||||||
"loginToCreateMeeting": "Inicia sesión para crear una reunión",
|
|
||||||
"moreLinkLabel": "Más información sobre {{appTitle}} - nueva ventana",
|
|
||||||
"moreLink": "Más información",
|
|
||||||
"moreAbout": "sobre {{appTitle}}",
|
|
||||||
"connectionTestLink": "Prueba tu configuración",
|
|
||||||
"createMenu": {
|
|
||||||
"laterOption": "Crear una reunión para más tarde",
|
|
||||||
"instantOption": "Iniciar una reunión ahora"
|
|
||||||
},
|
|
||||||
"laterMeetingDialog": {
|
|
||||||
"heading": "Tus datos de conexión",
|
|
||||||
"description": "Comparte estos datos con los invitados. Podrán unirse a la reunión sin necesidad de iniciar sesión. Esta reunión es permanente y puede reutilizarse.",
|
|
||||||
"permissions": "Las personas que dispongan de este enlace no necesitan tu autorización para unirse a esta reunión.",
|
|
||||||
"copy": "Copiar los datos",
|
|
||||||
"copied": "Copiados en el portapapeles",
|
|
||||||
"copyUrl": "Copiar el enlace de la reunión",
|
|
||||||
"phone": {
|
|
||||||
"call": "Llama al:",
|
|
||||||
"pinCode": "Código:"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"introSlider": {
|
|
||||||
"carouselLabel": "Presentación de diapositivas",
|
|
||||||
"previous": {
|
|
||||||
"label": "Diapositiva anterior",
|
|
||||||
"labelWithPosition": "Diapositiva anterior ({{current}} de {{total}})",
|
|
||||||
"tooltip": "Diapositiva anterior"
|
|
||||||
},
|
|
||||||
"next": {
|
|
||||||
"label": "Diapositiva siguiente",
|
|
||||||
"labelWithPosition": "Diapositiva siguiente ({{current}} de {{total}})",
|
|
||||||
"tooltip": "Diapositiva siguiente"
|
|
||||||
},
|
|
||||||
"slidePosition": "Diapositiva {{current}} de {{total}}",
|
|
||||||
"beta": {
|
|
||||||
"text": "Probar la beta",
|
|
||||||
"tooltip": "Acceder al formulario"
|
|
||||||
},
|
|
||||||
"slide1": {
|
|
||||||
"title": "Pásate a la sencillez. ¡Pruébanos ahora mismo!",
|
|
||||||
"body": "Descubre una solución intuitiva y accesible, pensada para el sector público y todos sus colaboradores."
|
|
||||||
},
|
|
||||||
"slide2": {
|
|
||||||
"title": "Organiza llamadas de grupo sin límite",
|
|
||||||
"body": "Reuniones sin límite de tiempo, con una comunicación fluida y de alta calidad, sea cual sea el número de participantes."
|
|
||||||
},
|
|
||||||
"slide3": {
|
|
||||||
"title": "Transforma tus reuniones con la IA",
|
|
||||||
"body": "Obtén transcripciones precisas y accionables para impulsar tu productividad. Función experimental, ¡pruébala ahora!"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,37 +0,0 @@
|
|||||||
{
|
|
||||||
"title": "Aviso legal",
|
|
||||||
"creator": {
|
|
||||||
"title": "Editor",
|
|
||||||
"body": "El servicio Visio está editado por la Dirección Interministerial de lo Digital del Estado (DINUM), con domicilio en:",
|
|
||||||
"contact": {
|
|
||||||
"title": "Datos de contacto",
|
|
||||||
"address": "20 avenue de Ségur",
|
|
||||||
"city": "75007 París",
|
|
||||||
"phone": "Tel. Recepción: 01.71.21.01.70",
|
|
||||||
"siret": "SIRET: 12000101100010 (secretaría general del gobierno)",
|
|
||||||
"siren": "SIREN: 120 001 011"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"director": {
|
|
||||||
"title": "Director de la publicación",
|
|
||||||
"body": "La directora de la publicación es la señora Stéphanie Schaer, directora interministerial de lo digital."
|
|
||||||
},
|
|
||||||
"hosting": {
|
|
||||||
"title": "Alojamiento",
|
|
||||||
"body": "El servicio está alojado por Outscale West-1 CloudGouv SecNumCloud, situado en Francia."
|
|
||||||
},
|
|
||||||
"accessibility": {
|
|
||||||
"title": "Accesibilidad",
|
|
||||||
"body": "La conformidad con las normas de accesibilidad digital es un objetivo posterior. El sitio todavía no ha sido auditado. No obstante, el equipo trabaja para crear un sitio accesible para todo el mundo siguiendo las recomendaciones del RGAA.",
|
|
||||||
"more": "Para saber más: ",
|
|
||||||
"link": "enlace a la página de accesibilidad",
|
|
||||||
"status": "Estado de accesibilidad: no conforme"
|
|
||||||
},
|
|
||||||
"reuse": {
|
|
||||||
"title": "Reutilización de los contenidos y enlaces",
|
|
||||||
"body1": "Salvo mención explícita de propiedad intelectual perteneciente a terceros, los contenidos de este sitio se ofrecen bajo ",
|
|
||||||
"license": "licencia abierta Etalab 2.0",
|
|
||||||
"body2": "En particular, eres libre de reproducirlos, copiarlos, modificarlos, extraerlos, transformarlos, comunicarlos, difundirlos, redistribuirlos, publicarlos, transmitirlos y explotarlos siempre que menciones su fuente y su fecha de última actualización, y no induzcas a error a terceros sobre la información que contienen.",
|
|
||||||
"body3": "Todo sitio público o privado está autorizado a establecer, sin autorización previa, un enlace (incluido un enlace profundo) hacia la información difundida en este sitio."
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,66 +0,0 @@
|
|||||||
{
|
|
||||||
"defaultName": "Un colaborador",
|
|
||||||
"joined": {
|
|
||||||
"description": "{{name}} participa en la llamada"
|
|
||||||
},
|
|
||||||
"raised": {
|
|
||||||
"description": "{{name}} ha levantado la mano.",
|
|
||||||
"cta": "Abrir la lista de espera"
|
|
||||||
},
|
|
||||||
"muted": "{{name}} ha silenciado tu micrófono. Ningún participante puede oírte.",
|
|
||||||
"openChat": "Abrir el chat",
|
|
||||||
"chatMessageReceived": "{{name}} dice: {{message}}",
|
|
||||||
"lowerHand": {
|
|
||||||
"auto": "Parece que has tomado la palabra, así que se va a bajar la mano.",
|
|
||||||
"dismiss": "Dejar la mano levantada"
|
|
||||||
},
|
|
||||||
"autoMuteLargeRoom": {
|
|
||||||
"auto": "Acabas de unirte a una reunión numerosa. Tu micrófono se ha silenciado automáticamente.",
|
|
||||||
"dismiss": "Reactivar el micrófono"
|
|
||||||
},
|
|
||||||
"reaction": {
|
|
||||||
"description": "{{name}} ha reaccionado con {{emoji}}"
|
|
||||||
},
|
|
||||||
"permissionsRemoved": {
|
|
||||||
"camera": "El organizador ha desactivado el vídeo de todos los participantes.",
|
|
||||||
"microphone": "El organizador ha desactivado el micrófono de todos los participantes.",
|
|
||||||
"screen_share": "El organizador ha desactivado la pantalla compartida de todos los participantes."
|
|
||||||
},
|
|
||||||
"waitingParticipants": {
|
|
||||||
"one": "Una persona quiere participar en esta llamada.",
|
|
||||||
"several": "Varias personas quieren participar en esta llamada.",
|
|
||||||
"open": "Mostrar",
|
|
||||||
"accept": "Aceptar"
|
|
||||||
},
|
|
||||||
"transcript": {
|
|
||||||
"started": "{{name}} ha iniciado la transcripción de la reunión.",
|
|
||||||
"stopped": "{{name}} ha detenido la transcripción de la reunión.",
|
|
||||||
"limitReached": "La transcripción ha superado la duración máxima permitida, se va a guardar automáticamente.",
|
|
||||||
"requested": "{{name}} ha solicitado iniciar la transcripción."
|
|
||||||
},
|
|
||||||
"screenRecording": {
|
|
||||||
"started": "{{name}} ha iniciado la grabación de la reunión.",
|
|
||||||
"stopped": "{{name}} ha detenido la grabación de la reunión.",
|
|
||||||
"limitReached": "La grabación ha superado la duración máxima permitida, se va a guardar automáticamente.",
|
|
||||||
"requested": "{{name}} ha solicitado iniciar la grabación."
|
|
||||||
},
|
|
||||||
"recordingSave": {
|
|
||||||
"transcript": {
|
|
||||||
"message": "¡Estamos finalizando tu grabación! Recibirás un correo electrónico en <strong>{{email}}</strong> en cuanto la transcripción esté lista.",
|
|
||||||
"default": "¡Estamos finalizando tu grabación! Recibirás un correo electrónico en cuanto la transcripción esté lista."
|
|
||||||
},
|
|
||||||
"screenRecording": {
|
|
||||||
"message": "¡Estamos finalizando tu grabación! Recibirás un correo electrónico en <strong>{{email}}</strong> en cuanto esté lista.",
|
|
||||||
"default": "¡Estamos finalizando tu grabación! Recibirás un correo electrónico en cuanto esté lista."
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"roleChanged": {
|
|
||||||
"body": "Tu rol ha cambiado, ahora eres {{role}}.",
|
|
||||||
"roles": {
|
|
||||||
"owner": "Organizador",
|
|
||||||
"administrator": "Coorganizador",
|
|
||||||
"member": "Miembro"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"openMenu": "Abrir el menú"
|
|
||||||
}
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user