mirror of
https://github.com/suitenumerique/meet.git
synced 2026-07-27 12:19:10 +00:00
Compare commits
43 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d59a352181 | |||
| 09055684cb | |||
| f5c7e0f6ec | |||
| 08288e1a46 | |||
| a97d47096e | |||
| fbaade1a08 | |||
| f55fa0c42b | |||
| 2bc5e47c75 | |||
| 0d5136206f | |||
| 05a67320b1 | |||
| 2fbaa49089 | |||
| 76542b2235 | |||
| 10231b0333 | |||
| 9b9e6578ee | |||
| 0c81d29ee7 | |||
| 839a8f1c71 | |||
| 40ef420cda | |||
| d995bd9041 | |||
| 8ac4c2409d | |||
| e47ad42656 | |||
| 2509452c52 | |||
| 1022780027 | |||
| 4a1a04f86e | |||
| 8702638cf3 | |||
| f88c0307ea | |||
| e7f15b50ff | |||
| 75ba2ff146 | |||
| bbe2a32efc | |||
| 86fff16eed | |||
| 195d2b5006 | |||
| 30cf264276 | |||
| 945d779b45 | |||
| d43b335546 | |||
| e6f0ff5112 | |||
| 224d6ce358 | |||
| 20718f2d5a | |||
| 8edcb6e973 | |||
| b00c4e5d6d | |||
| 3c0ba03976 | |||
| 7fa172d100 | |||
| ecf8f0fe3f | |||
| 27da57aff5 | |||
| e327a5e35f |
@@ -82,7 +82,7 @@ jobs:
|
|||||||
- name: Install Node.js
|
- name: Install Node.js
|
||||||
uses: actions/setup-node@v6
|
uses: actions/setup-node@v6
|
||||||
with:
|
with:
|
||||||
node-version: "18"
|
node-version: "22"
|
||||||
|
|
||||||
- name: Restore the mail templates
|
- name: Restore the mail templates
|
||||||
uses: actions/cache@v5
|
uses: actions/cache@v5
|
||||||
@@ -305,7 +305,6 @@ jobs:
|
|||||||
working-directory: src/summary
|
working-directory: src/summary
|
||||||
|
|
||||||
env:
|
env:
|
||||||
V1_TENANT_ID: 'test-tenant'
|
|
||||||
AUTHORIZED_TENANTS: '[{"id": "test-tenant", "api_key": "test-api-token", "webhook_url": "https://example.com/webhook", "webhook_api_key": "test-webhook-api-key"}]'
|
AUTHORIZED_TENANTS: '[{"id": "test-tenant", "api_key": "test-api-token", "webhook_url": "https://example.com/webhook", "webhook_api_key": "test-webhook-api-key"}]'
|
||||||
AWS_STORAGE_BUCKET_NAME: "http://meet-media-storage"
|
AWS_STORAGE_BUCKET_NAME: "http://meet-media-storage"
|
||||||
AWS_S3_ENDPOINT_URL: "minio:9000"
|
AWS_S3_ENDPOINT_URL: "minio:9000"
|
||||||
|
|||||||
@@ -8,9 +8,50 @@ and this project adheres to
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- ✨(backend) allow searching the recording admin table by owner email
|
||||||
|
- ✨(frontend) add participant color gradient when camera is off #1490
|
||||||
|
- ✨(all) allow forcing SSO display name for authenticated users
|
||||||
|
- ➕(frontend) install vite-plugin-static-copy for MediaPipe WASM assets
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- 🗑️(settings) deprecate SUMMARY_SERVICE_VERSION=1
|
||||||
|
- ⬆️(mail) update mjml to v5 and @html-to/text-cli
|
||||||
|
- 🚸(frontend) initialize the join input name with the persisted full name
|
||||||
|
- ♻️(frontend) refactor background processors to use the new API
|
||||||
|
- ♻️(frontend) inline model weights to avoid loading them from remote
|
||||||
|
- ♻️(frontend) inline MediaPipe WASM modules to avoid loading from remote
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- 🩹(backend) identify externally provisioned users to PostHog
|
||||||
|
- 🐛(backend) fix info panel crash for unregistered rooms
|
||||||
|
|
||||||
|
## [1.23.0] - 2026-07-08
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- ✨(backend) extend analytics module to support feature flags
|
||||||
|
- ✨(backend) implement feature flags in Posthog analytics backend
|
||||||
|
- ✨(agents) report errors to Sentry for all LiveKit agents
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
|
|
||||||
- ⬆️(agents) upgrade to python 3.14 slim
|
- ⬆️(agents) upgrade to python 3.14 slim
|
||||||
|
- ⬆️(dependencies) update python dependencies
|
||||||
|
- 💥(summary) remove v1 related code #1362
|
||||||
|
- ✨(meet) use compatible with summary v2 #1362
|
||||||
|
- ♻️(backend) refactor analytics backend from Protocol to abstract class
|
||||||
|
- 🔥(summary) remove call to summary enabled feature flag
|
||||||
|
- ♻️(frontend) wrap MuteEveryoneButton with AdminOrOwnerOnly
|
||||||
|
- ⬆️(frontend) upgrade livekit-client from 2.19.0 to 2.19.2
|
||||||
|
- ⬆️(frontend) upgrade posthog-js from 1.386.5 to 1.387.0
|
||||||
|
- ⬆️(frontend) upgrade @tanstack/react-query from 5.100.14 to 5.101.0
|
||||||
|
- ⬆️(frontend) update the frontend build image to Node 22
|
||||||
|
- 🔒️(frontend) update docker image to nginx-unprivileged:1.30.3-alpine3.23
|
||||||
|
- ✨(summary) more precise analytics events
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|
||||||
@@ -30,6 +71,7 @@ and this project adheres to
|
|||||||
- ✨(backend) add fallback to save recordings without S3/MinIO webhooks
|
- ✨(backend) add fallback to save recordings without S3/MinIO webhooks
|
||||||
- 🩹(frontend) enable screen share button in PiP #1458
|
- 🩹(frontend) enable screen share button in PiP #1458
|
||||||
- 🐛(backend) support unencoded S3 notification object keys #1455
|
- 🐛(backend) support unencoded S3 notification object keys #1455
|
||||||
|
- ✨(frontend) prioritize screen share in picture-in-picture layout #1467
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
|
|
||||||
|
|||||||
+12
@@ -15,3 +15,15 @@ the following command inside your docker container:
|
|||||||
(Note : in your development environment, you can `make migrate`.)
|
(Note : in your development environment, you can `make migrate`.)
|
||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
## v1.23.0
|
||||||
|
|
||||||
|
As part of the 1.23.0 release, the legacy `api/v1` implementation has been removed from the _experimental_ Summary service and Meet has been migrated to the new `api/v2`.
|
||||||
|
|
||||||
|
**To avoid a breaking change, the Meet backend continues to use the Summary service's v1-compatible API format by default (`SUMMARY_SERVICE_VERSION` setting defaults to `1`).**
|
||||||
|
|
||||||
|
If you are deploying both Meet and Summary from this repository, you must configure the Meet backend to use the v2 API by setting the following environment variable `SUMMARY_SERVICE_VERSION=2`.
|
||||||
|
|
||||||
|
If you are upgrading only the Meet deployment while keeping an older Summary v1 compatible deployment, no action is required, as the v1-compatible API remains the default.
|
||||||
|
|
||||||
|
Note that we plan on removing the legacy `v1` summary compatibility in a future major version. If you have your own implementation for the summary service, we recommend updating its API contract and setting `SUMMARY_SERVICE_VERSION=2`.
|
||||||
|
|||||||
@@ -113,6 +113,12 @@ update_python_version "summary"
|
|||||||
# Update agents pyproject.toml
|
# Update agents pyproject.toml
|
||||||
update_python_version "agents"
|
update_python_version "agents"
|
||||||
|
|
||||||
|
# Run uv lock in agents
|
||||||
|
print_info "Running uv lock in agents..."
|
||||||
|
cd "src/agents"
|
||||||
|
uv lock
|
||||||
|
cd -
|
||||||
|
|
||||||
# Update CHANGELOG
|
# Update CHANGELOG
|
||||||
print_info "Updating CHANGELOG..."
|
print_info "Updating CHANGELOG..."
|
||||||
|
|
||||||
|
|||||||
+2
-2
@@ -301,7 +301,7 @@ services:
|
|||||||
context: ./src/summary
|
context: ./src/summary
|
||||||
dockerfile: Dockerfile
|
dockerfile: Dockerfile
|
||||||
target: production
|
target: production
|
||||||
command: celery -A summary.core.celery_worker worker --pool=solo --loglevel=debug -Q transcribe-queue
|
command: celery -A summary.core.celery_worker worker --pool=solo --loglevel=debug -Q transcribe-queue-v2
|
||||||
env_file:
|
env_file:
|
||||||
- env.d/development/summary
|
- env.d/development/summary
|
||||||
volumes:
|
volumes:
|
||||||
@@ -321,7 +321,7 @@ services:
|
|||||||
context: ./src/summary
|
context: ./src/summary
|
||||||
dockerfile: Dockerfile
|
dockerfile: Dockerfile
|
||||||
target: production
|
target: production
|
||||||
command: celery -A summary.core.celery_worker worker --pool=solo --loglevel=debug -Q summarize-queue
|
command: celery -A summary.core.celery_worker worker --pool=solo --loglevel=debug -Q summarize-queue-v2
|
||||||
env_file:
|
env_file:
|
||||||
- env.d/development/summary
|
- env.d/development/summary
|
||||||
volumes:
|
volumes:
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
# ---- Front-end image ----
|
# ---- Front-end image ----
|
||||||
FROM node:20-alpine AS frontend-deps
|
FROM node:22-alpine AS frontend-deps
|
||||||
|
|
||||||
WORKDIR /home/frontend/
|
WORKDIR /home/frontend/
|
||||||
|
|
||||||
@@ -54,7 +54,7 @@ RUN npx webpack --mode production
|
|||||||
|
|
||||||
|
|
||||||
# ---- Front-end image ----
|
# ---- Front-end image ----
|
||||||
FROM nginxinc/nginx-unprivileged:alpine3.23 AS frontend-production
|
FROM nginxinc/nginx-unprivileged:1.30.3-alpine3.23 AS frontend-production
|
||||||
|
|
||||||
USER root
|
USER root
|
||||||
|
|
||||||
|
|||||||
@@ -65,8 +65,9 @@ ALLOW_UNREGISTERED_ROOMS=False
|
|||||||
RECORDING_ENABLE=True
|
RECORDING_ENABLE=True
|
||||||
RECORDING_STORAGE_EVENT_ENABLE=True
|
RECORDING_STORAGE_EVENT_ENABLE=True
|
||||||
RECORDING_STORAGE_EVENT_TOKEN=password
|
RECORDING_STORAGE_EVENT_TOKEN=password
|
||||||
SUMMARY_SERVICE_ENDPOINT=http://app-summary-dev:8000/api/v1/tasks/
|
SUMMARY_SERVICE_ENDPOINT=http://app-summary-dev:8000/api/v2/async-jobs/transcribe/
|
||||||
SUMMARY_SERVICE_API_TOKEN=password
|
SUMMARY_SERVICE_API_TOKEN=password
|
||||||
|
SUMMARY_SERVICE_WEBHOOK_API_TOKEN=webhook-password
|
||||||
RECORDING_DOWNLOAD_BASE_URL=http://localhost:3000/recording
|
RECORDING_DOWNLOAD_BASE_URL=http://localhost:3000/recording
|
||||||
|
|
||||||
# Recording encoding (LiveKit Egress advanced options).
|
# Recording encoding (LiveKit Egress advanced options).
|
||||||
|
|||||||
@@ -9,3 +9,6 @@ DEEPGRAM_API_KEY=
|
|||||||
|
|
||||||
KYUTAI_STT_BASE_URL=
|
KYUTAI_STT_BASE_URL=
|
||||||
KYUTAI_API_KEY=
|
KYUTAI_API_KEY=
|
||||||
|
|
||||||
|
SENTRY_DSN=
|
||||||
|
SENTRY_ENVIRONMENT=
|
||||||
|
|||||||
@@ -30,3 +30,4 @@ POSTHOG_ENABLED="False"
|
|||||||
|
|
||||||
# Transcription
|
# Transcription
|
||||||
TRANSCRIPTION_SATISFACTION_FORM_BASE_URL=
|
TRANSCRIPTION_SATISFACTION_FORM_BASE_URL=
|
||||||
|
AUTHORIZED_TENANTS='[{"id": "meet","api_key": "password","webhook_url": "https://configure-your-url.com/api/v1.0/recordings/external-process-hook/","webhook_api_key": "webhook-password","allowed_push_to_docs": true}]'
|
||||||
|
|||||||
@@ -32,6 +32,8 @@ from minio import Minio
|
|||||||
from minio.error import S3Error
|
from minio.error import S3Error
|
||||||
|
|
||||||
from exceptions import MissingConfigError
|
from exceptions import MissingConfigError
|
||||||
|
from observability import configure_sentry, set_job_context
|
||||||
|
from tasks import done_callback
|
||||||
|
|
||||||
load_dotenv()
|
load_dotenv()
|
||||||
|
|
||||||
@@ -42,6 +44,7 @@ AGENT_NAME = os.getenv("METADATA_COLLECTOR_AGENT_NAME", "metadata-collector")
|
|||||||
|
|
||||||
def prewarm(proc: JobProcess):
|
def prewarm(proc: JobProcess):
|
||||||
"""Preload voice activity detection model."""
|
"""Preload voice activity detection model."""
|
||||||
|
configure_sentry(AGENT_NAME)
|
||||||
proc.userdata["vad"] = silero.VAD.load()
|
proc.userdata["vad"] = silero.VAD.load()
|
||||||
|
|
||||||
|
|
||||||
@@ -174,7 +177,13 @@ class MetadataCollector:
|
|||||||
self.on_chat_message_received(reader, participant_identity)
|
self.on_chat_message_received(reader, participant_identity)
|
||||||
)
|
)
|
||||||
self._tasks.add(task)
|
self._tasks.add(task)
|
||||||
task.add_done_callback(lambda _: self._tasks.remove(task))
|
task.add_done_callback(
|
||||||
|
done_callback(
|
||||||
|
logger,
|
||||||
|
self._tasks,
|
||||||
|
f"process chat stream from {participant_identity}",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
def save(self):
|
def save(self):
|
||||||
"""Serialize collected events and upload as JSON to S3."""
|
"""Serialize collected events and upload as JSON to S3."""
|
||||||
@@ -270,16 +279,18 @@ class MetadataCollector:
|
|||||||
logger.info("Participant disconnected: %s", participant.identity)
|
logger.info("Participant disconnected: %s", participant.identity)
|
||||||
task = asyncio.create_task(self._close_session(session))
|
task = asyncio.create_task(self._close_session(session))
|
||||||
self._tasks.add(task)
|
self._tasks.add(task)
|
||||||
|
task.add_done_callback(
|
||||||
def on_close_done(_):
|
done_callback(
|
||||||
self._tasks.discard(task)
|
logger,
|
||||||
logger.info(
|
self._tasks,
|
||||||
"VAD session closed for %s (remaining sessions: %d)",
|
f"close VAD session for {participant.identity}",
|
||||||
participant.identity,
|
on_success=lambda _: logger.info(
|
||||||
len(self._sessions),
|
"VAD session closed for %s (remaining sessions: %d)",
|
||||||
|
participant.identity,
|
||||||
|
len(self._sessions),
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
)
|
||||||
task.add_done_callback(on_close_done)
|
|
||||||
|
|
||||||
def on_participant_name_changed(self, participant: rtc.RemoteParticipant):
|
def on_participant_name_changed(self, participant: rtc.RemoteParticipant):
|
||||||
"""Update stored participant name when it changes."""
|
"""Update stored participant name when it changes."""
|
||||||
@@ -360,6 +371,8 @@ async def handle_job_request(job_req: JobRequest) -> None:
|
|||||||
@server.rtc_session(agent_name=AGENT_NAME, on_request=handle_job_request)
|
@server.rtc_session(agent_name=AGENT_NAME, on_request=handle_job_request)
|
||||||
async def entrypoint(ctx: JobContext):
|
async def entrypoint(ctx: JobContext):
|
||||||
"""Initialize and run the metadata collector."""
|
"""Initialize and run the metadata collector."""
|
||||||
|
set_job_context(room=ctx.room.name, job_id=ctx.job.id)
|
||||||
|
|
||||||
logger.info("Starting metadata agent in room: %s", ctx.room.name)
|
logger.info("Starting metadata agent in room: %s", ctx.room.name)
|
||||||
recording_id = ctx.job.metadata
|
recording_id = ctx.job.metadata
|
||||||
metadata_collector = MetadataCollector(ctx, recording_id)
|
metadata_collector = MetadataCollector(ctx, recording_id)
|
||||||
@@ -377,4 +390,7 @@ async def entrypoint(ctx: JobContext):
|
|||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
# Initialize Sentry for the worker process. Each job runs in its own
|
||||||
|
# (forked) process and re-initializes Sentry via prewarm().
|
||||||
|
configure_sentry(AGENT_NAME)
|
||||||
cli.run_app(server)
|
cli.run_app(server)
|
||||||
|
|||||||
@@ -25,6 +25,9 @@ from livekit.agents import (
|
|||||||
)
|
)
|
||||||
from livekit.plugins import deepgram, silero
|
from livekit.plugins import deepgram, silero
|
||||||
|
|
||||||
|
from observability import configure_sentry, set_job_context
|
||||||
|
from tasks import done_callback
|
||||||
|
|
||||||
load_dotenv()
|
load_dotenv()
|
||||||
|
|
||||||
logger = logging.getLogger("transcriber")
|
logger = logging.getLogger("transcriber")
|
||||||
@@ -99,24 +102,29 @@ class MultiUserTranscriber:
|
|||||||
logger.info(f"starting session for {participant.identity}")
|
logger.info(f"starting session for {participant.identity}")
|
||||||
task = asyncio.create_task(self._start_session(participant))
|
task = asyncio.create_task(self._start_session(participant))
|
||||||
self._tasks.add(task)
|
self._tasks.add(task)
|
||||||
|
task.add_done_callback(
|
||||||
def on_task_done(task: asyncio.Task):
|
done_callback(
|
||||||
try:
|
logger,
|
||||||
self._sessions[participant.identity] = task.result()
|
self._tasks,
|
||||||
finally:
|
f"start transcription session for {participant.identity}",
|
||||||
self._tasks.discard(task)
|
)
|
||||||
|
)
|
||||||
task.add_done_callback(on_task_done)
|
|
||||||
|
|
||||||
def on_participant_disconnected(self, participant: rtc.RemoteParticipant):
|
def on_participant_disconnected(self, participant: rtc.RemoteParticipant):
|
||||||
"""Handle participant disconnection by closing transcription session."""
|
"""Handle participant disconnection by closing transcription session."""
|
||||||
if (session := self._sessions.pop(participant.identity)) is None:
|
if (session := self._sessions.pop(participant.identity, None)) is None:
|
||||||
return
|
return
|
||||||
|
|
||||||
logger.info(f"closing session for {participant.identity}")
|
logger.info(f"closing session for {participant.identity}")
|
||||||
task = asyncio.create_task(self._close_session(session))
|
task = asyncio.create_task(self._close_session(session))
|
||||||
self._tasks.add(task)
|
self._tasks.add(task)
|
||||||
task.add_done_callback(lambda _: self._tasks.discard(task))
|
task.add_done_callback(
|
||||||
|
done_callback(
|
||||||
|
logger,
|
||||||
|
self._tasks,
|
||||||
|
f"close transcription session for {participant.identity}",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
async def _start_session(self, participant: rtc.RemoteParticipant) -> AgentSession:
|
async def _start_session(self, participant: rtc.RemoteParticipant) -> AgentSession:
|
||||||
"""Create and start transcription session for participant."""
|
"""Create and start transcription session for participant."""
|
||||||
@@ -139,6 +147,7 @@ class MultiUserTranscriber:
|
|||||||
participant_identity=participant.identity,
|
participant_identity=participant.identity,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
self._sessions[participant.identity] = session
|
||||||
return session
|
return session
|
||||||
|
|
||||||
async def _close_session(self, sess: AgentSession) -> None:
|
async def _close_session(self, sess: AgentSession) -> None:
|
||||||
@@ -149,6 +158,8 @@ class MultiUserTranscriber:
|
|||||||
|
|
||||||
async def entrypoint(ctx: JobContext):
|
async def entrypoint(ctx: JobContext):
|
||||||
"""Initialize and run the multi-user transcriber."""
|
"""Initialize and run the multi-user transcriber."""
|
||||||
|
set_job_context(room=ctx.room.name, job_id=ctx.job.id)
|
||||||
|
|
||||||
transcriber = MultiUserTranscriber(ctx)
|
transcriber = MultiUserTranscriber(ctx)
|
||||||
transcriber.start()
|
transcriber.start()
|
||||||
|
|
||||||
@@ -193,11 +204,15 @@ async def handle_transcriber_job_request(job_req: JobRequest) -> None:
|
|||||||
|
|
||||||
def prewarm(proc: JobProcess):
|
def prewarm(proc: JobProcess):
|
||||||
"""Preload voice activity detection model."""
|
"""Preload voice activity detection model."""
|
||||||
|
configure_sentry(TRANSCRIBER_AGENT_NAME)
|
||||||
if ENABLE_SILERO_VAD:
|
if ENABLE_SILERO_VAD:
|
||||||
proc.userdata["vad"] = silero.VAD.load()
|
proc.userdata["vad"] = silero.VAD.load()
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
# Initialize Sentry for the worker process. Each job runs in its own
|
||||||
|
# (forked) process and re-initializes Sentry via prewarm().
|
||||||
|
configure_sentry(TRANSCRIBER_AGENT_NAME)
|
||||||
cli.run_app(
|
cli.run_app(
|
||||||
WorkerOptions(
|
WorkerOptions(
|
||||||
entrypoint_fnc=entrypoint,
|
entrypoint_fnc=entrypoint,
|
||||||
|
|||||||
@@ -0,0 +1,83 @@
|
|||||||
|
"""Sentry helpers for the LiveKit agents."""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import tomllib
|
||||||
|
from os import path
|
||||||
|
|
||||||
|
import sentry_sdk
|
||||||
|
from sentry_sdk.integrations.logging import LoggingIntegration
|
||||||
|
|
||||||
|
logger = logging.getLogger("observability")
|
||||||
|
|
||||||
|
BASE_DIR = path.dirname(path.abspath(__file__))
|
||||||
|
|
||||||
|
|
||||||
|
def get_release():
|
||||||
|
"""Get the current release of the application.
|
||||||
|
|
||||||
|
By release, we mean the ``version`` declared in ``pyproject.toml``.
|
||||||
|
If the file cannot be read or declares no version, it defaults to "NA".
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
with open(path.join(BASE_DIR, "pyproject.toml"), "rb") as pyproject:
|
||||||
|
return tomllib.load(pyproject)["project"]["version"]
|
||||||
|
except (FileNotFoundError, KeyError, tomllib.TOMLDecodeError):
|
||||||
|
return "NA" # Default: not available
|
||||||
|
|
||||||
|
|
||||||
|
def configure_sentry(agent_name: str) -> None:
|
||||||
|
"""Initialize Sentry for the current agent process.
|
||||||
|
|
||||||
|
No-op if ``SENTRY_DSN`` is not configured. Otherwise (re)initializes Sentry
|
||||||
|
unconditionally so the calling process gets its own live transport.
|
||||||
|
|
||||||
|
Must be called once per process: in the worker entrypoint and again in the
|
||||||
|
per-job ``prewarm``/``setup_fnc`` hook, because LiveKit runs each job in a
|
||||||
|
forked process. A forked child inherits the parent's initialized Sentry
|
||||||
|
client but not its background transport thread (threads do not survive
|
||||||
|
``fork()``), so it must re-init to get a working transport. For that reason,
|
||||||
|
do NOT guard this with ``sentry_sdk.is_initialized()``: the child inherits it
|
||||||
|
as ``True`` and would skip init, silently dropping every event.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
agent_name: Identifier of the agent, attached as a tag to Sentry issues
|
||||||
|
"""
|
||||||
|
# Read the DSN at call time so it picks up variables that load_dotenv()
|
||||||
|
# populated after this module was first imported.
|
||||||
|
sentry_dsn = os.getenv("SENTRY_DSN")
|
||||||
|
if not sentry_dsn:
|
||||||
|
logger.debug("SENTRY_DSN not defined for agent '%s'", agent_name)
|
||||||
|
return
|
||||||
|
|
||||||
|
sentry_sdk.init(
|
||||||
|
dsn=sentry_dsn,
|
||||||
|
environment=os.getenv("SENTRY_ENVIRONMENT"),
|
||||||
|
release=get_release(),
|
||||||
|
debug=False,
|
||||||
|
integrations=[
|
||||||
|
# Capture log records emitted at ERROR and above as Sentry events.
|
||||||
|
# This covers the agents' explicit logger.exception(...) calls as
|
||||||
|
# well as asyncio's "Exception in callback" / "Task exception was
|
||||||
|
# never retrieved" records, so unhandled task failures surface too.
|
||||||
|
LoggingIntegration(level=logging.INFO, event_level=logging.ERROR),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
sentry_sdk.set_tag("application", "agents")
|
||||||
|
sentry_sdk.set_tag("agent", agent_name)
|
||||||
|
|
||||||
|
logger.info("Sentry initialized for agent '%s' (pid %d)", agent_name, os.getpid())
|
||||||
|
|
||||||
|
|
||||||
|
def set_job_context(*, room: str | None = None, job_id: str | None = None) -> None:
|
||||||
|
"""Tag the current Sentry scope with the LiveKit job being handled.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
room: Name of the room the job is serving.
|
||||||
|
job_id: LiveKit job identifier.
|
||||||
|
"""
|
||||||
|
scope = sentry_sdk.get_current_scope()
|
||||||
|
if room is not None:
|
||||||
|
scope.set_tag("room", room)
|
||||||
|
if job_id is not None:
|
||||||
|
scope.set_tag("job_id", job_id)
|
||||||
@@ -1,21 +1,22 @@
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "agents"
|
name = "agents"
|
||||||
version = "1.22.0"
|
version = "1.23.0"
|
||||||
requires-python = ">=3.12"
|
requires-python = ">=3.12"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"livekit-agents==1.5.13",
|
"livekit-agents==1.6.4",
|
||||||
"livekit-plugins-deepgram==1.5.13",
|
"livekit-plugins-deepgram==1.6.4",
|
||||||
"livekit-plugins-silero==1.5.13",
|
"livekit-plugins-silero==1.6.4",
|
||||||
"livekit-plugins-kyutai-lasuite==0.0.6",
|
"livekit-plugins-kyutai-lasuite==0.0.6",
|
||||||
"python-dotenv==1.2.2",
|
"python-dotenv==1.2.2",
|
||||||
"protobuf==6.33.6",
|
"protobuf==6.33.6",
|
||||||
"minio==7.2.20"
|
"minio==7.2.20",
|
||||||
|
"sentry-sdk==2.60.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
[project.optional-dependencies]
|
[project.optional-dependencies]
|
||||||
dev = [
|
dev = [
|
||||||
"ruff==0.15.14",
|
"ruff==0.15.19",
|
||||||
]
|
]
|
||||||
|
|
||||||
[tool.uv]
|
[tool.uv]
|
||||||
|
|||||||
@@ -0,0 +1,42 @@
|
|||||||
|
"""Helpers for managing asyncio tasks."""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
from collections.abc import Callable
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
|
||||||
|
def done_callback(
|
||||||
|
logger: logging.Logger,
|
||||||
|
tasks: set[asyncio.Task],
|
||||||
|
description: str,
|
||||||
|
*,
|
||||||
|
on_success: Callable[[Any], None] | None = None,
|
||||||
|
) -> Callable[[asyncio.Task], None]:
|
||||||
|
"""Build a done-callback for a background task.
|
||||||
|
|
||||||
|
Meant to be passed to `asyncio.Task.add_done_callback`.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
logger: Logger used to report failures, so records keep the caller's
|
||||||
|
logger name.
|
||||||
|
tasks: Set the task was registered in; the task is discarded from it.
|
||||||
|
description: Human-readable intended action
|
||||||
|
on_success: Optional callback invoked with the task's result when it
|
||||||
|
completes without error.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
A callback suitable for ``task.add_done_callback(...)``.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def _finalize(task: asyncio.Task) -> None:
|
||||||
|
tasks.discard(task)
|
||||||
|
if task.cancelled():
|
||||||
|
return
|
||||||
|
if (exc := task.exception()) is not None:
|
||||||
|
logger.exception("failed to %s", description, exc_info=exc)
|
||||||
|
return
|
||||||
|
if on_success is not None:
|
||||||
|
on_success(task.result())
|
||||||
|
|
||||||
|
return _finalize
|
||||||
Generated
+936
-832
File diff suppressed because it is too large
Load Diff
@@ -11,7 +11,7 @@ from core.recording.event import notification
|
|||||||
|
|
||||||
from . import models
|
from . import models
|
||||||
from .tasks.file import process_file_deletion
|
from .tasks.file import process_file_deletion
|
||||||
from .utils import generate_download_file_url
|
from .utils import generate_download_s3_url
|
||||||
|
|
||||||
|
|
||||||
def hard_delete_file(file):
|
def hard_delete_file(file):
|
||||||
@@ -242,7 +242,7 @@ class FileAdmin(admin.ModelAdmin):
|
|||||||
"""Return a clickable preview URL for the file."""
|
"""Return a clickable preview URL for the file."""
|
||||||
if not obj.is_ready:
|
if not obj.is_ready:
|
||||||
return "-"
|
return "-"
|
||||||
url = generate_download_file_url(obj, expires_in=60 * 60)
|
url = generate_download_s3_url(obj.key, expires_in=60 * 60)
|
||||||
|
|
||||||
return format_html(
|
return format_html(
|
||||||
'<a href="{}" target="_blank" rel="noopener noreferrer">Open File</a>', url
|
'<a href="{}" target="_blank" rel="noopener noreferrer">Open File</a>', url
|
||||||
@@ -402,7 +402,14 @@ class RecordingAdmin(admin.ModelAdmin):
|
|||||||
"""Recording admin interface declaration."""
|
"""Recording admin interface declaration."""
|
||||||
|
|
||||||
inlines = (RecordingAccessInline,)
|
inlines = (RecordingAccessInline,)
|
||||||
search_fields = ["status", "=id", "worker_id", "room__slug", "=room__id"]
|
search_fields = [
|
||||||
|
"status",
|
||||||
|
"=id",
|
||||||
|
"worker_id",
|
||||||
|
"room__slug",
|
||||||
|
"=room__id",
|
||||||
|
"accesses__user__email",
|
||||||
|
]
|
||||||
list_display = (
|
list_display = (
|
||||||
"id",
|
"id",
|
||||||
"status",
|
"status",
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ from django.utils.module_loading import import_string
|
|||||||
|
|
||||||
from .base import AnalyticsBackend, NoOpAnalytics
|
from .base import AnalyticsBackend, NoOpAnalytics
|
||||||
from .events import AnalyticsEvent
|
from .events import AnalyticsEvent
|
||||||
|
from .user_feature_flags import UserFeatureFlag
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"get_analytics",
|
"get_analytics",
|
||||||
@@ -26,6 +27,8 @@ __all__ = [
|
|||||||
"capture",
|
"capture",
|
||||||
"AnalyticsBackend",
|
"AnalyticsBackend",
|
||||||
"AnalyticsEvent",
|
"AnalyticsEvent",
|
||||||
|
"is_user_feature_flag_enabled",
|
||||||
|
"UserFeatureFlag",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
@@ -57,3 +60,8 @@ def capture(
|
|||||||
) -> None:
|
) -> None:
|
||||||
"""Record an event performed by an identified user."""
|
"""Record an event performed by an identified user."""
|
||||||
analytics_instance.capture(user, event, properties)
|
analytics_instance.capture(user, event, properties)
|
||||||
|
|
||||||
|
|
||||||
|
def is_user_feature_flag_enabled(user, feature_name: UserFeatureFlag) -> bool:
|
||||||
|
"""Check if a feature is enabled at the user level."""
|
||||||
|
return analytics_instance.is_user_feature_enabled(user, feature_name)
|
||||||
|
|||||||
@@ -1,12 +1,14 @@
|
|||||||
"""Analytics backend protocol and default no-op implementation."""
|
"""Analytics backend protocol and default no-op implementation."""
|
||||||
|
|
||||||
from typing import Any, Protocol
|
from abc import ABC, abstractmethod
|
||||||
|
from typing import Any, Mapping
|
||||||
|
|
||||||
from ..models import User
|
from ..models import User
|
||||||
from .events import AnalyticsEvent
|
from .events import AnalyticsEvent
|
||||||
|
from .user_feature_flags import UserFeatureFlag
|
||||||
|
|
||||||
|
|
||||||
class AnalyticsBackend(Protocol):
|
class AnalyticsBackend(ABC):
|
||||||
"""
|
"""
|
||||||
Interface every analytics backend must implement.
|
Interface every analytics backend must implement.
|
||||||
|
|
||||||
@@ -17,11 +19,11 @@ class AnalyticsBackend(Protocol):
|
|||||||
ANALYTICS_BACKEND_SETTINGS = {"api_key": "...", "host": "..."}
|
ANALYTICS_BACKEND_SETTINGS = {"api_key": "...", "host": "..."}
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self, **kwargs: Any) -> None: ...
|
@abstractmethod
|
||||||
|
|
||||||
def identify(self, user: User, properties: dict[str, Any] | None = None) -> None:
|
def identify(self, user: User, properties: dict[str, Any] | None = None) -> None:
|
||||||
"""Associate traits (email, name, ...) with an identified user."""
|
"""Associate traits (email, name, ...) with an identified user."""
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
def capture(
|
def capture(
|
||||||
self,
|
self,
|
||||||
user: User,
|
user: User,
|
||||||
@@ -30,16 +32,29 @@ class AnalyticsBackend(Protocol):
|
|||||||
) -> None:
|
) -> None:
|
||||||
"""Record an event performed by an identified user."""
|
"""Record an event performed by an identified user."""
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
def shutdown(self) -> None:
|
def shutdown(self) -> None:
|
||||||
"""Flush pending events. Called on process exit."""
|
"""Flush pending events. Called on process exit."""
|
||||||
|
|
||||||
|
def get_user_feature_flags(
|
||||||
|
self,
|
||||||
|
user: User, # pylint: disable=unused-argument
|
||||||
|
) -> Mapping[UserFeatureFlag, bool | str | None]:
|
||||||
|
"""Return a dict of feature flags for the given user."""
|
||||||
|
# We return an empty dict here by default to avoid a breaking change
|
||||||
|
# By making this method abstract.
|
||||||
|
return {}
|
||||||
|
|
||||||
class NoOpAnalytics:
|
def is_user_feature_enabled(
|
||||||
|
self, user: User, feature_name: UserFeatureFlag
|
||||||
|
) -> bool:
|
||||||
|
"""Check if a feature is enabled at the user level."""
|
||||||
|
return self.get_user_feature_flags(user).get(feature_name, False) is True
|
||||||
|
|
||||||
|
|
||||||
|
class NoOpAnalytics(AnalyticsBackend):
|
||||||
"""Default backend: silently discards everything."""
|
"""Default backend: silently discards everything."""
|
||||||
|
|
||||||
def __init__(self, **kwargs: Any) -> None:
|
|
||||||
"""No-op: accepts and ignores any backend settings kwargs."""
|
|
||||||
|
|
||||||
def identify(self, user: User, properties=None) -> None:
|
def identify(self, user: User, properties=None) -> None:
|
||||||
"""No-op: discards identify calls."""
|
"""No-op: discards identify calls."""
|
||||||
|
|
||||||
|
|||||||
@@ -1,17 +1,21 @@
|
|||||||
"""PostHog implementation of the analytics backend protocol."""
|
"""PostHog implementation of the analytics backend protocol."""
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
from typing import Any
|
from typing import Any, Mapping
|
||||||
|
|
||||||
|
from django.core.cache import cache
|
||||||
|
|
||||||
from posthog import Posthog
|
from posthog import Posthog
|
||||||
|
|
||||||
from ..models import User
|
from ..models import User
|
||||||
|
from .base import AnalyticsBackend
|
||||||
from .events import AnalyticsEvent
|
from .events import AnalyticsEvent
|
||||||
|
from .user_feature_flags import UserFeatureFlag
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
class PostHogAnalytics:
|
class PostHogAnalytics(AnalyticsBackend):
|
||||||
"""Send events to PostHog, keyed on the user's primary key (UUID)."""
|
"""Send events to PostHog, keyed on the user's primary key (UUID)."""
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
@@ -19,6 +23,8 @@ class PostHogAnalytics:
|
|||||||
*,
|
*,
|
||||||
api_key: str,
|
api_key: str,
|
||||||
host: str = "https://eu.i.posthog.com",
|
host: str = "https://eu.i.posthog.com",
|
||||||
|
feature_flags_cache_ttl: int = 60,
|
||||||
|
feature_flags_cache_prefix: str = "user_feature_flags:",
|
||||||
**kwargs: Any,
|
**kwargs: Any,
|
||||||
) -> None:
|
) -> None:
|
||||||
|
|
||||||
@@ -29,6 +35,8 @@ class PostHogAnalytics:
|
|||||||
host=host,
|
host=host,
|
||||||
**kwargs,
|
**kwargs,
|
||||||
)
|
)
|
||||||
|
self._feature_flags_cache_ttl = feature_flags_cache_ttl
|
||||||
|
self._feature_flags_cache_prefix = feature_flags_cache_prefix
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _distinct_id(user: User) -> str | None:
|
def _distinct_id(user: User) -> str | None:
|
||||||
@@ -72,3 +80,37 @@ class PostHogAnalytics:
|
|||||||
def shutdown(self) -> None:
|
def shutdown(self) -> None:
|
||||||
"""Flush pending events. Called on process exit."""
|
"""Flush pending events. Called on process exit."""
|
||||||
self._client.shutdown()
|
self._client.shutdown()
|
||||||
|
|
||||||
|
def _fetch_user_feature_flags(
|
||||||
|
self, user: User
|
||||||
|
) -> Mapping[UserFeatureFlag, bool | str | None]:
|
||||||
|
"""Compute feature flags for a user."""
|
||||||
|
|
||||||
|
distinct_id = self._distinct_id(user)
|
||||||
|
if distinct_id is None:
|
||||||
|
return {}
|
||||||
|
|
||||||
|
flags = self._client.evaluate_flags(distinct_id)
|
||||||
|
out: dict[UserFeatureFlag, bool | str | None] = {}
|
||||||
|
for flag_key in UserFeatureFlag:
|
||||||
|
out[flag_key] = flags.get_flag(flag_key.value)
|
||||||
|
|
||||||
|
return out
|
||||||
|
|
||||||
|
def get_user_feature_flags(
|
||||||
|
self, user: User
|
||||||
|
) -> Mapping[UserFeatureFlag, bool | str | None]:
|
||||||
|
"""Get feature flags for a user. Caches the result for a short time."""
|
||||||
|
distinct_id = self._distinct_id(user)
|
||||||
|
if distinct_id is None:
|
||||||
|
return {}
|
||||||
|
|
||||||
|
try:
|
||||||
|
return cache.get_or_set(
|
||||||
|
f"{self._feature_flags_cache_prefix}{distinct_id}",
|
||||||
|
default=lambda: self._fetch_user_feature_flags(user),
|
||||||
|
timeout=self._feature_flags_cache_ttl,
|
||||||
|
)
|
||||||
|
except Exception: # pylint: disable=broad-exception-caught
|
||||||
|
logger.exception("Failed to get feature flags for user %s", user.pk)
|
||||||
|
return {}
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
"""Catalog of all analytics feature flags used by the backend."""
|
||||||
|
|
||||||
|
from enum import StrEnum
|
||||||
|
|
||||||
|
|
||||||
|
class UserFeatureFlag(StrEnum):
|
||||||
|
"""All feature flags configured in the app."""
|
||||||
|
|
||||||
|
TRANSCRIPT_SUMMARY_ENABLED = "summary-enabled"
|
||||||
@@ -68,6 +68,9 @@ def get_frontend_configuration(request):
|
|||||||
"enable_firefox_proxy_workaround": settings.LIVEKIT_ENABLE_FIREFOX_PROXY_WORKAROUND,
|
"enable_firefox_proxy_workaround": settings.LIVEKIT_ENABLE_FIREFOX_PROXY_WORKAROUND,
|
||||||
"default_sources": settings.LIVEKIT_DEFAULT_SOURCES,
|
"default_sources": settings.LIVEKIT_DEFAULT_SOURCES,
|
||||||
},
|
},
|
||||||
|
"authenticated_users_can_edit_display_name": (
|
||||||
|
settings.AUTHENTICATED_PARTICIPANTS_CAN_EDIT_DISPLAY_NAME
|
||||||
|
),
|
||||||
}
|
}
|
||||||
frontend_configuration.update(settings.FRONTEND_CONFIGURATION)
|
frontend_configuration.update(settings.FRONTEND_CONFIGURATION)
|
||||||
return Response(frontend_configuration)
|
return Response(frontend_configuration)
|
||||||
|
|||||||
@@ -563,3 +563,13 @@ class RenameParticipantSerializer(BaseValidationOnlySerializer):
|
|||||||
"""Serializer for renaming a participant in a room."""
|
"""Serializer for renaming a participant in a room."""
|
||||||
|
|
||||||
name = serializers.CharField(min_length=1, max_length=255, allow_blank=False)
|
name = serializers.CharField(min_length=1, max_length=255, allow_blank=False)
|
||||||
|
|
||||||
|
|
||||||
|
class ExternalProcessEventSerializer(BaseValidationOnlySerializer):
|
||||||
|
"""Validate external process event data."""
|
||||||
|
|
||||||
|
job_id = serializers.CharField(required=True)
|
||||||
|
# We are not strict on purpose on those fields to avoid
|
||||||
|
# useless bad requests
|
||||||
|
type = serializers.CharField(required=False, allow_null=True, allow_blank=True)
|
||||||
|
status = serializers.CharField(required=False, allow_null=True, allow_blank=True)
|
||||||
|
|||||||
@@ -39,7 +39,10 @@ from core import analytics, enums, models, utils
|
|||||||
from core.api.filters import ListFileFilter
|
from core.api.filters import ListFileFilter
|
||||||
from core.enums import MEDIA_STORAGE_URL_PATTERN
|
from core.enums import MEDIA_STORAGE_URL_PATTERN
|
||||||
from core.recording.enums import FileExtension
|
from core.recording.enums import FileExtension
|
||||||
from core.recording.event.authentication import StorageEventAuthentication
|
from core.recording.event.authentication import (
|
||||||
|
RecordingProcessWebhookAuthentication,
|
||||||
|
StorageEventAuthentication,
|
||||||
|
)
|
||||||
from core.recording.event.exceptions import (
|
from core.recording.event.exceptions import (
|
||||||
InvalidBucketError,
|
InvalidBucketError,
|
||||||
InvalidFilepathError,
|
InvalidFilepathError,
|
||||||
@@ -89,6 +92,7 @@ from core.services.subtitle import SubtitleException, SubtitleService
|
|||||||
from core.tasks.file import process_file_deletion
|
from core.tasks.file import process_file_deletion
|
||||||
|
|
||||||
from ..authentication.livekit import LiveKitTokenAuthentication
|
from ..authentication.livekit import LiveKitTokenAuthentication
|
||||||
|
from ..models import RoomAccessLevel
|
||||||
from . import permissions, serializers, throttling
|
from . import permissions, serializers, throttling
|
||||||
from .feature_flag import FeatureFlag
|
from .feature_flag import FeatureFlag
|
||||||
|
|
||||||
@@ -264,6 +268,9 @@ class RoomViewSet(
|
|||||||
username = request.query_params.get("username", None)
|
username = request.query_params.get("username", None)
|
||||||
data = {
|
data = {
|
||||||
"id": None,
|
"id": None,
|
||||||
|
"slug": slug,
|
||||||
|
"is_administrable": False,
|
||||||
|
"access_level": RoomAccessLevel.PUBLIC,
|
||||||
"livekit": {
|
"livekit": {
|
||||||
"url": settings.LIVEKIT_CONFIGURATION["url"],
|
"url": settings.LIVEKIT_CONFIGURATION["url"],
|
||||||
"room": slug,
|
"room": slug,
|
||||||
@@ -999,6 +1006,60 @@ class RecordingViewSet(
|
|||||||
{"message": "Event processed."},
|
{"message": "Event processed."},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@decorators.action(
|
||||||
|
detail=False,
|
||||||
|
methods=["post"],
|
||||||
|
url_path="external-process-hook",
|
||||||
|
authentication_classes=[RecordingProcessWebhookAuthentication],
|
||||||
|
serializer_class=serializers.ExternalProcessEventSerializer,
|
||||||
|
)
|
||||||
|
def on_external_process_event_received(self, request, pk=None): # pylint: disable=unused-argument
|
||||||
|
"""Handle incoming external process events for recordings."""
|
||||||
|
logger.debug("Processing external process event %s", request.data)
|
||||||
|
serializer = self.get_serializer(data=request.data)
|
||||||
|
serializer.is_valid(raise_exception=True)
|
||||||
|
|
||||||
|
ok_response = drf_response.Response(
|
||||||
|
{"message": "Event processed."},
|
||||||
|
)
|
||||||
|
|
||||||
|
validated_data = serializer.validated_data
|
||||||
|
job_id = validated_data["job_id"]
|
||||||
|
try:
|
||||||
|
recording = models.Recording.objects.get(external_process_id=job_id)
|
||||||
|
except models.Recording.DoesNotExist as e:
|
||||||
|
logger.warning("No recording found for job_id %s: %s", job_id, e)
|
||||||
|
return ok_response
|
||||||
|
|
||||||
|
if validated_data.get("type") == "transcript":
|
||||||
|
if validated_data.get("status") == "success":
|
||||||
|
logger.info(
|
||||||
|
"External process transcript success received for recording %s",
|
||||||
|
job_id,
|
||||||
|
)
|
||||||
|
recording.status = (
|
||||||
|
models.RecordingStatusChoices.EXTERNAL_PROCESS_SUCCESSFUL
|
||||||
|
)
|
||||||
|
recording.save()
|
||||||
|
return ok_response
|
||||||
|
|
||||||
|
if validated_data.get("status") == "failure":
|
||||||
|
logger.info(
|
||||||
|
"External process transcript failure received for recording %s",
|
||||||
|
job_id,
|
||||||
|
)
|
||||||
|
recording.status = models.RecordingStatusChoices.EXTERNAL_PROCESS_FAILED
|
||||||
|
recording.save()
|
||||||
|
return ok_response
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"No changes to save for external process id %s and payload %s",
|
||||||
|
job_id,
|
||||||
|
validated_data,
|
||||||
|
)
|
||||||
|
|
||||||
|
return ok_response
|
||||||
|
|
||||||
def _auth_get_original_url(self, request):
|
def _auth_get_original_url(self, request):
|
||||||
"""
|
"""
|
||||||
Extracts and parses the original URL from the "HTTP_X_ORIGINAL_URL" header.
|
Extracts and parses the original URL from the "HTTP_X_ORIGINAL_URL" header.
|
||||||
|
|||||||
@@ -215,5 +215,6 @@ class RoomViewSet(
|
|||||||
"client_id": client_id,
|
"client_id": client_id,
|
||||||
"external_api": True,
|
"external_api": True,
|
||||||
"auth_method": auth_method,
|
"auth_method": auth_method,
|
||||||
|
"$set": {"email": self.request.user.email},
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|||||||
+23
@@ -0,0 +1,23 @@
|
|||||||
|
# Generated by Django 5.2.14 on 2026-06-22 08:26
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('core', '0020_alter_file_upload_state'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='recording',
|
||||||
|
name='external_process_id',
|
||||||
|
field=models.CharField(blank=True, help_text='ID of the external process associated with the recording.', max_length=255, null=True, unique=True, verbose_name='External Process ID'),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='recording',
|
||||||
|
name='status',
|
||||||
|
field=models.CharField(choices=[('initiated', 'Initiated'), ('active', 'Active'), ('stopped', 'Stopped'), ('saved', 'Saved'), ('aborted', 'Aborted'), ('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),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -60,6 +60,11 @@ class RecordingStatusChoices(models.TextChoices):
|
|||||||
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")
|
||||||
|
EXTERNAL_PROCESS_SUCCESSFUL = (
|
||||||
|
"external_process_successful",
|
||||||
|
_("External process successful"),
|
||||||
|
)
|
||||||
|
EXTERNAL_PROCESS_FAILED = "external_process_failed", _("External process failed")
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def is_final(cls, status):
|
def is_final(cls, status):
|
||||||
@@ -73,6 +78,8 @@ class RecordingStatusChoices(models.TextChoices):
|
|||||||
cls.STOPPED,
|
cls.STOPPED,
|
||||||
cls.SAVED,
|
cls.SAVED,
|
||||||
cls.ABORTED,
|
cls.ABORTED,
|
||||||
|
cls.EXTERNAL_PROCESS_SUCCESSFUL,
|
||||||
|
cls.EXTERNAL_PROCESS_FAILED,
|
||||||
cls.FAILED_TO_START,
|
cls.FAILED_TO_START,
|
||||||
cls.FAILED_TO_STOP,
|
cls.FAILED_TO_STOP,
|
||||||
}
|
}
|
||||||
@@ -598,6 +605,14 @@ class Recording(BaseModel):
|
|||||||
verbose_name=_("Recording options"),
|
verbose_name=_("Recording options"),
|
||||||
help_text=_("Recording options"),
|
help_text=_("Recording options"),
|
||||||
)
|
)
|
||||||
|
external_process_id = models.CharField(
|
||||||
|
max_length=255,
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
unique=True,
|
||||||
|
verbose_name=_("External Process ID"),
|
||||||
|
help_text=_("ID of the external process associated with the recording."),
|
||||||
|
)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
db_table = "meet_recording"
|
db_table = "meet_recording"
|
||||||
@@ -653,6 +668,8 @@ class Recording(BaseModel):
|
|||||||
return self.status in {
|
return self.status in {
|
||||||
RecordingStatusChoices.NOTIFICATION_SUCCEEDED,
|
RecordingStatusChoices.NOTIFICATION_SUCCEEDED,
|
||||||
RecordingStatusChoices.SAVED,
|
RecordingStatusChoices.SAVED,
|
||||||
|
RecordingStatusChoices.EXTERNAL_PROCESS_SUCCESSFUL,
|
||||||
|
RecordingStatusChoices.EXTERNAL_PROCESS_FAILED,
|
||||||
}
|
}
|
||||||
|
|
||||||
@property
|
@property
|
||||||
|
|||||||
@@ -14,9 +14,9 @@ logger = logging.getLogger(__name__)
|
|||||||
class MachineUser:
|
class MachineUser:
|
||||||
"""Represent a non-interactive system user for automated storage operations."""
|
"""Represent a non-interactive system user for automated storage operations."""
|
||||||
|
|
||||||
def __init__(self) -> None:
|
def __init__(self, username: str = "storage_event_user") -> None:
|
||||||
self.pk = None
|
self.pk = None
|
||||||
self.username = "storage_event_user"
|
self.username = username
|
||||||
self.is_active = True
|
self.is_active = True
|
||||||
|
|
||||||
@property
|
@property
|
||||||
@@ -34,33 +34,33 @@ class MachineUser:
|
|||||||
return self.username
|
return self.username
|
||||||
|
|
||||||
|
|
||||||
class StorageEventAuthentication(BaseAuthentication):
|
class HeaderBasedAuthentication(BaseAuthentication):
|
||||||
"""Authenticate requests using a Bearer token for storage event integration.
|
"""Authenticate requests using a header with a secret key."""
|
||||||
This class validates Bearer tokens for storage events that don't map to database users.
|
|
||||||
It's designed for S3-compatible storage integrations and similar use cases.
|
|
||||||
Events are submitted when a webhook is configured on some bucket's events.
|
|
||||||
"""
|
|
||||||
|
|
||||||
AUTH_HEADER = "Authorization"
|
AUTH_HEADER = "Authorization"
|
||||||
TOKEN_TYPE = "Bearer" # noqa S105
|
TOKEN_TYPE = "Bearer" # noqa S105
|
||||||
|
REALM = ""
|
||||||
|
|
||||||
|
IS_ENFORCED_SETTINGS_KEY = None
|
||||||
|
EXPECTED_TOKEN_SETTINGS_KEY = None
|
||||||
|
|
||||||
def authenticate(self, request):
|
def authenticate(self, request):
|
||||||
"""Validate the Bearer token from the Authorization header."""
|
"""Validate the Bearer token from the Authorization header."""
|
||||||
|
|
||||||
if not settings.RECORDING_ENABLE_STORAGE_EVENT_AUTH:
|
if self.IS_ENFORCED_SETTINGS_KEY is not None:
|
||||||
return MachineUser(), None
|
if not getattr(settings, self.IS_ENFORCED_SETTINGS_KEY):
|
||||||
|
return MachineUser(), None
|
||||||
|
|
||||||
required_token = settings.RECORDING_STORAGE_EVENT_TOKEN
|
if (
|
||||||
if not required_token:
|
self.EXPECTED_TOKEN_SETTINGS_KEY is None
|
||||||
if settings.RECORDING_ENABLE_STORAGE_EVENT_AUTH:
|
or (required_token := getattr(settings, self.EXPECTED_TOKEN_SETTINGS_KEY))
|
||||||
raise AuthenticationFailed(
|
is None
|
||||||
"Authentication is enabled but token is not configured."
|
):
|
||||||
)
|
raise AuthenticationFailed(
|
||||||
|
"Authentication is enabled but token is not configured."
|
||||||
return MachineUser(), None
|
)
|
||||||
|
|
||||||
auth_header = request.headers.get(self.AUTH_HEADER)
|
auth_header = request.headers.get(self.AUTH_HEADER)
|
||||||
|
|
||||||
if not auth_header:
|
if not auth_header:
|
||||||
logger.warning(
|
logger.warning(
|
||||||
"Authentication failed: Missing Authorization header (ip: %s)",
|
"Authentication failed: Missing Authorization header (ip: %s)",
|
||||||
@@ -68,15 +68,10 @@ class StorageEventAuthentication(BaseAuthentication):
|
|||||||
)
|
)
|
||||||
raise AuthenticationFailed("Authorization header is required")
|
raise AuthenticationFailed("Authorization header is required")
|
||||||
|
|
||||||
auth_parts = auth_header.split(" ")
|
scheme, _, token = auth_header.partition(" ")
|
||||||
if len(auth_parts) != 2 or auth_parts[0] != self.TOKEN_TYPE:
|
if scheme.lower() != self.TOKEN_TYPE.lower() or not token.strip():
|
||||||
logger.warning(
|
raise AuthenticationFailed("Invalid authorization header format.")
|
||||||
"Authentication failed: Invalid authorization header (ip: %s)",
|
token = token.strip()
|
||||||
request.META.get("REMOTE_ADDR"),
|
|
||||||
)
|
|
||||||
raise AuthenticationFailed("Invalid authorization header.")
|
|
||||||
|
|
||||||
token = auth_parts[1]
|
|
||||||
|
|
||||||
# Use constant-time comparison to prevent timing attacks
|
# Use constant-time comparison to prevent timing attacks
|
||||||
if not secrets.compare_digest(token.encode(), required_token.encode()):
|
if not secrets.compare_digest(token.encode(), required_token.encode()):
|
||||||
@@ -90,4 +85,26 @@ class StorageEventAuthentication(BaseAuthentication):
|
|||||||
|
|
||||||
def authenticate_header(self, request):
|
def authenticate_header(self, request):
|
||||||
"""Return the WWW-Authenticate header value."""
|
"""Return the WWW-Authenticate header value."""
|
||||||
return f"{self.TOKEN_TYPE} realm='Storage event API'"
|
return f"{self.TOKEN_TYPE} realm='{self.REALM}'"
|
||||||
|
|
||||||
|
|
||||||
|
class StorageEventAuthentication(HeaderBasedAuthentication):
|
||||||
|
"""Authenticate requests using a Bearer token for storage event integration.
|
||||||
|
This class validates Bearer tokens for storage events that don't map to database users.
|
||||||
|
It's designed for S3-compatible storage integrations and similar use cases.
|
||||||
|
Events are submitted when a webhook is configured on some bucket's events.
|
||||||
|
"""
|
||||||
|
|
||||||
|
REALM = "Storage event API"
|
||||||
|
IS_ENFORCED_SETTINGS_KEY = "RECORDING_ENABLE_STORAGE_EVENT_AUTH"
|
||||||
|
EXPECTED_TOKEN_SETTINGS_KEY = "RECORDING_STORAGE_EVENT_TOKEN" # noqa S105
|
||||||
|
|
||||||
|
|
||||||
|
class RecordingProcessWebhookAuthentication(HeaderBasedAuthentication):
|
||||||
|
"""
|
||||||
|
Custom authentication class for recording process webhook requests.
|
||||||
|
Validates the API key in the Authorization header.
|
||||||
|
"""
|
||||||
|
|
||||||
|
REALM = "External process webhook API"
|
||||||
|
EXPECTED_TOKEN_SETTINGS_KEY = "SUMMARY_SERVICE_WEBHOOK_API_TOKEN" # noqa S105
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import asyncio
|
|||||||
import logging
|
import logging
|
||||||
import smtplib
|
import smtplib
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
|
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
|
||||||
|
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.core.mail import send_mail
|
from django.core.mail import send_mail
|
||||||
@@ -17,6 +18,8 @@ from asgiref.sync import async_to_sync
|
|||||||
from livekit import api as livekit_api
|
from livekit import api as livekit_api
|
||||||
|
|
||||||
from core import models, utils
|
from core import models, utils
|
||||||
|
from core.analytics import UserFeatureFlag, is_user_feature_flag_enabled
|
||||||
|
from core.utils import generate_download_s3_url
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -178,8 +181,49 @@ class NotificationService:
|
|||||||
|
|
||||||
return _ns_to_utc(file_result.started_at), _ns_to_utc(file_result.ended_at)
|
return _ns_to_utc(file_result.started_at), _ns_to_utc(file_result.ended_at)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _generate_title(
|
||||||
|
*,
|
||||||
|
locale: str,
|
||||||
|
room: str,
|
||||||
|
recording_datetime: datetime | None,
|
||||||
|
owner_timezone: str | None,
|
||||||
|
) -> str:
|
||||||
|
"""Generate title from context or return default."""
|
||||||
|
if recording_datetime is None:
|
||||||
|
with override(locale):
|
||||||
|
return _("Transcription")
|
||||||
|
|
||||||
|
dt = recording_datetime
|
||||||
|
if owner_timezone:
|
||||||
|
try:
|
||||||
|
dt = recording_datetime.astimezone(ZoneInfo(owner_timezone))
|
||||||
|
except (KeyError, ZoneInfoNotFoundError):
|
||||||
|
pass # Keep the original UTC datetime
|
||||||
|
|
||||||
|
with override(locale):
|
||||||
|
translated_template = _(
|
||||||
|
'Meeting "{room}" on {room_recording_date} at {room_recording_time}'
|
||||||
|
)
|
||||||
|
return translated_template.format(
|
||||||
|
room=room,
|
||||||
|
room_recording_date=dt.strftime("%Y-%m-%d"),
|
||||||
|
room_recording_time=dt.strftime("%H:%M"),
|
||||||
|
)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _notify_summary_service(recording: models.Recording):
|
def _notify_summary_service(recording: models.Recording):
|
||||||
|
if settings.SUMMARY_SERVICE_VERSION == 1:
|
||||||
|
return NotificationService._notify_summary_service_v1(recording)
|
||||||
|
if settings.SUMMARY_SERVICE_VERSION == 2:
|
||||||
|
return NotificationService._notify_summary_service_v2(recording)
|
||||||
|
|
||||||
|
raise NotImplementedError(
|
||||||
|
f"Unknown summary service version: {settings.SUMMARY_SERVICE_VERSION}"
|
||||||
|
)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _notify_summary_service_v1(recording: models.Recording):
|
||||||
"""Notify summary service about a new recording."""
|
"""Notify summary service about a new recording."""
|
||||||
|
|
||||||
if (
|
if (
|
||||||
@@ -253,5 +297,120 @@ class NotificationService:
|
|||||||
|
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _notify_summary_service_v2(recording: models.Recording):
|
||||||
|
"""Notify summary service about a new recording."""
|
||||||
|
|
||||||
|
if (
|
||||||
|
not settings.SUMMARY_SERVICE_ENDPOINT
|
||||||
|
or not settings.SUMMARY_SERVICE_API_TOKEN
|
||||||
|
):
|
||||||
|
logger.error("Summary service not configured")
|
||||||
|
return False
|
||||||
|
|
||||||
|
owner_access = (
|
||||||
|
models.RecordingAccess.objects.select_related("user")
|
||||||
|
.filter(
|
||||||
|
role=models.RoleChoices.OWNER,
|
||||||
|
recording_id=recording.id,
|
||||||
|
)
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
metadata_filename: None | str = None
|
||||||
|
if settings.METADATA_COLLECTOR_ENABLED and recording.options.get(
|
||||||
|
"collect_metadata", False
|
||||||
|
):
|
||||||
|
output_folder = settings.METADATA_COLLECTOR_OUTPUT_FOLDER
|
||||||
|
metadata_filename = f"{output_folder}/{recording.id}-metadata.json"
|
||||||
|
|
||||||
|
if not owner_access:
|
||||||
|
logger.error("No owner found for recording %s", recording.id)
|
||||||
|
return False
|
||||||
|
|
||||||
|
started_at, ended_at = async_to_sync(
|
||||||
|
NotificationService._get_recording_timestamps
|
||||||
|
)(recording.worker_id)
|
||||||
|
|
||||||
|
form_base_url = settings.TRANSCRIPTION_SATISFACTION_FORM_BASE_URL
|
||||||
|
form_link = (
|
||||||
|
f"{form_base_url}?room_id={recording.room.id}"
|
||||||
|
if (form_base_url and metadata_filename is not None)
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
metadata_payload = None
|
||||||
|
if started_at and ended_at and metadata_filename:
|
||||||
|
metadata_payload = {
|
||||||
|
"cloud_storage_url": generate_download_s3_url(
|
||||||
|
metadata_filename,
|
||||||
|
expires_in=settings.SUMMARY_SERVICE_CLOUD_STORAGE_SIGNED_URL_EXPIRY_SECONDS,
|
||||||
|
override_domain=False,
|
||||||
|
),
|
||||||
|
"started_at": started_at.isoformat(),
|
||||||
|
"ended_at": ended_at.isoformat(),
|
||||||
|
}
|
||||||
|
|
||||||
|
payload = {
|
||||||
|
"user_sub": owner_access.user.sub,
|
||||||
|
"user_email": owner_access.user.email,
|
||||||
|
"cloud_storage_url": generate_download_s3_url(
|
||||||
|
recording.key,
|
||||||
|
expires_in=settings.SUMMARY_SERVICE_CLOUD_STORAGE_SIGNED_URL_EXPIRY_SECONDS,
|
||||||
|
override_domain=False,
|
||||||
|
),
|
||||||
|
"language": recording.options.get(
|
||||||
|
"language", get_language().split("-")[0].lower()
|
||||||
|
),
|
||||||
|
"context_language": owner_access.user.language,
|
||||||
|
"push_to_docs_config": {
|
||||||
|
"user_email": owner_access.user.email,
|
||||||
|
"title": NotificationService._generate_title(
|
||||||
|
locale=owner_access.user.language
|
||||||
|
or recording.options.get("language", get_language()),
|
||||||
|
room=recording.room.name,
|
||||||
|
recording_datetime=started_at,
|
||||||
|
owner_timezone=str(owner_access.user.timezone),
|
||||||
|
),
|
||||||
|
"download_link": f"{get_recording_download_base_url()}/{recording.id}",
|
||||||
|
"form_link": form_link,
|
||||||
|
"auto_create_summary": is_user_feature_flag_enabled(
|
||||||
|
owner_access.user, UserFeatureFlag.TRANSCRIPT_SUMMARY_ENABLED
|
||||||
|
),
|
||||||
|
},
|
||||||
|
"metadata": metadata_payload,
|
||||||
|
}
|
||||||
|
|
||||||
|
headers = {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"Authorization": f"Bearer {settings.SUMMARY_SERVICE_API_TOKEN}",
|
||||||
|
}
|
||||||
|
|
||||||
|
try:
|
||||||
|
response = requests.post(
|
||||||
|
settings.SUMMARY_SERVICE_ENDPOINT,
|
||||||
|
json=payload,
|
||||||
|
headers=headers,
|
||||||
|
timeout=30,
|
||||||
|
)
|
||||||
|
response.raise_for_status()
|
||||||
|
response_json = response.json()
|
||||||
|
# We do not require a job_id to avoid a breaking change
|
||||||
|
job_id = response_json.get("job_id")
|
||||||
|
if not isinstance(job_id, str):
|
||||||
|
raise ValueError("job_id is not a string")
|
||||||
|
|
||||||
|
recording.external_process_id = job_id
|
||||||
|
recording.save()
|
||||||
|
|
||||||
|
except requests.RequestException as exc:
|
||||||
|
logger.exception(
|
||||||
|
"Summary service error for recording %s. URL: %s. Exception: %s",
|
||||||
|
recording.id,
|
||||||
|
settings.SUMMARY_SERVICE_ENDPOINT,
|
||||||
|
exc,
|
||||||
|
)
|
||||||
|
return False
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
notification_service = NotificationService()
|
notification_service = NotificationService()
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import pytest
|
|||||||
|
|
||||||
from core.analytics.events import AnalyticsEvent
|
from core.analytics.events import AnalyticsEvent
|
||||||
from core.analytics.posthog import PostHogAnalytics
|
from core.analytics.posthog import PostHogAnalytics
|
||||||
|
from core.analytics.user_feature_flags import UserFeatureFlag
|
||||||
from core.factories import UserFactory
|
from core.factories import UserFactory
|
||||||
|
|
||||||
pytestmark = pytest.mark.django_db
|
pytestmark = pytest.mark.django_db
|
||||||
@@ -248,6 +249,60 @@ def test_capture_logs_the_failing_event_name_on_exception(mock_posthog_cls, capl
|
|||||||
assert any("PostHog capture failed" in record.message for record in caplog.records)
|
assert any("PostHog capture failed" in record.message for record in caplog.records)
|
||||||
|
|
||||||
|
|
||||||
|
# ==============================
|
||||||
|
# feature flags
|
||||||
|
# ==============================
|
||||||
|
|
||||||
|
|
||||||
|
@patch("core.analytics.posthog.Posthog")
|
||||||
|
def test_compute_feature_flags_returns_all_catalog_entries(mock_posthog_cls):
|
||||||
|
"""Should map every declared feature flag key to the SDK evaluated value."""
|
||||||
|
backend = PostHogAnalytics(api_key="test-api-key")
|
||||||
|
user = UserFactory()
|
||||||
|
|
||||||
|
mock_posthog_cls.return_value.evaluate_flags.return_value.get_flag.return_value = (
|
||||||
|
True
|
||||||
|
)
|
||||||
|
|
||||||
|
flags = backend._fetch_user_feature_flags(user)
|
||||||
|
|
||||||
|
assert flags == {UserFeatureFlag.TRANSCRIPT_SUMMARY_ENABLED: True}
|
||||||
|
mock_posthog_cls.return_value.evaluate_flags.assert_called_once_with(str(user.pk))
|
||||||
|
mock_posthog_cls.return_value.evaluate_flags.return_value.get_flag.assert_called_once_with(
|
||||||
|
UserFeatureFlag.TRANSCRIPT_SUMMARY_ENABLED.value
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@patch("core.analytics.posthog.cache.get_or_set")
|
||||||
|
@patch("core.analytics.posthog.Posthog")
|
||||||
|
def test_get_feature_flags_uses_cache_get_or_set(
|
||||||
|
mock_posthog_cls, mock_cache_get_or_set
|
||||||
|
):
|
||||||
|
"""Should cache feature flags by user distinct id with configured TTL."""
|
||||||
|
cached_flags = {UserFeatureFlag.TRANSCRIPT_SUMMARY_ENABLED: False}
|
||||||
|
mock_cache_get_or_set.return_value = cached_flags
|
||||||
|
backend = PostHogAnalytics(api_key="test-api-key", feature_flags_cache_ttl=120)
|
||||||
|
user = UserFactory()
|
||||||
|
|
||||||
|
flags = backend.get_user_feature_flags(user)
|
||||||
|
|
||||||
|
assert flags == cached_flags
|
||||||
|
mock_cache_get_or_set.assert_called_once()
|
||||||
|
args, kwargs = mock_cache_get_or_set.call_args
|
||||||
|
assert kwargs["timeout"] == 120
|
||||||
|
assert args[0] == f"user_feature_flags:{user.pk}"
|
||||||
|
assert callable(kwargs["default"])
|
||||||
|
|
||||||
|
|
||||||
|
@patch("core.analytics.posthog.Posthog")
|
||||||
|
def test_get_feature_flags_returns_empty_dict_on_exception(mock_posthog_cls):
|
||||||
|
"""Should swallow failures and return an empty mapping."""
|
||||||
|
backend = PostHogAnalytics(api_key="test-api-key")
|
||||||
|
user = UserFactory()
|
||||||
|
with patch("core.analytics.posthog.cache.get_or_set", side_effect=RuntimeError):
|
||||||
|
assert backend.get_user_feature_flags(user) == {}
|
||||||
|
|
||||||
|
|
||||||
# ==============================
|
# ==============================
|
||||||
# shutdown
|
# shutdown
|
||||||
# ==============================
|
# ==============================
|
||||||
|
|||||||
@@ -136,10 +136,10 @@ def test_authenticate_header():
|
|||||||
|
|
||||||
|
|
||||||
def test_multiple_spaces_in_auth_header(settings):
|
def test_multiple_spaces_in_auth_header(settings):
|
||||||
"""Test failure when Authorization header contains multiple spaces."""
|
"""Test success when Authorization header contains multiple spaces."""
|
||||||
settings.RECORDING_STORAGE_EVENT_TOKEN = "valid-test-token"
|
settings.RECORDING_STORAGE_EVENT_TOKEN = "valid-test-token"
|
||||||
request = RequestFactory().get("/")
|
request = RequestFactory().get("/")
|
||||||
request.headers = {"Authorization": "Bearer extra-spaces-token"}
|
request.headers = {"Authorization": "Bearer extra-spaces-token"}
|
||||||
|
|
||||||
with pytest.raises(AuthenticationFailed, match="Invalid authorization header"):
|
header = StorageEventAuthentication().authenticate_header(request)
|
||||||
StorageEventAuthentication().authenticate(request)
|
assert header == "Bearer realm='Storage event API'"
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ from django.contrib.sites.models import Site
|
|||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from core import factories, models
|
from core import factories, models
|
||||||
|
from core.analytics import UserFeatureFlag
|
||||||
from core.recording.event.notification import NotificationService, notification_service
|
from core.recording.event.notification import NotificationService, notification_service
|
||||||
|
|
||||||
pytestmark = pytest.mark.django_db
|
pytestmark = pytest.mark.django_db
|
||||||
@@ -243,3 +244,177 @@ def test_notify_user_by_email_smtp_exception(mocked_current_site, caplog):
|
|||||||
assert result is False
|
assert result is False
|
||||||
assert mock_send_mail.call_count == 2
|
assert mock_send_mail.call_count == 2
|
||||||
assert "notification could not be sent:" in caplog.text
|
assert "notification could not be sent:" in caplog.text
|
||||||
|
|
||||||
|
|
||||||
|
@mock.patch("core.recording.event.notification.requests.post")
|
||||||
|
@mock.patch("core.recording.event.notification.generate_download_s3_url")
|
||||||
|
@mock.patch.object(
|
||||||
|
NotificationService, "_get_recording_timestamps", new_callable=mock.AsyncMock
|
||||||
|
)
|
||||||
|
def test_notify_summary_service_post_args_with_metadata(
|
||||||
|
mock_get_recording_timestamps,
|
||||||
|
mock_generate_download_s3_url,
|
||||||
|
mock_post,
|
||||||
|
settings,
|
||||||
|
):
|
||||||
|
"""Test summary notification computed request args when metadata is enabled."""
|
||||||
|
settings.SUMMARY_SERVICE_VERSION = 2
|
||||||
|
settings.SUMMARY_SERVICE_ENDPOINT = "https://summary.test/api/v2/tasks"
|
||||||
|
settings.SUMMARY_SERVICE_API_TOKEN = "summary-token"
|
||||||
|
settings.RECORDING_DOWNLOAD_BASE_URL = "https://app.test/recordings"
|
||||||
|
settings.SCREEN_RECORDING_BASE_URL = None
|
||||||
|
settings.METADATA_COLLECTOR_ENABLED = True
|
||||||
|
settings.METADATA_COLLECTOR_OUTPUT_FOLDER = "recordings-metadata"
|
||||||
|
|
||||||
|
recording = factories.RecordingFactory(
|
||||||
|
room__name="Engineering Sync",
|
||||||
|
worker_id="egress-1",
|
||||||
|
options={"collect_metadata": True, "language": "en-us"},
|
||||||
|
)
|
||||||
|
owner = factories.UserFactory(
|
||||||
|
email="owner@test.com",
|
||||||
|
sub="owner-sub",
|
||||||
|
language="fr-fr",
|
||||||
|
timezone="Europe/Paris",
|
||||||
|
)
|
||||||
|
factories.UserRecordingAccessFactory(
|
||||||
|
recording=recording, role=models.RoleChoices.OWNER, user=owner
|
||||||
|
)
|
||||||
|
|
||||||
|
started_at = datetime.datetime(2026, 1, 2, 10, 30, tzinfo=datetime.timezone.utc)
|
||||||
|
ended_at = datetime.datetime(2026, 1, 2, 11, 45, tzinfo=datetime.timezone.utc)
|
||||||
|
mock_get_recording_timestamps.return_value = (started_at, ended_at)
|
||||||
|
mock_generate_download_s3_url.side_effect = [
|
||||||
|
"https://storage.test/metadata.json",
|
||||||
|
"https://storage.test/recording.ogg",
|
||||||
|
]
|
||||||
|
|
||||||
|
mock_response = mock.Mock()
|
||||||
|
mock_response.raise_for_status.return_value = None
|
||||||
|
mock_response.json.return_value = {"job_id": "job-42"}
|
||||||
|
mock_post.return_value = mock_response
|
||||||
|
|
||||||
|
result = NotificationService._notify_summary_service(recording)
|
||||||
|
recording.refresh_from_db()
|
||||||
|
|
||||||
|
assert result is True
|
||||||
|
assert recording.external_process_id == "job-42"
|
||||||
|
|
||||||
|
metadata_filename = (
|
||||||
|
f"{settings.METADATA_COLLECTOR_OUTPUT_FOLDER}/{recording.id}-metadata.json"
|
||||||
|
)
|
||||||
|
expected_payload = {
|
||||||
|
"user_sub": owner.sub,
|
||||||
|
"user_email": owner.email,
|
||||||
|
"cloud_storage_url": "https://storage.test/recording.ogg",
|
||||||
|
"language": "en-us",
|
||||||
|
"context_language": owner.language,
|
||||||
|
"push_to_docs_config": {
|
||||||
|
"user_email": owner.email,
|
||||||
|
"title": 'Réunion "Engineering Sync" du 2026-01-02 à 11:30',
|
||||||
|
"download_link": f"{settings.RECORDING_DOWNLOAD_BASE_URL}/{recording.id}",
|
||||||
|
"auto_create_summary": False,
|
||||||
|
"form_link": None,
|
||||||
|
},
|
||||||
|
"metadata": {
|
||||||
|
"cloud_storage_url": "https://storage.test/metadata.json",
|
||||||
|
"started_at": started_at.isoformat(),
|
||||||
|
"ended_at": ended_at.isoformat(),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
expected_headers = {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"Authorization": "Bearer summary-token",
|
||||||
|
}
|
||||||
|
mock_post.assert_called_once_with(
|
||||||
|
"https://summary.test/api/v2/tasks",
|
||||||
|
json=expected_payload,
|
||||||
|
headers=expected_headers,
|
||||||
|
timeout=30,
|
||||||
|
)
|
||||||
|
assert mock_generate_download_s3_url.call_args_list == [
|
||||||
|
mock.call(metadata_filename, expires_in=60 * 60 * 24, override_domain=False),
|
||||||
|
mock.call(recording.key, expires_in=60 * 60 * 24, override_domain=False),
|
||||||
|
]
|
||||||
|
mock_get_recording_timestamps.assert_awaited_once_with("egress-1")
|
||||||
|
|
||||||
|
|
||||||
|
@mock.patch("core.recording.event.notification.requests.post")
|
||||||
|
@mock.patch("core.recording.event.notification.generate_download_s3_url")
|
||||||
|
@mock.patch.object(
|
||||||
|
NotificationService, "_get_recording_timestamps", new_callable=mock.AsyncMock
|
||||||
|
)
|
||||||
|
@pytest.mark.parametrize("auto_create_summary_enabled", [False, True])
|
||||||
|
def test_notify_summary_service_post_args_without_metadata(
|
||||||
|
mock_get_recording_timestamps,
|
||||||
|
mock_generate_download_s3_url,
|
||||||
|
mock_post,
|
||||||
|
auto_create_summary_enabled,
|
||||||
|
settings,
|
||||||
|
):
|
||||||
|
"""Test summary notification computed request args when metadata is not available."""
|
||||||
|
settings.SUMMARY_SERVICE_VERSION = 2
|
||||||
|
settings.SUMMARY_SERVICE_ENDPOINT = "https://summary.test/api/v2/tasks"
|
||||||
|
settings.SUMMARY_SERVICE_API_TOKEN = "summary-token"
|
||||||
|
settings.RECORDING_DOWNLOAD_BASE_URL = "https://app.test/recordings"
|
||||||
|
settings.SCREEN_RECORDING_BASE_URL = None
|
||||||
|
settings.METADATA_COLLECTOR_ENABLED = False
|
||||||
|
|
||||||
|
recording = factories.RecordingFactory(room__name="Daily")
|
||||||
|
owner = factories.UserFactory(
|
||||||
|
email="owner@test.com",
|
||||||
|
sub="owner-sub",
|
||||||
|
language="en-us",
|
||||||
|
timezone="UTC",
|
||||||
|
)
|
||||||
|
factories.UserRecordingAccessFactory(
|
||||||
|
recording=recording, role=models.RoleChoices.OWNER, user=owner
|
||||||
|
)
|
||||||
|
|
||||||
|
mock_get_recording_timestamps.return_value = (None, None)
|
||||||
|
mock_generate_download_s3_url.return_value = "https://storage.test/recording.mp4"
|
||||||
|
|
||||||
|
mock_response = mock.Mock()
|
||||||
|
mock_response.raise_for_status.return_value = None
|
||||||
|
mock_response.json.return_value = {"job_id": "job-51"}
|
||||||
|
mock_post.return_value = mock_response
|
||||||
|
|
||||||
|
with mock.patch(
|
||||||
|
"core.recording.event.notification.is_user_feature_flag_enabled",
|
||||||
|
return_value=auto_create_summary_enabled,
|
||||||
|
) as mock_is_feature_flag_enabled:
|
||||||
|
result = NotificationService._notify_summary_service(recording)
|
||||||
|
|
||||||
|
assert result is True
|
||||||
|
expected_payload = {
|
||||||
|
"user_sub": owner.sub,
|
||||||
|
"user_email": owner.email,
|
||||||
|
"cloud_storage_url": "https://storage.test/recording.mp4",
|
||||||
|
"language": "en",
|
||||||
|
"context_language": owner.language,
|
||||||
|
"push_to_docs_config": {
|
||||||
|
"user_email": owner.email,
|
||||||
|
"title": "Transcription",
|
||||||
|
"download_link": f"{settings.RECORDING_DOWNLOAD_BASE_URL}/{recording.id}",
|
||||||
|
"auto_create_summary": auto_create_summary_enabled,
|
||||||
|
"form_link": None,
|
||||||
|
},
|
||||||
|
"metadata": None,
|
||||||
|
}
|
||||||
|
expected_headers = {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"Authorization": "Bearer summary-token",
|
||||||
|
}
|
||||||
|
mock_post.assert_called_once_with(
|
||||||
|
"https://summary.test/api/v2/tasks",
|
||||||
|
json=expected_payload,
|
||||||
|
headers=expected_headers,
|
||||||
|
timeout=30,
|
||||||
|
)
|
||||||
|
mock_generate_download_s3_url.assert_called_once_with(
|
||||||
|
recording.key, expires_in=60 * 60 * 24, override_domain=False
|
||||||
|
)
|
||||||
|
mock_get_recording_timestamps.assert_awaited_once_with(recording.worker_id)
|
||||||
|
mock_is_feature_flag_enabled.assert_called_once_with(
|
||||||
|
owner, UserFeatureFlag.TRANSCRIPT_SUMMARY_ENABLED
|
||||||
|
)
|
||||||
|
|||||||
@@ -0,0 +1,134 @@
|
|||||||
|
"""
|
||||||
|
Test recordings API endpoints: external process hook.
|
||||||
|
"""
|
||||||
|
|
||||||
|
# pylint: disable=redefined-outer-name,unused-argument
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from ...factories import RecordingFactory
|
||||||
|
from ...models import RecordingStatusChoices
|
||||||
|
|
||||||
|
pytestmark = pytest.mark.django_db
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def external_process_settings(settings):
|
||||||
|
"""Configure authentication token for the external process webhook."""
|
||||||
|
settings.SUMMARY_SERVICE_WEBHOOK_API_TOKEN = "testWebhookToken"
|
||||||
|
return settings
|
||||||
|
|
||||||
|
|
||||||
|
def test_external_process_event_missing_authorization_header(
|
||||||
|
external_process_settings, client
|
||||||
|
):
|
||||||
|
"""Requests without authorization must be rejected."""
|
||||||
|
response = client.post(
|
||||||
|
"/api/v1.0/recordings/external-process-hook/",
|
||||||
|
{"job_id": "job-1", "type": "transcript", "status": "success"},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 401
|
||||||
|
|
||||||
|
|
||||||
|
def test_external_process_event_wrong_bearer_token(external_process_settings, client):
|
||||||
|
"""Requests with invalid bearer token must be rejected."""
|
||||||
|
response = client.post(
|
||||||
|
"/api/v1.0/recordings/external-process-hook/",
|
||||||
|
{"job_id": "job-1", "type": "transcript", "status": "success"},
|
||||||
|
HTTP_AUTHORIZATION="Bearer wrongToken",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 401
|
||||||
|
|
||||||
|
|
||||||
|
def test_external_process_event_missing_job_id(external_process_settings, client):
|
||||||
|
"""Payload without job_id must fail validation."""
|
||||||
|
response = client.post(
|
||||||
|
"/api/v1.0/recordings/external-process-hook/",
|
||||||
|
{"type": "transcript", "status": "success"},
|
||||||
|
HTTP_AUTHORIZATION="Bearer testWebhookToken",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 400
|
||||||
|
assert response.json() == {"job_id": ["This field is required."]}
|
||||||
|
|
||||||
|
|
||||||
|
def test_external_process_event_success_updates_recording_status(
|
||||||
|
external_process_settings, client
|
||||||
|
):
|
||||||
|
"""A successful transcript process should update recording status."""
|
||||||
|
recording = RecordingFactory(
|
||||||
|
status=RecordingStatusChoices.SAVED,
|
||||||
|
external_process_id="job-123",
|
||||||
|
)
|
||||||
|
|
||||||
|
response = client.post(
|
||||||
|
"/api/v1.0/recordings/external-process-hook/",
|
||||||
|
{"job_id": "job-123", "type": "transcript", "status": "success"},
|
||||||
|
HTTP_AUTHORIZATION="Bearer testWebhookToken",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.json() == {"message": "Event processed."}
|
||||||
|
|
||||||
|
recording.refresh_from_db()
|
||||||
|
assert recording.status == RecordingStatusChoices.EXTERNAL_PROCESS_SUCCESSFUL
|
||||||
|
|
||||||
|
|
||||||
|
def test_external_process_event_failure_updates_recording_status(
|
||||||
|
external_process_settings, client
|
||||||
|
):
|
||||||
|
"""A failing transcript process should update recording status."""
|
||||||
|
recording = RecordingFactory(
|
||||||
|
status=RecordingStatusChoices.SAVED,
|
||||||
|
external_process_id="job-456",
|
||||||
|
)
|
||||||
|
|
||||||
|
response = client.post(
|
||||||
|
"/api/v1.0/recordings/external-process-hook/",
|
||||||
|
{"job_id": "job-456", "type": "transcript", "status": "failure"},
|
||||||
|
HTTP_AUTHORIZATION="Bearer testWebhookToken",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.json() == {"message": "Event processed."}
|
||||||
|
|
||||||
|
recording.refresh_from_db()
|
||||||
|
assert recording.status == RecordingStatusChoices.EXTERNAL_PROCESS_FAILED
|
||||||
|
|
||||||
|
|
||||||
|
def test_external_process_event_unknown_recording_is_ignored(
|
||||||
|
external_process_settings, client
|
||||||
|
):
|
||||||
|
"""Unknown job_id should not fail the webhook processing."""
|
||||||
|
response = client.post(
|
||||||
|
"/api/v1.0/recordings/external-process-hook/",
|
||||||
|
{"job_id": "missing-job", "type": "transcript", "status": "success"},
|
||||||
|
HTTP_AUTHORIZATION="Bearer testWebhookToken",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.json() == {"message": "Event processed."}
|
||||||
|
|
||||||
|
|
||||||
|
def test_external_process_event_non_transcript_event_does_not_change_status(
|
||||||
|
external_process_settings, client
|
||||||
|
):
|
||||||
|
"""Only transcript events should update recording status."""
|
||||||
|
recording = RecordingFactory(
|
||||||
|
status=RecordingStatusChoices.SAVED,
|
||||||
|
external_process_id="job-789",
|
||||||
|
)
|
||||||
|
|
||||||
|
response = client.post(
|
||||||
|
"/api/v1.0/recordings/external-process-hook/",
|
||||||
|
{"job_id": "job-789", "type": "thumbnail", "status": "success"},
|
||||||
|
HTTP_AUTHORIZATION="Bearer testWebhookToken",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.json() == {"message": "Event processed."}
|
||||||
|
|
||||||
|
recording.refresh_from_db()
|
||||||
|
assert recording.status == RecordingStatusChoices.SAVED
|
||||||
@@ -130,6 +130,9 @@ def test_api_rooms_retrieve_anonymous_unregistered_allowed(mock_token):
|
|||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
assert response.json() == {
|
assert response.json() == {
|
||||||
"id": None,
|
"id": None,
|
||||||
|
"slug": "unregistered-room",
|
||||||
|
"access_level": "public",
|
||||||
|
"is_administrable": False,
|
||||||
"livekit": {
|
"livekit": {
|
||||||
"url": "test_url_value",
|
"url": "test_url_value",
|
||||||
"room": "unregistered-room",
|
"room": "unregistered-room",
|
||||||
@@ -162,6 +165,9 @@ def test_api_rooms_retrieve_anonymous_unregistered_allowed_not_normalized(mock_t
|
|||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
assert response.json() == {
|
assert response.json() == {
|
||||||
"id": None,
|
"id": None,
|
||||||
|
"slug": "reunion",
|
||||||
|
"access_level": "public",
|
||||||
|
"is_administrable": False,
|
||||||
"livekit": {
|
"livekit": {
|
||||||
"url": "test_url_value",
|
"url": "test_url_value",
|
||||||
"room": "reunion",
|
"room": "reunion",
|
||||||
|
|||||||
@@ -2,13 +2,109 @@
|
|||||||
Test utils functions
|
Test utils functions
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
# pylint: disable=W0621
|
||||||
import json
|
import json
|
||||||
from unittest import mock
|
from unittest import mock
|
||||||
|
|
||||||
|
from django.conf import settings
|
||||||
|
from django.contrib.auth.models import AnonymousUser
|
||||||
|
|
||||||
|
import jwt
|
||||||
import pytest
|
import pytest
|
||||||
from livekit.api import TwirpError
|
from livekit.api import TwirpError
|
||||||
|
|
||||||
from core.utils import NotificationError, create_livekit_client, notify_participants
|
from core.factories import UserFactory
|
||||||
|
from core.utils import (
|
||||||
|
NotificationError,
|
||||||
|
create_livekit_client,
|
||||||
|
generate_token,
|
||||||
|
notify_participants,
|
||||||
|
)
|
||||||
|
|
||||||
|
pytestmark = pytest.mark.django_db
|
||||||
|
|
||||||
|
|
||||||
|
def decode_token(token: str) -> dict:
|
||||||
|
"""Decode a LiveKit JWT access token for inspection."""
|
||||||
|
return jwt.decode(
|
||||||
|
token,
|
||||||
|
settings.LIVEKIT_CONFIGURATION["api_secret"],
|
||||||
|
algorithms=["HS256"],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_generate_token_authenticated_uses_full_name():
|
||||||
|
"""The token's display name should default to the user's full name."""
|
||||||
|
user = UserFactory(full_name="Jane Doe")
|
||||||
|
|
||||||
|
token = generate_token(room="my-room", user=user)
|
||||||
|
|
||||||
|
claims = decode_token(token)
|
||||||
|
assert claims["name"] == "Jane Doe"
|
||||||
|
assert claims["sub"] == str(user.sub)
|
||||||
|
|
||||||
|
|
||||||
|
def test_generate_token_authenticated_fallback_user_representation():
|
||||||
|
"""
|
||||||
|
When the user has no full name, the token's display name should fall back
|
||||||
|
to the user's string representation.
|
||||||
|
"""
|
||||||
|
user = UserFactory(full_name=None)
|
||||||
|
|
||||||
|
token = generate_token(room="my-room", user=user)
|
||||||
|
|
||||||
|
claims = decode_token(token)
|
||||||
|
assert claims["name"] == str(user)
|
||||||
|
|
||||||
|
|
||||||
|
def test_generate_token_explicit_username_overrides_default():
|
||||||
|
"""An explicitly provided username should take precedence over the full name."""
|
||||||
|
user = UserFactory(full_name="Jane Doe")
|
||||||
|
|
||||||
|
token = generate_token(room="my-room", user=user, username="Custom Name")
|
||||||
|
|
||||||
|
claims = decode_token(token)
|
||||||
|
assert claims["name"] == "Custom Name"
|
||||||
|
|
||||||
|
|
||||||
|
def test_authenticated_username_ignored_when_editing_disabled(settings):
|
||||||
|
"""With editing disabled, an authenticated user's username is ignored."""
|
||||||
|
settings.AUTHENTICATED_PARTICIPANTS_CAN_EDIT_DISPLAY_NAME = False
|
||||||
|
user = UserFactory(full_name="Jane Doe")
|
||||||
|
token = generate_token(room="my-room", user=user, username="Custom Name")
|
||||||
|
claims = decode_token(token)
|
||||||
|
assert claims["name"] == "Jane Doe"
|
||||||
|
|
||||||
|
|
||||||
|
def test_authenticated_default_name_unaffected_when_editing_disabled(settings):
|
||||||
|
"""Disabling editing doesn't disturb the default full-name path."""
|
||||||
|
settings.AUTHENTICATED_PARTICIPANTS_CAN_EDIT_DISPLAY_NAME = False
|
||||||
|
user = UserFactory(full_name="Jane Doe")
|
||||||
|
token = generate_token(room="my-room", user=user)
|
||||||
|
claims = decode_token(token)
|
||||||
|
assert claims["name"] == "Jane Doe"
|
||||||
|
|
||||||
|
|
||||||
|
def test_anonymous_uses_username_when_provided():
|
||||||
|
"""An anonymous user's provided username is used as the display name."""
|
||||||
|
token = generate_token(room="my-room", user=AnonymousUser(), username="Guest42")
|
||||||
|
claims = decode_token(token)
|
||||||
|
assert claims["name"] == "Guest42"
|
||||||
|
|
||||||
|
|
||||||
|
def test_anonymous_username_used_even_when_editing_disabled(settings):
|
||||||
|
"""The setting governs authenticated users only; anonymous can still set a name."""
|
||||||
|
settings.AUTHENTICATED_PARTICIPANTS_CAN_EDIT_DISPLAY_NAME = False
|
||||||
|
token = generate_token(room="my-room", user=AnonymousUser(), username="Guest42")
|
||||||
|
claims = decode_token(token)
|
||||||
|
assert claims["name"] == "Guest42"
|
||||||
|
|
||||||
|
|
||||||
|
def test_anonymous_falls_back_to_anonymous_label():
|
||||||
|
"""With no username, an anonymous user is labelled 'Anonymous'."""
|
||||||
|
token = generate_token(room="my-room", user=AnonymousUser())
|
||||||
|
claims = decode_token(token)
|
||||||
|
assert claims["name"] == "Anonymous"
|
||||||
|
|
||||||
|
|
||||||
@mock.patch("asyncio.get_running_loop")
|
@mock.patch("asyncio.get_running_loop")
|
||||||
|
|||||||
@@ -109,11 +109,16 @@ def generate_token(
|
|||||||
default_username = "Anonymous"
|
default_username = "Anonymous"
|
||||||
else:
|
else:
|
||||||
identity = str(user.sub)
|
identity = str(user.sub)
|
||||||
default_username = str(user)
|
default_username = user.full_name or str(user)
|
||||||
|
|
||||||
if color is None:
|
if color is None:
|
||||||
color = generate_color(identity)
|
color = generate_color(identity)
|
||||||
|
|
||||||
|
can_edit = (
|
||||||
|
settings.AUTHENTICATED_PARTICIPANTS_CAN_EDIT_DISPLAY_NAME or user.is_anonymous
|
||||||
|
)
|
||||||
|
display_name = (username or default_username) if can_edit else default_username
|
||||||
|
|
||||||
token = (
|
token = (
|
||||||
AccessToken(
|
AccessToken(
|
||||||
api_key=settings.LIVEKIT_CONFIGURATION["api_key"],
|
api_key=settings.LIVEKIT_CONFIGURATION["api_key"],
|
||||||
@@ -121,7 +126,7 @@ def generate_token(
|
|||||||
)
|
)
|
||||||
.with_grants(video_grants)
|
.with_grants(video_grants)
|
||||||
.with_identity(identity)
|
.with_identity(identity)
|
||||||
.with_name(username or default_username)
|
.with_name(display_name)
|
||||||
.with_attributes(
|
.with_attributes(
|
||||||
{"color": color, "room_admin": "true" if is_admin_or_owner else "false"}
|
{"color": color, "room_admin": "true" if is_admin_or_owner else "false"}
|
||||||
)
|
)
|
||||||
@@ -459,12 +464,14 @@ def generate_upload_policy(file):
|
|||||||
return policy
|
return policy
|
||||||
|
|
||||||
|
|
||||||
def generate_download_file_url(file, *, expires_in: int, override_domain: bool = True):
|
def generate_download_s3_url(
|
||||||
|
key: str, *, expires_in: int, override_domain: bool = True
|
||||||
|
):
|
||||||
"""
|
"""
|
||||||
Generate a S3 signed download url for a given file.
|
Generate a S3 signed download url for a given key.
|
||||||
"""
|
"""
|
||||||
|
if not key:
|
||||||
key = file.file_key
|
raise ValueError("key cannot be empty")
|
||||||
|
|
||||||
# This setting should be used if the backend application and the frontend application
|
# This setting should be used if the backend application and the frontend application
|
||||||
# can't connect to the object storage with the same domain. This is the case in the
|
# can't connect to the object storage with the same domain. This is the case in the
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ msgid ""
|
|||||||
msgstr ""
|
msgstr ""
|
||||||
"Project-Id-Version: PACKAGE VERSION\n"
|
"Project-Id-Version: PACKAGE VERSION\n"
|
||||||
"Report-Msgid-Bugs-To: \n"
|
"Report-Msgid-Bugs-To: \n"
|
||||||
"POT-Creation-Date: 2026-04-21 14:09+0000\n"
|
"POT-Creation-Date: 2026-07-02 10:47+0000\n"
|
||||||
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
|
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
|
||||||
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
|
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
|
||||||
"Language-Team: LANGUAGE <LL@li.org>\n"
|
"Language-Team: LANGUAGE <LL@li.org>\n"
|
||||||
@@ -17,70 +17,92 @@ msgstr ""
|
|||||||
"Content-Type: text/plain; charset=UTF-8\n"
|
"Content-Type: text/plain; charset=UTF-8\n"
|
||||||
"Content-Transfer-Encoding: 8bit\n"
|
"Content-Transfer-Encoding: 8bit\n"
|
||||||
|
|
||||||
#: core/admin.py:29
|
#: core/admin.py:67
|
||||||
msgid "Personal info"
|
msgid "Personal info"
|
||||||
msgstr "Persönliche Informationen"
|
msgstr "Persönliche Informationen"
|
||||||
|
|
||||||
#: core/admin.py:42
|
#: core/admin.py:80
|
||||||
msgid "Permissions"
|
msgid "Permissions"
|
||||||
msgstr "Berechtigungen"
|
msgstr "Berechtigungen"
|
||||||
|
|
||||||
#: core/admin.py:54
|
#: core/admin.py:92
|
||||||
msgid "Important dates"
|
msgid "Important dates"
|
||||||
msgstr "Wichtige Daten"
|
msgstr "Wichtige Daten"
|
||||||
|
|
||||||
#: core/admin.py:132 core/admin.py:275
|
#: core/admin.py:206
|
||||||
|
msgid "Content"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: core/admin.py:217
|
||||||
|
#, fuzzy
|
||||||
|
#| msgid "Delete rooms"
|
||||||
|
msgid "Deletion"
|
||||||
|
msgstr "Räume löschen"
|
||||||
|
|
||||||
|
#: core/admin.py:226
|
||||||
|
msgid "Derived info"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: core/admin.py:237
|
||||||
|
msgid "Timestamps"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: core/admin.py:240
|
||||||
|
msgid "File preview"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: core/admin.py:300 core/admin.py:443
|
||||||
msgid "No owner"
|
msgid "No owner"
|
||||||
msgstr "Kein Eigentümer"
|
msgstr "Kein Eigentümer"
|
||||||
|
|
||||||
#: core/admin.py:135 core/admin.py:278
|
#: core/admin.py:303 core/admin.py:446
|
||||||
msgid "Multiple owners"
|
msgid "Multiple owners"
|
||||||
msgstr "Mehrere Eigentümer"
|
msgstr "Mehrere Eigentümer"
|
||||||
|
|
||||||
#: core/admin.py:148
|
#: core/admin.py:316
|
||||||
msgid "Resend notification to external service"
|
msgid "Resend notification to external service"
|
||||||
msgstr "Benachrichtigung erneut an externen Dienst senden"
|
msgstr "Benachrichtigung erneut an externen Dienst senden"
|
||||||
|
|
||||||
#: core/admin.py:171
|
#: core/admin.py:339
|
||||||
#, python-format
|
#, python-format
|
||||||
msgid "Failed to notify for recording %(id)s"
|
msgid "Failed to notify for recording %(id)s"
|
||||||
msgstr "Benachrichtigung für Aufnahme %(id)s fehlgeschlagen"
|
msgstr "Benachrichtigung für Aufnahme %(id)s fehlgeschlagen"
|
||||||
|
|
||||||
#: core/admin.py:179
|
#: core/admin.py:347
|
||||||
#, python-format
|
#, python-format
|
||||||
msgid "Failed to notify for recording %(id)s: %(error)s"
|
msgid "Failed to notify for recording %(id)s: %(error)s"
|
||||||
msgstr "Benachrichtigung für Aufnahme %(id)s fehlgeschlagen: %(error)s"
|
msgstr "Benachrichtigung für Aufnahme %(id)s fehlgeschlagen: %(error)s"
|
||||||
|
|
||||||
#: core/admin.py:187
|
#: core/admin.py:355
|
||||||
#, python-format
|
#, python-format
|
||||||
msgid "Successfully sent notifications for %(count)s recording(s)."
|
msgid "Successfully sent notifications for %(count)s recording(s)."
|
||||||
msgstr "Benachrichtigungen für %(count)s Aufnahme(n) erfolgreich gesendet."
|
msgstr "Benachrichtigungen für %(count)s Aufnahme(n) erfolgreich gesendet."
|
||||||
|
|
||||||
#: core/admin.py:195
|
#: core/admin.py:363
|
||||||
#, python-format
|
#, python-format
|
||||||
msgid "Skipped %(count)s expired recording(s)."
|
msgid "Skipped %(count)s expired recording(s)."
|
||||||
msgstr "%(count)s abgelaufene Aufnahme(n) übersprungen."
|
msgstr "%(count)s abgelaufene Aufnahme(n) übersprungen."
|
||||||
|
|
||||||
#: core/admin.py:200
|
#: core/admin.py:368
|
||||||
msgid "Mark selected recordings as 'Failed to Stop'"
|
msgid "Mark selected recordings as 'Failed to Stop'"
|
||||||
msgstr "Ausgewählte Aufnahmen als ‚Fehler beim Stoppen‘ markieren"
|
msgstr "Ausgewählte Aufnahmen als ‚Fehler beim Stoppen‘ markieren"
|
||||||
|
|
||||||
#: core/admin.py:218
|
#: core/admin.py:386
|
||||||
#, python-format
|
#, python-format
|
||||||
msgid "%(count)s recording(s) successfully marked as 'Failed to Stop'."
|
msgid "%(count)s recording(s) successfully marked as 'Failed to Stop'."
|
||||||
msgstr "%(count)s Aufnahme(n) erfolgreich als ‚Fehler beim Stoppen‘ markiert."
|
msgstr "%(count)s Aufnahme(n) erfolgreich als ‚Fehler beim Stoppen‘ markiert."
|
||||||
|
|
||||||
#: core/admin.py:226
|
#: core/admin.py:394
|
||||||
#, fuzzy, python-format
|
#, fuzzy, python-format
|
||||||
#| msgid "Skipped %(count)s expired recording(s)."
|
#| msgid "Skipped %(count)s expired recording(s)."
|
||||||
msgid "Skipped %(count)s recording(s) with an ineligible status."
|
msgid "Skipped %(count)s recording(s) with an ineligible status."
|
||||||
msgstr "%(count)s abgelaufene Aufnahme(n) übersprungen."
|
msgstr "%(count)s abgelaufene Aufnahme(n) übersprungen."
|
||||||
|
|
||||||
#: core/admin.py:342
|
#: core/admin.py:510
|
||||||
msgid "No scopes"
|
msgid "No scopes"
|
||||||
msgstr "Keine Scopes"
|
msgstr "Keine Scopes"
|
||||||
|
|
||||||
#: core/admin.py:344
|
#: core/admin.py:512
|
||||||
msgid "Scopes"
|
msgid "Scopes"
|
||||||
msgstr "Scopes"
|
msgstr "Scopes"
|
||||||
|
|
||||||
@@ -88,17 +110,17 @@ msgstr "Scopes"
|
|||||||
msgid "Creator is me"
|
msgid "Creator is me"
|
||||||
msgstr "Ersteller bin ich"
|
msgstr "Ersteller bin ich"
|
||||||
|
|
||||||
#: core/api/serializers.py:88
|
#: core/api/serializers.py:89
|
||||||
msgid "You must be administrator or owner of a room to add accesses to it."
|
msgid "You must be administrator or owner of a room to add accesses to it."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
"Sie müssen Administrator oder Eigentümer eines Raums sein, um Zugriffe "
|
"Sie müssen Administrator oder Eigentümer eines Raums sein, um Zugriffe "
|
||||||
"hinzuzufügen."
|
"hinzuzufügen."
|
||||||
|
|
||||||
#: core/api/serializers.py:509
|
#: core/api/serializers.py:534
|
||||||
msgid "This file extension is not allowed."
|
msgid "This file extension is not allowed."
|
||||||
msgstr "Diese Dateiendung ist nicht erlaubt."
|
msgstr "Diese Dateiendung ist nicht erlaubt."
|
||||||
|
|
||||||
#: core/api/viewsets.py:1090
|
#: core/api/viewsets.py:1222
|
||||||
msgid "You have reached the maximum number of files for this type."
|
msgid "You have reached the maximum number of files for this type."
|
||||||
msgstr "Sie haben die maximale Anzahl an Dateien dieses Typs erreicht."
|
msgstr "Sie haben die maximale Anzahl an Dateien dieses Typs erreicht."
|
||||||
|
|
||||||
@@ -146,51 +168,59 @@ msgstr "Stopp fehlgeschlagen"
|
|||||||
msgid "Notification succeeded"
|
msgid "Notification succeeded"
|
||||||
msgstr "Benachrichtigung erfolgreich"
|
msgstr "Benachrichtigung erfolgreich"
|
||||||
|
|
||||||
#: core/models.py:89
|
#: core/models.py:65
|
||||||
|
msgid "External process successful"
|
||||||
|
msgstr "Externer Prozess erfolgreich"
|
||||||
|
|
||||||
|
#: core/models.py:67
|
||||||
|
msgid "External process failed"
|
||||||
|
msgstr "Externer Prozess fehlgeschlagen"
|
||||||
|
|
||||||
|
#: core/models.py:96
|
||||||
msgid "SCREEN_RECORDING"
|
msgid "SCREEN_RECORDING"
|
||||||
msgstr "BILDSCHIRMAUFZEICHNUNG"
|
msgstr "BILDSCHIRMAUFZEICHNUNG"
|
||||||
|
|
||||||
#: core/models.py:90
|
#: core/models.py:97
|
||||||
msgid "TRANSCRIPT"
|
msgid "TRANSCRIPT"
|
||||||
msgstr "TRANSKRIPT"
|
msgstr "TRANSKRIPT"
|
||||||
|
|
||||||
#: core/models.py:96
|
#: core/models.py:103
|
||||||
msgid "Public Access"
|
msgid "Public Access"
|
||||||
msgstr "Öffentlicher Zugriff"
|
msgstr "Öffentlicher Zugriff"
|
||||||
|
|
||||||
#: core/models.py:97
|
#: core/models.py:104
|
||||||
msgid "Trusted Access"
|
msgid "Trusted Access"
|
||||||
msgstr "Vertrauenswürdiger Zugriff"
|
msgstr "Vertrauenswürdiger Zugriff"
|
||||||
|
|
||||||
#: core/models.py:98
|
#: core/models.py:105
|
||||||
msgid "Restricted Access"
|
msgid "Restricted Access"
|
||||||
msgstr "Eingeschränkter Zugriff"
|
msgstr "Eingeschränkter Zugriff"
|
||||||
|
|
||||||
#: core/models.py:110
|
#: core/models.py:117
|
||||||
msgid "id"
|
msgid "id"
|
||||||
msgstr "ID"
|
msgstr "ID"
|
||||||
|
|
||||||
#: core/models.py:111
|
#: core/models.py:118
|
||||||
msgid "primary key for the record as UUID"
|
msgid "primary key for the record as UUID"
|
||||||
msgstr "Primärschlüssel des Eintrags als UUID"
|
msgstr "Primärschlüssel des Eintrags als UUID"
|
||||||
|
|
||||||
#: core/models.py:117
|
#: core/models.py:124
|
||||||
msgid "created on"
|
msgid "created on"
|
||||||
msgstr "erstellt am"
|
msgstr "erstellt am"
|
||||||
|
|
||||||
#: core/models.py:118
|
#: core/models.py:125
|
||||||
msgid "date and time at which a record was created"
|
msgid "date and time at which a record was created"
|
||||||
msgstr "Datum und Uhrzeit der Erstellung eines Eintrags"
|
msgstr "Datum und Uhrzeit der Erstellung eines Eintrags"
|
||||||
|
|
||||||
#: core/models.py:123
|
#: core/models.py:130
|
||||||
msgid "updated on"
|
msgid "updated on"
|
||||||
msgstr "aktualisiert am"
|
msgstr "aktualisiert am"
|
||||||
|
|
||||||
#: core/models.py:124
|
#: core/models.py:131
|
||||||
msgid "date and time at which a record was last updated"
|
msgid "date and time at which a record was last updated"
|
||||||
msgstr "Datum und Uhrzeit der letzten Aktualisierung eines Eintrags"
|
msgstr "Datum und Uhrzeit der letzten Aktualisierung eines Eintrags"
|
||||||
|
|
||||||
#: core/models.py:144
|
#: core/models.py:151
|
||||||
msgid ""
|
msgid ""
|
||||||
"Enter a valid sub. This value may contain only letters, numbers, and @/./+/-/"
|
"Enter a valid sub. This value may contain only letters, numbers, and @/./+/-/"
|
||||||
"_ characters."
|
"_ characters."
|
||||||
@@ -198,11 +228,11 @@ msgstr ""
|
|||||||
"Geben Sie einen gültigen Sub ein. Dieser Wert darf nur Buchstaben, Zahlen "
|
"Geben Sie einen gültigen Sub ein. Dieser Wert darf nur Buchstaben, Zahlen "
|
||||||
"und die Zeichen @/./+/-/_ enthalten."
|
"und die Zeichen @/./+/-/_ enthalten."
|
||||||
|
|
||||||
#: core/models.py:150
|
#: core/models.py:157
|
||||||
msgid "sub"
|
msgid "sub"
|
||||||
msgstr "Sub"
|
msgstr "Sub"
|
||||||
|
|
||||||
#: core/models.py:152
|
#: core/models.py:159
|
||||||
msgid ""
|
msgid ""
|
||||||
"Optional for pending users; required upon account activation. 255 characters "
|
"Optional for pending users; required upon account activation. 255 characters "
|
||||||
"or fewer. Letters, numbers, and @/./+/-/_ characters only."
|
"or fewer. Letters, numbers, and @/./+/-/_ characters only."
|
||||||
@@ -210,55 +240,55 @@ msgstr ""
|
|||||||
"Optional für ausstehende Benutzer; erforderlich nach Kontoaktivierung. "
|
"Optional für ausstehende Benutzer; erforderlich nach Kontoaktivierung. "
|
||||||
"Maximal 255 Zeichen. Nur Buchstaben, Zahlen und @/./+/-/_ Zeichen erlaubt."
|
"Maximal 255 Zeichen. Nur Buchstaben, Zahlen und @/./+/-/_ Zeichen erlaubt."
|
||||||
|
|
||||||
#: core/models.py:161
|
#: core/models.py:168
|
||||||
msgid "identity email address"
|
msgid "identity email address"
|
||||||
msgstr "Identitäts-E-Mail-Adresse"
|
msgstr "Identitäts-E-Mail-Adresse"
|
||||||
|
|
||||||
#: core/models.py:166
|
#: core/models.py:173
|
||||||
msgid "admin email address"
|
msgid "admin email address"
|
||||||
msgstr "Administrator-E-Mail-Adresse"
|
msgstr "Administrator-E-Mail-Adresse"
|
||||||
|
|
||||||
#: core/models.py:168
|
#: core/models.py:175
|
||||||
msgid "full name"
|
msgid "full name"
|
||||||
msgstr "Vollständiger Name"
|
msgstr "Vollständiger Name"
|
||||||
|
|
||||||
#: core/models.py:170
|
#: core/models.py:177
|
||||||
msgid "short name"
|
msgid "short name"
|
||||||
msgstr "Kurzname"
|
msgstr "Kurzname"
|
||||||
|
|
||||||
#: core/models.py:176
|
#: core/models.py:183
|
||||||
msgid "language"
|
msgid "language"
|
||||||
msgstr "Sprache"
|
msgstr "Sprache"
|
||||||
|
|
||||||
#: core/models.py:177
|
#: core/models.py:184
|
||||||
msgid "The language in which the user wants to see the interface."
|
msgid "The language in which the user wants to see the interface."
|
||||||
msgstr "Die Sprache, in der der Benutzer die Oberfläche sehen möchte."
|
msgstr "Die Sprache, in der der Benutzer die Oberfläche sehen möchte."
|
||||||
|
|
||||||
#: core/models.py:183
|
#: core/models.py:190
|
||||||
msgid "The timezone in which the user wants to see times."
|
msgid "The timezone in which the user wants to see times."
|
||||||
msgstr "Die Zeitzone, in der der Benutzer die Zeiten sehen möchte."
|
msgstr "Die Zeitzone, in der der Benutzer die Zeiten sehen möchte."
|
||||||
|
|
||||||
#: core/models.py:186
|
#: core/models.py:193
|
||||||
msgid "device"
|
msgid "device"
|
||||||
msgstr "Gerät"
|
msgstr "Gerät"
|
||||||
|
|
||||||
#: core/models.py:188
|
#: core/models.py:195
|
||||||
msgid "Whether the user is a device or a real user."
|
msgid "Whether the user is a device or a real user."
|
||||||
msgstr "Ob es sich um ein Gerät oder einen echten Benutzer handelt."
|
msgstr "Ob es sich um ein Gerät oder einen echten Benutzer handelt."
|
||||||
|
|
||||||
#: core/models.py:191
|
#: core/models.py:198
|
||||||
msgid "staff status"
|
msgid "staff status"
|
||||||
msgstr "Mitarbeiterstatus"
|
msgstr "Mitarbeiterstatus"
|
||||||
|
|
||||||
#: core/models.py:193
|
#: core/models.py:200
|
||||||
msgid "Whether the user can log into this admin site."
|
msgid "Whether the user can log into this admin site."
|
||||||
msgstr "Ob der Benutzer sich bei dieser Admin-Seite anmelden kann."
|
msgstr "Ob der Benutzer sich bei dieser Admin-Seite anmelden kann."
|
||||||
|
|
||||||
#: core/models.py:196
|
#: core/models.py:203
|
||||||
msgid "active"
|
msgid "active"
|
||||||
msgstr "aktiv"
|
msgstr "aktiv"
|
||||||
|
|
||||||
#: core/models.py:199
|
#: core/models.py:206
|
||||||
msgid ""
|
msgid ""
|
||||||
"Whether this user should be treated as active. Unselect this instead of "
|
"Whether this user should be treated as active. Unselect this instead of "
|
||||||
"deleting accounts."
|
"deleting accounts."
|
||||||
@@ -266,66 +296,66 @@ msgstr ""
|
|||||||
"Ob dieser Benutzer als aktiv behandelt werden soll. Deaktivieren Sie dies "
|
"Ob dieser Benutzer als aktiv behandelt werden soll. Deaktivieren Sie dies "
|
||||||
"anstelle des Löschens des Kontos."
|
"anstelle des Löschens des Kontos."
|
||||||
|
|
||||||
#: core/models.py:212
|
#: core/models.py:219
|
||||||
msgid "user"
|
msgid "user"
|
||||||
msgstr "Benutzer"
|
msgstr "Benutzer"
|
||||||
|
|
||||||
#: core/models.py:213
|
#: core/models.py:220
|
||||||
msgid "users"
|
msgid "users"
|
||||||
msgstr "Benutzer"
|
msgstr "Benutzer"
|
||||||
|
|
||||||
#: core/models.py:272
|
#: core/models.py:286
|
||||||
msgid "Resource"
|
msgid "Resource"
|
||||||
msgstr "Ressource"
|
msgstr "Ressource"
|
||||||
|
|
||||||
#: core/models.py:273
|
#: core/models.py:287
|
||||||
msgid "Resources"
|
msgid "Resources"
|
||||||
msgstr "Ressourcen"
|
msgstr "Ressourcen"
|
||||||
|
|
||||||
#: core/models.py:331
|
#: core/models.py:345
|
||||||
msgid "Resource access"
|
msgid "Resource access"
|
||||||
msgstr "Ressourcenzugriff"
|
msgstr "Ressourcenzugriff"
|
||||||
|
|
||||||
#: core/models.py:332
|
#: core/models.py:346
|
||||||
msgid "Resource accesses"
|
msgid "Resource accesses"
|
||||||
msgstr "Ressourcenzugriffe"
|
msgstr "Ressourcenzugriffe"
|
||||||
|
|
||||||
#: core/models.py:338
|
#: core/models.py:352
|
||||||
msgid "Resource access with this User and Resource already exists."
|
msgid "Resource access with this User and Resource already exists."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
"Ein Ressourcenzugriff mit diesem Benutzer und dieser Ressource existiert "
|
"Ein Ressourcenzugriff mit diesem Benutzer und dieser Ressource existiert "
|
||||||
"bereits."
|
"bereits."
|
||||||
|
|
||||||
#: core/models.py:394
|
#: core/models.py:409
|
||||||
msgid "Visio room configuration"
|
msgid "Visio room configuration"
|
||||||
msgstr "Visio-Raumkonfiguration"
|
msgstr "Visio-Raumkonfiguration"
|
||||||
|
|
||||||
#: core/models.py:395
|
#: core/models.py:410
|
||||||
msgid "Values for Visio parameters to configure the room."
|
msgid "Values for Visio parameters to configure the room."
|
||||||
msgstr "Werte für Visio-Parameter zur Konfiguration des Raums."
|
msgstr "Werte für Visio-Parameter zur Konfiguration des Raums."
|
||||||
|
|
||||||
#: core/models.py:402
|
#: core/models.py:417
|
||||||
msgid "Room PIN code"
|
msgid "Room PIN code"
|
||||||
msgstr "PIN-Code für den Raum"
|
msgstr "PIN-Code für den Raum"
|
||||||
|
|
||||||
#: core/models.py:403
|
#: core/models.py:418
|
||||||
msgid "Unique n-digit code that identifies this room in telephony mode."
|
msgid "Unique n-digit code that identifies this room in telephony mode."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
"Eindeutiger n-stelliger Code, der diesen Raum im Telephonmodus identifiziert."
|
"Eindeutiger n-stelliger Code, der diesen Raum im Telephonmodus identifiziert."
|
||||||
|
|
||||||
#: core/models.py:409 core/models.py:563
|
#: core/models.py:424 core/models.py:578
|
||||||
msgid "Room"
|
msgid "Room"
|
||||||
msgstr "Raum"
|
msgstr "Raum"
|
||||||
|
|
||||||
#: core/models.py:410
|
#: core/models.py:425
|
||||||
msgid "Rooms"
|
msgid "Rooms"
|
||||||
msgstr "Räume"
|
msgstr "Räume"
|
||||||
|
|
||||||
#: core/models.py:574
|
#: core/models.py:589
|
||||||
msgid "Worker ID"
|
msgid "Worker ID"
|
||||||
msgstr "Worker-ID"
|
msgstr "Worker-ID"
|
||||||
|
|
||||||
#: core/models.py:576
|
#: core/models.py:591
|
||||||
msgid ""
|
msgid ""
|
||||||
"Enter an identifier for the worker recording.This ID is retained even when "
|
"Enter an identifier for the worker recording.This ID is retained even when "
|
||||||
"the worker stops, allowing for easy tracking."
|
"the worker stops, allowing for easy tracking."
|
||||||
@@ -334,141 +364,153 @@ msgstr ""
|
|||||||
"erhalten, auch wenn der Worker stoppt, was ein einfaches Nachverfolgen "
|
"erhalten, auch wenn der Worker stoppt, was ein einfaches Nachverfolgen "
|
||||||
"ermöglicht."
|
"ermöglicht."
|
||||||
|
|
||||||
#: core/models.py:584
|
#: core/models.py:599
|
||||||
msgid "Recording mode"
|
msgid "Recording mode"
|
||||||
msgstr "Aufzeichnungsmodus"
|
msgstr "Aufzeichnungsmodus"
|
||||||
|
|
||||||
#: core/models.py:585
|
#: core/models.py:600
|
||||||
msgid "Defines the mode of recording being called."
|
msgid "Defines the mode of recording being called."
|
||||||
msgstr "Definiert den aufgerufenen Aufzeichnungsmodus."
|
msgstr "Definiert den aufgerufenen Aufzeichnungsmodus."
|
||||||
|
|
||||||
#: core/models.py:590 core/models.py:591
|
#: core/models.py:605 core/models.py:606
|
||||||
msgid "Recording options"
|
msgid "Recording options"
|
||||||
msgstr "Aufnahmeoptionen"
|
msgstr "Aufnahmeoptionen"
|
||||||
|
|
||||||
#: core/models.py:597
|
#: core/models.py:613
|
||||||
|
msgid "External Process ID"
|
||||||
|
msgstr "External Process ID"
|
||||||
|
|
||||||
|
#: core/models.py:614
|
||||||
|
msgid "ID of the external process associated with the recording."
|
||||||
|
msgstr "ID des externen Prozesses, der mit der Aufzeichnung verknüpft ist"
|
||||||
|
|
||||||
|
#: core/models.py:620
|
||||||
msgid "Recording"
|
msgid "Recording"
|
||||||
msgstr "Aufzeichnung"
|
msgstr "Aufzeichnung"
|
||||||
|
|
||||||
#: core/models.py:598
|
#: core/models.py:621
|
||||||
msgid "Recordings"
|
msgid "Recordings"
|
||||||
msgstr "Aufzeichnungen"
|
msgstr "Aufzeichnungen"
|
||||||
|
|
||||||
#: core/models.py:706
|
#: core/models.py:731
|
||||||
msgid "Recording/user relation"
|
msgid "Recording/user relation"
|
||||||
msgstr "Beziehung Aufzeichnung/Benutzer"
|
msgstr "Beziehung Aufzeichnung/Benutzer"
|
||||||
|
|
||||||
#: core/models.py:707
|
#: core/models.py:732
|
||||||
msgid "Recording/user relations"
|
msgid "Recording/user relations"
|
||||||
msgstr "Beziehungen Aufzeichnung/Benutzer"
|
msgstr "Beziehungen Aufzeichnung/Benutzer"
|
||||||
|
|
||||||
#: core/models.py:713
|
#: core/models.py:738
|
||||||
msgid "This user is already in this recording."
|
msgid "This user is already in this recording."
|
||||||
msgstr "Dieser Benutzer ist bereits Teil dieser Aufzeichnung."
|
msgstr "Dieser Benutzer ist bereits Teil dieser Aufzeichnung."
|
||||||
|
|
||||||
#: core/models.py:719
|
#: core/models.py:744
|
||||||
msgid "This team is already in this recording."
|
msgid "This team is already in this recording."
|
||||||
msgstr "Dieses Team ist bereits Teil dieser Aufzeichnung."
|
msgstr "Dieses Team ist bereits Teil dieser Aufzeichnung."
|
||||||
|
|
||||||
#: core/models.py:725
|
#: core/models.py:750
|
||||||
msgid "Either user or team must be set, not both."
|
msgid "Either user or team must be set, not both."
|
||||||
msgstr "Entweder Benutzer oder Team muss festgelegt werden, nicht beides."
|
msgstr "Entweder Benutzer oder Team muss festgelegt werden, nicht beides."
|
||||||
|
|
||||||
#: core/models.py:742
|
#: core/models.py:767
|
||||||
msgid "Create rooms"
|
msgid "Create rooms"
|
||||||
msgstr "Räume erstellen"
|
msgstr "Räume erstellen"
|
||||||
|
|
||||||
#: core/models.py:743
|
#: core/models.py:768
|
||||||
msgid "List rooms"
|
msgid "List rooms"
|
||||||
msgstr "Räume auflisten"
|
msgstr "Räume auflisten"
|
||||||
|
|
||||||
#: core/models.py:744
|
#: core/models.py:769
|
||||||
msgid "Retrieve room details"
|
msgid "Retrieve room details"
|
||||||
msgstr "Raumdetails abrufen"
|
msgstr "Raumdetails abrufen"
|
||||||
|
|
||||||
#: core/models.py:745
|
#: core/models.py:770
|
||||||
msgid "Update rooms"
|
msgid "Update rooms"
|
||||||
msgstr "Räume aktualisieren"
|
msgstr "Räume aktualisieren"
|
||||||
|
|
||||||
#: core/models.py:746
|
#: core/models.py:771
|
||||||
msgid "Delete rooms"
|
msgid "Delete rooms"
|
||||||
msgstr "Räume löschen"
|
msgstr "Räume löschen"
|
||||||
|
|
||||||
#: core/models.py:759
|
#: core/models.py:784
|
||||||
msgid "Application name"
|
msgid "Application name"
|
||||||
msgstr "Anwendungsname"
|
msgstr "Anwendungsname"
|
||||||
|
|
||||||
#: core/models.py:760
|
#: core/models.py:785
|
||||||
msgid "Descriptive name for this application."
|
msgid "Descriptive name for this application."
|
||||||
msgstr "Beschreibender Name für diese Anwendung."
|
msgstr "Beschreibender Name für diese Anwendung."
|
||||||
|
|
||||||
#: core/models.py:770
|
#: core/models.py:795
|
||||||
msgid "Hashed on Save. Copy it now if this is a new secret."
|
msgid "Hashed on Save. Copy it now if this is a new secret."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
"Beim Speichern gehasht. Jetzt kopieren, wenn dies ein neues Geheimnis ist."
|
"Beim Speichern gehasht. Jetzt kopieren, wenn dies ein neues Geheimnis ist."
|
||||||
|
|
||||||
#: core/models.py:781
|
#: core/models.py:806
|
||||||
msgid "Application"
|
msgid "Application"
|
||||||
msgstr "Anwendung"
|
msgstr "Anwendung"
|
||||||
|
|
||||||
#: core/models.py:782
|
#: core/models.py:807
|
||||||
msgid "Applications"
|
msgid "Applications"
|
||||||
msgstr "Anwendungen"
|
msgstr "Anwendungen"
|
||||||
|
|
||||||
#: core/models.py:805
|
#: core/models.py:830
|
||||||
msgid "Enter a valid domain"
|
msgid "Enter a valid domain"
|
||||||
msgstr "Geben Sie eine gültige Domain ein"
|
msgstr "Geben Sie eine gültige Domain ein"
|
||||||
|
|
||||||
#: core/models.py:808
|
#: core/models.py:833
|
||||||
msgid "Domain"
|
msgid "Domain"
|
||||||
msgstr "Domain"
|
msgstr "Domain"
|
||||||
|
|
||||||
#: core/models.py:809
|
#: core/models.py:834
|
||||||
msgid "Email domain this application can act on behalf of."
|
msgid "Email domain this application can act on behalf of."
|
||||||
msgstr "E-Mail-Domain, im Namen der diese Anwendung handeln kann."
|
msgstr "E-Mail-Domain, im Namen der diese Anwendung handeln kann."
|
||||||
|
|
||||||
#: core/models.py:821
|
#: core/models.py:846
|
||||||
msgid "Application domain"
|
msgid "Application domain"
|
||||||
msgstr "Anwendungsdomain"
|
msgstr "Anwendungsdomain"
|
||||||
|
|
||||||
#: core/models.py:822
|
#: core/models.py:847
|
||||||
msgid "Application domains"
|
msgid "Application domains"
|
||||||
msgstr "Anwendungsdomains"
|
msgstr "Anwendungsdomains"
|
||||||
|
|
||||||
#: core/models.py:840
|
#: core/models.py:865
|
||||||
msgid "Pending"
|
msgid "Pending"
|
||||||
msgstr "Ausstehend"
|
msgstr "Ausstehend"
|
||||||
|
|
||||||
#: core/models.py:848
|
#: core/models.py:866
|
||||||
|
msgid "Analyzing"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: core/models.py:873
|
||||||
msgid "Ready"
|
msgid "Ready"
|
||||||
msgstr "Bereit"
|
msgstr "Bereit"
|
||||||
|
|
||||||
#: core/models.py:854
|
#: core/models.py:879
|
||||||
msgid "Background image"
|
msgid "Background image"
|
||||||
msgstr "Hintergrundbild"
|
msgstr "Hintergrundbild"
|
||||||
|
|
||||||
#: core/models.py:866
|
#: core/models.py:891
|
||||||
msgid "title"
|
msgid "title"
|
||||||
msgstr "Titel"
|
msgstr "Titel"
|
||||||
|
|
||||||
#: core/models.py:890
|
#: core/models.py:915
|
||||||
msgid "Malware detection info when the analysis status is unsafe."
|
msgid "Malware detection info when the analysis status is unsafe."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
"Informationen zur Malware-Erkennung, wenn der Analyse-Status unsicher ist."
|
"Informationen zur Malware-Erkennung, wenn der Analyse-Status unsicher ist."
|
||||||
|
|
||||||
#: core/models.py:895
|
#: core/models.py:920
|
||||||
msgid "File"
|
msgid "File"
|
||||||
msgstr "Datei"
|
msgstr "Datei"
|
||||||
|
|
||||||
#: core/models.py:896
|
#: core/models.py:921
|
||||||
msgid "Files"
|
msgid "Files"
|
||||||
msgstr "Dateien"
|
msgstr "Dateien"
|
||||||
|
|
||||||
#: core/models.py:1000
|
#: core/models.py:1041
|
||||||
msgid "This file is already hard deleted."
|
msgid "This file is already hard deleted."
|
||||||
msgstr "Diese Datei wurde bereits endgültig gelöscht."
|
msgstr "Diese Datei wurde bereits endgültig gelöscht."
|
||||||
|
|
||||||
#: core/models.py:1010
|
#: core/models.py:1051
|
||||||
#, fuzzy
|
#, fuzzy
|
||||||
#| msgid "To hard delete a file, it must first be soft deleted."
|
#| msgid "To hard delete a file, it must first be soft deleted."
|
||||||
msgid "To hard delete a file, it must first be soft deleted."
|
msgid "To hard delete a file, it must first be soft deleted."
|
||||||
@@ -476,10 +518,20 @@ msgstr ""
|
|||||||
"Um eine Datei endgültig zu löschen, muss sie zuvor weich gelöscht worden "
|
"Um eine Datei endgültig zu löschen, muss sie zuvor weich gelöscht worden "
|
||||||
"sein."
|
"sein."
|
||||||
|
|
||||||
#: core/recording/event/notification.py:116
|
#: core/recording/event/notification.py:123
|
||||||
msgid "Your recording is ready"
|
msgid "Your recording is ready"
|
||||||
msgstr "Ihre Aufzeichnung ist bereit"
|
msgstr "Ihre Aufzeichnung ist bereit"
|
||||||
|
|
||||||
|
#: core/recording/event/notification.py:194
|
||||||
|
msgid "Transcription"
|
||||||
|
msgstr "Transkription"
|
||||||
|
|
||||||
|
#: core/recording/event/notification.py:204
|
||||||
|
#, python-brace-format
|
||||||
|
msgid "Meeting \"{room}\" on {room_recording_date} at {room_recording_time}"
|
||||||
|
msgstr ""
|
||||||
|
"Besprechung \"{room}\" am {room_recording_date} um {room_recording_time}"
|
||||||
|
|
||||||
#: core/services/invitation.py:44
|
#: core/services/invitation.py:44
|
||||||
#, python-brace-format
|
#, python-brace-format
|
||||||
msgid "Video call in progress: {sender.email} is waiting for you to connect"
|
msgid "Video call in progress: {sender.email} is waiting for you to connect"
|
||||||
@@ -575,7 +627,7 @@ msgid "To keep this recording permanently:"
|
|||||||
msgstr "So speichern Sie diese Aufzeichnung dauerhaft:"
|
msgstr "So speichern Sie diese Aufzeichnung dauerhaft:"
|
||||||
|
|
||||||
#: core/templates/mail/html/screen_recording.html:208
|
#: core/templates/mail/html/screen_recording.html:208
|
||||||
#: core/templates/mail/text/screen_recording.txt:13
|
#, python-format
|
||||||
msgid "Click the \"<a href=\"%(link)s\">Open</a>\" link below "
|
msgid "Click the \"<a href=\"%(link)s\">Open</a>\" link below "
|
||||||
msgstr "Klicken Sie auf den Link „<a href=\"%(link)s\">Öffnen</a>\" unten "
|
msgstr "Klicken Sie auf den Link „<a href=\"%(link)s\">Öffnen</a>\" unten "
|
||||||
|
|
||||||
@@ -604,18 +656,24 @@ msgstr ""
|
|||||||
" Wenn Sie Fragen haben oder Unterstützung benötigen, wenden Sie sich bitte "
|
" Wenn Sie Fragen haben oder Unterstützung benötigen, wenden Sie sich bitte "
|
||||||
"an unser Support-Team unter %(support_email)s. "
|
"an unser Support-Team unter %(support_email)s. "
|
||||||
|
|
||||||
#: meet/settings.py:223
|
#: core/templates/mail/text/screen_recording.txt:13
|
||||||
|
#, fuzzy, python-format
|
||||||
|
#| msgid "Click the \"<a href=\"%(link)s\">Open</a>\" link below "
|
||||||
|
msgid "Click the \"Open [%(link)s]\" link below "
|
||||||
|
msgstr "Klicken Sie auf den Link „<a href=\"%(link)s\">Öffnen</a>\" unten "
|
||||||
|
|
||||||
|
#: meet/settings.py:228
|
||||||
msgid "English"
|
msgid "English"
|
||||||
msgstr "Englisch"
|
msgstr "Englisch"
|
||||||
|
|
||||||
#: meet/settings.py:224
|
#: meet/settings.py:229
|
||||||
msgid "French"
|
msgid "French"
|
||||||
msgstr "Französisch"
|
msgstr "Französisch"
|
||||||
|
|
||||||
#: meet/settings.py:225
|
#: meet/settings.py:230
|
||||||
msgid "Dutch"
|
msgid "Dutch"
|
||||||
msgstr "Niederländisch"
|
msgstr "Niederländisch"
|
||||||
|
|
||||||
#: meet/settings.py:226
|
#: meet/settings.py:231
|
||||||
msgid "German"
|
msgid "German"
|
||||||
msgstr "Deutsch"
|
msgstr "Deutsch"
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ msgid ""
|
|||||||
msgstr ""
|
msgstr ""
|
||||||
"Project-Id-Version: PACKAGE VERSION\n"
|
"Project-Id-Version: PACKAGE VERSION\n"
|
||||||
"Report-Msgid-Bugs-To: \n"
|
"Report-Msgid-Bugs-To: \n"
|
||||||
"POT-Creation-Date: 2026-04-21 14:09+0000\n"
|
"POT-Creation-Date: 2026-07-02 10:47+0000\n"
|
||||||
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
|
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
|
||||||
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
|
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
|
||||||
"Language-Team: LANGUAGE <LL@li.org>\n"
|
"Language-Team: LANGUAGE <LL@li.org>\n"
|
||||||
@@ -17,70 +17,92 @@ msgstr ""
|
|||||||
"Content-Type: text/plain; charset=UTF-8\n"
|
"Content-Type: text/plain; charset=UTF-8\n"
|
||||||
"Content-Transfer-Encoding: 8bit\n"
|
"Content-Transfer-Encoding: 8bit\n"
|
||||||
|
|
||||||
#: core/admin.py:29
|
#: core/admin.py:67
|
||||||
msgid "Personal info"
|
msgid "Personal info"
|
||||||
msgstr "Personal info"
|
msgstr "Personal info"
|
||||||
|
|
||||||
#: core/admin.py:42
|
#: core/admin.py:80
|
||||||
msgid "Permissions"
|
msgid "Permissions"
|
||||||
msgstr "Permissions"
|
msgstr "Permissions"
|
||||||
|
|
||||||
#: core/admin.py:54
|
#: core/admin.py:92
|
||||||
msgid "Important dates"
|
msgid "Important dates"
|
||||||
msgstr "Important dates"
|
msgstr "Important dates"
|
||||||
|
|
||||||
#: core/admin.py:132 core/admin.py:275
|
#: core/admin.py:206
|
||||||
|
msgid "Content"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: core/admin.py:217
|
||||||
|
#, fuzzy
|
||||||
|
#| msgid "Delete rooms"
|
||||||
|
msgid "Deletion"
|
||||||
|
msgstr "Delete rooms"
|
||||||
|
|
||||||
|
#: core/admin.py:226
|
||||||
|
msgid "Derived info"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: core/admin.py:237
|
||||||
|
msgid "Timestamps"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: core/admin.py:240
|
||||||
|
msgid "File preview"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: core/admin.py:300 core/admin.py:443
|
||||||
msgid "No owner"
|
msgid "No owner"
|
||||||
msgstr "No owner"
|
msgstr "No owner"
|
||||||
|
|
||||||
#: core/admin.py:135 core/admin.py:278
|
#: core/admin.py:303 core/admin.py:446
|
||||||
msgid "Multiple owners"
|
msgid "Multiple owners"
|
||||||
msgstr "Multiple owners"
|
msgstr "Multiple owners"
|
||||||
|
|
||||||
#: core/admin.py:148
|
#: core/admin.py:316
|
||||||
msgid "Resend notification to external service"
|
msgid "Resend notification to external service"
|
||||||
msgstr "Resend notification to external service"
|
msgstr "Resend notification to external service"
|
||||||
|
|
||||||
#: core/admin.py:171
|
#: core/admin.py:339
|
||||||
#, python-format
|
#, python-format
|
||||||
msgid "Failed to notify for recording %(id)s"
|
msgid "Failed to notify for recording %(id)s"
|
||||||
msgstr "Failed to notify for recording %(id)s"
|
msgstr "Failed to notify for recording %(id)s"
|
||||||
|
|
||||||
#: core/admin.py:179
|
#: core/admin.py:347
|
||||||
#, python-format
|
#, python-format
|
||||||
msgid "Failed to notify for recording %(id)s: %(error)s"
|
msgid "Failed to notify for recording %(id)s: %(error)s"
|
||||||
msgstr "Failed to notify for recording %(id)s: %(error)s"
|
msgstr "Failed to notify for recording %(id)s: %(error)s"
|
||||||
|
|
||||||
#: core/admin.py:187
|
#: core/admin.py:355
|
||||||
#, python-format
|
#, python-format
|
||||||
msgid "Successfully sent notifications for %(count)s recording(s)."
|
msgid "Successfully sent notifications for %(count)s recording(s)."
|
||||||
msgstr "Successfully sent notifications for %(count)s recording(s)."
|
msgstr "Successfully sent notifications for %(count)s recording(s)."
|
||||||
|
|
||||||
#: core/admin.py:195
|
#: core/admin.py:363
|
||||||
#, python-format
|
#, python-format
|
||||||
msgid "Skipped %(count)s expired recording(s)."
|
msgid "Skipped %(count)s expired recording(s)."
|
||||||
msgstr "Skipped %(count)s expired recording(s)."
|
msgstr "Skipped %(count)s expired recording(s)."
|
||||||
|
|
||||||
#: core/admin.py:200
|
#: core/admin.py:368
|
||||||
msgid "Mark selected recordings as 'Failed to Stop'"
|
msgid "Mark selected recordings as 'Failed to Stop'"
|
||||||
msgstr "Mark selected recordings as 'Failed to Stop'"
|
msgstr "Mark selected recordings as 'Failed to Stop'"
|
||||||
|
|
||||||
#: core/admin.py:218
|
#: core/admin.py:386
|
||||||
#, python-format
|
#, python-format
|
||||||
msgid "%(count)s recording(s) successfully marked as 'Failed to Stop'."
|
msgid "%(count)s recording(s) successfully marked as 'Failed to Stop'."
|
||||||
msgstr "%(count)s recording(s) successfully marked as 'Failed to Stop'."
|
msgstr "%(count)s recording(s) successfully marked as 'Failed to Stop'."
|
||||||
|
|
||||||
#: core/admin.py:226
|
#: core/admin.py:394
|
||||||
#, fuzzy, python-format
|
#, fuzzy, python-format
|
||||||
#| msgid "Skipped %(count)s expired recording(s)."
|
#| msgid "Skipped %(count)s expired recording(s)."
|
||||||
msgid "Skipped %(count)s recording(s) with an ineligible status."
|
msgid "Skipped %(count)s recording(s) with an ineligible status."
|
||||||
msgstr "Skipped %(count)s expired recording(s)."
|
msgstr "Skipped %(count)s expired recording(s)."
|
||||||
|
|
||||||
#: core/admin.py:342
|
#: core/admin.py:510
|
||||||
msgid "No scopes"
|
msgid "No scopes"
|
||||||
msgstr "No scopes"
|
msgstr "No scopes"
|
||||||
|
|
||||||
#: core/admin.py:344
|
#: core/admin.py:512
|
||||||
msgid "Scopes"
|
msgid "Scopes"
|
||||||
msgstr "Scopes"
|
msgstr "Scopes"
|
||||||
|
|
||||||
@@ -88,15 +110,15 @@ msgstr "Scopes"
|
|||||||
msgid "Creator is me"
|
msgid "Creator is me"
|
||||||
msgstr "Creator is me"
|
msgstr "Creator is me"
|
||||||
|
|
||||||
#: core/api/serializers.py:88
|
#: core/api/serializers.py:89
|
||||||
msgid "You must be administrator or owner of a room to add accesses to it."
|
msgid "You must be administrator or owner of a room to add accesses to it."
|
||||||
msgstr "You must be administrator or owner of a room to add accesses to it."
|
msgstr "You must be administrator or owner of a room to add accesses to it."
|
||||||
|
|
||||||
#: core/api/serializers.py:509
|
#: core/api/serializers.py:534
|
||||||
msgid "This file extension is not allowed."
|
msgid "This file extension is not allowed."
|
||||||
msgstr "This file extension is not allowed."
|
msgstr "This file extension is not allowed."
|
||||||
|
|
||||||
#: core/api/viewsets.py:1090
|
#: core/api/viewsets.py:1222
|
||||||
msgid "You have reached the maximum number of files for this type."
|
msgid "You have reached the maximum number of files for this type."
|
||||||
msgstr "You have reached the maximum number of files for this type."
|
msgstr "You have reached the maximum number of files for this type."
|
||||||
|
|
||||||
@@ -144,51 +166,59 @@ msgstr "Failed to Stop"
|
|||||||
msgid "Notification succeeded"
|
msgid "Notification succeeded"
|
||||||
msgstr "Notification succeeded"
|
msgstr "Notification succeeded"
|
||||||
|
|
||||||
#: core/models.py:89
|
#: core/models.py:65
|
||||||
|
msgid "External process successful"
|
||||||
|
msgstr "External process successful"
|
||||||
|
|
||||||
|
#: core/models.py:67
|
||||||
|
msgid "External process failed"
|
||||||
|
msgstr "External process failed"
|
||||||
|
|
||||||
|
#: core/models.py:96
|
||||||
msgid "SCREEN_RECORDING"
|
msgid "SCREEN_RECORDING"
|
||||||
msgstr "SCREEN_RECORDING"
|
msgstr "SCREEN_RECORDING"
|
||||||
|
|
||||||
#: core/models.py:90
|
#: core/models.py:97
|
||||||
msgid "TRANSCRIPT"
|
msgid "TRANSCRIPT"
|
||||||
msgstr "TRANSCRIPT"
|
msgstr "TRANSCRIPT"
|
||||||
|
|
||||||
#: core/models.py:96
|
#: core/models.py:103
|
||||||
msgid "Public Access"
|
msgid "Public Access"
|
||||||
msgstr "Public Access"
|
msgstr "Public Access"
|
||||||
|
|
||||||
#: core/models.py:97
|
#: core/models.py:104
|
||||||
msgid "Trusted Access"
|
msgid "Trusted Access"
|
||||||
msgstr "Trusted Access"
|
msgstr "Trusted Access"
|
||||||
|
|
||||||
#: core/models.py:98
|
#: core/models.py:105
|
||||||
msgid "Restricted Access"
|
msgid "Restricted Access"
|
||||||
msgstr "Restricted Access"
|
msgstr "Restricted Access"
|
||||||
|
|
||||||
#: core/models.py:110
|
#: core/models.py:117
|
||||||
msgid "id"
|
msgid "id"
|
||||||
msgstr "id"
|
msgstr "id"
|
||||||
|
|
||||||
#: core/models.py:111
|
#: core/models.py:118
|
||||||
msgid "primary key for the record as UUID"
|
msgid "primary key for the record as UUID"
|
||||||
msgstr "primary key for the record as UUID"
|
msgstr "primary key for the record as UUID"
|
||||||
|
|
||||||
#: core/models.py:117
|
#: core/models.py:124
|
||||||
msgid "created on"
|
msgid "created on"
|
||||||
msgstr "created on"
|
msgstr "created on"
|
||||||
|
|
||||||
#: core/models.py:118
|
#: core/models.py:125
|
||||||
msgid "date and time at which a record was created"
|
msgid "date and time at which a record was created"
|
||||||
msgstr "date and time at which a record was created"
|
msgstr "date and time at which a record was created"
|
||||||
|
|
||||||
#: core/models.py:123
|
#: core/models.py:130
|
||||||
msgid "updated on"
|
msgid "updated on"
|
||||||
msgstr "updated on"
|
msgstr "updated on"
|
||||||
|
|
||||||
#: core/models.py:124
|
#: core/models.py:131
|
||||||
msgid "date and time at which a record was last updated"
|
msgid "date and time at which a record was last updated"
|
||||||
msgstr "date and time at which a record was last updated"
|
msgstr "date and time at which a record was last updated"
|
||||||
|
|
||||||
#: core/models.py:144
|
#: core/models.py:151
|
||||||
msgid ""
|
msgid ""
|
||||||
"Enter a valid sub. This value may contain only letters, numbers, and @/./+/-/"
|
"Enter a valid sub. This value may contain only letters, numbers, and @/./+/-/"
|
||||||
"_ characters."
|
"_ characters."
|
||||||
@@ -196,11 +226,11 @@ msgstr ""
|
|||||||
"Enter a valid sub. This value may contain only letters, numbers, and @/./+/-/"
|
"Enter a valid sub. This value may contain only letters, numbers, and @/./+/-/"
|
||||||
"_ characters."
|
"_ characters."
|
||||||
|
|
||||||
#: core/models.py:150
|
#: core/models.py:157
|
||||||
msgid "sub"
|
msgid "sub"
|
||||||
msgstr "sub"
|
msgstr "sub"
|
||||||
|
|
||||||
#: core/models.py:152
|
#: core/models.py:159
|
||||||
msgid ""
|
msgid ""
|
||||||
"Optional for pending users; required upon account activation. 255 characters "
|
"Optional for pending users; required upon account activation. 255 characters "
|
||||||
"or fewer. Letters, numbers, and @/./+/-/_ characters only."
|
"or fewer. Letters, numbers, and @/./+/-/_ characters only."
|
||||||
@@ -208,55 +238,55 @@ msgstr ""
|
|||||||
"Required. 255 characters or fewer. Letters, numbers, and @/./+/-/_ "
|
"Required. 255 characters or fewer. Letters, numbers, and @/./+/-/_ "
|
||||||
"characters only."
|
"characters only."
|
||||||
|
|
||||||
#: core/models.py:161
|
#: core/models.py:168
|
||||||
msgid "identity email address"
|
msgid "identity email address"
|
||||||
msgstr "identity email address"
|
msgstr "identity email address"
|
||||||
|
|
||||||
#: core/models.py:166
|
#: core/models.py:173
|
||||||
msgid "admin email address"
|
msgid "admin email address"
|
||||||
msgstr "admin email address"
|
msgstr "admin email address"
|
||||||
|
|
||||||
#: core/models.py:168
|
#: core/models.py:175
|
||||||
msgid "full name"
|
msgid "full name"
|
||||||
msgstr "full name"
|
msgstr "full name"
|
||||||
|
|
||||||
#: core/models.py:170
|
#: core/models.py:177
|
||||||
msgid "short name"
|
msgid "short name"
|
||||||
msgstr "short name"
|
msgstr "short name"
|
||||||
|
|
||||||
#: core/models.py:176
|
#: core/models.py:183
|
||||||
msgid "language"
|
msgid "language"
|
||||||
msgstr "language"
|
msgstr "language"
|
||||||
|
|
||||||
#: core/models.py:177
|
#: core/models.py:184
|
||||||
msgid "The language in which the user wants to see the interface."
|
msgid "The language in which the user wants to see the interface."
|
||||||
msgstr "The language in which the user wants to see the interface."
|
msgstr "The language in which the user wants to see the interface."
|
||||||
|
|
||||||
#: core/models.py:183
|
#: core/models.py:190
|
||||||
msgid "The timezone in which the user wants to see times."
|
msgid "The timezone in which the user wants to see times."
|
||||||
msgstr "The timezone in which the user wants to see times."
|
msgstr "The timezone in which the user wants to see times."
|
||||||
|
|
||||||
#: core/models.py:186
|
#: core/models.py:193
|
||||||
msgid "device"
|
msgid "device"
|
||||||
msgstr "device"
|
msgstr "device"
|
||||||
|
|
||||||
#: core/models.py:188
|
#: core/models.py:195
|
||||||
msgid "Whether the user is a device or a real user."
|
msgid "Whether the user is a device or a real user."
|
||||||
msgstr "Whether the user is a device or a real user."
|
msgstr "Whether the user is a device or a real user."
|
||||||
|
|
||||||
#: core/models.py:191
|
#: core/models.py:198
|
||||||
msgid "staff status"
|
msgid "staff status"
|
||||||
msgstr "staff status"
|
msgstr "staff status"
|
||||||
|
|
||||||
#: core/models.py:193
|
#: core/models.py:200
|
||||||
msgid "Whether the user can log into this admin site."
|
msgid "Whether the user can log into this admin site."
|
||||||
msgstr "Whether the user can log into this admin site."
|
msgstr "Whether the user can log into this admin site."
|
||||||
|
|
||||||
#: core/models.py:196
|
#: core/models.py:203
|
||||||
msgid "active"
|
msgid "active"
|
||||||
msgstr "active"
|
msgstr "active"
|
||||||
|
|
||||||
#: core/models.py:199
|
#: core/models.py:206
|
||||||
msgid ""
|
msgid ""
|
||||||
"Whether this user should be treated as active. Unselect this instead of "
|
"Whether this user should be treated as active. Unselect this instead of "
|
||||||
"deleting accounts."
|
"deleting accounts."
|
||||||
@@ -264,63 +294,63 @@ msgstr ""
|
|||||||
"Whether this user should be treated as active. Unselect this instead of "
|
"Whether this user should be treated as active. Unselect this instead of "
|
||||||
"deleting accounts."
|
"deleting accounts."
|
||||||
|
|
||||||
#: core/models.py:212
|
#: core/models.py:219
|
||||||
msgid "user"
|
msgid "user"
|
||||||
msgstr "user"
|
msgstr "user"
|
||||||
|
|
||||||
#: core/models.py:213
|
#: core/models.py:220
|
||||||
msgid "users"
|
msgid "users"
|
||||||
msgstr "users"
|
msgstr "users"
|
||||||
|
|
||||||
#: core/models.py:272
|
#: core/models.py:286
|
||||||
msgid "Resource"
|
msgid "Resource"
|
||||||
msgstr "Resource"
|
msgstr "Resource"
|
||||||
|
|
||||||
#: core/models.py:273
|
#: core/models.py:287
|
||||||
msgid "Resources"
|
msgid "Resources"
|
||||||
msgstr "Resources"
|
msgstr "Resources"
|
||||||
|
|
||||||
#: core/models.py:331
|
#: core/models.py:345
|
||||||
msgid "Resource access"
|
msgid "Resource access"
|
||||||
msgstr "Resource access"
|
msgstr "Resource access"
|
||||||
|
|
||||||
#: core/models.py:332
|
#: core/models.py:346
|
||||||
msgid "Resource accesses"
|
msgid "Resource accesses"
|
||||||
msgstr "Resource accesses"
|
msgstr "Resource accesses"
|
||||||
|
|
||||||
#: core/models.py:338
|
#: core/models.py:352
|
||||||
msgid "Resource access with this User and Resource already exists."
|
msgid "Resource access with this User and Resource already exists."
|
||||||
msgstr "Resource access with this User and Resource already exists."
|
msgstr "Resource access with this User and Resource already exists."
|
||||||
|
|
||||||
#: core/models.py:394
|
#: core/models.py:409
|
||||||
msgid "Visio room configuration"
|
msgid "Visio room configuration"
|
||||||
msgstr "Visio room configuration"
|
msgstr "Visio room configuration"
|
||||||
|
|
||||||
#: core/models.py:395
|
#: core/models.py:410
|
||||||
msgid "Values for Visio parameters to configure the room."
|
msgid "Values for Visio parameters to configure the room."
|
||||||
msgstr "Values for Visio parameters to configure the room."
|
msgstr "Values for Visio parameters to configure the room."
|
||||||
|
|
||||||
#: core/models.py:402
|
#: core/models.py:417
|
||||||
msgid "Room PIN code"
|
msgid "Room PIN code"
|
||||||
msgstr "Room PIN code"
|
msgstr "Room PIN code"
|
||||||
|
|
||||||
#: core/models.py:403
|
#: core/models.py:418
|
||||||
msgid "Unique n-digit code that identifies this room in telephony mode."
|
msgid "Unique n-digit code that identifies this room in telephony mode."
|
||||||
msgstr "Unique n-digit code that identifies this room in telephony mode."
|
msgstr "Unique n-digit code that identifies this room in telephony mode."
|
||||||
|
|
||||||
#: core/models.py:409 core/models.py:563
|
#: core/models.py:424 core/models.py:578
|
||||||
msgid "Room"
|
msgid "Room"
|
||||||
msgstr "Room"
|
msgstr "Room"
|
||||||
|
|
||||||
#: core/models.py:410
|
#: core/models.py:425
|
||||||
msgid "Rooms"
|
msgid "Rooms"
|
||||||
msgstr "Rooms"
|
msgstr "Rooms"
|
||||||
|
|
||||||
#: core/models.py:574
|
#: core/models.py:589
|
||||||
msgid "Worker ID"
|
msgid "Worker ID"
|
||||||
msgstr "Worker ID"
|
msgstr "Worker ID"
|
||||||
|
|
||||||
#: core/models.py:576
|
#: core/models.py:591
|
||||||
msgid ""
|
msgid ""
|
||||||
"Enter an identifier for the worker recording.This ID is retained even when "
|
"Enter an identifier for the worker recording.This ID is retained even when "
|
||||||
"the worker stops, allowing for easy tracking."
|
"the worker stops, allowing for easy tracking."
|
||||||
@@ -328,154 +358,175 @@ msgstr ""
|
|||||||
"Enter an identifier for the worker recording.This ID is retained even when "
|
"Enter an identifier for the worker recording.This ID is retained even when "
|
||||||
"the worker stops, allowing for easy tracking."
|
"the worker stops, allowing for easy tracking."
|
||||||
|
|
||||||
#: core/models.py:584
|
#: core/models.py:599
|
||||||
msgid "Recording mode"
|
msgid "Recording mode"
|
||||||
msgstr "Recording mode"
|
msgstr "Recording mode"
|
||||||
|
|
||||||
#: core/models.py:585
|
#: core/models.py:600
|
||||||
msgid "Defines the mode of recording being called."
|
msgid "Defines the mode of recording being called."
|
||||||
msgstr "Defines the mode of recording being called."
|
msgstr "Defines the mode of recording being called."
|
||||||
|
|
||||||
#: core/models.py:590 core/models.py:591
|
#: core/models.py:605 core/models.py:606
|
||||||
msgid "Recording options"
|
msgid "Recording options"
|
||||||
msgstr "Recording options"
|
msgstr "Recording options"
|
||||||
|
|
||||||
#: core/models.py:597
|
#: core/models.py:613
|
||||||
|
msgid "External Process ID"
|
||||||
|
msgstr "External Process ID"
|
||||||
|
|
||||||
|
#: core/models.py:614
|
||||||
|
msgid "ID of the external process associated with the recording."
|
||||||
|
msgstr "ID of the external process associated with the recording."
|
||||||
|
|
||||||
|
#: core/models.py:620
|
||||||
msgid "Recording"
|
msgid "Recording"
|
||||||
msgstr "Recording"
|
msgstr "Recording"
|
||||||
|
|
||||||
#: core/models.py:598
|
#: core/models.py:621
|
||||||
msgid "Recordings"
|
msgid "Recordings"
|
||||||
msgstr "Recordings"
|
msgstr "Recordings"
|
||||||
|
|
||||||
#: core/models.py:706
|
#: core/models.py:731
|
||||||
msgid "Recording/user relation"
|
msgid "Recording/user relation"
|
||||||
msgstr "Recording/user relation"
|
msgstr "Recording/user relation"
|
||||||
|
|
||||||
#: core/models.py:707
|
#: core/models.py:732
|
||||||
msgid "Recording/user relations"
|
msgid "Recording/user relations"
|
||||||
msgstr "Recording/user relations"
|
msgstr "Recording/user relations"
|
||||||
|
|
||||||
#: core/models.py:713
|
#: core/models.py:738
|
||||||
msgid "This user is already in this recording."
|
msgid "This user is already in this recording."
|
||||||
msgstr "This user is already in this recording."
|
msgstr "This user is already in this recording."
|
||||||
|
|
||||||
#: core/models.py:719
|
#: core/models.py:744
|
||||||
msgid "This team is already in this recording."
|
msgid "This team is already in this recording."
|
||||||
msgstr "This team is already in this recording."
|
msgstr "This team is already in this recording."
|
||||||
|
|
||||||
#: core/models.py:725
|
#: core/models.py:750
|
||||||
msgid "Either user or team must be set, not both."
|
msgid "Either user or team must be set, not both."
|
||||||
msgstr "Either user or team must be set, not both."
|
msgstr "Either user or team must be set, not both."
|
||||||
|
|
||||||
#: core/models.py:742
|
#: core/models.py:767
|
||||||
#, fuzzy
|
#, fuzzy
|
||||||
#| msgid "created on"
|
#| msgid "created on"
|
||||||
msgid "Create rooms"
|
msgid "Create rooms"
|
||||||
msgstr "Create rooms"
|
msgstr "Create rooms"
|
||||||
|
|
||||||
#: core/models.py:743
|
#: core/models.py:768
|
||||||
msgid "List rooms"
|
msgid "List rooms"
|
||||||
msgstr "List rooms"
|
msgstr "List rooms"
|
||||||
|
|
||||||
#: core/models.py:744
|
#: core/models.py:769
|
||||||
msgid "Retrieve room details"
|
msgid "Retrieve room details"
|
||||||
msgstr "Retrieve room details"
|
msgstr "Retrieve room details"
|
||||||
|
|
||||||
#: core/models.py:745
|
#: core/models.py:770
|
||||||
#, fuzzy
|
#, fuzzy
|
||||||
#| msgid "updated on"
|
#| msgid "updated on"
|
||||||
msgid "Update rooms"
|
msgid "Update rooms"
|
||||||
msgstr "Update rooms"
|
msgstr "Update rooms"
|
||||||
|
|
||||||
#: core/models.py:746
|
#: core/models.py:771
|
||||||
msgid "Delete rooms"
|
msgid "Delete rooms"
|
||||||
msgstr "Delete rooms"
|
msgstr "Delete rooms"
|
||||||
|
|
||||||
#: core/models.py:759
|
#: core/models.py:784
|
||||||
msgid "Application name"
|
msgid "Application name"
|
||||||
msgstr "Application name"
|
msgstr "Application name"
|
||||||
|
|
||||||
#: core/models.py:760
|
#: core/models.py:785
|
||||||
msgid "Descriptive name for this application."
|
msgid "Descriptive name for this application."
|
||||||
msgstr "Descriptive name for this application."
|
msgstr "Descriptive name for this application."
|
||||||
|
|
||||||
#: core/models.py:770
|
#: core/models.py:795
|
||||||
msgid "Hashed on Save. Copy it now if this is a new secret."
|
msgid "Hashed on Save. Copy it now if this is a new secret."
|
||||||
msgstr "Hashed on Save. Copy it now if this is a new secret."
|
msgstr "Hashed on Save. Copy it now if this is a new secret."
|
||||||
|
|
||||||
#: core/models.py:781
|
#: core/models.py:806
|
||||||
msgid "Application"
|
msgid "Application"
|
||||||
msgstr "Application"
|
msgstr "Application"
|
||||||
|
|
||||||
#: core/models.py:782
|
#: core/models.py:807
|
||||||
msgid "Applications"
|
msgid "Applications"
|
||||||
msgstr "Applications"
|
msgstr "Applications"
|
||||||
|
|
||||||
#: core/models.py:805
|
#: core/models.py:830
|
||||||
msgid "Enter a valid domain"
|
msgid "Enter a valid domain"
|
||||||
msgstr "Enter a valid domain"
|
msgstr "Enter a valid domain"
|
||||||
|
|
||||||
#: core/models.py:808
|
#: core/models.py:833
|
||||||
msgid "Domain"
|
msgid "Domain"
|
||||||
msgstr "Domain"
|
msgstr "Domain"
|
||||||
|
|
||||||
#: core/models.py:809
|
#: core/models.py:834
|
||||||
msgid "Email domain this application can act on behalf of."
|
msgid "Email domain this application can act on behalf of."
|
||||||
msgstr "Email domain this application can act on behalf of."
|
msgstr "Email domain this application can act on behalf of."
|
||||||
|
|
||||||
#: core/models.py:821
|
#: core/models.py:846
|
||||||
msgid "Application domain"
|
msgid "Application domain"
|
||||||
msgstr "Application domain"
|
msgstr "Application domain"
|
||||||
|
|
||||||
#: core/models.py:822
|
#: core/models.py:847
|
||||||
msgid "Application domains"
|
msgid "Application domains"
|
||||||
msgstr "Application domains"
|
msgstr "Application domains"
|
||||||
|
|
||||||
#: core/models.py:840
|
#: core/models.py:865
|
||||||
#, fuzzy
|
#, fuzzy
|
||||||
#| msgid "Recording"
|
#| msgid "Recording"
|
||||||
msgid "Pending"
|
msgid "Pending"
|
||||||
msgstr "Pending"
|
msgstr "Pending"
|
||||||
|
|
||||||
#: core/models.py:848
|
#: core/models.py:866
|
||||||
|
msgid "Analyzing"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: core/models.py:873
|
||||||
msgid "Ready"
|
msgid "Ready"
|
||||||
msgstr "Ready"
|
msgstr "Ready"
|
||||||
|
|
||||||
#: core/models.py:854
|
#: core/models.py:879
|
||||||
msgid "Background image"
|
msgid "Background image"
|
||||||
msgstr "Background image"
|
msgstr "Background image"
|
||||||
|
|
||||||
#: core/models.py:866
|
#: core/models.py:891
|
||||||
msgid "title"
|
msgid "title"
|
||||||
msgstr "title"
|
msgstr "title"
|
||||||
|
|
||||||
#: core/models.py:890
|
#: core/models.py:915
|
||||||
msgid "Malware detection info when the analysis status is unsafe."
|
msgid "Malware detection info when the analysis status is unsafe."
|
||||||
msgstr "Malware detection info when the analysis status is unsafe."
|
msgstr "Malware detection info when the analysis status is unsafe."
|
||||||
|
|
||||||
#: core/models.py:895
|
#: core/models.py:920
|
||||||
msgid "File"
|
msgid "File"
|
||||||
msgstr "File"
|
msgstr "File"
|
||||||
|
|
||||||
#: core/models.py:896
|
#: core/models.py:921
|
||||||
msgid "Files"
|
msgid "Files"
|
||||||
msgstr "Files"
|
msgstr "Files"
|
||||||
|
|
||||||
#: core/models.py:1000
|
#: core/models.py:1041
|
||||||
#, fuzzy
|
#, fuzzy
|
||||||
#| msgid "This user is already in this recording."
|
#| msgid "This user is already in this recording."
|
||||||
msgid "This file is already hard deleted."
|
msgid "This file is already hard deleted."
|
||||||
msgstr "This file is already hard deleted."
|
msgstr "This file is already hard deleted."
|
||||||
|
|
||||||
#: core/models.py:1010
|
#: core/models.py:1051
|
||||||
msgid "To hard delete a file, it must first be soft deleted."
|
msgid "To hard delete a file, it must first be soft deleted."
|
||||||
msgstr "To hard delete a file, it must first be soft deleted."
|
msgstr "To hard delete a file, it must first be soft deleted."
|
||||||
|
|
||||||
#: core/recording/event/notification.py:116
|
#: core/recording/event/notification.py:123
|
||||||
msgid "Your recording is ready"
|
msgid "Your recording is ready"
|
||||||
msgstr "Your recording is ready"
|
msgstr "Your recording is ready"
|
||||||
|
|
||||||
|
#: core/recording/event/notification.py:194
|
||||||
|
msgid "Transcription"
|
||||||
|
msgstr "Transcription"
|
||||||
|
|
||||||
|
#: core/recording/event/notification.py:204
|
||||||
|
#, python-brace-format
|
||||||
|
msgid "Meeting \"{room}\" on {room_recording_date} at {room_recording_time}"
|
||||||
|
msgstr "Meeting \"{room}\" on {room_recording_date} at {room_recording_time}"
|
||||||
|
|
||||||
#: core/services/invitation.py:44
|
#: core/services/invitation.py:44
|
||||||
#, python-brace-format
|
#, python-brace-format
|
||||||
msgid "Video call in progress: {sender.email} is waiting for you to connect"
|
msgid "Video call in progress: {sender.email} is waiting for you to connect"
|
||||||
@@ -571,7 +622,7 @@ msgid "To keep this recording permanently:"
|
|||||||
msgstr "To keep this recording permanently:"
|
msgstr "To keep this recording permanently:"
|
||||||
|
|
||||||
#: core/templates/mail/html/screen_recording.html:208
|
#: core/templates/mail/html/screen_recording.html:208
|
||||||
#: core/templates/mail/text/screen_recording.txt:13
|
#, python-format
|
||||||
msgid "Click the \"<a href=\"%(link)s\">Open</a>\" link below "
|
msgid "Click the \"<a href=\"%(link)s\">Open</a>\" link below "
|
||||||
msgstr "Click the \"<a href=\"%(link)s\">Open</a>\" link below "
|
msgstr "Click the \"<a href=\"%(link)s\">Open</a>\" link below "
|
||||||
|
|
||||||
@@ -600,18 +651,24 @@ msgstr ""
|
|||||||
" If you have any questions or need assistance, please contact our support "
|
" If you have any questions or need assistance, please contact our support "
|
||||||
"team at %(support_email)s. "
|
"team at %(support_email)s. "
|
||||||
|
|
||||||
#: meet/settings.py:223
|
#: core/templates/mail/text/screen_recording.txt:13
|
||||||
|
#, fuzzy, python-format
|
||||||
|
#| msgid "Click the \"<a href=\"%(link)s\">Open</a>\" link below "
|
||||||
|
msgid "Click the \"Open [%(link)s]\" link below "
|
||||||
|
msgstr "Click the \"<a href=\"%(link)s\">Open</a>\" link below "
|
||||||
|
|
||||||
|
#: meet/settings.py:228
|
||||||
msgid "English"
|
msgid "English"
|
||||||
msgstr "English"
|
msgstr "English"
|
||||||
|
|
||||||
#: meet/settings.py:224
|
#: meet/settings.py:229
|
||||||
msgid "French"
|
msgid "French"
|
||||||
msgstr "French"
|
msgstr "French"
|
||||||
|
|
||||||
#: meet/settings.py:225
|
#: meet/settings.py:230
|
||||||
msgid "Dutch"
|
msgid "Dutch"
|
||||||
msgstr "Dutch"
|
msgstr "Dutch"
|
||||||
|
|
||||||
#: meet/settings.py:226
|
#: meet/settings.py:231
|
||||||
msgid "German"
|
msgid "German"
|
||||||
msgstr "German"
|
msgstr "German"
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ msgid ""
|
|||||||
msgstr ""
|
msgstr ""
|
||||||
"Project-Id-Version: PACKAGE VERSION\n"
|
"Project-Id-Version: PACKAGE VERSION\n"
|
||||||
"Report-Msgid-Bugs-To: \n"
|
"Report-Msgid-Bugs-To: \n"
|
||||||
"POT-Creation-Date: 2026-04-21 14:09+0000\n"
|
"POT-Creation-Date: 2026-07-02 10:47+0000\n"
|
||||||
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
|
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
|
||||||
"Last-Translator: antoine.lebaud@mail.numerique.gouv.fr\n"
|
"Last-Translator: antoine.lebaud@mail.numerique.gouv.fr\n"
|
||||||
"Language-Team: LANGUAGE <LL@li.org>\n"
|
"Language-Team: LANGUAGE <LL@li.org>\n"
|
||||||
@@ -17,71 +17,93 @@ msgstr ""
|
|||||||
"Content-Type: text/plain; charset=UTF-8\n"
|
"Content-Type: text/plain; charset=UTF-8\n"
|
||||||
"Content-Transfer-Encoding: 8bit\n"
|
"Content-Transfer-Encoding: 8bit\n"
|
||||||
|
|
||||||
#: core/admin.py:29
|
#: core/admin.py:67
|
||||||
msgid "Personal info"
|
msgid "Personal info"
|
||||||
msgstr "Informations personnelles"
|
msgstr "Informations personnelles"
|
||||||
|
|
||||||
#: core/admin.py:42
|
#: core/admin.py:80
|
||||||
msgid "Permissions"
|
msgid "Permissions"
|
||||||
msgstr "Permissions"
|
msgstr "Permissions"
|
||||||
|
|
||||||
#: core/admin.py:54
|
#: core/admin.py:92
|
||||||
msgid "Important dates"
|
msgid "Important dates"
|
||||||
msgstr "Dates importantes"
|
msgstr "Dates importantes"
|
||||||
|
|
||||||
#: core/admin.py:132 core/admin.py:275
|
#: core/admin.py:206
|
||||||
|
msgid "Content"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: core/admin.py:217
|
||||||
|
#, fuzzy
|
||||||
|
#| msgid "Delete rooms"
|
||||||
|
msgid "Deletion"
|
||||||
|
msgstr "Supprimer les salles"
|
||||||
|
|
||||||
|
#: core/admin.py:226
|
||||||
|
msgid "Derived info"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: core/admin.py:237
|
||||||
|
msgid "Timestamps"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: core/admin.py:240
|
||||||
|
msgid "File preview"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: core/admin.py:300 core/admin.py:443
|
||||||
msgid "No owner"
|
msgid "No owner"
|
||||||
msgstr "Pas de propriétaire"
|
msgstr "Pas de propriétaire"
|
||||||
|
|
||||||
#: core/admin.py:135 core/admin.py:278
|
#: core/admin.py:303 core/admin.py:446
|
||||||
msgid "Multiple owners"
|
msgid "Multiple owners"
|
||||||
msgstr "Plusieurs propriétaires"
|
msgstr "Plusieurs propriétaires"
|
||||||
|
|
||||||
#: core/admin.py:148
|
#: core/admin.py:316
|
||||||
msgid "Resend notification to external service"
|
msgid "Resend notification to external service"
|
||||||
msgstr "Renvoyer la notification au service externe"
|
msgstr "Renvoyer la notification au service externe"
|
||||||
|
|
||||||
#: core/admin.py:171
|
#: core/admin.py:339
|
||||||
#, python-format
|
#, python-format
|
||||||
msgid "Failed to notify for recording %(id)s"
|
msgid "Failed to notify for recording %(id)s"
|
||||||
msgstr "Échec de la notification pour l’enregistrement %(id)s"
|
msgstr "Échec de la notification pour l’enregistrement %(id)s"
|
||||||
|
|
||||||
#: core/admin.py:179
|
#: core/admin.py:347
|
||||||
#, python-format
|
#, python-format
|
||||||
msgid "Failed to notify for recording %(id)s: %(error)s"
|
msgid "Failed to notify for recording %(id)s: %(error)s"
|
||||||
msgstr "Échec de la notification pour l’enregistrement %(id)s : %(error)s"
|
msgstr "Échec de la notification pour l’enregistrement %(id)s : %(error)s"
|
||||||
|
|
||||||
#: core/admin.py:187
|
#: core/admin.py:355
|
||||||
#, python-format
|
#, python-format
|
||||||
msgid "Successfully sent notifications for %(count)s recording(s)."
|
msgid "Successfully sent notifications for %(count)s recording(s)."
|
||||||
msgstr "Notifications envoyées avec succès pour %(count)s enregistrement(s)."
|
msgstr "Notifications envoyées avec succès pour %(count)s enregistrement(s)."
|
||||||
|
|
||||||
#: core/admin.py:195
|
#: core/admin.py:363
|
||||||
#, python-format
|
#, python-format
|
||||||
msgid "Skipped %(count)s expired recording(s)."
|
msgid "Skipped %(count)s expired recording(s)."
|
||||||
msgstr "%(count)s enregistrement(s) expiré(s) ignoré(s)."
|
msgstr "%(count)s enregistrement(s) expiré(s) ignoré(s)."
|
||||||
|
|
||||||
#: core/admin.py:200
|
#: core/admin.py:368
|
||||||
msgid "Mark selected recordings as 'Failed to Stop'"
|
msgid "Mark selected recordings as 'Failed to Stop'"
|
||||||
msgstr "Marquer les enregistrements sélectionnés comme « Échec d’arrêt »"
|
msgstr "Marquer les enregistrements sélectionnés comme « Échec d’arrêt »"
|
||||||
|
|
||||||
#: core/admin.py:218
|
#: core/admin.py:386
|
||||||
#, python-format
|
#, python-format
|
||||||
msgid "%(count)s recording(s) successfully marked as 'Failed to Stop'."
|
msgid "%(count)s recording(s) successfully marked as 'Failed to Stop'."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
"%(count)s enregistrement(s) marqué(s) avec succès comme « Échec d’arrêt »."
|
"%(count)s enregistrement(s) marqué(s) avec succès comme « Échec d’arrêt »."
|
||||||
|
|
||||||
#: core/admin.py:226
|
#: core/admin.py:394
|
||||||
#, fuzzy, python-format
|
#, fuzzy, python-format
|
||||||
#| msgid "Skipped %(count)s expired recording(s)."
|
#| msgid "Skipped %(count)s expired recording(s)."
|
||||||
msgid "Skipped %(count)s recording(s) with an ineligible status."
|
msgid "Skipped %(count)s recording(s) with an ineligible status."
|
||||||
msgstr "%(count)s enregistrement(s) avec un statut inéligible ignoré(s)."
|
msgstr "%(count)s enregistrement(s) avec un statut inéligible ignoré(s)."
|
||||||
|
|
||||||
#: core/admin.py:342
|
#: core/admin.py:510
|
||||||
msgid "No scopes"
|
msgid "No scopes"
|
||||||
msgstr "Aucun scopes"
|
msgstr "Aucun scopes"
|
||||||
|
|
||||||
#: core/admin.py:344
|
#: core/admin.py:512
|
||||||
msgid "Scopes"
|
msgid "Scopes"
|
||||||
msgstr "Scopes"
|
msgstr "Scopes"
|
||||||
|
|
||||||
@@ -89,17 +111,17 @@ msgstr "Scopes"
|
|||||||
msgid "Creator is me"
|
msgid "Creator is me"
|
||||||
msgstr "Je suis le créateur"
|
msgstr "Je suis le créateur"
|
||||||
|
|
||||||
#: core/api/serializers.py:88
|
#: core/api/serializers.py:89
|
||||||
msgid "You must be administrator or owner of a room to add accesses to it."
|
msgid "You must be administrator or owner of a room to add accesses to it."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
"Vous devez être administrateur ou propriétaire d'une salle pour y ajouter "
|
"Vous devez être administrateur ou propriétaire d'une salle pour y ajouter "
|
||||||
"des accès."
|
"des accès."
|
||||||
|
|
||||||
#: core/api/serializers.py:509
|
#: core/api/serializers.py:534
|
||||||
msgid "This file extension is not allowed."
|
msgid "This file extension is not allowed."
|
||||||
msgstr "Cette extension n'est pas autorisée"
|
msgstr "Cette extension n'est pas autorisée"
|
||||||
|
|
||||||
#: core/api/viewsets.py:1090
|
#: core/api/viewsets.py:1222
|
||||||
msgid "You have reached the maximum number of files for this type."
|
msgid "You have reached the maximum number of files for this type."
|
||||||
msgstr "Vous avez atteint le nombre maximum de fichiers de ce type"
|
msgstr "Vous avez atteint le nombre maximum de fichiers de ce type"
|
||||||
|
|
||||||
@@ -147,53 +169,61 @@ msgstr "Échec à l'arrêt"
|
|||||||
msgid "Notification succeeded"
|
msgid "Notification succeeded"
|
||||||
msgstr "Notification réussie"
|
msgstr "Notification réussie"
|
||||||
|
|
||||||
#: core/models.py:89
|
#: core/models.py:65
|
||||||
|
msgid "External process successful"
|
||||||
|
msgstr "Traitement externe : succès"
|
||||||
|
|
||||||
|
#: core/models.py:67
|
||||||
|
msgid "External process failed"
|
||||||
|
msgstr "Traitement externe : erreur"
|
||||||
|
|
||||||
|
#: core/models.py:96
|
||||||
msgid "SCREEN_RECORDING"
|
msgid "SCREEN_RECORDING"
|
||||||
msgstr "ENREGISTREMENT_ÉCRAN"
|
msgstr "ENREGISTREMENT_ÉCRAN"
|
||||||
|
|
||||||
#: core/models.py:90
|
#: core/models.py:97
|
||||||
msgid "TRANSCRIPT"
|
msgid "TRANSCRIPT"
|
||||||
msgstr "TRANSCRIPTION"
|
msgstr "TRANSCRIPTION"
|
||||||
|
|
||||||
#: core/models.py:96
|
#: core/models.py:103
|
||||||
msgid "Public Access"
|
msgid "Public Access"
|
||||||
msgstr "Accès public"
|
msgstr "Accès public"
|
||||||
|
|
||||||
#: core/models.py:97
|
#: core/models.py:104
|
||||||
msgid "Trusted Access"
|
msgid "Trusted Access"
|
||||||
msgstr "Accès de confiance"
|
msgstr "Accès de confiance"
|
||||||
|
|
||||||
#: core/models.py:98
|
#: core/models.py:105
|
||||||
msgid "Restricted Access"
|
msgid "Restricted Access"
|
||||||
msgstr "Accès restreint"
|
msgstr "Accès restreint"
|
||||||
|
|
||||||
#: core/models.py:110
|
#: core/models.py:117
|
||||||
msgid "id"
|
msgid "id"
|
||||||
msgstr "id"
|
msgstr "id"
|
||||||
|
|
||||||
#: core/models.py:111
|
#: core/models.py:118
|
||||||
msgid "primary key for the record as UUID"
|
msgid "primary key for the record as UUID"
|
||||||
msgstr "clé primaire pour l'enregistrement sous forme d'UUID"
|
msgstr "clé primaire pour l'enregistrement sous forme d'UUID"
|
||||||
|
|
||||||
#: core/models.py:117
|
#: core/models.py:124
|
||||||
msgid "created on"
|
msgid "created on"
|
||||||
msgstr "créé le"
|
msgstr "créé le"
|
||||||
|
|
||||||
#: core/models.py:118
|
#: core/models.py:125
|
||||||
msgid "date and time at which a record was created"
|
msgid "date and time at which a record was created"
|
||||||
msgstr "date et heure auxquelles un enregistrement a été créé"
|
msgstr "date et heure auxquelles un enregistrement a été créé"
|
||||||
|
|
||||||
#: core/models.py:123
|
#: core/models.py:130
|
||||||
msgid "updated on"
|
msgid "updated on"
|
||||||
msgstr "mis à jour le"
|
msgstr "mis à jour le"
|
||||||
|
|
||||||
#: core/models.py:124
|
#: core/models.py:131
|
||||||
msgid "date and time at which a record was last updated"
|
msgid "date and time at which a record was last updated"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
"date et heure auxquelles un enregistrement a été mis à jour pour la dernière "
|
"date et heure auxquelles un enregistrement a été mis à jour pour la dernière "
|
||||||
"fois"
|
"fois"
|
||||||
|
|
||||||
#: core/models.py:144
|
#: core/models.py:151
|
||||||
msgid ""
|
msgid ""
|
||||||
"Enter a valid sub. This value may contain only letters, numbers, and @/./+/-/"
|
"Enter a valid sub. This value may contain only letters, numbers, and @/./+/-/"
|
||||||
"_ characters."
|
"_ characters."
|
||||||
@@ -201,11 +231,11 @@ msgstr ""
|
|||||||
"Entrez un sub valide. Cette valeur ne peut contenir que des lettres, des "
|
"Entrez un sub valide. Cette valeur ne peut contenir que des lettres, des "
|
||||||
"chiffres et les caractères @/./+/-/_."
|
"chiffres et les caractères @/./+/-/_."
|
||||||
|
|
||||||
#: core/models.py:150
|
#: core/models.py:157
|
||||||
msgid "sub"
|
msgid "sub"
|
||||||
msgstr "sub"
|
msgstr "sub"
|
||||||
|
|
||||||
#: core/models.py:152
|
#: core/models.py:159
|
||||||
msgid ""
|
msgid ""
|
||||||
"Optional for pending users; required upon account activation. 255 characters "
|
"Optional for pending users; required upon account activation. 255 characters "
|
||||||
"or fewer. Letters, numbers, and @/./+/-/_ characters only."
|
"or fewer. Letters, numbers, and @/./+/-/_ characters only."
|
||||||
@@ -213,55 +243,55 @@ msgstr ""
|
|||||||
"Optionnel pour les utilisateurs en attente ; requis lors de l'activation du "
|
"Optionnel pour les utilisateurs en attente ; requis lors de l'activation du "
|
||||||
"compte. 255 caractères maximum. Lettres, chiffres et @/./+/-/_ uniquement."
|
"compte. 255 caractères maximum. Lettres, chiffres et @/./+/-/_ uniquement."
|
||||||
|
|
||||||
#: core/models.py:161
|
#: core/models.py:168
|
||||||
msgid "identity email address"
|
msgid "identity email address"
|
||||||
msgstr "adresse e-mail d'identité"
|
msgstr "adresse e-mail d'identité"
|
||||||
|
|
||||||
#: core/models.py:166
|
#: core/models.py:173
|
||||||
msgid "admin email address"
|
msgid "admin email address"
|
||||||
msgstr "adresse e-mail d'administrateur"
|
msgstr "adresse e-mail d'administrateur"
|
||||||
|
|
||||||
#: core/models.py:168
|
#: core/models.py:175
|
||||||
msgid "full name"
|
msgid "full name"
|
||||||
msgstr "nom complet"
|
msgstr "nom complet"
|
||||||
|
|
||||||
#: core/models.py:170
|
#: core/models.py:177
|
||||||
msgid "short name"
|
msgid "short name"
|
||||||
msgstr "nom court"
|
msgstr "nom court"
|
||||||
|
|
||||||
#: core/models.py:176
|
#: core/models.py:183
|
||||||
msgid "language"
|
msgid "language"
|
||||||
msgstr "langue"
|
msgstr "langue"
|
||||||
|
|
||||||
#: core/models.py:177
|
#: core/models.py:184
|
||||||
msgid "The language in which the user wants to see the interface."
|
msgid "The language in which the user wants to see the interface."
|
||||||
msgstr "La langue dans laquelle l'utilisateur souhaite voir l'interface."
|
msgstr "La langue dans laquelle l'utilisateur souhaite voir l'interface."
|
||||||
|
|
||||||
#: core/models.py:183
|
#: core/models.py:190
|
||||||
msgid "The timezone in which the user wants to see times."
|
msgid "The timezone in which the user wants to see times."
|
||||||
msgstr "Le fuseau horaire dans lequel l'utilisateur souhaite voir les heures."
|
msgstr "Le fuseau horaire dans lequel l'utilisateur souhaite voir les heures."
|
||||||
|
|
||||||
#: core/models.py:186
|
#: core/models.py:193
|
||||||
msgid "device"
|
msgid "device"
|
||||||
msgstr "appareil"
|
msgstr "appareil"
|
||||||
|
|
||||||
#: core/models.py:188
|
#: core/models.py:195
|
||||||
msgid "Whether the user is a device or a real user."
|
msgid "Whether the user is a device or a real user."
|
||||||
msgstr "Si l'utilisateur est un appareil ou un utilisateur réel."
|
msgstr "Si l'utilisateur est un appareil ou un utilisateur réel."
|
||||||
|
|
||||||
#: core/models.py:191
|
#: core/models.py:198
|
||||||
msgid "staff status"
|
msgid "staff status"
|
||||||
msgstr "statut du personnel"
|
msgstr "statut du personnel"
|
||||||
|
|
||||||
#: core/models.py:193
|
#: core/models.py:200
|
||||||
msgid "Whether the user can log into this admin site."
|
msgid "Whether the user can log into this admin site."
|
||||||
msgstr "Si l'utilisateur peut se connecter à ce site d'administration."
|
msgstr "Si l'utilisateur peut se connecter à ce site d'administration."
|
||||||
|
|
||||||
#: core/models.py:196
|
#: core/models.py:203
|
||||||
msgid "active"
|
msgid "active"
|
||||||
msgstr "actif"
|
msgstr "actif"
|
||||||
|
|
||||||
#: core/models.py:199
|
#: core/models.py:206
|
||||||
msgid ""
|
msgid ""
|
||||||
"Whether this user should be treated as active. Unselect this instead of "
|
"Whether this user should be treated as active. Unselect this instead of "
|
||||||
"deleting accounts."
|
"deleting accounts."
|
||||||
@@ -269,65 +299,65 @@ msgstr ""
|
|||||||
"Si cet utilisateur doit être traité comme actif. Désélectionnez cette option "
|
"Si cet utilisateur doit être traité comme actif. Désélectionnez cette option "
|
||||||
"au lieu de supprimer des comptes."
|
"au lieu de supprimer des comptes."
|
||||||
|
|
||||||
#: core/models.py:212
|
#: core/models.py:219
|
||||||
msgid "user"
|
msgid "user"
|
||||||
msgstr "utilisateur"
|
msgstr "utilisateur"
|
||||||
|
|
||||||
#: core/models.py:213
|
#: core/models.py:220
|
||||||
msgid "users"
|
msgid "users"
|
||||||
msgstr "utilisateurs"
|
msgstr "utilisateurs"
|
||||||
|
|
||||||
#: core/models.py:272
|
#: core/models.py:286
|
||||||
msgid "Resource"
|
msgid "Resource"
|
||||||
msgstr "Ressource"
|
msgstr "Ressource"
|
||||||
|
|
||||||
#: core/models.py:273
|
#: core/models.py:287
|
||||||
msgid "Resources"
|
msgid "Resources"
|
||||||
msgstr "Ressources"
|
msgstr "Ressources"
|
||||||
|
|
||||||
#: core/models.py:331
|
#: core/models.py:345
|
||||||
msgid "Resource access"
|
msgid "Resource access"
|
||||||
msgstr "Accès aux ressources"
|
msgstr "Accès aux ressources"
|
||||||
|
|
||||||
#: core/models.py:332
|
#: core/models.py:346
|
||||||
msgid "Resource accesses"
|
msgid "Resource accesses"
|
||||||
msgstr "Accès aux ressources"
|
msgstr "Accès aux ressources"
|
||||||
|
|
||||||
#: core/models.py:338
|
#: core/models.py:352
|
||||||
msgid "Resource access with this User and Resource already exists."
|
msgid "Resource access with this User and Resource already exists."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
"L'accès à la ressource avec cet utilisateur et cette ressource existe déjà."
|
"L'accès à la ressource avec cet utilisateur et cette ressource existe déjà."
|
||||||
|
|
||||||
#: core/models.py:394
|
#: core/models.py:409
|
||||||
msgid "Visio room configuration"
|
msgid "Visio room configuration"
|
||||||
msgstr "Configuration de la salle de visioconférence"
|
msgstr "Configuration de la salle de visioconférence"
|
||||||
|
|
||||||
#: core/models.py:395
|
#: core/models.py:410
|
||||||
msgid "Values for Visio parameters to configure the room."
|
msgid "Values for Visio parameters to configure the room."
|
||||||
msgstr "Valeurs des paramètres de visioconférence pour configurer la salle."
|
msgstr "Valeurs des paramètres de visioconférence pour configurer la salle."
|
||||||
|
|
||||||
#: core/models.py:402
|
#: core/models.py:417
|
||||||
msgid "Room PIN code"
|
msgid "Room PIN code"
|
||||||
msgstr "Code PIN de la salle"
|
msgstr "Code PIN de la salle"
|
||||||
|
|
||||||
#: core/models.py:403
|
#: core/models.py:418
|
||||||
msgid "Unique n-digit code that identifies this room in telephony mode."
|
msgid "Unique n-digit code that identifies this room in telephony mode."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
"Code unique à n chiffres qui identifie cette salle en mode téléphonique."
|
"Code unique à n chiffres qui identifie cette salle en mode téléphonique."
|
||||||
|
|
||||||
#: core/models.py:409 core/models.py:563
|
#: core/models.py:424 core/models.py:578
|
||||||
msgid "Room"
|
msgid "Room"
|
||||||
msgstr "Salle"
|
msgstr "Salle"
|
||||||
|
|
||||||
#: core/models.py:410
|
#: core/models.py:425
|
||||||
msgid "Rooms"
|
msgid "Rooms"
|
||||||
msgstr "Salles"
|
msgstr "Salles"
|
||||||
|
|
||||||
#: core/models.py:574
|
#: core/models.py:589
|
||||||
msgid "Worker ID"
|
msgid "Worker ID"
|
||||||
msgstr "ID du Worker"
|
msgstr "ID du Worker"
|
||||||
|
|
||||||
#: core/models.py:576
|
#: core/models.py:591
|
||||||
msgid ""
|
msgid ""
|
||||||
"Enter an identifier for the worker recording.This ID is retained even when "
|
"Enter an identifier for the worker recording.This ID is retained even when "
|
||||||
"the worker stops, allowing for easy tracking."
|
"the worker stops, allowing for easy tracking."
|
||||||
@@ -335,153 +365,174 @@ msgstr ""
|
|||||||
"Entrez un identifiant pour l'enregistrement du Worker. Cet identifiant est "
|
"Entrez un identifiant pour l'enregistrement du Worker. Cet identifiant est "
|
||||||
"conservé même lorsque le Worker s'arrête, permettant un suivi facile."
|
"conservé même lorsque le Worker s'arrête, permettant un suivi facile."
|
||||||
|
|
||||||
#: core/models.py:584
|
#: core/models.py:599
|
||||||
msgid "Recording mode"
|
msgid "Recording mode"
|
||||||
msgstr "Mode d'enregistrement"
|
msgstr "Mode d'enregistrement"
|
||||||
|
|
||||||
#: core/models.py:585
|
#: core/models.py:600
|
||||||
msgid "Defines the mode of recording being called."
|
msgid "Defines the mode of recording being called."
|
||||||
msgstr "Définit le mode d'enregistrement appelé."
|
msgstr "Définit le mode d'enregistrement appelé."
|
||||||
|
|
||||||
#: core/models.py:590 core/models.py:591
|
#: core/models.py:605 core/models.py:606
|
||||||
msgid "Recording options"
|
msgid "Recording options"
|
||||||
msgstr "Options d'enregistrement"
|
msgstr "Options d'enregistrement"
|
||||||
|
|
||||||
#: core/models.py:597
|
#: core/models.py:613
|
||||||
|
msgid "External Process ID"
|
||||||
|
msgstr "ID Traitement externe"
|
||||||
|
|
||||||
|
#: core/models.py:614
|
||||||
|
msgid "ID of the external process associated with the recording."
|
||||||
|
msgstr "ID du traitement externe associé avec l'enregistrement."
|
||||||
|
|
||||||
|
#: core/models.py:620
|
||||||
msgid "Recording"
|
msgid "Recording"
|
||||||
msgstr "Enregistrement"
|
msgstr "Enregistrement"
|
||||||
|
|
||||||
#: core/models.py:598
|
#: core/models.py:621
|
||||||
msgid "Recordings"
|
msgid "Recordings"
|
||||||
msgstr "Enregistrements"
|
msgstr "Enregistrements"
|
||||||
|
|
||||||
#: core/models.py:706
|
#: core/models.py:731
|
||||||
msgid "Recording/user relation"
|
msgid "Recording/user relation"
|
||||||
msgstr "Relation enregistrement/utilisateur"
|
msgstr "Relation enregistrement/utilisateur"
|
||||||
|
|
||||||
#: core/models.py:707
|
#: core/models.py:732
|
||||||
msgid "Recording/user relations"
|
msgid "Recording/user relations"
|
||||||
msgstr "Relations enregistrement/utilisateur"
|
msgstr "Relations enregistrement/utilisateur"
|
||||||
|
|
||||||
#: core/models.py:713
|
#: core/models.py:738
|
||||||
msgid "This user is already in this recording."
|
msgid "This user is already in this recording."
|
||||||
msgstr "Cet utilisateur est déjà dans cet enregistrement."
|
msgstr "Cet utilisateur est déjà dans cet enregistrement."
|
||||||
|
|
||||||
#: core/models.py:719
|
#: core/models.py:744
|
||||||
msgid "This team is already in this recording."
|
msgid "This team is already in this recording."
|
||||||
msgstr "Cette équipe est déjà dans cet enregistrement."
|
msgstr "Cette équipe est déjà dans cet enregistrement."
|
||||||
|
|
||||||
#: core/models.py:725
|
#: core/models.py:750
|
||||||
msgid "Either user or team must be set, not both."
|
msgid "Either user or team must be set, not both."
|
||||||
msgstr "Soit l'utilisateur, soit l'équipe doit être défini, pas les deux."
|
msgstr "Soit l'utilisateur, soit l'équipe doit être défini, pas les deux."
|
||||||
|
|
||||||
#: core/models.py:742
|
#: core/models.py:767
|
||||||
msgid "Create rooms"
|
msgid "Create rooms"
|
||||||
msgstr "Créer des salles"
|
msgstr "Créer des salles"
|
||||||
|
|
||||||
#: core/models.py:743
|
#: core/models.py:768
|
||||||
msgid "List rooms"
|
msgid "List rooms"
|
||||||
msgstr "Lister les salles"
|
msgstr "Lister les salles"
|
||||||
|
|
||||||
#: core/models.py:744
|
#: core/models.py:769
|
||||||
msgid "Retrieve room details"
|
msgid "Retrieve room details"
|
||||||
msgstr "Afficher les détails d’une salle"
|
msgstr "Afficher les détails d’une salle"
|
||||||
|
|
||||||
#: core/models.py:745
|
#: core/models.py:770
|
||||||
msgid "Update rooms"
|
msgid "Update rooms"
|
||||||
msgstr "Mettre à jour les salles"
|
msgstr "Mettre à jour les salles"
|
||||||
|
|
||||||
#: core/models.py:746
|
#: core/models.py:771
|
||||||
msgid "Delete rooms"
|
msgid "Delete rooms"
|
||||||
msgstr "Supprimer les salles"
|
msgstr "Supprimer les salles"
|
||||||
|
|
||||||
#: core/models.py:759
|
#: core/models.py:784
|
||||||
msgid "Application name"
|
msgid "Application name"
|
||||||
msgstr "Nom de l’application"
|
msgstr "Nom de l’application"
|
||||||
|
|
||||||
#: core/models.py:760
|
#: core/models.py:785
|
||||||
msgid "Descriptive name for this application."
|
msgid "Descriptive name for this application."
|
||||||
msgstr "Nom descriptif de cette application."
|
msgstr "Nom descriptif de cette application."
|
||||||
|
|
||||||
#: core/models.py:770
|
#: core/models.py:795
|
||||||
msgid "Hashed on Save. Copy it now if this is a new secret."
|
msgid "Hashed on Save. Copy it now if this is a new secret."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
"Haché lors de l’enregistrement. Copiez-le maintenant s’il s’agit d’un "
|
"Haché lors de l’enregistrement. Copiez-le maintenant s’il s’agit d’un "
|
||||||
"nouveau secret."
|
"nouveau secret."
|
||||||
|
|
||||||
#: core/models.py:781
|
#: core/models.py:806
|
||||||
msgid "Application"
|
msgid "Application"
|
||||||
msgstr "Application"
|
msgstr "Application"
|
||||||
|
|
||||||
#: core/models.py:782
|
#: core/models.py:807
|
||||||
msgid "Applications"
|
msgid "Applications"
|
||||||
msgstr "Applications"
|
msgstr "Applications"
|
||||||
|
|
||||||
#: core/models.py:805
|
#: core/models.py:830
|
||||||
msgid "Enter a valid domain"
|
msgid "Enter a valid domain"
|
||||||
msgstr "Saisissez un domaine valide"
|
msgstr "Saisissez un domaine valide"
|
||||||
|
|
||||||
#: core/models.py:808
|
#: core/models.py:833
|
||||||
msgid "Domain"
|
msgid "Domain"
|
||||||
msgstr "Domaine"
|
msgstr "Domaine"
|
||||||
|
|
||||||
#: core/models.py:809
|
#: core/models.py:834
|
||||||
msgid "Email domain this application can act on behalf of."
|
msgid "Email domain this application can act on behalf of."
|
||||||
msgstr "Domaine de messagerie au nom duquel cette application peut agir."
|
msgstr "Domaine de messagerie au nom duquel cette application peut agir."
|
||||||
|
|
||||||
#: core/models.py:821
|
#: core/models.py:846
|
||||||
msgid "Application domain"
|
msgid "Application domain"
|
||||||
msgstr "Domaine d’application"
|
msgstr "Domaine d’application"
|
||||||
|
|
||||||
#: core/models.py:822
|
#: core/models.py:847
|
||||||
msgid "Application domains"
|
msgid "Application domains"
|
||||||
msgstr "Domaines d’application"
|
msgstr "Domaines d’application"
|
||||||
|
|
||||||
#: core/models.py:840
|
#: core/models.py:865
|
||||||
msgid "Pending"
|
msgid "Pending"
|
||||||
msgstr "En attente"
|
msgstr "En attente"
|
||||||
|
|
||||||
#: core/models.py:848
|
#: core/models.py:866
|
||||||
|
msgid "Analyzing"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: core/models.py:873
|
||||||
msgid "Ready"
|
msgid "Ready"
|
||||||
msgstr "Prêt"
|
msgstr "Prêt"
|
||||||
|
|
||||||
#: core/models.py:854
|
#: core/models.py:879
|
||||||
msgid "Background image"
|
msgid "Background image"
|
||||||
msgstr "Image de fond"
|
msgstr "Image de fond"
|
||||||
|
|
||||||
#: core/models.py:866
|
#: core/models.py:891
|
||||||
msgid "title"
|
msgid "title"
|
||||||
msgstr "Titre"
|
msgstr "Titre"
|
||||||
|
|
||||||
#: core/models.py:890
|
#: core/models.py:915
|
||||||
msgid "Malware detection info when the analysis status is unsafe."
|
msgid "Malware detection info when the analysis status is unsafe."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
"Information concernant la détection de Malware cand le statut n'est pas sain"
|
"Information concernant la détection de Malware cand le statut n'est pas sain"
|
||||||
|
|
||||||
#: core/models.py:895
|
#: core/models.py:920
|
||||||
msgid "File"
|
msgid "File"
|
||||||
msgstr "Fichier"
|
msgstr "Fichier"
|
||||||
|
|
||||||
#: core/models.py:896
|
#: core/models.py:921
|
||||||
msgid "Files"
|
msgid "Files"
|
||||||
msgstr "Fichiers"
|
msgstr "Fichiers"
|
||||||
|
|
||||||
#: core/models.py:1000
|
#: core/models.py:1041
|
||||||
#, fuzzy
|
#, fuzzy
|
||||||
#| msgid "This user is already in this recording."
|
#| msgid "This user is already in this recording."
|
||||||
msgid "This file is already hard deleted."
|
msgid "This file is already hard deleted."
|
||||||
msgstr "Ce fichier a été supprimé."
|
msgstr "Ce fichier a été supprimé."
|
||||||
|
|
||||||
#: core/models.py:1010
|
#: core/models.py:1051
|
||||||
msgid "To hard delete a file, it must first be soft deleted."
|
msgid "To hard delete a file, it must first be soft deleted."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
"Pour supprimer définitivement un fichier il doit d'abord avoir été marqué "
|
"Pour supprimer définitivement un fichier il doit d'abord avoir été marqué "
|
||||||
"comme supprimé (soft delete)"
|
"comme supprimé (soft delete)"
|
||||||
|
|
||||||
#: core/recording/event/notification.py:116
|
#: core/recording/event/notification.py:123
|
||||||
msgid "Your recording is ready"
|
msgid "Your recording is ready"
|
||||||
msgstr "Votre enregistrement est prêt"
|
msgstr "Votre enregistrement est prêt"
|
||||||
|
|
||||||
|
#: core/recording/event/notification.py:194
|
||||||
|
msgid "Transcription"
|
||||||
|
msgstr "Transcription"
|
||||||
|
|
||||||
|
#: core/recording/event/notification.py:204
|
||||||
|
#, python-brace-format
|
||||||
|
msgid "Meeting \"{room}\" on {room_recording_date} at {room_recording_time}"
|
||||||
|
msgstr "Réunion \"{room}\" du {room_recording_date} à {room_recording_time}"
|
||||||
|
|
||||||
#: core/services/invitation.py:44
|
#: core/services/invitation.py:44
|
||||||
#, python-brace-format
|
#, python-brace-format
|
||||||
msgid "Video call in progress: {sender.email} is waiting for you to connect"
|
msgid "Video call in progress: {sender.email} is waiting for you to connect"
|
||||||
@@ -577,7 +628,7 @@ msgid "To keep this recording permanently:"
|
|||||||
msgstr "Pour conserver cet enregistrement de façon permanente :"
|
msgstr "Pour conserver cet enregistrement de façon permanente :"
|
||||||
|
|
||||||
#: core/templates/mail/html/screen_recording.html:208
|
#: core/templates/mail/html/screen_recording.html:208
|
||||||
#: core/templates/mail/text/screen_recording.txt:13
|
#, python-format
|
||||||
msgid "Click the \"<a href=\"%(link)s\">Open</a>\" link below "
|
msgid "Click the \"<a href=\"%(link)s\">Open</a>\" link below "
|
||||||
msgstr "Cliquez sur le lien \"<a href=\"%(link)s\">Ouvrir</a>\" ci-dessous "
|
msgstr "Cliquez sur le lien \"<a href=\"%(link)s\">Ouvrir</a>\" ci-dessous "
|
||||||
|
|
||||||
@@ -606,18 +657,24 @@ msgstr ""
|
|||||||
" Si vous avez des questions ou besoin d'assistance, veuillez contacter notre "
|
" Si vous avez des questions ou besoin d'assistance, veuillez contacter notre "
|
||||||
"équipe d'assistance à %(support_email)s. "
|
"équipe d'assistance à %(support_email)s. "
|
||||||
|
|
||||||
#: meet/settings.py:223
|
#: core/templates/mail/text/screen_recording.txt:13
|
||||||
|
#, fuzzy, python-format
|
||||||
|
#| msgid "Click the \"<a href=\"%(link)s\">Open</a>\" link below "
|
||||||
|
msgid "Click the \"Open [%(link)s]\" link below "
|
||||||
|
msgstr "Cliquez sur le lien \"<a href=\"%(link)s\">Ouvrir</a>\" ci-dessous "
|
||||||
|
|
||||||
|
#: meet/settings.py:228
|
||||||
msgid "English"
|
msgid "English"
|
||||||
msgstr "Anglais"
|
msgstr "Anglais"
|
||||||
|
|
||||||
#: meet/settings.py:224
|
#: meet/settings.py:229
|
||||||
msgid "French"
|
msgid "French"
|
||||||
msgstr "Français"
|
msgstr "Français"
|
||||||
|
|
||||||
#: meet/settings.py:225
|
#: meet/settings.py:230
|
||||||
msgid "Dutch"
|
msgid "Dutch"
|
||||||
msgstr "Néerlandais"
|
msgstr "Néerlandais"
|
||||||
|
|
||||||
#: meet/settings.py:226
|
#: meet/settings.py:231
|
||||||
msgid "German"
|
msgid "German"
|
||||||
msgstr "Allemand"
|
msgstr "Allemand"
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ msgid ""
|
|||||||
msgstr ""
|
msgstr ""
|
||||||
"Project-Id-Version: PACKAGE VERSION\n"
|
"Project-Id-Version: PACKAGE VERSION\n"
|
||||||
"Report-Msgid-Bugs-To: \n"
|
"Report-Msgid-Bugs-To: \n"
|
||||||
"POT-Creation-Date: 2026-04-21 14:09+0000\n"
|
"POT-Creation-Date: 2026-07-02 10:47+0000\n"
|
||||||
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
|
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
|
||||||
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
|
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
|
||||||
"Language-Team: LANGUAGE <LL@li.org>\n"
|
"Language-Team: LANGUAGE <LL@li.org>\n"
|
||||||
@@ -17,70 +17,92 @@ msgstr ""
|
|||||||
"Content-Type: text/plain; charset=UTF-8\n"
|
"Content-Type: text/plain; charset=UTF-8\n"
|
||||||
"Content-Transfer-Encoding: 8bit\n"
|
"Content-Transfer-Encoding: 8bit\n"
|
||||||
|
|
||||||
#: core/admin.py:29
|
#: core/admin.py:67
|
||||||
msgid "Personal info"
|
msgid "Personal info"
|
||||||
msgstr "Persoonlijke informatie"
|
msgstr "Persoonlijke informatie"
|
||||||
|
|
||||||
#: core/admin.py:42
|
#: core/admin.py:80
|
||||||
msgid "Permissions"
|
msgid "Permissions"
|
||||||
msgstr "Rechten"
|
msgstr "Rechten"
|
||||||
|
|
||||||
#: core/admin.py:54
|
#: core/admin.py:92
|
||||||
msgid "Important dates"
|
msgid "Important dates"
|
||||||
msgstr "Belangrijke datums"
|
msgstr "Belangrijke datums"
|
||||||
|
|
||||||
#: core/admin.py:132 core/admin.py:275
|
#: core/admin.py:206
|
||||||
|
msgid "Content"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: core/admin.py:217
|
||||||
|
#, fuzzy
|
||||||
|
#| msgid "Delete rooms"
|
||||||
|
msgid "Deletion"
|
||||||
|
msgstr "Ruimtes verwijderen"
|
||||||
|
|
||||||
|
#: core/admin.py:226
|
||||||
|
msgid "Derived info"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: core/admin.py:237
|
||||||
|
msgid "Timestamps"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: core/admin.py:240
|
||||||
|
msgid "File preview"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: core/admin.py:300 core/admin.py:443
|
||||||
msgid "No owner"
|
msgid "No owner"
|
||||||
msgstr "Geen eigenaar"
|
msgstr "Geen eigenaar"
|
||||||
|
|
||||||
#: core/admin.py:135 core/admin.py:278
|
#: core/admin.py:303 core/admin.py:446
|
||||||
msgid "Multiple owners"
|
msgid "Multiple owners"
|
||||||
msgstr "Meerdere eigenaren"
|
msgstr "Meerdere eigenaren"
|
||||||
|
|
||||||
#: core/admin.py:148
|
#: core/admin.py:316
|
||||||
msgid "Resend notification to external service"
|
msgid "Resend notification to external service"
|
||||||
msgstr "Melding opnieuw verzenden naar externe dienst"
|
msgstr "Melding opnieuw verzenden naar externe dienst"
|
||||||
|
|
||||||
#: core/admin.py:171
|
#: core/admin.py:339
|
||||||
#, python-format
|
#, python-format
|
||||||
msgid "Failed to notify for recording %(id)s"
|
msgid "Failed to notify for recording %(id)s"
|
||||||
msgstr "Melding voor opname %(id)s mislukt"
|
msgstr "Melding voor opname %(id)s mislukt"
|
||||||
|
|
||||||
#: core/admin.py:179
|
#: core/admin.py:347
|
||||||
#, python-format
|
#, python-format
|
||||||
msgid "Failed to notify for recording %(id)s: %(error)s"
|
msgid "Failed to notify for recording %(id)s: %(error)s"
|
||||||
msgstr "Melding voor opname %(id)s mislukt: %(error)s"
|
msgstr "Melding voor opname %(id)s mislukt: %(error)s"
|
||||||
|
|
||||||
#: core/admin.py:187
|
#: core/admin.py:355
|
||||||
#, python-format
|
#, python-format
|
||||||
msgid "Successfully sent notifications for %(count)s recording(s)."
|
msgid "Successfully sent notifications for %(count)s recording(s)."
|
||||||
msgstr "Meldingen succesvol verzonden voor %(count)s opname(n)."
|
msgstr "Meldingen succesvol verzonden voor %(count)s opname(n)."
|
||||||
|
|
||||||
#: core/admin.py:195
|
#: core/admin.py:363
|
||||||
#, python-format
|
#, python-format
|
||||||
msgid "Skipped %(count)s expired recording(s)."
|
msgid "Skipped %(count)s expired recording(s)."
|
||||||
msgstr "%(count)s verlopen opname(n) overgeslagen."
|
msgstr "%(count)s verlopen opname(n) overgeslagen."
|
||||||
|
|
||||||
#: core/admin.py:200
|
#: core/admin.py:368
|
||||||
msgid "Mark selected recordings as 'Failed to Stop'"
|
msgid "Mark selected recordings as 'Failed to Stop'"
|
||||||
msgstr "Geselecteerde opnames markeren als 'Mislukt bij stoppen'"
|
msgstr "Geselecteerde opnames markeren als 'Mislukt bij stoppen'"
|
||||||
|
|
||||||
#: core/admin.py:218
|
#: core/admin.py:386
|
||||||
#, python-format
|
#, python-format
|
||||||
msgid "%(count)s recording(s) successfully marked as 'Failed to Stop'."
|
msgid "%(count)s recording(s) successfully marked as 'Failed to Stop'."
|
||||||
msgstr "%(count)s opname(s) succesvol gemarkeerd als 'Mislukt bij stoppen'."
|
msgstr "%(count)s opname(s) succesvol gemarkeerd als 'Mislukt bij stoppen'."
|
||||||
|
|
||||||
#: core/admin.py:226
|
#: core/admin.py:394
|
||||||
#, fuzzy, python-format
|
#, fuzzy, python-format
|
||||||
#| msgid "Skipped %(count)s expired recording(s)."
|
#| msgid "Skipped %(count)s expired recording(s)."
|
||||||
msgid "Skipped %(count)s recording(s) with an ineligible status."
|
msgid "Skipped %(count)s recording(s) with an ineligible status."
|
||||||
msgstr "%(count)s opname(s) met een niet-toegestane status overgeslagen."
|
msgstr "%(count)s opname(s) met een niet-toegestane status overgeslagen."
|
||||||
|
|
||||||
#: core/admin.py:342
|
#: core/admin.py:510
|
||||||
msgid "No scopes"
|
msgid "No scopes"
|
||||||
msgstr "Geen scopes"
|
msgstr "Geen scopes"
|
||||||
|
|
||||||
#: core/admin.py:344
|
#: core/admin.py:512
|
||||||
msgid "Scopes"
|
msgid "Scopes"
|
||||||
msgstr "Scopes"
|
msgstr "Scopes"
|
||||||
|
|
||||||
@@ -88,16 +110,16 @@ msgstr "Scopes"
|
|||||||
msgid "Creator is me"
|
msgid "Creator is me"
|
||||||
msgstr "Maker ben ik"
|
msgstr "Maker ben ik"
|
||||||
|
|
||||||
#: core/api/serializers.py:88
|
#: core/api/serializers.py:89
|
||||||
msgid "You must be administrator or owner of a room to add accesses to it."
|
msgid "You must be administrator or owner of a room to add accesses to it."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
"Je moet beheerder of eigenaar van een ruimte zijn om toegang toe te voegen."
|
"Je moet beheerder of eigenaar van een ruimte zijn om toegang toe te voegen."
|
||||||
|
|
||||||
#: core/api/serializers.py:509
|
#: core/api/serializers.py:534
|
||||||
msgid "This file extension is not allowed."
|
msgid "This file extension is not allowed."
|
||||||
msgstr "Deze bestandsextensie is niet toegestaan."
|
msgstr "Deze bestandsextensie is niet toegestaan."
|
||||||
|
|
||||||
#: core/api/viewsets.py:1090
|
#: core/api/viewsets.py:1222
|
||||||
msgid "You have reached the maximum number of files for this type."
|
msgid "You have reached the maximum number of files for this type."
|
||||||
msgstr "Het maximale aantal bestanden voor dit type is bereikt."
|
msgstr "Het maximale aantal bestanden voor dit type is bereikt."
|
||||||
|
|
||||||
@@ -145,51 +167,59 @@ msgstr "Stoppen mislukt"
|
|||||||
msgid "Notification succeeded"
|
msgid "Notification succeeded"
|
||||||
msgstr "Notificatie geslaagd"
|
msgstr "Notificatie geslaagd"
|
||||||
|
|
||||||
#: core/models.py:89
|
#: core/models.py:65
|
||||||
|
msgid "External process successful"
|
||||||
|
msgstr "Externe procedure succesvol"
|
||||||
|
|
||||||
|
#: core/models.py:67
|
||||||
|
msgid "External process failed"
|
||||||
|
msgstr "Het externe proces is mislukt."
|
||||||
|
|
||||||
|
#: core/models.py:96
|
||||||
msgid "SCREEN_RECORDING"
|
msgid "SCREEN_RECORDING"
|
||||||
msgstr "SCHERM_OPNAME"
|
msgstr "SCHERM_OPNAME"
|
||||||
|
|
||||||
#: core/models.py:90
|
#: core/models.py:97
|
||||||
msgid "TRANSCRIPT"
|
msgid "TRANSCRIPT"
|
||||||
msgstr "TRANSCRIPT"
|
msgstr "TRANSCRIPT"
|
||||||
|
|
||||||
#: core/models.py:96
|
#: core/models.py:103
|
||||||
msgid "Public Access"
|
msgid "Public Access"
|
||||||
msgstr "Openbare toegang"
|
msgstr "Openbare toegang"
|
||||||
|
|
||||||
#: core/models.py:97
|
#: core/models.py:104
|
||||||
msgid "Trusted Access"
|
msgid "Trusted Access"
|
||||||
msgstr "Vertrouwde toegang"
|
msgstr "Vertrouwde toegang"
|
||||||
|
|
||||||
#: core/models.py:98
|
#: core/models.py:105
|
||||||
msgid "Restricted Access"
|
msgid "Restricted Access"
|
||||||
msgstr "Beperkte toegang"
|
msgstr "Beperkte toegang"
|
||||||
|
|
||||||
#: core/models.py:110
|
#: core/models.py:117
|
||||||
msgid "id"
|
msgid "id"
|
||||||
msgstr "id"
|
msgstr "id"
|
||||||
|
|
||||||
#: core/models.py:111
|
#: core/models.py:118
|
||||||
msgid "primary key for the record as UUID"
|
msgid "primary key for the record as UUID"
|
||||||
msgstr "primaire sleutel voor het record als UUID"
|
msgstr "primaire sleutel voor het record als UUID"
|
||||||
|
|
||||||
#: core/models.py:117
|
#: core/models.py:124
|
||||||
msgid "created on"
|
msgid "created on"
|
||||||
msgstr "aangemaakt op"
|
msgstr "aangemaakt op"
|
||||||
|
|
||||||
#: core/models.py:118
|
#: core/models.py:125
|
||||||
msgid "date and time at which a record was created"
|
msgid "date and time at which a record was created"
|
||||||
msgstr "datum en tijd waarop een record werd aangemaakt"
|
msgstr "datum en tijd waarop een record werd aangemaakt"
|
||||||
|
|
||||||
#: core/models.py:123
|
#: core/models.py:130
|
||||||
msgid "updated on"
|
msgid "updated on"
|
||||||
msgstr "bijgewerkt op"
|
msgstr "bijgewerkt op"
|
||||||
|
|
||||||
#: core/models.py:124
|
#: core/models.py:131
|
||||||
msgid "date and time at which a record was last updated"
|
msgid "date and time at which a record was last updated"
|
||||||
msgstr "datum en tijd waarop een record voor het laatst werd bijgewerkt"
|
msgstr "datum en tijd waarop een record voor het laatst werd bijgewerkt"
|
||||||
|
|
||||||
#: core/models.py:144
|
#: core/models.py:151
|
||||||
msgid ""
|
msgid ""
|
||||||
"Enter a valid sub. This value may contain only letters, numbers, and @/./+/-/"
|
"Enter a valid sub. This value may contain only letters, numbers, and @/./+/-/"
|
||||||
"_ characters."
|
"_ characters."
|
||||||
@@ -197,11 +227,11 @@ msgstr ""
|
|||||||
"Voer een geldige sub in. Deze waarde mag alleen letters, cijfers en @/./+/-/"
|
"Voer een geldige sub in. Deze waarde mag alleen letters, cijfers en @/./+/-/"
|
||||||
"_ tekens bevatten."
|
"_ tekens bevatten."
|
||||||
|
|
||||||
#: core/models.py:150
|
#: core/models.py:157
|
||||||
msgid "sub"
|
msgid "sub"
|
||||||
msgstr "sub"
|
msgstr "sub"
|
||||||
|
|
||||||
#: core/models.py:152
|
#: core/models.py:159
|
||||||
msgid ""
|
msgid ""
|
||||||
"Optional for pending users; required upon account activation. 255 characters "
|
"Optional for pending users; required upon account activation. 255 characters "
|
||||||
"or fewer. Letters, numbers, and @/./+/-/_ characters only."
|
"or fewer. Letters, numbers, and @/./+/-/_ characters only."
|
||||||
@@ -209,55 +239,55 @@ msgstr ""
|
|||||||
"Optioneel voor gebruikers in afwachting; vereist bij accountactivering. "
|
"Optioneel voor gebruikers in afwachting; vereist bij accountactivering. "
|
||||||
"Maximum 255 tekens. Alleen letters, cijfers en @/./+/-/_ toegestaan."
|
"Maximum 255 tekens. Alleen letters, cijfers en @/./+/-/_ toegestaan."
|
||||||
|
|
||||||
#: core/models.py:161
|
#: core/models.py:168
|
||||||
msgid "identity email address"
|
msgid "identity email address"
|
||||||
msgstr "identiteit e-mailadres"
|
msgstr "identiteit e-mailadres"
|
||||||
|
|
||||||
#: core/models.py:166
|
#: core/models.py:173
|
||||||
msgid "admin email address"
|
msgid "admin email address"
|
||||||
msgstr "beheerder e-mailadres"
|
msgstr "beheerder e-mailadres"
|
||||||
|
|
||||||
#: core/models.py:168
|
#: core/models.py:175
|
||||||
msgid "full name"
|
msgid "full name"
|
||||||
msgstr "volledige naam"
|
msgstr "volledige naam"
|
||||||
|
|
||||||
#: core/models.py:170
|
#: core/models.py:177
|
||||||
msgid "short name"
|
msgid "short name"
|
||||||
msgstr "korte naam"
|
msgstr "korte naam"
|
||||||
|
|
||||||
#: core/models.py:176
|
#: core/models.py:183
|
||||||
msgid "language"
|
msgid "language"
|
||||||
msgstr "taal"
|
msgstr "taal"
|
||||||
|
|
||||||
#: core/models.py:177
|
#: core/models.py:184
|
||||||
msgid "The language in which the user wants to see the interface."
|
msgid "The language in which the user wants to see the interface."
|
||||||
msgstr "De taal waarin de gebruiker de interface wil zien."
|
msgstr "De taal waarin de gebruiker de interface wil zien."
|
||||||
|
|
||||||
#: core/models.py:183
|
#: core/models.py:190
|
||||||
msgid "The timezone in which the user wants to see times."
|
msgid "The timezone in which the user wants to see times."
|
||||||
msgstr "De tijdzone waarin de gebruiker tijden wil zien."
|
msgstr "De tijdzone waarin de gebruiker tijden wil zien."
|
||||||
|
|
||||||
#: core/models.py:186
|
#: core/models.py:193
|
||||||
msgid "device"
|
msgid "device"
|
||||||
msgstr "apparaat"
|
msgstr "apparaat"
|
||||||
|
|
||||||
#: core/models.py:188
|
#: core/models.py:195
|
||||||
msgid "Whether the user is a device or a real user."
|
msgid "Whether the user is a device or a real user."
|
||||||
msgstr "Of de gebruiker een apparaat is of een echte gebruiker."
|
msgstr "Of de gebruiker een apparaat is of een echte gebruiker."
|
||||||
|
|
||||||
#: core/models.py:191
|
#: core/models.py:198
|
||||||
msgid "staff status"
|
msgid "staff status"
|
||||||
msgstr "personeelsstatus"
|
msgstr "personeelsstatus"
|
||||||
|
|
||||||
#: core/models.py:193
|
#: core/models.py:200
|
||||||
msgid "Whether the user can log into this admin site."
|
msgid "Whether the user can log into this admin site."
|
||||||
msgstr "Of de gebruiker kan inloggen op deze beheersite."
|
msgstr "Of de gebruiker kan inloggen op deze beheersite."
|
||||||
|
|
||||||
#: core/models.py:196
|
#: core/models.py:203
|
||||||
msgid "active"
|
msgid "active"
|
||||||
msgstr "actief"
|
msgstr "actief"
|
||||||
|
|
||||||
#: core/models.py:199
|
#: core/models.py:206
|
||||||
msgid ""
|
msgid ""
|
||||||
"Whether this user should be treated as active. Unselect this instead of "
|
"Whether this user should be treated as active. Unselect this instead of "
|
||||||
"deleting accounts."
|
"deleting accounts."
|
||||||
@@ -265,64 +295,64 @@ msgstr ""
|
|||||||
"Of deze gebruiker als actief moet worden behandeld. Deselecteer dit in "
|
"Of deze gebruiker als actief moet worden behandeld. Deselecteer dit in "
|
||||||
"plaats van accounts te verwijderen."
|
"plaats van accounts te verwijderen."
|
||||||
|
|
||||||
#: core/models.py:212
|
#: core/models.py:219
|
||||||
msgid "user"
|
msgid "user"
|
||||||
msgstr "gebruiker"
|
msgstr "gebruiker"
|
||||||
|
|
||||||
#: core/models.py:213
|
#: core/models.py:220
|
||||||
msgid "users"
|
msgid "users"
|
||||||
msgstr "gebruikers"
|
msgstr "gebruikers"
|
||||||
|
|
||||||
#: core/models.py:272
|
#: core/models.py:286
|
||||||
msgid "Resource"
|
msgid "Resource"
|
||||||
msgstr "Bron"
|
msgstr "Bron"
|
||||||
|
|
||||||
#: core/models.py:273
|
#: core/models.py:287
|
||||||
msgid "Resources"
|
msgid "Resources"
|
||||||
msgstr "Bronnen"
|
msgstr "Bronnen"
|
||||||
|
|
||||||
#: core/models.py:331
|
#: core/models.py:345
|
||||||
msgid "Resource access"
|
msgid "Resource access"
|
||||||
msgstr "Brontoegang"
|
msgstr "Brontoegang"
|
||||||
|
|
||||||
#: core/models.py:332
|
#: core/models.py:346
|
||||||
msgid "Resource accesses"
|
msgid "Resource accesses"
|
||||||
msgstr "Brontoegangsrechten"
|
msgstr "Brontoegangsrechten"
|
||||||
|
|
||||||
#: core/models.py:338
|
#: core/models.py:352
|
||||||
msgid "Resource access with this User and Resource already exists."
|
msgid "Resource access with this User and Resource already exists."
|
||||||
msgstr "Brontoegang met deze gebruiker en bron bestaat al."
|
msgstr "Brontoegang met deze gebruiker en bron bestaat al."
|
||||||
|
|
||||||
#: core/models.py:394
|
#: core/models.py:409
|
||||||
msgid "Visio room configuration"
|
msgid "Visio room configuration"
|
||||||
msgstr "Visio-ruimteconfiguratie"
|
msgstr "Visio-ruimteconfiguratie"
|
||||||
|
|
||||||
#: core/models.py:395
|
#: core/models.py:410
|
||||||
msgid "Values for Visio parameters to configure the room."
|
msgid "Values for Visio parameters to configure the room."
|
||||||
msgstr "Waarden voor Visio-parameters om de ruimte te configureren."
|
msgstr "Waarden voor Visio-parameters om de ruimte te configureren."
|
||||||
|
|
||||||
#: core/models.py:402
|
#: core/models.py:417
|
||||||
msgid "Room PIN code"
|
msgid "Room PIN code"
|
||||||
msgstr "Pincode van de kamer"
|
msgstr "Pincode van de kamer"
|
||||||
|
|
||||||
#: core/models.py:403
|
#: core/models.py:418
|
||||||
msgid "Unique n-digit code that identifies this room in telephony mode."
|
msgid "Unique n-digit code that identifies this room in telephony mode."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
"Unieke n-cijferige code die deze kamer identificeert in telefonie-modus."
|
"Unieke n-cijferige code die deze kamer identificeert in telefonie-modus."
|
||||||
|
|
||||||
#: core/models.py:409 core/models.py:563
|
#: core/models.py:424 core/models.py:578
|
||||||
msgid "Room"
|
msgid "Room"
|
||||||
msgstr "Ruimte"
|
msgstr "Ruimte"
|
||||||
|
|
||||||
#: core/models.py:410
|
#: core/models.py:425
|
||||||
msgid "Rooms"
|
msgid "Rooms"
|
||||||
msgstr "Ruimtes"
|
msgstr "Ruimtes"
|
||||||
|
|
||||||
#: core/models.py:574
|
#: core/models.py:589
|
||||||
msgid "Worker ID"
|
msgid "Worker ID"
|
||||||
msgstr "Worker ID"
|
msgstr "Worker ID"
|
||||||
|
|
||||||
#: core/models.py:576
|
#: core/models.py:591
|
||||||
msgid ""
|
msgid ""
|
||||||
"Enter an identifier for the worker recording.This ID is retained even when "
|
"Enter an identifier for the worker recording.This ID is retained even when "
|
||||||
"the worker stops, allowing for easy tracking."
|
"the worker stops, allowing for easy tracking."
|
||||||
@@ -330,140 +360,152 @@ msgstr ""
|
|||||||
"Voer een identificatie in voor de worker-opname. Deze ID blijft behouden, "
|
"Voer een identificatie in voor de worker-opname. Deze ID blijft behouden, "
|
||||||
"zelfs wanneer de worker stopt, waardoor eenvoudige tracking mogelijk is."
|
"zelfs wanneer de worker stopt, waardoor eenvoudige tracking mogelijk is."
|
||||||
|
|
||||||
#: core/models.py:584
|
#: core/models.py:599
|
||||||
msgid "Recording mode"
|
msgid "Recording mode"
|
||||||
msgstr "Opnamemodus"
|
msgstr "Opnamemodus"
|
||||||
|
|
||||||
#: core/models.py:585
|
#: core/models.py:600
|
||||||
msgid "Defines the mode of recording being called."
|
msgid "Defines the mode of recording being called."
|
||||||
msgstr "Definieert de modus van opname die wordt aangeroepen."
|
msgstr "Definieert de modus van opname die wordt aangeroepen."
|
||||||
|
|
||||||
#: core/models.py:590 core/models.py:591
|
#: core/models.py:605 core/models.py:606
|
||||||
msgid "Recording options"
|
msgid "Recording options"
|
||||||
msgstr "Opnameopties"
|
msgstr "Opnameopties"
|
||||||
|
|
||||||
#: core/models.py:597
|
#: core/models.py:613
|
||||||
|
msgid "External Process ID"
|
||||||
|
msgstr "Externe proces-ID"
|
||||||
|
|
||||||
|
#: core/models.py:614
|
||||||
|
msgid "ID of the external process associated with the recording."
|
||||||
|
msgstr "ID van het externe proces dat aan de opname is gekoppeld."
|
||||||
|
|
||||||
|
#: core/models.py:620
|
||||||
msgid "Recording"
|
msgid "Recording"
|
||||||
msgstr "Opname"
|
msgstr "Opname"
|
||||||
|
|
||||||
#: core/models.py:598
|
#: core/models.py:621
|
||||||
msgid "Recordings"
|
msgid "Recordings"
|
||||||
msgstr "Opnames"
|
msgstr "Opnames"
|
||||||
|
|
||||||
#: core/models.py:706
|
#: core/models.py:731
|
||||||
msgid "Recording/user relation"
|
msgid "Recording/user relation"
|
||||||
msgstr "Opname/gebruiker-relatie"
|
msgstr "Opname/gebruiker-relatie"
|
||||||
|
|
||||||
#: core/models.py:707
|
#: core/models.py:732
|
||||||
msgid "Recording/user relations"
|
msgid "Recording/user relations"
|
||||||
msgstr "Opname/gebruiker-relaties"
|
msgstr "Opname/gebruiker-relaties"
|
||||||
|
|
||||||
#: core/models.py:713
|
#: core/models.py:738
|
||||||
msgid "This user is already in this recording."
|
msgid "This user is already in this recording."
|
||||||
msgstr "Deze gebruiker is al in deze opname."
|
msgstr "Deze gebruiker is al in deze opname."
|
||||||
|
|
||||||
#: core/models.py:719
|
#: core/models.py:744
|
||||||
msgid "This team is already in this recording."
|
msgid "This team is already in this recording."
|
||||||
msgstr "Dit team is al in deze opname."
|
msgstr "Dit team is al in deze opname."
|
||||||
|
|
||||||
#: core/models.py:725
|
#: core/models.py:750
|
||||||
msgid "Either user or team must be set, not both."
|
msgid "Either user or team must be set, not both."
|
||||||
msgstr "Ofwel gebruiker of team moet worden ingesteld, niet beide."
|
msgstr "Ofwel gebruiker of team moet worden ingesteld, niet beide."
|
||||||
|
|
||||||
#: core/models.py:742
|
#: core/models.py:767
|
||||||
msgid "Create rooms"
|
msgid "Create rooms"
|
||||||
msgstr "Ruimtes aanmaken"
|
msgstr "Ruimtes aanmaken"
|
||||||
|
|
||||||
#: core/models.py:743
|
#: core/models.py:768
|
||||||
msgid "List rooms"
|
msgid "List rooms"
|
||||||
msgstr "Ruimtes weergeven"
|
msgstr "Ruimtes weergeven"
|
||||||
|
|
||||||
#: core/models.py:744
|
#: core/models.py:769
|
||||||
msgid "Retrieve room details"
|
msgid "Retrieve room details"
|
||||||
msgstr "Details van een ruimte ophalen"
|
msgstr "Details van een ruimte ophalen"
|
||||||
|
|
||||||
#: core/models.py:745
|
#: core/models.py:770
|
||||||
msgid "Update rooms"
|
msgid "Update rooms"
|
||||||
msgstr "Ruimtes bijwerken"
|
msgstr "Ruimtes bijwerken"
|
||||||
|
|
||||||
#: core/models.py:746
|
#: core/models.py:771
|
||||||
msgid "Delete rooms"
|
msgid "Delete rooms"
|
||||||
msgstr "Ruimtes verwijderen"
|
msgstr "Ruimtes verwijderen"
|
||||||
|
|
||||||
#: core/models.py:759
|
#: core/models.py:784
|
||||||
msgid "Application name"
|
msgid "Application name"
|
||||||
msgstr "Naam van de applicatie"
|
msgstr "Naam van de applicatie"
|
||||||
|
|
||||||
#: core/models.py:760
|
#: core/models.py:785
|
||||||
msgid "Descriptive name for this application."
|
msgid "Descriptive name for this application."
|
||||||
msgstr "Beschrijvende naam voor deze applicatie."
|
msgstr "Beschrijvende naam voor deze applicatie."
|
||||||
|
|
||||||
#: core/models.py:770
|
#: core/models.py:795
|
||||||
msgid "Hashed on Save. Copy it now if this is a new secret."
|
msgid "Hashed on Save. Copy it now if this is a new secret."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
"Wordt gehasht bij het opslaan. Kopieer het nu als dit een nieuw geheim is."
|
"Wordt gehasht bij het opslaan. Kopieer het nu als dit een nieuw geheim is."
|
||||||
|
|
||||||
#: core/models.py:781
|
#: core/models.py:806
|
||||||
msgid "Application"
|
msgid "Application"
|
||||||
msgstr "Applicatie"
|
msgstr "Applicatie"
|
||||||
|
|
||||||
#: core/models.py:782
|
#: core/models.py:807
|
||||||
msgid "Applications"
|
msgid "Applications"
|
||||||
msgstr "Applicaties"
|
msgstr "Applicaties"
|
||||||
|
|
||||||
#: core/models.py:805
|
#: core/models.py:830
|
||||||
msgid "Enter a valid domain"
|
msgid "Enter a valid domain"
|
||||||
msgstr "Voer een geldig domein in"
|
msgstr "Voer een geldig domein in"
|
||||||
|
|
||||||
#: core/models.py:808
|
#: core/models.py:833
|
||||||
msgid "Domain"
|
msgid "Domain"
|
||||||
msgstr "Domein"
|
msgstr "Domein"
|
||||||
|
|
||||||
#: core/models.py:809
|
#: core/models.py:834
|
||||||
msgid "Email domain this application can act on behalf of."
|
msgid "Email domain this application can act on behalf of."
|
||||||
msgstr "E-maildomein namens welke deze applicatie kan handelen."
|
msgstr "E-maildomein namens welke deze applicatie kan handelen."
|
||||||
|
|
||||||
#: core/models.py:821
|
#: core/models.py:846
|
||||||
msgid "Application domain"
|
msgid "Application domain"
|
||||||
msgstr "Applicatiedomein"
|
msgstr "Applicatiedomein"
|
||||||
|
|
||||||
#: core/models.py:822
|
#: core/models.py:847
|
||||||
msgid "Application domains"
|
msgid "Application domains"
|
||||||
msgstr "Applicatiedomeinen"
|
msgstr "Applicatiedomeinen"
|
||||||
|
|
||||||
#: core/models.py:840
|
#: core/models.py:865
|
||||||
msgid "Pending"
|
msgid "Pending"
|
||||||
msgstr "In afwachting"
|
msgstr "In afwachting"
|
||||||
|
|
||||||
#: core/models.py:848
|
#: core/models.py:866
|
||||||
|
msgid "Analyzing"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: core/models.py:873
|
||||||
msgid "Ready"
|
msgid "Ready"
|
||||||
msgstr "Klaar"
|
msgstr "Klaar"
|
||||||
|
|
||||||
#: core/models.py:854
|
#: core/models.py:879
|
||||||
msgid "Background image"
|
msgid "Background image"
|
||||||
msgstr "Achtergrondafbeelding"
|
msgstr "Achtergrondafbeelding"
|
||||||
|
|
||||||
#: core/models.py:866
|
#: core/models.py:891
|
||||||
msgid "title"
|
msgid "title"
|
||||||
msgstr "Titel"
|
msgstr "Titel"
|
||||||
|
|
||||||
#: core/models.py:890
|
#: core/models.py:915
|
||||||
msgid "Malware detection info when the analysis status is unsafe."
|
msgid "Malware detection info when the analysis status is unsafe."
|
||||||
msgstr "Informatie over malwaredetectie wanneer de analysestatus onveilig is."
|
msgstr "Informatie over malwaredetectie wanneer de analysestatus onveilig is."
|
||||||
|
|
||||||
#: core/models.py:895
|
#: core/models.py:920
|
||||||
msgid "File"
|
msgid "File"
|
||||||
msgstr "Bestand"
|
msgstr "Bestand"
|
||||||
|
|
||||||
#: core/models.py:896
|
#: core/models.py:921
|
||||||
msgid "Files"
|
msgid "Files"
|
||||||
msgstr "Bestanden"
|
msgstr "Bestanden"
|
||||||
|
|
||||||
#: core/models.py:1000
|
#: core/models.py:1041
|
||||||
msgid "This file is already hard deleted."
|
msgid "This file is already hard deleted."
|
||||||
msgstr "Dit bestand is al definitief verwijderd."
|
msgstr "Dit bestand is al definitief verwijderd."
|
||||||
|
|
||||||
#: core/models.py:1010
|
#: core/models.py:1051
|
||||||
#, fuzzy
|
#, fuzzy
|
||||||
#| msgid "To hard delete a file, it must first be soft deleted."
|
#| msgid "To hard delete a file, it must first be soft deleted."
|
||||||
msgid "To hard delete a file, it must first be soft deleted."
|
msgid "To hard delete a file, it must first be soft deleted."
|
||||||
@@ -471,10 +513,20 @@ msgstr ""
|
|||||||
"Om een bestand definitief te verwijderen, moet het eerst zacht verwijderd "
|
"Om een bestand definitief te verwijderen, moet het eerst zacht verwijderd "
|
||||||
"zijn."
|
"zijn."
|
||||||
|
|
||||||
#: core/recording/event/notification.py:116
|
#: core/recording/event/notification.py:123
|
||||||
msgid "Your recording is ready"
|
msgid "Your recording is ready"
|
||||||
msgstr "Je opname is klaar"
|
msgstr "Je opname is klaar"
|
||||||
|
|
||||||
|
#: core/recording/event/notification.py:194
|
||||||
|
msgid "Transcription"
|
||||||
|
msgstr "Transcriptie"
|
||||||
|
|
||||||
|
#: core/recording/event/notification.py:204
|
||||||
|
#, python-brace-format
|
||||||
|
msgid "Meeting \"{room}\" on {room_recording_date} at {room_recording_time}"
|
||||||
|
msgstr ""
|
||||||
|
"Vergadering \"{room}\" op {room_recording_date} om {room_recording_time}"
|
||||||
|
|
||||||
#: core/services/invitation.py:44
|
#: core/services/invitation.py:44
|
||||||
#, python-brace-format
|
#, python-brace-format
|
||||||
msgid "Video call in progress: {sender.email} is waiting for you to connect"
|
msgid "Video call in progress: {sender.email} is waiting for you to connect"
|
||||||
@@ -570,7 +622,7 @@ msgid "To keep this recording permanently:"
|
|||||||
msgstr "Om deze opname permanent te bewaren:"
|
msgstr "Om deze opname permanent te bewaren:"
|
||||||
|
|
||||||
#: core/templates/mail/html/screen_recording.html:208
|
#: core/templates/mail/html/screen_recording.html:208
|
||||||
#: core/templates/mail/text/screen_recording.txt:13
|
#, python-format
|
||||||
msgid "Click the \"<a href=\"%(link)s\">Open</a>\" link below "
|
msgid "Click the \"<a href=\"%(link)s\">Open</a>\" link below "
|
||||||
msgstr "Klik op de \"<a href=\"%(link)s\">Openen</a>\"-link hieronder "
|
msgstr "Klik op de \"<a href=\"%(link)s\">Openen</a>\"-link hieronder "
|
||||||
|
|
||||||
@@ -599,18 +651,24 @@ msgstr ""
|
|||||||
" Als je vragen hebt of hulp nodig hebt, neem dan contact op met ons support "
|
" Als je vragen hebt of hulp nodig hebt, neem dan contact op met ons support "
|
||||||
"team via %(support_email)s. "
|
"team via %(support_email)s. "
|
||||||
|
|
||||||
#: meet/settings.py:223
|
#: core/templates/mail/text/screen_recording.txt:13
|
||||||
|
#, fuzzy, python-format
|
||||||
|
#| msgid "Click the \"<a href=\"%(link)s\">Open</a>\" link below "
|
||||||
|
msgid "Click the \"Open [%(link)s]\" link below "
|
||||||
|
msgstr "Klik op de \"<a href=\"%(link)s\">Openen</a>\"-link hieronder "
|
||||||
|
|
||||||
|
#: meet/settings.py:228
|
||||||
msgid "English"
|
msgid "English"
|
||||||
msgstr "Engels"
|
msgstr "Engels"
|
||||||
|
|
||||||
#: meet/settings.py:224
|
#: meet/settings.py:229
|
||||||
msgid "French"
|
msgid "French"
|
||||||
msgstr "Frans"
|
msgstr "Frans"
|
||||||
|
|
||||||
#: meet/settings.py:225
|
#: meet/settings.py:230
|
||||||
msgid "Dutch"
|
msgid "Dutch"
|
||||||
msgstr "Nederlands"
|
msgstr "Nederlands"
|
||||||
|
|
||||||
#: meet/settings.py:226
|
#: meet/settings.py:231
|
||||||
msgid "German"
|
msgid "German"
|
||||||
msgstr "Duits"
|
msgstr "Duits"
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ https://docs.djangoproject.com/en/3.1/ref/settings/
|
|||||||
# pylint: disable=too-many-lines
|
# pylint: disable=too-many-lines
|
||||||
|
|
||||||
import json
|
import json
|
||||||
|
import warnings
|
||||||
from os import path
|
from os import path
|
||||||
from socket import gethostbyname, gethostname
|
from socket import gethostbyname, gethostname
|
||||||
|
|
||||||
@@ -670,6 +671,11 @@ class Base(Configuration):
|
|||||||
environ_name="PARTICIPANT_FORBIDDEN_PERMISSION_FIELDS",
|
environ_name="PARTICIPANT_FORBIDDEN_PERMISSION_FIELDS",
|
||||||
environ_prefix=None,
|
environ_prefix=None,
|
||||||
)
|
)
|
||||||
|
AUTHENTICATED_PARTICIPANTS_CAN_EDIT_DISPLAY_NAME = values.BooleanValue(
|
||||||
|
True,
|
||||||
|
environ_name="AUTHENTICATED_PARTICIPANTS_CAN_EDIT_DISPLAY_NAME",
|
||||||
|
environ_prefix=None,
|
||||||
|
)
|
||||||
|
|
||||||
# Recording settings
|
# Recording settings
|
||||||
RECORDING_ENABLE = values.BooleanValue(
|
RECORDING_ENABLE = values.BooleanValue(
|
||||||
@@ -748,12 +754,30 @@ class Base(Configuration):
|
|||||||
environ_prefix=None,
|
environ_prefix=None,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
SUMMARY_SERVICE_VERSION = values.PositiveIntegerValue(
|
||||||
|
1, environ_name="SUMMARY_SERVICE_VERSION", environ_prefix=None
|
||||||
|
)
|
||||||
SUMMARY_SERVICE_ENDPOINT = values.Value(
|
SUMMARY_SERVICE_ENDPOINT = values.Value(
|
||||||
None, environ_name="SUMMARY_SERVICE_ENDPOINT", environ_prefix=None
|
None, environ_name="SUMMARY_SERVICE_ENDPOINT", environ_prefix=None
|
||||||
)
|
)
|
||||||
SUMMARY_SERVICE_API_TOKEN = SecretFileValue(
|
SUMMARY_SERVICE_API_TOKEN = SecretFileValue(
|
||||||
None, environ_name="SUMMARY_SERVICE_API_TOKEN", environ_prefix=None
|
None, environ_name="SUMMARY_SERVICE_API_TOKEN", environ_prefix=None
|
||||||
)
|
)
|
||||||
|
SUMMARY_SERVICE_WEBHOOK_API_TOKEN = SecretFileValue(
|
||||||
|
None, environ_name="SUMMARY_SERVICE_WEBHOOK_API_TOKEN", environ_prefix=None
|
||||||
|
)
|
||||||
|
SUMMARY_SERVICE_CLOUD_STORAGE_SIGNED_URL_EXPIRY_SECONDS = (
|
||||||
|
values.PositiveIntegerValue(
|
||||||
|
60 * 60 * 24,
|
||||||
|
environ_name="SUMMARY_SERVICE_CLOUD_STORAGE_SIGNED_URL_EXPIRY_SECONDS",
|
||||||
|
environ_prefix=None,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
TRANSCRIPTION_SATISFACTION_FORM_BASE_URL = values.Value(
|
||||||
|
None,
|
||||||
|
environ_name="TRANSCRIPTION_SATISFACTION_FORM_BASE_URL",
|
||||||
|
environ_prefix=None,
|
||||||
|
)
|
||||||
SCREEN_RECORDING_BASE_URL = values.Value(
|
SCREEN_RECORDING_BASE_URL = values.Value(
|
||||||
None, environ_name="SCREEN_RECORDING_BASE_URL", environ_prefix=None
|
None, environ_name="SCREEN_RECORDING_BASE_URL", environ_prefix=None
|
||||||
)
|
)
|
||||||
@@ -1110,11 +1134,25 @@ class Base(Configuration):
|
|||||||
"FILE_UPLOAD_TMP_PATH cannot be the same as FILE_UPLOAD_PATH"
|
"FILE_UPLOAD_TMP_PATH cannot be the same as FILE_UPLOAD_PATH"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if (
|
||||||
|
cls.SUMMARY_SERVICE_VERSION == 1
|
||||||
|
and cls.SUMMARY_SERVICE_ENDPOINT is not None
|
||||||
|
):
|
||||||
|
warnings.warn(
|
||||||
|
"SUMMARY_SERVICE_VERSION=1 is deprecated. "
|
||||||
|
"The legacy v1 API has been removed from the experimental "
|
||||||
|
"Summary service. Please update your Summary service deployment to "
|
||||||
|
"the v2 API and set SUMMARY_SERVICE_VERSION=2.",
|
||||||
|
# We use UserWarning to make sure it shows up in production deployment
|
||||||
|
UserWarning,
|
||||||
|
stacklevel=2,
|
||||||
|
)
|
||||||
|
|
||||||
# The SENTRY_DSN setting should be available to activate sentry for an environment
|
# The SENTRY_DSN setting should be available to activate sentry for an environment
|
||||||
if cls.SENTRY_DSN is not None:
|
if cls.SENTRY_DSN is not None:
|
||||||
sentry_sdk.init(
|
sentry_sdk.init(
|
||||||
dsn=cls.SENTRY_DSN,
|
dsn=cls.SENTRY_DSN,
|
||||||
environment=cls.__name__.lower(),
|
environment=cls.__name__.lower(), # build, test, development, production
|
||||||
release=get_release(),
|
release=get_release(),
|
||||||
integrations=[DjangoIntegration()],
|
integrations=[DjangoIntegration()],
|
||||||
)
|
)
|
||||||
|
|||||||
+13
-13
@@ -7,7 +7,7 @@ build-backend = "uv_build"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "meet"
|
name = "meet"
|
||||||
version = "1.22.0"
|
version = "1.23.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",
|
||||||
@@ -24,19 +24,19 @@ keywords = ["Django", "Contacts", "Templates", "RBAC"]
|
|||||||
license = "MIT"
|
license = "MIT"
|
||||||
requires-python = ">=3.13"
|
requires-python = ">=3.13"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"boto3==1.43.14",
|
"boto3==1.43.36",
|
||||||
"Brotli==1.2.0",
|
"Brotli==1.2.0",
|
||||||
"brevo-python==1.2.0",
|
"brevo-python==1.2.0",
|
||||||
"celery[redis]==5.6.3",
|
"celery[redis]==5.6.3",
|
||||||
"dj-database-url==3.1.2",
|
"dj-database-url==3.1.2",
|
||||||
"django-configurations==2.5.1",
|
"django-configurations==2.5.1",
|
||||||
"django-cors-headers==4.9.0",
|
"django-cors-headers==4.9.0",
|
||||||
"django-countries==8.2.0",
|
"django-countries==9.0.0",
|
||||||
"django-filter==25.2",
|
"django-filter==25.2",
|
||||||
"django-lasuite[all]==0.0.26",
|
"django-lasuite[all]==0.0.27",
|
||||||
"django-parler==2.4",
|
"django-parler==2.4",
|
||||||
"redis==5.2.1",
|
"redis==5.2.1",
|
||||||
"django-redis==6.0.0",
|
"django-redis==7.0.0",
|
||||||
"django-storages[s3]==1.14.6",
|
"django-storages[s3]==1.14.6",
|
||||||
"django-timezone-field>=5.1",
|
"django-timezone-field>=5.1",
|
||||||
"django-pydantic-field==0.5.4",
|
"django-pydantic-field==0.5.4",
|
||||||
@@ -57,13 +57,13 @@ dependencies = [
|
|||||||
"python-frontmatter==1.3.0",
|
"python-frontmatter==1.3.0",
|
||||||
"python-magic==0.4.27",
|
"python-magic==0.4.27",
|
||||||
"requests==2.34.2",
|
"requests==2.34.2",
|
||||||
"sentry-sdk==2.60.0",
|
"sentry-sdk==2.63.0",
|
||||||
"whitenoise==6.12.0",
|
"whitenoise==6.12.0",
|
||||||
"mozilla-django-oidc==5.0.2",
|
"mozilla-django-oidc==5.0.2",
|
||||||
"livekit-api==1.1.0",
|
"livekit-api==1.1.1",
|
||||||
"aiohttp==3.14.0",
|
"aiohttp==3.14.1",
|
||||||
"urllib3==2.7.0",
|
"urllib3==2.7.0",
|
||||||
"phonenumbers==9.0.31",
|
"phonenumbers==9.0.33",
|
||||||
]
|
]
|
||||||
|
|
||||||
[project.urls]
|
[project.urls]
|
||||||
@@ -75,20 +75,20 @@ dependencies = [
|
|||||||
[dependency-groups]
|
[dependency-groups]
|
||||||
dev = [
|
dev = [
|
||||||
"django-extensions==4.1",
|
"django-extensions==4.1",
|
||||||
"drf-spectacular-sidecar==2026.5.1",
|
"drf-spectacular-sidecar==2026.6.1",
|
||||||
"freezegun==1.5.5",
|
"freezegun==1.5.5",
|
||||||
"ipdb==0.13.13",
|
"ipdb==0.13.13",
|
||||||
"ipython==9.13.0",
|
"ipython==9.14.1",
|
||||||
"pyfakefs==6.2.0",
|
"pyfakefs==6.2.0",
|
||||||
"pylint-django==2.7.0",
|
"pylint-django==2.7.0",
|
||||||
"pylint<4.0.0",
|
"pylint<4.0.0",
|
||||||
"pytest-cov==7.1.0",
|
"pytest-cov==7.1.0",
|
||||||
"pytest-django==4.12.0",
|
"pytest-django==4.12.0",
|
||||||
"pytest==9.0.3",
|
"pytest==9.1.1",
|
||||||
"pytest-icdiff==0.9",
|
"pytest-icdiff==0.9",
|
||||||
"pytest-xdist==3.8.0",
|
"pytest-xdist==3.8.0",
|
||||||
"responses==0.26.1",
|
"responses==0.26.1",
|
||||||
"ruff==0.15.14",
|
"ruff==0.15.19",
|
||||||
"types-requests==2.33.0.20260518",
|
"types-requests==2.33.0.20260518",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
Generated
+508
-517
File diff suppressed because it is too large
Load Diff
@@ -1,4 +1,4 @@
|
|||||||
FROM node:20-alpine AS frontend-deps
|
FROM node:22-alpine AS frontend-deps
|
||||||
|
|
||||||
USER node
|
USER node
|
||||||
|
|
||||||
@@ -39,7 +39,7 @@ ENV VITE_API_BASE_URL=${VITE_API_BASE_URL}
|
|||||||
RUN npm run build
|
RUN npm run build
|
||||||
|
|
||||||
# ---- Front-end image ----
|
# ---- Front-end image ----
|
||||||
FROM nginxinc/nginx-unprivileged:alpine3.23 AS frontend-production
|
FROM nginxinc/nginx-unprivileged:1.30.3-alpine3.23 AS frontend-production
|
||||||
|
|
||||||
USER root
|
USER root
|
||||||
|
|
||||||
|
|||||||
Generated
+135
-20
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "meet",
|
"name": "meet",
|
||||||
"version": "1.22.0",
|
"version": "1.23.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "meet",
|
"name": "meet",
|
||||||
"version": "1.22.0",
|
"version": "1.23.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@fontsource-variable/atkinson-hyperlegible-next": "5.2.6",
|
"@fontsource-variable/atkinson-hyperlegible-next": "5.2.6",
|
||||||
"@fontsource-variable/lexend": "5.2.11",
|
"@fontsource-variable/lexend": "5.2.11",
|
||||||
@@ -15,10 +15,11 @@
|
|||||||
"@livekit/components-react": "2.9.21",
|
"@livekit/components-react": "2.9.21",
|
||||||
"@livekit/components-styles": "1.2.0",
|
"@livekit/components-styles": "1.2.0",
|
||||||
"@livekit/track-processors": "0.7.2",
|
"@livekit/track-processors": "0.7.2",
|
||||||
|
"@mediapipe/tasks-vision": "0.10.14",
|
||||||
"@pandacss/preset-panda": "1.11.3",
|
"@pandacss/preset-panda": "1.11.3",
|
||||||
"@react-types/overlays": "3.10.0",
|
"@react-types/overlays": "3.10.0",
|
||||||
"@remixicon/react": "4.9.0",
|
"@remixicon/react": "4.9.0",
|
||||||
"@tanstack/react-query": "5.100.14",
|
"@tanstack/react-query": "5.101.0",
|
||||||
"@timephy/rnnoise-wasm": "1.0.0",
|
"@timephy/rnnoise-wasm": "1.0.0",
|
||||||
"crisp-sdk-web": "1.1.2",
|
"crisp-sdk-web": "1.1.2",
|
||||||
"derive-valtio": "0.2.0",
|
"derive-valtio": "0.2.0",
|
||||||
@@ -28,8 +29,8 @@
|
|||||||
"i18next-browser-languagedetector": "8.2.1",
|
"i18next-browser-languagedetector": "8.2.1",
|
||||||
"i18next-parser": "9.4.0",
|
"i18next-parser": "9.4.0",
|
||||||
"i18next-resources-to-backend": "1.2.1",
|
"i18next-resources-to-backend": "1.2.1",
|
||||||
"livekit-client": "2.19.0",
|
"livekit-client": "2.19.2",
|
||||||
"posthog-js": "1.386.5",
|
"posthog-js": "1.387.0",
|
||||||
"react": "18.3.1",
|
"react": "18.3.1",
|
||||||
"react-aria": "3.49.0",
|
"react-aria": "3.49.0",
|
||||||
"react-aria-components": "1.18.0",
|
"react-aria-components": "1.18.0",
|
||||||
@@ -63,6 +64,7 @@
|
|||||||
"typescript": "6.0.3",
|
"typescript": "6.0.3",
|
||||||
"typescript-eslint": "8.60.1",
|
"typescript-eslint": "8.60.1",
|
||||||
"vite": "8.0.14",
|
"vite": "8.0.14",
|
||||||
|
"vite-plugin-static-copy": "4.1.1",
|
||||||
"vite-plugin-svgr": "5.2.0"
|
"vite-plugin-svgr": "5.2.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -2357,9 +2359,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@tanstack/query-core": {
|
"node_modules/@tanstack/query-core": {
|
||||||
"version": "5.100.14",
|
"version": "5.101.0",
|
||||||
"resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.100.14.tgz",
|
"resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.101.0.tgz",
|
||||||
"integrity": "sha512-5X41dGpxgeaHISCRW2oYwcSycZeULZzAunaudXT9ov1KOTj9xwt0CH6hbwqP1/z74ZWF7rYFnDpyYH07XFcZew==",
|
"integrity": "sha512-cQetA74EB+seWySv1TTKr828TnP0u39m6LykwDXIo84SNortpDkp30TMEjkqtYCNP9c40uT/iwl6MLiufEt0Ow==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"funding": {
|
"funding": {
|
||||||
"type": "github",
|
"type": "github",
|
||||||
@@ -2378,12 +2380,12 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@tanstack/react-query": {
|
"node_modules/@tanstack/react-query": {
|
||||||
"version": "5.100.14",
|
"version": "5.101.0",
|
||||||
"resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.100.14.tgz",
|
"resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.101.0.tgz",
|
||||||
"integrity": "sha512-oOr6aRdSFEwWhzxEkD/9ZcItM3+LjBSkeVmadWKwUssAHTsqd/7bOjWrX4AbvEkoEhgAxzN0Xk6H/aYzXiYBAw==",
|
"integrity": "sha512-rLlJXSpkqfizLWgkR5+eLeIk0MvTx/meEIR7LRjxic+qxiQP8zVjq7BqQkiCMNLQBlLfuOLqqr6KO5GtrDlmSg==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@tanstack/query-core": "5.100.14"
|
"@tanstack/query-core": "5.101.0"
|
||||||
},
|
},
|
||||||
"funding": {
|
"funding": {
|
||||||
"type": "github",
|
"type": "github",
|
||||||
@@ -3192,6 +3194,19 @@
|
|||||||
"node": ">=6.0.0"
|
"node": ">=6.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/binary-extensions": {
|
||||||
|
"version": "2.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz",
|
||||||
|
"integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/bl": {
|
"node_modules/bl": {
|
||||||
"version": "5.1.0",
|
"version": "5.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/bl/-/bl-5.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/bl/-/bl-5.1.0.tgz",
|
||||||
@@ -7094,6 +7109,19 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/is-binary-path": {
|
||||||
|
"version": "2.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz",
|
||||||
|
"integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"binary-extensions": "^2.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/is-boolean-object": {
|
"node_modules/is-boolean-object": {
|
||||||
"version": "1.2.2",
|
"version": "1.2.2",
|
||||||
"resolved": "https://registry.npmjs.org/is-boolean-object/-/is-boolean-object-1.2.2.tgz",
|
"resolved": "https://registry.npmjs.org/is-boolean-object/-/is-boolean-object-1.2.2.tgz",
|
||||||
@@ -8035,9 +8063,9 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/livekit-client": {
|
"node_modules/livekit-client": {
|
||||||
"version": "2.19.0",
|
"version": "2.19.2",
|
||||||
"resolved": "https://registry.npmjs.org/livekit-client/-/livekit-client-2.19.0.tgz",
|
"resolved": "https://registry.npmjs.org/livekit-client/-/livekit-client-2.19.2.tgz",
|
||||||
"integrity": "sha512-aolY1XDAtx0nHKBNm29W9OhzBnSz1CP5kq3phvRhFfi1NbvMXs8tcACjAkZTnIKgihkp+BiJScZZ3tZv0Gz8sA==",
|
"integrity": "sha512-Kvk07QYDWRAbmYNLRll04ZIuxMQobW/oLPYnmR1kCy8GGHpU0gqyHf704Rz+29zfy8IJZRjKqeVbzGSKn9sumw==",
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@livekit/mutex": "1.1.1",
|
"@livekit/mutex": "1.1.1",
|
||||||
@@ -8675,6 +8703,19 @@
|
|||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/p-map": {
|
||||||
|
"version": "7.0.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/p-map/-/p-map-7.0.5.tgz",
|
||||||
|
"integrity": "sha512-e8vJF4XdVkzqqSHguEMz41mQO1wKwxKm5ENrUJQUu9kLDCtn83cxbyHZcszr4QC5zEA7WffRRC4gsTecC7J9oA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/package-manager-detector": {
|
"node_modules/package-manager-detector": {
|
||||||
"version": "1.6.0",
|
"version": "1.6.0",
|
||||||
"resolved": "https://registry.npmjs.org/package-manager-detector/-/package-manager-detector-1.6.0.tgz",
|
"resolved": "https://registry.npmjs.org/package-manager-detector/-/package-manager-detector-1.6.0.tgz",
|
||||||
@@ -9033,13 +9074,13 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/posthog-js": {
|
"node_modules/posthog-js": {
|
||||||
"version": "1.386.5",
|
"version": "1.387.0",
|
||||||
"resolved": "https://registry.npmjs.org/posthog-js/-/posthog-js-1.386.5.tgz",
|
"resolved": "https://registry.npmjs.org/posthog-js/-/posthog-js-1.387.0.tgz",
|
||||||
"integrity": "sha512-ASejQQf5Xw0XolMwH/KCLZlZtoyLK6VsvORwGagAtfa8/ElIOF76BMQspkDsRTybEI+uzHqRDm2m/na1Dki2mA==",
|
"integrity": "sha512-Pv1jUMySMN62zoAxdJBJPV8n62lkHdjuWhpeU7izczc5Dqbx3hhqO2hkrNTI8Yx1ezmWk2qUHZs03FuOBubdFQ==",
|
||||||
"license": "SEE LICENSE IN LICENSE",
|
"license": "SEE LICENSE IN LICENSE",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@posthog/core": "^1.32.3",
|
"@posthog/core": "^1.33.0",
|
||||||
"@posthog/types": "^1.386.3",
|
"@posthog/types": "^1.387.0",
|
||||||
"core-js": "^3.38.1",
|
"core-js": "^3.38.1",
|
||||||
"dompurify": "^3.3.2",
|
"dompurify": "^3.3.2",
|
||||||
"fflate": "^0.4.8",
|
"fflate": "^0.4.8",
|
||||||
@@ -11137,6 +11178,80 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/vite-plugin-static-copy": {
|
||||||
|
"version": "4.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/vite-plugin-static-copy/-/vite-plugin-static-copy-4.1.1.tgz",
|
||||||
|
"integrity": "sha512-GrlA8YklrAfSyxJ4M3fdQLOo9oNkp56IM9FYgX/WtEgeIFkPwhu4wzpufBCIuNKCa6Fn77FkRdYxkHqV0FwjAw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"chokidar": "^3.6.0",
|
||||||
|
"p-map": "^7.0.4",
|
||||||
|
"picocolors": "^1.1.1",
|
||||||
|
"tinyglobby": "^0.2.17"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": "^22.0.0 || >=24.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/sapphi-red"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"vite": "^6.0.0 || ^7.0.0 || ^8.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/vite-plugin-static-copy/node_modules/chokidar": {
|
||||||
|
"version": "3.6.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz",
|
||||||
|
"integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"anymatch": "~3.1.2",
|
||||||
|
"braces": "~3.0.2",
|
||||||
|
"glob-parent": "~5.1.2",
|
||||||
|
"is-binary-path": "~2.1.0",
|
||||||
|
"is-glob": "~4.0.1",
|
||||||
|
"normalize-path": "~3.0.0",
|
||||||
|
"readdirp": "~3.6.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 8.10.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://paulmillr.com/funding/"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"fsevents": "~2.3.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/vite-plugin-static-copy/node_modules/glob-parent": {
|
||||||
|
"version": "5.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz",
|
||||||
|
"integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"is-glob": "^4.0.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 6"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/vite-plugin-static-copy/node_modules/readdirp": {
|
||||||
|
"version": "3.6.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz",
|
||||||
|
"integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"picomatch": "^2.2.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8.10.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/vite-plugin-svgr": {
|
"node_modules/vite-plugin-svgr": {
|
||||||
"version": "5.2.0",
|
"version": "5.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/vite-plugin-svgr/-/vite-plugin-svgr-5.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/vite-plugin-svgr/-/vite-plugin-svgr-5.2.0.tgz",
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "meet",
|
"name": "meet",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "1.22.0",
|
"version": "1.23.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "panda codegen && vite",
|
"dev": "panda codegen && vite",
|
||||||
@@ -17,15 +17,16 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@fontsource-variable/atkinson-hyperlegible-next": "5.2.6",
|
"@fontsource-variable/atkinson-hyperlegible-next": "5.2.6",
|
||||||
"@fontsource-variable/lexend": "5.2.11",
|
"@fontsource-variable/lexend": "5.2.11",
|
||||||
"@libreaudio/la-call": "0.1.4",
|
|
||||||
"@fontsource/opendyslexic": "5.2.5",
|
"@fontsource/opendyslexic": "5.2.5",
|
||||||
|
"@libreaudio/la-call": "0.1.4",
|
||||||
"@livekit/components-react": "2.9.21",
|
"@livekit/components-react": "2.9.21",
|
||||||
"@livekit/components-styles": "1.2.0",
|
"@livekit/components-styles": "1.2.0",
|
||||||
"@livekit/track-processors": "0.7.2",
|
"@livekit/track-processors": "0.7.2",
|
||||||
|
"@mediapipe/tasks-vision": "0.10.14",
|
||||||
"@pandacss/preset-panda": "1.11.3",
|
"@pandacss/preset-panda": "1.11.3",
|
||||||
"@react-types/overlays": "3.10.0",
|
"@react-types/overlays": "3.10.0",
|
||||||
"@remixicon/react": "4.9.0",
|
"@remixicon/react": "4.9.0",
|
||||||
"@tanstack/react-query": "5.100.14",
|
"@tanstack/react-query": "5.101.0",
|
||||||
"@timephy/rnnoise-wasm": "1.0.0",
|
"@timephy/rnnoise-wasm": "1.0.0",
|
||||||
"crisp-sdk-web": "1.1.2",
|
"crisp-sdk-web": "1.1.2",
|
||||||
"derive-valtio": "0.2.0",
|
"derive-valtio": "0.2.0",
|
||||||
@@ -35,8 +36,8 @@
|
|||||||
"i18next-browser-languagedetector": "8.2.1",
|
"i18next-browser-languagedetector": "8.2.1",
|
||||||
"i18next-parser": "9.4.0",
|
"i18next-parser": "9.4.0",
|
||||||
"i18next-resources-to-backend": "1.2.1",
|
"i18next-resources-to-backend": "1.2.1",
|
||||||
"livekit-client": "2.19.0",
|
"livekit-client": "2.19.2",
|
||||||
"posthog-js": "1.386.5",
|
"posthog-js": "1.387.0",
|
||||||
"react": "18.3.1",
|
"react": "18.3.1",
|
||||||
"react-aria": "3.49.0",
|
"react-aria": "3.49.0",
|
||||||
"react-aria-components": "1.18.0",
|
"react-aria-components": "1.18.0",
|
||||||
@@ -70,6 +71,7 @@
|
|||||||
"typescript": "6.0.3",
|
"typescript": "6.0.3",
|
||||||
"typescript-eslint": "8.60.1",
|
"typescript-eslint": "8.60.1",
|
||||||
"vite": "8.0.14",
|
"vite": "8.0.14",
|
||||||
|
"vite-plugin-static-copy": "4.1.1",
|
||||||
"vite-plugin-svgr": "5.2.0"
|
"vite-plugin-svgr": "5.2.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
@@ -58,6 +58,7 @@ export interface ApiConfig {
|
|||||||
transcription_destination?: string
|
transcription_destination?: string
|
||||||
max_participants_for_sound: number
|
max_participants_for_sound: number
|
||||||
auto_mute_on_join_threshold: number
|
auto_mute_on_join_threshold: number
|
||||||
|
authenticated_users_can_edit_display_name: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
const fetchConfig = (): Promise<ApiConfig> => {
|
const fetchConfig = (): Promise<ApiConfig> => {
|
||||||
|
|||||||
@@ -9,10 +9,11 @@ import { useState } from 'react'
|
|||||||
|
|
||||||
import { menuRecipe } from '@/primitives/menuRecipe'
|
import { menuRecipe } from '@/primitives/menuRecipe'
|
||||||
import { ApiRoom } from '@/features/rooms/api/ApiRoom'
|
import { ApiRoom } from '@/features/rooms/api/ApiRoom'
|
||||||
import { loadUserChoices } from '@livekit/components-core'
|
import { useSnapshot } from 'valtio'
|
||||||
|
import { userStore } from '@/stores/user'
|
||||||
|
|
||||||
export const CreateMeetingMenu = () => {
|
export const CreateMeetingMenu = () => {
|
||||||
const { username } = loadUserChoices()
|
const { username } = useSnapshot(userStore)
|
||||||
|
|
||||||
const { t } = useTranslation('home')
|
const { t } = useTranslation('home')
|
||||||
const { mutateAsync: createRoom } = useCreateRoom()
|
const { mutateAsync: createRoom } = useCreateRoom()
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ export const PipFocusLayout = memo(
|
|||||||
<ParticipantTile
|
<ParticipantTile
|
||||||
key={getTrackKey(mainTrack)}
|
key={getTrackKey(mainTrack)}
|
||||||
trackRef={mainTrack}
|
trackRef={mainTrack}
|
||||||
|
disableTileControls
|
||||||
/>
|
/>
|
||||||
</MainSlot>
|
</MainSlot>
|
||||||
)}
|
)}
|
||||||
@@ -34,6 +35,7 @@ export const PipFocusLayout = memo(
|
|||||||
<ParticipantTile
|
<ParticipantTile
|
||||||
key={getTrackKey(thumbnailTrack)}
|
key={getTrackKey(thumbnailTrack)}
|
||||||
trackRef={thumbnailTrack}
|
trackRef={thumbnailTrack}
|
||||||
|
disableTileControls
|
||||||
/>
|
/>
|
||||||
</Thumbnail>
|
</Thumbnail>
|
||||||
)}
|
)}
|
||||||
@@ -48,9 +50,10 @@ const FocusContainer = styled('div', {
|
|||||||
position: 'relative',
|
position: 'relative',
|
||||||
width: '100%',
|
width: '100%',
|
||||||
height: '100%',
|
height: '100%',
|
||||||
borderRadius: '4px',
|
borderRadius: '8px',
|
||||||
overflow: 'hidden',
|
overflow: 'hidden',
|
||||||
backgroundColor: 'primaryDark.100',
|
backgroundColor: 'primaryDark.100',
|
||||||
|
boxSizing: 'border-box',
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -58,6 +61,8 @@ const MainSlot = styled('div', {
|
|||||||
base: {
|
base: {
|
||||||
width: '100%',
|
width: '100%',
|
||||||
height: '100%',
|
height: '100%',
|
||||||
|
borderRadius: '8px',
|
||||||
|
overflow: 'hidden',
|
||||||
'& .lk-participant-media-video': {
|
'& .lk-participant-media-video': {
|
||||||
objectFit: 'contain',
|
objectFit: 'contain',
|
||||||
},
|
},
|
||||||
@@ -67,13 +72,13 @@ const MainSlot = styled('div', {
|
|||||||
const Thumbnail = styled('div', {
|
const Thumbnail = styled('div', {
|
||||||
base: {
|
base: {
|
||||||
position: 'absolute',
|
position: 'absolute',
|
||||||
right: '1rem',
|
right: '1.25rem',
|
||||||
bottom: '1rem',
|
bottom: '1.25rem',
|
||||||
width: '42%',
|
width: '42%',
|
||||||
maxWidth: '220px',
|
maxWidth: '220px',
|
||||||
minWidth: '140px',
|
minWidth: '140px',
|
||||||
aspectRatio: '16 / 9',
|
aspectRatio: '16 / 9',
|
||||||
borderRadius: '4px',
|
borderRadius: '8px',
|
||||||
overflow: 'hidden',
|
overflow: 'hidden',
|
||||||
boxShadow: 'md',
|
boxShadow: 'md',
|
||||||
zIndex: 2,
|
zIndex: 2,
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ export const PipGridLayout = memo(({ tracks }: PipGridLayoutProps) => {
|
|||||||
<GridContainer ref={containerRef} style={gridStyle}>
|
<GridContainer ref={containerRef} style={gridStyle}>
|
||||||
{tracks.map((track, index) => (
|
{tracks.map((track, index) => (
|
||||||
<GridCell key={getTrackKey(track)} style={placements[index]}>
|
<GridCell key={getTrackKey(track)} style={placements[index]}>
|
||||||
<ParticipantTile trackRef={track} />
|
<ParticipantTile trackRef={track} disableTileControls />
|
||||||
</GridCell>
|
</GridCell>
|
||||||
))}
|
))}
|
||||||
</GridContainer>
|
</GridContainer>
|
||||||
@@ -54,7 +54,8 @@ const GridContainer = styled('div', {
|
|||||||
width: '100%',
|
width: '100%',
|
||||||
height: '100%',
|
height: '100%',
|
||||||
display: 'grid',
|
display: 'grid',
|
||||||
gap: '0.25rem',
|
gap: '0.5rem',
|
||||||
|
boxSizing: 'border-box',
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -63,7 +64,7 @@ const GridCell = styled('div', {
|
|||||||
position: 'relative',
|
position: 'relative',
|
||||||
minWidth: 0,
|
minWidth: 0,
|
||||||
minHeight: 0,
|
minHeight: 0,
|
||||||
borderRadius: '4px',
|
borderRadius: '8px',
|
||||||
overflow: 'hidden',
|
overflow: 'hidden',
|
||||||
backgroundColor: 'primaryDark.100',
|
backgroundColor: 'primaryDark.100',
|
||||||
// Paint on own layer so FLIP transforms don't trigger layout thrash.
|
// Paint on own layer so FLIP transforms don't trigger layout thrash.
|
||||||
|
|||||||
@@ -0,0 +1,97 @@
|
|||||||
|
import { memo } from 'react'
|
||||||
|
import type { TrackReferenceOrPlaceholder } from '@livekit/components-core'
|
||||||
|
import { styled } from '@/styled-system/jsx'
|
||||||
|
import { ParticipantTile } from '@/features/rooms/livekit/components/ParticipantTile'
|
||||||
|
import { getTrackKey } from '../../utils/pipTrackSelection'
|
||||||
|
|
||||||
|
type PipScreenShareLayoutProps = {
|
||||||
|
screenShareTrack: TrackReferenceOrPlaceholder
|
||||||
|
cameraTracks: TrackReferenceOrPlaceholder[]
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Layout when a screen share is active.
|
||||||
|
* Camera tiles are shown as a compact row at the top; the screen share occupies
|
||||||
|
* the remaining space below, much larger than the camera tiles.
|
||||||
|
*/
|
||||||
|
export const PipScreenShareLayout = memo(
|
||||||
|
({ screenShareTrack, cameraTracks }: PipScreenShareLayoutProps) => {
|
||||||
|
return (
|
||||||
|
<LayoutContainer>
|
||||||
|
{cameraTracks.length > 0 && (
|
||||||
|
<CameraTilesRow>
|
||||||
|
{cameraTracks.map((track) => (
|
||||||
|
<CameraTile key={getTrackKey(track)}>
|
||||||
|
<ParticipantTile trackRef={track} disableTileControls />
|
||||||
|
</CameraTile>
|
||||||
|
))}
|
||||||
|
</CameraTilesRow>
|
||||||
|
)}
|
||||||
|
<ScreenShareSlot>
|
||||||
|
<ParticipantTile trackRef={screenShareTrack} disableTileControls />
|
||||||
|
</ScreenShareSlot>
|
||||||
|
</LayoutContainer>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
PipScreenShareLayout.displayName = 'PipScreenShareLayout'
|
||||||
|
|
||||||
|
const LayoutContainer = styled('div', {
|
||||||
|
base: {
|
||||||
|
width: '100%',
|
||||||
|
height: '100%',
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
gap: '0.5rem',
|
||||||
|
boxSizing: 'border-box',
|
||||||
|
overflow: 'hidden',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const CameraTilesRow = styled('div', {
|
||||||
|
base: {
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'row',
|
||||||
|
justifyContent: 'center',
|
||||||
|
gap: '0.5rem',
|
||||||
|
flexShrink: 0,
|
||||||
|
height: '22%',
|
||||||
|
minHeight: '60px',
|
||||||
|
maxHeight: '120px',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const CameraTile = styled('div', {
|
||||||
|
base: {
|
||||||
|
position: 'relative',
|
||||||
|
flex: '0 1 auto',
|
||||||
|
height: '100%',
|
||||||
|
aspectRatio: '16 / 9',
|
||||||
|
minWidth: 0,
|
||||||
|
borderRadius: '8px',
|
||||||
|
overflow: 'hidden',
|
||||||
|
backgroundColor: 'primaryDark.100',
|
||||||
|
'& .lk-participant-tile': {
|
||||||
|
width: '100%',
|
||||||
|
height: '100%',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const ScreenShareSlot = styled('div', {
|
||||||
|
base: {
|
||||||
|
position: 'relative',
|
||||||
|
flex: 1,
|
||||||
|
minHeight: 0,
|
||||||
|
borderRadius: '8px',
|
||||||
|
overflow: 'hidden',
|
||||||
|
backgroundColor: 'primaryDark.100',
|
||||||
|
'& .lk-participant-tile': {
|
||||||
|
width: '100%',
|
||||||
|
height: '100%',
|
||||||
|
},
|
||||||
|
'& .lk-participant-media-video': {
|
||||||
|
objectFit: 'contain',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
@@ -1,10 +1,11 @@
|
|||||||
import { useMemo } from 'react'
|
import React, { useMemo } from 'react'
|
||||||
import { usePagination, useTracks } from '@livekit/components-react'
|
import { usePagination, useTracks } from '@livekit/components-react'
|
||||||
import { RoomEvent, Track } from 'livekit-client'
|
import { RoomEvent, Track } from 'livekit-client'
|
||||||
import { styled } from '@/styled-system/jsx'
|
import { styled } from '@/styled-system/jsx'
|
||||||
import { PipFocusLayout } from './PipFocusLayout'
|
import { PipFocusLayout } from './PipFocusLayout'
|
||||||
import { PipGridLayout } from './PipGridLayout'
|
import { PipGridLayout } from './PipGridLayout'
|
||||||
import { PipPagination } from './PipPagination'
|
import { PipPagination } from './PipPagination'
|
||||||
|
import { PipScreenShareLayout } from './PipScreenShareLayout'
|
||||||
import { StageFrame } from './StageFrame'
|
import { StageFrame } from './StageFrame'
|
||||||
import { MAX_PIP_TILES } from '../../utils/pipGrid'
|
import { MAX_PIP_TILES } from '../../utils/pipGrid'
|
||||||
import {
|
import {
|
||||||
@@ -13,9 +14,12 @@ import {
|
|||||||
} from '@livekit/components-core'
|
} from '@livekit/components-core'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* PipStage picks between two layouts based on track count:
|
* PipStage picks between three layouts:
|
||||||
* - Focus mode (≤ 2 tracks): one main track + one thumbnail overlay.
|
* - Screen share mode (any screen share active):
|
||||||
* - Grid mode (3+ tracks): adaptive tiling.
|
* small camera tiles in a row at the top, large screen share below.
|
||||||
|
* - Grid mode (3+ camera tracks, no screen share): adaptive tiling.
|
||||||
|
* - Focus mode (≤ 2 camera tracks, no screen share): one main track
|
||||||
|
* + one thumbnail overlay.
|
||||||
*/
|
*/
|
||||||
export const PipStage = () => {
|
export const PipStage = () => {
|
||||||
const tracks = useTracks(
|
const tracks = useTracks(
|
||||||
@@ -41,51 +45,55 @@ export const PipStage = () => {
|
|||||||
[tracks]
|
[tracks]
|
||||||
)
|
)
|
||||||
|
|
||||||
// Grid mode order: screen share leads, then cameras (already ordered by
|
// Cap camera tiles in screen-share mode. Called unconditionally for hook rules.
|
||||||
// active speaker via the `ActiveSpeakersChanged` update above).
|
const paginatedCameraTracks = usePagination(MAX_PIP_TILES - 1, cameraTracks)
|
||||||
const gridTracks = useMemo(
|
|
||||||
() =>
|
|
||||||
screenShareTrack ? [screenShareTrack, ...cameraTracks] : cameraTracks,
|
|
||||||
[screenShareTrack, cameraTracks]
|
|
||||||
)
|
|
||||||
|
|
||||||
// Cap the grid at MAX_PIP_TILES per page. `usePagination` keeps the visible
|
// Cap the grid at MAX_PIP_TILES per page for the non-screenshare grid mode.
|
||||||
// page visually stable (active/recent speakers stay put) via its internal
|
const pagination = usePagination(MAX_PIP_TILES, cameraTracks)
|
||||||
// `useVisualStableUpdate`. Called unconditionally to respect hook rules.
|
|
||||||
const pagination = usePagination(MAX_PIP_TILES, gridTracks)
|
|
||||||
|
|
||||||
if (tracks.length === 0) return null
|
if (tracks.length === 0) return null
|
||||||
|
|
||||||
/**
|
// Screen share active
|
||||||
* The focus layout shows one main track with one thumbnail overlay,
|
if (screenShareTrack) {
|
||||||
* so it can only fit 2 tracks. Beyond that we switch to the grid.
|
// Solo presenter: screen share fills the area, camera as small thumbnail
|
||||||
*/
|
if (cameraTracks.length <= 1) {
|
||||||
if (gridTracks.length > 2) {
|
return (
|
||||||
return (
|
|
||||||
<StageWrapper>
|
|
||||||
<StageFrame>
|
<StageFrame>
|
||||||
<PipGridLayout tracks={pagination.tracks} />
|
<PipFocusLayout
|
||||||
|
mainTrack={screenShareTrack}
|
||||||
|
thumbnailTrack={cameraTracks[0]}
|
||||||
|
/>
|
||||||
</StageFrame>
|
</StageFrame>
|
||||||
<PipPagination
|
)
|
||||||
totalPageCount={pagination.totalPageCount}
|
}
|
||||||
currentPage={pagination.currentPage}
|
// Multiple cameras: camera row at top, screen share below
|
||||||
nextPage={pagination.nextPage}
|
return (
|
||||||
prevPage={pagination.prevPage}
|
<PaginatedStage pagination={paginatedCameraTracks}>
|
||||||
|
<PipScreenShareLayout
|
||||||
|
screenShareTrack={screenShareTrack}
|
||||||
|
cameraTracks={paginatedCameraTracks.tracks}
|
||||||
/>
|
/>
|
||||||
</StageWrapper>
|
</PaginatedStage>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 3+ camera tracks → adaptive grid
|
||||||
|
if (cameraTracks.length > 2) {
|
||||||
|
return (
|
||||||
|
<PaginatedStage pagination={pagination}>
|
||||||
|
<PipGridLayout tracks={pagination.tracks} />
|
||||||
|
</PaginatedStage>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ≤ 2 camera tracks → focus layout (main + optional thumbnail)
|
||||||
const localCameraTrack = cameraTracks.find(
|
const localCameraTrack = cameraTracks.find(
|
||||||
(track) => track.participant?.isLocal
|
(track) => track.participant?.isLocal
|
||||||
)
|
)
|
||||||
|
|
||||||
const remoteCameraTrack = cameraTracks.find(
|
const remoteCameraTrack = cameraTracks.find(
|
||||||
(track) => !track.participant?.isLocal
|
(track) => !track.participant?.isLocal
|
||||||
)
|
)
|
||||||
|
const mainTrack = remoteCameraTrack ?? localCameraTrack
|
||||||
const mainTrack = screenShareTrack ?? remoteCameraTrack ?? localCameraTrack
|
|
||||||
|
|
||||||
const thumbnailTrack =
|
const thumbnailTrack =
|
||||||
mainTrack === localCameraTrack ? undefined : localCameraTrack
|
mainTrack === localCameraTrack ? undefined : localCameraTrack
|
||||||
|
|
||||||
@@ -96,6 +104,31 @@ export const PipStage = () => {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type PaginationResult = {
|
||||||
|
totalPageCount: number
|
||||||
|
currentPage: number
|
||||||
|
nextPage: () => void
|
||||||
|
prevPage: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const PaginatedStage = ({
|
||||||
|
pagination,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
pagination: PaginationResult
|
||||||
|
children: React.ReactNode
|
||||||
|
}) => (
|
||||||
|
<StageWrapper>
|
||||||
|
<StageFrame>{children}</StageFrame>
|
||||||
|
<PipPagination
|
||||||
|
totalPageCount={pagination.totalPageCount}
|
||||||
|
currentPage={pagination.currentPage}
|
||||||
|
nextPage={pagination.nextPage}
|
||||||
|
prevPage={pagination.prevPage}
|
||||||
|
/>
|
||||||
|
</StageWrapper>
|
||||||
|
)
|
||||||
|
|
||||||
const StageWrapper = styled('div', {
|
const StageWrapper = styled('div', {
|
||||||
base: {
|
base: {
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
|
|||||||
@@ -25,8 +25,8 @@ const Container = styled('div', {
|
|||||||
flex: 1,
|
flex: 1,
|
||||||
minWidth: 0,
|
minWidth: 0,
|
||||||
minHeight: 0,
|
minHeight: 0,
|
||||||
marginLeft: '0.5rem',
|
padding: '0.5rem',
|
||||||
marginRight: '0.5rem',
|
boxSizing: 'border-box',
|
||||||
borderRadius: '4px',
|
borderRadius: '4px',
|
||||||
overflow: 'hidden',
|
overflow: 'hidden',
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -57,7 +57,9 @@ const RecordingDownload = () => {
|
|||||||
const isSaved =
|
const isSaved =
|
||||||
data?.status === RecordingStatus.Saved ||
|
data?.status === RecordingStatus.Saved ||
|
||||||
data?.status === RecordingStatus.NotificationSucceed ||
|
data?.status === RecordingStatus.NotificationSucceed ||
|
||||||
data?.status === RecordingStatus.FailedToStop
|
data?.status === RecordingStatus.FailedToStop ||
|
||||||
|
data?.status === RecordingStatus.ExternalProcessFailed ||
|
||||||
|
data?.status === RecordingStatus.ExternalProcessSuccessful
|
||||||
|
|
||||||
const pageTitle = useMemo(() => {
|
const pageTitle = useMemo(() => {
|
||||||
if (isError) return `${APP_TITLE} - ${t('error.title')}`
|
if (isError) return `${APP_TITLE} - ${t('error.title')}`
|
||||||
@@ -91,6 +93,8 @@ const RecordingDownload = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (
|
if (
|
||||||
|
data.status !== RecordingStatus.ExternalProcessFailed &&
|
||||||
|
data.status !== RecordingStatus.ExternalProcessSuccessful &&
|
||||||
data.status !== RecordingStatus.Saved &&
|
data.status !== RecordingStatus.Saved &&
|
||||||
data.status !== RecordingStatus.NotificationSucceed &&
|
data.status !== RecordingStatus.NotificationSucceed &&
|
||||||
data.status !== RecordingStatus.FailedToStop
|
data.status !== RecordingStatus.FailedToStop
|
||||||
|
|||||||
@@ -12,4 +12,6 @@ export enum RecordingStatus {
|
|||||||
FailedToStart = 'failedToStart',
|
FailedToStart = 'failedToStart',
|
||||||
FailedToStop = 'failedToStop',
|
FailedToStop = 'failedToStop',
|
||||||
NotificationSucceed = 'notification_succeeded',
|
NotificationSucceed = 'notification_succeeded',
|
||||||
|
ExternalProcessSuccessful = 'external_process_successful',
|
||||||
|
ExternalProcessFailed = 'external_process_failed',
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ export type ApiRoom = {
|
|||||||
id: string
|
id: string
|
||||||
name: string
|
name: string
|
||||||
slug: string
|
slug: string
|
||||||
pin_code: string
|
pin_code?: string
|
||||||
is_administrable: boolean
|
is_administrable: boolean
|
||||||
access_level: ApiAccessLevel
|
access_level: ApiAccessLevel
|
||||||
livekit?: ApiLiveKit
|
livekit?: ApiLiveKit
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ import { PictureInPictureConference } from '@/features/pip/components/PictureInP
|
|||||||
import { notifyAutoMutedOnJoin } from '@/features/notifications/utils'
|
import { notifyAutoMutedOnJoin } from '@/features/notifications/utils'
|
||||||
import { useSnapshot } from 'valtio'
|
import { useSnapshot } from 'valtio'
|
||||||
import { userPreferencesStore } from '@/stores/userPreferences'
|
import { userPreferencesStore } from '@/stores/userPreferences'
|
||||||
|
import { userStore } from '@/stores/user'
|
||||||
|
|
||||||
export const Conference = ({
|
export const Conference = ({
|
||||||
roomId,
|
roomId,
|
||||||
@@ -53,6 +54,8 @@ export const Conference = ({
|
|||||||
userChoices: LocalUserChoices
|
userChoices: LocalUserChoices
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const { username } = useSnapshot(userStore)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
posthog.capture('visit-room', { slug: roomId })
|
posthog.capture('visit-room', { slug: roomId })
|
||||||
}, [roomId, posthog])
|
}, [roomId, posthog])
|
||||||
@@ -83,10 +86,10 @@ export const Conference = ({
|
|||||||
queryFn: () =>
|
queryFn: () =>
|
||||||
fetchRoom({
|
fetchRoom({
|
||||||
roomId: roomId as string,
|
roomId: roomId as string,
|
||||||
username: userConfig.username,
|
username: username,
|
||||||
}).catch((error) => {
|
}).catch((error) => {
|
||||||
if (error.statusCode == '404') {
|
if (error.statusCode == '404') {
|
||||||
createRoom({ slug: roomId, username: userConfig.username })
|
createRoom({ slug: roomId, username })
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
retry: false,
|
retry: false,
|
||||||
|
|||||||
@@ -42,14 +42,17 @@ import {
|
|||||||
saveAudioInputDeviceId,
|
saveAudioInputDeviceId,
|
||||||
saveAudioInputEnabled,
|
saveAudioInputEnabled,
|
||||||
saveAudioOutputDeviceId,
|
saveAudioOutputDeviceId,
|
||||||
saveUsername,
|
|
||||||
saveVideoInputDeviceId,
|
saveVideoInputDeviceId,
|
||||||
saveVideoInputEnabled,
|
saveVideoInputEnabled,
|
||||||
userChoicesStore,
|
userChoicesStore,
|
||||||
} from '@/stores/userChoices'
|
} from '@/stores/userChoices'
|
||||||
|
|
||||||
|
import { saveUsername, userStore } from '@/stores/user'
|
||||||
|
|
||||||
import { useCannotUseDevice } from '../livekit/hooks/useCannotUseDevice'
|
import { useCannotUseDevice } from '../livekit/hooks/useCannotUseDevice'
|
||||||
import { useSnapshot } from 'valtio'
|
import { useSnapshot } from 'valtio'
|
||||||
|
import { useUser } from '@/features/auth/api/useUser'
|
||||||
|
import { useConfig } from '@/api/useConfig'
|
||||||
|
|
||||||
const onError = (e: Error) => console.error('ERROR', e)
|
const onError = (e: Error) => console.error('ERROR', e)
|
||||||
|
|
||||||
@@ -114,6 +117,9 @@ export const Join = ({
|
|||||||
}) => {
|
}) => {
|
||||||
const { t } = useTranslation('rooms', { keyPrefix: 'join' })
|
const { t } = useTranslation('rooms', { keyPrefix: 'join' })
|
||||||
|
|
||||||
|
const { data: configData } = useConfig()
|
||||||
|
const { isLoggedIn, user } = useUser()
|
||||||
|
|
||||||
const {
|
const {
|
||||||
audioEnabled,
|
audioEnabled,
|
||||||
videoEnabled,
|
videoEnabled,
|
||||||
@@ -121,9 +127,10 @@ export const Join = ({
|
|||||||
audioOutputDeviceId,
|
audioOutputDeviceId,
|
||||||
videoDeviceId,
|
videoDeviceId,
|
||||||
processorConfig,
|
processorConfig,
|
||||||
username,
|
|
||||||
} = useSnapshot(userChoicesStore)
|
} = useSnapshot(userChoicesStore)
|
||||||
|
|
||||||
|
const { username } = useSnapshot(userStore)
|
||||||
|
|
||||||
const initialUserChoices = useRef<LocalUserChoices | null>(null)
|
const initialUserChoices = useRef<LocalUserChoices | null>(null)
|
||||||
|
|
||||||
if (initialUserChoices.current === null) {
|
if (initialUserChoices.current === null) {
|
||||||
@@ -134,7 +141,6 @@ export const Join = ({
|
|||||||
audioOutputDeviceId,
|
audioOutputDeviceId,
|
||||||
videoDeviceId,
|
videoDeviceId,
|
||||||
processorConfig,
|
processorConfig,
|
||||||
username,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -448,20 +454,23 @@ export const Join = ({
|
|||||||
<H lvl={1} margin="sm" centered>
|
<H lvl={1} margin="sm" centered>
|
||||||
{t('heading')}
|
{t('heading')}
|
||||||
</H>
|
</H>
|
||||||
<Field
|
{(!isLoggedIn ||
|
||||||
type="text"
|
configData?.authenticated_users_can_edit_display_name) && (
|
||||||
onChange={saveUsername}
|
<Field
|
||||||
label={t('usernameLabel')}
|
type="text"
|
||||||
id="input-name"
|
onChange={saveUsername}
|
||||||
defaultValue={username}
|
label={t('usernameLabel')}
|
||||||
validate={(value) => !value && t('errors.usernameEmpty')}
|
id="input-name"
|
||||||
wrapperProps={{
|
defaultValue={username || user?.full_name}
|
||||||
noMargin: true,
|
validate={(value) => !value && t('errors.usernameEmpty')}
|
||||||
fullWidth: true,
|
wrapperProps={{
|
||||||
}}
|
noMargin: true,
|
||||||
autoComplete="name"
|
fullWidth: true,
|
||||||
maxLength={50}
|
}}
|
||||||
/>
|
autoComplete="name"
|
||||||
|
maxLength={50}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</VStack>
|
</VStack>
|
||||||
</Form>
|
</Form>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import type { Participant } from 'livekit-client'
|
|||||||
import { styled } from '@/styled-system/jsx'
|
import { styled } from '@/styled-system/jsx'
|
||||||
import { Avatar } from '@/components/Avatar'
|
import { Avatar } from '@/components/Avatar'
|
||||||
import { useIsSpeaking } from '@livekit/components-react'
|
import { useIsSpeaking } from '@livekit/components-react'
|
||||||
|
import { getParticipantBackgroundGradient } from '@/features/rooms/utils/getParticipantBackgroundGradient'
|
||||||
import { getParticipantColor } from '@/features/rooms/utils/getParticipantColor'
|
import { getParticipantColor } from '@/features/rooms/utils/getParticipantColor'
|
||||||
import { useSize } from '@/features/rooms/livekit/hooks/useResizeObserver'
|
import { useSize } from '@/features/rooms/livekit/hooks/useResizeObserver'
|
||||||
import { useMemo, useRef } from 'react'
|
import { useMemo, useRef } from 'react'
|
||||||
@@ -10,7 +11,6 @@ const StyledParticipantPlaceHolder = styled('div', {
|
|||||||
base: {
|
base: {
|
||||||
width: '100%',
|
width: '100%',
|
||||||
height: '100%',
|
height: '100%',
|
||||||
backgroundColor: 'primaryDark.100',
|
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
@@ -26,6 +26,10 @@ export const ParticipantPlaceholder = ({
|
|||||||
}: ParticipantPlaceholderProps) => {
|
}: ParticipantPlaceholderProps) => {
|
||||||
const isSpeaking = useIsSpeaking(participant)
|
const isSpeaking = useIsSpeaking(participant)
|
||||||
const participantColor = getParticipantColor(participant)
|
const participantColor = getParticipantColor(participant)
|
||||||
|
const backgroundGradient = useMemo(
|
||||||
|
() => getParticipantBackgroundGradient(participantColor),
|
||||||
|
[participantColor]
|
||||||
|
)
|
||||||
|
|
||||||
const placeholderEl = useRef<HTMLDivElement>(null)
|
const placeholderEl = useRef<HTMLDivElement>(null)
|
||||||
const { width, height } = useSize(placeholderEl)
|
const { width, height } = useSize(placeholderEl)
|
||||||
@@ -39,7 +43,13 @@ export const ParticipantPlaceholder = ({
|
|||||||
const initialSize = useMemo(() => Math.round(avatarSize * 0.3), [avatarSize])
|
const initialSize = useMemo(() => Math.round(avatarSize * 0.3), [avatarSize])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<StyledParticipantPlaceHolder ref={placeholderEl}>
|
<StyledParticipantPlaceHolder
|
||||||
|
ref={placeholderEl}
|
||||||
|
style={{
|
||||||
|
backgroundColor: participantColor,
|
||||||
|
backgroundImage: backgroundGradient,
|
||||||
|
}}
|
||||||
|
>
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
borderRadius: '50%',
|
borderRadius: '50%',
|
||||||
|
|||||||
@@ -52,6 +52,7 @@ export function TrackRefContextIfNeeded(
|
|||||||
|
|
||||||
interface ParticipantTileExtendedProps extends ParticipantTileProps {
|
interface ParticipantTileExtendedProps extends ParticipantTileProps {
|
||||||
disableMetadata?: boolean
|
disableMetadata?: boolean
|
||||||
|
disableTileControls?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ParticipantTile: (
|
export const ParticipantTile: (
|
||||||
@@ -66,6 +67,7 @@ export const ParticipantTile: (
|
|||||||
onParticipantClick,
|
onParticipantClick,
|
||||||
disableSpeakingIndicator,
|
disableSpeakingIndicator,
|
||||||
disableMetadata,
|
disableMetadata,
|
||||||
|
disableTileControls,
|
||||||
...htmlProps
|
...htmlProps
|
||||||
}: ParticipantTileExtendedProps,
|
}: ParticipantTileExtendedProps,
|
||||||
ref
|
ref
|
||||||
@@ -231,7 +233,7 @@ export const ParticipantTile: (
|
|||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{!disableMetadata && (
|
{!disableMetadata && !disableTileControls && (
|
||||||
<ParticipantTileFocus
|
<ParticipantTileFocus
|
||||||
trackRef={trackReference}
|
trackRef={trackReference}
|
||||||
hasKeyboardFocus={hasKeyboardFocus}
|
hasKeyboardFocus={hasKeyboardFocus}
|
||||||
|
|||||||
+4
-5
@@ -13,8 +13,10 @@ import {
|
|||||||
} from './TimerWorker'
|
} from './TimerWorker'
|
||||||
import {
|
import {
|
||||||
BackgroundProcessorInterface,
|
BackgroundProcessorInterface,
|
||||||
|
SELFIE_SEGMENTER_MODEL_PATH,
|
||||||
type ProcessorConfig,
|
type ProcessorConfig,
|
||||||
type ProcessorType,
|
type ProcessorType,
|
||||||
|
MEDIAPIPE_PATH_WASM,
|
||||||
} from '.'
|
} from '.'
|
||||||
|
|
||||||
const PROCESSING_WIDTH = 256
|
const PROCESSING_WIDTH = 256
|
||||||
@@ -153,13 +155,10 @@ export class BackgroundCustomProcessor implements BackgroundProcessorInterface {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async initSegmenter() {
|
async initSegmenter() {
|
||||||
const vision = await FilesetResolver.forVisionTasks(
|
const vision = await FilesetResolver.forVisionTasks(MEDIAPIPE_PATH_WASM)
|
||||||
'https://cdn.jsdelivr.net/npm/@mediapipe/tasks-vision/wasm'
|
|
||||||
)
|
|
||||||
this.imageSegmenter = await ImageSegmenter.createFromOptions(vision, {
|
this.imageSegmenter = await ImageSegmenter.createFromOptions(vision, {
|
||||||
baseOptions: {
|
baseOptions: {
|
||||||
modelAssetPath:
|
modelAssetPath: SELFIE_SEGMENTER_MODEL_PATH,
|
||||||
'https://storage.googleapis.com/mediapipe-models/image_segmenter/selfie_segmenter_landscape/float16/latest/selfie_segmenter_landscape.tflite',
|
|
||||||
delegate: 'CPU', // Use CPU for Firefox.
|
delegate: 'CPU', // Use CPU for Firefox.
|
||||||
},
|
},
|
||||||
runningMode: 'VIDEO',
|
runningMode: 'VIDEO',
|
||||||
|
|||||||
@@ -11,7 +11,11 @@ import {
|
|||||||
TIMEOUT_TICK,
|
TIMEOUT_TICK,
|
||||||
timerWorkerScript,
|
timerWorkerScript,
|
||||||
} from './TimerWorker'
|
} from './TimerWorker'
|
||||||
import { ProcessorType } from '.'
|
import {
|
||||||
|
FACE_LANDMARKS_MODEL_PATH,
|
||||||
|
ProcessorType,
|
||||||
|
MEDIAPIPE_PATH_WASM,
|
||||||
|
} from '.'
|
||||||
|
|
||||||
const PROCESSING_WIDTH = 256 * 3
|
const PROCESSING_WIDTH = 256 * 3
|
||||||
const PROCESSING_HEIGHT = 144 * 3
|
const PROCESSING_HEIGHT = 144 * 3
|
||||||
@@ -128,13 +132,10 @@ export class FaceLandmarksProcessor implements TrackProcessor<Track.Kind> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async initFaceLandmarker() {
|
async initFaceLandmarker() {
|
||||||
const vision = await FilesetResolver.forVisionTasks(
|
const vision = await FilesetResolver.forVisionTasks(MEDIAPIPE_PATH_WASM)
|
||||||
'https://cdn.jsdelivr.net/npm/@mediapipe/tasks-vision/wasm'
|
|
||||||
)
|
|
||||||
this.faceLandmarker = await FaceLandmarker.createFromOptions(vision, {
|
this.faceLandmarker = await FaceLandmarker.createFromOptions(vision, {
|
||||||
baseOptions: {
|
baseOptions: {
|
||||||
modelAssetPath:
|
modelAssetPath: FACE_LANDMARKS_MODEL_PATH,
|
||||||
'https://storage.googleapis.com/mediapipe-models/face_landmarker/face_landmarker/float16/latest/face_landmarker.task',
|
|
||||||
delegate: 'GPU',
|
delegate: 'GPU',
|
||||||
},
|
},
|
||||||
runningMode: 'VIDEO',
|
runningMode: 'VIDEO',
|
||||||
|
|||||||
+19
-4
@@ -1,13 +1,14 @@
|
|||||||
import type { ProcessorOptions, Track } from 'livekit-client'
|
import type { ProcessorOptions, Track } from 'livekit-client'
|
||||||
import {
|
import {
|
||||||
BackgroundBlur,
|
|
||||||
ProcessorWrapper,
|
ProcessorWrapper,
|
||||||
VirtualBackground,
|
BackgroundProcessor,
|
||||||
} from '@livekit/track-processors'
|
} from '@livekit/track-processors'
|
||||||
import {
|
import {
|
||||||
type ProcessorConfig,
|
type ProcessorConfig,
|
||||||
BackgroundProcessorInterface,
|
BackgroundProcessorInterface,
|
||||||
ProcessorType,
|
ProcessorType,
|
||||||
|
MEDIAPIPE_PATH_WASM,
|
||||||
|
SELFIE_SEGMENTER_MODEL_PATH,
|
||||||
} from '.'
|
} from '.'
|
||||||
|
|
||||||
export class UnifiedBackgroundTrackProcessor implements BackgroundProcessorInterface {
|
export class UnifiedBackgroundTrackProcessor implements BackgroundProcessorInterface {
|
||||||
@@ -20,10 +21,24 @@ export class UnifiedBackgroundTrackProcessor implements BackgroundProcessorInter
|
|||||||
|
|
||||||
if (opts.type === 'virtual') {
|
if (opts.type === 'virtual') {
|
||||||
this.processorType = ProcessorType.VIRTUAL
|
this.processorType = ProcessorType.VIRTUAL
|
||||||
this.processor = VirtualBackground(opts.imagePath)
|
this.processor = BackgroundProcessor({
|
||||||
|
mode: 'virtual-background',
|
||||||
|
imagePath: opts.imagePath,
|
||||||
|
assetPaths: {
|
||||||
|
tasksVisionFileSet: MEDIAPIPE_PATH_WASM,
|
||||||
|
modelAssetPath: SELFIE_SEGMENTER_MODEL_PATH,
|
||||||
|
},
|
||||||
|
})
|
||||||
} else if (opts.type === 'blur') {
|
} else if (opts.type === 'blur') {
|
||||||
this.processorType = ProcessorType.BLUR
|
this.processorType = ProcessorType.BLUR
|
||||||
this.processor = BackgroundBlur(opts.blurRadius)
|
this.processor = BackgroundProcessor({
|
||||||
|
mode: 'background-blur',
|
||||||
|
blurRadius: opts.blurRadius,
|
||||||
|
assetPaths: {
|
||||||
|
tasksVisionFileSet: MEDIAPIPE_PATH_WASM,
|
||||||
|
modelAssetPath: SELFIE_SEGMENTER_MODEL_PATH,
|
||||||
|
},
|
||||||
|
})
|
||||||
} else {
|
} else {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
'Must provide either imagePath for virtual background or blurRadius for blur'
|
'Must provide either imagePath for virtual background or blurRadius for blur'
|
||||||
|
|||||||
@@ -4,6 +4,14 @@ import { BackgroundCustomProcessor } from './BackgroundCustomProcessor'
|
|||||||
import { UnifiedBackgroundTrackProcessor } from './UnifiedBackgroundTrackProcessor'
|
import { UnifiedBackgroundTrackProcessor } from './UnifiedBackgroundTrackProcessor'
|
||||||
import { FaceLandmarksOptions } from './FaceLandmarksProcessor'
|
import { FaceLandmarksOptions } from './FaceLandmarksProcessor'
|
||||||
|
|
||||||
|
export const SELFIE_SEGMENTER_MODEL_PATH =
|
||||||
|
'/assets/mediapipe/models/selfie_segmenter_landscape.tflite'
|
||||||
|
|
||||||
|
export const FACE_LANDMARKS_MODEL_PATH =
|
||||||
|
'/assets/mediapipe/models/face_landmarker.task'
|
||||||
|
|
||||||
|
export const MEDIAPIPE_PATH_WASM = '/assets/mediapipe/wasm'
|
||||||
|
|
||||||
export enum ProcessorType {
|
export enum ProcessorType {
|
||||||
BLUR = 'blur',
|
BLUR = 'blur',
|
||||||
VIRTUAL = 'virtual',
|
VIRTUAL = 'virtual',
|
||||||
|
|||||||
+13
-6
@@ -1,23 +1,20 @@
|
|||||||
import { Button } from '@/primitives'
|
import { Button } from '@/primitives'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import type { Participant } from 'livekit-client'
|
import type { Participant } from 'livekit-client'
|
||||||
import { useIsAdminOrOwner } from '@/features/rooms/livekit/hooks/useIsAdminOrOwner'
|
|
||||||
import { useMuteParticipants } from '@/features/rooms/api/muteParticipants'
|
import { useMuteParticipants } from '@/features/rooms/api/muteParticipants'
|
||||||
import { RiMicOffLine } from '@remixicon/react'
|
import { RiMicOffLine } from '@remixicon/react'
|
||||||
import { css } from '@/styled-system/css'
|
import { css } from '@/styled-system/css'
|
||||||
|
import { AdminOrOwnerOnly } from '@/features/rooms/components/AdminOrOwnerOnly'
|
||||||
|
|
||||||
type MuteEveryoneButtonProps = {
|
type MuteEveryoneButtonProps = {
|
||||||
participants: Array<Participant>
|
participants: Array<Participant>
|
||||||
}
|
}
|
||||||
|
|
||||||
export const MuteEveryoneButton = ({
|
const MuteEveryoneButtonInner = ({ participants }: MuteEveryoneButtonProps) => {
|
||||||
participants,
|
|
||||||
}: MuteEveryoneButtonProps) => {
|
|
||||||
const { muteParticipants } = useMuteParticipants()
|
const { muteParticipants } = useMuteParticipants()
|
||||||
const { t } = useTranslation('rooms')
|
const { t } = useTranslation('rooms')
|
||||||
|
|
||||||
const isAdminOrOwner = useIsAdminOrOwner()
|
if (!participants.length) return null
|
||||||
if (!isAdminOrOwner || !participants.length) return null
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Button
|
<Button
|
||||||
@@ -36,3 +33,13 @@ export const MuteEveryoneButton = ({
|
|||||||
</Button>
|
</Button>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const MuteEveryoneButton = ({
|
||||||
|
participants,
|
||||||
|
}: MuteEveryoneButtonProps) => {
|
||||||
|
return (
|
||||||
|
<AdminOrOwnerOnly>
|
||||||
|
<MuteEveryoneButtonInner participants={participants} />
|
||||||
|
</AdminOrOwnerOnly>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
/**
|
||||||
|
* Builds a radial gradient from the participant's avatar color:
|
||||||
|
* brighter in the center (where the avatar sits), darker at the edges.
|
||||||
|
* Mixed in oklch so the color stays vivid as it darkens instead of greying out.
|
||||||
|
*/
|
||||||
|
export const getParticipantBackgroundGradient = (color: string): string =>
|
||||||
|
`radial-gradient(circle at 50% 45%,
|
||||||
|
color-mix(in oklch, ${color} 82%, white) 0%,
|
||||||
|
color-mix(in oklch, ${color} 90%, white) 8%,
|
||||||
|
${color} 35%,
|
||||||
|
color-mix(in oklch, ${color} 85%, black) 65%,
|
||||||
|
color-mix(in oklch, ${color} 65%, black) 100%)`
|
||||||
@@ -8,8 +8,9 @@ import { HStack } from '@/styled-system/jsx'
|
|||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { LoginButton } from '@/components/LoginButton'
|
import { LoginButton } from '@/components/LoginButton'
|
||||||
import { useRenameParticipant } from '@/features/rooms/api/renameParticipant'
|
import { useRenameParticipant } from '@/features/rooms/api/renameParticipant'
|
||||||
import { saveUsername } from '@/stores/userChoices'
|
import { saveUsername } from '@/stores/user'
|
||||||
import { logout } from '@/features/auth/utils/logout'
|
import { logout } from '@/features/auth/utils/logout'
|
||||||
|
import { useConfig } from '@/api/useConfig'
|
||||||
|
|
||||||
export type AccountTabProps = Pick<DialogProps, 'onOpenChange'> &
|
export type AccountTabProps = Pick<DialogProps, 'onOpenChange'> &
|
||||||
Pick<TabPanelProps, 'id'>
|
Pick<TabPanelProps, 'id'>
|
||||||
@@ -17,6 +18,7 @@ export type AccountTabProps = Pick<DialogProps, 'onOpenChange'> &
|
|||||||
export const AccountTab = ({ id, onOpenChange }: AccountTabProps) => {
|
export const AccountTab = ({ id, onOpenChange }: AccountTabProps) => {
|
||||||
const { t } = useTranslation('settings')
|
const { t } = useTranslation('settings')
|
||||||
const room = useRoomContext()
|
const room = useRoomContext()
|
||||||
|
const { data } = useConfig()
|
||||||
const { user, isLoggedIn } = useUser()
|
const { user, isLoggedIn } = useUser()
|
||||||
|
|
||||||
const { renameParticipant } = useRenameParticipant()
|
const { renameParticipant } = useRenameParticipant()
|
||||||
@@ -45,15 +47,17 @@ export const AccountTab = ({ id, onOpenChange }: AccountTabProps) => {
|
|||||||
return (
|
return (
|
||||||
<TabPanel padding={'md'} flex id={id}>
|
<TabPanel padding={'md'} flex id={id}>
|
||||||
<H lvl={2}>{t('account.heading')}</H>
|
<H lvl={2}>{t('account.heading')}</H>
|
||||||
<Field
|
{(!isLoggedIn || data?.authenticated_users_can_edit_display_name) && (
|
||||||
type="text"
|
<Field
|
||||||
label={t('account.nameLabel')}
|
type="text"
|
||||||
value={name}
|
label={t('account.nameLabel')}
|
||||||
onChange={setName}
|
value={name}
|
||||||
validate={(value) => {
|
onChange={setName}
|
||||||
return !value ? <p>{t('account.nameError')}</p> : null
|
validate={(value) => {
|
||||||
}}
|
return !value ? <p>{t('account.nameError')}</p> : null
|
||||||
/>
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
<H lvl={2}>{t('account.authentication')}</H>
|
<H lvl={2}>{t('account.authentication')}</H>
|
||||||
{isLoggedIn ? (
|
{isLoggedIn ? (
|
||||||
<>
|
<>
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import { proxy, subscribe } from 'valtio'
|
||||||
|
import { STORAGE_KEYS } from '@/utils/storageKeys'
|
||||||
|
|
||||||
|
type State = {
|
||||||
|
username: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULT_STATE = {
|
||||||
|
username: '',
|
||||||
|
}
|
||||||
|
|
||||||
|
function getUserState(): State {
|
||||||
|
try {
|
||||||
|
const stored = localStorage.getItem(STORAGE_KEYS.USER)
|
||||||
|
if (!stored) return DEFAULT_STATE
|
||||||
|
const parsed = JSON.parse(stored)
|
||||||
|
return {
|
||||||
|
...parsed,
|
||||||
|
}
|
||||||
|
} catch (error: unknown) {
|
||||||
|
console.error(
|
||||||
|
'[UserPreferencesStore] Failed to parse stored settings:',
|
||||||
|
error
|
||||||
|
)
|
||||||
|
return DEFAULT_STATE
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const userStore = proxy<State>(getUserState())
|
||||||
|
|
||||||
|
subscribe(userStore, () => {
|
||||||
|
localStorage.setItem(STORAGE_KEYS.USER, JSON.stringify(userStore))
|
||||||
|
})
|
||||||
|
|
||||||
|
export const saveUsername = (username: string) => {
|
||||||
|
userStore.username = username
|
||||||
|
}
|
||||||
@@ -12,7 +12,7 @@ import { VideoQuality } from 'livekit-client'
|
|||||||
|
|
||||||
export type VideoResolution = 'h720' | 'h360' | 'h180'
|
export type VideoResolution = 'h720' | 'h360' | 'h180'
|
||||||
|
|
||||||
export type LocalUserChoices = LocalUserChoicesLK & {
|
export type LocalUserChoices = Omit<LocalUserChoicesLK, 'username'> & {
|
||||||
processorConfig?: ProcessorConfig
|
processorConfig?: ProcessorConfig
|
||||||
noiseReductionEnabled?: boolean
|
noiseReductionEnabled?: boolean
|
||||||
audioOutputDeviceId?: string
|
audioOutputDeviceId?: string
|
||||||
@@ -32,7 +32,13 @@ function getUserChoicesState(): LocalUserChoices {
|
|||||||
|
|
||||||
export const userChoicesStore = proxy<LocalUserChoices>(getUserChoicesState())
|
export const userChoicesStore = proxy<LocalUserChoices>(getUserChoicesState())
|
||||||
subscribe(userChoicesStore, () => {
|
subscribe(userChoicesStore, () => {
|
||||||
saveUserChoices(userChoicesStore, false)
|
// TEMPORARY: cast needed because our store omits `username`, which we no
|
||||||
|
// longer persis in this store, while LiveKit's `saveUserChoices` still expects the full
|
||||||
|
// `LocalUserChoices` shape. `username` ends up `undefined` in the saved
|
||||||
|
// object, which `saveUserChoices` tolerates at runtime.
|
||||||
|
// We are migrating away from LiveKit's persistence logic to our own store
|
||||||
|
// handling for more control — this cast can be removed once that lands.
|
||||||
|
saveUserChoices(userChoicesStore as LocalUserChoicesLK, false)
|
||||||
})
|
})
|
||||||
|
|
||||||
// we run some logic on store loading to check if the processor config is still valid
|
// we run some logic on store loading to check if the processor config is still valid
|
||||||
@@ -90,10 +96,6 @@ export const saveVideoSubscribeQuality = (quality: VideoQuality) => {
|
|||||||
userChoicesStore.videoSubscribeQuality = quality
|
userChoicesStore.videoSubscribeQuality = quality
|
||||||
}
|
}
|
||||||
|
|
||||||
export const saveUsername = (username: string) => {
|
|
||||||
userChoicesStore.username = username
|
|
||||||
}
|
|
||||||
|
|
||||||
export const saveNoiseReductionEnabled = (enabled: boolean) => {
|
export const saveNoiseReductionEnabled = (enabled: boolean) => {
|
||||||
userChoicesStore.noiseReductionEnabled = enabled
|
userChoicesStore.noiseReductionEnabled = enabled
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,5 +4,6 @@
|
|||||||
export const STORAGE_KEYS = {
|
export const STORAGE_KEYS = {
|
||||||
NOTIFICATIONS: 'app_notification_settings',
|
NOTIFICATIONS: 'app_notification_settings',
|
||||||
USER_PREFERENCES: 'app_user_preferences',
|
USER_PREFERENCES: 'app_user_preferences',
|
||||||
|
USER: 'app_user',
|
||||||
ACCESSIBILITY: 'app_accessibility_settings',
|
ACCESSIBILITY: 'app_accessibility_settings',
|
||||||
} as const
|
} as const
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { defineConfig, loadEnv } from 'vite'
|
|||||||
import react from '@vitejs/plugin-react'
|
import react from '@vitejs/plugin-react'
|
||||||
import { visualizer } from 'rollup-plugin-visualizer'
|
import { visualizer } from 'rollup-plugin-visualizer'
|
||||||
import svgr from 'vite-plugin-svgr'
|
import svgr from 'vite-plugin-svgr'
|
||||||
|
import { viteStaticCopy } from 'vite-plugin-static-copy'
|
||||||
|
|
||||||
// https://vitejs.dev/config/
|
// https://vitejs.dev/config/
|
||||||
export default defineConfig(({ mode }) => {
|
export default defineConfig(({ mode }) => {
|
||||||
@@ -11,18 +12,32 @@ export default defineConfig(({ mode }) => {
|
|||||||
react(),
|
react(),
|
||||||
svgr({
|
svgr({
|
||||||
svgrOptions: {
|
svgrOptions: {
|
||||||
replaceAttrValues: { '#000': 'currentColor', '#000000': 'currentColor', '#1f1f1f': 'currentColor' },
|
replaceAttrValues: {
|
||||||
|
'#000': 'currentColor',
|
||||||
|
'#000000': 'currentColor',
|
||||||
|
'#1f1f1f': 'currentColor',
|
||||||
|
},
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
env.VITE_ANALYZE === 'true' && visualizer({
|
viteStaticCopy({
|
||||||
open: true,
|
targets: [
|
||||||
filename: 'rollup-plugin-visualizer/stats.html',
|
{
|
||||||
gzipSize: true,
|
src: 'node_modules/@mediapipe/tasks-vision/wasm/*',
|
||||||
brotliSize: true,
|
dest: 'assets/mediapipe/wasm',
|
||||||
|
rename: { stripBase: 4 },
|
||||||
|
},
|
||||||
|
],
|
||||||
}),
|
}),
|
||||||
|
env.VITE_ANALYZE === 'true' &&
|
||||||
|
visualizer({
|
||||||
|
open: true,
|
||||||
|
filename: 'rollup-plugin-visualizer/stats.html',
|
||||||
|
gzipSize: true,
|
||||||
|
brotliSize: true,
|
||||||
|
}),
|
||||||
],
|
],
|
||||||
resolve: {
|
resolve: {
|
||||||
tsconfigPaths: true
|
tsconfigPaths: true,
|
||||||
},
|
},
|
||||||
build: {
|
build: {
|
||||||
sourcemap: env.VITE_BUILD_SOURCEMAP === 'true',
|
sourcemap: env.VITE_BUILD_SOURCEMAP === 'true',
|
||||||
|
|||||||
@@ -28,7 +28,25 @@ _summaryEnvVars: &summaryEnvVars
|
|||||||
AWS_S3_ACCESS_KEY_ID: meet
|
AWS_S3_ACCESS_KEY_ID: meet
|
||||||
AWS_S3_SECRET_ACCESS_KEY: password
|
AWS_S3_SECRET_ACCESS_KEY: password
|
||||||
AWS_S3_SECURE_ACCESS: False
|
AWS_S3_SECURE_ACCESS: False
|
||||||
AUTHORIZED_TENANTS: '[{"id": "dictaphone", "api_key": "dictaphone_token", "webhook_url": "http://dictaphone-backend.dictaphone.svc.cluster.local/api/v1.0/ai-jobs/webhook/", "webhook_api_key": "token_summary"}]'
|
AUTHORIZED_TENANTS: >
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"id": "dictaphone",
|
||||||
|
"api_key": "dictaphone_token",
|
||||||
|
"webhook_url": "http://dictaphone-backend.dictaphone.svc.cluster.local/api/v1.0/ai-jobs/webhook/",
|
||||||
|
"webhook_api_key": "token_summary",
|
||||||
|
"allowed_push_to_docs": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "meet",
|
||||||
|
"api_key": "password",
|
||||||
|
"webhook_url": "https://meet.127.0.0.1.nip.io/api/v1.0/recordings/external-process-hook/",
|
||||||
|
"webhook_api_key": "webhook-password",
|
||||||
|
"allowed_push_to_docs": false
|
||||||
|
}
|
||||||
|
]
|
||||||
|
SSL_CERT_FILE: /usr/local/lib/python3.13/site-packages/certifi/cacert.pem
|
||||||
|
IS_DOCS_INTEGRATION_ENABLED: false
|
||||||
WHISPERX_API_KEY:
|
WHISPERX_API_KEY:
|
||||||
secretKeyRef:
|
secretKeyRef:
|
||||||
name: secret-dev
|
name: secret-dev
|
||||||
@@ -169,9 +187,12 @@ backend:
|
|||||||
RECORDING_ENABLE: True
|
RECORDING_ENABLE: True
|
||||||
RECORDING_STORAGE_EVENT_ENABLE: True
|
RECORDING_STORAGE_EVENT_ENABLE: True
|
||||||
RECORDING_STORAGE_EVENT_TOKEN: password
|
RECORDING_STORAGE_EVENT_TOKEN: password
|
||||||
SUMMARY_SERVICE_ENDPOINT: http://meet-summary:80/api/v1/tasks/
|
SUMMARY_SERVICE_ENDPOINT: http://meet-summary:80/api/v2/async-jobs/transcribe/
|
||||||
SUMMARY_SERVICE_API_TOKEN: password
|
SUMMARY_SERVICE_API_TOKEN: password
|
||||||
|
SUMMARY_SERVICE_WEBHOOK_API_TOKEN: webhook-password
|
||||||
RECORDING_DOWNLOAD_BASE_URL: https://meet.127.0.0.1.nip.io/recording
|
RECORDING_DOWNLOAD_BASE_URL: https://meet.127.0.0.1.nip.io/recording
|
||||||
|
OIDC_USERINFO_FULLNAME_FIELDS: first_name, last_name
|
||||||
|
OIDC_USERINFO_SHORTNAME_FIELD: first_name
|
||||||
|
|
||||||
migrate:
|
migrate:
|
||||||
command:
|
command:
|
||||||
@@ -291,7 +312,7 @@ celeryTranscribe:
|
|||||||
- "--pool=solo"
|
- "--pool=solo"
|
||||||
- "--loglevel=info"
|
- "--loglevel=info"
|
||||||
- "-Q"
|
- "-Q"
|
||||||
- "transcribe-queue,transcribe-queue-v2"
|
- "transcribe-queue-v2"
|
||||||
|
|
||||||
celerySummarize:
|
celerySummarize:
|
||||||
replicas: 1
|
replicas: 1
|
||||||
@@ -308,7 +329,7 @@ celerySummarize:
|
|||||||
- "--pool=solo"
|
- "--pool=solo"
|
||||||
- "--loglevel=info"
|
- "--loglevel=info"
|
||||||
- "-Q"
|
- "-Q"
|
||||||
- "summarize-queue,summarize-queue-v2"
|
- "summarize-queue-v2"
|
||||||
|
|
||||||
celerySummaryBackend:
|
celerySummaryBackend:
|
||||||
replicas: 1
|
replicas: 1
|
||||||
|
|||||||
@@ -1170,6 +1170,8 @@ agentMetadata:
|
|||||||
## @extra agentMetadata.envVars.FROM_SECRET.secretKeyRef.key Key within a Secret when configuring env vars from a Secret
|
## @extra agentMetadata.envVars.FROM_SECRET.secretKeyRef.key Key within a Secret when configuring env vars from a Secret
|
||||||
## @extra agentMetadata.envVars.DEEPGRAM_STT_MODEL Deepgram model to use for speech-to-text (default: nova-3)
|
## @extra agentMetadata.envVars.DEEPGRAM_STT_MODEL Deepgram model to use for speech-to-text (default: nova-3)
|
||||||
## @extra agentMetadata.envVars.DEEPGRAM_STT_LANGUAGE Language code for transcription or 'multi' for automatic multilingual support with real-time code-switching (default: multi, supports: en, es, fr, de, hi, ru, pt, ja, it, nl)
|
## @extra agentMetadata.envVars.DEEPGRAM_STT_LANGUAGE Language code for transcription or 'multi' for automatic multilingual support with real-time code-switching (default: multi, supports: en, es, fr, de, hi, ru, pt, ja, it, nl)
|
||||||
|
## @extra agentMetadata.envVars.SENTRY_DSN Sentry DSN to enable error reporting (disabled when unset)
|
||||||
|
## @extra agentMetadata.envVars.SENTRY_ENVIRONMENT Environment name reported to Sentry (e.g. production, staging)
|
||||||
## @skip agentMetadata.envVars
|
## @skip agentMetadata.envVars
|
||||||
envVars:
|
envVars:
|
||||||
<<: *commonEnvVars
|
<<: *commonEnvVars
|
||||||
@@ -1249,6 +1251,8 @@ agentSubtitles:
|
|||||||
## @extra agentSubtitles.envVars.FROM_SECRET.secretKeyRef.key Key within a Secret when configuring env vars from a Secret
|
## @extra agentSubtitles.envVars.FROM_SECRET.secretKeyRef.key Key within a Secret when configuring env vars from a Secret
|
||||||
## @extra agentSubtitles.envVars.DEEPGRAM_STT_MODEL Deepgram model to use for speech-to-text (default: nova-3)
|
## @extra agentSubtitles.envVars.DEEPGRAM_STT_MODEL Deepgram model to use for speech-to-text (default: nova-3)
|
||||||
## @extra agentSubtitles.envVars.DEEPGRAM_STT_LANGUAGE Language code for transcription or 'multi' for automatic multilingual support with real-time code-switching (default: multi, supports: en, es, fr, de, hi, ru, pt, ja, it, nl)
|
## @extra agentSubtitles.envVars.DEEPGRAM_STT_LANGUAGE Language code for transcription or 'multi' for automatic multilingual support with real-time code-switching (default: multi, supports: en, es, fr, de, hi, ru, pt, ja, it, nl)
|
||||||
|
## @extra agentSubtitles.envVars.SENTRY_DSN Sentry DSN to enable error reporting (disabled when unset)
|
||||||
|
## @extra agentSubtitles.envVars.SENTRY_ENVIRONMENT Environment name reported to Sentry (e.g. production, staging)
|
||||||
## @skip agentSubtitles.envVars
|
## @skip agentSubtitles.envVars
|
||||||
envVars:
|
envVars:
|
||||||
<<: *commonEnvVars
|
<<: *commonEnvVars
|
||||||
|
|||||||
@@ -6,4 +6,4 @@ DIR_MAILS="../backend/core/templates/mail/html/"
|
|||||||
if [ ! -d "${DIR_MAILS}" ]; then
|
if [ ! -d "${DIR_MAILS}" ]; then
|
||||||
mkdir -p "${DIR_MAILS}";
|
mkdir -p "${DIR_MAILS}";
|
||||||
fi
|
fi
|
||||||
mjml mjml/*.mjml -o "${DIR_MAILS}";
|
mjml mjml/*.mjml -o "${DIR_MAILS}" --config.allowIncludes true;
|
||||||
|
|||||||
@@ -2,6 +2,11 @@
|
|||||||
<mj-include path="./partial/header.mjml" />
|
<mj-include path="./partial/header.mjml" />
|
||||||
|
|
||||||
<mj-body mj-class="bg--blue-100">
|
<mj-body mj-class="bg--blue-100">
|
||||||
|
<!--
|
||||||
|
We load django tags here so they appear in the body of the HTML output.
|
||||||
|
This ensures html-to-text also includes them in the plain text template.
|
||||||
|
-->
|
||||||
|
<mj-raw>{% load i18n static extra_tags %}</mj-raw>
|
||||||
<mj-wrapper css-class="wrapper" padding="0 40px 40px 40px">
|
<mj-wrapper css-class="wrapper" padding="0 40px 40px 40px">
|
||||||
<mj-section css-class="wrapper-logo">
|
<mj-section css-class="wrapper-logo">
|
||||||
<mj-column>
|
<mj-column>
|
||||||
@@ -59,7 +64,7 @@
|
|||||||
</mj-column>
|
</mj-column>
|
||||||
</mj-section>
|
</mj-section>
|
||||||
</mj-wrapper>
|
</mj-wrapper>
|
||||||
</mj-body>
|
|
||||||
|
|
||||||
<mj-include path="./partial/footer.mjml" />
|
<mj-include path="./partial/footer.mjml" />
|
||||||
|
</mj-body>
|
||||||
</mjml>
|
</mjml>
|
||||||
|
|||||||
@@ -1,21 +1,16 @@
|
|||||||
<mj-head>
|
<mj-head>
|
||||||
<mj-title>{{ title }}</mj-title>
|
<mj-title>{{ title }}</mj-title>
|
||||||
<mj-preview>
|
<mj-preview>{{ title }}</mj-preview>
|
||||||
<!--
|
<mj-font name="Roboto" href="https://fonts.bunny.net/css?family=roboto:400,700,900" />
|
||||||
We load django tags here, in this way there are put within the body in html output
|
|
||||||
so the html-to-text command includes it within its output
|
|
||||||
-->
|
|
||||||
{% load i18n static extra_tags %}
|
|
||||||
{{ title }}
|
|
||||||
</mj-preview>
|
|
||||||
<mj-attributes>
|
<mj-attributes>
|
||||||
<mj-font name="Roboto" href="https://fonts.googleapis.com/css2?family=Roboto:wght@400;700;900&display=swap" />
|
|
||||||
<mj-all
|
<mj-all
|
||||||
font-family="Roboto, -apple-system, BlinkMacSystemFont, 'Segoe UI', Oxygen, Ubuntu, Cantarell, 'Helvetica Neue', sans-serif"
|
font-family="Roboto, -apple-system, BlinkMacSystemFont, 'Segoe UI', Oxygen, Ubuntu, Cantarell, 'Helvetica Neue', sans-serif"
|
||||||
font-size="16px"
|
font-size="16px"
|
||||||
line-height="1.5em"
|
line-height="1.5em"
|
||||||
color="#3A3A3A"
|
color="#3A3A3A"
|
||||||
/>
|
/>
|
||||||
|
<mj-text font-family="Roboto, sans-serif" />
|
||||||
|
<mj-button font-family="Roboto, sans-serif" />
|
||||||
</mj-attributes>
|
</mj-attributes>
|
||||||
<mj-style>
|
<mj-style>
|
||||||
/* Reset */
|
/* Reset */
|
||||||
|
|||||||
@@ -2,6 +2,11 @@
|
|||||||
<mj-include path="./partial/header.mjml" />
|
<mj-include path="./partial/header.mjml" />
|
||||||
|
|
||||||
<mj-body mj-class="bg--blue-100">
|
<mj-body mj-class="bg--blue-100">
|
||||||
|
<!--
|
||||||
|
We load django tags here so they appear in the body of the HTML output.
|
||||||
|
This ensures html-to-text also includes them in the plain text template.
|
||||||
|
-->
|
||||||
|
<mj-raw>{% load i18n static extra_tags %}</mj-raw>
|
||||||
<mj-wrapper css-class="wrapper" padding="5px 25px 0px 25px">
|
<mj-wrapper css-class="wrapper" padding="5px 25px 0px 25px">
|
||||||
<mj-section css-class="wrapper-logo">
|
<mj-section css-class="wrapper-logo">
|
||||||
<mj-column>
|
<mj-column>
|
||||||
|
|||||||
Generated
+1904
-942
File diff suppressed because it is too large
Load Diff
@@ -1,11 +1,11 @@
|
|||||||
{
|
{
|
||||||
"name": "mail_mjml",
|
"name": "mail_mjml",
|
||||||
"version": "1.22.0",
|
"version": "1.23.0",
|
||||||
"description": "An util to generate html and text django's templates from mjml templates",
|
"description": "An util to generate html and text django's templates from mjml templates",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@html-to/text-cli": "0.5.4",
|
"@html-to/text-cli": "0.6.0",
|
||||||
"mjml": "4.18.0"
|
"mjml": "5.4.0"
|
||||||
},
|
},
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "sdk",
|
"name": "sdk",
|
||||||
"version": "1.22.0",
|
"version": "1.23.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "sdk",
|
"name": "sdk",
|
||||||
"version": "1.22.0",
|
"version": "1.23.0",
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"workspaces": [
|
"workspaces": [
|
||||||
"./library",
|
"./library",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "sdk",
|
"name": "sdk",
|
||||||
"version": "1.22.0",
|
"version": "1.23.0",
|
||||||
"author": "",
|
"author": "",
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"description": "",
|
"description": "",
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "summary"
|
name = "summary"
|
||||||
version = "1.22.0"
|
version = "1.23.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"fastapi[standard]>=0.105.0",
|
"fastapi[standard]>=0.105.0",
|
||||||
"uvicorn>=0.24.0",
|
"uvicorn>=0.24.0",
|
||||||
@@ -10,17 +10,17 @@ dependencies = [
|
|||||||
"celery==5.6.3",
|
"celery==5.6.3",
|
||||||
"redis==5.2.1",
|
"redis==5.2.1",
|
||||||
"minio==7.2.20",
|
"minio==7.2.20",
|
||||||
"openai==2.38.0",
|
"openai==2.44.0",
|
||||||
"posthog==7.15.4",
|
"posthog==7.20.5",
|
||||||
"requests==2.34.2",
|
"requests==2.34.2",
|
||||||
"sentry-sdk[fastapi, celery]==2.60.0",
|
"sentry-sdk[fastapi, celery]==2.63.0",
|
||||||
"langfuse==4.6.1"
|
"langfuse==4.11.0"
|
||||||
]
|
]
|
||||||
|
|
||||||
[project.optional-dependencies]
|
[project.optional-dependencies]
|
||||||
dev = [
|
dev = [
|
||||||
"ruff==0.15.14",
|
"ruff==0.15.19",
|
||||||
"pytest==9.0.3",
|
"pytest==9.1.1",
|
||||||
"responses>=0.25.8",
|
"responses>=0.25.8",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
@@ -2,11 +2,8 @@
|
|||||||
|
|
||||||
from fastapi import APIRouter, Depends
|
from fastapi import APIRouter, Depends
|
||||||
|
|
||||||
from summary.api.route import tasks, tasks_v2
|
from summary.api.route import tasks_v2
|
||||||
from summary.core.security import verify_tenant_api_key
|
from summary.core.security import verify_tenant_api_key
|
||||||
|
|
||||||
api_router_v1 = APIRouter(dependencies=[Depends(verify_tenant_api_key)])
|
|
||||||
api_router_v1.include_router(tasks.router_tasks_v1, tags=["tasks"])
|
|
||||||
|
|
||||||
api_router_v2 = APIRouter(dependencies=[Depends(verify_tenant_api_key)])
|
api_router_v2 = APIRouter(dependencies=[Depends(verify_tenant_api_key)])
|
||||||
api_router_v2.include_router(tasks_v2.router_tasks_v2, tags=["tasks"])
|
api_router_v2.include_router(tasks_v2.router_tasks_v2, tags=["tasks"])
|
||||||
|
|||||||
@@ -1,79 +0,0 @@
|
|||||||
"""API routes related to application tasks."""
|
|
||||||
|
|
||||||
import time
|
|
||||||
from typing import Optional
|
|
||||||
|
|
||||||
from celery.result import AsyncResult
|
|
||||||
from fastapi import APIRouter
|
|
||||||
from pydantic import BaseModel, field_validator
|
|
||||||
|
|
||||||
from summary.core.celery_worker import (
|
|
||||||
process_audio_transcribe_summarize_v2,
|
|
||||||
)
|
|
||||||
from summary.core.config import get_settings
|
|
||||||
|
|
||||||
settings = get_settings()
|
|
||||||
|
|
||||||
|
|
||||||
class TranscribeSummarizeTaskCreation(BaseModel):
|
|
||||||
"""Transcription and summarization parameters."""
|
|
||||||
|
|
||||||
owner_id: str
|
|
||||||
recording_filename: str
|
|
||||||
metadata_filename: Optional[str] = None
|
|
||||||
email: str
|
|
||||||
sub: str
|
|
||||||
version: Optional[int] = 2
|
|
||||||
room: Optional[str]
|
|
||||||
owner_timezone: Optional[str]
|
|
||||||
language: Optional[str]
|
|
||||||
download_link: Optional[str]
|
|
||||||
context_language: Optional[str] = None
|
|
||||||
recording_start_at: Optional[str] = None
|
|
||||||
recording_end_at: Optional[str] = None
|
|
||||||
|
|
||||||
@field_validator("language")
|
|
||||||
@classmethod
|
|
||||||
def validate_language(cls, v):
|
|
||||||
"""Validate 'language' parameter."""
|
|
||||||
if v is not None and v not in settings.whisperx_allowed_languages:
|
|
||||||
raise ValueError(
|
|
||||||
f"Language '{v}' is not allowed. "
|
|
||||||
f"Allowed languages: {', '.join(settings.whisperx_allowed_languages)}"
|
|
||||||
)
|
|
||||||
return v
|
|
||||||
|
|
||||||
|
|
||||||
router_tasks_v1 = APIRouter(prefix="/tasks")
|
|
||||||
|
|
||||||
|
|
||||||
@router_tasks_v1.post("/")
|
|
||||||
async def create_transcribe_summarize_task(request: TranscribeSummarizeTaskCreation):
|
|
||||||
"""Create a transcription and summarization task."""
|
|
||||||
task = process_audio_transcribe_summarize_v2.apply_async(
|
|
||||||
args=[
|
|
||||||
request.owner_id,
|
|
||||||
request.recording_filename,
|
|
||||||
request.metadata_filename,
|
|
||||||
request.email,
|
|
||||||
request.sub,
|
|
||||||
time.time(),
|
|
||||||
request.room,
|
|
||||||
request.owner_timezone,
|
|
||||||
request.language,
|
|
||||||
request.download_link,
|
|
||||||
request.context_language,
|
|
||||||
request.recording_start_at,
|
|
||||||
request.recording_end_at,
|
|
||||||
],
|
|
||||||
queue=settings.transcribe_queue,
|
|
||||||
)
|
|
||||||
|
|
||||||
return {"id": task.id, "message": "Task created"}
|
|
||||||
|
|
||||||
|
|
||||||
@router_tasks_v1.get("/{task_id}")
|
|
||||||
async def get_task_status(task_id: str):
|
|
||||||
"""Check task status by ID."""
|
|
||||||
task = AsyncResult(task_id)
|
|
||||||
return {"id": task_id, "status": task.status}
|
|
||||||
@@ -1,15 +1,19 @@
|
|||||||
"""API routes related to application tasks (V2 / tenant friendly)."""
|
"""API routes related to application tasks (V2 / tenant friendly)."""
|
||||||
|
|
||||||
from celery.result import AsyncResult
|
import logging
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Request
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
from celery.result import AsyncResult
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, Request, status
|
||||||
|
|
||||||
|
from summary.core.analytics import get_analytics
|
||||||
from summary.core.celery_worker import (
|
from summary.core.celery_worker import (
|
||||||
celery,
|
celery,
|
||||||
process_audio_transcribe_v2_task,
|
process_audio_transcribe_v2_task,
|
||||||
summarize_v2_task,
|
summarize_v2_task,
|
||||||
)
|
)
|
||||||
from summary.core.config import AuthorizedTenant
|
from summary.core.config import AuthorizedTenant, get_settings
|
||||||
from summary.core.models import SummarizeTaskV2Request, TranscribeTaskV2Request
|
from summary.core.models import SummarizeTaskApiRequest, TranscribeTaskApiRequest
|
||||||
from summary.core.security import verify_tenant_api_key_v2
|
from summary.core.security import verify_tenant_api_key_v2
|
||||||
from summary.core.shared_models import (
|
from summary.core.shared_models import (
|
||||||
SummarizeWebhookFailurePayload,
|
SummarizeWebhookFailurePayload,
|
||||||
@@ -20,17 +24,51 @@ from summary.core.shared_models import (
|
|||||||
TranscribeWebhookSuccessPayload,
|
TranscribeWebhookSuccessPayload,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
router_tasks_v2 = APIRouter()
|
router_tasks_v2 = APIRouter()
|
||||||
|
|
||||||
|
analytics = get_analytics()
|
||||||
|
settings = get_settings()
|
||||||
|
|
||||||
|
|
||||||
@router_tasks_v2.post("/async-jobs/transcribe")
|
@router_tasks_v2.post("/async-jobs/transcribe")
|
||||||
async def create_transcribe_task_v2(
|
async def create_transcribe_task_v2(
|
||||||
request: TranscribeTaskV2Request,
|
request: TranscribeTaskApiRequest,
|
||||||
request_tenant: AuthorizedTenant = Depends(verify_tenant_api_key_v2),
|
request_tenant: AuthorizedTenant = Depends(verify_tenant_api_key_v2),
|
||||||
):
|
):
|
||||||
"""Create a transcription task."""
|
"""Create a transcription task."""
|
||||||
|
if (
|
||||||
|
request.push_to_docs_config is not None
|
||||||
|
and not request_tenant.allowed_push_to_docs
|
||||||
|
):
|
||||||
|
logger.warning(
|
||||||
|
"Push to docs is not allowed for this tenant (%s).", request_tenant.id
|
||||||
|
)
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail="Push to docs is not allowed for this tenant.",
|
||||||
|
)
|
||||||
|
|
||||||
task = process_audio_transcribe_v2_task.apply_async(
|
task = process_audio_transcribe_v2_task.apply_async(
|
||||||
args=[{**request.model_dump(), "tenant_id": request_tenant.id}]
|
args=[
|
||||||
|
{
|
||||||
|
**request.model_dump(),
|
||||||
|
"tenant_id": request_tenant.id,
|
||||||
|
"received_at": datetime.now(timezone.utc),
|
||||||
|
}
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
properties = {}
|
||||||
|
if request.user_email:
|
||||||
|
properties["$set"] = {"email": request.user_email}
|
||||||
|
|
||||||
|
# We track the request, this also properly initializes the user in the
|
||||||
|
# analytics system, so that later feature flags work properly
|
||||||
|
analytics.capture(
|
||||||
|
settings.posthog_transcript_request,
|
||||||
|
request.user_sub,
|
||||||
|
properties=properties,
|
||||||
)
|
)
|
||||||
|
|
||||||
return TranscribeWebhookPendingPayload(job_id=task.id).model_dump()
|
return TranscribeWebhookPendingPayload(job_id=task.id).model_dump()
|
||||||
@@ -38,14 +76,32 @@ async def create_transcribe_task_v2(
|
|||||||
|
|
||||||
@router_tasks_v2.post("/async-jobs/summarize")
|
@router_tasks_v2.post("/async-jobs/summarize")
|
||||||
async def create_summarize_task_v2(
|
async def create_summarize_task_v2(
|
||||||
request: SummarizeTaskV2Request,
|
request: SummarizeTaskApiRequest,
|
||||||
request_tenant: AuthorizedTenant = Depends(verify_tenant_api_key_v2),
|
request_tenant: AuthorizedTenant = Depends(verify_tenant_api_key_v2),
|
||||||
):
|
):
|
||||||
"""Create a summarization task."""
|
"""Create a summarization task."""
|
||||||
task = summarize_v2_task.apply_async(
|
task = summarize_v2_task.apply_async(
|
||||||
args=[{**request.model_dump(), "tenant_id": request_tenant.id}]
|
args=[
|
||||||
|
{
|
||||||
|
**request.model_dump(),
|
||||||
|
"tenant_id": request_tenant.id,
|
||||||
|
"received_at": datetime.now(timezone.utc),
|
||||||
|
}
|
||||||
|
]
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# We track the request, this also properly initializes the user in the
|
||||||
|
# analytics system, so that later feature flags work properly
|
||||||
|
|
||||||
|
properties = {}
|
||||||
|
if request.user_email:
|
||||||
|
properties["$set"] = {"email": request.user_email}
|
||||||
|
|
||||||
|
analytics.capture(
|
||||||
|
settings.posthog_summary_request,
|
||||||
|
request.user_sub,
|
||||||
|
properties=properties,
|
||||||
|
)
|
||||||
return SummarizeWebhookPendingPayload(job_id=task.id).model_dump()
|
return SummarizeWebhookPendingPayload(job_id=task.id).model_dump()
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -4,12 +4,14 @@ import json
|
|||||||
import time
|
import time
|
||||||
from collections import Counter
|
from collections import Counter
|
||||||
from functools import lru_cache
|
from functools import lru_cache
|
||||||
|
from urllib.parse import urlsplit, urlunsplit
|
||||||
|
|
||||||
import redis
|
import redis
|
||||||
from celery.utils.log import get_task_logger
|
from celery.utils.log import get_task_logger
|
||||||
from posthog import Posthog
|
from posthog import Posthog
|
||||||
|
|
||||||
from summary.core.config import get_settings
|
from summary.core.config import get_settings
|
||||||
|
from summary.core.models import SummarizeTaskJob, TranscribeTaskJob
|
||||||
|
|
||||||
logger = get_task_logger(__name__)
|
logger = get_task_logger(__name__)
|
||||||
settings = get_settings()
|
settings = get_settings()
|
||||||
@@ -107,25 +109,31 @@ class MetadataManager:
|
|||||||
"""Check if task_id exists in tasks metadata cache."""
|
"""Check if task_id exists in tasks metadata cache."""
|
||||||
return self._redis.exists(self._get_redis_key(task_id))
|
return self._redis.exists(self._get_redis_key(task_id))
|
||||||
|
|
||||||
def create(self, task_id, task_args):
|
def create(self, task_id: str, task_payload: TranscribeTaskJob | SummarizeTaskJob):
|
||||||
"""Create initial metadata entry for a new task."""
|
"""Create initial metadata entry for a new task."""
|
||||||
if self._is_disabled or self.has_task_id(task_id):
|
if self._is_disabled or self.has_task_id(task_id):
|
||||||
return
|
return
|
||||||
|
|
||||||
# Positional args mirror process_audio_transcribe_summarize_v2 signature:
|
|
||||||
# owner_id, recording_filename, metadata_filename, email, sub, received_at, ...
|
|
||||||
_, filename, _, email, _, received_at, *_ = task_args
|
|
||||||
|
|
||||||
start_time = time.time()
|
start_time = time.time()
|
||||||
initial_metadata = {
|
initial_metadata = {
|
||||||
"start_time": start_time,
|
"start_time": start_time,
|
||||||
"asr_model": settings.whisperx_asr_model,
|
|
||||||
"retries": 0,
|
"retries": 0,
|
||||||
"filename": filename,
|
"sub": task_payload.user_sub,
|
||||||
"email": email,
|
# avoid None in redis, it shouldn't happen anyway in prod
|
||||||
"queuing_time": round(start_time - received_at, 2),
|
"email": task_payload.user_email or "",
|
||||||
|
"tenant_id": task_payload.tenant_id,
|
||||||
|
"queuing_time": round(start_time - task_payload.received_at.timestamp(), 2),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if isinstance(task_payload, TranscribeTaskJob):
|
||||||
|
parts = urlsplit(task_payload.cloud_storage_url)
|
||||||
|
clean_url = urlunsplit((parts.scheme, parts.netloc, parts.path, "", ""))
|
||||||
|
initial_metadata["source_url"] = clean_url
|
||||||
|
initial_metadata["asr_model"] = settings.whisperx_asr_model
|
||||||
|
elif isinstance(task_payload, SummarizeTaskJob):
|
||||||
|
initial_metadata["content_length"] = len(task_payload.content)
|
||||||
|
initial_metadata["llm_model"] = settings.llm_model
|
||||||
|
|
||||||
self._save_metadata(task_id, initial_metadata)
|
self._save_metadata(task_id, initial_metadata)
|
||||||
|
|
||||||
def retry(self, task_id):
|
def retry(self, task_id):
|
||||||
@@ -210,6 +218,6 @@ class MetadataManager:
|
|||||||
self.clear(task_id)
|
self.clear(task_id)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
self._analytics.capture(event_name, metadata.get("email"), metadata)
|
self._analytics.capture(event_name, metadata.get("sub"), metadata)
|
||||||
except AnalyticsException:
|
except AnalyticsException:
|
||||||
logger.exception("Failed to capture analytics event")
|
logger.exception("Failed to capture analytics event")
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
|
|
||||||
import json
|
import json
|
||||||
import time
|
import time
|
||||||
from datetime import datetime
|
from datetime import datetime, timezone
|
||||||
from typing import Any
|
from typing import Any
|
||||||
from urllib.parse import urljoin
|
from urllib.parse import urljoin
|
||||||
|
|
||||||
@@ -17,16 +17,15 @@ from requests import exceptions
|
|||||||
|
|
||||||
from summary.core.analytics import MetadataManager, get_analytics
|
from summary.core.analytics import MetadataManager, get_analytics
|
||||||
from summary.core.config import get_settings
|
from summary.core.config import get_settings
|
||||||
from summary.core.file_service import (
|
from summary.core.docs_service import create_document_in_lasuite_docs
|
||||||
FileService,
|
from summary.core.file_service import FileService, FileServiceException, TranscribeError
|
||||||
FileServiceException,
|
|
||||||
TranscribeError,
|
|
||||||
)
|
|
||||||
from summary.core.llm_service import LLMException, LLMObservability, LLMService
|
from summary.core.llm_service import LLMException, LLMObservability, LLMService
|
||||||
from summary.core.locales import get_locale
|
from summary.core.locales import get_locale
|
||||||
from summary.core.models import (
|
from summary.core.models import (
|
||||||
SummarizeTaskV2Payload,
|
PushToDocsBaseConfig,
|
||||||
TranscribeTaskV2Payload,
|
RecordingMetadata,
|
||||||
|
SummarizeTaskJob,
|
||||||
|
TranscribeTaskJob,
|
||||||
)
|
)
|
||||||
from summary.core.prompt import (
|
from summary.core.prompt import (
|
||||||
FORMAT_NEXT_STEPS,
|
FORMAT_NEXT_STEPS,
|
||||||
@@ -50,7 +49,6 @@ from summary.core.transcript_formatter import TranscriptFormatter
|
|||||||
from summary.core.user_assign import resolve_speaker_identities
|
from summary.core.user_assign import resolve_speaker_identities
|
||||||
from summary.core.webhook_service import (
|
from summary.core.webhook_service import (
|
||||||
call_webhook_v2,
|
call_webhook_v2,
|
||||||
submit_content,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
settings = get_settings()
|
settings = get_settings()
|
||||||
@@ -86,29 +84,22 @@ file_service = FileService()
|
|||||||
def transcribe_audio(
|
def transcribe_audio(
|
||||||
*,
|
*,
|
||||||
task_id: str,
|
task_id: str,
|
||||||
recording_filename: str | None = None,
|
|
||||||
language: str,
|
language: str,
|
||||||
cloud_storage_url=None,
|
cloud_storage_url: str,
|
||||||
raises: bool = False,
|
raises: bool = False,
|
||||||
):
|
):
|
||||||
"""Transcribe an audio file using WhisperX.
|
"""Transcribe an audio file using WhisperX.
|
||||||
|
|
||||||
Downloads the audio from MinIO or a cloud storage URL, sends it to
|
Downloads the audio from a cloud storage URL, sends it to
|
||||||
WhisperX for transcription, and tracks metadata throughout the process.
|
WhisperX for transcription, and tracks metadata throughout the process.
|
||||||
|
|
||||||
Returns the transcription object, or None if the file could not be retrieved.
|
Returns the transcription object, or None if the file could not be retrieved.
|
||||||
"""
|
"""
|
||||||
if bool(recording_filename) == bool(cloud_storage_url):
|
|
||||||
raise ValueError(
|
|
||||||
"Either filename or cloud_storage_url must be provided, but not both."
|
|
||||||
)
|
|
||||||
|
|
||||||
logger.info("Initiating WhisperX client")
|
logger.info("Initiating WhisperX client")
|
||||||
|
|
||||||
# Transcription
|
# Transcription
|
||||||
try:
|
try:
|
||||||
with file_service.prepare_audio_file(
|
with file_service.prepare_audio_file(
|
||||||
remote_object_key=recording_filename,
|
|
||||||
cloud_storage_url=cloud_storage_url,
|
cloud_storage_url=cloud_storage_url,
|
||||||
) as (audio_file, metadata):
|
) as (audio_file, metadata):
|
||||||
metadata_manager.track(task_id, {"audio_length": metadata["duration"]})
|
metadata_manager.track(task_id, {"audio_length": metadata["duration"]})
|
||||||
@@ -197,11 +188,7 @@ def transcribe_audio(
|
|||||||
cloud_storage_url.split("?", 1)[0] if cloud_storage_url else None
|
cloud_storage_url.split("?", 1)[0] if cloud_storage_url else None
|
||||||
)
|
)
|
||||||
logger.exception(
|
logger.exception(
|
||||||
(
|
("Unexpected error while preparing file %s "),
|
||||||
"Unexpected error while preparing file | filename: %s "
|
|
||||||
"| cloud_storage_url: %s"
|
|
||||||
),
|
|
||||||
recording_filename,
|
|
||||||
redacted_cloud_storage_url,
|
redacted_cloud_storage_url,
|
||||||
)
|
)
|
||||||
return None
|
return None
|
||||||
@@ -211,44 +198,34 @@ def transcribe_audio(
|
|||||||
|
|
||||||
|
|
||||||
def resolve_speaker_identities_and_apply_to(
|
def resolve_speaker_identities_and_apply_to(
|
||||||
transcription, recording_start_at, recording_end_at, metadata_filename, task_id
|
*, transcription: WhisperXResponse, recording_metadata: RecordingMetadata, task_id
|
||||||
):
|
) -> WhisperXResponse:
|
||||||
"""Assign users to detected speakers and rewrite the transcriptions.
|
"""Assign users to detected speakers and rewrite the transcriptions.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
transcription: output of meet-whisperx after transcription and diarization
|
transcription: output of meet-whisperx after transcription and diarization
|
||||||
recording_start_at: sourced from LiveKit FileInfo via the egress_ended webhook
|
recording_metadata: Metadata of the recording
|
||||||
recording_end_at: sourced from LiveKit FileInfo via the egress_ended webhook
|
|
||||||
metadata_filename: name of metadata file containing VAD information in S3
|
|
||||||
task_id: current task id, for logging purposes
|
task_id: current task id, for logging purposes
|
||||||
"""
|
"""
|
||||||
recording_start_dt = (
|
|
||||||
datetime.fromisoformat(recording_start_at) if recording_start_at else None
|
|
||||||
)
|
|
||||||
recording_end_dt = (
|
|
||||||
datetime.fromisoformat(recording_end_at) if recording_end_at else None
|
|
||||||
)
|
|
||||||
|
|
||||||
logger.debug(
|
logger.debug(
|
||||||
"recording_start_dt: %s ; recording_end_dt: %s",
|
"recording_start_dt: %s ; recording_end_dt: %s",
|
||||||
recording_start_dt,
|
recording_metadata.started_at,
|
||||||
recording_end_dt,
|
recording_metadata.ended_at,
|
||||||
)
|
)
|
||||||
if (recording_start_dt is None) or (recording_end_dt is None):
|
|
||||||
logger.debug("Skipping resolve_speaker_identities")
|
|
||||||
return transcription
|
|
||||||
|
|
||||||
logger.debug("Running resolve_speaker_identities")
|
logger.debug("Running resolve_speaker_identities")
|
||||||
try:
|
try:
|
||||||
metadata = file_service.read_json(metadata_filename)
|
metadata = file_service.read_cloud_storage_json(
|
||||||
|
recording_metadata.cloud_storage_url
|
||||||
|
)
|
||||||
speaker_mapping = resolve_speaker_identities(
|
speaker_mapping = resolve_speaker_identities(
|
||||||
metadata,
|
metadata,
|
||||||
transcription,
|
transcription.model_dump(),
|
||||||
recording_start_dt,
|
recording_metadata.started_at,
|
||||||
recording_end_dt,
|
recording_metadata.ended_at,
|
||||||
)
|
)
|
||||||
new_transcription = speaker_mapping.apply_to(transcription.model_dump())
|
new_transcription = speaker_mapping.apply_to(transcription.model_dump())
|
||||||
return new_transcription
|
return WhisperXResponse.model_validate(new_transcription)
|
||||||
|
|
||||||
except FileServiceException as exc:
|
except FileServiceException as exc:
|
||||||
logger.error(
|
logger.error(
|
||||||
@@ -273,12 +250,9 @@ def format_transcript(
|
|||||||
transcription,
|
transcription,
|
||||||
context_language: str | None,
|
context_language: str | None,
|
||||||
language: str,
|
language: str,
|
||||||
room: str | None,
|
|
||||||
recording_datetime: str | None,
|
|
||||||
owner_timezone: str | None,
|
|
||||||
download_link: str | None,
|
download_link: str | None,
|
||||||
form_link: str | None,
|
form_link: str | None,
|
||||||
) -> tuple[str, str]:
|
) -> str:
|
||||||
"""Format a transcription into readable content with a title.
|
"""Format a transcription into readable content with a title.
|
||||||
|
|
||||||
Resolves the locale from context_language / language, then uses
|
Resolves the locale from context_language / language, then uses
|
||||||
@@ -291,9 +265,6 @@ def format_transcript(
|
|||||||
|
|
||||||
return formatter.format(
|
return formatter.format(
|
||||||
transcription,
|
transcription,
|
||||||
room=room,
|
|
||||||
recording_datetime=recording_datetime,
|
|
||||||
owner_timezone=owner_timezone,
|
|
||||||
download_link=download_link,
|
download_link=download_link,
|
||||||
form_link=form_link,
|
form_link=form_link,
|
||||||
)
|
)
|
||||||
@@ -317,138 +288,8 @@ def format_actions(llm_output: dict) -> str:
|
|||||||
return ""
|
return ""
|
||||||
|
|
||||||
|
|
||||||
@celery.task(
|
|
||||||
bind=True,
|
|
||||||
autoretry_for=[exceptions.HTTPError],
|
|
||||||
max_retries=settings.celery_max_retries,
|
|
||||||
queue=settings.transcribe_queue,
|
|
||||||
)
|
|
||||||
def process_audio_transcribe_summarize_v2(
|
|
||||||
self,
|
|
||||||
owner_id: str,
|
|
||||||
recording_filename: str,
|
|
||||||
metadata_filename: str | None,
|
|
||||||
email: str,
|
|
||||||
sub: str,
|
|
||||||
received_at: float,
|
|
||||||
room: str | None,
|
|
||||||
owner_timezone: str | None,
|
|
||||||
language: str | None,
|
|
||||||
download_link: str | None,
|
|
||||||
context_language: str | None = None,
|
|
||||||
recording_start_at: str | None = None,
|
|
||||||
recording_end_at: str | None = None,
|
|
||||||
):
|
|
||||||
"""Process an audio file by transcribing it and generating a summary.
|
|
||||||
|
|
||||||
This Celery task orchestrates:
|
|
||||||
1. Audio transcription via WhisperX
|
|
||||||
2. Transcript formatting
|
|
||||||
3. Webhook submission
|
|
||||||
4. Conditional summarization queuing
|
|
||||||
|
|
||||||
Args:
|
|
||||||
self: Celery task instance (passed on with bind=True)
|
|
||||||
owner_id: Unique identifier of the recording owner.
|
|
||||||
recording_filename: Name of the audio file in MinIO storage.
|
|
||||||
metadata_filename: Name of the audio file in MinIO storage.
|
|
||||||
email: Email address of the recording owner.
|
|
||||||
sub: OIDC subject identifier of the recording owner.
|
|
||||||
received_at: Unix timestamp when the recording was received.
|
|
||||||
room: room name where the recording took place.
|
|
||||||
owner_timezone: IANA timezone of the recording owner (e.g. "Europe/Paris").
|
|
||||||
language: ISO 639-1 language code for transcription.
|
|
||||||
download_link: URL to download the original recording.
|
|
||||||
context_language: ISO 639-1 language code of the meeting summary context text.
|
|
||||||
recording_start_at: ISO 8601 timestamp of when file recording actually started
|
|
||||||
(from LiveKit FileInfo.started_at via the egress_ended webhook).
|
|
||||||
recording_end_at: ISO 8601 timestamp of when file recording ended
|
|
||||||
(from LiveKit FileInfo.ended_at via the egress_ended webhook).
|
|
||||||
"""
|
|
||||||
logger.info(
|
|
||||||
"Notification received | Owner: %s | Room: %s",
|
|
||||||
owner_id,
|
|
||||||
room,
|
|
||||||
)
|
|
||||||
|
|
||||||
task_id = self.request.id
|
|
||||||
|
|
||||||
# Transcribe the audio
|
|
||||||
transcription = transcribe_audio(
|
|
||||||
task_id=task_id, recording_filename=recording_filename, language=language
|
|
||||||
)
|
|
||||||
if transcription is None:
|
|
||||||
return
|
|
||||||
|
|
||||||
# Assign speakers and rewrite transcription/diarization output
|
|
||||||
if settings.is_resolve_speaker_identities_enabled and (
|
|
||||||
metadata_filename is not None
|
|
||||||
):
|
|
||||||
transcription = resolve_speaker_identities_and_apply_to(
|
|
||||||
transcription,
|
|
||||||
recording_start_at,
|
|
||||||
recording_end_at,
|
|
||||||
metadata_filename,
|
|
||||||
task_id,
|
|
||||||
)
|
|
||||||
|
|
||||||
form_base_url = settings.transcription_satisfaction_form_base_url
|
|
||||||
form_link = (
|
|
||||||
f"{form_base_url}?room_id={room}"
|
|
||||||
if (form_base_url and metadata_filename is not None)
|
|
||||||
else None
|
|
||||||
)
|
|
||||||
|
|
||||||
# Format output
|
|
||||||
content, title = format_transcript(
|
|
||||||
transcription,
|
|
||||||
context_language,
|
|
||||||
language,
|
|
||||||
room,
|
|
||||||
recording_start_at,
|
|
||||||
owner_timezone,
|
|
||||||
download_link,
|
|
||||||
form_link,
|
|
||||||
)
|
|
||||||
|
|
||||||
submit_content(content, title, email, sub)
|
|
||||||
metadata_manager.capture(task_id, settings.posthog_event_success)
|
|
||||||
|
|
||||||
# LLM Summarization
|
|
||||||
if (
|
|
||||||
analytics.is_feature_enabled("summary-enabled", distinct_id=owner_id)
|
|
||||||
and settings.is_summary_enabled
|
|
||||||
):
|
|
||||||
logger.info("Queuing summary generation task.")
|
|
||||||
summarize_transcription.apply_async(
|
|
||||||
args=[owner_id, content, email, sub, title],
|
|
||||||
queue=settings.summarize_queue,
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
logger.info("Summary generation not enabled for this user. Skipping.")
|
|
||||||
|
|
||||||
|
|
||||||
@signals.task_prerun.connect(sender=process_audio_transcribe_summarize_v2)
|
|
||||||
def task_started(task_id=None, task=None, args=None, **kwargs):
|
|
||||||
"""Signal handler called before task execution begins."""
|
|
||||||
task_args = args or []
|
|
||||||
metadata_manager.create(task_id, task_args)
|
|
||||||
|
|
||||||
|
|
||||||
@signals.task_retry.connect(sender=process_audio_transcribe_summarize_v2)
|
|
||||||
def task_retry_handler(request=None, reason=None, einfo=None, **kwargs):
|
|
||||||
"""Signal handler called when task execution retries."""
|
|
||||||
metadata_manager.retry(request.id)
|
|
||||||
|
|
||||||
|
|
||||||
@signals.task_failure.connect(sender=process_audio_transcribe_summarize_v2)
|
|
||||||
def task_failure_handler(task_id, exception=None, **kwargs):
|
|
||||||
"""Signal handler called when task execution fails permanently."""
|
|
||||||
metadata_manager.capture(task_id, settings.posthog_event_failure)
|
|
||||||
|
|
||||||
|
|
||||||
def summarize_transcription_internals(
|
def summarize_transcription_internals(
|
||||||
*, owner_id: str, transcript: str, session_id: str
|
*, distinct_id: str, transcript: str, session_id: str
|
||||||
) -> str:
|
) -> str:
|
||||||
"""Generate a summary from the provided transcription text.
|
"""Generate a summary from the provided transcription text.
|
||||||
|
|
||||||
@@ -459,11 +300,11 @@ def summarize_transcription_internals(
|
|||||||
"""
|
"""
|
||||||
logger.info(
|
logger.info(
|
||||||
"Starting summarization task | Owner: %s",
|
"Starting summarization task | Owner: %s",
|
||||||
owner_id,
|
distinct_id,
|
||||||
)
|
)
|
||||||
|
|
||||||
user_has_tracing_consent = analytics.is_feature_enabled(
|
user_has_tracing_consent = analytics.is_feature_enabled(
|
||||||
"summary-tracing-consent", distinct_id=owner_id
|
"summary-tracing-consent", distinct_id=distinct_id
|
||||||
)
|
)
|
||||||
|
|
||||||
# NOTE: We must instantiate a new LLMObservability client for each task invocation
|
# NOTE: We must instantiate a new LLMObservability client for each task invocation
|
||||||
@@ -474,7 +315,7 @@ def summarize_transcription_internals(
|
|||||||
llm_observability = LLMObservability(
|
llm_observability = LLMObservability(
|
||||||
user_has_tracing_consent=user_has_tracing_consent,
|
user_has_tracing_consent=user_has_tracing_consent,
|
||||||
session_id=session_id,
|
session_id=session_id,
|
||||||
user_id=owner_id,
|
user_id=distinct_id,
|
||||||
)
|
)
|
||||||
llm_service = LLMService(llm_observability=llm_observability)
|
llm_service = LLMService(llm_observability=llm_observability)
|
||||||
|
|
||||||
@@ -526,34 +367,52 @@ def summarize_transcription_internals(
|
|||||||
return summary
|
return summary
|
||||||
|
|
||||||
|
|
||||||
@celery.task(
|
|
||||||
bind=True,
|
|
||||||
autoretry_for=[LLMException, Exception],
|
|
||||||
max_retries=settings.celery_max_retries,
|
|
||||||
queue=settings.summarize_queue,
|
|
||||||
)
|
|
||||||
def summarize_transcription(
|
|
||||||
self, owner_id: str, transcript: str, email: str, sub: str, title: str
|
|
||||||
):
|
|
||||||
"""Generate a summary from the provided transcription text.
|
|
||||||
|
|
||||||
This Celery task performs the following operations:
|
|
||||||
1. Run summary internals
|
|
||||||
2. Sends the final summary via webhook.
|
|
||||||
"""
|
|
||||||
summary = summarize_transcription_internals(
|
|
||||||
owner_id=owner_id, transcript=transcript, session_id=self.request.id
|
|
||||||
)
|
|
||||||
summary_title = settings.summary_title_template.format(title=title)
|
|
||||||
|
|
||||||
submit_content(summary, summary_title, email, sub)
|
|
||||||
|
|
||||||
|
|
||||||
##################################################################################
|
##################################################################################
|
||||||
# Tasks v2
|
# Tasks v2
|
||||||
##################################################################################
|
##################################################################################
|
||||||
|
|
||||||
|
|
||||||
|
def _should_push_to_docs(
|
||||||
|
payload: TranscribeTaskJob | SummarizeTaskJob,
|
||||||
|
) -> bool:
|
||||||
|
"""Determines if the transcription should be pushed to docs.
|
||||||
|
|
||||||
|
Based on the payload and settings.
|
||||||
|
"""
|
||||||
|
if not payload.push_to_docs_config:
|
||||||
|
reason = "Push to docs is not requested in the payload"
|
||||||
|
elif not settings.is_lasuite_docs_integration_enabled:
|
||||||
|
reason = "Docs integration is disabled"
|
||||||
|
elif not settings.get_authorized_tenant(
|
||||||
|
tenant_id=payload.tenant_id
|
||||||
|
).allowed_push_to_docs:
|
||||||
|
reason = "Tenant is not allowed to push to docs"
|
||||||
|
else:
|
||||||
|
return True
|
||||||
|
|
||||||
|
logger.info("Push to docs is not requested: %s", reason)
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _should_auto_create_summary(payload: TranscribeTaskJob) -> bool:
|
||||||
|
"""Determines if the transcription should have an auto-created summary.
|
||||||
|
|
||||||
|
Based on the payload and settings.
|
||||||
|
"""
|
||||||
|
if (
|
||||||
|
payload.push_to_docs_config is None
|
||||||
|
or not payload.push_to_docs_config.auto_create_summary
|
||||||
|
):
|
||||||
|
reason = "Auto create summary is not requested in the payload"
|
||||||
|
elif not settings.is_summary_enabled:
|
||||||
|
reason = "Summary feature is disabled"
|
||||||
|
else:
|
||||||
|
return True
|
||||||
|
|
||||||
|
logger.info("Auto create summary is not requested: %s", reason)
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
@celery.task(
|
@celery.task(
|
||||||
max_retries=3,
|
max_retries=3,
|
||||||
queue=settings.call_webhook_queue_v2,
|
queue=settings.call_webhook_queue_v2,
|
||||||
@@ -590,7 +449,7 @@ def process_audio_transcribe_v2_task(
|
|||||||
self: Celery task instance (passed on with bind=True)
|
self: Celery task instance (passed on with bind=True)
|
||||||
payload: Serialized dictionary of TranscribeSummarizeTaskCreationV2
|
payload: Serialized dictionary of TranscribeSummarizeTaskCreationV2
|
||||||
"""
|
"""
|
||||||
payload = TranscribeTaskV2Payload.model_validate(payload)
|
payload = TranscribeTaskJob.model_validate(payload)
|
||||||
logger.info(
|
logger.info(
|
||||||
"Transcribing for object received | Owner: %s",
|
"Transcribing for object received | Owner: %s",
|
||||||
payload.user_sub,
|
payload.user_sub,
|
||||||
@@ -617,6 +476,60 @@ def process_audio_transcribe_v2_task(
|
|||||||
)
|
)
|
||||||
return failure_payload.model_dump()
|
return failure_payload.model_dump()
|
||||||
|
|
||||||
|
# Assign speakers and rewrite transcription/diarization output
|
||||||
|
if settings.is_resolve_speaker_identities_enabled and payload.metadata is not None:
|
||||||
|
try:
|
||||||
|
transcription_res = resolve_speaker_identities_and_apply_to(
|
||||||
|
transcription=transcription_res,
|
||||||
|
recording_metadata=payload.metadata,
|
||||||
|
task_id=job_id,
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to resolve speaker identities, skipping: {e}")
|
||||||
|
|
||||||
|
should_push_to_docs = _should_push_to_docs(payload)
|
||||||
|
# We do it synchronously for now
|
||||||
|
if should_push_to_docs:
|
||||||
|
if payload.push_to_docs_config is None:
|
||||||
|
raise ValueError("Push to docs config is missing")
|
||||||
|
|
||||||
|
# Format output
|
||||||
|
content = format_transcript(
|
||||||
|
transcription_res.model_dump(),
|
||||||
|
payload.context_language,
|
||||||
|
payload.language,
|
||||||
|
payload.push_to_docs_config.download_link,
|
||||||
|
payload.push_to_docs_config.form_link,
|
||||||
|
)
|
||||||
|
|
||||||
|
create_document_in_lasuite_docs(
|
||||||
|
content=content,
|
||||||
|
title=payload.push_to_docs_config.title,
|
||||||
|
email=payload.push_to_docs_config.user_email,
|
||||||
|
sub=payload.user_sub,
|
||||||
|
)
|
||||||
|
|
||||||
|
if _should_auto_create_summary(payload):
|
||||||
|
locale = get_locale(payload.context_language, payload.language)
|
||||||
|
|
||||||
|
summarize_v2_task.apply_async(
|
||||||
|
args=[
|
||||||
|
SummarizeTaskJob(
|
||||||
|
received_at=datetime.now(timezone.utc),
|
||||||
|
tenant_id=payload.tenant_id,
|
||||||
|
user_sub=payload.user_sub,
|
||||||
|
user_email=payload.user_email,
|
||||||
|
push_to_docs_config=PushToDocsBaseConfig(
|
||||||
|
user_email=payload.push_to_docs_config.user_email,
|
||||||
|
title=locale.summary_title_template.format(
|
||||||
|
title=payload.push_to_docs_config.title
|
||||||
|
),
|
||||||
|
),
|
||||||
|
content=content,
|
||||||
|
).model_dump()
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
file_service.store_transcript(
|
file_service.store_transcript(
|
||||||
transcript=transcription_res,
|
transcript=transcription_res,
|
||||||
job_id=job_id,
|
job_id=job_id,
|
||||||
@@ -629,9 +542,30 @@ def process_audio_transcribe_v2_task(
|
|||||||
call_webhook_v2_task.apply_async(
|
call_webhook_v2_task.apply_async(
|
||||||
args=[success_payload.model_dump(), payload.tenant_id]
|
args=[success_payload.model_dump(), payload.tenant_id]
|
||||||
)
|
)
|
||||||
|
metadata_manager.capture(job_id, settings.posthog_transcript_success)
|
||||||
|
|
||||||
return success_payload.model_dump()
|
return success_payload.model_dump()
|
||||||
|
|
||||||
|
|
||||||
|
@signals.task_prerun.connect(sender=process_audio_transcribe_v2_task)
|
||||||
|
def task_started_transcript(task_id=None, task=None, args=None, **kwargs):
|
||||||
|
"""Signal handler called before task execution begins."""
|
||||||
|
if args:
|
||||||
|
metadata_manager.create(task_id, TranscribeTaskJob.model_validate(args[0]))
|
||||||
|
|
||||||
|
|
||||||
|
@signals.task_retry.connect(sender=process_audio_transcribe_v2_task)
|
||||||
|
def task_retry_handler_transcript(request=None, reason=None, einfo=None, **kwargs):
|
||||||
|
"""Signal handler called when task execution retries."""
|
||||||
|
metadata_manager.retry(request.id)
|
||||||
|
|
||||||
|
|
||||||
|
@signals.task_failure.connect(sender=process_audio_transcribe_v2_task)
|
||||||
|
def task_failure_handler_transcript(task_id, exception=None, **kwargs):
|
||||||
|
"""Signal handler called when task execution fails permanently."""
|
||||||
|
metadata_manager.capture(task_id, settings.posthog_transcript_failure)
|
||||||
|
|
||||||
|
|
||||||
@signals.task_failure.connect(sender=process_audio_transcribe_v2_task)
|
@signals.task_failure.connect(sender=process_audio_transcribe_v2_task)
|
||||||
def handle_transcribe_v2_failed(
|
def handle_transcribe_v2_failed(
|
||||||
sender,
|
sender,
|
||||||
@@ -687,15 +621,26 @@ def summarize_v2_task(
|
|||||||
1. Run summary internals
|
1. Run summary internals
|
||||||
2. Sends the final summary via webhook.
|
2. Sends the final summary via webhook.
|
||||||
"""
|
"""
|
||||||
payload = SummarizeTaskV2Payload.model_validate(payload)
|
payload = SummarizeTaskJob.model_validate(payload)
|
||||||
summary = summarize_transcription_internals(
|
summary = summarize_transcription_internals(
|
||||||
owner_id=payload.user_sub,
|
distinct_id=payload.user_sub,
|
||||||
transcript=payload.content,
|
transcript=payload.content,
|
||||||
session_id=self.request.id,
|
session_id=self.request.id,
|
||||||
)
|
)
|
||||||
job_id = self.request.id
|
job_id = self.request.id
|
||||||
file_service.store_summary(summary=summary, job_id=job_id)
|
file_service.store_summary(summary=summary, job_id=job_id)
|
||||||
|
|
||||||
|
if _should_push_to_docs(payload):
|
||||||
|
if payload.push_to_docs_config is None:
|
||||||
|
raise ValueError("Push to docs config is missing")
|
||||||
|
|
||||||
|
create_document_in_lasuite_docs(
|
||||||
|
content=summary,
|
||||||
|
title=payload.push_to_docs_config.title,
|
||||||
|
email=payload.push_to_docs_config.user_email,
|
||||||
|
sub=payload.user_sub,
|
||||||
|
)
|
||||||
|
|
||||||
success_payload = SummarizeWebhookSuccessPayload(
|
success_payload = SummarizeWebhookSuccessPayload(
|
||||||
job_id=job_id,
|
job_id=job_id,
|
||||||
summary_data_url=file_service.get_summary_signed_url(job_id),
|
summary_data_url=file_service.get_summary_signed_url(job_id),
|
||||||
@@ -703,9 +648,30 @@ def summarize_v2_task(
|
|||||||
call_webhook_v2_task.apply_async(
|
call_webhook_v2_task.apply_async(
|
||||||
args=[success_payload.model_dump(), payload.tenant_id]
|
args=[success_payload.model_dump(), payload.tenant_id]
|
||||||
)
|
)
|
||||||
|
metadata_manager.capture(job_id, settings.posthog_summary_success)
|
||||||
|
|
||||||
return success_payload.model_dump()
|
return success_payload.model_dump()
|
||||||
|
|
||||||
|
|
||||||
|
@signals.task_prerun.connect(sender=summarize_v2_task)
|
||||||
|
def task_started_summary(task_id=None, task=None, args=None, **kwargs):
|
||||||
|
"""Signal handler called before task execution begins."""
|
||||||
|
if args:
|
||||||
|
metadata_manager.create(task_id, SummarizeTaskJob.model_validate(args[0]))
|
||||||
|
|
||||||
|
|
||||||
|
@signals.task_retry.connect(sender=summarize_v2_task)
|
||||||
|
def task_retry_handler_summary(request=None, reason=None, einfo=None, **kwargs):
|
||||||
|
"""Signal handler called when task execution retries."""
|
||||||
|
metadata_manager.retry(request.id)
|
||||||
|
|
||||||
|
|
||||||
|
@signals.task_failure.connect(sender=summarize_v2_task)
|
||||||
|
def task_failure_handler_summary(task_id, exception=None, **kwargs):
|
||||||
|
"""Signal handler called when task execution fails permanently."""
|
||||||
|
metadata_manager.capture(task_id, settings.posthog_summary_failure)
|
||||||
|
|
||||||
|
|
||||||
@signals.task_failure.connect(sender=summarize_v2_task)
|
@signals.task_failure.connect(sender=summarize_v2_task)
|
||||||
def handle_summarize_v2_failed(
|
def handle_summarize_v2_failed(
|
||||||
sender,
|
sender,
|
||||||
|
|||||||
@@ -1,9 +1,8 @@
|
|||||||
"""Application configuration and settings."""
|
"""Application configuration and settings."""
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
import os
|
|
||||||
from functools import cached_property, lru_cache
|
from functools import cached_property, lru_cache
|
||||||
from typing import Annotated, Any, List, Literal, Mapping, Optional, Set
|
from typing import Annotated, List, Mapping, Optional, Set
|
||||||
|
|
||||||
from fastapi import Depends
|
from fastapi import Depends
|
||||||
from pydantic import (
|
from pydantic import (
|
||||||
@@ -34,9 +33,12 @@ class AuthorizedTenant(BaseModel):
|
|||||||
title="Webhook API Key",
|
title="Webhook API Key",
|
||||||
description="The api_key to authenticate the webhook request.",
|
description="The api_key to authenticate the webhook request.",
|
||||||
)
|
)
|
||||||
|
allowed_push_to_docs: bool = Field(
|
||||||
|
title="Allow Push to Docs",
|
||||||
V1_DEFAULT_TENANT_ID = "__deprecated_meet_tenant__"
|
description="Whether to allow pushing transcript"
|
||||||
|
" and summaries to docs for this tenant.",
|
||||||
|
default=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class Settings(BaseSettings):
|
class Settings(BaseSettings):
|
||||||
@@ -45,14 +47,12 @@ class Settings(BaseSettings):
|
|||||||
model_config = SettingsConfigDict(env_file=".env", frozen=True)
|
model_config = SettingsConfigDict(env_file=".env", frozen=True)
|
||||||
|
|
||||||
app_name: str = "summary"
|
app_name: str = "summary"
|
||||||
app_api_v1_str: str = "/api/v1"
|
|
||||||
app_api_v2_str: str = "/api/v2"
|
app_api_v2_str: str = "/api/v2"
|
||||||
|
|
||||||
# Authorized Tenants
|
# Authorized Tenants
|
||||||
# Using env variables to store authorized tenants for now
|
# Using env variables to store authorized tenants for now
|
||||||
# to avoid any other external dependency (DB)
|
# to avoid any other external dependency (DB)
|
||||||
authorized_tenants: tuple[AuthorizedTenant, ...] = Field(default_factory=tuple)
|
authorized_tenants: tuple[AuthorizedTenant, ...] = Field(min_length=1)
|
||||||
v1_tenant_id: str = V1_DEFAULT_TENANT_ID
|
|
||||||
|
|
||||||
# Audio recordings
|
# Audio recordings
|
||||||
recording_max_duration: Optional[int] = None
|
recording_max_duration: Optional[int] = None
|
||||||
@@ -72,8 +72,6 @@ class Settings(BaseSettings):
|
|||||||
celery_result_backend: str = "redis://redis/0"
|
celery_result_backend: str = "redis://redis/0"
|
||||||
celery_max_retries: int = 1
|
celery_max_retries: int = 1
|
||||||
|
|
||||||
transcribe_queue: str = "transcribe-queue"
|
|
||||||
summarize_queue: str = "summarize-queue"
|
|
||||||
# v2 tasks
|
# v2 tasks
|
||||||
transcribe_queue_v2: str = "transcribe-queue-v2"
|
transcribe_queue_v2: str = "transcribe-queue-v2"
|
||||||
summarize_queue_v2: str = "summarize-queue-v2"
|
summarize_queue_v2: str = "summarize-queue-v2"
|
||||||
@@ -90,7 +88,7 @@ class Settings(BaseSettings):
|
|||||||
|
|
||||||
# AI-related settings
|
# AI-related settings
|
||||||
whisperx_api_key: SecretStr
|
whisperx_api_key: SecretStr
|
||||||
whisperx_base_url: str = "https://api.openai.com/v1"
|
whisperx_base_url: Url = "https://api.openai.com/v1"
|
||||||
whisperx_asr_model: str = "whisper-1"
|
whisperx_asr_model: str = "whisper-1"
|
||||||
# ISO 639-1 language code (e.g., "en", "fr", "es")
|
# ISO 639-1 language code (e.g., "en", "fr", "es")
|
||||||
whisperx_default_language: Optional[str] = None
|
whisperx_default_language: Optional[str] = None
|
||||||
@@ -112,16 +110,17 @@ class Settings(BaseSettings):
|
|||||||
webhook_max_retries: int = 2
|
webhook_max_retries: int = 2
|
||||||
webhook_status_forcelist: List[int] = [502, 503, 504]
|
webhook_status_forcelist: List[int] = [502, 503, 504]
|
||||||
webhook_backoff_factor: float = 0.1
|
webhook_backoff_factor: float = 0.1
|
||||||
|
app_external_user_agent: str = "summary"
|
||||||
# Locale
|
|
||||||
default_context_language: Literal["de", "en", "fr", "nl"] = "fr"
|
|
||||||
|
|
||||||
# Output related settings
|
|
||||||
summary_title_template: Optional[str] = "Résumé de {title}"
|
|
||||||
|
|
||||||
# Summary related settings
|
# Summary related settings
|
||||||
is_summary_enabled: bool = True
|
is_summary_enabled: bool = True
|
||||||
transcription_satisfaction_form_base_url: Optional[str] = None
|
|
||||||
|
# Docs service configuration
|
||||||
|
is_lasuite_docs_integration_enabled: bool = False
|
||||||
|
lasuite_docs_base_url: Url = "https://example.com"
|
||||||
|
lasuite_docs_server_to_server_api_key: SecretStr = Field(
|
||||||
|
title="API key for using docs server to server api", default="NO_API_KEY"
|
||||||
|
)
|
||||||
|
|
||||||
# Sentry
|
# Sentry
|
||||||
sentry_is_enabled: bool = False
|
sentry_is_enabled: bool = False
|
||||||
@@ -131,8 +130,12 @@ class Settings(BaseSettings):
|
|||||||
posthog_enabled: bool = False
|
posthog_enabled: bool = False
|
||||||
posthog_api_key: Optional[str] = None
|
posthog_api_key: Optional[str] = None
|
||||||
posthog_api_host: Optional[str] = "https://eu.i.posthog.com"
|
posthog_api_host: Optional[str] = "https://eu.i.posthog.com"
|
||||||
posthog_event_failure: str = "transcript-failure"
|
posthog_transcript_request: str = "transcript-request"
|
||||||
posthog_event_success: str = "transcript-success"
|
posthog_transcript_failure: str = "transcript-failure"
|
||||||
|
posthog_transcript_success: str = "transcript-success"
|
||||||
|
posthog_summary_request: str = "summary-request"
|
||||||
|
posthog_summary_failure: str = "summary-failure"
|
||||||
|
posthog_summary_success: str = "summary-success"
|
||||||
|
|
||||||
# Langfuse (LLM Observability)
|
# Langfuse (LLM Observability)
|
||||||
langfuse_enabled: bool = False
|
langfuse_enabled: bool = False
|
||||||
@@ -145,38 +148,9 @@ class Settings(BaseSettings):
|
|||||||
task_tracker_redis_url: str = "redis://redis/0"
|
task_tracker_redis_url: str = "redis://redis/0"
|
||||||
task_tracker_prefix: str = "task_metadata:"
|
task_tracker_prefix: str = "task_metadata:"
|
||||||
|
|
||||||
@model_validator(mode="before")
|
|
||||||
@classmethod
|
|
||||||
def legacy_default_tenant_config(cls, data: Any) -> Any:
|
|
||||||
"""Migrate the legacy default tenant configuration."""
|
|
||||||
if isinstance(data, dict):
|
|
||||||
api_key = os.getenv("APP_API_TOKEN")
|
|
||||||
webhook_api_key = os.getenv("WEBHOOK_API_TOKEN")
|
|
||||||
webhook_url = os.getenv("WEBHOOK_URL")
|
|
||||||
if api_key and webhook_api_key and webhook_url:
|
|
||||||
logger.warning(
|
|
||||||
"Deprecated legacy app configuration detected, "
|
|
||||||
"please use only the new 'authorized_tenants' field instead."
|
|
||||||
)
|
|
||||||
|
|
||||||
authorized_tenants = list(data.get("authorized_tenants", []))
|
|
||||||
authorized_tenants.append(
|
|
||||||
AuthorizedTenant(
|
|
||||||
id=V1_DEFAULT_TENANT_ID,
|
|
||||||
api_key=SecretStr(api_key),
|
|
||||||
webhook_url=webhook_url,
|
|
||||||
webhook_api_key=SecretStr(webhook_api_key),
|
|
||||||
)
|
|
||||||
)
|
|
||||||
data["authorized_tenants"] = tuple(authorized_tenants)
|
|
||||||
|
|
||||||
return data
|
|
||||||
|
|
||||||
@model_validator(mode="after")
|
@model_validator(mode="after")
|
||||||
def validate_authorized_tenants(self):
|
def validate_authorized_tenants(self):
|
||||||
"""Validate authorized tenants configuration."""
|
"""Validate authorized tenants configuration."""
|
||||||
if len(self.authorized_tenants) == 0:
|
|
||||||
raise ValueError("No authorized tenants configured")
|
|
||||||
tenant_ids = {tenant.id for tenant in self.authorized_tenants}
|
tenant_ids = {tenant.id for tenant in self.authorized_tenants}
|
||||||
|
|
||||||
if len(tenant_ids) != len(self.authorized_tenants):
|
if len(tenant_ids) != len(self.authorized_tenants):
|
||||||
@@ -191,13 +165,23 @@ class Settings(BaseSettings):
|
|||||||
return self
|
return self
|
||||||
|
|
||||||
@model_validator(mode="after")
|
@model_validator(mode="after")
|
||||||
def validate_default_v1_tenant(self):
|
def validate_docs_config(self):
|
||||||
"""Validate default v1 tenant configuration."""
|
"""Validate docs integration configuration."""
|
||||||
if not any(
|
if not self.is_lasuite_docs_integration_enabled:
|
||||||
tenant.id == self.v1_tenant_id for tenant in self.authorized_tenants
|
return self
|
||||||
):
|
|
||||||
raise ValueError("v1 tenant is not configured in authorized tenants")
|
|
||||||
|
|
||||||
|
if not self.lasuite_docs_base_url:
|
||||||
|
raise ValueError(
|
||||||
|
"lasuite_docs_base_url is required when docs integration is enabled"
|
||||||
|
)
|
||||||
|
if self.lasuite_docs_server_to_server_api_key.get_secret_value() in {
|
||||||
|
"",
|
||||||
|
"NO_API_KEY",
|
||||||
|
}:
|
||||||
|
raise ValueError(
|
||||||
|
"Valid lasuite_docs_server_to_server_api_key is required when"
|
||||||
|
" docs integration is enabled"
|
||||||
|
)
|
||||||
return self
|
return self
|
||||||
|
|
||||||
@cached_property
|
@cached_property
|
||||||
|
|||||||
@@ -0,0 +1,94 @@
|
|||||||
|
"""Service for delivering content to external destinations."""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
from urllib.parse import urljoin
|
||||||
|
|
||||||
|
from requests import Session
|
||||||
|
from requests.adapters import HTTPAdapter
|
||||||
|
from urllib3.util import Retry
|
||||||
|
|
||||||
|
from summary.core.config import get_settings
|
||||||
|
|
||||||
|
settings = get_settings()
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def _create_retry_session(api_key: str | None = None):
|
||||||
|
"""Create an HTTP session configured with retry logic."""
|
||||||
|
session = Session()
|
||||||
|
retries = Retry(
|
||||||
|
total=settings.webhook_max_retries,
|
||||||
|
backoff_factor=settings.webhook_backoff_factor,
|
||||||
|
status_forcelist=settings.webhook_status_forcelist,
|
||||||
|
allowed_methods={"POST"},
|
||||||
|
)
|
||||||
|
session.mount("https://", HTTPAdapter(max_retries=retries))
|
||||||
|
if api_key:
|
||||||
|
session.headers.update(
|
||||||
|
{
|
||||||
|
"Authorization": f"Bearer {api_key}",
|
||||||
|
"User-Agent": settings.app_external_user_agent,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return session
|
||||||
|
|
||||||
|
|
||||||
|
def _post_with_retries(*, url, data, api_key: str | None = None):
|
||||||
|
"""Send POST request with automatic retries."""
|
||||||
|
session = _create_retry_session(api_key=api_key)
|
||||||
|
|
||||||
|
try:
|
||||||
|
response = session.post(url, json=data, timeout=(20, 3 * 60))
|
||||||
|
response.raise_for_status()
|
||||||
|
return response
|
||||||
|
finally:
|
||||||
|
session.close()
|
||||||
|
|
||||||
|
|
||||||
|
def create_document_in_lasuite_docs(
|
||||||
|
*, content: str, title: str, email: str, sub: str
|
||||||
|
) -> None:
|
||||||
|
"""Call the Docs API to create a document on behalf of the user there.
|
||||||
|
|
||||||
|
Builds the payload, sends it with retries, and logs the outcome.
|
||||||
|
"""
|
||||||
|
data = {
|
||||||
|
"title": title,
|
||||||
|
"content": content,
|
||||||
|
"email": email,
|
||||||
|
"sub": sub,
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.debug(
|
||||||
|
"Submitting document to docs endpoint: %s", settings.lasuite_docs_base_url
|
||||||
|
)
|
||||||
|
logger.debug(
|
||||||
|
"Docs payload metadata | title_len=%s content_len=%s has_email=%s has_sub=%s",
|
||||||
|
len(title),
|
||||||
|
len(content),
|
||||||
|
bool(email),
|
||||||
|
bool(sub),
|
||||||
|
)
|
||||||
|
|
||||||
|
response = _post_with_retries(
|
||||||
|
url=urljoin(
|
||||||
|
settings.lasuite_docs_base_url, "/api/v1.0/documents/create-for-owner/"
|
||||||
|
),
|
||||||
|
api_key=settings.lasuite_docs_server_to_server_api_key.get_secret_value(),
|
||||||
|
data=data,
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
response_data = response.json()
|
||||||
|
document_id = response_data.get("id", "N/A")
|
||||||
|
except (json.JSONDecodeError, AttributeError):
|
||||||
|
document_id = "Unable to parse response"
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"Delivery success | Document %s submitted (HTTP %s)",
|
||||||
|
document_id,
|
||||||
|
response.status_code,
|
||||||
|
)
|
||||||
|
logger.debug("Docs response received (body omitted)")
|
||||||
@@ -14,7 +14,6 @@ from urllib.parse import urlparse
|
|||||||
|
|
||||||
import requests
|
import requests
|
||||||
from minio import Minio
|
from minio import Minio
|
||||||
from minio.error import MinioException, S3Error
|
|
||||||
|
|
||||||
from summary.core.config import get_settings
|
from summary.core.config import get_settings
|
||||||
from summary.core.shared_models import WhisperXResponse
|
from summary.core.shared_models import WhisperXResponse
|
||||||
@@ -284,50 +283,6 @@ class FileService:
|
|||||||
|
|
||||||
self._max_duration_seconds = settings.recording_max_duration
|
self._max_duration_seconds = settings.recording_max_duration
|
||||||
|
|
||||||
def _download_from_minio(self, remote_object_key) -> Path:
|
|
||||||
"""Download file from MinIO to local temporary file.
|
|
||||||
|
|
||||||
The file is downloaded to a temporary location for local manipulation
|
|
||||||
such as validation, conversion, or processing before being used.
|
|
||||||
"""
|
|
||||||
logger.info("Download recording | object_key: %s", remote_object_key)
|
|
||||||
|
|
||||||
if not remote_object_key:
|
|
||||||
logger.warning("Invalid object_key '%s'", remote_object_key)
|
|
||||||
raise ValueError("Invalid object_key")
|
|
||||||
|
|
||||||
extension = Path(remote_object_key).suffix.lower()
|
|
||||||
|
|
||||||
response = None
|
|
||||||
|
|
||||||
try:
|
|
||||||
response = self._minio_client.get_object(
|
|
||||||
self._bucket_name, remote_object_key
|
|
||||||
)
|
|
||||||
|
|
||||||
with tempfile.NamedTemporaryFile(
|
|
||||||
suffix=extension, delete=False, prefix="minio_download_"
|
|
||||||
) as tmp:
|
|
||||||
for chunk in response.stream(self._stream_chunk_size):
|
|
||||||
tmp.write(chunk)
|
|
||||||
|
|
||||||
tmp.flush()
|
|
||||||
local_path = Path(tmp.name)
|
|
||||||
|
|
||||||
logger.info("Recording successfully downloaded")
|
|
||||||
logger.debug("Recording local file path: %s", local_path)
|
|
||||||
|
|
||||||
return local_path
|
|
||||||
|
|
||||||
except (MinioException, S3Error) as e:
|
|
||||||
raise FileServiceException(
|
|
||||||
"Unexpected error while downloading object."
|
|
||||||
) from e
|
|
||||||
|
|
||||||
finally:
|
|
||||||
if response:
|
|
||||||
response.close()
|
|
||||||
|
|
||||||
def _download_from_cloud_storage_url(self, cloud_storage_url: str) -> Path:
|
def _download_from_cloud_storage_url(self, cloud_storage_url: str) -> Path:
|
||||||
"""Download file from a cloud storage URL to local temporary file."""
|
"""Download file from a cloud storage URL to local temporary file."""
|
||||||
logger.info(
|
logger.info(
|
||||||
@@ -392,33 +347,17 @@ class FileService:
|
|||||||
logger.error(error_msg)
|
logger.error(error_msg)
|
||||||
raise MediaDurationTooLongError(error_msg)
|
raise MediaDurationTooLongError(error_msg)
|
||||||
|
|
||||||
def read_json(self, object_name: str) -> dict:
|
def read_cloud_storage_json(self, cloud_storage_url: str) -> dict:
|
||||||
"""Read and parse a JSON file from MinIO storage."""
|
"""Read and parse a JSON file from a url."""
|
||||||
logger.info("Reading JSON: %s", object_name)
|
logger.info("Reading JSON: %s", cloud_storage_url)
|
||||||
|
res = requests.get(cloud_storage_url, timeout=(10, 100))
|
||||||
if not object_name:
|
res.raise_for_status()
|
||||||
raise ValueError("Invalid object_name")
|
return res.json()
|
||||||
|
|
||||||
response = None
|
|
||||||
try:
|
|
||||||
response = self._minio_client.get_object(self._bucket_name, object_name)
|
|
||||||
return json.loads(response.read())
|
|
||||||
except (MinioException, S3Error) as e:
|
|
||||||
raise FileServiceException(
|
|
||||||
"Unexpected error while reading JSON object."
|
|
||||||
) from e
|
|
||||||
except (json.JSONDecodeError, UnicodeDecodeError) as e:
|
|
||||||
raise FileServiceException("Invalid JSON content.") from e
|
|
||||||
finally:
|
|
||||||
if response:
|
|
||||||
response.close()
|
|
||||||
response.release_conn()
|
|
||||||
|
|
||||||
@contextmanager
|
@contextmanager
|
||||||
def prepare_audio_file(
|
def prepare_audio_file(
|
||||||
self,
|
self,
|
||||||
remote_object_key: str | None = None,
|
cloud_storage_url: str,
|
||||||
cloud_storage_url: str | None = None,
|
|
||||||
):
|
):
|
||||||
"""Download and prepare audio file for processing.
|
"""Download and prepare audio file for processing.
|
||||||
|
|
||||||
@@ -431,20 +370,7 @@ class FileService:
|
|||||||
file_handle = None
|
file_handle = None
|
||||||
|
|
||||||
try:
|
try:
|
||||||
if bool(remote_object_key) == bool(cloud_storage_url):
|
downloaded_path = self._download_from_cloud_storage_url(cloud_storage_url)
|
||||||
raise ValueError(
|
|
||||||
(
|
|
||||||
"Exactly one of 'remote_object_key' or "
|
|
||||||
"'cloud_storage_url' must be provided."
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
if cloud_storage_url:
|
|
||||||
downloaded_path = self._download_from_cloud_storage_url(
|
|
||||||
cloud_storage_url
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
downloaded_path = self._download_from_minio(remote_object_key)
|
|
||||||
|
|
||||||
media_info = get_media_info(downloaded_path)
|
media_info = get_media_info(downloaded_path)
|
||||||
|
|
||||||
|
|||||||
@@ -30,8 +30,5 @@ Einige Punkte, die wir Ihnen empfehlen zu überprüfen:
|
|||||||
"(externer Link)]({form_link})*\n"
|
"(externer Link)]({form_link})*\n"
|
||||||
),
|
),
|
||||||
hallucination_replacement_text="[Text konnte nicht transkribiert werden]",
|
hallucination_replacement_text="[Text konnte nicht transkribiert werden]",
|
||||||
document_default_title="Transkription",
|
summary_title_template="Zusammenfassung von {title}",
|
||||||
document_title_template=(
|
|
||||||
'Besprechung "{room}" am {room_recording_date} um {room_recording_time}'
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -30,8 +30,5 @@ A few things we recommend you check:
|
|||||||
"(external link)]({form_link})*\n"
|
"(external link)]({form_link})*\n"
|
||||||
),
|
),
|
||||||
hallucination_replacement_text="[Unable to transcribe text]",
|
hallucination_replacement_text="[Unable to transcribe text]",
|
||||||
document_default_title="Transcription",
|
summary_title_template="Summary of {title}",
|
||||||
document_title_template=(
|
|
||||||
'Meeting "{room}" on {room_recording_date} at {room_recording_time}'
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -29,8 +29,5 @@ Quelques points que nous vous conseillons de vérifier :
|
|||||||
"\n\n*[Donnez nous votre avis sur cette transcription]({form_link})*\n"
|
"\n\n*[Donnez nous votre avis sur cette transcription]({form_link})*\n"
|
||||||
),
|
),
|
||||||
hallucination_replacement_text="[Texte impossible à transcrire]",
|
hallucination_replacement_text="[Texte impossible à transcrire]",
|
||||||
document_default_title="Transcription",
|
summary_title_template="Résumé de {title}",
|
||||||
document_title_template=(
|
|
||||||
'Réunion "{room}" du {room_recording_date} à {room_recording_time}'
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -30,8 +30,5 @@ Een paar punten die wij u aanraden te controleren:
|
|||||||
"(externe link)]({form_link})*\n"
|
"(externe link)]({form_link})*\n"
|
||||||
),
|
),
|
||||||
hallucination_replacement_text="[Tekst kon niet worden getranscribeerd]",
|
hallucination_replacement_text="[Tekst kon niet worden getranscribeerd]",
|
||||||
document_default_title="Transcriptie",
|
summary_title_template="Samenvatting van {title}",
|
||||||
document_title_template=(
|
|
||||||
'Vergadering "{room}" op {room_recording_date} om {room_recording_time}'
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -12,5 +12,4 @@ class LocaleStrings:
|
|||||||
download_header_template: str
|
download_header_template: str
|
||||||
form_footer_template: str
|
form_footer_template: str
|
||||||
hallucination_replacement_text: str
|
hallucination_replacement_text: str
|
||||||
document_default_title: str
|
summary_title_template: str
|
||||||
document_title_template: str
|
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
"""Models for the API & Celery tasks creation."""
|
"""Models for the API & Celery tasks creation."""
|
||||||
|
|
||||||
from pydantic import BaseModel, Field, field_validator
|
from datetime import datetime
|
||||||
|
|
||||||
|
from pydantic import AwareDatetime, BaseModel, EmailStr, Field, field_validator
|
||||||
|
|
||||||
from summary.core.config import get_settings
|
from summary.core.config import get_settings
|
||||||
from summary.core.types import Url
|
from summary.core.types import Url
|
||||||
@@ -12,10 +14,60 @@ class SharedV2TaskCreation(BaseModel):
|
|||||||
"""Model that holds basic information for task creation."""
|
"""Model that holds basic information for task creation."""
|
||||||
|
|
||||||
user_sub: str = Field(title="User Sub", description="The user's sub.")
|
user_sub: str = Field(title="User Sub", description="The user's sub.")
|
||||||
|
user_email: EmailStr | None = Field(
|
||||||
|
default=None,
|
||||||
|
title="User Email",
|
||||||
|
description="The user's email for analytics purposes.",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class TranscribeTaskV2Request(SharedV2TaskCreation):
|
class RecordingMetadata(BaseModel):
|
||||||
"""Model for creating a transcribe and summarize task (used for API request)."""
|
"""Model for recording metadata."""
|
||||||
|
|
||||||
|
cloud_storage_url: Url = Field(
|
||||||
|
title="Cloud Storage URL",
|
||||||
|
description="The URL of the metadata file for speaker assignement.",
|
||||||
|
)
|
||||||
|
started_at: AwareDatetime = Field(title="Start time of the recording to transcribe")
|
||||||
|
ended_at: AwareDatetime = Field(title="End time of the recording to transcribe")
|
||||||
|
|
||||||
|
|
||||||
|
class PushToDocsBaseConfig(BaseModel):
|
||||||
|
"""Model containing information for pushing transcript and summaries to docs."""
|
||||||
|
|
||||||
|
user_email: EmailStr = Field(
|
||||||
|
title="User Email", description="The user's email, future owner of the docs."
|
||||||
|
)
|
||||||
|
title: str = Field(title="Title", description="The title for the created document.")
|
||||||
|
|
||||||
|
|
||||||
|
class PushToDocsTranscriptConfig(PushToDocsBaseConfig):
|
||||||
|
"""Model for push to docs information for transcripts."""
|
||||||
|
|
||||||
|
download_link: Url | None = Field(
|
||||||
|
default=None,
|
||||||
|
title="Download Link",
|
||||||
|
description="The link to download the recording.",
|
||||||
|
)
|
||||||
|
form_link: Url | None = Field(
|
||||||
|
default=None,
|
||||||
|
title="Form Link",
|
||||||
|
description="The link to fill out a form for the recording.",
|
||||||
|
)
|
||||||
|
auto_create_summary: bool = Field(
|
||||||
|
title="Auto Create Summary Docs",
|
||||||
|
description="Whether to automatically create a summary "
|
||||||
|
"for the transcription task and push it to docs.",
|
||||||
|
default=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class PushToDocsSummaryConfig(PushToDocsBaseConfig):
|
||||||
|
"""Model for push to docs information for summaries."""
|
||||||
|
|
||||||
|
|
||||||
|
class TranscribeTaskApiRequest(SharedV2TaskCreation):
|
||||||
|
"""Model for creating a transcribe task (used for API request)."""
|
||||||
|
|
||||||
cloud_storage_url: Url = Field(
|
cloud_storage_url: Url = Field(
|
||||||
title="Cloud storage URL",
|
title="Cloud storage URL",
|
||||||
@@ -27,7 +79,17 @@ class TranscribeTaskV2Request(SharedV2TaskCreation):
|
|||||||
description="The language of the context text.",
|
description="The language of the context text.",
|
||||||
)
|
)
|
||||||
language: str = Field(
|
language: str = Field(
|
||||||
title="Language", description="The language of the content to summarize."
|
title="Language", description="The language of the content to transcribe."
|
||||||
|
)
|
||||||
|
metadata: RecordingMetadata | None = Field(
|
||||||
|
title="Metadata",
|
||||||
|
description="The metadata for the transcribe task.",
|
||||||
|
default=None,
|
||||||
|
)
|
||||||
|
push_to_docs_config: PushToDocsTranscriptConfig | None = Field(
|
||||||
|
title="Push to Docs info",
|
||||||
|
description="If set, configuration for pushing to docs",
|
||||||
|
default=None,
|
||||||
)
|
)
|
||||||
|
|
||||||
@field_validator("language")
|
@field_validator("language")
|
||||||
@@ -42,19 +104,30 @@ class TranscribeTaskV2Request(SharedV2TaskCreation):
|
|||||||
return v
|
return v
|
||||||
|
|
||||||
|
|
||||||
class TranscribeTaskV2Payload(TranscribeTaskV2Request):
|
class TranscribeTaskJob(TranscribeTaskApiRequest):
|
||||||
"""Model for creating a transcribe and summarize task (used for actual task creation).""" # noqa: E501
|
"""Model for creating a transcribe task (used for actual task creation)."""
|
||||||
|
|
||||||
tenant_id: str = Field(title="Tenant ID", description="The ID of the tenant.")
|
tenant_id: str = Field(title="Tenant ID", description="The ID of the tenant.")
|
||||||
|
received_at: datetime = Field(
|
||||||
|
title="Received At", description="The time the task was received."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class SummarizeTaskV2Request(SharedV2TaskCreation):
|
class SummarizeTaskApiRequest(SharedV2TaskCreation):
|
||||||
"""Model for creating a summarize task (used for API request)."""
|
"""Model for creating a summarize task (used for API request)."""
|
||||||
|
|
||||||
content: str = Field(title="Content", description="The content to summarize.")
|
content: str = Field(title="Content", description="The content to summarize.")
|
||||||
|
|
||||||
|
|
||||||
class SummarizeTaskV2Payload(SummarizeTaskV2Request):
|
class SummarizeTaskJob(SummarizeTaskApiRequest):
|
||||||
"""Model for creating a summarize task (used for actual task creation)."""
|
"""Model for creating a summarize task (used for actual task creation)."""
|
||||||
|
|
||||||
tenant_id: str = Field(title="Tenant ID", description="The ID of the tenant.")
|
tenant_id: str = Field(title="Tenant ID", description="The ID of the tenant.")
|
||||||
|
received_at: datetime = Field(
|
||||||
|
title="Received At", description="The time the task was received."
|
||||||
|
)
|
||||||
|
push_to_docs_config: PushToDocsBaseConfig | None = Field(
|
||||||
|
title="Push to Docs info",
|
||||||
|
description="If set, configuration for pushing to docs",
|
||||||
|
default=None,
|
||||||
|
)
|
||||||
|
|||||||
@@ -167,4 +167,5 @@ __all__ = [
|
|||||||
"SummarizeWebhookPayloads",
|
"SummarizeWebhookPayloads",
|
||||||
"WebhookPayloads",
|
"WebhookPayloads",
|
||||||
"WhisperXResponse",
|
"WhisperXResponse",
|
||||||
|
"webhook_payload_adapter",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -1,9 +1,6 @@
|
|||||||
"""Transcript formatting into readable conversation format with speaker labels."""
|
"""Transcript formatting into readable conversation format with speaker labels."""
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
from datetime import datetime
|
|
||||||
from typing import Tuple
|
|
||||||
from zoneinfo import ZoneInfo
|
|
||||||
|
|
||||||
from summary.core.config import get_settings
|
from summary.core.config import get_settings
|
||||||
from summary.core.locales import LocaleStrings
|
from summary.core.locales import LocaleStrings
|
||||||
@@ -38,16 +35,13 @@ class TranscriptFormatter:
|
|||||||
|
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def format( # noqa: PLR0913
|
def format(
|
||||||
self,
|
self,
|
||||||
transcription,
|
transcription,
|
||||||
room: str | None = None,
|
|
||||||
recording_datetime: str | None = None,
|
|
||||||
owner_timezone: str | None = None,
|
|
||||||
download_link: str | None = None,
|
download_link: str | None = None,
|
||||||
form_link: str | None = None,
|
form_link: str | None = None,
|
||||||
) -> Tuple[str, str]:
|
) -> str:
|
||||||
"""Format transcription into the final document and its title."""
|
"""Format transcription into the final document."""
|
||||||
segments = self._get_segments(transcription)
|
segments = self._get_segments(transcription)
|
||||||
|
|
||||||
if not segments:
|
if not segments:
|
||||||
@@ -59,9 +53,7 @@ class TranscriptFormatter:
|
|||||||
if form_link:
|
if form_link:
|
||||||
content = self._add_footer(content, form_link)
|
content = self._add_footer(content, form_link)
|
||||||
|
|
||||||
title = self._generate_title(room, recording_datetime, owner_timezone)
|
return content
|
||||||
|
|
||||||
return content, title
|
|
||||||
|
|
||||||
def _remove_hallucinations(self, content: str) -> str:
|
def _remove_hallucinations(self, content: str) -> str:
|
||||||
"""Remove hallucination patterns from content."""
|
"""Remove hallucination patterns from content."""
|
||||||
@@ -104,23 +96,3 @@ class TranscriptFormatter:
|
|||||||
|
|
||||||
footer = self._locale.form_footer_template.format(form_link=form_link)
|
footer = self._locale.form_footer_template.format(form_link=form_link)
|
||||||
return content + footer
|
return content + footer
|
||||||
|
|
||||||
def _generate_title(
|
|
||||||
self,
|
|
||||||
room: str | None = None,
|
|
||||||
recording_datetime: str | None = None,
|
|
||||||
owner_timezone: str | None = None,
|
|
||||||
) -> str:
|
|
||||||
"""Generate title from context or return default."""
|
|
||||||
if not room or not recording_datetime:
|
|
||||||
return self._locale.document_default_title
|
|
||||||
|
|
||||||
dt = datetime.fromisoformat(recording_datetime)
|
|
||||||
if owner_timezone:
|
|
||||||
dt = dt.astimezone(ZoneInfo(owner_timezone))
|
|
||||||
|
|
||||||
return self._locale.document_title_template.format(
|
|
||||||
room=room,
|
|
||||||
room_recording_date=dt.strftime("%Y-%m-%d"),
|
|
||||||
room_recording_time=dt.strftime("%H:%M"),
|
|
||||||
)
|
|
||||||
|
|||||||
@@ -4,9 +4,6 @@ import json
|
|||||||
import logging
|
import logging
|
||||||
|
|
||||||
import requests
|
import requests
|
||||||
from requests import Session
|
|
||||||
from requests.adapters import HTTPAdapter
|
|
||||||
from urllib3.util import Retry
|
|
||||||
|
|
||||||
from summary.core.config import get_settings
|
from summary.core.config import get_settings
|
||||||
from summary.core.shared_models import (
|
from summary.core.shared_models import (
|
||||||
@@ -18,83 +15,6 @@ settings = get_settings()
|
|||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
def _create_retry_session(api_key: str | None = None):
|
|
||||||
"""Create an HTTP session configured with retry logic."""
|
|
||||||
session = Session()
|
|
||||||
retries = Retry(
|
|
||||||
total=settings.webhook_max_retries,
|
|
||||||
backoff_factor=settings.webhook_backoff_factor,
|
|
||||||
status_forcelist=settings.webhook_status_forcelist,
|
|
||||||
allowed_methods={"POST"},
|
|
||||||
)
|
|
||||||
session.mount("https://", HTTPAdapter(max_retries=retries))
|
|
||||||
if api_key:
|
|
||||||
session.headers.update({"Authorization": f"Bearer {api_key}"})
|
|
||||||
|
|
||||||
return session
|
|
||||||
|
|
||||||
|
|
||||||
def _post_with_retries(*, url, data, api_key: str | None = None):
|
|
||||||
"""Send POST request with automatic retries."""
|
|
||||||
session = _create_retry_session(api_key=api_key)
|
|
||||||
|
|
||||||
try:
|
|
||||||
response = session.post(url, json=data)
|
|
||||||
response.raise_for_status()
|
|
||||||
return response
|
|
||||||
finally:
|
|
||||||
session.close()
|
|
||||||
|
|
||||||
|
|
||||||
def call_webhook_v1(*, tenant_id: str, payload: dict) -> None:
|
|
||||||
"""Call webhook with payload a payload and optional token."""
|
|
||||||
tenant = settings.get_authorized_tenant(tenant_id=tenant_id)
|
|
||||||
|
|
||||||
logger.debug("Submitting to %s", tenant.webhook_url)
|
|
||||||
logger.debug("Request payload: %s", json.dumps(payload, indent=2))
|
|
||||||
|
|
||||||
response = _post_with_retries(
|
|
||||||
url=tenant.webhook_url,
|
|
||||||
api_key=tenant.webhook_api_key.get_secret_value(),
|
|
||||||
data=payload,
|
|
||||||
)
|
|
||||||
|
|
||||||
try:
|
|
||||||
response_data = response.json()
|
|
||||||
document_id = response_data.get("id", "N/A")
|
|
||||||
except (json.JSONDecodeError, AttributeError):
|
|
||||||
document_id = "Unable to parse response"
|
|
||||||
response_data = response.text
|
|
||||||
|
|
||||||
logger.info(
|
|
||||||
"Delivery success | Document %s submitted (HTTP %s)",
|
|
||||||
document_id,
|
|
||||||
response.status_code,
|
|
||||||
)
|
|
||||||
logger.debug("Full response: %s", response_data)
|
|
||||||
|
|
||||||
|
|
||||||
def submit_content(content: str, title: str, email: str, sub: str) -> None:
|
|
||||||
"""Submit content to the configured webhook destination.
|
|
||||||
|
|
||||||
Builds the payload, sends it with retries, and logs the outcome.
|
|
||||||
|
|
||||||
Notes:
|
|
||||||
Deprecated: Use call_webhook_v2 directly instead.
|
|
||||||
|
|
||||||
Deprecated:
|
|
||||||
This will route content to the v1 default tenant
|
|
||||||
"""
|
|
||||||
data = {
|
|
||||||
"title": title,
|
|
||||||
"content": content,
|
|
||||||
"email": email,
|
|
||||||
"sub": sub,
|
|
||||||
}
|
|
||||||
|
|
||||||
call_webhook_v1(payload=data, tenant_id=settings.v1_tenant_id)
|
|
||||||
|
|
||||||
|
|
||||||
def call_webhook_v2(
|
def call_webhook_v2(
|
||||||
*,
|
*,
|
||||||
tenant_id: str,
|
tenant_id: str,
|
||||||
@@ -114,6 +34,7 @@ def call_webhook_v2(
|
|||||||
json=payload.model_dump(),
|
json=payload.model_dump(),
|
||||||
headers={
|
headers={
|
||||||
"Authorization": f"Bearer {tenant.webhook_api_key.get_secret_value()}",
|
"Authorization": f"Bearer {tenant.webhook_api_key.get_secret_value()}",
|
||||||
|
"User-Agent": settings.app_external_user_agent,
|
||||||
},
|
},
|
||||||
timeout=(10, 20),
|
timeout=(10, 20),
|
||||||
)
|
)
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user