Compare commits

..

2 Commits

Author SHA1 Message Date
Mohamed BEN HAMMOUDA 4759fd97ff ✨(frontend) switch frontend proxy to Caddy 2026-09-23 09:03:17 +02:00
Mohamed BEN HAMMOUDA 1aeb5141b4 ✨(backend) add OpenShift-compatible recording download endpoint
Implement a Django-based download endpoint that streams recording files
2026-09-22 09:21:06 +02:00
92 changed files with 756 additions and 3018 deletions
+34 -34
View File
@@ -18,7 +18,7 @@ jobs:
contents: read
steps:
- name: Checkout repository
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0
with:
fetch-depth: 0
- name: show
@@ -33,7 +33,7 @@ jobs:
! git log | grep 'fixup!'
- name: Install uv
if: always()
uses: astral-sh/setup-uv@bec219d24cd3e171d82865faccec33120bb574f4 # v10.1.0
uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7.6.0
- name: Lint commit messages added to main
if: always()
run: uvx --no-build --from gitlint-core==0.19.1 gitlint --commits origin/${{ github.event.pull_request.base.ref }}..HEAD
@@ -47,7 +47,7 @@ jobs:
contents: read
steps:
- name: Checkout repository
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0
with:
fetch-depth: 50
- name: Check that the CHANGELOG has been modified in the current branch
@@ -59,7 +59,7 @@ jobs:
contents: read
steps:
- name: Checkout repository
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0
- name: Check CHANGELOG max line length
run: |
max_line_length=$(cat CHANGELOG.md | grep -Ev "^\[.*\]: https://github.com" | wc -L)
@@ -77,15 +77,15 @@ jobs:
working-directory: src/mail
steps:
- name: Checkout repository
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0
- name: Install Node.js
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6.5.0
with:
node-version: "24"
node-version: "22"
- name: Restore the mail templates
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
uses: actions/cache@caa296126883cff596d87d8935842f9db880ef25 # v5.1.0
id: mail-templates
with:
path: "src/backend/core/templates/mail"
@@ -105,7 +105,7 @@ jobs:
- name: Cache mail templates
if: steps.mail-templates.outputs.cache-hit != 'true'
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
uses: actions/cache@caa296126883cff596d87d8935842f9db880ef25 # v5.1.0
with:
path: "src/backend/core/templates/mail"
key: mail-templates-${{ hashFiles('src/mail/mjml') }}
@@ -119,13 +119,13 @@ jobs:
working-directory: src/backend
steps:
- name: Checkout repository
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0
- name: Install Python
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0
with:
python-version: "3.14"
python-version: "3.13"
- name: Install uv
uses: astral-sh/setup-uv@bec219d24cd3e171d82865faccec33120bb574f4 # v10.1.0
uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7.6.0
- name: Install the project
run: uv sync --locked --all-extras
@@ -145,13 +145,13 @@ jobs:
working-directory: src/agents
steps:
- name: Checkout repository
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0
- name: Install Python
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0
with:
python-version: "3.14"
python-version: "3.13"
- name: Install uv
uses: astral-sh/setup-uv@bec219d24cd3e171d82865faccec33120bb574f4 # v10.1.0
uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7.6.0
- name: Install the project
run: uv sync --locked --all-extras --no-build
- name: Check code formatting with ruff
@@ -168,13 +168,13 @@ jobs:
working-directory: src/summary
steps:
- name: Checkout repository
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0
- name: Install Python
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0
with:
python-version: "3.14"
python-version: "3.13"
- name: Install uv
uses: astral-sh/setup-uv@bec219d24cd3e171d82865faccec33120bb574f4 # v10.1.0
uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7.6.0
- name: Install the project
run: uv sync --locked --all-extras
- name: Check code formatting with ruff
@@ -203,7 +203,7 @@ jobs:
# needed because the postgres container does not provide a healthcheck
options: --health-cmd pg_isready --health-interval 10s --health-timeout 5s --health-retries 5
redis:
image: redis:7
image: redis:5
ports:
- 6379:6379
# Set health checks to wait until redis has started
@@ -236,7 +236,7 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0
- name: Create writable /data
run: |
@@ -244,7 +244,7 @@ jobs:
sudo mkdir -p /data/static
- name: Restore the mail templates
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
uses: actions/cache@caa296126883cff596d87d8935842f9db880ef25 # v5.1.0
id: mail-templates
with:
path: "src/backend/core/templates/mail"
@@ -280,11 +280,11 @@ jobs:
mc mb meet/meet-media-storage"
- name: Install Python
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0
with:
python-version: "3.14"
python-version: "3.13"
- name: Install uv
uses: astral-sh/setup-uv@bec219d24cd3e171d82865faccec33120bb574f4 # v10.1.0
uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7.6.0
- name: Install the dependencies
run: uv sync --locked --all-extras
@@ -323,7 +323,7 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0
- name: Install ffmpeg
run: |
@@ -331,12 +331,12 @@ jobs:
sudo apt-get install -y ffmpeg
- name: Install Python
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0
with:
python-version: "3.14"
python-version: "3.13"
- name: Install uv
uses: astral-sh/setup-uv@bec219d24cd3e171d82865faccec33120bb574f4 # v10.1.0
uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7.6.0
- name: Install the project
run: uv sync --locked --all-extras
@@ -350,7 +350,7 @@ jobs:
contents: read
steps:
- name: Checkout repository
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0
- name: Install dependencies
run: cd src/frontend/ && npm ci --ignore-scripts
@@ -370,7 +370,7 @@ jobs:
working-directory: src/sdk/library
steps:
- name: Checkout repository
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0
- name: Install dependencies
run: npm ci --ignore-scripts
@@ -391,7 +391,7 @@ jobs:
working-directory: src/sdk/library
steps:
- name: Checkout repository
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0
- name: Install dependencies
run: npm ci --ignore-scripts
+2 -2
View File
@@ -13,10 +13,10 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0
- name: Download Crowdin files
uses: crowdin/github-action@9af557de76d70c480f88065d336f445a362f402b # v3.1.0
uses: crowdin/github-action@c7af9bc98b01694653031fef2a0dc6c7888ce9bc # v2.17.0
with:
upload_sources: false
upload_translations: false
+30 -30
View File
@@ -30,24 +30,24 @@ jobs:
steps:
-
name: Checkout repository
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0
-
name: Set up QEMU
if: env.IS_MULTI_PLATFORM_BUILD == 'true'
uses: docker/setup-qemu-action@99012661954931238ded8c8b007157a8430204e1 # v4.4.0
uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 # v3.7.0
-
name: Set up Docker Buildx
uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0
-
name: Docker meta
id: meta
uses: docker/metadata-action@dc802804100637a589fabce1cb79ff13a1411302 # v6.2.0
uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5.10.0
with:
images: '${{ env.DOCKER_CONTAINER_REGISTRY_NAMESPACE }}/meet-backend'
-
name: Login to DockerHub
if: github.event_name != 'pull_request' || startsWith(github.head_ref, 'integration/')
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
with:
username: ${{ secrets.DOCKER_HUB_USER }}
password: ${{ secrets.DOCKER_HUB_PASSWORD }}
@@ -59,7 +59,7 @@ jobs:
docker-image-name: '${{ env.DOCKER_CONTAINER_REGISTRY_HOSTNAME }}/${{ env.DOCKER_CONTAINER_REGISTRY_NAMESPACE }}/meet-backend:${{ github.sha }}'
-
name: Build and push
uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7.4.0
uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6.19.2
with:
context: .
target: backend-production
@@ -76,24 +76,24 @@ jobs:
steps:
-
name: Checkout repository
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0
-
name: Set up QEMU
if: env.IS_MULTI_PLATFORM_BUILD == 'true'
uses: docker/setup-qemu-action@99012661954931238ded8c8b007157a8430204e1 # v4.4.0
uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 # v3.7.0
-
name: Set up Docker Buildx
uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0
-
name: Docker meta
id: meta
uses: docker/metadata-action@dc802804100637a589fabce1cb79ff13a1411302 # v6.2.0
uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5.10.0
with:
images: '${{ env.DOCKER_CONTAINER_REGISTRY_NAMESPACE }}/meet-frontend'
-
name: Login to DockerHub
if: github.event_name != 'pull_request' || startsWith(github.head_ref, 'integration/')
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
with:
username: ${{ secrets.DOCKER_HUB_USER }}
password: ${{ secrets.DOCKER_HUB_PASSWORD }}
@@ -105,7 +105,7 @@ jobs:
docker-image-name: '${{ env.DOCKER_CONTAINER_REGISTRY_HOSTNAME }}/${{ env.DOCKER_CONTAINER_REGISTRY_NAMESPACE }}/meet-frontend:${{ github.sha }}'
-
name: Build and push
uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7.4.0
uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6.19.2
with:
context: .
file: ./src/frontend/Dockerfile
@@ -123,24 +123,24 @@ jobs:
steps:
-
name: Checkout repository
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0
-
name: Set up QEMU
if: env.IS_MULTI_PLATFORM_BUILD == 'true'
uses: docker/setup-qemu-action@99012661954931238ded8c8b007157a8430204e1 # v4.4.0
uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 # v3.7.0
-
name: Set up Docker Buildx
uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0
-
name: Docker meta
id: meta
uses: docker/metadata-action@dc802804100637a589fabce1cb79ff13a1411302 # v6.2.0
uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5.10.0
with:
images: '${{ env.DOCKER_CONTAINER_REGISTRY_NAMESPACE }}/meet-frontend-dinum'
-
name: Login to DockerHub
if: github.event_name != 'pull_request' || startsWith(github.head_ref, 'integration/')
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
with:
username: ${{ secrets.DOCKER_HUB_USER }}
password: ${{ secrets.DOCKER_HUB_PASSWORD }}
@@ -152,7 +152,7 @@ jobs:
docker-image-name: '${{ env.DOCKER_CONTAINER_REGISTRY_HOSTNAME }}/${{ env.DOCKER_CONTAINER_REGISTRY_NAMESPACE }}/meet-frontend-dinum:${{ github.sha }}'
-
name: Build and push
uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7.4.0
uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6.19.2
with:
context: .
file: ./docker/dinum-frontend/Dockerfile
@@ -170,24 +170,24 @@ jobs:
steps:
-
name: Checkout repository
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0
-
name: Set up QEMU
if: env.IS_MULTI_PLATFORM_BUILD == 'true'
uses: docker/setup-qemu-action@99012661954931238ded8c8b007157a8430204e1 # v4.4.0
uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 # v3.7.0
-
name: Set up Docker Buildx
uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0
-
name: Docker meta
id: meta
uses: docker/metadata-action@dc802804100637a589fabce1cb79ff13a1411302 # v6.2.0
uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5.10.0
with:
images: '${{ env.DOCKER_CONTAINER_REGISTRY_NAMESPACE }}/meet-summary'
-
name: Login to DockerHub
if: github.event_name != 'pull_request' || startsWith(github.head_ref, 'integration/')
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
with:
username: ${{ secrets.DOCKER_HUB_USER }}
password: ${{ secrets.DOCKER_HUB_PASSWORD }}
@@ -201,7 +201,7 @@ jobs:
docker-context: './src/summary'
-
name: Build and push
uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7.4.0
uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6.19.2
with:
context: ./src/summary
file: ./src/summary/Dockerfile
@@ -219,24 +219,24 @@ jobs:
steps:
-
name: Checkout repository
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0
-
name: Set up QEMU
if: env.IS_MULTI_PLATFORM_BUILD == 'true'
uses: docker/setup-qemu-action@99012661954931238ded8c8b007157a8430204e1 # v4.4.0
uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 # v3.7.0
-
name: Set up Docker Buildx
uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0
-
name: Docker meta
id: meta
uses: docker/metadata-action@dc802804100637a589fabce1cb79ff13a1411302 # v6.2.0
uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5.10.0
with:
images: lasuite/meet-agents
-
name: Login to DockerHub
if: github.event_name != 'pull_request' || startsWith(github.head_ref, 'integration/')
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
with:
username: ${{ secrets.DOCKER_HUB_USER }}
password: ${{ secrets.DOCKER_HUB_PASSWORD }}
@@ -250,7 +250,7 @@ jobs:
docker-context: './src/agents'
-
name: Build and push
uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7.4.0
uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6.19.2
with:
context: ./src/agents
file: ./src/agents/Dockerfile
+2 -2
View File
@@ -13,7 +13,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0
with:
fetch-depth: 0
@@ -21,7 +21,7 @@ jobs:
run: rm -rf ./src/helm/extra
- name: Install Helm
uses: azure/setup-helm@9bc31f4ebc9c6b171d7bfbaa5d006ae7abdb4310 # v5.0.1
uses: azure/setup-helm@1a275c3b69536ee54be43f2070a358922e12c8d4 # v4.3.1
env:
GITHUB_TOKEN: "${{ secrets.GITHUB_TOKEN }}"
+1 -25
View File
@@ -8,27 +8,14 @@ and this project adheres to
## [Unreleased]
## [1.32.1] - 2026-09-25
### Fixed
- 🔒️(backend) fix CVE-2026-73228 and CVE-2026-73229 in drf
- 🔒️(agent) fix CRITICAL CVE-2026-63072 / CVE-2026-63073 in libssl3t64
## [1.32.0] - 2026-09-25
### Added
- ✨(backend) make the LiveKit default video codec configurable
- 🔧(dev) add support for Bureautix workstations
- ✨(frontend) add screen share zoom controls #1498
- ✨(frontend) switch frontend images to Caddy and proxy recording/file downloads through it, removing the NGINX Ingress auth annotations dependency
### Changed
- 🔥(backend) remove unused API viewset and permission helpers
- 🔊(backend) pin the dockerflow logger level to WARNING
- 🚑️(summary) serve health endpoints with the dockerflow router
- ♻️(backend) serve the dockerflow views early in the middleware stack
- 📈(frontend) include LiveKit SIDs in the connection analytics event
- 🔇(backend) silence expected 401 warnings on /me
- 🔇(backend) silence noisy request summary info logs
@@ -37,26 +24,15 @@ and this project adheres to
- ⬆️(frontend) upgrade posthog-js from 1.414.0 to 1.418.10
- ⬆️(addons) upgrade i18next from 26.3.6 to 26.4.0
- ⬆️(frontend) upgrade humanize-duration from 3.33.2 to 3.34.1
- ⬆️(addons) upgrade i18next from 26.4.0 to 26.4.2
- 🔖(helm) release chart 0.0.28
- ♻️(backend) decouple recording event handling from LiveKit egress statuses
### Fixed
- 🐛(helm) probe liveness on __lbheartbeat__ and readiness on __heartbeat__
- 🐛(helm) render periodSeconds and failureThreshold on probes
- 🐛(backend) report the app release to Sentry instead of "NA"
- 🐛(frontend) play the waiting room notification sound on every arrival
- 🐛(frontend) apply saved reception resolution when joining a meeting #1714
- 🐛(backend) acknowledge unknown LiveKit webhook events instead of 422
- 🔒️(backend) enforce display name setting on rename API
- 🔒️(backend) reject inactive users in resource server backend
- 🐛(frontend) fix file permissions in the Docker image
- 🚸(frontend) inform user that recording waits until a track is published
- 🔒(backend) upgrade base image to python:3.13.5-alpine3.24
- 🐛(backend) handle failed and aborted egresses
- 🩹(frontend) notify participants when a recording fails or is aborted
- 🔒️(frontend) fix HIGH CVE-2026-93990 in libexpat
## [1.31.0] - 2026-09-08
+1 -1
View File
@@ -1,7 +1,7 @@
# Django Meet
# ---- base image to inherit from ----
FROM python:3.13.15-alpine3.24 AS base
FROM python:3.13.5-alpine3.21 AS base
# Upgrade pip to its latest release to speed up dependencies installation
RUN python -m pip install --upgrade pip
+42
View File
@@ -0,0 +1,42 @@
# Shared Caddy snippet: proxies recording/file downloads to object storage after
# checking authorization with the backend
#
# Env vars (all optional, fall back to local dev defaults): BACKEND_INTERNAL_HOST,
# BACKEND_INTERNAL_PORT, MEDIA_STORAGE_HOST, MEDIA_STORAGE_PROTOCOL, MEDIA_STORAGE_PORT, AWS_STORAGE_BUCKET_NAME.
handle /media/files/* {
route {
forward_auth http://{$BACKEND_INTERNAL_HOST:localhost}:{$BACKEND_INTERNAL_PORT:8000} {
uri /api/v1.0/files/media-auth/
copy_headers Authorization X-Amz-Date X-Amz-Content-Sha256
header_up X-Original-URL {http.request.uri}
# Backend has SECURE_SSL_REDIRECT: without this the auth subrequest is
# 301'd to https, the browser follows it to media-auth (no X-Original-URL) and 403s.
header_up X-Forwarded-Proto https
}
uri replace /media/ /{$AWS_STORAGE_BUCKET_NAME:meet-media-storage}/ 1
reverse_proxy {$MEDIA_STORAGE_PROTOCOL:http}://{$MEDIA_STORAGE_HOST:localhost}:{$MEDIA_STORAGE_PORT:9000} {
header_up Host {$MEDIA_STORAGE_HOST:localhost}:{$MEDIA_STORAGE_PORT:9000}
header_down -Content-Disposition
header_down Content-Disposition attachment
}
}
}
# Recordings media - kept generic (/media/*) to match the existing ingress path.
handle /media/* {
route {
forward_auth http://{$BACKEND_INTERNAL_HOST:localhost}:{$BACKEND_INTERNAL_PORT:8000} {
uri /api/v1.0/recordings/media-auth/
copy_headers Authorization X-Amz-Date X-Amz-Content-Sha256
header_up X-Original-URL {http.request.uri}
header_up X-Forwarded-Proto https
}
uri replace /media/ /{$AWS_STORAGE_BUCKET_NAME:meet-media-storage}/ 1
reverse_proxy {$MEDIA_STORAGE_PROTOCOL:http}://{$MEDIA_STORAGE_HOST:localhost}:{$MEDIA_STORAGE_PORT:9000} {
header_up Host {$MEDIA_STORAGE_HOST:localhost}:{$MEDIA_STORAGE_PORT:9000}
header_down -Content-Disposition
header_down Content-Disposition attachment
}
}
}
+75
View File
@@ -0,0 +1,75 @@
# Global options
{
auto_https off
admin off
# replace_response can't process compressed bodies, so it must run before encode.
order replace before encode
}
:{$PORT} {
root * /usr/share/nginx/html
encode gzip
route {
import /etc/caddy/media-proxy.caddy
handle /.well-known/windows-app-web-link {
header Content-Type "application/json"
header Content-Disposition "attachment; filename=windows-app-web-link"
file_server
}
# Manifest — fetched, never iframed
handle /addons/outlook/manifest.xml {
header Access-Control-Allow-Origin "*"
header Cache-Control "no-cache, no-store, must-revalidate"
header X-Frame-Options "DENY"
header Content-Security-Policy "frame-ancestors 'none'"
file_server
}
handle /addons/outlook/assets/* {
header Cache-Control "public, max-age=2592000, immutable"
header Access-Control-Allow-Origin "*"
header Vary "Origin"
file_server
}
# Outlook add-on pages: per-request CSP nonce, mirrors the Office.js/config.js
# script tags baked into the build with a literal NONCE_PLACEHOLDER string.
handle /addons/outlook/* {
header Cache-Control "no-cache, no-store, must-revalidate"
header Pragma "no-cache"
header Expires 0
header Content-Security-Policy "default-src 'self'; upgrade-insecure-requests; frame-ancestors https://*.live.com https://*.office.com https://*.microsoft.com https://*.office365.com https://*.sharepoint.com; script-src 'nonce-{http.request.uuid}' 'strict-dynamic'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; font-src 'self' data:; connect-src 'self' https://*.live.com https://*.office.com https://*.microsoft.com https://*.office365.com https://*.sharepoint.com; frame-src 'none'; object-src 'none'; base-uri 'none'"
replace NONCE_PLACEHOLDER {http.request.uuid}
try_files {path} /index.html
file_server
}
# Vite fingerprints everything under /assets, so a given URL's bytes never
# change: cache it forever. A new build emits new hashed URLs.
@immutable path /assets/*
header @immutable Cache-Control "public, max-age=2592000, immutable"
# The SPA shell and other non-fingerprinted files must revalidate every
# load, or a deploy's new asset hashes only show up after a hard refresh.
@revalidate not path /assets/*
header @revalidate {
Cache-Control "no-cache, no-store, must-revalidate"
Pragma "no-cache"
Expires 0
}
try_files {path} /index.html
file_server
}
handle_errors {
@spa_404 expression `{err.status_code} == 404`
handle @spa_404 {
rewrite * /index.html
file_server
}
}
}
+12 -15
View File
@@ -52,20 +52,17 @@ COPY ./src/addons/outlook/ .
RUN npx webpack --mode production
# ---- Caddy builder image ----
FROM caddy:2.11.4-builder AS caddy-builder
RUN xcaddy build --with github.com/caddyserver/replace-response
RUN apk add --no-cache libcap && \
setcap -r /usr/bin/caddy
# ---- Front-end image ----
FROM nginxinc/nginx-unprivileged:1.30.4-alpine3.24 AS frontend-production
FROM gcr.io/distroless/static-debian12:nonroot AS frontend-production
USER root
RUN apk upgrade --no-cache libexpat && \
apk del curl
USER nginx
USER nginx
# Un-privileged user running the application
ARG DOCKER_USER
USER ${DOCKER_USER}
COPY --from=caddy-builder /usr/bin/caddy /usr/bin/caddy
COPY --from=meet-builder \
/home/frontend/dist \
@@ -75,9 +72,9 @@ COPY --from=addons-builder \
/home/addons/outlook/dist \
/usr/share/nginx/html/addons/outlook
COPY ./docker/dinum-frontend/nginx/default.conf /etc/nginx/conf.d
COPY ./docker/files/usr/local/bin/entrypoint /usr/local/bin/entrypoint
COPY ./docker/dinum-frontend/Caddyfile /etc/caddy/Caddyfile
COPY ./docker/caddy/media-proxy.caddy /etc/caddy/media-proxy.caddy
ENTRYPOINT [ "/usr/local/bin/entrypoint" ]
ENV PORT=8080
CMD ["nginx", "-g", "daemon off;"]
ENTRYPOINT ["caddy", "run", "--config", "/etc/caddy/Caddyfile", "--adapter", "caddyfile"]
-90
View File
@@ -1,90 +0,0 @@
server {
listen 8080;
server_name localhost;
server_tokens off;
root /usr/share/nginx/html;
location = /.well-known/windows-app-web-link {
default_type application/json;
alias /usr/share/nginx/html/.well-known/windows-app-web-link;
add_header Content-Disposition "attachment; filename=windows-app-web-link";
}
# Manifest — fetched, never iframed
location = /addons/outlook/manifest.xml {
alias /usr/share/nginx/html/addons/outlook/manifest.xml;
add_header Access-Control-Allow-Origin "*";
add_header Cache-Control "no-cache, no-store, must-revalidate";
add_header X-Frame-Options "DENY";
add_header Content-Security-Policy "frame-ancestors 'none'";
}
location = /addons/outlook/assets/ {
return 404;
}
location ~* ^/addons/outlook/assets/(.+\.(?:css|js|json|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot))/?$ {
root /usr/share/nginx/html;
expires 30d;
add_header Cache-Control "public, max-age=2592000, immutable" always;
add_header Access-Control-Allow-Origin "*";
add_header Vary "Origin" always;
}
location = /addons/outlook/ {
return 404;
}
location ~ ^/addons/outlook(/.*)?$ {
alias /usr/share/nginx/html/addons/outlook$1;
error_page 404 =200 /index.html;
add_header Cache-Control "no-cache, no-store, must-revalidate";
add_header Pragma "no-cache" always;
add_header Expires 0 always;
set $ms_domains "https://*.live.com https://*.office.com https://*.microsoft.com https://*.office365.com https://*.sharepoint.com";
set $nonce $request_id;
set $csp "default-src 'self'; upgrade-insecure-requests; ";
set $csp "${csp}frame-ancestors ${ms_domains}; ";
set $csp "${csp}script-src 'nonce-${nonce}' 'strict-dynamic'; ";
set $csp "${csp}style-src 'self' 'unsafe-inline'; ";
set $csp "${csp}img-src 'self' data:; ";
set $csp "${csp}font-src 'self' data:; ";
set $csp "${csp}connect-src 'self' ${ms_domains}; ";
set $csp "${csp}frame-src 'none'; ";
set $csp "${csp}object-src 'none'; ";
set $csp "${csp}base-uri 'none'; ";
add_header Content-Security-Policy $csp;
sub_filter 'NONCE_PLACEHOLDER' $nonce;
sub_filter_once off;
}
location ^~ /assets/mediapipe/wasm/ {
expires 30d;
add_header Cache-Control "public, max-age=2592000";
}
# Serve static files with caching
location ~* ^/assets/.*\.(css|js|json|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
expires 30d;
add_header Cache-Control "public, max-age=2592000";
}
# Serve static files
location / {
try_files $uri $uri/ /index.html;
# Add no-cache headers
add_header Cache-Control "no-cache, no-store, must-revalidate";
add_header Pragma "no-cache"; # HTTP 1.0 header for backward compatibility
add_header Expires 0;
}
# Optionally, handle 404 errors by redirecting to index.html
error_page 404 =200 /index.html;
}
+4 -4
View File
@@ -10,7 +10,7 @@
"license": "MIT",
"dependencies": {
"core-js": "3.50.0",
"i18next": "26.4.2",
"i18next": "26.4.0",
"i18next-browser-languagedetector": "8.2.1",
"regenerator-runtime": "0.14.1"
},
@@ -9367,9 +9367,9 @@
}
},
"node_modules/i18next": {
"version": "26.4.2",
"resolved": "https://registry.npmjs.org/i18next/-/i18next-26.4.2.tgz",
"integrity": "sha512-RX+R0VLg13IbvRuJSxnqykUFS9vQZTl8wYpWPCIUDWVrSGjsQywB5Y+pjzrkboxGAuYfJZVH1InFTdgBdxq6ug==",
"version": "26.4.0",
"resolved": "https://registry.npmjs.org/i18next/-/i18next-26.4.0.tgz",
"integrity": "sha512-rsmK5bFqsD1AetSFSIa43wtNR4WpvvH4p0tLEsTxkC7QTrfdFm06nbQ95bh8Og4wwaCnUEcm9DVYL2cgxitiQg==",
"funding": [
{
"type": "individual",
+1 -1
View File
@@ -27,7 +27,7 @@
},
"dependencies": {
"core-js": "3.50.0",
"i18next": "26.4.2",
"i18next": "26.4.0",
"i18next-browser-languagedetector": "8.2.1",
"regenerator-runtime": "0.14.1"
},
-1
View File
@@ -5,7 +5,6 @@ RUN sed -i "s|^URIs: http://|URIs: https://|" /etc/apt/sources.list.d/debian.sou
&& apt-get update && apt-get install -y --no-install-recommends \
libglib2.0-0 \
libgobject-2.0-0 \
libssl3t64 \
&& rm -rf /var/lib/apt/lists/*
+1 -1
View File
@@ -1,7 +1,7 @@
[project]
name = "agents"
version = "1.32.1"
version = "1.31.0"
requires-python = ">=3.12"
dependencies = [
"livekit-agents==1.6.7",
+1 -1
View File
@@ -9,7 +9,7 @@ resolution-markers = [
[[package]]
name = "agents"
version = "1.32.1"
version = "1.31.0"
source = { virtual = "." }
dependencies = [
{ name = "httpx" },
+13
View File
@@ -12,6 +12,10 @@ from ..services.participants_management import (
ParticipantsManagementException,
)
ACTION_FOR_METHOD_TO_PERMISSION = {
"versions_detail": {"DELETE": "versions_destroy", "GET": "versions_retrieve"}
}
class IsAuthenticated(permissions.BasePermission):
"""
@@ -23,6 +27,15 @@ class IsAuthenticated(permissions.BasePermission):
return bool(request.auth) or request.user.is_authenticated
class IsAuthenticatedOrSafe(IsAuthenticated):
"""Allows access to authenticated users (or anonymous users but only on safe methods)."""
def has_permission(self, request, view):
if request.method in permissions.SAFE_METHODS:
return True
return super().has_permission(request, view)
class IsSelf(IsAuthenticated):
"""
Allows access only to authenticated users. Alternative method checking the presence
+54
View File
@@ -95,6 +95,60 @@ from .feature_flag import FeatureFlag
logger = getLogger(__name__)
class NestedGenericViewSet(viewsets.GenericViewSet):
"""
A generic Viewset aims to be used in a nested route context.
e.g: `/api/v1.0/resource_1/<resource_1_pk>/resource_2/<resource_2_pk>/`
It allows to define all url kwargs and lookup fields to perform the lookup.
"""
lookup_fields: list[str] = ["pk"]
lookup_url_kwargs: list[str] = []
def __getattribute__(self, file):
"""
This method is overridden to allow to get the last lookup field or lookup url kwarg
when accessing the `lookup_field` or `lookup_url_kwarg` attribute. This is useful
to keep compatibility with all methods used by the parent class `GenericViewSet`.
"""
if file in ["lookup_field", "lookup_url_kwarg"]:
return getattr(self, file + "s", [None])[-1]
return super().__getattribute__(file)
def get_queryset(self):
"""
Get the list of files for this view.
`lookup_fields` attribute is enumerated here to perform the nested lookup.
"""
queryset = super().get_queryset()
# The last lookup field is removed to perform the nested lookup as it corresponds
# to the object pk, it is used within get_object method.
lookup_url_kwargs = (
self.lookup_url_kwargs[:-1]
if self.lookup_url_kwargs
else self.lookup_fields[:-1]
)
filter_kwargs = {}
for index, lookup_url_kwarg in enumerate(lookup_url_kwargs):
if lookup_url_kwarg not in self.kwargs:
raise KeyError(
f"Expected view {self.__class__.__name__} to be called with a URL "
f'keyword argument named "{lookup_url_kwarg}". Fix your URL conf, or '
"set the `.lookup_fields` attribute on the view correctly."
)
filter_kwargs.update(
{self.lookup_fields[index]: self.kwargs[lookup_url_kwarg]}
)
return queryset.filter(**filter_kwargs)
class SerializerPerActionMixin:
"""
A mixin to allow to define serializer classes for each action.
@@ -1,18 +0,0 @@
# Generated by Django 5.2.16 on 2026-09-23 16:49
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('core', '0022_user_default_room_access_level_and_more'),
]
operations = [
migrations.AlterField(
model_name='recording',
name='status',
field=models.CharField(choices=[('initiated', 'Initiated'), ('active', 'Active'), ('stopped', 'Stopped'), ('saved', 'Saved'), ('aborted', 'Aborted'), ('failed', 'Failed'), ('failed_to_start', 'Failed to Start'), ('failed_to_stop', 'Failed to Stop'), ('notification_succeeded', 'Notification succeeded'), ('external_process_successful', 'External process successful'), ('external_process_failed', 'External process failed')], default='initiated', max_length=50),
),
]
+5 -3
View File
@@ -58,7 +58,6 @@ class RecordingStatusChoices(models.TextChoices):
STOPPED = "stopped", _("Stopped")
SAVED = "saved", _("Saved")
ABORTED = "aborted", _("Aborted")
FAILED = "failed", _("Failed")
FAILED_TO_START = "failed_to_start", _("Failed to Start")
FAILED_TO_STOP = "failed_to_stop", _("Failed to Stop")
NOTIFICATION_SUCCEEDED = "notification_succeeded", _("Notification succeeded")
@@ -80,13 +79,17 @@ class RecordingStatusChoices(models.TextChoices):
cls.STOPPED,
cls.SAVED,
cls.ABORTED,
cls.FAILED,
cls.EXTERNAL_PROCESS_SUCCESSFUL,
cls.EXTERNAL_PROCESS_FAILED,
cls.FAILED_TO_START,
cls.FAILED_TO_STOP,
}
@classmethod
def is_unsuccessful(cls, status):
"""Determine if the recording status represents an unsuccessful state."""
return status in {cls.ABORTED, cls.FAILED_TO_START, cls.FAILED_TO_STOP}
class RecordingModeChoices(models.TextChoices):
"""Recording mode choices."""
@@ -579,7 +582,6 @@ class Recording(BaseModel):
4. NOTIFICATION_SUCCEEDED: External service has been notified of this recording
Error States:
- FAILED: Egress failed mid-recording
- FAILED_TO_START: Worker failed to initialize recording
- FAILED_TO_STOP: Worker failed during stop operation
- ABORTED: Recording was terminated before completion
-47
View File
@@ -8,50 +8,3 @@ class FileExtension(Enum):
OGG = "ogg"
MP4 = "mp4"
class RecordingWorkerEvent(Enum):
"""Lifecycle events a recording worker reports about a recording.
It is intended to be free of SFU-specific vocabulary.
"""
# The worker accepted the request but is not recording yet.
STARTING = "starting"
# The worker is recording.
STARTED = "started"
# The worker stopped recording and is flushing the media file.
SAVING = "saving"
# The recording ended, its media file is available.
COMPLETED = "completed"
# The recording ended on its configured limit, its media file is available.
LIMIT_REACHED = "limit reached"
# The worker stopped before it ever started recording, there is no media file.
ABORTED = "aborted"
# The worker hit a runtime error once recording had started; its media file
# may be available.
FAILED = "failed"
@classmethod
def is_terminal(cls, event):
"""Determine if the event ends the recording's lifecycle (successful or not)."""
return event in TERMINAL_EVENTS
SUCCESSFUL_EVENTS = frozenset(
{
RecordingWorkerEvent.COMPLETED,
RecordingWorkerEvent.LIMIT_REACHED,
}
)
UNSUCCESSFUL_EVENTS = frozenset(
{
RecordingWorkerEvent.ABORTED,
RecordingWorkerEvent.FAILED,
}
)
TERMINAL_EVENTS = SUCCESSFUL_EVENTS | UNSUCCESSFUL_EVENTS
@@ -1,13 +1,13 @@
"""Recording-related Events Service"""
"""Recording-related LiveKit Events Service"""
# pylint: disable=no-member
from logging import getLogger
from livekit import api
from core import models, utils
from core.models import Recording
from core.recording.enums import (
UNSUCCESSFUL_EVENTS,
RecordingWorkerEvent,
)
from core.recording.event.notification import notification_service
from core.services.room_management import (
RoomManagement,
@@ -26,110 +26,22 @@ class RecordingNotSavableError(Exception):
"""Recording cannot be saved because it is either in an error state or has already been saved"""
# Notification sent to the room's participants, per event and recording mode.
NOTIFICATION_PREFIXES = {
models.RecordingModeChoices.SCREEN_RECORDING: "screenRecording",
models.RecordingModeChoices.TRANSCRIPT: "transcription",
}
NOTIFICATION_SUFFIXES = {
RecordingWorkerEvent.LIMIT_REACHED: "LimitReached",
RecordingWorkerEvent.FAILED: "Failed",
RecordingWorkerEvent.ABORTED: "Aborted",
}
def get_notification_type(recording_mode, event):
"""Generate corresponding notification type string."""
try:
return f"{NOTIFICATION_PREFIXES[recording_mode]}{NOTIFICATION_SUFFIXES[event]}"
except KeyError:
return None
# Recording status in the room's metadata, per event.
ROOM_METADATA_RECORDING_STATUSES = {
RecordingWorkerEvent.STARTED: "started",
RecordingWorkerEvent.SAVING: "saving",
}
class RecordingEventsService:
"""Handles recording-related worker events.
Two entry points: `handle_update` for the events a running recording
reports, and `handle_terminal_event` for the one ending it.
"""
"""Handles recording-related LiveKit webhook events."""
@staticmethod
def log_worker_error(recording, event, error=None, error_code=None):
"""Log FAILED at error level and expected ABORTED outcomes at info level."""
if event == RecordingWorkerEvent.FAILED:
log = logger.error
elif event == RecordingWorkerEvent.ABORTED:
log = logger.info
else:
return
log(
"Recording worker reported %s for recording %s (room=%s, mode=%s): %s (error_code=%s)",
event.value,
recording.id,
recording.room.id,
recording.mode,
error or "no error reported",
error_code or "no error_code reported",
)
@staticmethod
def _notify_participants(recording: Recording, event: RecordingWorkerEvent):
"""Notify the room's participants that a recording ended on the given event."""
recording_mode = recording.options.get("original_mode", None) or recording.mode
notification_type = get_notification_type(recording_mode, event)
if not notification_type:
logger.warning(
"Could not find notification type for: "
"room=%s, recording_id=%s, mode=%s, event=%s",
recording.room.id,
recording.id,
recording_mode,
event.value,
)
return
try:
utils.notify_participants(
room_name=str(recording.room.id),
notification_data={"type": notification_type},
)
except utils.NotificationError as e:
raise RecordingEventsError(
f"Failed to notify participants in room '{recording.room.id}' about "
f"recording {event.value} (recording_id={recording.id})"
) from e
@staticmethod
def _log_notification_failure(recording, event: RecordingWorkerEvent):
"""Log a participant notification error on an unsuccessful recording."""
logger.exception(
"Failed to notify participants that recording %s %s (room=%s)",
recording.id,
event.value,
recording.room.id,
)
@staticmethod
def handle_update(recording: Recording, event: RecordingWorkerEvent):
"""Handle non-terminal worker events and sync recording state to room metadata.
Terminal events are dispatched through `handle_terminal_event` instead.
"""
def handle_update(recording: Recording, egress_status):
"""Handle egress status updates and sync recording state to room metadata."""
room_name = str(recording.room.id)
recording_status = ROOM_METADATA_RECORDING_STATUSES.get(event)
status_mapping = {
api.EgressStatus.EGRESS_ACTIVE: "started",
api.EgressStatus.EGRESS_ENDING: "saving",
api.EgressStatus.EGRESS_ABORTED: "aborted",
}
recording_status = status_mapping.get(egress_status)
if recording_status:
try:
RoomManagement.update_metadata(
@@ -143,113 +55,42 @@ class RecordingEventsService:
except RoomManagementException as e:
logger.exception("Failed to update room's metadata: %s", e)
def handle_terminal_event(self, recording: Recording, event: RecordingWorkerEvent):
"""Run the appropriate handlers for a terminal event, given the recording's state."""
if not RecordingWorkerEvent.is_terminal(event):
logger.warning(
"Ignoring non-terminal event %s dispatched as a terminal event "
"for recording %s.",
event.value,
recording.id,
)
return
if event in UNSUCCESSFUL_EVENTS:
self._flag_unsuccessful_recording(recording, event)
else:
self._save_successful_recording(recording, event)
def _flag_unsuccessful_recording(
self, recording: Recording, event: RecordingWorkerEvent
):
"""Persist the outcome of a recording the worker announced as unsuccessful."""
# Aborted
if event == RecordingWorkerEvent.ABORTED:
if recording.status == models.RecordingStatusChoices.ACTIVE:
self._apply_outcome(recording, event, self._handle_aborted)
return
# Failed
if event == RecordingWorkerEvent.FAILED:
if recording.is_savable():
self._apply_outcome(recording, event, self._handle_failed)
return
logger.error(
"Unsuccessful event %s has no handler; recording %s keeps status '%s'.",
event.value,
recording.id,
recording.status,
)
def _save_successful_recording(
self, recording: Recording, event: RecordingWorkerEvent
):
"""Save a recording whose media file the worker made available."""
# Limit reached
if (
event == RecordingWorkerEvent.LIMIT_REACHED
and recording.status == models.RecordingStatusChoices.ACTIVE
):
self._apply_outcome(recording, event, self._handle_limit_reached)
try:
self._handle_successful(recording)
except RecordingNotSavableError:
logger.warning(
"Recording %s is not savable on a completed recording "
"(already saved or in an error state); ignoring.",
recording.id,
)
def _apply_outcome(
self, recording: Recording, event: RecordingWorkerEvent, handler
):
"""Keep notification failure non-fatal."""
try:
handler(recording)
except RecordingEventsError:
self._log_notification_failure(recording, event)
@classmethod
def _handle_limit_reached(cls, recording: Recording):
@staticmethod
def handle_limit_reached(recording: Recording):
"""Stop recording and notify participants when limit is reached."""
recording.status = models.RecordingStatusChoices.STOPPED
recording.save()
cls._notify_participants(recording, RecordingWorkerEvent.LIMIT_REACHED)
notification_mapping = {
models.RecordingModeChoices.SCREEN_RECORDING: "screenRecordingLimitReached",
models.RecordingModeChoices.TRANSCRIPT: "transcriptionLimitReached",
}
@classmethod
def _handle_failed(cls, recording: Recording):
"""Set recording status to failed, matching the worker event, and notify participants.
notification_type = notification_mapping.get(recording.mode)
if not notification_type:
return
FAILED: used when an actual runtime/pipeline error occurs after the
recording has started
"""
recording.status = models.RecordingStatusChoices.FAILED
recording.save()
cls._notify_participants(recording, RecordingWorkerEvent.FAILED)
@classmethod
def _handle_aborted(cls, recording: Recording):
"""Set recording status to aborted, matching the worker event, and notify participants.
ABORTED: used when the worker stops before it ever became
active/recording
"""
recording.status = models.RecordingStatusChoices.ABORTED
recording.save()
cls._notify_participants(recording, RecordingWorkerEvent.ABORTED)
try:
utils.notify_participants(
room_name=str(recording.room.id),
notification_data={"type": notification_type},
)
except utils.NotificationError as e:
logger.exception(
"Failed to notify participants about recording limit reached: "
"room=%s, recording_id=%s, mode=%s",
recording.room.id,
recording.id,
recording.mode,
)
raise RecordingEventsError(
f"Failed to notify participants in room '{recording.room.id}' about "
f"recording limit reached (recording_id={recording.id})"
) from e
@staticmethod
def _handle_successful(recording: Recording):
def handle_complete(recording: Recording):
"""Notify external services and save recording."""
if not recording.is_savable():
+5 -37
View File
@@ -2,8 +2,6 @@
# pylint: disable=no-member
import logging
from asgiref.sync import async_to_sync
from livekit import api as livekit_api
@@ -12,8 +10,6 @@ from ..enums import FileExtension
from .exceptions import WorkerConnectionError, WorkerResponseError
from .factories import WorkerServiceConfig
logger = logging.getLogger(__name__)
class BaseEgressService:
"""Base egress defining common methods to manage and interact with LiveKit egress processes."""
@@ -53,22 +49,6 @@ class BaseEgressService:
finally:
await lkapi.aclose()
@staticmethod
def _log_egress_error(response, event: str):
"""Log the reason LiveKit reported an unsuccessful egress on stop.
Mirrors the logging done in the 'egress_ended' webhook. The
StopEgress response carries the same error fields.
"""
logger.error(
"Egress %s on stop (egress_id=%s, status=%s): %s (error_code=%s)",
event,
response.egress_id,
livekit_api.EgressStatus.Name(response.status),
response.error or "no error reported",
response.error_code or "no error_code reported",
)
def stop(self, worker_id: str) -> str:
"""Stop an ongoing egress worker.
The StopEgressRequest is shared among all types of egress,
@@ -86,26 +66,14 @@ class BaseEgressService:
"LiveKit response is missing the recording status."
)
# To avoid exposing EgressStatus values and coupling with LiveKit outside of this class,
# the response status is mapped to simpler "ABORTED", "STOPPED" or "FAILED_TO_STOP" strings.
if response.status == livekit_api.EgressStatus.EGRESS_ABORTED:
return "ABORTED"
if response.status == livekit_api.EgressStatus.EGRESS_ENDING:
return "STOPPED"
if response.status == livekit_api.EgressStatus.EGRESS_LIMIT_REACHED:
return "STOPPED"
# Cases below should be very infrequent as status changes should be
# received and processed by `handle_ended`, thus `stop` would not
# be called (unless failure and stop are very close in time).
# We therefore accept not to notify the user in this code branch.
# This could be fixed in a future refactoring.
if response.status == livekit_api.EgressStatus.EGRESS_ABORTED:
self._log_egress_error(response, "aborted")
return "ABORTED"
if response.status == livekit_api.EgressStatus.EGRESS_FAILED:
self._log_egress_error(response, "failed")
return "FAILED"
self._log_egress_error(response, "failed to stop")
return "FAILED_TO_STOP"
def start(self, room_name, recording_id):
+33 -52
View File
@@ -12,12 +12,15 @@ from django.conf import settings
from livekit import api
from core import models
from core.recording.enums import RecordingWorkerEvent
from core.recording.services.metadata_collector import (
MetadataCollectorException,
MetadataCollectorService,
)
from core.recording.services.recording_events import RecordingEventsService
from core.recording.services.recording_events import (
RecordingEventsError,
RecordingEventsService,
RecordingNotSavableError,
)
from .lobby import LobbyService
from .presence import PresenceCache
@@ -81,30 +84,6 @@ class LiveKitWebhookEventType(Enum):
INGRESS_ENDED = "ingress_ended"
# LiveKit egress statuses mapped to recording worker event statuses
EGRESS_STATUS_TO_RECORDING_EVENT = {
api.EgressStatus.EGRESS_STARTING: RecordingWorkerEvent.STARTING,
api.EgressStatus.EGRESS_ACTIVE: RecordingWorkerEvent.STARTED,
api.EgressStatus.EGRESS_ENDING: RecordingWorkerEvent.SAVING,
api.EgressStatus.EGRESS_COMPLETE: RecordingWorkerEvent.COMPLETED,
api.EgressStatus.EGRESS_LIMIT_REACHED: RecordingWorkerEvent.LIMIT_REACHED,
api.EgressStatus.EGRESS_ABORTED: RecordingWorkerEvent.ABORTED,
api.EgressStatus.EGRESS_FAILED: RecordingWorkerEvent.FAILED,
}
def to_recording_event(egress_status):
"""Translate a LiveKit egress status into a recording worker event."""
event = EGRESS_STATUS_TO_RECORDING_EVENT.get(egress_status)
if event is None:
logger.warning(
"Unmapped LiveKit egress status '%s', ignoring the event.",
egress_status,
)
return event
class LiveKitEventsService:
"""Service for processing and handling LiveKit webhook events and notifications."""
@@ -194,20 +173,12 @@ class LiveKitEventsService:
f"Recording with worker ID {egress_id} does not exist"
) from err
event = to_recording_event(data.egress_info.status)
if event is None:
return
self.recording_events.handle_update(recording, event)
egress_status = data.egress_info.status
self.recording_events.handle_update(recording, egress_status)
def _handle_egress_ended(self, data):
"""Handle 'egress_ended' event.
"""Handle 'egress_ended' event."""
Egress ended is sent with one of these statuses:
EGRESS_COMPLETE, EGRESS_FAILED, EGRESS_ABORTED, EGRESS_LIMIT_REACHED
"""
# Fetch recording
try:
recording = models.Recording.objects.select_related("room").get(
worker_id=data.egress_info.egress_id
@@ -217,17 +188,6 @@ class LiveKitEventsService:
f"Recording with worker ID {data.egress_info.egress_id} does not exist"
) from err
event = to_recording_event(data.egress_info.status)
# Log if/why the recording failed
self.recording_events.log_worker_error(
recording,
event,
error=data.egress_info.error,
error_code=data.egress_info.error_code,
)
# Update room
try:
room_name = str(recording.room.id)
RoomManagement.update_metadata(
@@ -241,17 +201,38 @@ class LiveKitEventsService:
except RoomManagementException as e:
logger.exception("Failed to update room's metadata: %s", e)
# Stop metadata collector
if recording.options.get("metadata_collector_dispatch_id", None) is not None:
try:
MetadataCollectorService().stop(recording)
except MetadataCollectorException:
logger.warning("Failed to stop the MetadataCollectorService")
if event is None:
return
if (
data.egress_info.status == api.EgressStatus.EGRESS_LIMIT_REACHED
and recording.status == models.RecordingStatusChoices.ACTIVE
):
try:
self.recording_events.handle_limit_reached(recording)
except RecordingEventsError as e:
raise ActionFailedError(
f"Failed to process limit reached event for recording {recording}"
) from e
self.recording_events.handle_terminal_event(recording, event)
# Finalize the recording, the egress has uploaded the file to the storage
if data.egress_info.status in [
api.EgressStatus.EGRESS_COMPLETE,
api.EgressStatus.EGRESS_LIMIT_REACHED,
]:
try:
self.recording_events.handle_complete(recording)
except RecordingNotSavableError:
logger.warning(
"Recording %s is not savable on egress complete "
"(already saved or in an error state); ignoring.",
recording.id,
)
# Silently ignoring EGRESS_ABORTED, EGRESS_FAILED
@staticmethod
def _is_connection_test_room(room_name: str) -> bool:
@@ -76,6 +76,10 @@ class RoomManagement:
except TwirpError as e:
if e.code == "not_found":
logger.warning(
"Room %s not found in LiveKit, skipping metadata update",
room_name,
)
raise RoomNotFoundException("Room does not exist") from e
logger.exception(
@@ -2,23 +2,18 @@
Test RecordingEventsService service.
"""
# pylint: disable=redefined-outer-name,protected-access
# pylint: disable=redefined-outer-name
import logging
from unittest import mock
import pytest
from core.factories import RecordingFactory
from core.recording.enums import RecordingWorkerEvent
from core.recording.services.recording_events import (
RecordingEventsError,
RecordingEventsService,
RecordingNotSavableError,
)
from core.services.room_management import (
RoomManagementException,
)
from core.utils import NotificationError
pytestmark = pytest.mark.django_db
@@ -39,10 +34,10 @@ def service():
)
@mock.patch("core.utils.notify_participants")
def test_handle_limit_reached_success(mock_notify, mode, notification_type, service):
"""Test _handle_limit_reached stops recording and notifies participants."""
"""Test handle_limit_reached stops recording and notifies participants."""
recording = RecordingFactory(status="active", mode=mode)
service._handle_limit_reached(recording)
service.handle_limit_reached(recording)
assert recording.status == "stopped"
mock_notify.assert_called_once_with(
@@ -53,69 +48,13 @@ def test_handle_limit_reached_success(mock_notify, mode, notification_type, serv
@pytest.mark.parametrize(
("mode", "notification_type"),
(
("screen_recording", "screenRecordingFailed"),
("transcript", "transcriptionFailed"),
("screen_recording", "screenRecordingLimitReached"),
("transcript", "transcriptionLimitReached"),
),
)
@mock.patch("core.utils.notify_participants")
def test_handle_failed_success(mock_notify, mode, notification_type, service):
"""Test _handle_failed marks recording as failed and notifies participants."""
recording = RecordingFactory(status="active", mode=mode)
service._handle_failed(recording)
assert recording.status == "failed"
mock_notify.assert_called_once_with(
room_name=str(recording.room.id), notification_data={"type": notification_type}
)
@pytest.mark.parametrize(
("mode", "notification_type"),
(
("screen_recording", "screenRecordingAborted"),
("transcript", "transcriptionAborted"),
),
)
@mock.patch("core.utils.notify_participants")
def test_handle_aborted_success(mock_notify, mode, notification_type, service):
"""Test _handle_aborted marks recording as aborted and notifies participants."""
recording = RecordingFactory(status="active", mode=mode)
service._handle_aborted(recording)
assert recording.status == "aborted"
mock_notify.assert_called_once_with(
room_name=str(recording.room.id), notification_data={"type": notification_type}
)
@pytest.mark.parametrize(
("mode", "notification_prefix"),
(("screen_recording", "screenRecording"), ("transcript", "transcription")),
)
@pytest.mark.parametrize(
("handler", "expected_status", "event", "notification_suffix"),
(
("_handle_limit_reached", "stopped", "limit reached", "LimitReached"),
("_handle_failed", "failed", "failed", "Failed"),
("_handle_aborted", "aborted", "aborted", "Aborted"),
),
)
@mock.patch("core.utils.notify_participants")
def test_handle_event_notification_error( # noqa: PLR0913, PLR0917
mock_notify,
handler,
expected_status,
event,
notification_suffix,
mode,
notification_prefix,
service,
): # pylint: disable=too-many-arguments,too-many-positional-arguments
"""Test handlers raise RecordingEventsError when notifying participants fails,
while still applying the recording status of their event.
"""
def test_handle_limit_reached_error(mock_notify, mode, notification_type, service):
"""Test handle_limit_reached raises RecordingEventsError when notification fails."""
mock_notify.side_effect = NotificationError("Error notifying")
@@ -123,15 +62,14 @@ def test_handle_event_notification_error( # noqa: PLR0913, PLR0917
with pytest.raises(
RecordingEventsError,
match=rf"Failed to notify participants in room '.+' "
rf"about recording {event} \(recording_id=.+\)",
match=r"Failed to notify participants in room '.+' "
r"about recording limit reached \(recording_id=.+\)",
):
getattr(service, handler)(recording)
service.handle_limit_reached(recording)
assert recording.status == expected_status
assert recording.status == "stopped"
mock_notify.assert_called_once_with(
room_name=str(recording.room.id),
notification_data={"type": f"{notification_prefix}{notification_suffix}"},
room_name=str(recording.room.id), notification_data={"type": notification_type}
)
@@ -144,19 +82,19 @@ def test_handle_event_notification_error( # noqa: PLR0913, PLR0917
"core.recording.services.recording_events.notification_service."
"notify_external_services"
)
def test_handle_successful_saves_recording( # pylint: disable=too-many-arguments, too-many-positional-arguments
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_successful notifies external services and saves a savable recording."""
"""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_successful(recording)
service.handle_complete(recording)
mock_notify_external_services.assert_called_once_with(recording)
@@ -166,293 +104,23 @@ def test_handle_successful_saves_recording( # pylint: disable=too-many-argument
@pytest.mark.parametrize(
"status",
[
"initiated",
"saved",
"notification_succeeded",
"aborted",
"failed",
"failed_to_start",
],
["initiated", "saved", "notification_succeeded", "aborted", "failed_to_start"],
)
@mock.patch(
"core.recording.services.recording_events.notification_service."
"notify_external_services"
)
def test_handle_successful_non_savable_recording(
def test_handle_complete_non_savable_recording(
mock_notify_external_services, status, service
):
"""Test _handle_successful refuses recordings that are already saved or in error."""
"""Test handle_complete refuses recordings that are already saved or in error."""
recording = RecordingFactory(status=status)
with pytest.raises(RecordingNotSavableError):
service._handle_successful(recording)
service.handle_complete(recording)
mock_notify_external_services.assert_not_called()
recording.refresh_from_db()
assert recording.status == status
@pytest.mark.parametrize(
("event", "recording_status"),
(
(RecordingWorkerEvent.STARTED, "started"),
(RecordingWorkerEvent.SAVING, "saving"),
),
)
@mock.patch("core.services.room_management.RoomManagement.update_metadata")
def test_handle_update_syncs_room_metadata(
mock_update_metadata, event, recording_status, service
):
"""Test handle_update updates the room's metadata."""
recording = RecordingFactory(status="active")
service.handle_update(recording, event)
mock_update_metadata.assert_called_once_with(
str(recording.room.id), {"recording_status": recording_status}
)
@pytest.mark.parametrize(
"event",
(
RecordingWorkerEvent.STARTING,
RecordingWorkerEvent.COMPLETED,
RecordingWorkerEvent.LIMIT_REACHED,
RecordingWorkerEvent.ABORTED,
RecordingWorkerEvent.FAILED,
),
)
@mock.patch("core.services.room_management.RoomManagement.update_metadata")
def test_handle_update_ignores_events_without_a_metadata_status(
mock_update_metadata, event, service
):
"""Test handle_update doesn't update metadata for events it doesn't match."""
recording = RecordingFactory(status="active")
service.handle_update(recording, event)
mock_update_metadata.assert_not_called()
@pytest.mark.parametrize(
("event", "initial_status", "expected_status", "notification_type"),
(
(RecordingWorkerEvent.LIMIT_REACHED, "active", "saved", "LimitReached"),
(RecordingWorkerEvent.LIMIT_REACHED, "stopped", "saved", None),
(RecordingWorkerEvent.LIMIT_REACHED, "saved", "saved", None),
(RecordingWorkerEvent.ABORTED, "active", "aborted", "Aborted"),
(RecordingWorkerEvent.ABORTED, "failed_to_stop", "failed_to_stop", None),
(RecordingWorkerEvent.FAILED, "active", "failed", "Failed"),
(RecordingWorkerEvent.FAILED, "stopped", "failed", "Failed"),
(RecordingWorkerEvent.FAILED, "aborted", "aborted", None),
(RecordingWorkerEvent.COMPLETED, "active", "saved", None),
(RecordingWorkerEvent.COMPLETED, "saved", "saved", None),
),
)
@mock.patch(
"core.recording.services.recording_events.notification_service."
"notify_external_services"
)
@mock.patch("core.utils.notify_participants")
def test_handle_terminal_event_dispatches_on_event_and_status( # noqa: PLR0913, PLR0917
mock_notify,
mock_notify_external_services,
event,
initial_status,
expected_status,
notification_type,
service,
): # pylint: disable=too-many-arguments,too-many-positional-arguments
"""Test handle_terminal_event chooses the right handler from the event and status."""
mock_notify_external_services.return_value = False
recording = RecordingFactory(status=initial_status, mode="screen_recording")
service.handle_terminal_event(recording, event)
recording.refresh_from_db()
assert recording.status == expected_status
if notification_type is None:
mock_notify.assert_not_called()
else:
mock_notify.assert_called_once_with(
room_name=str(recording.room.id),
notification_data={"type": f"screenRecording{notification_type}"},
)
@pytest.mark.parametrize(
"event",
(
RecordingWorkerEvent.STARTING,
RecordingWorkerEvent.STARTED,
RecordingWorkerEvent.SAVING,
),
)
@mock.patch(
"core.recording.services.recording_events.notification_service."
"notify_external_services"
)
@mock.patch("core.utils.notify_participants")
def test_handle_terminal_event_ignores_non_terminal_events(
mock_notify, mock_notify_external_services, event, service, caplog
):
"""Test handle_terminal_event refuses non-terminal events."""
recording = RecordingFactory(status="active")
with caplog.at_level(logging.WARNING):
service.handle_terminal_event(recording, event)
assert f"Ignoring non-terminal event {event.value}" in caplog.text
mock_notify.assert_not_called()
mock_notify_external_services.assert_not_called()
recording.refresh_from_db()
assert recording.status == "active"
@pytest.mark.parametrize(
("event", "expected_status"),
(
(RecordingWorkerEvent.LIMIT_REACHED, "saved"),
(RecordingWorkerEvent.ABORTED, "aborted"),
(RecordingWorkerEvent.FAILED, "failed"),
),
)
@mock.patch(
"core.recording.services.recording_events.notification_service."
"notify_external_services"
)
@mock.patch("core.utils.notify_participants")
def test_handle_terminal_event_survives_a_notification_failure( # noqa: PLR0913, PLR0917
mock_notify,
mock_notify_external_services,
event,
expected_status,
service,
caplog,
): # pylint: disable=too-many-arguments,too-many-positional-arguments
"""Test handle_terminal_event logs a notification failure instead of raising.
The recording status must still be persisted: participants missing their
notification should not disturb recording.
"""
mock_notify_external_services.return_value = False
mock_notify.side_effect = NotificationError("Error notifying")
recording = RecordingFactory(status="active")
with caplog.at_level(logging.ERROR):
service.handle_terminal_event(recording, event)
assert f"Failed to notify participants that recording {recording.id}" in caplog.text
recording.refresh_from_db()
assert recording.status == expected_status
@pytest.mark.parametrize(
"status",
["failed_to_start", "aborted", "failed", "failed_to_stop", "saved", "initiated"],
)
@mock.patch(
"core.recording.services.recording_events.notification_service."
"notify_external_services"
)
def test_handle_terminal_event_ignores_a_non_savable_recording(
mock_notify_external_services, status, service, caplog
):
"""Test handle_terminal_event handles a redelivered event idempotently.
A terminal event may be redelivered for an already finalized recording;
this must not raise, otherwise the webhook would 500 and be retried.
"""
recording = RecordingFactory(status=status)
with caplog.at_level(logging.WARNING):
service.handle_terminal_event(recording, RecordingWorkerEvent.COMPLETED)
assert f"Recording {recording.id} is not savable" in caplog.text
mock_notify_external_services.assert_not_called()
recording.refresh_from_db()
assert recording.status == status
@mock.patch("core.services.room_management.RoomManagement.update_metadata")
def test_handle_update_survives_a_metadata_failure(
mock_update_metadata, service, caplog
):
"""Test handle_update logs a metadata failure instead of raising."""
mock_update_metadata.side_effect = RoomManagementException("Error updating")
recording = RecordingFactory(status="active")
with caplog.at_level(logging.ERROR):
service.handle_update(recording, RecordingWorkerEvent.SAVING)
assert "Failed to update room's metadata" in caplog.text
@pytest.mark.parametrize(
("event", "expected_level"),
(
(RecordingWorkerEvent.ABORTED, logging.INFO),
(RecordingWorkerEvent.FAILED, logging.ERROR),
),
)
def test_log_worker_error_reports_an_unsuccessful_event(
event, expected_level, service, caplog
):
"""Test log_worker_error records the reason the recording did not succeed."""
recording = RecordingFactory(status="active", mode="screen_recording")
with caplog.at_level(logging.INFO):
service.log_worker_error(
recording, event, error="could not connect to the room", error_code=500
)
assert (
f"Recording worker reported {event.value} for recording {recording.id}"
in caplog.text
)
assert "could not connect to the room" in caplog.text
assert "error_code=500" in caplog.text
worker_logs = [
record
for record in caplog.records
if record.name == "core.recording.services.recording_events"
]
assert [record.levelno for record in worker_logs] == [expected_level]
@pytest.mark.parametrize(
"event",
(
RecordingWorkerEvent.STARTING,
RecordingWorkerEvent.STARTED,
RecordingWorkerEvent.SAVING,
RecordingWorkerEvent.COMPLETED,
RecordingWorkerEvent.LIMIT_REACHED,
None,
),
)
def test_log_worker_error_stays_quiet_on_anything_else(event, service, caplog):
"""Test log_worker_error ignores events other than FAILED and ABORTED."""
recording = RecordingFactory(status="active")
with caplog.at_level(logging.INFO):
service.log_worker_error(recording, event, error="some error", error_code=500)
assert "Recording worker reported" not in caplog.text
@@ -224,7 +224,6 @@ def test_api_recording_retrieve_expired(settings):
RecordingStatusChoices.INITIATED,
RecordingStatusChoices.ACTIVE,
RecordingStatusChoices.SAVED,
RecordingStatusChoices.FAILED,
RecordingStatusChoices.FAILED_TO_START,
RecordingStatusChoices.FAILED_TO_STOP,
RecordingStatusChoices.ABORTED,
@@ -4,7 +4,6 @@ Test worker service classes.
# pylint: disable=protected-access,redefined-outer-name,unused-argument,no-member
import logging
from unittest.mock import AsyncMock, Mock, patch
import pytest
@@ -155,9 +154,9 @@ def test_base_egress_filepath_construction(service, filename, extension, expecte
"response_status,expected_result",
[
(livekit_api.EgressStatus.EGRESS_ABORTED, "ABORTED"),
(livekit_api.EgressStatus.EGRESS_FAILED, "FAILED"),
(livekit_api.EgressStatus.EGRESS_COMPLETE, "FAILED_TO_STOP"),
(livekit_api.EgressStatus.EGRESS_ENDING, "STOPPED"),
(livekit_api.EgressStatus.EGRESS_FAILED, "FAILED_TO_STOP"),
],
)
def test_base_egress_stop_with_status(service, response_status, expected_result):
@@ -176,32 +175,6 @@ def test_base_egress_stop_with_status(service, response_status, expected_result)
assert result == expected_result
@pytest.mark.parametrize(
"response_status,event",
[
(livekit_api.EgressStatus.EGRESS_ABORTED, "aborted"),
(livekit_api.EgressStatus.EGRESS_FAILED, "failed"),
(livekit_api.EgressStatus.EGRESS_COMPLETE, "failed to stop"),
],
)
def test_base_egress_stop_logs_livekit_error(service, response_status, event, caplog):
"""Should log the reason LiveKit reported for an unsuccessful stop."""
mock_response = Mock(
status=response_status,
egress_id="test_worker_id",
error="could not connect to the room",
error_code=500,
)
service._handle_request = Mock(return_value=mock_response)
with caplog.at_level(logging.ERROR):
service.stop("test_worker_id")
assert f"Egress {event} on stop (egress_id=test_worker_id" in caplog.text
assert "could not connect to the room" in caplog.text
assert "error_code=500" in caplog.text
def test_base_egress_stop_missing_status(service):
"""Test stop method when response is missing status"""
# Mock _handle_request with missing status
@@ -3,7 +3,6 @@ Test LiveKitEvents service.
"""
# pylint: disable=W0621,W0613, W0212, E0611
import logging
import uuid
from unittest import mock
@@ -11,16 +10,13 @@ import pytest
from livekit.api import EgressStatus
from core.factories import RecordingFactory, RoomFactory
from core.recording.enums import RecordingWorkerEvent
from core.recording.services.recording_events import RecordingEventsService
from core.services.livekit_events import (
EGRESS_STATUS_TO_RECORDING_EVENT,
ActionFailedError,
AuthenticationError,
InvalidPayloadError,
LiveKitEventsService,
api,
to_recording_event,
)
from core.services.lobby import LobbyService
from core.services.room_management import RoomManagementException
@@ -81,7 +77,7 @@ def test_initialization(
def test_handle_egress_ended_success( # pylint: disable=too-many-arguments, too-many-positional-arguments
mock_update_metadata, mock_notify, mode, notification_type, service
):
"""Should successfully stop recording and notify all participant."""
"""Should successfully stop recording and notifies all participant."""
recording = RecordingFactory(worker_id="worker-1", mode=mode, status="active")
mock_data = mock.MagicMock()
@@ -108,6 +104,7 @@ def test_handle_egress_ended_success( # pylint: disable=too-many-arguments, too
(
(EgressStatus.EGRESS_ACTIVE, "started"),
(EgressStatus.EGRESS_ENDING, "saving"),
(EgressStatus.EGRESS_ABORTED, "aborted"),
),
)
@mock.patch("core.services.room_management.RoomManagement.update_metadata")
@@ -128,6 +125,29 @@ def test_handle_egress_updated_success(
)
@pytest.mark.parametrize(
"egress_status",
(
EgressStatus.EGRESS_FAILED,
EgressStatus.EGRESS_LIMIT_REACHED,
),
)
@mock.patch("core.services.room_management.RoomManagement.update_metadata")
def test_handle_egress_updated_non_handled(
mock_update_metadata, egress_status, service
):
"""Should ignore certain egress status and don't trigger metadata updates."""
recording = RecordingFactory(worker_id="worker-1", status="initiated")
mock_data = mock.MagicMock()
mock_data.egress_info.egress_id = recording.worker_id
mock_data.egress_info.status = egress_status
service._handle_egress_updated(mock_data)
mock_update_metadata.assert_not_called()
@pytest.mark.parametrize(
("mode", "notification_type"),
(
@@ -160,38 +180,33 @@ def test_handle_egress_ended_metadata_update_fails( # pylint: disable=too-many-
assert recording.status == "saved"
@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")
def test_handle_egress_ended_notification_fails(
mock_update_metadata, mock_notify, mock_notify_external_services, service
mock_update_metadata, mock_notify, service
):
"""Should still stop and save the recording when notifying participants fails."""
mock_notify_external_services.return_value = False
mock_notify.side_effect = NotificationError("Error notifying")
"""Should raise ActionFailedError when notification fails but still stop recording."""
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 = EgressStatus.EGRESS_LIMIT_REACHED
service._handle_egress_ended(mock_data)
mock_notify.side_effect = NotificationError("Error notifying")
with pytest.raises(
ActionFailedError,
match=r"Failed to process limit reached event for recording .+",
):
service._handle_egress_ended(mock_data)
recording.refresh_from_db()
assert recording.status == "stopped"
mock_notify.assert_called_once_with(
room_name=str(recording.room.id),
notification_data={"type": "screenRecordingLimitReached"},
)
mock_update_metadata.assert_called_once_with(
str(recording.room.id), remove_keys=["recording_mode", "recording_status"]
)
recording.refresh_from_db()
assert recording.status == "saved"
@mock.patch("core.utils.notify_participants")
@mock.patch("core.services.room_management.RoomManagement.update_metadata")
@@ -217,25 +232,17 @@ def test_handle_egress_ended_recording_not_found(
assert recording.status == "active"
@pytest.mark.parametrize(
"egress_status",
(
EgressStatus.EGRESS_FAILED,
EgressStatus.EGRESS_ABORTED,
EgressStatus.EGRESS_LIMIT_REACHED,
),
)
@mock.patch("core.utils.notify_participants")
@mock.patch("core.services.room_management.RoomManagement.update_metadata")
def test_handle_egress_ended_recording_should_not_be_saved(
mock_update_metadata, mock_notify, egress_status, service
def test_handle_egress_ended_recording_not_active(
mock_update_metadata, mock_notify, service
):
"""Don't update status for recordings that must not be saved."""
"""Should ignore non-active recordings."""
recording = RecordingFactory(worker_id="worker-1", status="failed_to_stop")
mock_data = mock.MagicMock()
mock_data.egress_info.egress_id = "worker-1"
mock_data.egress_info.status = egress_status
mock_data.egress_info.status = EgressStatus.EGRESS_LIMIT_REACHED
service._handle_egress_ended(mock_data)
@@ -248,24 +255,12 @@ def test_handle_egress_ended_recording_should_not_be_saved(
assert recording.status == "failed_to_stop"
@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")
def test_handle_egress_ended_complete_does_not_notify_participants(
mock_update_metadata, mock_notify, mock_notify_external_services, service
def test_handle_egress_ended_recording_not_limit_reached(
mock_update_metadata, mock_notify, service
):
"""Shouldn't notify participants on a successful egress.
EGRESS_COMPLETE is the only ended status that notifies no one: limit
reached, aborted and failed egresses each send their own notification.
A stopped recording is simply finalized, which is the nominal flow once
the egress uploaded the file of a user-initiated stop.
"""
mock_notify_external_services.return_value = False
"""Should ignore egress non-limit-reached statuses."""
recording = RecordingFactory(worker_id="worker-1", status="stopped")
mock_data = mock.MagicMock()
@@ -278,10 +273,7 @@ def test_handle_egress_ended_complete_does_not_notify_participants(
mock_update_metadata.assert_called_once_with(
str(recording.room.id), remove_keys=["recording_mode", "recording_status"]
)
mock_notify_external_services.assert_called_once_with(recording)
recording.refresh_from_db()
assert recording.status == "saved"
assert recording.status == "stopped"
@mock.patch("core.services.livekit_events.MetadataCollectorService")
@@ -341,163 +333,96 @@ def test_handle_egress_ended_does_not_call_metadata_collector_stop_when_conditio
mock_collector.stop.assert_not_called()
@pytest.mark.parametrize(
("egress_status", "recording_status", "event", "expected_level"),
(
(EgressStatus.EGRESS_ABORTED, "active", "aborted", logging.INFO),
(EgressStatus.EGRESS_FAILED, "active", "failed", logging.ERROR),
# The synchronous stop may already have persisted the terminal status,
# and the error details exist only in the webhook payload.
(EgressStatus.EGRESS_ABORTED, "aborted", "aborted", logging.INFO),
(EgressStatus.EGRESS_FAILED, "failed", "failed", logging.ERROR),
),
)
@mock.patch("core.utils.notify_participants")
@mock.patch("core.services.room_management.RoomManagement.update_metadata")
def test_handle_egress_ended_logs_livekit_error( # noqa: PLR0913, PLR0917
mock_update_metadata,
mock_notify,
egress_status,
recording_status,
event,
expected_level,
service,
caplog,
): # pylint: disable=too-many-arguments,too-many-positional-arguments
"""Should log the reason LiveKit reported an unsuccessful egress."""
recording = RecordingFactory(worker_id="worker-1", status=recording_status)
mock_data = mock.MagicMock()
mock_data.egress_info.egress_id = recording.worker_id
mock_data.egress_info.status = egress_status
mock_data.egress_info.error = "could not connect to the room"
mock_data.egress_info.error_code = 500
with caplog.at_level(logging.INFO):
service._handle_egress_ended(mock_data)
assert (
f"Recording worker reported {event} for recording {recording.id}" in caplog.text
)
assert "could not connect to the room" in caplog.text
assert "error_code=500" in caplog.text
worker_logs = [
record
for record in caplog.records
if record.name == "core.recording.services.recording_events"
]
assert [record.levelno for record in worker_logs] == [expected_level]
@pytest.mark.parametrize(
"egress_status",
(EgressStatus.EGRESS_COMPLETE, EgressStatus.EGRESS_LIMIT_REACHED),
)
@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")
def test_handle_egress_ended_does_not_log_error_on_successful_egress( # noqa: PLR0913, PLR0917
@pytest.mark.parametrize(
"egress_status",
[EgressStatus.EGRESS_COMPLETE, EgressStatus.EGRESS_LIMIT_REACHED],
)
@pytest.mark.parametrize(
"notify_return_value, recording_status",
[(True, "notification_succeeded"), (False, "saved")],
)
def test_handle_egress_ended_finalizes_recording( # noqa: PLR0913, PLR0917
mock_update_metadata,
mock_notify,
mock_notify_external_services,
notify_return_value,
recording_status,
egress_status,
service,
caplog,
): # pylint: disable=too-many-arguments,too-many-positional-arguments
"""Shouldn't log an egress error when LiveKit reports a successful egress."""
"""Should notify external services and save the recording on egress completion
(EGRESS_COMPLETE or EGRESS_LIMIT_REACHED).
"""
mock_notify_external_services.return_value = notify_return_value
recording = RecordingFactory(worker_id="worker-1", status="active")
mock_data = mock.MagicMock()
mock_data.egress_info.egress_id = recording.worker_id
mock_data.egress_info.status = egress_status
with caplog.at_level(logging.ERROR):
service._handle_egress_ended(mock_data)
service._handle_egress_ended(mock_data)
assert "Recording worker reported" not in caplog.text
mock_notify_external_services.assert_called_once_with(recording)
recording.refresh_from_db()
assert recording.status == recording_status
@mock.patch("core.utils.notify_participants")
@pytest.mark.parametrize(
"egress_status",
[
EgressStatus.EGRESS_STARTING,
EgressStatus.EGRESS_ACTIVE,
EgressStatus.EGRESS_ENDING,
EgressStatus.EGRESS_FAILED,
EgressStatus.EGRESS_ABORTED,
],
)
@mock.patch("core.services.room_management.RoomManagement.update_metadata")
def test_handle_egress_ended_logs_livekit_error_before_cleaning_up(
mock_update_metadata, mock_notify, service, caplog
def test_handle_egress_ended_does_not_save_on_wrong_status(
mock_update_metadata, egress_status, service
):
"""Should log the failure reason even when the cleanup fails afterwards."""
mock_update_metadata.side_effect = RuntimeError("LiveKit is unreachable")
"""Shouldn't save on invalid status."""
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 = EgressStatus.EGRESS_FAILED
mock_data.egress_info.error = "could not connect to the room"
mock_data.egress_info.error_code = 500
mock_data.egress_info.status = egress_status
with caplog.at_level(logging.ERROR), pytest.raises(RuntimeError):
service._handle_egress_ended(mock_data)
service._handle_egress_ended(mock_data)
assert (
f"Recording worker reported failed for recording {recording.id}" in caplog.text
)
assert "could not connect to the room" in caplog.text
def test_egress_status_mapping_covers_every_livekit_status():
"""Every egress status LiveKit can report must translate to a recording event."""
unmapped = [
name
for name in EgressStatus.keys()
if getattr(EgressStatus, name) not in EGRESS_STATUS_TO_RECORDING_EVENT
]
assert not unmapped
recording.refresh_from_db()
assert recording.status == "active"
@pytest.mark.parametrize(
("egress_status", "expected_event"),
(
(EgressStatus.EGRESS_STARTING, RecordingWorkerEvent.STARTING),
(EgressStatus.EGRESS_ACTIVE, RecordingWorkerEvent.STARTED),
(EgressStatus.EGRESS_ENDING, RecordingWorkerEvent.SAVING),
(EgressStatus.EGRESS_COMPLETE, RecordingWorkerEvent.COMPLETED),
(EgressStatus.EGRESS_LIMIT_REACHED, RecordingWorkerEvent.LIMIT_REACHED),
(EgressStatus.EGRESS_ABORTED, RecordingWorkerEvent.ABORTED),
(EgressStatus.EGRESS_FAILED, RecordingWorkerEvent.FAILED),
),
"status", ["failed_to_start", "aborted", "failed_to_stop", "saved", "initiated"]
)
def test_to_recording_event_translates_egress_status(egress_status, expected_event):
"""Should translate a LiveKit egress status into a recording worker event."""
assert to_recording_event(egress_status) == expected_event
def test_to_recording_event_returns_none_on_unmapped_status(caplog):
"""Should warn and return None when LiveKit reports an unknown status."""
with caplog.at_level(logging.WARNING):
event = to_recording_event(999)
assert event is None
assert "Unmapped LiveKit egress status" in caplog.text
@mock.patch("core.services.room_management.RoomManagement.update_metadata")
def test_handle_egress_updated_ignores_unmapped_status(mock_update_metadata, service):
"""Shouldn't touch the room's metadata when the egress status is unknown."""
def test_handle_egress_ended_ignores_non_savable_recording(
mock_update_metadata, status, service
):
"""Should handle non-savable recordings idempotently without raising.
RecordingFactory(worker_id="worker-1", status="active")
'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.
"""
recording = RecordingFactory(worker_id="worker-1", status=status)
mock_data = mock.MagicMock()
mock_data.egress_info.egress_id = "worker-1"
mock_data.egress_info.status = 999
mock_data.egress_info.egress_id = recording.worker_id
mock_data.egress_info.status = EgressStatus.EGRESS_COMPLETE
service._handle_egress_updated(mock_data)
service._handle_egress_ended(mock_data)
mock_update_metadata.assert_not_called()
recording.refresh_from_db()
assert recording.status == status
@mock.patch.object(LobbyService, "clear_room_cache")
-78
View File
@@ -1,78 +0,0 @@
"""Tests for the Dockerflow health endpoints backing the Kubernetes probes."""
from unittest import mock
from django.core import checks
from django.test.utils import override_settings
import pytest
from dockerflow.django.views import django_check_registry
pytestmark = pytest.mark.django_db
@pytest.mark.parametrize("path", ["/__lbheartbeat__", "/__heartbeat__"])
def test_dockerflow_endpoints_are_anonymous(client, path):
"""Both endpoints answer without authentication."""
response = client.get(path)
assert response.status_code == 200
def test_dockerflow_lbheartbeat_is_a_liveness_signal(client, django_assert_num_queries):
"""The load balancer heartbeat answers 200 without touching the database."""
with django_assert_num_queries(0):
response = client.get("/__lbheartbeat__")
assert response.status_code == 200
def test_dockerflow_lbheartbeat_ignores_allowed_hosts(client):
"""The probes are served before ALLOWED_HOSTS is enforced."""
response = client.get("/__lbheartbeat__", headers={"host": "1.2.3.4:8000"})
assert response.status_code == 200
def test_dockerflow_heartbeat_reports_the_configured_checks(client):
"""The heartbeat runs the database, migrations and redis checks."""
with override_settings(DEBUG=True):
response = client.get("/__heartbeat__")
assert response.status_code == 200
payload = response.json()
assert payload["status"] == "ok"
assert payload["details"] == {}
assert payload["checks"]["check_database_connected"] == "ok"
assert payload["checks"]["check_migrations_applied"] == "ok"
def test_dockerflow_heartbeat_is_a_readiness_signal(client):
"""A check reporting an error takes the heartbeat down with a 500."""
def failing_check(**kwargs):
return [checks.Error("Could not connect to database", id="health.E001")]
with mock.patch.object(
django_check_registry, "get_checks", return_value=[failing_check]
):
response = client.get("/__heartbeat__")
assert response.status_code == 500
assert response.json()["status"] == "error"
def test_dockerflow_heartbeat_tolerates_warnings(client):
"""A warning in the check should not affect the probes."""
def warning_check(**kwargs):
return [checks.Warning("Unapplied migration", id="health.W001")]
with mock.patch.object(
django_check_registry, "get_checks", return_value=[warning_check]
):
response = client.get("/__heartbeat__")
assert response.status_code == 200
assert response.json()["status"] == "warning"
@@ -157,7 +157,6 @@ def test_models_recording_is_savable_normal():
@pytest.mark.parametrize(
"status",
[
RecordingStatusChoices.FAILED,
RecordingStatusChoices.FAILED_TO_STOP,
RecordingStatusChoices.FAILED_TO_START,
RecordingStatusChoices.ABORTED,
@@ -280,7 +279,6 @@ def test_models_recording_is_saved_false_initiated():
@pytest.mark.parametrize(
"status",
[
RecordingStatusChoices.FAILED,
RecordingStatusChoices.FAILED_TO_STOP,
RecordingStatusChoices.FAILED_TO_START,
RecordingStatusChoices.ABORTED,
+1 -8
View File
@@ -287,7 +287,6 @@ class Base(Configuration):
MIDDLEWARE = [
"django.middleware.security.SecurityMiddleware",
"dockerflow.django.middleware.DockerflowMiddleware",
"whitenoise.middleware.WhiteNoiseMiddleware",
"django.contrib.sessions.middleware.SessionMiddleware",
"django.middleware.locale.LocaleMiddleware",
@@ -297,6 +296,7 @@ class Base(Configuration):
"django.middleware.csrf.CsrfViewMiddleware",
"django.contrib.auth.middleware.AuthenticationMiddleware",
"django.contrib.messages.middleware.MessageMiddleware",
"dockerflow.django.middleware.DockerflowMiddleware",
]
AUTHENTICATION_BACKENDS = [
@@ -1173,13 +1173,6 @@ class Base(Configuration):
environ_prefix="",
)
},
"dockerflow": {
"level": values.Value(
"WARNING",
environ_name="LOGGING_LEVEL_DOCKERFLOW",
environ_prefix="",
)
},
"core": {
"handlers": ["console"],
"level": values.Value(
+2 -2
View File
@@ -7,7 +7,7 @@ build-backend = "uv_build"
[project]
name = "meet"
version = "1.32.1"
version = "1.31.0"
authors = [{ "name" = "DINUM", "email" = "dev@mail.numerique.gouv.fr" }]
classifiers = [
"Development Status :: 5 - Production/Stable",
@@ -41,7 +41,7 @@ dependencies = [
"django-timezone-field>=5.1",
"django-pydantic-field==0.5.4",
"django==5.2.16",
"djangorestframework==3.17.2",
"djangorestframework==3.17.1",
"drf_spectacular==0.30.0",
"dockerflow==2026.3.4",
"easy_thumbnails==2.10.1",
+5 -5
View File
@@ -754,14 +754,14 @@ wheels = [
[[package]]
name = "djangorestframework"
version = "3.17.2"
version = "3.17.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "django" },
]
sdist = { url = "https://files.pythonhosted.org/packages/3b/35/c96055e700fdff25da3a7b7756cfd1d4dc54f38b9bc6d6c5e19e3a0fdc20/djangorestframework-3.17.2.tar.gz", hash = "sha256:89ed713b6dc83e1539f214b7d10808ae19bb8511004beba886225da6d5c9dafa", size = 906683, upload-time = "2026-08-05T07:47:22.5Z" }
sdist = { url = "https://files.pythonhosted.org/packages/ca/d7/c016e69fac19ff8afdc89db9d31d9ae43ae031e4d1993b20aca179b8301a/djangorestframework-3.17.1.tar.gz", hash = "sha256:a6def5f447fe78ff853bff1d47a3c59bf38f5434b031780b351b0c73a62db1a5", size = 905742, upload-time = "2026-03-24T16:58:33.705Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/a2/46/c14108e400b208c394325eb63fbae06c81341b6447fa1a6f9da718b17fe7/djangorestframework-3.17.2-py3-none-any.whl", hash = "sha256:cb0546a7415d5b46c04e0f4fe0a54b2109f4fdd5e83ca773c8c6183a6493d042", size = 899109, upload-time = "2026-08-05T07:47:20.853Z" },
{ url = "https://files.pythonhosted.org/packages/5a/e1/2c516bdc83652b1a60c6119366ac2c0607b479ed05cd6093f916ca8928f8/djangorestframework-3.17.1-py3-none-any.whl", hash = "sha256:c3c74dd3e83a5a3efc37b3c18d92bd6f86a6791c7b7d4dff62bb068500e76457", size = 898844, upload-time = "2026-03-24T16:58:31.845Z" },
]
[[package]]
@@ -1187,7 +1187,7 @@ wheels = [
[[package]]
name = "meet"
version = "1.32.1"
version = "1.31.0"
source = { editable = "." }
dependencies = [
{ name = "aiohttp" },
@@ -1273,7 +1273,7 @@ requires-dist = [
{ name = "django-redis", specifier = "==7.0.0" },
{ name = "django-storages", extras = ["s3"], specifier = "==1.14.6" },
{ name = "django-timezone-field", specifier = ">=5.1" },
{ name = "djangorestframework", specifier = "==3.17.2" },
{ name = "djangorestframework", specifier = "==3.17.1" },
{ name = "dockerflow", specifier = "==2026.3.4" },
{ name = "drf-spectacular", specifier = "==0.30.0" },
{ name = "easy-thumbnails", specifier = "==2.10.1" },
+38
View File
@@ -0,0 +1,38 @@
# Global options
{
auto_https off
admin off
}
:{$PORT} {
root * /usr/share/nginx/html
encode gzip
# Vite fingerprints everything under /assets, so a given URL's bytes never
# change: cache it forever. A new build emits new hashed URLs.
@immutable path /assets/*
header @immutable Cache-Control "public, max-age=2592000, immutable"
# The SPA shell and other non-fingerprinted files must revalidate every
# load, or a deploy's new asset hashes only show up after a hard refresh.
@revalidate not path /assets/*
header @revalidate {
Cache-Control "no-cache, no-store, must-revalidate"
Pragma "no-cache"
Expires 0
}
route {
import /etc/caddy/media-proxy.caddy
try_files {path} /index.html
file_server
}
handle_errors {
@spa_404 expression `{err.status_code} == 404`
handle @spa_404 {
rewrite * /index.html
file_server
}
}
}
+13 -14
View File
@@ -41,25 +41,24 @@ ENV VITE_APP_TITLE=${VITE_APP_TITLE}
RUN npm run build
# ---- Caddy builder image ----
FROM caddy:2.11.4-builder AS caddy-builder
RUN xcaddy build --with github.com/caddyserver/replace-response
RUN apk add --no-cache libcap && \
setcap -r /usr/bin/caddy
# ---- Front-end image ----
FROM nginxinc/nginx-unprivileged:1.30.4-alpine3.24 AS frontend-production
USER root
RUN apk upgrade --no-cache libexpat && \
apk del curl
USER nginx
# Un-privileged user running the application
ARG DOCKER_USER
USER ${DOCKER_USER}
FROM gcr.io/distroless/static-debian12:nonroot AS frontend-production
COPY --from=caddy-builder /usr/bin/caddy /usr/bin/caddy
COPY --from=meet-builder \
/home/frontend/dist \
/usr/share/nginx/html
COPY ./src/frontend/default.conf /etc/nginx/conf.d
COPY ./docker/files/usr/local/bin/entrypoint /usr/local/bin/entrypoint
COPY ./src/frontend/Caddyfile /etc/caddy/Caddyfile
COPY ./docker/caddy/media-proxy.caddy /etc/caddy/media-proxy.caddy
ENTRYPOINT [ "/usr/local/bin/entrypoint" ]
ENV PORT=8080
CMD ["nginx", "-g", "daemon off;"]
ENTRYPOINT ["caddy", "run", "--config", "/etc/caddy/Caddyfile", "--adapter", "caddyfile"]
-30
View File
@@ -1,30 +0,0 @@
server {
listen 8080;
server_name localhost;
server_tokens off;
root /usr/share/nginx/html;
location ^~ /assets/mediapipe/wasm/ {
expires 30d;
add_header Cache-Control "public, max-age=2592000";
}
# Serve static files with caching
location ~* ^/assets/.*\.(css|js|json|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
expires 30d;
add_header Cache-Control "public, max-age=2592000";
}
# Serve static files
location / {
try_files $uri $uri/ /index.html;
# Add no-cache headers
add_header Cache-Control "no-cache, no-store, must-revalidate";
add_header Pragma "no-cache"; # HTTP 1.0 header for backward compatibility
add_header Expires 0;
}
# Optionally, handle 404 errors by redirecting to index.html
error_page 404 =200 /index.html;
}
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "meet",
"version": "1.32.1",
"version": "1.31.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "meet",
"version": "1.32.1",
"version": "1.31.0",
"dependencies": {
"@fontsource-variable/atkinson-hyperlegible-next": "5.3.0",
"@fontsource-variable/lexend": "5.3.0",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "meet",
"private": true,
"version": "1.32.1",
"version": "1.31.0",
"type": "module",
"scripts": {
"dev": "panda codegen && vite",
@@ -96,10 +96,6 @@ export const MainNotificationToast = () => {
case NotificationType.ScreenRecordingStopped:
case NotificationType.TranscriptionLimitReached:
case NotificationType.ScreenRecordingLimitReached:
case NotificationType.TranscriptionFailed:
case NotificationType.ScreenRecordingFailed:
case NotificationType.TranscriptionAborted:
case NotificationType.ScreenRecordingAborted:
toastQueue.add(
{
participant,
@@ -1,6 +1,5 @@
import { Div } from '@/primitives'
import { ToastProvider } from './components/ToastProvider'
import { RecordingWaitingForTracksNotification } from './components/RecordingWaitingForTracksNotification'
import { WaitingParticipantNotification } from './components/WaitingParticipantNotification'
export const NotificationProvider = ({
@@ -13,6 +12,5 @@ export const NotificationProvider = ({
<Div position="absolute" bottom={bottom} right={right} zIndex={1000}>
<ToastProvider />
<WaitingParticipantNotification />
<RecordingWaitingForTracksNotification />
</Div>
)
@@ -10,15 +10,11 @@ export enum NotificationType {
TranscriptionStarted = 'transcriptionStarted',
TranscriptionStopped = 'transcriptionStopped',
TranscriptionLimitReached = 'transcriptionLimitReached',
TranscriptionFailed = 'transcriptionFailed',
TranscriptionAborted = 'transcriptionAborted',
TranscriptionRequested = 'transcriptionRequested',
ScreenRecordingStarted = 'screenRecordingStarted',
ScreenRecordingStopped = 'screenRecordingStopped',
ScreenRecordingRequested = 'screenRecordingRequested',
ScreenRecordingLimitReached = 'screenRecordingLimitReached',
ScreenRecordingFailed = 'screenRecordingFailed',
ScreenRecordingAborted = 'screenRecordingAborted',
RecordingSaving = 'recordingSaving',
PermissionsRemoved = 'permissionsRemoved',
RoleChanged = 'roleChanged',
@@ -1,41 +0,0 @@
import { useTranslation } from 'react-i18next'
import { Text } from '@/primitives'
import { css } from '@/styled-system/css'
import { HStack } from '@/styled-system/jsx'
import { RecordingMode } from '@/features/recording'
import { useRecordingWaitingForTracks } from '@/features/recording/hooks/useRecordingWaitingForTracks'
import { StyledToastContainer } from './StyledToastContainer'
export const RecordingWaitingForTracksNotification = () => {
const { t } = useTranslation('notifications')
const isTranscriptWaiting = useRecordingWaitingForTracks(
RecordingMode.Transcript
)
const isScreenRecordingWaiting = useRecordingWaitingForTracks(
RecordingMode.ScreenRecording
)
if (!isTranscriptWaiting && !isScreenRecordingWaiting) return null
return (
<StyledToastContainer role="status">
<HStack padding={14}>
<Text
margin={false}
className={css({
maxWidth: '22rem',
wordBreak: 'break-word',
overflowWrap: 'break-word',
whiteSpace: 'normal',
})}
>
{t(
isTranscriptWaiting
? 'transcript.waitingForTracks'
: 'screenRecording.waitingForTracks'
)}
</Text>
</HStack>
</StyledToastContainer>
)
}
@@ -22,20 +22,12 @@ export function ToastAnyRecording({ state, ...props }: Readonly<ToastProps>) {
return 'transcript.stopped'
case NotificationType.TranscriptionLimitReached:
return 'transcript.limitReached'
case NotificationType.TranscriptionFailed:
return 'transcript.failed'
case NotificationType.TranscriptionAborted:
return 'transcript.aborted'
case NotificationType.ScreenRecordingStarted:
return 'screenRecording.started'
case NotificationType.ScreenRecordingStopped:
return 'screenRecording.stopped'
case NotificationType.ScreenRecordingLimitReached:
return 'screenRecording.limitReached'
case NotificationType.ScreenRecordingFailed:
return 'screenRecording.failed'
case NotificationType.ScreenRecordingAborted:
return 'screenRecording.aborted'
default:
return
}
@@ -58,10 +58,6 @@ const renderToast = (
case NotificationType.ScreenRecordingStarted:
case NotificationType.ScreenRecordingStopped:
case NotificationType.ScreenRecordingLimitReached:
case NotificationType.TranscriptionFailed:
case NotificationType.ScreenRecordingFailed:
case NotificationType.TranscriptionAborted:
case NotificationType.ScreenRecordingAborted:
return <ToastAnyRecording key={toast.key} toast={toast} state={state} />
case NotificationType.TranscriptionRequested:
@@ -20,7 +20,6 @@ import { Track } from 'livekit-client'
import { ParticipantPlaceholder } from './ParticipantPlaceholder'
import { ParticipantTileFocus } from './participantTileFocus/ParticipantTileFocus'
import { FullScreenShareWarning } from './FullScreenShareWarning'
import { ScreenShareZoomableVideo } from '@/features/rooms/livekit/components/ScreenShareZoomableVideo'
import { useTranslation } from 'react-i18next'
import { getShortcutDescriptorById } from '@/features/shortcuts/catalog'
import { formatShortcutLabel } from '@/features/shortcuts/formatLabels'
@@ -90,8 +89,6 @@ export const ParticipantTile: (
)
const isScreenShare = trackReference.source != Track.Source.Camera
const isRemoteScreenShare =
isScreenShare && !trackReference.participant.isLocal
const [hasKeyboardFocus, setHasKeyboardFocus] = React.useState(false)
const participantColor = getParticipantColor(trackReference.participant)
@@ -101,23 +98,11 @@ export const ParticipantTile: (
})
const participantName = name || identity || 'Unknown'
// tileRef: fullscreen target, and the node the focus overlay listens on.
// 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 interactiveProps = {
...elementProps,
// Ensure the tile is focusable to expose contextual controls to keyboard users.
tabIndex: 0,
'aria-label': t('containerLabel', { name: participantName }),
onFocus: (event: React.FocusEvent<HTMLDivElement>) => {
@@ -135,41 +120,8 @@ export const ParticipantTile: (
},
}
const isVideoTrack =
isTrackReference(trackReference) &&
trackReference.publication.kind === 'video'
let trackMedia: React.ReactNode = null
if (isVideoTrack) {
const videoTrack = (
<VideoTrack
trackRef={trackReference}
onSubscriptionStatusChanged={handleSubscribe}
manageSubscription={autoManageSubscription}
/>
)
// Zoom toolbar stays out of picture-in-picture: that window has its own
// document and the fullscreen API is off. Follow-up PR can restore zoom
// there without the dead fullscreen button.
trackMedia =
isRemoteScreenShare && !disableTileControls ? (
<ScreenShareZoomableVideo tileRef={tileRef}>
{videoTrack}
</ScreenShareZoomableVideo>
) : (
videoTrack
)
} else if (isTrackReference(trackReference)) {
trackMedia = (
<AudioTrack
trackRef={trackReference}
onSubscriptionStatusChanged={handleSubscribe}
/>
)
}
return (
<div ref={setRefs} style={{ position: 'relative' }} {...interactiveProps}>
<div ref={ref} style={{ position: 'relative' }} {...interactiveProps}>
<TrackRefContextIfNeeded trackRef={trackReference}>
<ParticipantContextIfNeeded participant={trackReference.participant}>
{trackReference.participant.isLocal && (
@@ -177,7 +129,23 @@ export const ParticipantTile: (
)}
{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">
<ParticipantPlaceholder
color={participantColor}
@@ -196,7 +164,6 @@ export const ParticipantTile: (
{!disableMetadata && !disableTileControls && (
<ParticipantTileFocus
trackRef={trackReference}
tileRef={tileRef}
hasKeyboardFocus={hasKeyboardFocus}
/>
)}
@@ -7,64 +7,38 @@ import { useCanMute } from '@/features/rooms/livekit/hooks/useCanMute'
import { FocusButton } from './FocusButton'
import { EffectsButton } from './EffectsButton'
import { MuteButton } from './MuteButton'
import { ZoomButton } from './ZoomButton'
const MOUSE_IDLE_TIME = 3000
type FadeOverlayProps = {
children: ReactNode
hasKeyboardFocus: boolean
tileRef: React.RefObject<HTMLDivElement | null>
}
// Pointer-events none so this overlay doesn't block the zoom surface below.
// The tile node still gets the mouse events, so we listen on it directly
// rather than lifting the state up: this keeps mouse moves from re-rendering
// the tile and the video it contains.
const FadeOverlay = ({
children,
hasKeyboardFocus,
tileRef,
}: FadeOverlayProps) => {
const FadeOverlay = ({ children, hasKeyboardFocus }: FadeOverlayProps) => {
const [active, setActive] = useState(false)
const idleTimerRef = useRef<number | null>(null)
useEffect(() => {
const tile = tileRef.current
if (!tile) return
const clearIdleTimer = () => {
if (idleTimerRef.current) window.clearTimeout(idleTimerRef.current)
}
const clearIdleTimer = () => {
if (idleTimerRef.current) window.clearTimeout(idleTimerRef.current)
idleTimerRef.current = null
}
const handleActivity = () => {
setActive(true)
clearIdleTimer()
idleTimerRef.current = window.setTimeout(
() => setActive(false),
MOUSE_IDLE_TIME
)
}
const handleLeave = () => {
clearIdleTimer()
const armIdleTimer = () => {
clearIdleTimer()
idleTimerRef.current = window.setTimeout(() => {
setActive(false)
}
}, MOUSE_IDLE_TIME)
}
tile.addEventListener('mouseenter', handleActivity)
tile.addEventListener('mousemove', handleActivity)
tile.addEventListener('mouseleave', handleLeave)
const handleActivity = () => {
setActive(true)
armIdleTimer()
}
return () => {
clearIdleTimer()
tile.removeEventListener('mouseenter', handleActivity)
tile.removeEventListener('mousemove', handleActivity)
tile.removeEventListener('mouseleave', handleLeave)
}
}, [tileRef])
useEffect(() => clearIdleTimer, [])
const isVisible = hasKeyboardFocus || active
return (
<div
className={css({
@@ -76,10 +50,15 @@ const FadeOverlay = ({
alignItems: 'center',
width: '100%',
height: '100%',
pointerEvents: 'none',
})}
data-visible={isVisible || undefined}
aria-hidden={!isVisible}
onMouseEnter={handleActivity}
onMouseMove={handleActivity}
onMouseLeave={() => {
clearIdleTimer()
setActive(false)
}}
>
{isVisible && children}
</div>
@@ -88,11 +67,9 @@ const FadeOverlay = ({
export const ParticipantTileFocus = ({
trackRef,
tileRef,
hasKeyboardFocus,
}: {
trackRef: TrackReferenceOrPlaceholder
tileRef: React.RefObject<HTMLDivElement | null>
hasKeyboardFocus: boolean
}) => {
const participant = trackRef.participant
@@ -101,7 +78,7 @@ export const ParticipantTileFocus = ({
const canMute = useCanMute(participant)
return (
<FadeOverlay hasKeyboardFocus={hasKeyboardFocus} tileRef={tileRef}>
<FadeOverlay hasKeyboardFocus={hasKeyboardFocus}>
<div
className={css({
backgroundColor: 'primaryDark.50',
@@ -110,7 +87,6 @@ export const ParticipantTileFocus = ({
display: 'flex',
opacity: 0.6,
animation: 'overlayIn 200ms linear 300ms backwards',
pointerEvents: 'auto',
_hover: {
opacity: 0.95,
},
@@ -118,7 +94,7 @@ export const ParticipantTileFocus = ({
>
<HStack gap={0.5} padding={0.5}>
<FocusButton trackRef={trackRef} />
{!isScreenShare && (
{!isScreenShare ? (
<>
{isLocal ? (
<EffectsButton />
@@ -126,6 +102,8 @@ export const ParticipantTileFocus = ({
canMute && <MuteButton participant={participant} />
)}
</>
) : (
!isLocal && <ZoomButton trackRef={trackRef} />
)}
</HStack>
</div>
@@ -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,29 +0,0 @@
import { useEffect, useState } from 'react'
import { useTracks } from '@livekit/components-react'
import { Track } from 'livekit-client'
import { RecordingMode } from '@/features/recording'
import { useRecordingStatuses } from './useRecordingStatuses'
const STARTING_NOTIFICATION_DELAY = 15_000
export const useRecordingWaitingForTracks = (mode: RecordingMode) => {
const { isStarting } = useRecordingStatuses(mode)
const tracks = useTracks(
mode === RecordingMode.Transcript ? [Track.Source.Microphone] : undefined,
{ onlySubscribed: false }
)
const [delayElapsed, setDelayElapsed] = useState(false)
useEffect(() => {
setDelayElapsed(false)
if (!isStarting) return
const timeout = window.setTimeout(
() => setDelayElapsed(true),
STARTING_NOTIFICATION_DELAY
)
return () => window.clearTimeout(timeout)
}, [isStarting, mode])
return isStarting && delayElapsed && tracks.length === 0
}
+2 -3
View File
@@ -9,9 +9,8 @@ export enum RecordingStatus {
Stopped = 'stopped',
Saved = 'saved',
Aborted = 'aborted',
Failed = 'failed',
FailedToStart = 'failed_to_start',
FailedToStop = 'failed_to_stop',
FailedToStart = 'failedToStart',
FailedToStop = 'failedToStop',
NotificationSucceed = 'notification_succeeded',
ExternalProcessSuccessful = 'external_process_successful',
ExternalProcessFailed = 'external_process_failed',
@@ -1,76 +0,0 @@
import { Button } from '@/primitives'
import { RiCollapseDiagonalLine, RiExpandDiagonalLine } from '@remixicon/react'
import { memo, useCallback, useEffect, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useScreenReaderAnnounce } from '@/hooks/useScreenReaderAnnounce'
// Keeps the fullscreen state here rather than on the toolbar, so entering or
// leaving fullscreen does not re-render the zoom controls.
export const ScreenShareFullscreenButton = memo(
({
containerRef,
}: {
containerRef: React.RefObject<HTMLDivElement | null>
}) => {
const { t } = useTranslation('rooms', { keyPrefix: 'screenShareZoom' })
const announce = useScreenReaderAnnounce()
const [isFullscreen, setIsFullscreen] = useState(false)
// Tracks whether this tile's container triggered fullscreen (vs another share's).
const wasThisTileFullscreen = useRef(false)
// Covers Esc and browser UI exits, not just this 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])
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])
if (!document.fullscreenEnabled) return null
return (
<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>
)
}
)
ScreenShareFullscreenButton.displayName = 'ScreenShareFullscreenButton'
@@ -1,165 +0,0 @@
import { css } from '@/styled-system/css'
import { Button } from '@/primitives'
import {
RiFullscreenExitLine,
RiZoomInLine,
RiZoomOutLine,
} from '@remixicon/react'
import { useTranslation } from 'react-i18next'
import { Toolbar } from 'react-aria-components'
import { useEffect, useRef } from 'react'
import { isMacintosh } from '@/utils/livekit'
import { srOnly } from '@/styles/a11y'
import { ScreenShareFullscreenButton } from './ScreenShareFullscreenButton'
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 zoomInButtonRef = useRef<HTMLButtonElement>(null)
const hadFocusInCollapsibleRef = useRef(false)
// 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 wheelShortcut = t(isMacintosh() ? 'wheelShortcutMac' : 'wheelShortcut')
return (
<div
className={css({
position: 'absolute',
bottom: '12px',
right: '12px',
zIndex: 2,
pointerEvents: 'auto',
})}
>
<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: wheelShortcut,
})}
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: wheelShortcut })}
aria-label={t('zoomIn')}
isDisabled={!canZoomIn}
onPress={onZoomIn}
>
<RiZoomInLine size={20} />
</Button>
<ScreenShareFullscreenButton containerRef={containerRef} />
</Toolbar>
</div>
)
}
@@ -1,98 +0,0 @@
import { css } from '@/styled-system/css'
import { useEffect, useRef, type ReactNode } from 'react'
import { useTranslation } from 'react-i18next'
import { useScreenShareZoom } from '../hooks/useScreenShareZoom'
import { useScreenReaderAnnounce } from '@/hooks/useScreenReaderAnnounce'
import { ScreenShareZoomControls } from './ScreenShareZoomControls'
interface ScreenShareZoomableVideoProps {
tileRef: React.RefObject<HTMLDivElement | null>
children: ReactNode
}
// The video comes in as children so that a zoom change, which only re-renders
// this wrapper, leaves the video subtree untouched.
export const ScreenShareZoomableVideo = ({
tileRef,
children,
}: 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',
}}
>
{children}
</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}
/>
</>
)
}
@@ -1,16 +1,17 @@
import { useEffect } from 'react'
import { useRoomContext } from '@livekit/components-react'
import {
type RemoteTrack,
type RemoteParticipant,
type RemoteTrackPublication,
RoomEvent,
Track,
VideoQuality,
} from 'livekit-client'
import { useSnapshot } from 'valtio'
import { userChoicesStore } from '@/stores/userChoices'
/**
* Applies the saved reception quality to every remote camera.
* Sets initial video quality for new participants as they join.
* LiveKit doesn't allow handling video quality preferences at the room level.
*/
export const VideoResolutionSubscription = () => {
@@ -18,40 +19,30 @@ export const VideoResolutionSubscription = () => {
const room = useRoomContext()
useEffect(() => {
if (!room || videoSubscribeQuality === undefined) return
if (!room) return
const applyQuality = (publication: RemoteTrackPublication) => {
const handleTrackPublished = (
publication: RemoteTrackPublication,
_participant: RemoteParticipant
) => {
// By default, the maximum quality is set to high
if (
publication.kind !== Track.Kind.Video ||
publication.source === Track.Source.ScreenShare ||
publication.videoQuality === videoSubscribeQuality
) {
videoSubscribeQuality === undefined ||
videoSubscribeQuality === VideoQuality.HIGH
)
return
if (
publication.kind === Track.Kind.Video &&
publication.source !== Track.Source.ScreenShare
) {
publication.setVideoQuality(videoSubscribeQuality)
}
publication.setVideoQuality(videoSubscribeQuality)
}
// Cameras we are already receiving: those published before this effect ran,
// and all of them again whenever the preference changes mid-call.
room.remoteParticipants.forEach((participant) =>
participant.videoTrackPublications.forEach(applyQuality)
)
const handleTrackPublished = (publication: RemoteTrackPublication) =>
applyQuality(publication)
// TrackPublished is not raised for cameras that were already sending when we
// joined, but it is the earliest point for the ones that start after us.
const handleTrackSubscribed = (
_track: RemoteTrack,
publication: RemoteTrackPublication
) => applyQuality(publication)
room.on(RoomEvent.TrackPublished, handleTrackPublished)
room.on(RoomEvent.TrackSubscribed, handleTrackSubscribed)
return () => {
room.off(RoomEvent.TrackPublished, handleTrackPublished)
room.off(RoomEvent.TrackSubscribed, handleTrackSubscribed)
}
}, [room, videoSubscribeQuality])
@@ -1,270 +0,0 @@
import { useCallback, useRef, useState } from 'react'
import { useMove } from 'react-aria'
import type { MoveMoveEvent } from '@react-types/shared'
import {
FULL_PICTURE_RATIO,
MAX_ZOOM,
MIN_ZOOM,
PAN_STEP,
WHEEL_ZOOM_SPEED,
ZOOM_STEP,
type PanOffset,
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 dragging and panning never re-render.
* React state only tracks what the toolbar displays, and only updates when
* the zoom level actually changes.
*
* Drag/touch panning is handled by react-aria's useMove (moveProps).
* The wheel listener (non-passive) zooms on Ctrl/Cmd+scroll and pans on a
* two-finger trackpad scroll once zoomed.
* 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)
// Mirrors zoomRef for the toolbar. Panning never publishes: the toolbar
// shows the zoom level, not the position.
const [zoomLevel, setZoomLevel] = useState(MIN_ZOOM)
const syncToolbar = useCallback(() => setZoomLevel(zoomRef.current), [])
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 setZoom = useCallback(
(next: number) => {
zoomRef.current = next
panRef.current =
next <= MIN_ZOOM
? { x: 0, y: 0 }
: clampPan(panRef.current, next, readPictureRatio())
applyTransform()
applyCursor()
syncToolbar()
},
[applyTransform, applyCursor, syncToolbar, readPictureRatio]
)
const zoomIn = useCallback(
() => setZoom(clampZoom(zoomRef.current + ZOOM_STEP)),
[setZoom]
)
const zoomOut = useCallback(
() => setZoom(clampZoom(zoomRef.current - ZOOM_STEP)),
[setZoom]
)
const resetZoom = useCallback(() => setZoom(MIN_ZOOM), [setZoom])
// Cancels the scale() that multiplies translate(), so the picture follows
// the pointer 1:1 and keyboard steps keep the same visual size.
const panBy = useCallback(
(dx: number, dy: number) => {
const zoom = zoomRef.current
panRef.current = clampPan(
{
x: panRef.current.x + dx / zoom,
y: panRef.current.y + dy / zoom,
},
zoom,
readPictureRatio()
)
applyTransform()
},
[applyTransform, readPictureRatio]
)
// Must be attached with { passive: false } so preventDefault() blocks
// the browser's native Ctrl+scroll page zoom. Trackpad pinch arrives
// here as a wheel event with ctrl/cmd already set.
const handleWheel = useCallback(
(e: WheelEvent) => {
if (e.ctrlKey || e.metaKey) {
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()
syncToolbar()
return
}
// Two-finger trackpad scroll: pan only once zoomed, otherwise leave
// the event alone so the page can still scroll.
if (zoomRef.current <= MIN_ZOOM) return
const el = surfaceElRef.current
if (!el) return
e.preventDefault()
e.stopPropagation()
const { deltaXPercent, deltaYPercent } = getPanDeltaPercentsFromMove(
-e.deltaX,
-e.deltaY,
el
)
panBy(deltaXPercent, deltaYPercent)
},
[applyTransform, applyCursor, syncToolbar, readPictureRatio, panBy]
)
// 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()
},
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
)
panBy(deltaXPercent, deltaYPercent)
},
onMoveEnd() {
draggingRef.current = false
applyTransform()
applyCursor()
},
})
// 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 {
zoomPercentage: Math.round(zoomLevel * 100),
isZoomed: zoomLevel > MIN_ZOOM,
canZoomIn: zoomLevel < MAX_ZOOM,
canZoomOut: zoomLevel > MIN_ZOOM,
transformElRef,
surfaceElRef,
moveProps,
zoomIn,
zoomOut,
resetZoom,
handleWheel,
handleKeyDown,
}
}
@@ -1,128 +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 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 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,
}
}
@@ -34,7 +34,7 @@ const EMPTY_PROPS = {}
export const VideoTab = ({ id }: VideoTabProps) => {
const { t } = useTranslation('settings', { keyPrefix: 'video' })
const room = useRoomContext()
const { localParticipant } = room
const { localParticipant, remoteParticipants } = room
const {
videoDeviceId,
@@ -88,6 +88,22 @@ export const VideoTab = ({ id }: VideoTabProps) => {
})
}
/**
* Updates video quality for all existing remote video tracks when user preference changes.
* LiveKit doesn't support setting video quality preferences at the room level for remote participants,
* so this function applies the selected quality to all existing remote video tracks.
* Hook useVideoResolutionSubscription updates quality preferences of new participants joining.
*/
const updateExistingRemoteVideoQuality = (selectedQuality: VideoQuality) => {
remoteParticipants.forEach((participant) => {
participant.videoTrackPublications.forEach((publication) => {
if (publication.videoQuality !== selectedQuality) {
publication.setVideoQuality(selectedQuality)
}
})
})
}
useEffect(() => {
let videoTrack: LocalVideoTrack | null = null
@@ -215,7 +231,9 @@ export const VideoTab = ({ id }: VideoTabProps) => {
selectedKey={videoSubscribeQuality?.toString()}
onSelectionChange={(key) => {
if (key == undefined) return
saveVideoSubscribeQuality(Number(String(key)))
const selectedQuality = Number(String(key))
saveVideoSubscribeQuality(selectedQuality)
updateExistingRemoteVideoQuality(selectedQuality)
}}
style={{
width: '100%',
+1 -28
View File
@@ -16,10 +16,6 @@ export type ShortcutId =
| 'recording'
| 'reaction'
| 'fullscreen'
| 'zoom-in'
| 'zoom-out'
| 'zoom-reset'
| 'zoom-pan'
export const getShortcutDescriptorById = (id: ShortcutId) =>
shortcutCatalog.find((item) => item.id === id)
@@ -28,7 +24,7 @@ export type ShortcutDescriptor = {
id: ShortcutId
category: ShortcutCategory
shortcut?: Shortcut
kind?: 'press' | 'longPress' | 'arrows'
kind?: 'press' | 'longPress'
code?: string // used when kind === 'longPress' (KeyboardEvent.code)
description?: string
}
@@ -90,27 +86,4 @@ export const shortcutCatalog: ShortcutDescriptor[] = [
category: 'interaction',
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,
plusLabel,
noShortcutLabel,
keyLabels,
}: {
controlLabel: string
commandLabel: string
@@ -34,12 +33,10 @@ export const formatShortcutLabelForSR = (
shiftLabel: string
plusLabel: string
noShortcutLabel: string
// Spelled-out names for keys screen readers may skip or mispronounce.
keyLabels?: Record<string, string>
}
) => {
if (!shortcut) return noShortcutLabel
const key = keyLabels?.[shortcut.key] ?? shortcut.key?.toUpperCase()
const key = shortcut.key?.toUpperCase()
if (!key) return noShortcutLabel
const ctrlWord = isMacintosh() ? commandLabel : controlLabel
const altWord = isMacintosh() ? optionLabel : altLabel
@@ -12,9 +12,6 @@ export const useShortcutFormatting = () => {
const formatVisual = useCallback(
(shortcut?: Shortcut, code?: string, kind?: string) => {
if (kind === 'arrows') {
return t('shortcutsPanel.visual.arrows')
}
if (code && kind === 'longPress') {
const label = getKeyLabelFromCode(code)
return t('shortcutsPanel.visual.hold', { key: label || '?' })
@@ -26,9 +23,6 @@ export const useShortcutFormatting = () => {
const formatForSR = useCallback(
(shortcut?: Shortcut, code?: string, kind?: string) => {
if (kind === 'arrows') {
return t('shortcutsPanel.sr.arrows')
}
if (code && kind === 'longPress') {
const label = getKeyLabelFromCode(code)
return t('shortcutsPanel.sr.hold', { key: label || '?' })
@@ -41,10 +35,6 @@ export const useShortcutFormatting = () => {
shiftLabel: t('shortcutsPanel.sr.shift'),
plusLabel: t('shortcutsPanel.sr.plus'),
noShortcutLabel: t('shortcutsPanel.sr.noShortcut'),
keyLabels: {
'+': t('shortcutsPanel.sr.plusKey'),
'-': t('shortcutsPanel.sr.minusKey'),
},
})
},
[t]
@@ -33,21 +33,15 @@
"accept": "Hereinlassen"
},
"transcript": {
"waitingForTracks": "Die Transkription wartet auf ein Mikrofon. Schalten Sie Ihr Mikrofon ein, damit die Transkription starten kann.",
"started": "{{name}} hat die Meeting-Transkription gestartet.",
"stopped": "{{name}} hat die Meeting-Transkription gestoppt.",
"limitReached": "Die Transkription hat die maximal zulässige Dauer überschritten und wird automatisch gespeichert.",
"failed": "Die Transkription wurde unerwartet beendet und konnte nicht gespeichert werden.",
"aborted": "Die Transkription konnte nicht gestartet werden.",
"requested": "{{name}} möchte die Meeting-Transkription starten."
},
"screenRecording": {
"waitingForTracks": "Die Aufnahme wartet auf Audio oder Video. Schalten Sie Ihr Mikrofon oder Ihre Kamera ein oder teilen Sie Ihren Bildschirm, damit die Aufnahme starten kann.",
"started": "{{name}} hat die Meeting-Aufzeichnung gestartet.",
"stopped": "{{name}} hat die Meeting-Aufzeichnung gestoppt.",
"limitReached": "Die Aufzeichnung hat die maximal zulässige Dauer überschritten und wird automatisch gespeichert.",
"failed": "Die Aufzeichnung wurde unerwartet beendet und konnte nicht gespeichert werden.",
"aborted": "Die Aufzeichnung konnte nicht gestartet werden.",
"requested": "{{name}} möchte die Meeting-Aufzeichnung starten."
},
"recordingSave": {
+2 -28
View File
@@ -756,24 +756,6 @@
"muteParticipant": "{{name}} stummschalten",
"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": "⌘+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": {
"title": "Tastenkürzel",
"categories": {
@@ -793,11 +775,7 @@
"raise-hand": "Hand heben oder senken",
"toggle-chat": "Chat anzeigen/ausblenden",
"toggle-participants": "Teilnehmende anzeigen/ausblenden",
"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"
"open-shortcuts-settings": "Tastenkürzel-Einstellungen öffnen"
},
"sr": {
"control": "Steuerung",
@@ -807,14 +785,10 @@
"shift": "Umschalt",
"plus": "plus",
"hold": "Halte {{key}} gedrückt",
"arrows": "Pfeiltasten",
"plusKey": "Plus-Taste",
"minusKey": "Minus-Taste",
"noShortcut": "Kein Tastenkürzel"
},
"visual": {
"hold": "Halte {{key}} gedrückt",
"arrows": "↑ ↓ ← →"
"hold": "Halte {{key}} gedrückt"
}
},
"fullScreenWarning": {
@@ -33,21 +33,15 @@
"accept": "Accept"
},
"transcript": {
"waitingForTracks": "Transcription is waiting for a microphone. Turn on your microphone so the transcription can start.",
"started": "{{name}} started the meeting transcription.",
"stopped": "{{name}} stopped the meeting transcription.",
"limitReached": "The transcription has exceeded the maximum allowed duration and will be automatically saved.",
"failed": "The transcription stopped unexpectedly and could not be saved.",
"aborted": "The transcription could not be started.",
"requested": "{{name}} wants to start the meeting transcription."
},
"screenRecording": {
"waitingForTracks": "Recording is waiting for audio or video. Turn on your microphone or camera, or share your screen, so the recording can start.",
"started": "{{name}} started the meeting recording.",
"stopped": "{{name}} stopped the meeting recording.",
"limitReached": "The recording has exceeded the maximum allowed duration and will be automatically saved.",
"failed": "The recording stopped unexpectedly and could not be saved.",
"aborted": "The recording could not be started.",
"requested": "{{name}} wants to start the meeting recording."
},
"recordingSave": {
+2 -28
View File
@@ -756,24 +756,6 @@
"muteParticipant": "Mute {{name}}",
"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": "⌘+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": {
"title": "Keyboard shortcuts",
"categories": {
@@ -793,11 +775,7 @@
"raise-hand": "Raise or lower hand",
"toggle-chat": "Toggle chat",
"toggle-participants": "Toggle participants",
"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"
"open-shortcuts-settings": "Open shortcuts settings"
},
"sr": {
"control": "Control",
@@ -807,14 +785,10 @@
"shift": "Shift",
"plus": "plus",
"hold": "Hold {{key}}",
"arrows": "Arrow keys",
"plusKey": "Plus key",
"minusKey": "Minus key",
"noShortcut": "No shortcut"
},
"visual": {
"hold": "Hold {{key}}",
"arrows": "↑ ↓ ← →"
"hold": "Hold {{key}}"
}
},
"fullScreenWarning": {
@@ -33,21 +33,15 @@
"accept": "Aceptar"
},
"transcript": {
"waitingForTracks": "La transcripción está esperando un micrófono. Activa el micrófono para que la transcripción pueda comenzar.",
"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.",
"failed": "La transcripción se ha interrumpido de forma inesperada y no se ha podido guardar.",
"aborted": "No se ha podido iniciar la transcripción.",
"requested": "{{name}} ha solicitado iniciar la transcripción."
},
"screenRecording": {
"waitingForTracks": "La grabación está esperando audio o vídeo. Activa el micrófono o la cámara, o comparte la pantalla, para que la grabación pueda comenzar.",
"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.",
"failed": "La grabación se ha interrumpido de forma inesperada y no se ha podido guardar.",
"aborted": "No se ha podido iniciar la grabación.",
"requested": "{{name}} ha solicitado iniciar la grabación."
},
"recordingSave": {
+1 -23
View File
@@ -755,24 +755,6 @@
"muteParticipant": "Silenciar el micrófono de {{name}}",
"fullScreen": "Pantalla completa"
},
"screenShareZoom": {
"toolbarLabel": "Controles de zoom de la pantalla compartida",
"zoomIn": "Acercar",
"zoomInWithShortcut": "Acercar ({{shortcut}})",
"zoomOut": "Alejar",
"zoomOutWithShortcut": "Alejar ({{shortcut}})",
"wheelShortcut": "Control más rueda del ratón",
"wheelShortcutMac": "⌘+rueda del ratón",
"wheelShortcutHint": "Atajo: Control más rueda del ratón para acercar o alejar.",
"wheelShortcutHintMac": "Atajo: Comando más rueda del ratón para acercar o alejar.",
"fitToWindow": "Ajustar a la ventana",
"fullScreen": "Pantalla completa",
"exitFullScreen": "Salir de pantalla completa",
"currentZoomLevel": "Zoom {{level}} %",
"panHint": "Zoom {{level}} %. Arrastra con el ratón, o vuelve a la pantalla compartida y usa las flechas para moverte por la imagen.",
"fullScreenEntered": "Pantalla completa activada",
"fullScreenExited": "Pantalla completa desactivada"
},
"shortcutsPanel": {
"title": "Atajos de teclado",
"categories": {
@@ -792,11 +774,7 @@
"raise-hand": "Levantar o bajar la mano",
"toggle-chat": "Mostrar/Ocultar el chat",
"toggle-participants": "Mostrar/Ocultar los participantes",
"open-shortcuts-settings": "Abrir los ajustes de los atajos",
"zoom-in": "Acercar una pantalla compartida",
"zoom-out": "Alejar una pantalla compartida",
"zoom-reset": "Restablecer el zoom de la pantalla compartida",
"zoom-pan": "Moverse por una pantalla compartida ampliada"
"open-shortcuts-settings": "Abrir los ajustes de los atajos"
},
"sr": {
"control": "Control",
@@ -33,21 +33,15 @@
"accept": "Accepter"
},
"transcript": {
"waitingForTracks": "La transcription attend un microphone. Activez votre microphone pour permettre à la transcription de démarrer.",
"started": "{{name}} a démarré la transcription de la réunion.",
"stopped": "{{name}} a arrêté la transcription de la réunion.",
"limitReached": "La transcription a dépassé la durée maximale autorisée, elle va être automatiquement sauvegardée.",
"failed": "La transcription s'est interrompue de façon inattendue et n'a pas pu être sauvegardée.",
"aborted": "La transcription n'a pas pu être démarrée.",
"requested": "{{name}} a demandé à démarrer la transcription."
},
"screenRecording": {
"waitingForTracks": "L’enregistrement attend un flux audio ou vidéo. Activez votre microphone ou votre caméra, ou partagez votre écran, pour permettre à l’enregistrement de démarrer.",
"started": "{{name}} a démarré l'enregistrement de la réunion.",
"stopped": "{{name}} a arrêté l'enregistrement de la réunion.",
"limitReached": "L'enregistrement a dépassé la durée maximale autorisée, il va être automatiquement sauvegardé.",
"failed": "L'enregistrement s'est interrompu de façon inattendue et n'a pas pu être sauvegardé.",
"aborted": "L'enregistrement n'a pas pu être démarré.",
"requested": "{{name}} a demandé à démarrer l'enregistrement."
},
"recordingSave": {
+2 -28
View File
@@ -756,24 +756,6 @@
"muteParticipant": "Couper le micro de {{name}}",
"fullScreen": "Plein écran"
},
"screenShareZoom": {
"toolbarLabel": "Contrôles de zoom du partage d'écran",
"zoomIn": "Zoomer",
"zoomInWithShortcut": "Zoomer ({{shortcut}})",
"zoomOut": "Dézoomer",
"zoomOutWithShortcut": "Dézoomer ({{shortcut}})",
"wheelShortcut": "Contrôle plus molette",
"wheelShortcutMac": "⌘+molette",
"wheelShortcutHint": "Raccourci : Contrôle plus molette pour zoomer ou dézoomer.",
"wheelShortcutHintMac": "Raccourci : Commande plus molette pour zoomer ou dézoomer.",
"fitToWindow": "Ajuster à la fenêtre",
"fullScreen": "Plein écran",
"exitFullScreen": "Quitter le plein écran",
"currentZoomLevel": "Zoom {{level}} %",
"panHint": "Zoom {{level}} %. Glissez avec la souris, ou revenez sur le partage d'écran et utilisez les touches fléchées pour naviguer dans l'image.",
"fullScreenEntered": "Plein écran activé",
"fullScreenExited": "Plein écran désactivé"
},
"shortcutsPanel": {
"title": "Raccourcis clavier",
"categories": {
@@ -793,11 +775,7 @@
"raise-hand": "Lever ou baisser la main",
"toggle-chat": "Afficher/Masquer le chat",
"toggle-participants": "Afficher/Masquer les participants",
"open-shortcuts-settings": "Ouvrir les réglages des raccourcis",
"zoom-in": "Zoomer sur un écran partagé",
"zoom-out": "Dézoomer sur un écran partagé",
"zoom-reset": "Réinitialiser le zoom de l’écran partagé",
"zoom-pan": "Se déplacer dans un écran partagé zoomé"
"open-shortcuts-settings": "Ouvrir les réglages des raccourcis"
},
"sr": {
"control": "Contrôle",
@@ -807,14 +785,10 @@
"shift": "Majuscule",
"plus": "plus",
"hold": "Maintenir {{key}}",
"arrows": "Touches fléchées",
"plusKey": "Touche plus",
"minusKey": "Touche moins",
"noShortcut": "Aucun raccourci"
},
"visual": {
"hold": "Maintenir {{key}}",
"arrows": "↑ ↓ ← →"
"hold": "Maintenir {{key}}"
}
},
"fullScreenWarning": {
@@ -33,21 +33,15 @@
"accept": "Accepteren"
},
"transcript": {
"waitingForTracks": "De transcriptie wacht op een microfoon. Zet uw microfoon aan, zodat de transcriptie kan starten.",
"started": "{{name}} is de transcriptie van de vergadering gestart.",
"stopped": "{{name}} heeft de transcriptie van de vergadering gestopt.",
"limitReached": "De transcriptie heeft de maximaal toegestane duur overschreden en wordt automatisch opgeslagen.",
"failed": "De transcriptie is onverwacht gestopt en kon niet worden opgeslagen.",
"aborted": "De transcriptie kon niet worden gestart.",
"requested": "{{name}} wil graag de transcriptie starten."
},
"screenRecording": {
"waitingForTracks": "De opname wacht op audio of video. Zet uw microfoon of camera aan of deel uw scherm, zodat de opname kan starten.",
"started": "{{name}} is begonnen met het opnemen van de vergadering.",
"stopped": "{{name}} is gestopt met het opnemen van de vergadering.",
"limitReached": "De opname heeft de maximaal toegestane duur overschreden en wordt automatisch opgeslagen.",
"failed": "De opname is onverwacht gestopt en kon niet worden opgeslagen.",
"aborted": "De opname kon niet worden gestart.",
"requested": "{{name}} wil graag de opname starten."
},
"recordingSave": {
+2 -28
View File
@@ -756,24 +756,6 @@
"muteParticipant": "Demp {{name}}",
"fullScreen": "Volledig scherm"
},
"screenShareZoom": {
"toolbarLabel": "Zoombediening voor schermdeling",
"zoomIn": "Inzoomen",
"zoomInWithShortcut": "Inzoomen ({{shortcut}})",
"zoomOut": "Uitzoomen",
"zoomOutWithShortcut": "Uitzoomen ({{shortcut}})",
"wheelShortcut": "Control plus scrollwiel",
"wheelShortcutMac": "⌘+scrollwiel",
"wheelShortcutHint": "Sneltoets: Control plus scrollwiel om in of uit te zoomen.",
"wheelShortcutHintMac": "Sneltoets: Command plus scrollwiel om in of uit te zoomen.",
"fitToWindow": "Aanpassen aan venster",
"fullScreen": "Volledig scherm",
"exitFullScreen": "Volledig scherm verlaten",
"currentZoomLevel": "Zoom {{level}} %",
"panHint": "Zoom {{level}} %. Sleep met de muis, of zet de focus terug op de schermdeling en gebruik de pijltjestoetsen om door het beeld te navigeren.",
"fullScreenEntered": "Volledig scherm ingeschakeld",
"fullScreenExited": "Volledig scherm uitgeschakeld"
},
"shortcutsPanel": {
"title": "Sneltoetsen",
"categories": {
@@ -793,11 +775,7 @@
"raise-hand": "Hand opsteken of laten zakken",
"toggle-chat": "Chat tonen/verbergen",
"toggle-participants": "Deelnemers tonen/verbergen",
"open-shortcuts-settings": "Sneltoets-instellingen openen",
"zoom-in": "Inzoomen op een gedeeld scherm",
"zoom-out": "Uitzoomen op een gedeeld scherm",
"zoom-reset": "Zoom van het gedeelde scherm herstellen",
"zoom-pan": "Navigeren in een ingezoomd gedeeld scherm"
"open-shortcuts-settings": "Sneltoets-instellingen openen"
},
"sr": {
"control": "Control",
@@ -807,14 +785,10 @@
"shift": "Shift",
"plus": "plus",
"hold": "Houd {{key}} ingedrukt",
"arrows": "Pijltoetsen",
"plusKey": "Plus-toets",
"minusKey": "Min-toets",
"noShortcut": "Geen sneltoets"
},
"visual": {
"hold": "Houd {{key}} ingedrukt",
"arrows": "↑ ↓ ← →"
"hold": "Houd {{key}} ingedrukt"
}
},
"fullScreenWarning": {
-31
View File
@@ -256,37 +256,6 @@ posthog:
ingressAssets:
enabled: false
# ---- Extra ingress/service for recording file downloads -----------
ingressMedia:
enabled: true
host: meet.127.0.0.1.nip.io
annotations:
nginx.ingress.kubernetes.io/auth-url: https://meet.127.0.0.1.nip.io/api/v1.0/recordings/media-auth/
nginx.ingress.kubernetes.io/auth-response-headers: "Authorization, X-Amz-Date, X-Amz-Content-SHA256"
nginx.ingress.kubernetes.io/upstream-vhost: minio.meet.svc.cluster.local:9000
nginx.ingress.kubernetes.io/rewrite-target: /meet-media-storage/$1
serviceMedia:
host: minio.meet.svc.cluster.local
port: 9000
# ---- Extra ingress/service for background file uploads ------------
ingressMediaFiles:
enabled: true
host: meet.127.0.0.1.nip.io
annotations:
nginx.ingress.kubernetes.io/auth-url: https://meet.127.0.0.1.nip.io/api/v1.0/files/media-auth/
nginx.ingress.kubernetes.io/auth-response-headers: "Authorization, X-Amz-Date, X-Amz-Content-SHA256"
nginx.ingress.kubernetes.io/upstream-vhost: minio.meet.svc.cluster.local:9000
nginx.ingress.kubernetes.io/rewrite-target: /meet-media-storage/files/$1
serviceMediaFiles:
host: minio.meet.svc.cluster.local
port: 9000
# ---- STT Orchestration Microservice Components --------------------
summary:
@@ -23,9 +23,6 @@ frontend:
- name: outlook-addon-manifest
configMap:
name: outlook-addon-manifest
- name: frontend-nginx-config
configMap:
name: frontend-nginx-config
extraVolumeMounts:
- name: outlook-addon-config
@@ -36,10 +33,6 @@ frontend:
mountPath: /usr/share/nginx/html/addons/outlook/manifest.xml
subPath: manifest.xml
readOnly: true
- name: frontend-nginx-config
mountPath: /etc/nginx/conf.d/default.conf
subPath: default.conf
readOnly: true
outlookAddon:
enabled: true
@@ -49,5 +42,3 @@ frontend:
appName: "Visio"
id: "a025f0f6-757a-4790-97f3-99c66c4a5795"
frontendNginxConfig:
enabled: true
@@ -55,5 +55,3 @@ agentSubtitles:
- key: cacert.pem
path: cert.pem
frontendNginxConfig:
enabled: false
@@ -1,99 +0,0 @@
{{- if .Values.frontendNginxConfig.enabled }}
apiVersion: v1
kind: ConfigMap
metadata:
name: frontend-nginx-config
namespace: {{ .Release.Namespace }}
data:
default.conf: |
server {
listen 8080;
server_name localhost;
server_tokens off;
root /usr/share/nginx/html;
location = /.well-known/windows-app-web-link {
default_type application/json;
alias /usr/share/nginx/html/.well-known/windows-app-web-link;
add_header Content-Disposition "attachment; filename=windows-app-web-link";
}
# Manifest — fetched, never iframed
location = /addons/outlook/manifest.xml {
alias /usr/share/nginx/html/addons/outlook/manifest.xml;
add_header Access-Control-Allow-Origin "*";
add_header Cache-Control "no-cache, no-store, must-revalidate";
add_header X-Frame-Options "DENY";
add_header Content-Security-Policy "frame-ancestors 'none'";
}
location = /addons/outlook/assets/ {
return 404;
}
location ~* ^/addons/outlook/assets/(.+\.(?:css|js|json|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot))/?$ {
root /usr/share/nginx/html;
expires 30d;
add_header Cache-Control "public, max-age=2592000, immutable" always;
add_header Access-Control-Allow-Origin "*";
add_header Vary "Origin" always;
}
location = /addons/outlook/ {
return 404;
}
location ~ ^/addons/outlook(/.*)?$ {
alias /usr/share/nginx/html/addons/outlook$1;
error_page 404 =200 /index.html;
add_header Cache-Control "no-cache, no-store, must-revalidate";
add_header Pragma "no-cache" always;
add_header Expires 0 always;
set $ms_domains "https://*.live.com https://*.office.com https://*.microsoft.com https://*.office365.com https://*.sharepoint.com";
set $nonce $request_id;
set $csp "default-src 'self'; upgrade-insecure-requests; ";
set $csp "${csp}frame-ancestors ${ms_domains}; ";
set $csp "${csp}script-src 'nonce-${nonce}' 'strict-dynamic'; ";
set $csp "${csp}style-src 'self' 'unsafe-inline'; ";
set $csp "${csp}img-src 'self' data:; ";
set $csp "${csp}font-src 'self' data:; ";
set $csp "${csp}connect-src 'self' ${ms_domains}; ";
set $csp "${csp}frame-src 'none'; ";
set $csp "${csp}object-src 'none'; ";
set $csp "${csp}base-uri 'none'; ";
add_header Content-Security-Policy $csp;
sub_filter 'NONCE_PLACEHOLDER' $nonce;
sub_filter_once off;
}
location ^~ /assets/mediapipe/wasm/ {
expires 30d;
add_header Cache-Control "public, max-age=2592000";
}
# Serve static files with caching
location ~* ^/assets/.*\.(css|js|json|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
expires 30d;
add_header Cache-Control "public, max-age=2592000";
}
# Serve static files
location / {
try_files $uri $uri/ /index.html;
# Add no-cache headers
add_header Cache-Control "no-cache, no-store, must-revalidate";
add_header Pragma "no-cache"; # HTTP 1.0 header for backward compatibility
add_header Expires 0;
}
# Optionally, handle 404 errors by redirecting to index.html
error_page 404 =200 /index.html;
}
{{- end }}
+1 -1
View File
@@ -1,4 +1,4 @@
apiVersion: v2
type: application
name: meet
version: 0.0.28
version: 0.0.27
+21 -47
View File
@@ -34,24 +34,6 @@
| `ingressAdmin.tls.secretName` | Secret name for TLS config | `nil` |
| `ingressAdmin.tls.additional[].secretName` | Secret name for additional TLS config | |
| `ingressAdmin.tls.additional[].hosts[]` | Hosts for additional TLS config | |
| `ingressMedia.enabled` | whether to enable the Ingress or not | `false` |
| `ingressMedia.className` | IngressClass to use for the Ingress | `nil` |
| `ingressMedia.host` | Host for the Ingress | `meet.example.com` |
| `ingressMedia.path` | Path to use for the Ingress | `/media/(.*)` |
| `ingressMedia.hosts` | Additional host to configure for the Ingress | `[]` |
| `ingressMedia.tls.enabled` | Weather to enable TLS for the Ingress | `true` |
| `ingressMedia.tls.secretName` | Secret name for TLS config | `nil` |
| `ingressMedia.tls.additional[].secretName` | Secret name for additional TLS config | |
| `ingressMedia.tls.additional[].hosts[]` | Hosts for additional TLS config | |
| `ingressMedia.annotations.nginx.ingress.kubernetes.io/auth-url` | | `https://meet.example.com/api/v1.0/recordings/media-auth/` |
| `ingressMedia.annotations.nginx.ingress.kubernetes.io/auth-response-headers` | | `Authorization, X-Amz-Date, X-Amz-Content-SHA256` |
| `ingressMedia.annotations.nginx.ingress.kubernetes.io/upstream-vhost` | | `minio.meet.svc.cluster.local:9000` |
| `ingressMedia.annotations.nginx.ingress.kubernetes.io/configuration-snippet` | | `add_header Content-Security-Policy "default-src 'none'" always;
` |
| `serviceMedia.host` | | `minio.meet.svc.cluster.local` |
| `serviceMedia.port` | | `9000` |
| `serviceMedia.annotations` | | `{}` |
### backend
| Name | Description | Value |
@@ -81,24 +63,18 @@
| `backend.migrate.restartPolicy` | backend migrate job restart policy | `Never` |
| `backend.createsuperuser.command` | backend migrate command | `["/bin/sh","-c","while ! python manage.py check --database default > /dev/null 2>&1\ndo\n echo \"Database not ready\"\n sleep 2\ndone\necho \"Database is ready\"\n\npython manage.py createsuperuser --email $DJANGO_SUPERUSER_EMAIL --password $DJANGO_SUPERUSER_PASSWORD\n"]` |
| `backend.createsuperuser.restartPolicy` | backend migrate job restart policy | `Never` |
| `backend.probes.liveness.path` | Configure path for backend HTTP liveness probe | `/__lbheartbeat__` |
| `backend.probes.liveness.path` | Configure path for backend HTTP liveness probe | `/__heartbeat__` |
| `backend.probes.liveness.targetPort` | Configure port for backend HTTP liveness probe | `undefined` |
| `backend.probes.liveness.initialDelaySeconds` | Configure initial delay for backend liveness probe | `undefined` |
| `backend.probes.liveness.periodSeconds` | Configure period for backend liveness probe | `30` |
| `backend.probes.liveness.timeoutSeconds` | Configure timeout for backend liveness probe | `5` |
| `backend.probes.liveness.failureThreshold` | Configure failure threshold for backend liveness probe | `undefined` |
| `backend.probes.startup.path` | Configure path for backend HTTP startup probe | `/__lbheartbeat__` |
| `backend.probes.liveness.initialDelaySeconds` | Configure initial delay for backend liveness probe | `30` |
| `backend.probes.liveness.initialDelaySeconds` | Configure timeout for backend liveness probe | `30` |
| `backend.probes.startup.path` | Configure path for backend HTTP startup probe | `undefined` |
| `backend.probes.startup.targetPort` | Configure port for backend HTTP startup probe | `undefined` |
| `backend.probes.startup.initialDelaySeconds` | Configure initial delay for backend startup probe | `undefined` |
| `backend.probes.startup.periodSeconds` | Configure period for backend startup probe | `5` |
| `backend.probes.startup.timeoutSeconds` | Configure timeout for backend startup probe | `5` |
| `backend.probes.startup.failureThreshold` | Configure failure threshold for backend startup probe | `12` |
| `backend.probes.readiness.path` | Configure path for backend HTTP readiness probe | `/__heartbeat__` |
| `backend.probes.startup.initialDelaySeconds` | Configure timeout for backend startup probe | `undefined` |
| `backend.probes.readiness.path` | Configure path for backend HTTP readiness probe | `/__lbheartbeat__` |
| `backend.probes.readiness.targetPort` | Configure port for backend HTTP readiness probe | `undefined` |
| `backend.probes.readiness.initialDelaySeconds` | Configure initial delay for backend readiness probe | `undefined` |
| `backend.probes.readiness.periodSeconds` | Configure period for backend readiness probe | `30` |
| `backend.probes.readiness.timeoutSeconds` | Configure timeout for backend readiness probe | `5` |
| `backend.probes.readiness.failureThreshold` | Configure failure threshold for backend readiness probe | `3` |
| `backend.probes.readiness.initialDelaySeconds` | Configure initial delay for backend readiness probe | `30` |
| `backend.probes.readiness.initialDelaySeconds` | Configure timeout for backend readiness probe | `30` |
| `backend.resources` | Resource requirements for the backend container | `{}` |
| `backend.nodeSelector` | Node selector for the backend Pod | `{}` |
| `backend.tolerations` | Tolerations for the backend Pod | `[]` |
@@ -131,7 +107,11 @@
| `frontend.envVars.FROM_CONFIGMAP.configMapKeyRef.key` | Key within a ConfigMap when configuring env vars from a ConfigMap | |
| `frontend.envVars.FROM_SECRET.secretKeyRef.name` | Name of a Secret when configuring env vars from a Secret | |
| `frontend.envVars.FROM_SECRET.secretKeyRef.key` | Key within a Secret when configuring env vars from a Secret | |
| `frontend.podAnnotations` | Annotations to add to the frontend Pod | `{}` |
| `frontend.podAnnotations` | Annotations to add to the frontend Pod | `{}` | |
| `frontend.mediaProxy.storageHost` | Hostname of the S3/MinIO endpoint serving recordings and files | `minio.meet.svc.cluster.local` |
| `frontend.mediaProxy.storageProtocol` | The protocol of the S3/MinIO endpoint serving recordings and files | http |
| `frontend.mediaProxy.storagePort` | Port of the S3/MinIO endpoint serving recordings and files | `9000` |
| `frontend.mediaProxy.bucketName` | Name of the S3/MinIO bucket storing recordings and files | `meet-media-storage` |
| `frontend.service.type` | frontend Service type | `ClusterIP` |
| `frontend.service.port` | frontend Service listening port | `80` |
| `frontend.service.targetPort` | frontend container listening port | `8080` |
@@ -217,24 +197,18 @@
| `summary.service.port` | summary Service listening port | `80` |
| `summary.service.targetPort` | summary container listening port | `8000` |
| `summary.service.annotations` | Annotations to add to the summary Service | `{}` |
| `summary.probes.liveness.path` | Configure path for summary HTTP liveness probe | `/__lbheartbeat__` |
| `summary.probes.liveness.path` | Configure path for summary HTTP liveness probe | `/__heartbeat__` |
| `summary.probes.liveness.targetPort` | Configure port for summary HTTP liveness probe | `undefined` |
| `summary.probes.liveness.initialDelaySeconds` | Configure initial delay summary liveness probe | `undefined` |
| `summary.probes.liveness.periodSeconds` | Configure the period for the summary liveness probe | `30` |
| `summary.probes.liveness.timeoutSeconds` | Configure timeout for summary liveness probe | `5` |
| `summary.probes.liveness.failureThreshold` | Configure failure threshold for summary liveness probe | `undefined` |
| `summary.probes.startup.path` | Configure path for summary HTTP startup probe | `/__lbheartbeat__` |
| `summary.probes.liveness.initialDelaySeconds` | Configure initial delay for summary liveness probe | `30` |
| `summary.probes.liveness.initialDelaySeconds` | Configure timeout for summary liveness probe | `30` |
| `summary.probes.startup.path` | Configure path for summary HTTP startup probe | `undefined` |
| `summary.probes.startup.targetPort` | Configure port for summary HTTP startup probe | `undefined` |
| `summary.probes.startup.initialDelaySeconds` | Configure initial delay for summary startup probe | `undefined` |
| `summary.probes.startup.periodSeconds` | Configure period for the summary startup probe | `5` |
| `summary.probes.startup.timeoutSeconds` | Configure timeout for summary startup probe | `5` |
| `summary.probes.startup.failureThreshold` | Configure failure threshold for summary startup probe | `12` |
| `summary.probes.readiness.path` | Configure path for summary HTTP readiness probe | `/__heartbeat__` |
| `summary.probes.startup.initialDelaySeconds` | Configure timeout for summary startup probe | `undefined` |
| `summary.probes.readiness.path` | Configure path for summary HTTP readiness probe | `/__lbheartbeat__` |
| `summary.probes.readiness.targetPort` | Configure port for summary HTTP readiness probe | `undefined` |
| `summary.probes.readiness.initialDelaySeconds` | Configure initial delay for summary readiness probe | `undefined` |
| `summary.probes.readiness.periodSeconds` | Configure period for the summary readiness probe | `30` |
| `summary.probes.readiness.timeoutSeconds` | Configure timeout for summary readiness probe | `5` |
| `summary.probes.readiness.failureThreshold` | Configure failure threshold for summary readiness probe | `3` |
| `summary.probes.readiness.initialDelaySeconds` | Configure initial delay for summary readiness probe | `30` |
| `summary.probes.readiness.initialDelaySeconds` | Configure timeout for summary readiness probe | `30` |
| `summary.resources` | Resource requirements for the summary container | `{}` |
| `summary.nodeSelector` | Node selector for the summary Pod | `{}` |
| `summary.tolerations` | Tolerations for the summary Pod | `[]` |
-3
View File
@@ -128,9 +128,6 @@ httpGet:
{{- end }}
initialDelaySeconds: {{ .initialDelaySeconds | eq nil | ternary 0 .initialDelaySeconds }}
timeoutSeconds: {{ .timeoutSeconds | eq nil | ternary 1 .timeoutSeconds }}
periodSeconds: {{ .periodSeconds | eq nil | ternary 10 .periodSeconds }}
failureThreshold: {{ .failureThreshold | eq nil | ternary 3 .failureThreshold }}
successThreshold: {{ .successThreshold | eq nil | ternary 1 .successThreshold }}
{{- end }}
{{/*
@@ -54,6 +54,18 @@ spec:
{{- if $envVars }}
{{- $envVars | indent 12 }}
{{- end }}
- name: BACKEND_INTERNAL_HOST
value: {{ include "meet.backend.fullname" . | quote }}
- name: BACKEND_INTERNAL_PORT
value: {{ .Values.backend.service.port | quote }}
- name: MEDIA_STORAGE_HOST
value: {{ .Values.frontend.mediaProxy.storageHost | quote }}
- name: MEDIA_STORAGE_PROTOCOL
value: {{ .Values.frontend.mediaProxy.storageProtocol | quote }}
- name: MEDIA_STORAGE_PORT
value: {{ .Values.frontend.mediaProxy.storagePort | quote }}
- name: AWS_STORAGE_BUCKET_NAME
value: {{ .Values.frontend.mediaProxy.bucketName | quote }}
{{- with .Values.frontend.securityContext }}
securityContext:
{{- toYaml . | nindent 12 }}
@@ -1,83 +0,0 @@
{{- if .Values.ingressMedia.enabled -}}
{{- $fullName := include "meet.fullname" . -}}
{{- if and .Values.ingressMedia.className (not (semverCompare ">=1.18-0" .Capabilities.KubeVersion.GitVersion)) }}
{{- if not (hasKey .Values.ingressMedia.annotations "kubernetes.io/ingress.class") }}
{{- $_ := set .Values.ingressMedia.annotations "kubernetes.io/ingress.class" .Values.ingressMedia.className}}
{{- end }}
{{- end }}
{{- if semverCompare ">=1.19-0" .Capabilities.KubeVersion.GitVersion -}}
apiVersion: networking.k8s.io/v1
{{- else if semverCompare ">=1.14-0" .Capabilities.KubeVersion.GitVersion -}}
apiVersion: networking.k8s.io/v1beta1
{{- else -}}
apiVersion: extensions/v1beta1
{{- end }}
kind: Ingress
metadata:
name: {{ $fullName }}-media
namespace: {{ .Release.Namespace | quote }}
labels:
{{- include "meet.labels" . | nindent 4 }}
{{- with .Values.ingressMedia.annotations }}
annotations:
{{- toYaml . | nindent 4 }}
{{- end }}
spec:
{{- if and .Values.ingressMedia.className (semverCompare ">=1.18-0" .Capabilities.KubeVersion.GitVersion) }}
ingressClassName: {{ .Values.ingressMedia.className }}
{{- end }}
{{- if .Values.ingressMedia.tls.enabled }}
tls:
{{- if .Values.ingressMedia.host }}
- secretName: {{ .Values.ingressMedia.tls.secretName | default (printf "%s-tls" $fullName) | quote }}
hosts:
- {{ .Values.ingressMedia.host | quote }}
{{- end }}
{{- range .Values.ingressMedia.tls.additional }}
- hosts:
{{- range .hosts }}
- {{ . | quote }}
{{- end }}
secretName: {{ .secretName }}
{{- end }}
{{- end }}
rules:
{{- if .Values.ingressMedia.host }}
- host: {{ .Values.ingressMedia.host | quote }}
http:
paths:
- path: {{ .Values.ingressMedia.path | quote }}
{{- if semverCompare ">=1.18-0" $.Capabilities.KubeVersion.GitVersion }}
pathType: ImplementationSpecific
{{- end }}
backend:
{{- if semverCompare ">=1.19-0" $.Capabilities.KubeVersion.GitVersion }}
service:
name: {{ $fullName }}-media
port:
number: {{ .Values.serviceMedia.port }}
{{- else }}
serviceName: {{ $fullName }}-media
servicePort: {{ .Values.serviceMedia.port }}
{{- end }}
{{- end }}
{{- range .Values.ingressMedia.hosts }}
- host: {{ . | quote }}
http:
paths:
- path: {{ $.Values.ingressMedia.path | quote }}
{{- if semverCompare ">=1.18-0" $.Capabilities.KubeVersion.GitVersion }}
pathType: ImplementationSpecific
{{- end }}
backend:
{{- if semverCompare ">=1.19-0" $.Capabilities.KubeVersion.GitVersion }}
service:
name: {{ $fullName }}-media
port:
number: {{ .Values.serviceMedia.port }}
{{- else }}
serviceName: {{ $fullName }}-media
servicePort: {{ .Values.serviceMedia.port }}
{{- end }}
{{- end }}
{{- end }}
@@ -1,83 +0,0 @@
{{- if .Values.ingressMediaFiles.enabled -}}
{{- $fullName := include "meet.fullname" . -}}
{{- if and .Values.ingressMediaFiles.className (not (semverCompare ">=1.18-0" .Capabilities.KubeVersion.GitVersion)) }}
{{- if not (hasKey .Values.ingressMediaFiles.annotations "kubernetes.io/ingress.class") }}
{{- $_ := set .Values.ingressMediaFiles.annotations "kubernetes.io/ingress.class" .Values.ingressMediaFiles.className }}
{{- end }}
{{- end }}
{{- if semverCompare ">=1.19-0" .Capabilities.KubeVersion.GitVersion -}}
apiVersion: networking.k8s.io/v1
{{- else if semverCompare ">=1.14-0" .Capabilities.KubeVersion.GitVersion -}}
apiVersion: networking.k8s.io/v1beta1
{{- else -}}
apiVersion: extensions/v1beta1
{{- end }}
kind: Ingress
metadata:
name: {{ $fullName }}-media-files
namespace: {{ .Release.Namespace | quote }}
labels:
{{- include "meet.labels" . | nindent 4 }}
{{- with .Values.ingressMediaFiles.annotations }}
annotations:
{{- toYaml . | nindent 4 }}
{{- end }}
spec:
{{- if and .Values.ingressMediaFiles.className (semverCompare ">=1.18-0" .Capabilities.KubeVersion.GitVersion) }}
ingressClassName: {{ .Values.ingressMediaFiles.className }}
{{- end }}
{{- if .Values.ingressMediaFiles.tls.enabled }}
tls:
{{- if .Values.ingressMediaFiles.host }}
- secretName: {{ .Values.ingressMediaFiles.tls.secretName | default (printf "%s-tls" $fullName) | quote }}
hosts:
- {{ .Values.ingressMediaFiles.host | quote }}
{{- end }}
{{- range .Values.ingressMediaFiles.tls.additional }}
- hosts:
{{- range .hosts }}
- {{ . | quote }}
{{- end }}
secretName: {{ .secretName }}
{{- end }}
{{- end }}
rules:
{{- if .Values.ingressMediaFiles.host }}
- host: {{ .Values.ingressMediaFiles.host | quote }}
http:
paths:
- path: {{ .Values.ingressMediaFiles.path | quote }}
{{- if semverCompare ">=1.18-0" $.Capabilities.KubeVersion.GitVersion }}
pathType: ImplementationSpecific
{{- end }}
backend:
{{- if semverCompare ">=1.19-0" $.Capabilities.KubeVersion.GitVersion }}
service:
name: {{ $fullName }}-media-files
port:
number: {{ .Values.serviceMediaFiles.port }}
{{- else }}
serviceName: {{ $fullName }}-media-files
servicePort: {{ .Values.serviceMediaFiles.port }}
{{- end }}
{{- end }}
{{- range .Values.ingressMediaFiles.hosts }}
- host: {{ . | quote }}
http:
paths:
- path: {{ $.Values.ingressMediaFiles.path | quote }}
{{- if semverCompare ">=1.18-0" $.Capabilities.KubeVersion.GitVersion }}
pathType: ImplementationSpecific
{{- end }}
backend:
{{- if semverCompare ">=1.19-0" $.Capabilities.KubeVersion.GitVersion }}
service:
name: {{ $fullName }}-media-files
port:
number: {{ .Values.serviceMediaFiles.port }}
{{- else }}
serviceName: {{ $fullName }}-media-files
servicePort: {{ .Values.serviceMediaFiles.port }}
{{- end }}
{{- end }}
{{- end }}
@@ -1,14 +0,0 @@
{{- $fullName := include "meet.fullname" . -}}
{{- $component := "media-files" -}}
apiVersion: v1
kind: Service
metadata:
name: {{ $fullName }}-media-files
namespace: {{ .Release.Namespace | quote }}
labels:
{{- include "meet.common.labels" (list . $component) | nindent 4 }}
annotations:
{{- toYaml $.Values.serviceMediaFiles.annotations | nindent 4 }}
spec:
type: ExternalName
externalName: {{ $.Values.serviceMediaFiles.host }}
-14
View File
@@ -1,14 +0,0 @@
{{- $fullName := include "meet.fullname" . -}}
{{- $component := "media" -}}
apiVersion: v1
kind: Service
metadata:
name: {{ $fullName }}-media
namespace: {{ .Release.Namespace | quote }}
labels:
{{- include "meet.common.labels" (list . $component) | nindent 4 }}
annotations:
{{- toYaml $.Values.serviceMedia.annotations | nindent 4 }}
spec:
type: ExternalName
externalName: {{ $.Values.serviceMedia.host }}
+19 -118
View File
@@ -98,94 +98,6 @@ ingressAdmin:
enabled: true
additional: []
## @param ingressMedia.enabled whether to enable the Ingress or not
## @param ingressMedia.className IngressClass to use for the Ingress
## @param ingressMedia.host Host for the Ingress
## @param ingressMedia.path Path to use for the Ingress
ingressMedia:
enabled: false
className: null
host: meet.example.com
path: /media/(.*)
## @param ingressMedia.hosts Additional host to configure for the Ingress
hosts: [ ]
# - chart-example.local
## @param ingressMedia.tls.enabled Whether to enable TLS for the Ingress
## @param ingressMedia.tls.secretName Secret name for TLS config
## @skip ingressMedia.tls.additional
## @extra ingressMedia.tls.additional[].secretName Secret name for additional TLS config
## @extra ingressMedia.tls.additional[].hosts[] Hosts for additional TLS config
tls:
secretName: null
enabled: true
additional: []
## @param ingressMedia.annotations.nginx.ingress.kubernetes.io/auth-url
## @param ingressMedia.annotations.nginx.ingress.kubernetes.io/auth-response-headers
## @param ingressMedia.annotations.nginx.ingress.kubernetes.io/upstream-vhost
## @param ingressMedia.annotations.nginx.ingress.kubernetes.io/configuration-snippet
annotations:
nginx.ingress.kubernetes.io/use-regex: "true"
nginx.ingress.kubernetes.io/auth-url: https://meet.example.com/api/v1.0/recordings/media-auth/
nginx.ingress.kubernetes.io/auth-response-headers: "Authorization, X-Amz-Date, X-Amz-Content-SHA256"
nginx.ingress.kubernetes.io/upstream-vhost: minio.meet.svc.cluster.local:9000
nginx.ingress.kubernetes.io/configuration-snippet: |
add_header Content-Security-Policy "default-src 'none'" always;
## @param serviceMedia.host
## @param serviceMedia.port
## @param serviceMedia.annotations
serviceMedia:
host: minio.meet.svc.cluster.local
port: 9000
annotations: {}
## @param ingressMediaFiles.enabled whether to enable the Ingress or not
## @param ingressMediaFiles.className IngressClass to use for the Ingress
## @param ingressMediaFiles.host Host for the Ingress
## @param ingressMediaFiles.path Path to use for the Ingress
ingressMediaFiles:
enabled: false
className: null
host: meet.example.com
path: /media/files/(.*)
## @param ingressMediaFiles.hosts Additional host to configure for the Ingress
hosts: [ ]
# - chart-example.local
## @param ingressMediaFiles.tls.enabled Weather to enable TLS for the Ingress
## @param ingressMediaFiles.tls.secretName Secret name for TLS config
## @skip ingressMediaFiles.tls.additional
## @extra ingressMediaFiles.tls.additional[].secretName Secret name for additional TLS config
## @extra ingressMediaFiles.tls.additional[].hosts[] Hosts for additional TLS config
tls:
secretName: null
enabled: true
additional: []
## @param ingressMediaFiles.annotations.nginx.ingress.kubernetes.io/auth-url
## @param ingressMediaFiles.annotations.nginx.ingress.kubernetes.io/auth-response-headers
## @param ingressMediaFiles.annotations.nginx.ingress.kubernetes.io/upstream-vhost
## @param ingressMediaFiles.annotations.nginx.ingress.kubernetes.io/configuration-snippet
annotations:
nginx.ingress.kubernetes.io/use-regex: "true"
nginx.ingress.kubernetes.io/auth-url: https://meet.example.com/api/v1.0/files/media-auth/
nginx.ingress.kubernetes.io/auth-response-headers: "Authorization, X-Amz-Date, X-Amz-Content-SHA256"
nginx.ingress.kubernetes.io/upstream-vhost: minio.meet.svc.cluster.local:9000
nginx.ingress.kubernetes.io/configuration-snippet: |
add_header Content-Security-Policy "default-src 'none'" always;
add_header Content-Disposition "attachment";
## @param serviceMediaFiles.host
## @param serviceMediaFiles.port
## @param serviceMediaFiles.annotations
serviceMediaFiles:
host: minio.meet.svc.cluster.local
port: 9000
annotations: {}
## @section backend
backend:
@@ -320,36 +232,26 @@ backend:
## @param backend.probes.liveness.initialDelaySeconds [nullable] Configure initial delay for backend liveness probe
## @param backend.probes.liveness.periodSeconds [nullable] Configure period for backend liveness probe
## @param backend.probes.liveness.timeoutSeconds [nullable] Configure timeout for backend liveness probe
## @param backend.probes.liveness.failureThreshold [nullable] Configure failure threshold for backend liveness probe
## @param backend.probes.startup.path [nullable] Configure path for backend HTTP startup probe
## @param backend.probes.startup.targetPort [nullable] Configure port for backend HTTP startup probe
## @param backend.probes.startup.initialDelaySeconds [nullable] Configure initial delay for backend startup probe
## @param backend.probes.startup.periodSeconds [nullable] Configure period for backend startup probe
## @param backend.probes.startup.timeoutSeconds [nullable] Configure timeout for backend startup probe
## @param backend.probes.startup.failureThreshold [nullable] Configure failure threshold for backend startup probe
## @param backend.probes.readiness.path [nullable] Configure path for backend HTTP readiness probe
## @param backend.probes.readiness.targetPort [nullable] Configure port for backend HTTP readiness probe
## @param backend.probes.readiness.initialDelaySeconds [nullable] Configure initial delay for backend readiness probe
## @param backend.probes.readiness.periodSeconds [nullable] Configure period for backend readiness probe
## @param backend.probes.readiness.timeoutSeconds [nullable] Configure timeout for backend readiness probe
## @param backend.probes.readiness.failureThreshold [nullable] Configure failure threshold for backend readiness probe
probes:
startup:
path: /__lbheartbeat__
periodSeconds: 5
timeoutSeconds: 5
failureThreshold: 12
liveness:
path: /__lbheartbeat__
path: /__heartbeat__
initialDelaySeconds: 5
periodSeconds: 30
timeoutSeconds: 5
readiness:
path: /__heartbeat__
path: /__lbheartbeat__
initialDelaySeconds: 5
periodSeconds: 30
timeoutSeconds: 5
failureThreshold: 3
## @param backend.resources Resource requirements for the backend container
resources: {}
@@ -427,6 +329,16 @@ frontend:
## @param frontend.podAnnotations Annotations to add to the frontend Pod
podAnnotations: {}
## @param frontend.mediaProxy.storageHost Hostname of the S3/MinIO endpoint serving recordings and files
## @param frontend.mediaProxy.storageProtocol Protocol of the S3/MinIO endpoint serving recordings and files
## @param frontend.mediaProxy.storagePort Port of the S3/MinIO endpoint serving recordings and files
## @param frontend.mediaProxy.bucketName Name of the S3/MinIO bucket storing recordings and files
mediaProxy:
storageHost: minio.meet.svc.cluster.local
storageProtocol: http
storagePort: 9000
bucketName: meet-media-storage
## @param frontend.service.type frontend Service type
## @param frontend.service.port frontend Service listening port
## @param frontend.service.targetPort frontend container listening port
@@ -633,36 +545,25 @@ summary:
## @param summary.probes.liveness.initialDelaySeconds [nullable] Configure initial delay summary liveness probe
## @param summary.probes.liveness.periodSeconds [nullable] Configure the period for the summary liveness probe
## @param summary.probes.liveness.timeoutSeconds [nullable] Configure timeout for summary liveness probe
## @param summary.probes.liveness.failureThreshold [nullable] Configure failure threshold for summary liveness probe
## @param summary.probes.startup.path [nullable] Configure path for summary HTTP startup probe
## @param summary.probes.startup.targetPort [nullable] Configure port for summary HTTP startup probe
## @param summary.probes.startup.initialDelaySeconds [nullable] Configure initial delay for summary startup probe
## @param summary.probes.startup.periodSeconds [nullable] Configure period for the summary startup probe
## @param summary.probes.startup.timeoutSeconds [nullable] Configure timeout for summary startup probe
## @param summary.probes.startup.failureThreshold [nullable] Configure failure threshold for summary startup probe
## @param summary.probes.readiness.path [nullable] Configure path for summary HTTP readiness probe
## @param summary.probes.readiness.targetPort [nullable] Configure port for summary HTTP readiness probe
## @param summary.probes.readiness.initialDelaySeconds [nullable] Configure initial delay for summary readiness probe
## @param summary.probes.readiness.periodSeconds [nullable] Configure period for the summary readiness probe
## @param summary.probes.readiness.timeoutSeconds [nullable] Configure timeout for summary readiness probe
## @param summary.probes.readiness.failureThreshold [nullable] Configure failure threshold for summary readiness probe
probes:
startup:
path: /__lbheartbeat__
periodSeconds: 5
timeoutSeconds: 5
failureThreshold: 12
liveness:
path: /__lbheartbeat__
periodSeconds: 30
timeoutSeconds: 5
readiness:
path: /__heartbeat__
initialDelaySeconds: 5
periodSeconds: 30
readiness:
path: /__lbheartbeat__
initialDelaySeconds: 5
periodSeconds: 30
timeoutSeconds: 5
failureThreshold: 3
## @param summary.resources Resource requirements for the summary container
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "mail_mjml",
"version": "1.32.1",
"version": "1.31.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "mail_mjml",
"version": "1.32.1",
"version": "1.31.0",
"license": "MIT",
"dependencies": {
"@html-to/text-cli": "0.6.1",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "mail_mjml",
"version": "1.32.1",
"version": "1.31.0",
"description": "An util to generate html and text django's templates from mjml templates",
"type": "module",
"dependencies": {
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "sdk",
"version": "1.32.1",
"version": "1.31.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "sdk",
"version": "1.32.1",
"version": "1.31.0",
"license": "ISC",
"workspaces": [
"./library",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "sdk",
"version": "1.32.1",
"version": "1.31.0",
"author": "",
"license": "ISC",
"description": "",
+1 -2
View File
@@ -1,7 +1,7 @@
[project]
name = "summary"
version = "1.32.1"
version = "1.31.0"
requires-python = ">=3.13"
dependencies = [
"fastapi[standard]>=0.105.0",
@@ -10,7 +10,6 @@ dependencies = [
"pydantic-settings>=2.1.0",
"celery==5.6.3",
"redis==5.2.1",
"dockerflow==2026.3.4",
"minio==7.2.20",
"openai==2.48.0",
"posthog==7.29.0",
+17
View File
@@ -0,0 +1,17 @@
"""API routes related to application health checking."""
from fastapi import APIRouter
router = APIRouter()
@router.get("/__heartbeat__")
async def heartbeat():
"""Health check endpoint for monitoring."""
return
@router.get("/__lbheartbeat__")
async def lbheartbeat():
"""Health check endpoint for load balancer."""
return
-24
View File
@@ -1,24 +0,0 @@
"""Dockerflow checks backing the /__heartbeat__ endpoint."""
from celery.exceptions import OperationalError
from dockerflow import checks
from summary.core.celery_worker import celery
BROKER_SOCKET_TIMEOUT = 2 # Should be under the readiness probe timeout (5s)
@checks.register(name="broker_connected")
def check_broker_connected():
"""Report whether the Celery broker accepts a connection."""
try:
with celery.connection_for_write(
transport_options={
"socket_connect_timeout": BROKER_SOCKET_TIMEOUT,
"socket_timeout": BROKER_SOCKET_TIMEOUT,
}
) as connection:
connection.ensure_connection(max_retries=0)
except OperationalError as exc:
return [checks.Error(str(exc), id="summary.health.E001")]
return []
+2 -3
View File
@@ -1,11 +1,10 @@
"""Application."""
import sentry_sdk
from dockerflow.fastapi import router as dockerflow_router
from fastapi import FastAPI
from summary.api import health
from summary.api.main import api_router_v2
from summary.core import checks # noqa: F401 -- registers the Dockerflow checks
from summary.core.config import get_settings
settings = get_settings()
@@ -19,4 +18,4 @@ app = FastAPI(
)
app.include_router(api_router_v2, prefix=settings.app_api_v2_str)
app.include_router(dockerflow_router)
app.include_router(health.router)
+8 -41
View File
@@ -1,54 +1,21 @@
"""Tests for the health check endpoints."""
from unittest import mock
import pytest
from celery.exceptions import OperationalError
@pytest.fixture(name="broker")
def fixture_broker():
"""Patch the Celery broker connection the heartbeat check opens."""
with mock.patch("summary.core.checks.celery.connection_for_write") as connect:
connection = connect.return_value
connection.__enter__.return_value = connection
yield connection
class TestLBHeartbeat:
"""Tests for the /__lbheartbeat__ endpoint."""
def test_returns_200(self, client, broker):
"""The LB heartbeat endpoint responds with 200 OK without an api_key."""
response = client.get("/__lbheartbeat__")
broker.ensure_connection.assert_not_called()
assert response.status_code == 200
"""Integration tests for the health check endpoints."""
class TestHeartbeat:
"""Tests for the /__heartbeat__ endpoint."""
def test_returns_200(self, client, broker):
def test_returns_200(self, client):
"""The heartbeat endpoint responds with 200 OK without an api_key."""
response = client.get("/__heartbeat__")
assert response.status_code == 200
assert response.json()["checks"]["broker_connected"] == "ok"
broker.ensure_connection.assert_called_once_with(max_retries=0)
def test_returns_500_when_the_broker_is_unreachable(self, client, broker):
"""An unreachable celery broker takes the readiness probe down."""
broker.ensure_connection.side_effect = OperationalError("Connection refused")
response = client.get("/__heartbeat__")
class TestLBHeartbeat:
"""Tests for the /__lbheartbeat__ endpoint."""
assert response.status_code == 500
def test_returns_200(self, client):
"""The LB heartbeat endpoint responds with 200 OK without an api_key."""
response = client.get("/__lbheartbeat__")
payload = response.json()
assert payload["status"] == "error"
assert payload["checks"]["broker_connected"] == "error"
assert (
payload["details"]["broker_connected"]["messages"]["summary.health.E001"]
== "Connection refused"
)
assert response.status_code == 200
+1 -12
View File
@@ -423,15 +423,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/ba/5a/18ad964b0086c6e62e2e7500f7edc89e3faa45033c71c1893d34eed2b2de/dnspython-2.8.0-py3-none-any.whl", hash = "sha256:01d9bbc4a2d76bf0db7c1f729812ded6d912bd318d3b1cf81d30c0f845dbf3af", size = 331094, upload-time = "2025-09-07T18:57:58.071Z" },
]
[[package]]
name = "dockerflow"
version = "2026.3.4"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/9c/03/935f8399094611981b37634a7ee078484b3995f744135339e51385638f6c/dockerflow-2026.3.4.tar.gz", hash = "sha256:6cc7fcab2a078d06aeca8b02e22fd7f6766dca322fb30aed6910684dbc08690c", size = 68701, upload-time = "2026-03-04T09:01:28.178Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/86/b9/2d7bf30eb0c24e60e61e4f6ae7574e123d3fa51b39a612e146c94158446a/dockerflow-2026.3.4-py2.py3-none-any.whl", hash = "sha256:38fe0ad30a4aedf68e589919695d502fc6f584a761b56a91f56e80cbadfbf61a", size = 36424, upload-time = "2026-03-04T09:01:26.631Z" },
]
[[package]]
name = "email-validator"
version = "2.3.0"
@@ -1516,11 +1507,10 @@ wheels = [
[[package]]
name = "summary"
version = "1.32.1"
version = "1.31.0"
source = { editable = "." }
dependencies = [
{ name = "celery" },
{ name = "dockerflow" },
{ name = "fastapi", extra = ["standard"] },
{ name = "langfuse" },
{ name = "minio" },
@@ -1544,7 +1534,6 @@ dev = [
[package.metadata]
requires-dist = [
{ name = "celery", specifier = "==5.6.3" },
{ name = "dockerflow", specifier = "==2026.3.4" },
{ name = "fastapi", extras = ["standard"], specifier = ">=0.105.0" },
{ name = "langfuse", specifier = "==4.14.1" },
{ name = "minio", specifier = "==7.2.20" },