mirror of
https://github.com/suitenumerique/meet.git
synced 2026-09-30 06:28:59 +00:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 0e7e5d1a4c |
@@ -1,9 +0,0 @@
|
|||||||
[codespell]
|
|
||||||
# Files that are not English, or generated
|
|
||||||
skip = ./.git,*.pdf,*.po,*.pot,*.json,*.lock,package-lock.json,
|
|
||||||
./LICENSES,
|
|
||||||
./src/summary/summary/core/locales,
|
|
||||||
./src/summary/summary/core/prompt.py
|
|
||||||
# Valid words in French (connexion) or in the code (statics)
|
|
||||||
ignore-words-list = connexion,statics
|
|
||||||
check-filenames = true
|
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
---
|
||||||
|
name: 🐛 Bug Report
|
||||||
|
about: If something is not working as expected 🤔.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Bug Report
|
||||||
|
|
||||||
|
**Problematic behavior**
|
||||||
|
A clear and concise description of the behavior.
|
||||||
|
|
||||||
|
**Expected behavior/code**
|
||||||
|
A clear and concise description of what you expected to happen (or code).
|
||||||
|
|
||||||
|
**Steps to Reproduce**
|
||||||
|
1. Do this...
|
||||||
|
2. Then this...
|
||||||
|
3. And then the bug happens!
|
||||||
|
|
||||||
|
**Environment**
|
||||||
|
- Meet version:
|
||||||
|
- Platform:
|
||||||
|
|
||||||
|
**Possible Solution**
|
||||||
|
<!--- Only if you have suggestions on a fix for the bug -->
|
||||||
|
|
||||||
|
**Additional context/Screenshots**
|
||||||
|
Add any other context about the problem here. If applicable, add screenshots to help explain.
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
---
|
||||||
|
name: ✨ Feature Request
|
||||||
|
about: I have a suggestion (and may want to build it 💪)!
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Feature Request
|
||||||
|
|
||||||
|
**Is your feature request related to a problem or unsupported use case? Please describe.**
|
||||||
|
A clear and concise description of what the problem is. For example: I need to do some task and I have an issue...
|
||||||
|
|
||||||
|
**Describe the solution you'd like**
|
||||||
|
A clear and concise description of what you want to happen. Add any considered drawbacks.
|
||||||
|
|
||||||
|
**Describe alternatives you've considered**
|
||||||
|
A clear and concise description of any alternative solutions or features you've considered.
|
||||||
|
|
||||||
|
**Discovery, Documentation, Adoption, Migration Strategy**
|
||||||
|
If you can, explain how users will be able to use this and possibly write out a version the docs (if applicable).
|
||||||
|
Maybe a screenshot or design?
|
||||||
|
|
||||||
|
**Do you want to work on it through a Pull Request?**
|
||||||
|
<!-- Make sure to coordinate with us before you spend too much time working on an implementation! -->
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
---
|
||||||
|
name: 🤗 Support Question
|
||||||
|
about: If you have a question 💬, or something was not clear from the docs!
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<!-- ^ Click "Preview" for a nicer view! ^
|
||||||
|
We primarily use GitHub as an issue tracker. If however you're encountering an issue not covered in the docs, we may be able to help! -->
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
Please make sure you have read our [main Readme](https://github.com/numerique-gouv/meet).
|
||||||
|
|
||||||
|
Also make sure it was not already answered in [an open or close issue](https://github.com/numerique-gouv/meet/issues).
|
||||||
|
|
||||||
|
If your question was not covered, and you feel like it should be, fire away! We'd love to improve our docs! 👌
|
||||||
|
|
||||||
|
**Topic**
|
||||||
|
What's the general area of your question: for example, docker setup, database schema, search functionality,...
|
||||||
|
|
||||||
|
**Question**
|
||||||
|
Try to be as specific as possible so we can help you as best we can. Please be patient 🙏
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
## Purpose
|
||||||
|
|
||||||
|
Description...
|
||||||
|
|
||||||
|
|
||||||
|
## Proposal
|
||||||
|
|
||||||
|
Description...
|
||||||
|
|
||||||
|
- [] item 1...
|
||||||
|
- [] item 2...
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
name: Changelog Workflow
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches:
|
|
||||||
- main
|
|
||||||
pull_request:
|
|
||||||
types: [opened, synchronize, reopened, labeled, unlabeled]
|
|
||||||
|
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
|
|
||||||
concurrency:
|
|
||||||
group: ${{ github.workflow }}-${{ github.ref }}-${{ github.event.pull_request.number || github.sha }}
|
|
||||||
cancel-in-progress: true
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
changelog:
|
|
||||||
uses: suitenumerique/ci/.github/workflows/_changelog.yml@main
|
|
||||||
+176
-22
@@ -11,30 +11,180 @@ permissions:
|
|||||||
contents: read
|
contents: read
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
|
lint-git:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
if: github.event_name == 'pull_request' # Makes sense only for pull requests
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
steps:
|
||||||
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
- name: show
|
||||||
|
run: git log
|
||||||
|
- name: Enforce absence of print statements in code
|
||||||
|
if: always()
|
||||||
|
run: |
|
||||||
|
! git diff origin/${{ github.event.pull_request.base.ref }}..HEAD -- . ':(exclude).github/workflows/**' | grep "print("
|
||||||
|
- name: Check absence of fixup commits
|
||||||
|
if: always()
|
||||||
|
run: |
|
||||||
|
! git log | grep 'fixup!'
|
||||||
|
- name: Install uv
|
||||||
|
if: always()
|
||||||
|
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
|
||||||
|
|
||||||
lint-python:
|
check-changelog:
|
||||||
name: lint ${{ matrix.service }}
|
runs-on: ubuntu-latest
|
||||||
strategy:
|
if: |
|
||||||
fail-fast: false
|
contains(github.event.pull_request.labels.*.name, 'noChangeLog') == false &&
|
||||||
matrix:
|
github.event_name == 'pull_request'
|
||||||
include:
|
permissions:
|
||||||
- service: backend
|
contents: read
|
||||||
working_directory: src/backend
|
steps:
|
||||||
pylint_targets: meet demo core
|
- name: Checkout repository
|
||||||
- service: agents
|
uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0
|
||||||
working_directory: src/agents
|
with:
|
||||||
pylint_targets: ""
|
fetch-depth: 50
|
||||||
- service: summary
|
- name: Check that the CHANGELOG has been modified in the current branch
|
||||||
working_directory: src/summary
|
run: git diff --name-only ${{ github.event.pull_request.base.sha }} ${{ github.event.after }} | grep 'CHANGELOG.md'
|
||||||
pylint_targets: ""
|
|
||||||
uses: suitenumerique/ci/.github/workflows/_python-lint.yml@main
|
lint-changelog:
|
||||||
with:
|
runs-on: ubuntu-latest
|
||||||
working_directory: ${{ matrix.working_directory }}
|
permissions:
|
||||||
python_version: "3.13"
|
contents: read
|
||||||
pylint_targets: ${{ matrix.pylint_targets }}
|
steps:
|
||||||
|
- name: Checkout repository
|
||||||
|
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)
|
||||||
|
if [ $max_line_length -ge 80 ]; then
|
||||||
|
echo "ERROR: CHANGELOG has lines longer than 80 characters."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
build-mails:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
defaults:
|
||||||
|
run:
|
||||||
|
working-directory: src/mail
|
||||||
|
steps:
|
||||||
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0
|
||||||
|
|
||||||
|
- name: Install Node.js
|
||||||
|
uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6.5.0
|
||||||
|
with:
|
||||||
|
node-version: "22"
|
||||||
|
|
||||||
|
- name: Restore the mail templates
|
||||||
|
uses: actions/cache@caa296126883cff596d87d8935842f9db880ef25 # v5.1.0
|
||||||
|
id: mail-templates
|
||||||
|
with:
|
||||||
|
path: "src/backend/core/templates/mail"
|
||||||
|
key: mail-templates-${{ hashFiles('src/mail/mjml') }}
|
||||||
|
|
||||||
|
- name: Install yarn
|
||||||
|
if: steps.mail-templates.outputs.cache-hit != 'true'
|
||||||
|
run: npm install -g --ignore-scripts yarn@1.22.22
|
||||||
|
|
||||||
|
- name: Install node dependencies
|
||||||
|
if: steps.mail-templates.outputs.cache-hit != 'true'
|
||||||
|
run: yarn install --frozen-lockfile --ignore-scripts
|
||||||
|
|
||||||
|
- name: Build mails
|
||||||
|
if: steps.mail-templates.outputs.cache-hit != 'true'
|
||||||
|
run: yarn build
|
||||||
|
|
||||||
|
- name: Cache mail templates
|
||||||
|
if: steps.mail-templates.outputs.cache-hit != 'true'
|
||||||
|
uses: actions/cache@caa296126883cff596d87d8935842f9db880ef25 # v5.1.0
|
||||||
|
with:
|
||||||
|
path: "src/backend/core/templates/mail"
|
||||||
|
key: mail-templates-${{ hashFiles('src/mail/mjml') }}
|
||||||
|
|
||||||
|
lint-back:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
defaults:
|
||||||
|
run:
|
||||||
|
working-directory: src/backend
|
||||||
|
steps:
|
||||||
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0
|
||||||
|
- name: Install Python
|
||||||
|
uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0
|
||||||
|
with:
|
||||||
|
python-version: "3.13"
|
||||||
|
- name: Install uv
|
||||||
|
uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7.6.0
|
||||||
|
- name: Install the project
|
||||||
|
run: uv sync --locked --all-extras
|
||||||
|
|
||||||
|
- name: Check code formatting with ruff
|
||||||
|
run: uv run --no-sync --no-build ruff format . --diff
|
||||||
|
- name: Lint code with ruff
|
||||||
|
run: uv run --no-sync --no-build ruff check .
|
||||||
|
- name: Lint code with pylint
|
||||||
|
run: uv run --no-sync --no-build pylint meet demo core
|
||||||
|
|
||||||
|
lint-agents:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
defaults:
|
||||||
|
run:
|
||||||
|
working-directory: src/agents
|
||||||
|
steps:
|
||||||
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0
|
||||||
|
- name: Install Python
|
||||||
|
uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0
|
||||||
|
with:
|
||||||
|
python-version: "3.13"
|
||||||
|
- name: Install uv
|
||||||
|
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
|
||||||
|
run: uv run --no-sync --no-build ruff format . --diff
|
||||||
|
- name: Lint code with ruff
|
||||||
|
run: uv run --no-sync --no-build ruff check .
|
||||||
|
|
||||||
|
lint-summary:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
defaults:
|
||||||
|
run:
|
||||||
|
working-directory: src/summary
|
||||||
|
steps:
|
||||||
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0
|
||||||
|
- name: Install Python
|
||||||
|
uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0
|
||||||
|
with:
|
||||||
|
python-version: "3.13"
|
||||||
|
- name: Install uv
|
||||||
|
uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7.6.0
|
||||||
|
- name: Install the project
|
||||||
|
run: uv sync --locked --all-extras
|
||||||
|
- name: Check code formatting with ruff
|
||||||
|
run: uv run --no-sync --no-build ruff format . --diff
|
||||||
|
- name: Lint code with ruff
|
||||||
|
run: uv run --no-sync --no-build ruff check .
|
||||||
|
|
||||||
test-back:
|
test-back:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
needs: build-mails
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
defaults:
|
defaults:
|
||||||
@@ -93,8 +243,12 @@ jobs:
|
|||||||
sudo mkdir -p /data/media && \
|
sudo mkdir -p /data/media && \
|
||||||
sudo mkdir -p /data/static
|
sudo mkdir -p /data/static
|
||||||
|
|
||||||
- name: Build or restore the mail templates
|
- name: Restore the mail templates
|
||||||
uses: suitenumerique/ci/actions/mail-templates@main
|
uses: actions/cache@caa296126883cff596d87d8935842f9db880ef25 # v5.1.0
|
||||||
|
id: mail-templates
|
||||||
|
with:
|
||||||
|
path: "src/backend/core/templates/mail"
|
||||||
|
key: mail-templates-${{ hashFiles('src/mail/mjml') }}
|
||||||
|
|
||||||
- name: Start MinIO
|
- name: Start MinIO
|
||||||
run: |
|
run: |
|
||||||
|
|||||||
@@ -1,18 +0,0 @@
|
|||||||
name: Project quality Workflow
|
|
||||||
|
|
||||||
on:
|
|
||||||
pull_request:
|
|
||||||
|
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
|
|
||||||
concurrency:
|
|
||||||
group: ${{ github.workflow }}-${{ github.ref }}-${{ github.event.pull_request.number || github.sha }}
|
|
||||||
cancel-in-progress: true
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
quality:
|
|
||||||
uses: suitenumerique/ci/.github/workflows/_project-quality.yml@main
|
|
||||||
with:
|
|
||||||
print_check_paths: src/backend src/summary src/agents
|
|
||||||
codespell_ignore_words: ""
|
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
name: Download Crowdin translations
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
|
types: [file-fully-translated]
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
pull-requests: write
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
crowdin:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0
|
||||||
|
|
||||||
|
- name: Download Crowdin files
|
||||||
|
uses: crowdin/github-action@c7af9bc98b01694653031fef2a0dc6c7888ce9bc # v2.17.0
|
||||||
|
with:
|
||||||
|
upload_sources: false
|
||||||
|
upload_translations: false
|
||||||
|
download_translations: true
|
||||||
|
localization_branch_name: l10n_crowdin_translations
|
||||||
|
create_pull_request: true
|
||||||
|
pull_request_title: "New Crowdin translations"
|
||||||
|
pull_request_body: "New Crowdin pull request with translations"
|
||||||
|
pull_request_base_branch_name: "main"
|
||||||
|
env:
|
||||||
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
CROWDIN_PROJECT_ID: ${{ secrets.CROWDIN_PROJECT_ID }}
|
||||||
|
CROWDIN_PERSONAL_TOKEN: ${{ secrets.CROWDIN_PERSONAL_TOKEN }}
|
||||||
|
CROWDIN_BASE_PATH: ${{ github.workspace }}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
name: Docker images
|
name: Docker Hub Workflow
|
||||||
run-name: Docker images
|
run-name: Docker Hub Workflow
|
||||||
|
|
||||||
on:
|
on:
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
@@ -15,61 +15,265 @@ on:
|
|||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
|
|
||||||
|
env:
|
||||||
|
DOCKER_USER: 1001:127
|
||||||
|
DOCKER_CONTAINER_REGISTRY_HOSTNAME: docker.io
|
||||||
|
DOCKER_CONTAINER_REGISTRY_NAMESPACE: lasuite
|
||||||
|
IS_MULTI_PLATFORM_BUILD: ${{ startsWith(github.ref, 'refs/tags/v') }}
|
||||||
|
BUILD_PLATFORMS: ${{ startsWith(github.ref, 'refs/tags/v') && 'linux/amd64,linux/arm64' || 'linux/amd64' }}
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build-and-push:
|
build-and-push-backend:
|
||||||
name: ${{ matrix.service }}
|
runs-on: ubuntu-latest
|
||||||
strategy:
|
permissions:
|
||||||
fail-fast: false
|
contents: read
|
||||||
matrix:
|
steps:
|
||||||
include:
|
-
|
||||||
- service: backend
|
name: Checkout repository
|
||||||
image_name: lasuite/meet-backend
|
uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0
|
||||||
context: .
|
-
|
||||||
file: ./Dockerfile
|
name: Set up QEMU
|
||||||
target: backend-production
|
if: env.IS_MULTI_PLATFORM_BUILD == 'true'
|
||||||
- service: frontend
|
uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 # v3.7.0
|
||||||
image_name: lasuite/meet-frontend
|
-
|
||||||
context: .
|
name: Set up Docker Buildx
|
||||||
file: ./src/frontend/Dockerfile
|
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0
|
||||||
target: frontend-production
|
-
|
||||||
- service: frontend-dinum
|
name: Docker meta
|
||||||
image_name: lasuite/meet-frontend-dinum
|
id: meta
|
||||||
context: .
|
uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5.10.0
|
||||||
file: ./docker/dinum-frontend/Dockerfile
|
with:
|
||||||
target: frontend-production
|
images: '${{ env.DOCKER_CONTAINER_REGISTRY_NAMESPACE }}/meet-backend'
|
||||||
- service: summary
|
-
|
||||||
image_name: lasuite/meet-summary
|
name: Login to DockerHub
|
||||||
context: ./src/summary
|
if: github.event_name != 'pull_request' || startsWith(github.head_ref, 'integration/')
|
||||||
file: ./src/summary/Dockerfile
|
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
|
||||||
target: production
|
with:
|
||||||
- service: agents
|
username: ${{ secrets.DOCKER_HUB_USER }}
|
||||||
image_name: lasuite/meet-agents
|
password: ${{ secrets.DOCKER_HUB_PASSWORD }}
|
||||||
context: ./src/agents
|
-
|
||||||
file: ./src/agents/Dockerfile
|
name: Run trivy scan
|
||||||
target: production
|
uses: numerique-gouv/action-trivy-cache@d6e94cfb488f03a0b3e8b8739aad94e74d24d8da # main
|
||||||
uses: suitenumerique/ci/.github/workflows/_docker-publish.yml@main
|
with:
|
||||||
with:
|
docker-build-args: '--target backend-production -f Dockerfile'
|
||||||
image_name: ${{ matrix.image_name }}
|
docker-image-name: '${{ env.DOCKER_CONTAINER_REGISTRY_HOSTNAME }}/${{ env.DOCKER_CONTAINER_REGISTRY_NAMESPACE }}/meet-backend:${{ github.sha }}'
|
||||||
context: ${{ matrix.context }}
|
-
|
||||||
file: ${{ matrix.file }}
|
name: Build and push
|
||||||
target: ${{ matrix.target }}
|
uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6.19.2
|
||||||
docker_user: "1001:127"
|
with:
|
||||||
is_multi_platform: ${{ startsWith(github.ref, 'refs/tags/v') }}
|
context: .
|
||||||
should_push: ${{ github.event_name != 'pull_request' || startsWith(github.head_ref, 'integration/') }}
|
target: backend-production
|
||||||
trivy_scan: true
|
platforms: ${{ env.BUILD_PLATFORMS }}
|
||||||
secrets:
|
build-args: DOCKER_USER=${{ env.DOCKER_USER }}:-1000
|
||||||
DOCKER_HUB_USER: ${{ secrets.DOCKER_HUB_USER }}
|
push: ${{ github.event_name != 'pull_request' || startsWith(github.head_ref, 'integration/') }}
|
||||||
DOCKER_HUB_PASSWORD: ${{ secrets.DOCKER_HUB_PASSWORD }}
|
tags: ${{ steps.meta.outputs.tags }}
|
||||||
|
labels: ${{ steps.meta.outputs.labels }}
|
||||||
|
|
||||||
|
build-and-push-frontend-generic:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
steps:
|
||||||
|
-
|
||||||
|
name: Checkout repository
|
||||||
|
uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0
|
||||||
|
-
|
||||||
|
name: Set up QEMU
|
||||||
|
if: env.IS_MULTI_PLATFORM_BUILD == 'true'
|
||||||
|
uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 # v3.7.0
|
||||||
|
-
|
||||||
|
name: Set up Docker Buildx
|
||||||
|
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0
|
||||||
|
-
|
||||||
|
name: Docker meta
|
||||||
|
id: meta
|
||||||
|
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@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
|
||||||
|
with:
|
||||||
|
username: ${{ secrets.DOCKER_HUB_USER }}
|
||||||
|
password: ${{ secrets.DOCKER_HUB_PASSWORD }}
|
||||||
|
-
|
||||||
|
name: Run trivy scan
|
||||||
|
uses: numerique-gouv/action-trivy-cache@d6e94cfb488f03a0b3e8b8739aad94e74d24d8da # main
|
||||||
|
with:
|
||||||
|
docker-build-args: '-f src/frontend/Dockerfile --target frontend-production'
|
||||||
|
docker-image-name: '${{ env.DOCKER_CONTAINER_REGISTRY_HOSTNAME }}/${{ env.DOCKER_CONTAINER_REGISTRY_NAMESPACE }}/meet-frontend:${{ github.sha }}'
|
||||||
|
-
|
||||||
|
name: Build and push
|
||||||
|
uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6.19.2
|
||||||
|
with:
|
||||||
|
context: .
|
||||||
|
file: ./src/frontend/Dockerfile
|
||||||
|
target: frontend-production
|
||||||
|
platforms: ${{ env.BUILD_PLATFORMS }}
|
||||||
|
build-args: DOCKER_USER=${{ env.DOCKER_USER }}:-1000
|
||||||
|
push: ${{ github.event_name != 'pull_request' || startsWith(github.head_ref, 'integration/') }}
|
||||||
|
tags: ${{ steps.meta.outputs.tags }}
|
||||||
|
labels: ${{ steps.meta.outputs.labels }}
|
||||||
|
|
||||||
|
build-and-push-frontend-dinum:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
steps:
|
||||||
|
-
|
||||||
|
name: Checkout repository
|
||||||
|
uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0
|
||||||
|
-
|
||||||
|
name: Set up QEMU
|
||||||
|
if: env.IS_MULTI_PLATFORM_BUILD == 'true'
|
||||||
|
uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 # v3.7.0
|
||||||
|
-
|
||||||
|
name: Set up Docker Buildx
|
||||||
|
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0
|
||||||
|
-
|
||||||
|
name: Docker meta
|
||||||
|
id: meta
|
||||||
|
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@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
|
||||||
|
with:
|
||||||
|
username: ${{ secrets.DOCKER_HUB_USER }}
|
||||||
|
password: ${{ secrets.DOCKER_HUB_PASSWORD }}
|
||||||
|
-
|
||||||
|
name: Run trivy scan
|
||||||
|
uses: numerique-gouv/action-trivy-cache@d6e94cfb488f03a0b3e8b8739aad94e74d24d8da # main
|
||||||
|
with:
|
||||||
|
docker-build-args: '-f docker/dinum-frontend/Dockerfile --target frontend-production'
|
||||||
|
docker-image-name: '${{ env.DOCKER_CONTAINER_REGISTRY_HOSTNAME }}/${{ env.DOCKER_CONTAINER_REGISTRY_NAMESPACE }}/meet-frontend-dinum:${{ github.sha }}'
|
||||||
|
-
|
||||||
|
name: Build and push
|
||||||
|
uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6.19.2
|
||||||
|
with:
|
||||||
|
context: .
|
||||||
|
file: ./docker/dinum-frontend/Dockerfile
|
||||||
|
target: frontend-production
|
||||||
|
platforms: ${{ env.BUILD_PLATFORMS }}
|
||||||
|
build-args: DOCKER_USER=${{ env.DOCKER_USER }}:-1000
|
||||||
|
push: ${{ github.event_name != 'pull_request' || startsWith(github.head_ref, 'integration/') }}
|
||||||
|
tags: ${{ steps.meta.outputs.tags }}
|
||||||
|
labels: ${{ steps.meta.outputs.labels }}
|
||||||
|
|
||||||
|
build-and-push-summary:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
steps:
|
||||||
|
-
|
||||||
|
name: Checkout repository
|
||||||
|
uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0
|
||||||
|
-
|
||||||
|
name: Set up QEMU
|
||||||
|
if: env.IS_MULTI_PLATFORM_BUILD == 'true'
|
||||||
|
uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 # v3.7.0
|
||||||
|
-
|
||||||
|
name: Set up Docker Buildx
|
||||||
|
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0
|
||||||
|
-
|
||||||
|
name: Docker meta
|
||||||
|
id: meta
|
||||||
|
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@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
|
||||||
|
with:
|
||||||
|
username: ${{ secrets.DOCKER_HUB_USER }}
|
||||||
|
password: ${{ secrets.DOCKER_HUB_PASSWORD }}
|
||||||
|
-
|
||||||
|
name: Run trivy scan
|
||||||
|
uses: numerique-gouv/action-trivy-cache@d6e94cfb488f03a0b3e8b8739aad94e74d24d8da # main
|
||||||
|
continue-on-error: true
|
||||||
|
with:
|
||||||
|
docker-build-args: '-f src/summary/Dockerfile --target production'
|
||||||
|
docker-image-name: '${{ env.DOCKER_CONTAINER_REGISTRY_HOSTNAME }}/${{ env.DOCKER_CONTAINER_REGISTRY_NAMESPACE }}/meet-summary:${{ github.sha }}'
|
||||||
|
docker-context: './src/summary'
|
||||||
|
-
|
||||||
|
name: Build and push
|
||||||
|
uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6.19.2
|
||||||
|
with:
|
||||||
|
context: ./src/summary
|
||||||
|
file: ./src/summary/Dockerfile
|
||||||
|
target: production
|
||||||
|
platforms: ${{ env.BUILD_PLATFORMS }}
|
||||||
|
build-args: DOCKER_USER=${{ env.DOCKER_USER }}:-1000
|
||||||
|
push: ${{ github.event_name != 'pull_request' || startsWith(github.head_ref, 'integration/') }}
|
||||||
|
tags: ${{ steps.meta.outputs.tags }}
|
||||||
|
labels: ${{ steps.meta.outputs.labels }}
|
||||||
|
|
||||||
|
build-and-push-agents:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
steps:
|
||||||
|
-
|
||||||
|
name: Checkout repository
|
||||||
|
uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0
|
||||||
|
-
|
||||||
|
name: Set up QEMU
|
||||||
|
if: env.IS_MULTI_PLATFORM_BUILD == 'true'
|
||||||
|
uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 # v3.7.0
|
||||||
|
-
|
||||||
|
name: Set up Docker Buildx
|
||||||
|
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0
|
||||||
|
-
|
||||||
|
name: Docker meta
|
||||||
|
id: meta
|
||||||
|
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@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
|
||||||
|
with:
|
||||||
|
username: ${{ secrets.DOCKER_HUB_USER }}
|
||||||
|
password: ${{ secrets.DOCKER_HUB_PASSWORD }}
|
||||||
|
-
|
||||||
|
name: Run trivy scan
|
||||||
|
uses: numerique-gouv/action-trivy-cache@d6e94cfb488f03a0b3e8b8739aad94e74d24d8da # main
|
||||||
|
continue-on-error: true
|
||||||
|
with:
|
||||||
|
docker-build-args: '-f src/agents/Dockerfile --target production'
|
||||||
|
docker-image-name: '${{ env.DOCKER_CONTAINER_REGISTRY_HOSTNAME }}/${{ env.DOCKER_CONTAINER_REGISTRY_NAMESPACE }}/meet-agents:${{ github.sha }}'
|
||||||
|
docker-context: './src/agents'
|
||||||
|
-
|
||||||
|
name: Build and push
|
||||||
|
uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6.19.2
|
||||||
|
with:
|
||||||
|
context: ./src/agents
|
||||||
|
file: ./src/agents/Dockerfile
|
||||||
|
target: production
|
||||||
|
platforms: ${{ env.BUILD_PLATFORMS }}
|
||||||
|
build-args: DOCKER_USER=${{ env.DOCKER_USER }}:-1000
|
||||||
|
push: ${{ github.event_name != 'pull_request' || startsWith(github.head_ref, 'integration/') }}
|
||||||
|
tags: ${{ steps.meta.outputs.tags }}
|
||||||
|
labels: ${{ steps.meta.outputs.labels }}
|
||||||
|
|
||||||
notify-argocd:
|
notify-argocd:
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
needs:
|
needs:
|
||||||
- build-and-push
|
- build-and-push-frontend-generic
|
||||||
|
- build-and-push-frontend-dinum
|
||||||
|
- build-and-push-backend
|
||||||
|
- build-and-push-summary
|
||||||
|
- build-and-push-agents
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
if: github.event_name != 'pull_request'
|
if: github.event_name != 'pull_request'
|
||||||
steps:
|
steps:
|
||||||
- uses: suitenumerique/ci/actions/argocd-webhook-notification@main
|
- uses: numerique-gouv/action-argocd-webhook-notification@cac2ee67896eb13e84e804f60c4271370424eaa8 # main
|
||||||
id: notify
|
id: notify
|
||||||
with:
|
with:
|
||||||
deployment_repo_path: "${{ secrets.DEPLOYMENT_REPO_URL }}"
|
deployment_repo_path: "${{ secrets.DEPLOYMENT_REPO_URL }}"
|
||||||
|
|||||||
@@ -1,17 +1,33 @@
|
|||||||
name: Release Helm chart
|
name: Release Chart
|
||||||
|
run-name: Release Chart
|
||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
branches:
|
|
||||||
- main
|
|
||||||
paths:
|
paths:
|
||||||
- src/helm/meet/**
|
- src/helm/meet/**
|
||||||
|
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
release:
|
release:
|
||||||
permissions:
|
permissions:
|
||||||
contents: write
|
contents: write
|
||||||
uses: suitenumerique/ci/.github/workflows/_release-helm-chart.yml@main
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
|
- name: Cleanup
|
||||||
|
run: rm -rf ./src/helm/extra
|
||||||
|
|
||||||
|
- name: Install Helm
|
||||||
|
uses: azure/setup-helm@1a275c3b69536ee54be43f2070a358922e12c8d4 # v4.3.1
|
||||||
|
env:
|
||||||
|
GITHUB_TOKEN: "${{ secrets.GITHUB_TOKEN }}"
|
||||||
|
|
||||||
|
- name: Publish Helm charts
|
||||||
|
uses: numerique-gouv/helm-gh-pages@2cf477ae49d7c70037ceb1685803f4f7bad9b981 # add-overwrite-option
|
||||||
|
with:
|
||||||
|
charts_dir: ./src/helm
|
||||||
|
linting: on
|
||||||
|
token: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|||||||
@@ -1,21 +0,0 @@
|
|||||||
name: Security analysis
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches:
|
|
||||||
- main
|
|
||||||
pull_request:
|
|
||||||
branches:
|
|
||||||
- "**"
|
|
||||||
|
|
||||||
permissions: {}
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
zizmor:
|
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
actions: read
|
|
||||||
security-events: write
|
|
||||||
uses: suitenumerique/ci/.github/workflows/_zizmor.yml@main
|
|
||||||
with:
|
|
||||||
config: .github/zizmor.yml
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
rules:
|
|
||||||
unpinned-uses:
|
|
||||||
config:
|
|
||||||
policies:
|
|
||||||
"suitenumerique/*": ref-pin
|
|
||||||
@@ -86,6 +86,3 @@ docker/livekit/rootCA.pem
|
|||||||
|
|
||||||
# Frontend rollup-plugin-visualizer
|
# Frontend rollup-plugin-visualizer
|
||||||
/src/frontend/rollup-plugin-visualizer/*
|
/src/frontend/rollup-plugin-visualizer/*
|
||||||
|
|
||||||
# NixOS
|
|
||||||
.devenv
|
|
||||||
|
|||||||
+1
-30
@@ -8,27 +8,12 @@ and this project adheres to
|
|||||||
|
|
||||||
## [Unreleased]
|
## [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
|
### Added
|
||||||
|
|
||||||
- ✨(backend) make the LiveKit default video codec configurable
|
- ✨(backend) make the LiveKit default video codec configurable
|
||||||
- 🔧(dev) add support for Bureautix workstations
|
|
||||||
- ✨(frontend) add screen share zoom controls #1498
|
|
||||||
|
|
||||||
### Changed
|
### 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
|
- 📈(frontend) include LiveKit SIDs in the connection analytics event
|
||||||
- 🔇(backend) silence expected 401 warnings on /me
|
- 🔇(backend) silence expected 401 warnings on /me
|
||||||
- 🔇(backend) silence noisy request summary info logs
|
- 🔇(backend) silence noisy request summary info logs
|
||||||
@@ -37,26 +22,12 @@ and this project adheres to
|
|||||||
- ⬆️(frontend) upgrade posthog-js from 1.414.0 to 1.418.10
|
- ⬆️(frontend) upgrade posthog-js from 1.414.0 to 1.418.10
|
||||||
- ⬆️(addons) upgrade i18next from 26.3.6 to 26.4.0
|
- ⬆️(addons) upgrade i18next from 26.3.6 to 26.4.0
|
||||||
- ⬆️(frontend) upgrade humanize-duration from 3.33.2 to 3.34.1
|
- ⬆️(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
|
### 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) acknowledge unknown LiveKit webhook events instead of 422
|
||||||
- 🔒️(backend) enforce display name setting on rename API
|
- 🔒️(backend) enforce display name setting on rename API
|
||||||
- 🔒️(backend) reject inactive users in resource server backend
|
- 🔒️(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.31.0] - 2026-09-08
|
||||||
|
|
||||||
@@ -374,7 +345,7 @@ and this project adheres to
|
|||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|
||||||
- ♿️(frontend) improve accessibility of the Effects panel #1401
|
- ♿️(frontend) improve accessibilty of the Effects panel #1401
|
||||||
|
|
||||||
## [1.20.0] - 2026-06-12
|
## [1.20.0] - 2026-06-12
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
# Django Meet
|
# Django Meet
|
||||||
|
|
||||||
# ---- base image to inherit from ----
|
# ---- 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
|
# Upgrade pip to its latest release to speed up dependencies installation
|
||||||
RUN python -m pip install --upgrade pip
|
RUN python -m pip install --upgrade pip
|
||||||
|
|||||||
@@ -292,7 +292,7 @@ shell: ## connect to database shell
|
|||||||
# -- Database
|
# -- Database
|
||||||
|
|
||||||
dbshell: ## connect to database shell
|
dbshell: ## connect to database shell
|
||||||
@$(COMPOSE_EXEC_APP) python manage.py dbshell
|
docker compose exec app-dev python manage.py dbshell
|
||||||
.PHONY: dbshell
|
.PHONY: dbshell
|
||||||
|
|
||||||
resetdb: FLUSH_ARGS ?=
|
resetdb: FLUSH_ARGS ?=
|
||||||
|
|||||||
+10
-9
@@ -5,7 +5,7 @@ set -eo pipefail
|
|||||||
REPO_DIR="$(cd "$( dirname "${BASH_SOURCE[0]}" )/.." && pwd)"
|
REPO_DIR="$(cd "$( dirname "${BASH_SOURCE[0]}" )/.." && pwd)"
|
||||||
UNSET_USER=0
|
UNSET_USER=0
|
||||||
|
|
||||||
export COMPOSE_FILE="${COMPOSE_FILE:-${REPO_DIR}/compose.yml}"
|
COMPOSE_FILE="${REPO_DIR}/compose.yml"
|
||||||
COMPOSE_PROJECT="meet"
|
COMPOSE_PROJECT="meet"
|
||||||
|
|
||||||
|
|
||||||
@@ -42,6 +42,7 @@ function _docker_compose() {
|
|||||||
echo "🐳(compose) project: '${COMPOSE_PROJECT}' file: '${COMPOSE_FILE}'"
|
echo "🐳(compose) project: '${COMPOSE_PROJECT}' file: '${COMPOSE_FILE}'"
|
||||||
docker compose \
|
docker compose \
|
||||||
-p "${COMPOSE_PROJECT}" \
|
-p "${COMPOSE_PROJECT}" \
|
||||||
|
-f "${COMPOSE_FILE}" \
|
||||||
--project-directory "${REPO_DIR}" \
|
--project-directory "${REPO_DIR}" \
|
||||||
"$@"
|
"$@"
|
||||||
}
|
}
|
||||||
@@ -55,12 +56,12 @@ function _docker_compose() {
|
|||||||
function _dc_run() {
|
function _dc_run() {
|
||||||
_set_user
|
_set_user
|
||||||
|
|
||||||
user_args=()
|
user_args="--user=$USER_ID"
|
||||||
if [ -n "$USER_ID" ]; then
|
if [ -z $USER_ID ]; then
|
||||||
user_args=("--user=$USER_ID")
|
user_args=""
|
||||||
fi
|
fi
|
||||||
|
|
||||||
_docker_compose run --rm "${user_args[@]}" "$@"
|
_docker_compose run --rm $user_args "$@"
|
||||||
}
|
}
|
||||||
|
|
||||||
# _dc_exec: wrap docker compose exec command
|
# _dc_exec: wrap docker compose exec command
|
||||||
@@ -74,12 +75,12 @@ function _dc_exec() {
|
|||||||
|
|
||||||
echo "🐳(compose) exec command: '\$@'"
|
echo "🐳(compose) exec command: '\$@'"
|
||||||
|
|
||||||
user_args=()
|
user_args="--user=$USER_ID"
|
||||||
if [ -n "$USER_ID" ]; then
|
if [ -z $USER_ID ]; then
|
||||||
user_args=("--user=$USER_ID")
|
user_args=""
|
||||||
fi
|
fi
|
||||||
|
|
||||||
_docker_compose exec "${user_args[@]}" "$@"
|
_docker_compose exec $user_args "$@"
|
||||||
}
|
}
|
||||||
|
|
||||||
# _django_manage: wrap django's manage.py command with docker compose
|
# _django_manage: wrap django's manage.py command with docker compose
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ if [ -n "$CUSTOM_LOGO_URL" ]; then
|
|||||||
[[ "$IS_SVG" == false ]] && echo "[custom-logo] ERROR: not a valid SVG file" >&2 && exit 1
|
[[ "$IS_SVG" == false ]] && echo "[custom-logo] ERROR: not a valid SVG file" >&2 && exit 1
|
||||||
|
|
||||||
mv -f "$TMP_FILE" "$LOGO_FILE"
|
mv -f "$TMP_FILE" "$LOGO_FILE"
|
||||||
echo "[custom-logo] INFO: Custom logo downloaded successfully"
|
echo "[custom-logo] INFO: Custom logo downloaded successfuly"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
mv src/backend/* ./
|
mv src/backend/* ./
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ gunicorn -b 0.0.0.0:8000 meet.wsgi:application --log-file - &
|
|||||||
bin/run &
|
bin/run &
|
||||||
|
|
||||||
# if the current shell is killed, also terminate all its children
|
# if the current shell is killed, also terminate all its children
|
||||||
trap 'pkill -TERM -P $$' SIGTERM
|
trap "pkill SIGTERM -P $$" SIGTERM
|
||||||
|
|
||||||
# wait for a single child to finish,
|
# wait for a single child to finish,
|
||||||
wait -n
|
wait -n
|
||||||
|
|||||||
Executable
+6
@@ -0,0 +1,6 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
|
||||||
|
# shellcheck source=bin/_config.sh
|
||||||
|
source "$(dirname "${BASH_SOURCE[0]}")/_config.sh"
|
||||||
|
|
||||||
|
_docker_compose "$@"
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
set -o errexit
|
set -o errexit
|
||||||
|
|
||||||
|
CURRENT_DIR=$(pwd)
|
||||||
NAMESPACE=${1:-meet}
|
NAMESPACE=${1:-meet}
|
||||||
SECRET_NAME=${2:-bitwarden-cli-meet}
|
SECRET_NAME=${2:-bitwarden-cli-meet}
|
||||||
TEMP_SECRET_FILE=$(mktemp)
|
TEMP_SECRET_FILE=$(mktemp)
|
||||||
@@ -29,10 +30,10 @@ check_secret_exists() {
|
|||||||
# Collect user input securely
|
# Collect user input securely
|
||||||
get_user_input() {
|
get_user_input() {
|
||||||
echo "Please provide the following information:"
|
echo "Please provide the following information:"
|
||||||
read -r -p "Enter your Vaultwarden email login: " LOGIN
|
read -p "Enter your Vaultwarden email login: " LOGIN
|
||||||
read -r -s -p "Enter your Vaultwarden password: " PASSWORD
|
read -s -p "Enter your Vaultwarden password: " PASSWORD
|
||||||
echo
|
echo
|
||||||
read -r -p "Enter your Vaultwarden server url: " URL
|
read -p "Enter your Vaultwarden server url: " URL
|
||||||
}
|
}
|
||||||
|
|
||||||
# Create and apply the secret
|
# Create and apply the secret
|
||||||
@@ -76,7 +77,7 @@ main() {
|
|||||||
exit 0
|
exit 0
|
||||||
fi
|
fi
|
||||||
|
|
||||||
echo -e "${TEMP_SECRET_FILE}"
|
echo -e ${TEMP_SECRET_FILE}
|
||||||
|
|
||||||
get_user_input
|
get_user_input
|
||||||
echo -e "\nCreating Vaultwarden secret…"
|
echo -e "\nCreating Vaultwarden secret…"
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
mkdir -p "$(dirname -- "${BASH_SOURCE[0]}")/../.git/hooks/"
|
mkdir -p "$(dirname -- "${BASH_SOURCE[0]}")/../.git/hooks/"
|
||||||
PRE_COMMIT_FILE="$(dirname -- "${BASH_SOURCE[0]}")/../.git/hooks/pre-commit"
|
PRE_COMMIT_FILE="$(dirname -- "${BASH_SOURCE[0]}")/../.git/hooks/pre-commit"
|
||||||
|
|
||||||
cat <<'EOF' >"$PRE_COMMIT_FILE"
|
cat <<'EOF' >$PRE_COMMIT_FILE
|
||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
|
|
||||||
# directories containing potential secrets
|
# directories containing potential secrets
|
||||||
@@ -27,4 +27,4 @@ for d in $DIRS; do
|
|||||||
done
|
done
|
||||||
EOF
|
EOF
|
||||||
|
|
||||||
chmod +x "$PRE_COMMIT_FILE"
|
chmod +x $PRE_COMMIT_FILE
|
||||||
|
|||||||
@@ -68,7 +68,7 @@ fi
|
|||||||
|
|
||||||
# Ask user for release version number
|
# Ask user for release version number
|
||||||
echo ""
|
echo ""
|
||||||
read -r -p "Enter release version number (e.g., 1.2.3): " VERSION
|
read -p "Enter release version number (e.g., 1.2.3): " VERSION
|
||||||
|
|
||||||
# Validate version format (basic semver check)
|
# Validate version format (basic semver check)
|
||||||
if ! [[ $VERSION =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
|
if ! [[ $VERSION =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
|
|
||||||
git submodule update --init --recursive
|
git submodule update --init --recursive
|
||||||
# shellcheck disable=SC2016
|
|
||||||
git submodule foreach 'git fetch origin; git checkout $(git rev-parse --abbrev-ref HEAD); git reset --hard origin/$(git rev-parse --abbrev-ref HEAD); git submodule update --recursive; git clean -dfx'
|
git submodule foreach 'git fetch origin; git checkout $(git rev-parse --abbrev-ref HEAD); git reset --hard origin/$(git rev-parse --abbrev-ref HEAD); git submodule update --recursive; git clean -dfx'
|
||||||
|
|||||||
@@ -8,6 +8,6 @@ environments=$(awk '/environments:/ {flag=1; next} flag && NF {print} !NF {flag=
|
|||||||
|
|
||||||
for env in $environments; do
|
for env in $environments; do
|
||||||
echo "################### $env lint ###################"
|
echo "################### $env lint ###################"
|
||||||
helmfile -e "$env" -f src/helm/helmfile.yaml lint || exit 1
|
helmfile -e $env -f src/helm/helmfile.yaml lint || exit 1
|
||||||
echo -e "\n"
|
echo -e "\n"
|
||||||
done
|
done
|
||||||
|
|||||||
-47
@@ -1,47 +0,0 @@
|
|||||||
{
|
|
||||||
"nodes": {
|
|
||||||
"devenv": {
|
|
||||||
"locked": {
|
|
||||||
"dir": "src/modules",
|
|
||||||
"lastModified": 1778705847,
|
|
||||||
"narHash": "sha256-EQnZCy7r4VMO6KDoytxHBa0mFbM1D9g1kaDfs/s0YZA=",
|
|
||||||
"ref": "refs/tags/v2.1.2",
|
|
||||||
"rev": "ea3d94ac9d6bf6a1313773170122ca4e2ef5a0be",
|
|
||||||
"revCount": 6569,
|
|
||||||
"type": "git",
|
|
||||||
"url": "https://github.com/cachix/devenv"
|
|
||||||
},
|
|
||||||
"original": {
|
|
||||||
"dir": "src/modules",
|
|
||||||
"ref": "refs/tags/v2.1.2",
|
|
||||||
"type": "git",
|
|
||||||
"url": "https://github.com/cachix/devenv"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"nixpkgs": {
|
|
||||||
"locked": {
|
|
||||||
"lastModified": 1789542786,
|
|
||||||
"narHash": "sha256-ajQuqcxnj6RYnwCjA/9FTCnrdR2+BDzstE8UembXf60=",
|
|
||||||
"ref": "nixos-26.05",
|
|
||||||
"rev": "4c7870105e7f1fdf9c48688c8d7efc21abf0688a",
|
|
||||||
"shallow": true,
|
|
||||||
"type": "git",
|
|
||||||
"url": "https://github.com/NixOS/nixpkgs"
|
|
||||||
},
|
|
||||||
"original": {
|
|
||||||
"ref": "nixos-26.05",
|
|
||||||
"shallow": true,
|
|
||||||
"type": "git",
|
|
||||||
"url": "https://github.com/NixOS/nixpkgs"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"root": {
|
|
||||||
"inputs": {
|
|
||||||
"devenv": "devenv",
|
|
||||||
"nixpkgs": "nixpkgs"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"root": "root",
|
|
||||||
"version": 7
|
|
||||||
}
|
|
||||||
-265
@@ -1,265 +0,0 @@
|
|||||||
# =============================================================================
|
|
||||||
# devenv.nix — La Suite Meet ("Visio") developer environment
|
|
||||||
# =============================================================================
|
|
||||||
{
|
|
||||||
pkgs,
|
|
||||||
lib,
|
|
||||||
config,
|
|
||||||
...
|
|
||||||
}:
|
|
||||||
|
|
||||||
let
|
|
||||||
python = pkgs.python313;
|
|
||||||
nodejs = pkgs.nodejs_22;
|
|
||||||
|
|
||||||
backendDir = "src/backend";
|
|
||||||
agentsDir = "src/agents";
|
|
||||||
summaryDir = "src/summary";
|
|
||||||
frontendDir = "src/frontend";
|
|
||||||
|
|
||||||
readDotEnv =
|
|
||||||
file:
|
|
||||||
let
|
|
||||||
lines = lib.splitString "\n" (builtins.readFile file);
|
|
||||||
unquote =
|
|
||||||
v:
|
|
||||||
let
|
|
||||||
len = builtins.stringLength v;
|
|
||||||
in
|
|
||||||
if len >= 2 && lib.hasPrefix "\"" v && lib.hasSuffix "\"" v then
|
|
||||||
builtins.substring 1 (len - 2) v
|
|
||||||
else if len >= 2 && lib.hasPrefix "'" v && lib.hasSuffix "'" v then
|
|
||||||
builtins.substring 1 (len - 2) v
|
|
||||||
else
|
|
||||||
v;
|
|
||||||
parseLine =
|
|
||||||
line:
|
|
||||||
let
|
|
||||||
m = builtins.match "[ \t]*([A-Za-z_][A-Za-z0-9_]*)[ \t]*=[ \t]*(.*)" line;
|
|
||||||
in
|
|
||||||
if m == null then null else { name = builtins.elemAt m 0; value = unquote (builtins.elemAt m 1); };
|
|
||||||
in
|
|
||||||
builtins.listToAttrs (builtins.filter (x: x != null) (map parseLine lines));
|
|
||||||
|
|
||||||
# Reuse existing .env
|
|
||||||
dotEnv =
|
|
||||||
(readDotEnv ./env.d/development/common.dist)
|
|
||||||
// (readDotEnv ./env.d/development/postgresql.dist);
|
|
||||||
|
|
||||||
sharedEnv = builtins.removeAttrs dotEnv [ "PYTHONPATH" ]; # only makes sense inside the backend container.
|
|
||||||
in
|
|
||||||
{
|
|
||||||
options.meet = {
|
|
||||||
agents.enable = lib.mkEnableOption "tooling for the LiveKit agents in src/agents";
|
|
||||||
summary.enable = lib.mkEnableOption "tooling for the summary service in src/summary";
|
|
||||||
k8s.enable = lib.mkEnableOption "Kubernetes dev utilities";
|
|
||||||
};
|
|
||||||
|
|
||||||
config = {
|
|
||||||
# Profile can be activated with devenv --profile <profile> shell
|
|
||||||
profiles = {
|
|
||||||
agents.module = {
|
|
||||||
meet.agents.enable = true;
|
|
||||||
};
|
|
||||||
summary.module = {
|
|
||||||
meet.summary.enable = true;
|
|
||||||
};
|
|
||||||
k8s.module = {
|
|
||||||
meet.k8s.enable = true;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
languages.python = {
|
|
||||||
enable = true;
|
|
||||||
package = python;
|
|
||||||
directory = backendDir;
|
|
||||||
manylinux.enable = pkgs.stdenv.hostPlatform.isLinux;
|
|
||||||
|
|
||||||
libraries = [
|
|
||||||
"${config.devenv.dotfile}/profile"
|
|
||||||
pkgs.file
|
|
||||||
pkgs.zlib
|
|
||||||
pkgs.libffi
|
|
||||||
pkgs.openssl
|
|
||||||
];
|
|
||||||
|
|
||||||
uv.enable = true;
|
|
||||||
uv.sync.enable = false;
|
|
||||||
venv.enable = false;
|
|
||||||
lsp.enable = true;
|
|
||||||
};
|
|
||||||
|
|
||||||
languages.javascript = {
|
|
||||||
enable = true;
|
|
||||||
package = nodejs;
|
|
||||||
directory = frontendDir;
|
|
||||||
|
|
||||||
npm.enable = true;
|
|
||||||
yarn.enable = true;
|
|
||||||
corepack.enable = false;
|
|
||||||
};
|
|
||||||
|
|
||||||
languages.typescript.enable = false;
|
|
||||||
languages.nix.enable = true;
|
|
||||||
|
|
||||||
packages =
|
|
||||||
with pkgs;
|
|
||||||
[
|
|
||||||
gnumake
|
|
||||||
file
|
|
||||||
shared-mime-info
|
|
||||||
gettext
|
|
||||||
postgresql_16
|
|
||||||
git
|
|
||||||
curl
|
|
||||||
jq
|
|
||||||
podman
|
|
||||||
podman-compose
|
|
||||||
docker-client
|
|
||||||
]
|
|
||||||
|
|
||||||
# -- LiveKit agents
|
|
||||||
++ lib.optionals config.meet.agents.enable [
|
|
||||||
glib
|
|
||||||
portaudio
|
|
||||||
livekit-cli
|
|
||||||
]
|
|
||||||
|
|
||||||
# -- summary service
|
|
||||||
++ lib.optionals config.meet.summary.enable [
|
|
||||||
redis
|
|
||||||
]
|
|
||||||
|
|
||||||
# -- Kubernetes tools
|
|
||||||
++ lib.optionals config.meet.k8s.enable [
|
|
||||||
kubectl
|
|
||||||
kubernetes-helm
|
|
||||||
helmfile
|
|
||||||
tilt
|
|
||||||
kind
|
|
||||||
mkcert
|
|
||||||
];
|
|
||||||
|
|
||||||
env = sharedEnv // {
|
|
||||||
UV_LINK_MODE = "copy";
|
|
||||||
|
|
||||||
PYTHONDONTWRITEBYTECODE = "1";
|
|
||||||
PYTHONUNBUFFERED = "1";
|
|
||||||
|
|
||||||
UV_PROJECT_ENVIRONMENT = lib.mkForce ".venv";
|
|
||||||
|
|
||||||
COMPOSE_PROJECT_NAME = "meet";
|
|
||||||
|
|
||||||
DJANGO_DATA_DIR = "${config.devenv.root}/data";
|
|
||||||
|
|
||||||
# Database / Pgsql
|
|
||||||
DB_HOST = "127.0.0.1";
|
|
||||||
DB_PORT = "15432";
|
|
||||||
PGHOST = "127.0.0.1";
|
|
||||||
PGPORT = "15432";
|
|
||||||
PGDATABASE = sharedEnv.DB_NAME;
|
|
||||||
PGUSER = sharedEnv.DB_USER;
|
|
||||||
PGPASSWORD = sharedEnv.DB_PASSWORD;
|
|
||||||
|
|
||||||
REDIS_URL = "redis://127.0.0.1:6379/1";
|
|
||||||
CELERY_BROKER_URL = "redis://127.0.0.1:6379/0";
|
|
||||||
|
|
||||||
# S3 / MinIO
|
|
||||||
AWS_S3_ENDPOINT_URL = "http://127.0.0.1:9000";
|
|
||||||
|
|
||||||
# OIDC
|
|
||||||
OIDC_OP_JWKS_ENDPOINT = "http://localhost:8083/realms/meet/protocol/openid-connect/certs";
|
|
||||||
OIDC_OP_TOKEN_ENDPOINT = "http://localhost:8083/realms/meet/protocol/openid-connect/token";
|
|
||||||
OIDC_OP_USER_ENDPOINT = "http://localhost:8083/realms/meet/protocol/openid-connect/userinfo";
|
|
||||||
OIDC_OP_INTROSPECTION_ENDPOINT = "http://localhost:8083/realms/meet/protocol/openid-connect/token/introspect";
|
|
||||||
|
|
||||||
# summary service
|
|
||||||
SUMMARY_SERVICE_ENDPOINT = "http://127.0.0.1:8001/api/v2/async-jobs/transcribe/";
|
|
||||||
SUMMARY_SERVICE_VERSION = "2";
|
|
||||||
|
|
||||||
# Mail
|
|
||||||
DJANGO_EMAIL_HOST = "127.0.0.1";
|
|
||||||
};
|
|
||||||
|
|
||||||
scripts = {
|
|
||||||
|
|
||||||
meet-venv = {
|
|
||||||
description = "Create/refresh meet uv virtualenvs for backend, agents and summary";
|
|
||||||
exec = ''
|
|
||||||
set -euo pipefail
|
|
||||||
cd "$DEVENV_ROOT"
|
|
||||||
|
|
||||||
echo "==> ${backendDir} (uv sync --locked, dependency-groups)"
|
|
||||||
( cd "${backendDir}" && uv sync --locked --all-groups )
|
|
||||||
|
|
||||||
echo "==> ${agentsDir} (uv sync --locked --all-extras)"
|
|
||||||
( cd "${agentsDir}" && uv sync --locked --all-extras )
|
|
||||||
|
|
||||||
echo "==> ${summaryDir} (uv sync --locked --all-extras)"
|
|
||||||
( cd "${summaryDir}" && uv sync --locked --all-extras )
|
|
||||||
|
|
||||||
echo
|
|
||||||
echo "Synced the following virtualenvs successfully:"
|
|
||||||
echo " ${backendDir}/.venv"
|
|
||||||
echo " ${agentsDir}/.venv"
|
|
||||||
echo " ${summaryDir}/.venv"
|
|
||||||
'';
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
enterShell = ''
|
|
||||||
# Make podman socket accessible in order to launch regular docker commands.
|
|
||||||
# Set MEET_PODMAN_SOCKET=0 to keep the DOCKER_HOST of the calling environment.
|
|
||||||
case "''${MEET_PODMAN_SOCKET:-1}" in
|
|
||||||
0|false|no|off) ;;
|
|
||||||
*)
|
|
||||||
_rundir="''${XDG_RUNTIME_DIR:-/run/user/$(id -u)}"
|
|
||||||
export DOCKER_HOST="unix://$_rundir/podman/podman.sock"
|
|
||||||
unset _rundir
|
|
||||||
;;
|
|
||||||
esac
|
|
||||||
|
|
||||||
# Compose files to merge
|
|
||||||
_compose_dir="${config.devenv.root}/docker/compose.d"
|
|
||||||
_compose_files="${config.devenv.root}/compose.yml"
|
|
||||||
|
|
||||||
export DOCKER_USER="$(id -u):$(id -g)"
|
|
||||||
|
|
||||||
case "''${DOCKER_HOST:-}" in
|
|
||||||
*podman*)
|
|
||||||
_compose_files="$_compose_files:$_compose_dir/compose.podman.yml"
|
|
||||||
|
|
||||||
# Build images with Podman/Buildah rather than BuildKit. `docker
|
|
||||||
# compose build` otherwise has buildx boot a moby/buildkit container,
|
|
||||||
# and that container lands in its own network namespace with neither
|
|
||||||
# the proxy in its environment nor any route to it.
|
|
||||||
# Buildah has neither problem: base images are resolved by the Podman systemd
|
|
||||||
# service, which inherits the proxy from its systemd socket activated unit, and
|
|
||||||
# RUN steps execute in the *host* network namespace
|
|
||||||
|
|
||||||
export DOCKER_BUILDKIT=0
|
|
||||||
export COMPOSE_BAKE=false
|
|
||||||
;;
|
|
||||||
esac
|
|
||||||
|
|
||||||
|
|
||||||
# Apply Bureautix override
|
|
||||||
if [ -n "''${http_proxy:-}" ]; then
|
|
||||||
_compose_files="$_compose_files:$_compose_dir/compose.bureautix.yml"
|
|
||||||
fi
|
|
||||||
|
|
||||||
export COMPOSE_FILE="$_compose_files"
|
|
||||||
unset _compose_dir _compose_files
|
|
||||||
|
|
||||||
# Make binaries accessible
|
|
||||||
for _d in \
|
|
||||||
"$DEVENV_ROOT/${backendDir}/.venv/bin" \
|
|
||||||
"$DEVENV_ROOT/${agentsDir}/.venv/bin" \
|
|
||||||
"$DEVENV_ROOT/${summaryDir}/.venv/bin" \
|
|
||||||
"$DEVENV_ROOT/${frontendDir}/node_modules/.bin"
|
|
||||||
do
|
|
||||||
[ -d "$_d" ] && export PATH="$_d:$PATH"
|
|
||||||
done
|
|
||||||
unset _d
|
|
||||||
'';
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
inputs:
|
|
||||||
nixpkgs:
|
|
||||||
url: git+https://github.com/NixOS/nixpkgs?ref=nixos-26.05&shallow=1
|
|
||||||
devenv:
|
|
||||||
url: git+https://github.com/cachix/devenv?ref=refs/tags/v2.1.2&dir=src/modules
|
|
||||||
@@ -1,48 +0,0 @@
|
|||||||
# Bureautix proxy overrides
|
|
||||||
#
|
|
||||||
# Builds submitted through the Docker API of the Podman service get none of
|
|
||||||
# the proxy variables in their RUN steps. We need to pass the http_proxy explicitly
|
|
||||||
# otherwise all connections fail during the build.
|
|
||||||
|
|
||||||
x-proxy-vars: &proxy-vars
|
|
||||||
http_proxy: ${http_proxy:-}
|
|
||||||
https_proxy: ${https_proxy:-}
|
|
||||||
no_proxy: ${no_proxy:-}
|
|
||||||
|
|
||||||
services:
|
|
||||||
app:
|
|
||||||
build:
|
|
||||||
args:
|
|
||||||
<<: *proxy-vars
|
|
||||||
app-dev:
|
|
||||||
build:
|
|
||||||
args:
|
|
||||||
<<: *proxy-vars
|
|
||||||
frontend:
|
|
||||||
build:
|
|
||||||
args:
|
|
||||||
<<: *proxy-vars
|
|
||||||
metadata-collector-dev:
|
|
||||||
build:
|
|
||||||
args:
|
|
||||||
<<: *proxy-vars
|
|
||||||
multi-user-transcriber-dev:
|
|
||||||
build:
|
|
||||||
args:
|
|
||||||
<<: *proxy-vars
|
|
||||||
app-summary-dev:
|
|
||||||
build:
|
|
||||||
args:
|
|
||||||
<<: *proxy-vars
|
|
||||||
celery-summary-transcribe:
|
|
||||||
build:
|
|
||||||
args:
|
|
||||||
<<: *proxy-vars
|
|
||||||
celery-summary-summarize:
|
|
||||||
build:
|
|
||||||
args:
|
|
||||||
<<: *proxy-vars
|
|
||||||
# The local proxy listens on 8080 and collides with Keycloak's published admin port.
|
|
||||||
keycloak:
|
|
||||||
ports: !override
|
|
||||||
- "8081:8080"
|
|
||||||
@@ -1,33 +0,0 @@
|
|||||||
# Rootless Podman override for compose.yml.
|
|
||||||
#
|
|
||||||
# Rootless Podman maps container UID 0 to the host user and every other
|
|
||||||
# container UID to a subuid that owns nothing in the worktree. Meet compose.ymlruns
|
|
||||||
# its containers as DOCKER_USER=$(id -u):$(id -g), which would land on such a
|
|
||||||
# subuid and make every bind mount effectively read-only.
|
|
||||||
#
|
|
||||||
# `userns_mode: keep-id` maps the host user to the same UID and GID inside the
|
|
||||||
# container instead, so DOCKER_USER keeps its Docker value and files written
|
|
||||||
# through a bind mount are owned by the host user on both sides.
|
|
||||||
#
|
|
||||||
# Only the services that mount the worktree and run as DOCKER_USER are listed.
|
|
||||||
|
|
||||||
x-keep-id: &keep-id
|
|
||||||
userns_mode: keep-id
|
|
||||||
|
|
||||||
services:
|
|
||||||
app-dev:
|
|
||||||
<<: *keep-id
|
|
||||||
celery-dev:
|
|
||||||
<<: *keep-id
|
|
||||||
minio:
|
|
||||||
<<: *keep-id
|
|
||||||
node:
|
|
||||||
<<: *keep-id
|
|
||||||
crowdin:
|
|
||||||
<<: *keep-id
|
|
||||||
metadata-collector-dev:
|
|
||||||
<<: *keep-id
|
|
||||||
multi-user-transcriber-dev:
|
|
||||||
<<: *keep-id
|
|
||||||
app-summary-dev:
|
|
||||||
<<: *keep-id
|
|
||||||
@@ -57,8 +57,7 @@ RUN npx webpack --mode production
|
|||||||
FROM nginxinc/nginx-unprivileged:1.30.4-alpine3.24 AS frontend-production
|
FROM nginxinc/nginx-unprivileged:1.30.4-alpine3.24 AS frontend-production
|
||||||
|
|
||||||
USER root
|
USER root
|
||||||
RUN apk upgrade --no-cache libexpat && \
|
RUN apk del curl
|
||||||
apk del curl
|
|
||||||
USER nginx
|
USER nginx
|
||||||
|
|
||||||
USER nginx
|
USER nginx
|
||||||
|
|||||||
@@ -21,10 +21,3 @@ turn:
|
|||||||
- 192.168.0.0/16
|
- 192.168.0.0/16
|
||||||
- 172.16.0.0/12
|
- 172.16.0.0/12
|
||||||
|
|
||||||
rtc:
|
|
||||||
node_ip: 127.0.0.1
|
|
||||||
advertise_internal_ip: true
|
|
||||||
udp_port: 7882
|
|
||||||
tcp_port: 7881
|
|
||||||
use_external_ip: false
|
|
||||||
|
|
||||||
|
|||||||
@@ -317,7 +317,7 @@ These are the environmental options available on meet backend.
|
|||||||
| DJANGO_ALLOWED_HOSTS | Hosts that are allowed | [] |
|
| DJANGO_ALLOWED_HOSTS | Hosts that are allowed | [] |
|
||||||
| DJANGO_SECRET_KEY | Secret key used for Django security | |
|
| DJANGO_SECRET_KEY | Secret key used for Django security | |
|
||||||
| DJANGO_SILENCED_SYSTEM_CHECKS | Silence Django system checks | [] |
|
| DJANGO_SILENCED_SYSTEM_CHECKS | Silence Django system checks | [] |
|
||||||
| DJANGO_ALLOW_UNSECURE_USER_LISTING | Allow insecure user listing | false |
|
| DJANGO_ALLOW_UNSECURE_USER_LISTING | Allow unsecure user listing | false |
|
||||||
| DB_ENGINE | Database engine used | django.db.backends.postgresql_psycopg2 |
|
| DB_ENGINE | Database engine used | django.db.backends.postgresql_psycopg2 |
|
||||||
| DB_NAME | Name of the database | meet |
|
| DB_NAME | Name of the database | meet |
|
||||||
| DB_USER | User used to connect to database | dinum |
|
| DB_USER | User used to connect to database | dinum |
|
||||||
|
|||||||
+1
-1
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"extends": ["github>suitenumerique/ci//renovate/default"],
|
"extends": ["github>numerique-gouv/renovate-configuration"],
|
||||||
"dependencyDashboard": true,
|
"dependencyDashboard": true,
|
||||||
"labels": ["dependencies", "noChangeLog"],
|
"labels": ["dependencies", "noChangeLog"],
|
||||||
"packageRules": [
|
"packageRules": [
|
||||||
|
|||||||
Generated
+4
-4
@@ -10,7 +10,7 @@
|
|||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"core-js": "3.50.0",
|
"core-js": "3.50.0",
|
||||||
"i18next": "26.4.2",
|
"i18next": "26.4.0",
|
||||||
"i18next-browser-languagedetector": "8.2.1",
|
"i18next-browser-languagedetector": "8.2.1",
|
||||||
"regenerator-runtime": "0.14.1"
|
"regenerator-runtime": "0.14.1"
|
||||||
},
|
},
|
||||||
@@ -9367,9 +9367,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/i18next": {
|
"node_modules/i18next": {
|
||||||
"version": "26.4.2",
|
"version": "26.4.0",
|
||||||
"resolved": "https://registry.npmjs.org/i18next/-/i18next-26.4.2.tgz",
|
"resolved": "https://registry.npmjs.org/i18next/-/i18next-26.4.0.tgz",
|
||||||
"integrity": "sha512-RX+R0VLg13IbvRuJSxnqykUFS9vQZTl8wYpWPCIUDWVrSGjsQywB5Y+pjzrkboxGAuYfJZVH1InFTdgBdxq6ug==",
|
"integrity": "sha512-rsmK5bFqsD1AetSFSIa43wtNR4WpvvH4p0tLEsTxkC7QTrfdFm06nbQ95bh8Og4wwaCnUEcm9DVYL2cgxitiQg==",
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
"type": "individual",
|
"type": "individual",
|
||||||
|
|||||||
@@ -27,7 +27,7 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"core-js": "3.50.0",
|
"core-js": "3.50.0",
|
||||||
"i18next": "26.4.2",
|
"i18next": "26.4.0",
|
||||||
"i18next-browser-languagedetector": "8.2.1",
|
"i18next-browser-languagedetector": "8.2.1",
|
||||||
"regenerator-runtime": "0.14.1"
|
"regenerator-runtime": "0.14.1"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ const { initI18n, translateUI } = require("../common/i18n");
|
|||||||
document.querySelector("#close-msg").style.display = "block";
|
document.querySelector("#close-msg").style.display = "block";
|
||||||
})
|
})
|
||||||
.catch((e) => {
|
.catch((e) => {
|
||||||
console.error(`Error occurred: ${e}`);
|
console.error(`Error occured: ${e}`);
|
||||||
})
|
})
|
||||||
.finally(() => {
|
.finally(() => {
|
||||||
// NOTE: doesn't work with the desktop client — the browser considers
|
// NOTE: doesn't work with the desktop client — the browser considers
|
||||||
|
|||||||
@@ -1,11 +1,9 @@
|
|||||||
FROM python:3.14.6-slim AS base
|
FROM python:3.14.6-slim AS base
|
||||||
|
|
||||||
# Install system dependencies required by LiveKit, fetching packages over HTTPS only for Bureautix proxy
|
# Install system dependencies required by LiveKit
|
||||||
RUN sed -i "s|^URIs: http://|URIs: https://|" /etc/apt/sources.list.d/debian.sources \
|
RUN apt-get update && apt-get install -y \
|
||||||
&& apt-get update && apt-get install -y --no-install-recommends \
|
|
||||||
libglib2.0-0 \
|
libglib2.0-0 \
|
||||||
libgobject-2.0-0 \
|
libgobject-2.0-0 \
|
||||||
libssl3t64 \
|
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "agents"
|
name = "agents"
|
||||||
version = "1.32.1"
|
version = "1.31.0"
|
||||||
requires-python = ">=3.12"
|
requires-python = ">=3.12"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"livekit-agents==1.6.7",
|
"livekit-agents==1.6.7",
|
||||||
|
|||||||
Generated
+1
-1
@@ -9,7 +9,7 @@ resolution-markers = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "agents"
|
name = "agents"
|
||||||
version = "1.32.1"
|
version = "1.31.0"
|
||||||
source = { virtual = "." }
|
source = { virtual = "." }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "httpx" },
|
{ name = "httpx" },
|
||||||
|
|||||||
@@ -12,6 +12,10 @@ from ..services.participants_management import (
|
|||||||
ParticipantsManagementException,
|
ParticipantsManagementException,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
ACTION_FOR_METHOD_TO_PERMISSION = {
|
||||||
|
"versions_detail": {"DELETE": "versions_destroy", "GET": "versions_retrieve"}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
class IsAuthenticated(permissions.BasePermission):
|
class IsAuthenticated(permissions.BasePermission):
|
||||||
"""
|
"""
|
||||||
@@ -23,6 +27,15 @@ class IsAuthenticated(permissions.BasePermission):
|
|||||||
return bool(request.auth) or request.user.is_authenticated
|
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):
|
class IsSelf(IsAuthenticated):
|
||||||
"""
|
"""
|
||||||
Allows access only to authenticated users. Alternative method checking the presence
|
Allows access only to authenticated users. Alternative method checking the presence
|
||||||
|
|||||||
@@ -95,6 +95,60 @@ from .feature_flag import FeatureFlag
|
|||||||
logger = getLogger(__name__)
|
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:
|
class SerializerPerActionMixin:
|
||||||
"""
|
"""
|
||||||
A mixin to allow to define serializer classes for each action.
|
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),
|
|
||||||
),
|
|
||||||
]
|
|
||||||
@@ -58,7 +58,6 @@ class RecordingStatusChoices(models.TextChoices):
|
|||||||
STOPPED = "stopped", _("Stopped")
|
STOPPED = "stopped", _("Stopped")
|
||||||
SAVED = "saved", _("Saved")
|
SAVED = "saved", _("Saved")
|
||||||
ABORTED = "aborted", _("Aborted")
|
ABORTED = "aborted", _("Aborted")
|
||||||
FAILED = "failed", _("Failed")
|
|
||||||
FAILED_TO_START = "failed_to_start", _("Failed to Start")
|
FAILED_TO_START = "failed_to_start", _("Failed to Start")
|
||||||
FAILED_TO_STOP = "failed_to_stop", _("Failed to Stop")
|
FAILED_TO_STOP = "failed_to_stop", _("Failed to Stop")
|
||||||
NOTIFICATION_SUCCEEDED = "notification_succeeded", _("Notification succeeded")
|
NOTIFICATION_SUCCEEDED = "notification_succeeded", _("Notification succeeded")
|
||||||
@@ -80,13 +79,17 @@ class RecordingStatusChoices(models.TextChoices):
|
|||||||
cls.STOPPED,
|
cls.STOPPED,
|
||||||
cls.SAVED,
|
cls.SAVED,
|
||||||
cls.ABORTED,
|
cls.ABORTED,
|
||||||
cls.FAILED,
|
|
||||||
cls.EXTERNAL_PROCESS_SUCCESSFUL,
|
cls.EXTERNAL_PROCESS_SUCCESSFUL,
|
||||||
cls.EXTERNAL_PROCESS_FAILED,
|
cls.EXTERNAL_PROCESS_FAILED,
|
||||||
cls.FAILED_TO_START,
|
cls.FAILED_TO_START,
|
||||||
cls.FAILED_TO_STOP,
|
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):
|
class RecordingModeChoices(models.TextChoices):
|
||||||
"""Recording mode choices."""
|
"""Recording mode choices."""
|
||||||
@@ -579,7 +582,6 @@ class Recording(BaseModel):
|
|||||||
4. NOTIFICATION_SUCCEEDED: External service has been notified of this recording
|
4. NOTIFICATION_SUCCEEDED: External service has been notified of this recording
|
||||||
|
|
||||||
Error States:
|
Error States:
|
||||||
- FAILED: Egress failed mid-recording
|
|
||||||
- FAILED_TO_START: Worker failed to initialize recording
|
- FAILED_TO_START: Worker failed to initialize recording
|
||||||
- FAILED_TO_STOP: Worker failed during stop operation
|
- FAILED_TO_STOP: Worker failed during stop operation
|
||||||
- ABORTED: Recording was terminated before completion
|
- ABORTED: Recording was terminated before completion
|
||||||
|
|||||||
@@ -8,50 +8,3 @@ class FileExtension(Enum):
|
|||||||
|
|
||||||
OGG = "ogg"
|
OGG = "ogg"
|
||||||
MP4 = "mp4"
|
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 logging import getLogger
|
||||||
|
|
||||||
|
from livekit import api
|
||||||
|
|
||||||
from core import models, utils
|
from core import models, utils
|
||||||
from core.models import Recording
|
from core.models import Recording
|
||||||
from core.recording.enums import (
|
|
||||||
UNSUCCESSFUL_EVENTS,
|
|
||||||
RecordingWorkerEvent,
|
|
||||||
)
|
|
||||||
from core.recording.event.notification import notification_service
|
from core.recording.event.notification import notification_service
|
||||||
from core.services.room_management import (
|
from core.services.room_management import (
|
||||||
RoomManagement,
|
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"""
|
"""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:
|
class RecordingEventsService:
|
||||||
"""Handles recording-related worker events.
|
"""Handles recording-related LiveKit webhook events."""
|
||||||
|
|
||||||
Two entry points: `handle_update` for the events a running recording
|
|
||||||
reports, and `handle_terminal_event` for the one ending it.
|
|
||||||
"""
|
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def log_worker_error(recording, event, error=None, error_code=None):
|
def handle_update(recording: Recording, egress_status):
|
||||||
"""Log FAILED at error level and expected ABORTED outcomes at info level."""
|
"""Handle egress status updates and sync recording state to room metadata."""
|
||||||
|
|
||||||
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.
|
|
||||||
"""
|
|
||||||
|
|
||||||
room_name = str(recording.room.id)
|
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:
|
if recording_status:
|
||||||
try:
|
try:
|
||||||
RoomManagement.update_metadata(
|
RoomManagement.update_metadata(
|
||||||
@@ -143,113 +55,42 @@ class RecordingEventsService:
|
|||||||
except RoomManagementException as e:
|
except RoomManagementException as e:
|
||||||
logger.exception("Failed to update room's metadata: %s", e)
|
logger.exception("Failed to update room's metadata: %s", e)
|
||||||
|
|
||||||
def handle_terminal_event(self, recording: Recording, event: RecordingWorkerEvent):
|
@staticmethod
|
||||||
"""Run the appropriate handlers for a terminal event, given the recording's state."""
|
def handle_limit_reached(recording: Recording):
|
||||||
|
|
||||||
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):
|
|
||||||
"""Stop recording and notify participants when limit is reached."""
|
"""Stop recording and notify participants when limit is reached."""
|
||||||
|
|
||||||
recording.status = models.RecordingStatusChoices.STOPPED
|
recording.status = models.RecordingStatusChoices.STOPPED
|
||||||
recording.save()
|
recording.save()
|
||||||
|
|
||||||
cls._notify_participants(recording, RecordingWorkerEvent.LIMIT_REACHED)
|
notification_mapping = {
|
||||||
|
models.RecordingModeChoices.SCREEN_RECORDING: "screenRecordingLimitReached",
|
||||||
|
models.RecordingModeChoices.TRANSCRIPT: "transcriptionLimitReached",
|
||||||
|
}
|
||||||
|
|
||||||
@classmethod
|
notification_type = notification_mapping.get(recording.mode)
|
||||||
def _handle_failed(cls, recording: Recording):
|
if not notification_type:
|
||||||
"""Set recording status to failed, matching the worker event, and notify participants.
|
return
|
||||||
|
|
||||||
FAILED: used when an actual runtime/pipeline error occurs after the
|
try:
|
||||||
recording has started
|
utils.notify_participants(
|
||||||
"""
|
room_name=str(recording.room.id),
|
||||||
recording.status = models.RecordingStatusChoices.FAILED
|
notification_data={"type": notification_type},
|
||||||
recording.save()
|
)
|
||||||
|
except utils.NotificationError as e:
|
||||||
cls._notify_participants(recording, RecordingWorkerEvent.FAILED)
|
logger.exception(
|
||||||
|
"Failed to notify participants about recording limit reached: "
|
||||||
@classmethod
|
"room=%s, recording_id=%s, mode=%s",
|
||||||
def _handle_aborted(cls, recording: Recording):
|
recording.room.id,
|
||||||
"""Set recording status to aborted, matching the worker event, and notify participants.
|
recording.id,
|
||||||
|
recording.mode,
|
||||||
ABORTED: used when the worker stops before it ever became
|
)
|
||||||
active/recording
|
raise RecordingEventsError(
|
||||||
"""
|
f"Failed to notify participants in room '{recording.room.id}' about "
|
||||||
recording.status = models.RecordingStatusChoices.ABORTED
|
f"recording limit reached (recording_id={recording.id})"
|
||||||
recording.save()
|
) from e
|
||||||
|
|
||||||
cls._notify_participants(recording, RecordingWorkerEvent.ABORTED)
|
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _handle_successful(recording: Recording):
|
def handle_complete(recording: Recording):
|
||||||
"""Notify external services and save recording."""
|
"""Notify external services and save recording."""
|
||||||
|
|
||||||
if not recording.is_savable():
|
if not recording.is_savable():
|
||||||
|
|||||||
@@ -2,8 +2,6 @@
|
|||||||
|
|
||||||
# pylint: disable=no-member
|
# pylint: disable=no-member
|
||||||
|
|
||||||
import logging
|
|
||||||
|
|
||||||
from asgiref.sync import async_to_sync
|
from asgiref.sync import async_to_sync
|
||||||
from livekit import api as livekit_api
|
from livekit import api as livekit_api
|
||||||
|
|
||||||
@@ -12,8 +10,6 @@ from ..enums import FileExtension
|
|||||||
from .exceptions import WorkerConnectionError, WorkerResponseError
|
from .exceptions import WorkerConnectionError, WorkerResponseError
|
||||||
from .factories import WorkerServiceConfig
|
from .factories import WorkerServiceConfig
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
|
|
||||||
class BaseEgressService:
|
class BaseEgressService:
|
||||||
"""Base egress defining common methods to manage and interact with LiveKit egress processes."""
|
"""Base egress defining common methods to manage and interact with LiveKit egress processes."""
|
||||||
@@ -24,7 +20,7 @@ class BaseEgressService:
|
|||||||
|
|
||||||
def _get_filepath(self, filename: str, extension: str) -> str:
|
def _get_filepath(self, filename: str, extension: str) -> str:
|
||||||
"""Construct the file path for a given filename and extension.
|
"""Construct the file path for a given filename and extension.
|
||||||
Insecure method, doesn't handle paths robustly and securely.
|
Unsecure method, doesn't handle paths robustly and securely.
|
||||||
"""
|
"""
|
||||||
return f"{self._config.output_folder}/{filename}.{extension}"
|
return f"{self._config.output_folder}/{filename}.{extension}"
|
||||||
|
|
||||||
@@ -53,22 +49,6 @@ class BaseEgressService:
|
|||||||
finally:
|
finally:
|
||||||
await lkapi.aclose()
|
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:
|
def stop(self, worker_id: str) -> str:
|
||||||
"""Stop an ongoing egress worker.
|
"""Stop an ongoing egress worker.
|
||||||
The StopEgressRequest is shared among all types of egress,
|
The StopEgressRequest is shared among all types of egress,
|
||||||
@@ -86,26 +66,14 @@ class BaseEgressService:
|
|||||||
"LiveKit response is missing the recording status."
|
"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:
|
if response.status == livekit_api.EgressStatus.EGRESS_ENDING:
|
||||||
return "STOPPED"
|
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"
|
return "FAILED_TO_STOP"
|
||||||
|
|
||||||
def start(self, room_name, recording_id):
|
def start(self, room_name, recording_id):
|
||||||
|
|||||||
@@ -12,12 +12,15 @@ from django.conf import settings
|
|||||||
from livekit import api
|
from livekit import api
|
||||||
|
|
||||||
from core import models
|
from core import models
|
||||||
from core.recording.enums import RecordingWorkerEvent
|
|
||||||
from core.recording.services.metadata_collector import (
|
from core.recording.services.metadata_collector import (
|
||||||
MetadataCollectorException,
|
MetadataCollectorException,
|
||||||
MetadataCollectorService,
|
MetadataCollectorService,
|
||||||
)
|
)
|
||||||
from core.recording.services.recording_events import RecordingEventsService
|
from core.recording.services.recording_events import (
|
||||||
|
RecordingEventsError,
|
||||||
|
RecordingEventsService,
|
||||||
|
RecordingNotSavableError,
|
||||||
|
)
|
||||||
|
|
||||||
from .lobby import LobbyService
|
from .lobby import LobbyService
|
||||||
from .presence import PresenceCache
|
from .presence import PresenceCache
|
||||||
@@ -81,30 +84,6 @@ class LiveKitWebhookEventType(Enum):
|
|||||||
INGRESS_ENDED = "ingress_ended"
|
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:
|
class LiveKitEventsService:
|
||||||
"""Service for processing and handling LiveKit webhook events and notifications."""
|
"""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"
|
f"Recording with worker ID {egress_id} does not exist"
|
||||||
) from err
|
) from err
|
||||||
|
|
||||||
event = to_recording_event(data.egress_info.status)
|
egress_status = data.egress_info.status
|
||||||
if event is None:
|
self.recording_events.handle_update(recording, egress_status)
|
||||||
return
|
|
||||||
|
|
||||||
self.recording_events.handle_update(recording, event)
|
|
||||||
|
|
||||||
def _handle_egress_ended(self, data):
|
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:
|
try:
|
||||||
recording = models.Recording.objects.select_related("room").get(
|
recording = models.Recording.objects.select_related("room").get(
|
||||||
worker_id=data.egress_info.egress_id
|
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"
|
f"Recording with worker ID {data.egress_info.egress_id} does not exist"
|
||||||
) from err
|
) 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:
|
try:
|
||||||
room_name = str(recording.room.id)
|
room_name = str(recording.room.id)
|
||||||
RoomManagement.update_metadata(
|
RoomManagement.update_metadata(
|
||||||
@@ -241,17 +201,38 @@ class LiveKitEventsService:
|
|||||||
except RoomManagementException as e:
|
except RoomManagementException as e:
|
||||||
logger.exception("Failed to update room's metadata: %s", 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:
|
if recording.options.get("metadata_collector_dispatch_id", None) is not None:
|
||||||
try:
|
try:
|
||||||
MetadataCollectorService().stop(recording)
|
MetadataCollectorService().stop(recording)
|
||||||
except MetadataCollectorException:
|
except MetadataCollectorException:
|
||||||
logger.warning("Failed to stop the MetadataCollectorService")
|
logger.warning("Failed to stop the MetadataCollectorService")
|
||||||
|
|
||||||
if event is None:
|
if (
|
||||||
return
|
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
|
@staticmethod
|
||||||
def _is_connection_test_room(room_name: str) -> bool:
|
def _is_connection_test_room(room_name: str) -> bool:
|
||||||
|
|||||||
@@ -76,6 +76,10 @@ class RoomManagement:
|
|||||||
|
|
||||||
except TwirpError as e:
|
except TwirpError as e:
|
||||||
if e.code == "not_found":
|
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
|
raise RoomNotFoundException("Room does not exist") from e
|
||||||
|
|
||||||
logger.exception(
|
logger.exception(
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ def test_api_files_list_anonymous_not_allowed():
|
|||||||
|
|
||||||
def test_api_files_list_authentificated_user_allowed():
|
def test_api_files_list_authentificated_user_allowed():
|
||||||
"""
|
"""
|
||||||
Authenticated users should be allowed to list files
|
Authentificated users should be allowed to list files
|
||||||
"""
|
"""
|
||||||
user = factories.UserFactory()
|
user = factories.UserFactory()
|
||||||
client = APIClient()
|
client = APIClient()
|
||||||
|
|||||||
@@ -2,23 +2,18 @@
|
|||||||
Test RecordingEventsService service.
|
Test RecordingEventsService service.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
# pylint: disable=redefined-outer-name,protected-access
|
# pylint: disable=redefined-outer-name
|
||||||
|
|
||||||
import logging
|
|
||||||
from unittest import mock
|
from unittest import mock
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from core.factories import RecordingFactory
|
from core.factories import RecordingFactory
|
||||||
from core.recording.enums import RecordingWorkerEvent
|
|
||||||
from core.recording.services.recording_events import (
|
from core.recording.services.recording_events import (
|
||||||
RecordingEventsError,
|
RecordingEventsError,
|
||||||
RecordingEventsService,
|
RecordingEventsService,
|
||||||
RecordingNotSavableError,
|
RecordingNotSavableError,
|
||||||
)
|
)
|
||||||
from core.services.room_management import (
|
|
||||||
RoomManagementException,
|
|
||||||
)
|
|
||||||
from core.utils import NotificationError
|
from core.utils import NotificationError
|
||||||
|
|
||||||
pytestmark = pytest.mark.django_db
|
pytestmark = pytest.mark.django_db
|
||||||
@@ -39,10 +34,10 @@ def service():
|
|||||||
)
|
)
|
||||||
@mock.patch("core.utils.notify_participants")
|
@mock.patch("core.utils.notify_participants")
|
||||||
def test_handle_limit_reached_success(mock_notify, mode, notification_type, service):
|
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)
|
recording = RecordingFactory(status="active", mode=mode)
|
||||||
service._handle_limit_reached(recording)
|
service.handle_limit_reached(recording)
|
||||||
|
|
||||||
assert recording.status == "stopped"
|
assert recording.status == "stopped"
|
||||||
mock_notify.assert_called_once_with(
|
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(
|
@pytest.mark.parametrize(
|
||||||
("mode", "notification_type"),
|
("mode", "notification_type"),
|
||||||
(
|
(
|
||||||
("screen_recording", "screenRecordingFailed"),
|
("screen_recording", "screenRecordingLimitReached"),
|
||||||
("transcript", "transcriptionFailed"),
|
("transcript", "transcriptionLimitReached"),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
@mock.patch("core.utils.notify_participants")
|
@mock.patch("core.utils.notify_participants")
|
||||||
def test_handle_failed_success(mock_notify, mode, notification_type, service):
|
def test_handle_limit_reached_error(mock_notify, mode, notification_type, service):
|
||||||
"""Test _handle_failed marks recording as failed and notifies participants."""
|
"""Test handle_limit_reached raises RecordingEventsError when notification fails."""
|
||||||
|
|
||||||
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.
|
|
||||||
"""
|
|
||||||
|
|
||||||
mock_notify.side_effect = NotificationError("Error notifying")
|
mock_notify.side_effect = NotificationError("Error notifying")
|
||||||
|
|
||||||
@@ -123,15 +62,14 @@ def test_handle_event_notification_error( # noqa: PLR0913, PLR0917
|
|||||||
|
|
||||||
with pytest.raises(
|
with pytest.raises(
|
||||||
RecordingEventsError,
|
RecordingEventsError,
|
||||||
match=rf"Failed to notify participants in room '.+' "
|
match=r"Failed to notify participants in room '.+' "
|
||||||
rf"about recording {event} \(recording_id=.+\)",
|
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(
|
mock_notify.assert_called_once_with(
|
||||||
room_name=str(recording.room.id),
|
room_name=str(recording.room.id), notification_data={"type": notification_type}
|
||||||
notification_data={"type": f"{notification_prefix}{notification_suffix}"},
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -144,19 +82,19 @@ def test_handle_event_notification_error( # noqa: PLR0913, PLR0917
|
|||||||
"core.recording.services.recording_events.notification_service."
|
"core.recording.services.recording_events.notification_service."
|
||||||
"notify_external_services"
|
"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,
|
mock_notify_external_services,
|
||||||
notify_return_value,
|
notify_return_value,
|
||||||
expected_status,
|
expected_status,
|
||||||
status,
|
status,
|
||||||
service,
|
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
|
mock_notify_external_services.return_value = notify_return_value
|
||||||
|
|
||||||
recording = RecordingFactory(status=status)
|
recording = RecordingFactory(status=status)
|
||||||
service._handle_successful(recording)
|
service.handle_complete(recording)
|
||||||
|
|
||||||
mock_notify_external_services.assert_called_once_with(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(
|
@pytest.mark.parametrize(
|
||||||
"status",
|
"status",
|
||||||
[
|
["initiated", "saved", "notification_succeeded", "aborted", "failed_to_start"],
|
||||||
"initiated",
|
|
||||||
"saved",
|
|
||||||
"notification_succeeded",
|
|
||||||
"aborted",
|
|
||||||
"failed",
|
|
||||||
"failed_to_start",
|
|
||||||
],
|
|
||||||
)
|
)
|
||||||
@mock.patch(
|
@mock.patch(
|
||||||
"core.recording.services.recording_events.notification_service."
|
"core.recording.services.recording_events.notification_service."
|
||||||
"notify_external_services"
|
"notify_external_services"
|
||||||
)
|
)
|
||||||
def test_handle_successful_non_savable_recording(
|
def test_handle_complete_non_savable_recording(
|
||||||
mock_notify_external_services, status, service
|
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)
|
recording = RecordingFactory(status=status)
|
||||||
|
|
||||||
with pytest.raises(RecordingNotSavableError):
|
with pytest.raises(RecordingNotSavableError):
|
||||||
service._handle_successful(recording)
|
service.handle_complete(recording)
|
||||||
|
|
||||||
mock_notify_external_services.assert_not_called()
|
mock_notify_external_services.assert_not_called()
|
||||||
|
|
||||||
recording.refresh_from_db()
|
recording.refresh_from_db()
|
||||||
assert recording.status == status
|
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.INITIATED,
|
||||||
RecordingStatusChoices.ACTIVE,
|
RecordingStatusChoices.ACTIVE,
|
||||||
RecordingStatusChoices.SAVED,
|
RecordingStatusChoices.SAVED,
|
||||||
RecordingStatusChoices.FAILED,
|
|
||||||
RecordingStatusChoices.FAILED_TO_START,
|
RecordingStatusChoices.FAILED_TO_START,
|
||||||
RecordingStatusChoices.FAILED_TO_STOP,
|
RecordingStatusChoices.FAILED_TO_STOP,
|
||||||
RecordingStatusChoices.ABORTED,
|
RecordingStatusChoices.ABORTED,
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ Test worker service classes.
|
|||||||
|
|
||||||
# pylint: disable=protected-access,redefined-outer-name,unused-argument,no-member
|
# pylint: disable=protected-access,redefined-outer-name,unused-argument,no-member
|
||||||
|
|
||||||
import logging
|
|
||||||
from unittest.mock import AsyncMock, Mock, patch
|
from unittest.mock import AsyncMock, Mock, patch
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
@@ -155,9 +154,9 @@ def test_base_egress_filepath_construction(service, filename, extension, expecte
|
|||||||
"response_status,expected_result",
|
"response_status,expected_result",
|
||||||
[
|
[
|
||||||
(livekit_api.EgressStatus.EGRESS_ABORTED, "ABORTED"),
|
(livekit_api.EgressStatus.EGRESS_ABORTED, "ABORTED"),
|
||||||
(livekit_api.EgressStatus.EGRESS_FAILED, "FAILED"),
|
|
||||||
(livekit_api.EgressStatus.EGRESS_COMPLETE, "FAILED_TO_STOP"),
|
(livekit_api.EgressStatus.EGRESS_COMPLETE, "FAILED_TO_STOP"),
|
||||||
(livekit_api.EgressStatus.EGRESS_ENDING, "STOPPED"),
|
(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):
|
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
|
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):
|
def test_base_egress_stop_missing_status(service):
|
||||||
"""Test stop method when response is missing status"""
|
"""Test stop method when response is missing status"""
|
||||||
# Mock _handle_request with missing status
|
# Mock _handle_request with missing status
|
||||||
|
|||||||
@@ -458,7 +458,7 @@ def test_mute_participant_livekit_token_presence_check_twirp_error_forbidden(
|
|||||||
room = RoomFactory()
|
room = RoomFactory()
|
||||||
|
|
||||||
mock_livekit_client.room.get_participant.side_effect = TwirpError(
|
mock_livekit_client.room.get_participant.side_effect = TwirpError(
|
||||||
msg="an error occurred", code="not_found", status=500
|
msg="an error occured", code="not_found", status=500
|
||||||
)
|
)
|
||||||
|
|
||||||
user = AnonymousUser()
|
user = AnonymousUser()
|
||||||
|
|||||||
@@ -117,11 +117,9 @@ def test_start_subtitle_invalid_token():
|
|||||||
assert response.json() == {"detail": "Invalid LiveKit token: Not enough segments"}
|
assert response.json() == {"detail": "Invalid LiveKit token: Not enough segments"}
|
||||||
|
|
||||||
|
|
||||||
def test_start_subtitle_disabled_by_default(mock_livekit_token, settings):
|
def test_start_subtitle_disabled_by_default(mock_livekit_token):
|
||||||
"""Test that subtitle functionality is disabled when feature flag is off."""
|
"""Test that subtitle functionality is disabled when feature flag is off."""
|
||||||
|
|
||||||
settings.ROOM_SUBTITLE_ENABLED = False
|
|
||||||
|
|
||||||
room = RoomFactory()
|
room = RoomFactory()
|
||||||
user = UserFactory()
|
user = UserFactory()
|
||||||
client = APIClient()
|
client = APIClient()
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ Test LiveKitEvents service.
|
|||||||
"""
|
"""
|
||||||
# pylint: disable=W0621,W0613, W0212, E0611
|
# pylint: disable=W0621,W0613, W0212, E0611
|
||||||
|
|
||||||
import logging
|
|
||||||
import uuid
|
import uuid
|
||||||
from unittest import mock
|
from unittest import mock
|
||||||
|
|
||||||
@@ -11,16 +10,13 @@ import pytest
|
|||||||
from livekit.api import EgressStatus
|
from livekit.api import EgressStatus
|
||||||
|
|
||||||
from core.factories import RecordingFactory, RoomFactory
|
from core.factories import RecordingFactory, RoomFactory
|
||||||
from core.recording.enums import RecordingWorkerEvent
|
|
||||||
from core.recording.services.recording_events import RecordingEventsService
|
from core.recording.services.recording_events import RecordingEventsService
|
||||||
from core.services.livekit_events import (
|
from core.services.livekit_events import (
|
||||||
EGRESS_STATUS_TO_RECORDING_EVENT,
|
|
||||||
ActionFailedError,
|
ActionFailedError,
|
||||||
AuthenticationError,
|
AuthenticationError,
|
||||||
InvalidPayloadError,
|
InvalidPayloadError,
|
||||||
LiveKitEventsService,
|
LiveKitEventsService,
|
||||||
api,
|
api,
|
||||||
to_recording_event,
|
|
||||||
)
|
)
|
||||||
from core.services.lobby import LobbyService
|
from core.services.lobby import LobbyService
|
||||||
from core.services.room_management import RoomManagementException
|
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
|
def test_handle_egress_ended_success( # pylint: disable=too-many-arguments, too-many-positional-arguments
|
||||||
mock_update_metadata, mock_notify, mode, notification_type, service
|
mock_update_metadata, mock_notify, mode, notification_type, service
|
||||||
):
|
):
|
||||||
"""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")
|
recording = RecordingFactory(worker_id="worker-1", mode=mode, status="active")
|
||||||
mock_data = mock.MagicMock()
|
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_ACTIVE, "started"),
|
||||||
(EgressStatus.EGRESS_ENDING, "saving"),
|
(EgressStatus.EGRESS_ENDING, "saving"),
|
||||||
|
(EgressStatus.EGRESS_ABORTED, "aborted"),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
@mock.patch("core.services.room_management.RoomManagement.update_metadata")
|
@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(
|
@pytest.mark.parametrize(
|
||||||
("mode", "notification_type"),
|
("mode", "notification_type"),
|
||||||
(
|
(
|
||||||
@@ -160,38 +180,33 @@ def test_handle_egress_ended_metadata_update_fails( # pylint: disable=too-many-
|
|||||||
assert recording.status == "saved"
|
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.utils.notify_participants")
|
||||||
@mock.patch("core.services.room_management.RoomManagement.update_metadata")
|
@mock.patch("core.services.room_management.RoomManagement.update_metadata")
|
||||||
def test_handle_egress_ended_notification_fails(
|
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."""
|
"""Should raise ActionFailedError when notification fails but still stop recording."""
|
||||||
|
|
||||||
mock_notify_external_services.return_value = False
|
|
||||||
mock_notify.side_effect = NotificationError("Error notifying")
|
|
||||||
|
|
||||||
recording = RecordingFactory(worker_id="worker-1", status="active")
|
recording = RecordingFactory(worker_id="worker-1", status="active")
|
||||||
mock_data = mock.MagicMock()
|
mock_data = mock.MagicMock()
|
||||||
mock_data.egress_info.egress_id = recording.worker_id
|
mock_data.egress_info.egress_id = recording.worker_id
|
||||||
mock_data.egress_info.status = EgressStatus.EGRESS_LIMIT_REACHED
|
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(
|
mock_update_metadata.assert_called_once_with(
|
||||||
str(recording.room.id), remove_keys=["recording_mode", "recording_status"]
|
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.utils.notify_participants")
|
||||||
@mock.patch("core.services.room_management.RoomManagement.update_metadata")
|
@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"
|
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.utils.notify_participants")
|
||||||
@mock.patch("core.services.room_management.RoomManagement.update_metadata")
|
@mock.patch("core.services.room_management.RoomManagement.update_metadata")
|
||||||
def test_handle_egress_ended_recording_should_not_be_saved(
|
def test_handle_egress_ended_recording_not_active(
|
||||||
mock_update_metadata, mock_notify, egress_status, service
|
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")
|
recording = RecordingFactory(worker_id="worker-1", status="failed_to_stop")
|
||||||
mock_data = mock.MagicMock()
|
mock_data = mock.MagicMock()
|
||||||
mock_data.egress_info.egress_id = "worker-1"
|
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)
|
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"
|
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.utils.notify_participants")
|
||||||
@mock.patch("core.services.room_management.RoomManagement.update_metadata")
|
@mock.patch("core.services.room_management.RoomManagement.update_metadata")
|
||||||
def test_handle_egress_ended_complete_does_not_notify_participants(
|
def test_handle_egress_ended_recording_not_limit_reached(
|
||||||
mock_update_metadata, mock_notify, mock_notify_external_services, service
|
mock_update_metadata, mock_notify, service
|
||||||
):
|
):
|
||||||
"""Shouldn't notify participants on a successful egress.
|
"""Should ignore egress non-limit-reached statuses."""
|
||||||
|
|
||||||
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
|
|
||||||
|
|
||||||
recording = RecordingFactory(worker_id="worker-1", status="stopped")
|
recording = RecordingFactory(worker_id="worker-1", status="stopped")
|
||||||
mock_data = mock.MagicMock()
|
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(
|
mock_update_metadata.assert_called_once_with(
|
||||||
str(recording.room.id), remove_keys=["recording_mode", "recording_status"]
|
str(recording.room.id), remove_keys=["recording_mode", "recording_status"]
|
||||||
)
|
)
|
||||||
mock_notify_external_services.assert_called_once_with(recording)
|
assert recording.status == "stopped"
|
||||||
|
|
||||||
recording.refresh_from_db()
|
|
||||||
assert recording.status == "saved"
|
|
||||||
|
|
||||||
|
|
||||||
@mock.patch("core.services.livekit_events.MetadataCollectorService")
|
@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()
|
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(
|
@mock.patch(
|
||||||
"core.recording.services.recording_events.notification_service."
|
"core.recording.services.recording_events.notification_service."
|
||||||
"notify_external_services"
|
"notify_external_services"
|
||||||
)
|
)
|
||||||
@mock.patch("core.utils.notify_participants")
|
@mock.patch("core.utils.notify_participants")
|
||||||
@mock.patch("core.services.room_management.RoomManagement.update_metadata")
|
@mock.patch("core.services.room_management.RoomManagement.update_metadata")
|
||||||
def test_handle_egress_ended_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_update_metadata,
|
||||||
mock_notify,
|
mock_notify,
|
||||||
mock_notify_external_services,
|
mock_notify_external_services,
|
||||||
|
notify_return_value,
|
||||||
|
recording_status,
|
||||||
egress_status,
|
egress_status,
|
||||||
service,
|
service,
|
||||||
caplog,
|
|
||||||
): # pylint: disable=too-many-arguments,too-many-positional-arguments
|
): # 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")
|
recording = RecordingFactory(worker_id="worker-1", status="active")
|
||||||
mock_data = mock.MagicMock()
|
mock_data = mock.MagicMock()
|
||||||
mock_data.egress_info.egress_id = recording.worker_id
|
mock_data.egress_info.egress_id = recording.worker_id
|
||||||
mock_data.egress_info.status = egress_status
|
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")
|
@mock.patch("core.services.room_management.RoomManagement.update_metadata")
|
||||||
def test_handle_egress_ended_logs_livekit_error_before_cleaning_up(
|
def test_handle_egress_ended_does_not_save_on_wrong_status(
|
||||||
mock_update_metadata, mock_notify, service, caplog
|
mock_update_metadata, egress_status, service
|
||||||
):
|
):
|
||||||
"""Should log the failure reason even when the cleanup fails afterwards."""
|
"""Shouldn't save on invalid status."""
|
||||||
|
|
||||||
mock_update_metadata.side_effect = RuntimeError("LiveKit is unreachable")
|
|
||||||
|
|
||||||
recording = RecordingFactory(worker_id="worker-1", status="active")
|
recording = RecordingFactory(worker_id="worker-1", status="active")
|
||||||
mock_data = mock.MagicMock()
|
mock_data = mock.MagicMock()
|
||||||
mock_data.egress_info.egress_id = recording.worker_id
|
mock_data.egress_info.egress_id = recording.worker_id
|
||||||
mock_data.egress_info.status = EgressStatus.EGRESS_FAILED
|
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.ERROR), pytest.raises(RuntimeError):
|
service._handle_egress_ended(mock_data)
|
||||||
service._handle_egress_ended(mock_data)
|
|
||||||
|
|
||||||
assert (
|
recording.refresh_from_db()
|
||||||
f"Recording worker reported failed for recording {recording.id}" in caplog.text
|
assert recording.status == "active"
|
||||||
)
|
|
||||||
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
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
@pytest.mark.parametrize(
|
||||||
("egress_status", "expected_event"),
|
"status", ["failed_to_start", "aborted", "failed_to_stop", "saved", "initiated"]
|
||||||
(
|
|
||||||
(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),
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
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")
|
@mock.patch("core.services.room_management.RoomManagement.update_metadata")
|
||||||
def test_handle_egress_updated_ignores_unmapped_status(mock_update_metadata, service):
|
def test_handle_egress_ended_ignores_non_savable_recording(
|
||||||
"""Shouldn't touch the room's metadata when the egress status is unknown."""
|
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 = mock.MagicMock()
|
||||||
mock_data.egress_info.egress_id = "worker-1"
|
mock_data.egress_info.egress_id = recording.worker_id
|
||||||
mock_data.egress_info.status = 999
|
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")
|
@mock.patch.object(LobbyService, "clear_room_cache")
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
"""
|
"""
|
||||||
Test SIP management service.
|
Test SIP mamagement service.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
# pylint: disable=W0212
|
# pylint: disable=W0212
|
||||||
|
|||||||
@@ -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(
|
@pytest.mark.parametrize(
|
||||||
"status",
|
"status",
|
||||||
[
|
[
|
||||||
RecordingStatusChoices.FAILED,
|
|
||||||
RecordingStatusChoices.FAILED_TO_STOP,
|
RecordingStatusChoices.FAILED_TO_STOP,
|
||||||
RecordingStatusChoices.FAILED_TO_START,
|
RecordingStatusChoices.FAILED_TO_START,
|
||||||
RecordingStatusChoices.ABORTED,
|
RecordingStatusChoices.ABORTED,
|
||||||
@@ -280,7 +279,6 @@ def test_models_recording_is_saved_false_initiated():
|
|||||||
@pytest.mark.parametrize(
|
@pytest.mark.parametrize(
|
||||||
"status",
|
"status",
|
||||||
[
|
[
|
||||||
RecordingStatusChoices.FAILED,
|
|
||||||
RecordingStatusChoices.FAILED_TO_STOP,
|
RecordingStatusChoices.FAILED_TO_STOP,
|
||||||
RecordingStatusChoices.FAILED_TO_START,
|
RecordingStatusChoices.FAILED_TO_START,
|
||||||
RecordingStatusChoices.ABORTED,
|
RecordingStatusChoices.ABORTED,
|
||||||
|
|||||||
@@ -1,47 +0,0 @@
|
|||||||
"""Unit tests for the get_release settings helper."""
|
|
||||||
|
|
||||||
import re
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
from meet.settings import get_release
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(name="base_dir")
|
|
||||||
def fixture_empty_base_dir(tmp_path, monkeypatch):
|
|
||||||
"""Point get_release at an empty directory."""
|
|
||||||
monkeypatch.setattr("meet.settings.BASE_DIR", str(tmp_path))
|
|
||||||
return tmp_path
|
|
||||||
|
|
||||||
|
|
||||||
def test_get_release_reads_project_pyproject():
|
|
||||||
"""Should return the semantic version of the backend's pyproject.toml."""
|
|
||||||
assert re.fullmatch(r"\d+\.\d+\.\d+", get_release())
|
|
||||||
|
|
||||||
|
|
||||||
def test_get_release_reads_pyproject_version(base_dir):
|
|
||||||
"""Should return the version declared in the [project] table."""
|
|
||||||
(base_dir / "pyproject.toml").write_text(
|
|
||||||
'[project]\nname = "meet"\nversion = "1.2.3"\n', encoding="utf-8"
|
|
||||||
)
|
|
||||||
assert get_release() == "1.2.3"
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.usefixtures("base_dir")
|
|
||||||
def test_get_release_missing_pyproject():
|
|
||||||
"""Should fall back to "NA" without a pyproject.toml."""
|
|
||||||
assert get_release() == "NA"
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
|
||||||
"content",
|
|
||||||
[
|
|
||||||
'[project]\nname = "meet"\n', # no version
|
|
||||||
"[tool.uv]\npackage = true\n", # no [project] table
|
|
||||||
"[project\nversion = ", # malformed TOML
|
|
||||||
],
|
|
||||||
)
|
|
||||||
def test_get_release_unreadable_version(base_dir, content):
|
|
||||||
"""Should fall back to "NA" without a readable version in pyproject.toml."""
|
|
||||||
(base_dir / "pyproject.toml").write_text(content, encoding="utf-8")
|
|
||||||
assert get_release() == "NA"
|
|
||||||
@@ -12,7 +12,7 @@ https://docs.djangoproject.com/en/3.1/ref/settings/
|
|||||||
|
|
||||||
# pylint: disable=too-many-lines
|
# pylint: disable=too-many-lines
|
||||||
|
|
||||||
import tomllib
|
import json
|
||||||
import warnings
|
import warnings
|
||||||
from os import path
|
from os import path
|
||||||
from socket import gethostbyname, gethostname
|
from socket import gethostbyname, gethostname
|
||||||
@@ -37,11 +37,19 @@ GB = 1024 * MB
|
|||||||
def get_release():
|
def get_release():
|
||||||
"""
|
"""
|
||||||
Get the current release of the application
|
Get the current release of the application
|
||||||
|
|
||||||
|
By release, we mean the release from the version.json file à la Mozilla [1]
|
||||||
|
(if any). If this file has not been found, it defaults to "NA".
|
||||||
|
|
||||||
|
[1]
|
||||||
|
https://github.com/mozilla-services/Dockerflow/blob/master/docs/version_object.md
|
||||||
"""
|
"""
|
||||||
|
# Try to get the current release from the version.json file generated by the
|
||||||
|
# CI during the Docker image build
|
||||||
try:
|
try:
|
||||||
with open(path.join(BASE_DIR, "pyproject.toml"), "rb") as pyproject:
|
with open(path.join(BASE_DIR, "version.json"), encoding="utf8") as version:
|
||||||
return tomllib.load(pyproject)["project"]["version"]
|
return json.load(version)["version"]
|
||||||
except (FileNotFoundError, KeyError, tomllib.TOMLDecodeError):
|
except FileNotFoundError:
|
||||||
return "NA" # Default: not available
|
return "NA" # Default: not available
|
||||||
|
|
||||||
|
|
||||||
@@ -287,7 +295,6 @@ class Base(Configuration):
|
|||||||
|
|
||||||
MIDDLEWARE = [
|
MIDDLEWARE = [
|
||||||
"django.middleware.security.SecurityMiddleware",
|
"django.middleware.security.SecurityMiddleware",
|
||||||
"dockerflow.django.middleware.DockerflowMiddleware",
|
|
||||||
"whitenoise.middleware.WhiteNoiseMiddleware",
|
"whitenoise.middleware.WhiteNoiseMiddleware",
|
||||||
"django.contrib.sessions.middleware.SessionMiddleware",
|
"django.contrib.sessions.middleware.SessionMiddleware",
|
||||||
"django.middleware.locale.LocaleMiddleware",
|
"django.middleware.locale.LocaleMiddleware",
|
||||||
@@ -297,6 +304,7 @@ class Base(Configuration):
|
|||||||
"django.middleware.csrf.CsrfViewMiddleware",
|
"django.middleware.csrf.CsrfViewMiddleware",
|
||||||
"django.contrib.auth.middleware.AuthenticationMiddleware",
|
"django.contrib.auth.middleware.AuthenticationMiddleware",
|
||||||
"django.contrib.messages.middleware.MessageMiddleware",
|
"django.contrib.messages.middleware.MessageMiddleware",
|
||||||
|
"dockerflow.django.middleware.DockerflowMiddleware",
|
||||||
]
|
]
|
||||||
|
|
||||||
AUTHENTICATION_BACKENDS = [
|
AUTHENTICATION_BACKENDS = [
|
||||||
@@ -1173,13 +1181,6 @@ class Base(Configuration):
|
|||||||
environ_prefix="",
|
environ_prefix="",
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
"dockerflow": {
|
|
||||||
"level": values.Value(
|
|
||||||
"WARNING",
|
|
||||||
environ_name="LOGGING_LEVEL_DOCKERFLOW",
|
|
||||||
environ_prefix="",
|
|
||||||
)
|
|
||||||
},
|
|
||||||
"core": {
|
"core": {
|
||||||
"handlers": ["console"],
|
"handlers": ["console"],
|
||||||
"level": values.Value(
|
"level": values.Value(
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ build-backend = "uv_build"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "meet"
|
name = "meet"
|
||||||
version = "1.32.1"
|
version = "1.31.0"
|
||||||
authors = [{ "name" = "DINUM", "email" = "dev@mail.numerique.gouv.fr" }]
|
authors = [{ "name" = "DINUM", "email" = "dev@mail.numerique.gouv.fr" }]
|
||||||
classifiers = [
|
classifiers = [
|
||||||
"Development Status :: 5 - Production/Stable",
|
"Development Status :: 5 - Production/Stable",
|
||||||
@@ -41,7 +41,7 @@ dependencies = [
|
|||||||
"django-timezone-field>=5.1",
|
"django-timezone-field>=5.1",
|
||||||
"django-pydantic-field==0.5.4",
|
"django-pydantic-field==0.5.4",
|
||||||
"django==5.2.16",
|
"django==5.2.16",
|
||||||
"djangorestframework==3.17.2",
|
"djangorestframework==3.17.1",
|
||||||
"drf_spectacular==0.30.0",
|
"drf_spectacular==0.30.0",
|
||||||
"dockerflow==2026.3.4",
|
"dockerflow==2026.3.4",
|
||||||
"easy_thumbnails==2.10.1",
|
"easy_thumbnails==2.10.1",
|
||||||
|
|||||||
Generated
+5
-5
@@ -754,14 +754,14 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "djangorestframework"
|
name = "djangorestframework"
|
||||||
version = "3.17.2"
|
version = "3.17.1"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "django" },
|
{ 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 = [
|
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]]
|
[[package]]
|
||||||
@@ -1187,7 +1187,7 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "meet"
|
name = "meet"
|
||||||
version = "1.32.1"
|
version = "1.31.0"
|
||||||
source = { editable = "." }
|
source = { editable = "." }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "aiohttp" },
|
{ name = "aiohttp" },
|
||||||
@@ -1273,7 +1273,7 @@ requires-dist = [
|
|||||||
{ name = "django-redis", specifier = "==7.0.0" },
|
{ name = "django-redis", specifier = "==7.0.0" },
|
||||||
{ name = "django-storages", extras = ["s3"], specifier = "==1.14.6" },
|
{ name = "django-storages", extras = ["s3"], specifier = "==1.14.6" },
|
||||||
{ name = "django-timezone-field", specifier = ">=5.1" },
|
{ 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 = "dockerflow", specifier = "==2026.3.4" },
|
||||||
{ name = "drf-spectacular", specifier = "==0.30.0" },
|
{ name = "drf-spectacular", specifier = "==0.30.0" },
|
||||||
{ name = "easy-thumbnails", specifier = "==2.10.1" },
|
{ name = "easy-thumbnails", specifier = "==2.10.1" },
|
||||||
|
|||||||
@@ -4,12 +4,12 @@ USER node
|
|||||||
|
|
||||||
WORKDIR /home/frontend/
|
WORKDIR /home/frontend/
|
||||||
|
|
||||||
COPY --chown=node:node ./src/frontend/package.json ./package.json
|
COPY ./src/frontend/package.json ./package.json
|
||||||
COPY --chown=node:node ./src/frontend/package-lock.json ./package-lock.json
|
COPY ./src/frontend/package-lock.json ./package-lock.json
|
||||||
|
|
||||||
RUN npm ci
|
RUN npm ci
|
||||||
|
|
||||||
COPY --chown=node:node .dockerignore ./.dockerignore
|
COPY .dockerignore ./.dockerignore
|
||||||
COPY --chown=node:node ./src/frontend/ .
|
COPY --chown=node:node ./src/frontend/ .
|
||||||
|
|
||||||
### ---- Front-end builder image ----
|
### ---- Front-end builder image ----
|
||||||
@@ -45,8 +45,7 @@ RUN npm run build
|
|||||||
FROM nginxinc/nginx-unprivileged:1.30.4-alpine3.24 AS frontend-production
|
FROM nginxinc/nginx-unprivileged:1.30.4-alpine3.24 AS frontend-production
|
||||||
|
|
||||||
USER root
|
USER root
|
||||||
RUN apk upgrade --no-cache libexpat && \
|
RUN apk del curl
|
||||||
apk del curl
|
|
||||||
USER nginx
|
USER nginx
|
||||||
|
|
||||||
# Un-privileged user running the application
|
# Un-privileged user running the application
|
||||||
|
|||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "meet",
|
"name": "meet",
|
||||||
"version": "1.32.1",
|
"version": "1.31.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "meet",
|
"name": "meet",
|
||||||
"version": "1.32.1",
|
"version": "1.31.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@fontsource-variable/atkinson-hyperlegible-next": "5.3.0",
|
"@fontsource-variable/atkinson-hyperlegible-next": "5.3.0",
|
||||||
"@fontsource-variable/lexend": "5.3.0",
|
"@fontsource-variable/lexend": "5.3.0",
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "meet",
|
"name": "meet",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "1.32.1",
|
"version": "1.31.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "panda codegen && vite",
|
"dev": "panda codegen && vite",
|
||||||
|
|||||||
@@ -121,7 +121,7 @@ const config: Config = {
|
|||||||
},
|
},
|
||||||
tokens: defineTokens({
|
tokens: defineTokens({
|
||||||
/* we take a few things from the panda preset but for now we clear out some stuff.
|
/* we take a few things from the panda preset but for now we clear out some stuff.
|
||||||
* This way we'll only add the things we need step by step and prevent using lots of different things.
|
* This way we'll only add the things we need step by step and prevent using lots of differents things.
|
||||||
*/
|
*/
|
||||||
...pandaPreset.theme.tokens,
|
...pandaPreset.theme.tokens,
|
||||||
colors: defineTokens.colors({
|
colors: defineTokens.colors({
|
||||||
|
|||||||
@@ -96,10 +96,6 @@ export const MainNotificationToast = () => {
|
|||||||
case NotificationType.ScreenRecordingStopped:
|
case NotificationType.ScreenRecordingStopped:
|
||||||
case NotificationType.TranscriptionLimitReached:
|
case NotificationType.TranscriptionLimitReached:
|
||||||
case NotificationType.ScreenRecordingLimitReached:
|
case NotificationType.ScreenRecordingLimitReached:
|
||||||
case NotificationType.TranscriptionFailed:
|
|
||||||
case NotificationType.ScreenRecordingFailed:
|
|
||||||
case NotificationType.TranscriptionAborted:
|
|
||||||
case NotificationType.ScreenRecordingAborted:
|
|
||||||
toastQueue.add(
|
toastQueue.add(
|
||||||
{
|
{
|
||||||
participant,
|
participant,
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { Div } from '@/primitives'
|
import { Div } from '@/primitives'
|
||||||
import { ToastProvider } from './components/ToastProvider'
|
import { ToastProvider } from './components/ToastProvider'
|
||||||
import { RecordingWaitingForTracksNotification } from './components/RecordingWaitingForTracksNotification'
|
|
||||||
import { WaitingParticipantNotification } from './components/WaitingParticipantNotification'
|
import { WaitingParticipantNotification } from './components/WaitingParticipantNotification'
|
||||||
|
|
||||||
export const NotificationProvider = ({
|
export const NotificationProvider = ({
|
||||||
@@ -13,6 +12,5 @@ export const NotificationProvider = ({
|
|||||||
<Div position="absolute" bottom={bottom} right={right} zIndex={1000}>
|
<Div position="absolute" bottom={bottom} right={right} zIndex={1000}>
|
||||||
<ToastProvider />
|
<ToastProvider />
|
||||||
<WaitingParticipantNotification />
|
<WaitingParticipantNotification />
|
||||||
<RecordingWaitingForTracksNotification />
|
|
||||||
</Div>
|
</Div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -10,15 +10,11 @@ export enum NotificationType {
|
|||||||
TranscriptionStarted = 'transcriptionStarted',
|
TranscriptionStarted = 'transcriptionStarted',
|
||||||
TranscriptionStopped = 'transcriptionStopped',
|
TranscriptionStopped = 'transcriptionStopped',
|
||||||
TranscriptionLimitReached = 'transcriptionLimitReached',
|
TranscriptionLimitReached = 'transcriptionLimitReached',
|
||||||
TranscriptionFailed = 'transcriptionFailed',
|
|
||||||
TranscriptionAborted = 'transcriptionAborted',
|
|
||||||
TranscriptionRequested = 'transcriptionRequested',
|
TranscriptionRequested = 'transcriptionRequested',
|
||||||
ScreenRecordingStarted = 'screenRecordingStarted',
|
ScreenRecordingStarted = 'screenRecordingStarted',
|
||||||
ScreenRecordingStopped = 'screenRecordingStopped',
|
ScreenRecordingStopped = 'screenRecordingStopped',
|
||||||
ScreenRecordingRequested = 'screenRecordingRequested',
|
ScreenRecordingRequested = 'screenRecordingRequested',
|
||||||
ScreenRecordingLimitReached = 'screenRecordingLimitReached',
|
ScreenRecordingLimitReached = 'screenRecordingLimitReached',
|
||||||
ScreenRecordingFailed = 'screenRecordingFailed',
|
|
||||||
ScreenRecordingAborted = 'screenRecordingAborted',
|
|
||||||
RecordingSaving = 'recordingSaving',
|
RecordingSaving = 'recordingSaving',
|
||||||
PermissionsRemoved = 'permissionsRemoved',
|
PermissionsRemoved = 'permissionsRemoved',
|
||||||
RoleChanged = 'roleChanged',
|
RoleChanged = 'roleChanged',
|
||||||
|
|||||||
-41
@@ -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'
|
return 'transcript.stopped'
|
||||||
case NotificationType.TranscriptionLimitReached:
|
case NotificationType.TranscriptionLimitReached:
|
||||||
return 'transcript.limitReached'
|
return 'transcript.limitReached'
|
||||||
case NotificationType.TranscriptionFailed:
|
|
||||||
return 'transcript.failed'
|
|
||||||
case NotificationType.TranscriptionAborted:
|
|
||||||
return 'transcript.aborted'
|
|
||||||
case NotificationType.ScreenRecordingStarted:
|
case NotificationType.ScreenRecordingStarted:
|
||||||
return 'screenRecording.started'
|
return 'screenRecording.started'
|
||||||
case NotificationType.ScreenRecordingStopped:
|
case NotificationType.ScreenRecordingStopped:
|
||||||
return 'screenRecording.stopped'
|
return 'screenRecording.stopped'
|
||||||
case NotificationType.ScreenRecordingLimitReached:
|
case NotificationType.ScreenRecordingLimitReached:
|
||||||
return 'screenRecording.limitReached'
|
return 'screenRecording.limitReached'
|
||||||
case NotificationType.ScreenRecordingFailed:
|
|
||||||
return 'screenRecording.failed'
|
|
||||||
case NotificationType.ScreenRecordingAborted:
|
|
||||||
return 'screenRecording.aborted'
|
|
||||||
default:
|
default:
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -58,10 +58,6 @@ const renderToast = (
|
|||||||
case NotificationType.ScreenRecordingStarted:
|
case NotificationType.ScreenRecordingStarted:
|
||||||
case NotificationType.ScreenRecordingStopped:
|
case NotificationType.ScreenRecordingStopped:
|
||||||
case NotificationType.ScreenRecordingLimitReached:
|
case NotificationType.ScreenRecordingLimitReached:
|
||||||
case NotificationType.TranscriptionFailed:
|
|
||||||
case NotificationType.ScreenRecordingFailed:
|
|
||||||
case NotificationType.TranscriptionAborted:
|
|
||||||
case NotificationType.ScreenRecordingAborted:
|
|
||||||
return <ToastAnyRecording key={toast.key} toast={toast} state={state} />
|
return <ToastAnyRecording key={toast.key} toast={toast} state={state} />
|
||||||
|
|
||||||
case NotificationType.TranscriptionRequested:
|
case NotificationType.TranscriptionRequested:
|
||||||
|
|||||||
+2
-9
@@ -34,15 +34,6 @@ export const WaitingParticipantNotification = () => {
|
|||||||
const isParticipantListEmpty = (p?: WaitingParticipant[]) => p?.length == 0
|
const isParticipantListEmpty = (p?: WaitingParticipant[]) => p?.length == 0
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const previousIds = new Set(prevWaitingParticipant?.map(({ id }) => id))
|
|
||||||
const hasNewWaitingParticipant = waitingParticipants.some(
|
|
||||||
({ id }) => !previousIds.has(id)
|
|
||||||
)
|
|
||||||
|
|
||||||
if (hasNewWaitingParticipant) {
|
|
||||||
triggerNotificationSound(NotificationType.ParticipantWaiting)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Show notification when the first participant enters the waiting room
|
// Show notification when the first participant enters the waiting room
|
||||||
if (
|
if (
|
||||||
!isParticipantListEmpty(waitingParticipants) &&
|
!isParticipantListEmpty(waitingParticipants) &&
|
||||||
@@ -51,6 +42,8 @@ export const WaitingParticipantNotification = () => {
|
|||||||
) {
|
) {
|
||||||
setShowQuickActionsMessage(true)
|
setShowQuickActionsMessage(true)
|
||||||
|
|
||||||
|
triggerNotificationSound(NotificationType.ParticipantJoined)
|
||||||
|
|
||||||
if (timerRef.current !== null) {
|
if (timerRef.current !== null) {
|
||||||
clearTimeout(timerRef.current)
|
clearTimeout(timerRef.current)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ export const useNotificationSound = () => {
|
|||||||
participantJoined: [0, 1150],
|
participantJoined: [0, 1150],
|
||||||
handRaised: [1400, 180],
|
handRaised: [1400, 180],
|
||||||
messageReceived: [1580, 300],
|
messageReceived: [1580, 300],
|
||||||
participantWaiting: [2039, 710],
|
waiting: [2039, 710],
|
||||||
success: [2740, 1304],
|
success: [2740, 1304],
|
||||||
},
|
},
|
||||||
volume: notificationsSnap.soundNotificationVolume,
|
volume: notificationsSnap.soundNotificationVolume,
|
||||||
|
|||||||
@@ -20,7 +20,6 @@ import { Track } from 'livekit-client'
|
|||||||
import { ParticipantPlaceholder } from './ParticipantPlaceholder'
|
import { ParticipantPlaceholder } from './ParticipantPlaceholder'
|
||||||
import { ParticipantTileFocus } from './participantTileFocus/ParticipantTileFocus'
|
import { ParticipantTileFocus } from './participantTileFocus/ParticipantTileFocus'
|
||||||
import { FullScreenShareWarning } from './FullScreenShareWarning'
|
import { FullScreenShareWarning } from './FullScreenShareWarning'
|
||||||
import { ScreenShareZoomableVideo } from '@/features/rooms/livekit/components/ScreenShareZoomableVideo'
|
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { getShortcutDescriptorById } from '@/features/shortcuts/catalog'
|
import { getShortcutDescriptorById } from '@/features/shortcuts/catalog'
|
||||||
import { formatShortcutLabel } from '@/features/shortcuts/formatLabels'
|
import { formatShortcutLabel } from '@/features/shortcuts/formatLabels'
|
||||||
@@ -90,8 +89,6 @@ export const ParticipantTile: (
|
|||||||
)
|
)
|
||||||
|
|
||||||
const isScreenShare = trackReference.source != Track.Source.Camera
|
const isScreenShare = trackReference.source != Track.Source.Camera
|
||||||
const isRemoteScreenShare =
|
|
||||||
isScreenShare && !trackReference.participant.isLocal
|
|
||||||
const [hasKeyboardFocus, setHasKeyboardFocus] = React.useState(false)
|
const [hasKeyboardFocus, setHasKeyboardFocus] = React.useState(false)
|
||||||
|
|
||||||
const participantColor = getParticipantColor(trackReference.participant)
|
const participantColor = getParticipantColor(trackReference.participant)
|
||||||
@@ -101,23 +98,11 @@ export const ParticipantTile: (
|
|||||||
})
|
})
|
||||||
const participantName = name || identity || 'Unknown'
|
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 { t } = useTranslation('rooms', { keyPrefix: 'participantTileFocus' })
|
||||||
|
|
||||||
const interactiveProps = {
|
const interactiveProps = {
|
||||||
...elementProps,
|
...elementProps,
|
||||||
|
// Ensure the tile is focusable to expose contextual controls to keyboard users.
|
||||||
tabIndex: 0,
|
tabIndex: 0,
|
||||||
'aria-label': t('containerLabel', { name: participantName }),
|
'aria-label': t('containerLabel', { name: participantName }),
|
||||||
onFocus: (event: React.FocusEvent<HTMLDivElement>) => {
|
onFocus: (event: React.FocusEvent<HTMLDivElement>) => {
|
||||||
@@ -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 (
|
return (
|
||||||
<div ref={setRefs} style={{ position: 'relative' }} {...interactiveProps}>
|
<div ref={ref} style={{ position: 'relative' }} {...interactiveProps}>
|
||||||
<TrackRefContextIfNeeded trackRef={trackReference}>
|
<TrackRefContextIfNeeded trackRef={trackReference}>
|
||||||
<ParticipantContextIfNeeded participant={trackReference.participant}>
|
<ParticipantContextIfNeeded participant={trackReference.participant}>
|
||||||
{trackReference.participant.isLocal && (
|
{trackReference.participant.isLocal && (
|
||||||
@@ -177,7 +129,23 @@ export const ParticipantTile: (
|
|||||||
)}
|
)}
|
||||||
{children ?? (
|
{children ?? (
|
||||||
<>
|
<>
|
||||||
{trackMedia}
|
{isTrackReference(trackReference) &&
|
||||||
|
(trackReference.publication?.kind === 'video' ||
|
||||||
|
trackReference.source === Track.Source.Camera ||
|
||||||
|
trackReference.source === Track.Source.ScreenShare) ? (
|
||||||
|
<VideoTrack
|
||||||
|
trackRef={trackReference}
|
||||||
|
onSubscriptionStatusChanged={handleSubscribe}
|
||||||
|
manageSubscription={autoManageSubscription}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
isTrackReference(trackReference) && (
|
||||||
|
<AudioTrack
|
||||||
|
trackRef={trackReference}
|
||||||
|
onSubscriptionStatusChanged={handleSubscribe}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
)}
|
||||||
<div className="lk-participant-placeholder">
|
<div className="lk-participant-placeholder">
|
||||||
<ParticipantPlaceholder
|
<ParticipantPlaceholder
|
||||||
color={participantColor}
|
color={participantColor}
|
||||||
@@ -196,7 +164,6 @@ export const ParticipantTile: (
|
|||||||
{!disableMetadata && !disableTileControls && (
|
{!disableMetadata && !disableTileControls && (
|
||||||
<ParticipantTileFocus
|
<ParticipantTileFocus
|
||||||
trackRef={trackReference}
|
trackRef={trackReference}
|
||||||
tileRef={tileRef}
|
|
||||||
hasKeyboardFocus={hasKeyboardFocus}
|
hasKeyboardFocus={hasKeyboardFocus}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|||||||
+25
-47
@@ -7,64 +7,38 @@ import { useCanMute } from '@/features/rooms/livekit/hooks/useCanMute'
|
|||||||
import { FocusButton } from './FocusButton'
|
import { FocusButton } from './FocusButton'
|
||||||
import { EffectsButton } from './EffectsButton'
|
import { EffectsButton } from './EffectsButton'
|
||||||
import { MuteButton } from './MuteButton'
|
import { MuteButton } from './MuteButton'
|
||||||
|
import { ZoomButton } from './ZoomButton'
|
||||||
|
|
||||||
const MOUSE_IDLE_TIME = 3000
|
const MOUSE_IDLE_TIME = 3000
|
||||||
|
|
||||||
type FadeOverlayProps = {
|
type FadeOverlayProps = {
|
||||||
children: ReactNode
|
children: ReactNode
|
||||||
hasKeyboardFocus: boolean
|
hasKeyboardFocus: boolean
|
||||||
tileRef: React.RefObject<HTMLDivElement | null>
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Pointer-events none so this overlay doesn't block the zoom surface below.
|
const FadeOverlay = ({ children, hasKeyboardFocus }: FadeOverlayProps) => {
|
||||||
// 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 [active, setActive] = useState(false)
|
const [active, setActive] = useState(false)
|
||||||
const idleTimerRef = useRef<number | null>(null)
|
const idleTimerRef = useRef<number | null>(null)
|
||||||
|
|
||||||
useEffect(() => {
|
const clearIdleTimer = () => {
|
||||||
const tile = tileRef.current
|
if (idleTimerRef.current) window.clearTimeout(idleTimerRef.current)
|
||||||
if (!tile) return
|
}
|
||||||
|
|
||||||
const clearIdleTimer = () => {
|
const armIdleTimer = () => {
|
||||||
if (idleTimerRef.current) window.clearTimeout(idleTimerRef.current)
|
clearIdleTimer()
|
||||||
idleTimerRef.current = null
|
idleTimerRef.current = window.setTimeout(() => {
|
||||||
}
|
|
||||||
|
|
||||||
const handleActivity = () => {
|
|
||||||
setActive(true)
|
|
||||||
clearIdleTimer()
|
|
||||||
idleTimerRef.current = window.setTimeout(
|
|
||||||
() => setActive(false),
|
|
||||||
MOUSE_IDLE_TIME
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleLeave = () => {
|
|
||||||
clearIdleTimer()
|
|
||||||
setActive(false)
|
setActive(false)
|
||||||
}
|
}, MOUSE_IDLE_TIME)
|
||||||
|
}
|
||||||
|
|
||||||
tile.addEventListener('mouseenter', handleActivity)
|
const handleActivity = () => {
|
||||||
tile.addEventListener('mousemove', handleActivity)
|
setActive(true)
|
||||||
tile.addEventListener('mouseleave', handleLeave)
|
armIdleTimer()
|
||||||
|
}
|
||||||
|
|
||||||
return () => {
|
useEffect(() => clearIdleTimer, [])
|
||||||
clearIdleTimer()
|
|
||||||
tile.removeEventListener('mouseenter', handleActivity)
|
|
||||||
tile.removeEventListener('mousemove', handleActivity)
|
|
||||||
tile.removeEventListener('mouseleave', handleLeave)
|
|
||||||
}
|
|
||||||
}, [tileRef])
|
|
||||||
|
|
||||||
const isVisible = hasKeyboardFocus || active
|
const isVisible = hasKeyboardFocus || active
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={css({
|
className={css({
|
||||||
@@ -76,10 +50,15 @@ const FadeOverlay = ({
|
|||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
width: '100%',
|
width: '100%',
|
||||||
height: '100%',
|
height: '100%',
|
||||||
pointerEvents: 'none',
|
|
||||||
})}
|
})}
|
||||||
data-visible={isVisible || undefined}
|
data-visible={isVisible || undefined}
|
||||||
aria-hidden={!isVisible}
|
aria-hidden={!isVisible}
|
||||||
|
onMouseEnter={handleActivity}
|
||||||
|
onMouseMove={handleActivity}
|
||||||
|
onMouseLeave={() => {
|
||||||
|
clearIdleTimer()
|
||||||
|
setActive(false)
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{isVisible && children}
|
{isVisible && children}
|
||||||
</div>
|
</div>
|
||||||
@@ -88,11 +67,9 @@ const FadeOverlay = ({
|
|||||||
|
|
||||||
export const ParticipantTileFocus = ({
|
export const ParticipantTileFocus = ({
|
||||||
trackRef,
|
trackRef,
|
||||||
tileRef,
|
|
||||||
hasKeyboardFocus,
|
hasKeyboardFocus,
|
||||||
}: {
|
}: {
|
||||||
trackRef: TrackReferenceOrPlaceholder
|
trackRef: TrackReferenceOrPlaceholder
|
||||||
tileRef: React.RefObject<HTMLDivElement | null>
|
|
||||||
hasKeyboardFocus: boolean
|
hasKeyboardFocus: boolean
|
||||||
}) => {
|
}) => {
|
||||||
const participant = trackRef.participant
|
const participant = trackRef.participant
|
||||||
@@ -101,7 +78,7 @@ export const ParticipantTileFocus = ({
|
|||||||
const canMute = useCanMute(participant)
|
const canMute = useCanMute(participant)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<FadeOverlay hasKeyboardFocus={hasKeyboardFocus} tileRef={tileRef}>
|
<FadeOverlay hasKeyboardFocus={hasKeyboardFocus}>
|
||||||
<div
|
<div
|
||||||
className={css({
|
className={css({
|
||||||
backgroundColor: 'primaryDark.50',
|
backgroundColor: 'primaryDark.50',
|
||||||
@@ -110,7 +87,6 @@ export const ParticipantTileFocus = ({
|
|||||||
display: 'flex',
|
display: 'flex',
|
||||||
opacity: 0.6,
|
opacity: 0.6,
|
||||||
animation: 'overlayIn 200ms linear 300ms backwards',
|
animation: 'overlayIn 200ms linear 300ms backwards',
|
||||||
pointerEvents: 'auto',
|
|
||||||
_hover: {
|
_hover: {
|
||||||
opacity: 0.95,
|
opacity: 0.95,
|
||||||
},
|
},
|
||||||
@@ -118,7 +94,7 @@ export const ParticipantTileFocus = ({
|
|||||||
>
|
>
|
||||||
<HStack gap={0.5} padding={0.5}>
|
<HStack gap={0.5} padding={0.5}>
|
||||||
<FocusButton trackRef={trackRef} />
|
<FocusButton trackRef={trackRef} />
|
||||||
{!isScreenShare && (
|
{!isScreenShare ? (
|
||||||
<>
|
<>
|
||||||
{isLocal ? (
|
{isLocal ? (
|
||||||
<EffectsButton />
|
<EffectsButton />
|
||||||
@@ -126,6 +102,8 @@ export const ParticipantTileFocus = ({
|
|||||||
canMute && <MuteButton participant={participant} />
|
canMute && <MuteButton participant={participant} />
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
|
) : (
|
||||||
|
!isLocal && <ZoomButton trackRef={trackRef} />
|
||||||
)}
|
)}
|
||||||
</HStack>
|
</HStack>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+32
@@ -0,0 +1,32 @@
|
|||||||
|
import { TrackReferenceOrPlaceholder } from '@livekit/components-core'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import { useFullScreen } from '@/features/rooms/livekit/hooks/useFullScreen'
|
||||||
|
import { Button } from '@/primitives'
|
||||||
|
import { RiFullscreenLine } from '@remixicon/react'
|
||||||
|
|
||||||
|
export const ZoomButton = ({
|
||||||
|
trackRef,
|
||||||
|
}: {
|
||||||
|
trackRef: TrackReferenceOrPlaceholder
|
||||||
|
}) => {
|
||||||
|
const { t } = useTranslation('rooms', { keyPrefix: 'participantTileFocus' })
|
||||||
|
const { toggleFullScreen, isFullscreenAvailable } = useFullScreen({
|
||||||
|
trackRef,
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!isFullscreenAvailable) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="primaryTextDark"
|
||||||
|
square
|
||||||
|
tooltip={t('fullScreen')}
|
||||||
|
onPress={() => toggleFullScreen()}
|
||||||
|
>
|
||||||
|
<RiFullscreenLine />
|
||||||
|
</Button>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,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
|
|
||||||
}
|
|
||||||
@@ -9,9 +9,8 @@ export enum RecordingStatus {
|
|||||||
Stopped = 'stopped',
|
Stopped = 'stopped',
|
||||||
Saved = 'saved',
|
Saved = 'saved',
|
||||||
Aborted = 'aborted',
|
Aborted = 'aborted',
|
||||||
Failed = 'failed',
|
FailedToStart = 'failedToStart',
|
||||||
FailedToStart = 'failed_to_start',
|
FailedToStop = 'failedToStop',
|
||||||
FailedToStop = 'failed_to_stop',
|
|
||||||
NotificationSucceed = 'notification_succeeded',
|
NotificationSucceed = 'notification_succeeded',
|
||||||
ExternalProcessSuccessful = 'external_process_successful',
|
ExternalProcessSuccessful = 'external_process_successful',
|
||||||
ExternalProcessFailed = 'external_process_failed',
|
ExternalProcessFailed = 'external_process_failed',
|
||||||
|
|||||||
@@ -158,7 +158,7 @@ export const Conference = ({
|
|||||||
*
|
*
|
||||||
* Issue: On Firefox behind proxy configurations, WebSocket signaling fails to establish.
|
* Issue: On Firefox behind proxy configurations, WebSocket signaling fails to establish.
|
||||||
* Symptom: Client receives HTTP 200 instead of expected 101 (Switching Protocols).
|
* Symptom: Client receives HTTP 200 instead of expected 101 (Switching Protocols).
|
||||||
* Root Cause: Certificate/security issue where the initial request is considered insecure.
|
* Root Cause: Certificate/security issue where the initial request is considered unsecure.
|
||||||
*
|
*
|
||||||
* Solution: Pre-establish a WebSocket connection to the signaling server, which fails.
|
* Solution: Pre-establish a WebSocket connection to the signaling server, which fails.
|
||||||
* This "primes" the connection, allowing subsequent WebSocket establishments to work correctly.
|
* This "primes" the connection, allowing subsequent WebSocket establishments to work correctly.
|
||||||
|
|||||||
@@ -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}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
+18
-27
@@ -1,16 +1,17 @@
|
|||||||
import { useEffect } from 'react'
|
import { useEffect } from 'react'
|
||||||
import { useRoomContext } from '@livekit/components-react'
|
import { useRoomContext } from '@livekit/components-react'
|
||||||
import {
|
import {
|
||||||
type RemoteTrack,
|
type RemoteParticipant,
|
||||||
type RemoteTrackPublication,
|
type RemoteTrackPublication,
|
||||||
RoomEvent,
|
RoomEvent,
|
||||||
Track,
|
Track,
|
||||||
|
VideoQuality,
|
||||||
} from 'livekit-client'
|
} from 'livekit-client'
|
||||||
import { useSnapshot } from 'valtio'
|
import { useSnapshot } from 'valtio'
|
||||||
import { userChoicesStore } from '@/stores/userChoices'
|
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.
|
* LiveKit doesn't allow handling video quality preferences at the room level.
|
||||||
*/
|
*/
|
||||||
export const VideoResolutionSubscription = () => {
|
export const VideoResolutionSubscription = () => {
|
||||||
@@ -18,40 +19,30 @@ export const VideoResolutionSubscription = () => {
|
|||||||
const room = useRoomContext()
|
const room = useRoomContext()
|
||||||
|
|
||||||
useEffect(() => {
|
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 (
|
if (
|
||||||
publication.kind !== Track.Kind.Video ||
|
videoSubscribeQuality === undefined ||
|
||||||
publication.source === Track.Source.ScreenShare ||
|
videoSubscribeQuality === VideoQuality.HIGH
|
||||||
publication.videoQuality === videoSubscribeQuality
|
)
|
||||||
) {
|
|
||||||
return
|
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.TrackPublished, handleTrackPublished)
|
||||||
room.on(RoomEvent.TrackSubscribed, handleTrackSubscribed)
|
|
||||||
return () => {
|
return () => {
|
||||||
room.off(RoomEvent.TrackPublished, handleTrackPublished)
|
room.off(RoomEvent.TrackPublished, handleTrackPublished)
|
||||||
room.off(RoomEvent.TrackSubscribed, handleTrackSubscribed)
|
|
||||||
}
|
}
|
||||||
}, [room, videoSubscribeQuality])
|
}, [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) => {
|
export const VideoTab = ({ id }: VideoTabProps) => {
|
||||||
const { t } = useTranslation('settings', { keyPrefix: 'video' })
|
const { t } = useTranslation('settings', { keyPrefix: 'video' })
|
||||||
const room = useRoomContext()
|
const room = useRoomContext()
|
||||||
const { localParticipant } = room
|
const { localParticipant, remoteParticipants } = room
|
||||||
|
|
||||||
const {
|
const {
|
||||||
videoDeviceId,
|
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(() => {
|
useEffect(() => {
|
||||||
let videoTrack: LocalVideoTrack | null = null
|
let videoTrack: LocalVideoTrack | null = null
|
||||||
|
|
||||||
@@ -215,7 +231,9 @@ export const VideoTab = ({ id }: VideoTabProps) => {
|
|||||||
selectedKey={videoSubscribeQuality?.toString()}
|
selectedKey={videoSubscribeQuality?.toString()}
|
||||||
onSelectionChange={(key) => {
|
onSelectionChange={(key) => {
|
||||||
if (key == undefined) return
|
if (key == undefined) return
|
||||||
saveVideoSubscribeQuality(Number(String(key)))
|
const selectedQuality = Number(String(key))
|
||||||
|
saveVideoSubscribeQuality(selectedQuality)
|
||||||
|
updateExistingRemoteVideoQuality(selectedQuality)
|
||||||
}}
|
}}
|
||||||
style={{
|
style={{
|
||||||
width: '100%',
|
width: '100%',
|
||||||
|
|||||||
@@ -16,10 +16,6 @@ export type ShortcutId =
|
|||||||
| 'recording'
|
| 'recording'
|
||||||
| 'reaction'
|
| 'reaction'
|
||||||
| 'fullscreen'
|
| 'fullscreen'
|
||||||
| 'zoom-in'
|
|
||||||
| 'zoom-out'
|
|
||||||
| 'zoom-reset'
|
|
||||||
| 'zoom-pan'
|
|
||||||
|
|
||||||
export const getShortcutDescriptorById = (id: ShortcutId) =>
|
export const getShortcutDescriptorById = (id: ShortcutId) =>
|
||||||
shortcutCatalog.find((item) => item.id === id)
|
shortcutCatalog.find((item) => item.id === id)
|
||||||
@@ -28,7 +24,7 @@ export type ShortcutDescriptor = {
|
|||||||
id: ShortcutId
|
id: ShortcutId
|
||||||
category: ShortcutCategory
|
category: ShortcutCategory
|
||||||
shortcut?: Shortcut
|
shortcut?: Shortcut
|
||||||
kind?: 'press' | 'longPress' | 'arrows'
|
kind?: 'press' | 'longPress'
|
||||||
code?: string // used when kind === 'longPress' (KeyboardEvent.code)
|
code?: string // used when kind === 'longPress' (KeyboardEvent.code)
|
||||||
description?: string
|
description?: string
|
||||||
}
|
}
|
||||||
@@ -90,27 +86,4 @@ export const shortcutCatalog: ShortcutDescriptor[] = [
|
|||||||
category: 'interaction',
|
category: 'interaction',
|
||||||
shortcut: { key: 'P', ctrlKey: true, shiftKey: true },
|
shortcut: { key: 'P', ctrlKey: true, shiftKey: true },
|
||||||
},
|
},
|
||||||
// Screen share zoom keys are unmodified, so they are bound on the focused
|
|
||||||
// tile instead of being registered globally. They are listed here so the
|
|
||||||
// shortcuts panel stays exhaustive.
|
|
||||||
{
|
|
||||||
id: 'zoom-in',
|
|
||||||
category: 'interaction',
|
|
||||||
shortcut: { key: '+' },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'zoom-out',
|
|
||||||
category: 'interaction',
|
|
||||||
shortcut: { key: '-' },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'zoom-reset',
|
|
||||||
category: 'interaction',
|
|
||||||
shortcut: { key: '0' },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'zoom-pan',
|
|
||||||
category: 'interaction',
|
|
||||||
kind: 'arrows',
|
|
||||||
},
|
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -25,7 +25,6 @@ export const formatShortcutLabelForSR = (
|
|||||||
shiftLabel,
|
shiftLabel,
|
||||||
plusLabel,
|
plusLabel,
|
||||||
noShortcutLabel,
|
noShortcutLabel,
|
||||||
keyLabels,
|
|
||||||
}: {
|
}: {
|
||||||
controlLabel: string
|
controlLabel: string
|
||||||
commandLabel: string
|
commandLabel: string
|
||||||
@@ -34,12 +33,10 @@ export const formatShortcutLabelForSR = (
|
|||||||
shiftLabel: string
|
shiftLabel: string
|
||||||
plusLabel: string
|
plusLabel: string
|
||||||
noShortcutLabel: string
|
noShortcutLabel: string
|
||||||
// Spelled-out names for keys screen readers may skip or mispronounce.
|
|
||||||
keyLabels?: Record<string, string>
|
|
||||||
}
|
}
|
||||||
) => {
|
) => {
|
||||||
if (!shortcut) return noShortcutLabel
|
if (!shortcut) return noShortcutLabel
|
||||||
const key = keyLabels?.[shortcut.key] ?? shortcut.key?.toUpperCase()
|
const key = shortcut.key?.toUpperCase()
|
||||||
if (!key) return noShortcutLabel
|
if (!key) return noShortcutLabel
|
||||||
const ctrlWord = isMacintosh() ? commandLabel : controlLabel
|
const ctrlWord = isMacintosh() ? commandLabel : controlLabel
|
||||||
const altWord = isMacintosh() ? optionLabel : altLabel
|
const altWord = isMacintosh() ? optionLabel : altLabel
|
||||||
|
|||||||
@@ -12,9 +12,6 @@ export const useShortcutFormatting = () => {
|
|||||||
|
|
||||||
const formatVisual = useCallback(
|
const formatVisual = useCallback(
|
||||||
(shortcut?: Shortcut, code?: string, kind?: string) => {
|
(shortcut?: Shortcut, code?: string, kind?: string) => {
|
||||||
if (kind === 'arrows') {
|
|
||||||
return t('shortcutsPanel.visual.arrows')
|
|
||||||
}
|
|
||||||
if (code && kind === 'longPress') {
|
if (code && kind === 'longPress') {
|
||||||
const label = getKeyLabelFromCode(code)
|
const label = getKeyLabelFromCode(code)
|
||||||
return t('shortcutsPanel.visual.hold', { key: label || '?' })
|
return t('shortcutsPanel.visual.hold', { key: label || '?' })
|
||||||
@@ -26,9 +23,6 @@ export const useShortcutFormatting = () => {
|
|||||||
|
|
||||||
const formatForSR = useCallback(
|
const formatForSR = useCallback(
|
||||||
(shortcut?: Shortcut, code?: string, kind?: string) => {
|
(shortcut?: Shortcut, code?: string, kind?: string) => {
|
||||||
if (kind === 'arrows') {
|
|
||||||
return t('shortcutsPanel.sr.arrows')
|
|
||||||
}
|
|
||||||
if (code && kind === 'longPress') {
|
if (code && kind === 'longPress') {
|
||||||
const label = getKeyLabelFromCode(code)
|
const label = getKeyLabelFromCode(code)
|
||||||
return t('shortcutsPanel.sr.hold', { key: label || '?' })
|
return t('shortcutsPanel.sr.hold', { key: label || '?' })
|
||||||
@@ -41,10 +35,6 @@ export const useShortcutFormatting = () => {
|
|||||||
shiftLabel: t('shortcutsPanel.sr.shift'),
|
shiftLabel: t('shortcutsPanel.sr.shift'),
|
||||||
plusLabel: t('shortcutsPanel.sr.plus'),
|
plusLabel: t('shortcutsPanel.sr.plus'),
|
||||||
noShortcutLabel: t('shortcutsPanel.sr.noShortcut'),
|
noShortcutLabel: t('shortcutsPanel.sr.noShortcut'),
|
||||||
keyLabels: {
|
|
||||||
'+': t('shortcutsPanel.sr.plusKey'),
|
|
||||||
'-': t('shortcutsPanel.sr.minusKey'),
|
|
||||||
},
|
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
[t]
|
[t]
|
||||||
|
|||||||
@@ -33,21 +33,15 @@
|
|||||||
"accept": "Hereinlassen"
|
"accept": "Hereinlassen"
|
||||||
},
|
},
|
||||||
"transcript": {
|
"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.",
|
"started": "{{name}} hat die Meeting-Transkription gestartet.",
|
||||||
"stopped": "{{name}} hat die Meeting-Transkription gestoppt.",
|
"stopped": "{{name}} hat die Meeting-Transkription gestoppt.",
|
||||||
"limitReached": "Die Transkription hat die maximal zulässige Dauer überschritten und wird automatisch gespeichert.",
|
"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."
|
"requested": "{{name}} möchte die Meeting-Transkription starten."
|
||||||
},
|
},
|
||||||
"screenRecording": {
|
"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.",
|
"started": "{{name}} hat die Meeting-Aufzeichnung gestartet.",
|
||||||
"stopped": "{{name}} hat die Meeting-Aufzeichnung gestoppt.",
|
"stopped": "{{name}} hat die Meeting-Aufzeichnung gestoppt.",
|
||||||
"limitReached": "Die Aufzeichnung hat die maximal zulässige Dauer überschritten und wird automatisch gespeichert.",
|
"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."
|
"requested": "{{name}} möchte die Meeting-Aufzeichnung starten."
|
||||||
},
|
},
|
||||||
"recordingSave": {
|
"recordingSave": {
|
||||||
|
|||||||
@@ -756,24 +756,6 @@
|
|||||||
"muteParticipant": "{{name}} stummschalten",
|
"muteParticipant": "{{name}} stummschalten",
|
||||||
"fullScreen": "Vollbild"
|
"fullScreen": "Vollbild"
|
||||||
},
|
},
|
||||||
"screenShareZoom": {
|
|
||||||
"toolbarLabel": "Zoom-Steuerung für Bildschirmfreigabe",
|
|
||||||
"zoomIn": "Vergrößern",
|
|
||||||
"zoomInWithShortcut": "Vergrößern ({{shortcut}})",
|
|
||||||
"zoomOut": "Verkleinern",
|
|
||||||
"zoomOutWithShortcut": "Verkleinern ({{shortcut}})",
|
|
||||||
"wheelShortcut": "Steuerung plus Mausrad",
|
|
||||||
"wheelShortcutMac": "⌘+Mausrad",
|
|
||||||
"wheelShortcutHint": "Tastenkürzel: Steuerung plus Mausrad zum Vergrößern oder Verkleinern.",
|
|
||||||
"wheelShortcutHintMac": "Tastenkürzel: Befehl plus Mausrad zum Vergrößern oder Verkleinern.",
|
|
||||||
"fitToWindow": "An Fenster anpassen",
|
|
||||||
"fullScreen": "Vollbild",
|
|
||||||
"exitFullScreen": "Vollbild beenden",
|
|
||||||
"currentZoomLevel": "Zoom {{level}} %",
|
|
||||||
"panHint": "Zoom {{level}} %. Mit der Maus ziehen oder den Fokus zurück auf die Bildschirmfreigabe setzen und mit den Pfeiltasten im Bild navigieren.",
|
|
||||||
"fullScreenEntered": "Vollbild aktiviert",
|
|
||||||
"fullScreenExited": "Vollbild deaktiviert"
|
|
||||||
},
|
|
||||||
"shortcutsPanel": {
|
"shortcutsPanel": {
|
||||||
"title": "Tastenkürzel",
|
"title": "Tastenkürzel",
|
||||||
"categories": {
|
"categories": {
|
||||||
@@ -793,11 +775,7 @@
|
|||||||
"raise-hand": "Hand heben oder senken",
|
"raise-hand": "Hand heben oder senken",
|
||||||
"toggle-chat": "Chat anzeigen/ausblenden",
|
"toggle-chat": "Chat anzeigen/ausblenden",
|
||||||
"toggle-participants": "Teilnehmende anzeigen/ausblenden",
|
"toggle-participants": "Teilnehmende anzeigen/ausblenden",
|
||||||
"open-shortcuts-settings": "Tastenkürzel-Einstellungen öffnen",
|
"open-shortcuts-settings": "Tastenkürzel-Einstellungen öffnen"
|
||||||
"zoom-in": "In einen geteilten Bildschirm hineinzoomen",
|
|
||||||
"zoom-out": "Aus einem geteilten Bildschirm herauszoomen",
|
|
||||||
"zoom-reset": "Zoom des geteilten Bildschirms zurücksetzen",
|
|
||||||
"zoom-pan": "Im gezoomten geteilten Bildschirm navigieren"
|
|
||||||
},
|
},
|
||||||
"sr": {
|
"sr": {
|
||||||
"control": "Steuerung",
|
"control": "Steuerung",
|
||||||
@@ -807,14 +785,10 @@
|
|||||||
"shift": "Umschalt",
|
"shift": "Umschalt",
|
||||||
"plus": "plus",
|
"plus": "plus",
|
||||||
"hold": "Halte {{key}} gedrückt",
|
"hold": "Halte {{key}} gedrückt",
|
||||||
"arrows": "Pfeiltasten",
|
|
||||||
"plusKey": "Plus-Taste",
|
|
||||||
"minusKey": "Minus-Taste",
|
|
||||||
"noShortcut": "Kein Tastenkürzel"
|
"noShortcut": "Kein Tastenkürzel"
|
||||||
},
|
},
|
||||||
"visual": {
|
"visual": {
|
||||||
"hold": "Halte {{key}} gedrückt",
|
"hold": "Halte {{key}} gedrückt"
|
||||||
"arrows": "↑ ↓ ← →"
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"fullScreenWarning": {
|
"fullScreenWarning": {
|
||||||
|
|||||||
@@ -102,9 +102,6 @@
|
|||||||
},
|
},
|
||||||
"messageReceived": {
|
"messageReceived": {
|
||||||
"label": "Nachricht erhalten"
|
"label": "Nachricht erhalten"
|
||||||
},
|
|
||||||
"participantWaiting": {
|
|
||||||
"label": "Person im Warteraum"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -33,21 +33,15 @@
|
|||||||
"accept": "Accept"
|
"accept": "Accept"
|
||||||
},
|
},
|
||||||
"transcript": {
|
"transcript": {
|
||||||
"waitingForTracks": "Transcription is waiting for a microphone. Turn on your microphone so the transcription can start.",
|
|
||||||
"started": "{{name}} started the meeting transcription.",
|
"started": "{{name}} started the meeting transcription.",
|
||||||
"stopped": "{{name}} stopped the meeting transcription.",
|
"stopped": "{{name}} stopped the meeting transcription.",
|
||||||
"limitReached": "The transcription has exceeded the maximum allowed duration and will be automatically saved.",
|
"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."
|
"requested": "{{name}} wants to start the meeting transcription."
|
||||||
},
|
},
|
||||||
"screenRecording": {
|
"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.",
|
"started": "{{name}} started the meeting recording.",
|
||||||
"stopped": "{{name}} stopped the meeting recording.",
|
"stopped": "{{name}} stopped the meeting recording.",
|
||||||
"limitReached": "The recording has exceeded the maximum allowed duration and will be automatically saved.",
|
"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."
|
"requested": "{{name}} wants to start the meeting recording."
|
||||||
},
|
},
|
||||||
"recordingSave": {
|
"recordingSave": {
|
||||||
|
|||||||
@@ -756,24 +756,6 @@
|
|||||||
"muteParticipant": "Mute {{name}}",
|
"muteParticipant": "Mute {{name}}",
|
||||||
"fullScreen": "Full screen"
|
"fullScreen": "Full screen"
|
||||||
},
|
},
|
||||||
"screenShareZoom": {
|
|
||||||
"toolbarLabel": "Screen share zoom controls",
|
|
||||||
"zoomIn": "Zoom in",
|
|
||||||
"zoomInWithShortcut": "Zoom in ({{shortcut}})",
|
|
||||||
"zoomOut": "Zoom out",
|
|
||||||
"zoomOutWithShortcut": "Zoom out ({{shortcut}})",
|
|
||||||
"wheelShortcut": "Control plus scroll wheel",
|
|
||||||
"wheelShortcutMac": "⌘+scroll wheel",
|
|
||||||
"wheelShortcutHint": "Shortcut: Control plus scroll wheel to zoom in or out.",
|
|
||||||
"wheelShortcutHintMac": "Shortcut: Command plus scroll wheel to zoom in or out.",
|
|
||||||
"fitToWindow": "Fit to window",
|
|
||||||
"fullScreen": "Full screen",
|
|
||||||
"exitFullScreen": "Exit full screen",
|
|
||||||
"currentZoomLevel": "Zoom {{level}} %",
|
|
||||||
"panHint": "Zoom {{level}} %. Drag with mouse, or move focus back to the screen share and use arrow keys to navigate the picture.",
|
|
||||||
"fullScreenEntered": "Full screen enabled",
|
|
||||||
"fullScreenExited": "Full screen disabled"
|
|
||||||
},
|
|
||||||
"shortcutsPanel": {
|
"shortcutsPanel": {
|
||||||
"title": "Keyboard shortcuts",
|
"title": "Keyboard shortcuts",
|
||||||
"categories": {
|
"categories": {
|
||||||
@@ -793,11 +775,7 @@
|
|||||||
"raise-hand": "Raise or lower hand",
|
"raise-hand": "Raise or lower hand",
|
||||||
"toggle-chat": "Toggle chat",
|
"toggle-chat": "Toggle chat",
|
||||||
"toggle-participants": "Toggle participants",
|
"toggle-participants": "Toggle participants",
|
||||||
"open-shortcuts-settings": "Open shortcuts settings",
|
"open-shortcuts-settings": "Open shortcuts settings"
|
||||||
"zoom-in": "Zoom in on a shared screen",
|
|
||||||
"zoom-out": "Zoom out on a shared screen",
|
|
||||||
"zoom-reset": "Reset the shared screen zoom",
|
|
||||||
"zoom-pan": "Move around a zoomed shared screen"
|
|
||||||
},
|
},
|
||||||
"sr": {
|
"sr": {
|
||||||
"control": "Control",
|
"control": "Control",
|
||||||
@@ -807,14 +785,10 @@
|
|||||||
"shift": "Shift",
|
"shift": "Shift",
|
||||||
"plus": "plus",
|
"plus": "plus",
|
||||||
"hold": "Hold {{key}}",
|
"hold": "Hold {{key}}",
|
||||||
"arrows": "Arrow keys",
|
|
||||||
"plusKey": "Plus key",
|
|
||||||
"minusKey": "Minus key",
|
|
||||||
"noShortcut": "No shortcut"
|
"noShortcut": "No shortcut"
|
||||||
},
|
},
|
||||||
"visual": {
|
"visual": {
|
||||||
"hold": "Hold {{key}}",
|
"hold": "Hold {{key}}"
|
||||||
"arrows": "↑ ↓ ← →"
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"fullScreenWarning": {
|
"fullScreenWarning": {
|
||||||
|
|||||||
@@ -102,9 +102,6 @@
|
|||||||
},
|
},
|
||||||
"messageReceived": {
|
"messageReceived": {
|
||||||
"label": "Message received"
|
"label": "Message received"
|
||||||
},
|
|
||||||
"participantWaiting": {
|
|
||||||
"label": "Participant waiting"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -33,21 +33,15 @@
|
|||||||
"accept": "Aceptar"
|
"accept": "Aceptar"
|
||||||
},
|
},
|
||||||
"transcript": {
|
"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.",
|
"started": "{{name}} ha iniciado la transcripción de la reunión.",
|
||||||
"stopped": "{{name}} ha detenido 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.",
|
"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."
|
"requested": "{{name}} ha solicitado iniciar la transcripción."
|
||||||
},
|
},
|
||||||
"screenRecording": {
|
"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.",
|
"started": "{{name}} ha iniciado la grabación de la reunión.",
|
||||||
"stopped": "{{name}} ha detenido 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.",
|
"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."
|
"requested": "{{name}} ha solicitado iniciar la grabación."
|
||||||
},
|
},
|
||||||
"recordingSave": {
|
"recordingSave": {
|
||||||
|
|||||||
@@ -755,24 +755,6 @@
|
|||||||
"muteParticipant": "Silenciar el micrófono de {{name}}",
|
"muteParticipant": "Silenciar el micrófono de {{name}}",
|
||||||
"fullScreen": "Pantalla completa"
|
"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": {
|
"shortcutsPanel": {
|
||||||
"title": "Atajos de teclado",
|
"title": "Atajos de teclado",
|
||||||
"categories": {
|
"categories": {
|
||||||
@@ -792,11 +774,7 @@
|
|||||||
"raise-hand": "Levantar o bajar la mano",
|
"raise-hand": "Levantar o bajar la mano",
|
||||||
"toggle-chat": "Mostrar/Ocultar el chat",
|
"toggle-chat": "Mostrar/Ocultar el chat",
|
||||||
"toggle-participants": "Mostrar/Ocultar los participantes",
|
"toggle-participants": "Mostrar/Ocultar los participantes",
|
||||||
"open-shortcuts-settings": "Abrir los ajustes de los atajos",
|
"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"
|
|
||||||
},
|
},
|
||||||
"sr": {
|
"sr": {
|
||||||
"control": "Control",
|
"control": "Control",
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user