Compare commits

..

38 Commits

Author SHA1 Message Date
Florent Chehab acd7c51758 (backend) compatible with summary v2 routes
* Track external process (transcribe) status through a webhook and
new Enum choices,
* Send v2 and v1 summary routes params so that both endpoint can still be used,
* Updated dev setup to make it work.
2026-05-26 18:28:08 +02:00
Florent Chehab 7e286fcb4b 💥(summary) removed summary v1 related code
We are in the process of moving meet to summary v2 routes and tasks.
This commits removes code from summary and moves some code from summary to meet,
which should have the responsability of this code.
2026-05-26 16:59:43 +02:00
lebaudantoine ba8b3bda30 (frontend) add a connection state toast to the PiP window
Surface connection state changes (reconnecting, disconnected,
etc.) directly in the PiP window so the user stays informed
without needing to switch back to the main window.

Co-authored-by: Cyril <c.gromoff@gmail.com>
2026-05-22 17:24:17 +02:00
lebaudantoine ee85768940 ♻️(frontend) use a semantic CSS token for tooltip positioning
Replace the hard-coded tooltip offset with a semantic CSS token,
making it possible to override the spacing per context (e.g.
tighter spacing in the PiP window where vertical room is scarce).

Co-authored-by: Cyril <c.gromoff@gmail.com>
2026-05-22 17:24:17 +02:00
lebaudantoine a9ef134210 🩹(frontend) fix tooltip positioning in cross-document rendering (PiP)
react-aria computes overlay positions against the main window's
dimensions, which produces incorrect placement when the overlay
is rendered into a PiP document. Tooltips were the most visible
symptom, but the issue affects overlays in general.

Introduce a context that lets our primitives know when they're
rendering across documents. When set, the primitives use the
host document's window for positioning instead of falling back
to react-aria's default.

Original work by @ovgod. No cleaner approach seems feasible in
the short term, react-aria doesn't expose a clean way to
override the positioning target, so this works around it at our
primitive layer.

Co-authored-by: Cyril <c.gromoff@gmail.com>
2026-05-22 17:24:17 +02:00
lebaudantoine dee1e46173 (frontend) make the PiP control bar collapsible for responsiveness
The PiP window can be resized to a wide range of dimensions, so
the control bar needs to adapt. Introduce a collapsible mechanism
that hides or condenses controls as available width shrinks,
keeping the UX usable at small sizes.

Original works by @ovgdd

Co-authored-by: Cyril <c.gromoff@gmail.com>
2026-05-22 17:24:17 +02:00
lebaudantoine 60828ed895 🩹(frontend) set a proper page title on pip window
the wip placeholder was temporary, set a descriptive name.

Co-authored-by: Cyril <c.gromoff@gmail.com>
2026-05-22 17:24:17 +02:00
lebaudantoine 4830ea5673 🩹(frontend) fix reaction toolbar refocusing first button on click
The toolbar's onFocus handler called focusManager.focusFirst() whenever
focus arrived from outside the toolbar, which included clicks on
reaction buttons. This caused focus (and the scroll viewport) to snap
back to the first button on every click.

Use :focus-visible to distinguish keyboard focus from pointer focus.
Only redirect to the first button when focus arrives via keyboard,
leaving click-induced focus untouched.

Co-authored-by: Cyril <c.gromoff@gmail.com>
2026-05-22 17:24:17 +02:00
lebaudantoine e21da647a0 (frontend) handle notifications by reusing the existing notif region
Render the notification region from the main window into the PiP
document via a portal, rather than duplicating it. This keeps a
single listener, a single store, and a single component instance —
so notifications stay consistent across both windows without extra
synchronization.

Simplest approach I could find. Good enough as a first pass;
worth revisiting if the requirements grow.

Co-authored-by: Cyril <c.gromoff@gmail.com>
2026-05-22 17:24:17 +02:00
lebaudantoine 38c131e02c (frontend) introduce the camera stage
The core component of the PiP layout. Adapted from a simplified version
of @ovgodd's work in #890 — see that PR for context on the original design.

Co-authored-by: Cyril <c.gromoff@gmail.com>
2026-05-22 17:24:17 +02:00
lebaudantoine 8b8f9eae92 (frontend) add an options menu to the PiP window
Adding a menu turned out to be tricky. Our usual menu primitive
is built on react-aria, which positions overlays based on the
`window` object. Since the JS runs in the main window, the
computed position refers to the main window's coordinates, and
the menu renders in the wrong place inside the PiP document.

After investigating, we couldn't find a clean way to make
react-aria target the PiP window's `window`/`document` without
significant rework. Agreed with @ovgodd to duplicate the menu in
the PiP window as a pragmatic workaround until a better approach
emerges.

Co-authored-by: Cyril <c.gromoff@gmail.com>
2026-05-22 17:24:17 +02:00
lebaudantoine 4456137948 (frontend) add a basic control bar to the PiP window
The PiP control bar shares state with the main window's control
bar — most importantly, the reaction toolbar and the mic/camera
toggles, which are fully stateful.

Sharing state works naturally here because the PiP content is
rendered through a portal into the same React tree, so the
controls in both windows read from and write to the same stores.

Co-authored-by: Cyril <c.gromoff@gmail.com>
2026-05-22 17:24:17 +02:00
lebaudantoine faa86b8293 ♻️(frontend) add prop to disable reaction toolbar centering adjustment
The main window computes a JS offset to align the reaction toolbar
with the (off-center) reaction toggle. PiP and mobile don't need
this — centering the toolbar is enough. Add `adjustedCentering` to
opt out.

Not a great seam: the component shouldn't know about its host.
A cleaner fix would move the alignment concern to the parent.
Leaving for now to unblock PiP.

Co-authored-by: Cyril <c.gromoff@gmail.com>
2026-05-22 17:24:17 +02:00
lebaudantoine ae9cda463e (frontend) show a visual placeholder while picture-in-picture is open
When the PiP window is active, replace the corresponding content
in the main window with a lightweight placeholder. This avoids
rendering the camera feed (and other expensive media) twice when
the user is only watching the PiP window.

Co-authored-by: Cyril <c.gromoff@gmail.com>
2026-05-22 17:24:17 +02:00
lebaudantoine e9e4b360a0 (frontend) introduce basic document picture-in-picture hook
Add a hook that manages the logic needed to open a document
picture-in-picture window, stores a ref to the window in a global
store, and—once ready—mounts a portal to duplicate content into
the PiP document's container.

Mounting the PiP content via a portal lets us share the same React
tree as the main app, and therefore share application state. This
comes with some trade-offs, particularly around components that
rely on the DOM hierarchy: react-aria popovers and overlays may
not behave correctly across documents.

The logic is kept to a minimum here. This first commit does nothing
more than open a window containing a loading spinner. The topic is
new to us, so plenty is likely to be refined in follow-ups.

Co-authored-by: Cyril <c.gromoff@gmail.com>
2026-05-22 17:24:17 +02:00
renovate[bot] 4911a7cda0 ⬆️(dependencies) update webpack-dev-server to v5.2.4 [SECURITY] 2026-05-22 17:07:13 +02:00
lebaudantoine eb74feaa0d ️(frontend) improve accessibility and navigation for reaction toolbar
Refine keyboard behavior so left and right arrow keys no longer
control toolbar navigation, allowing users to directly tab into
individual reaction buttons for faster and more intuitive access.

Remove the reaction toolbar from accessibility tree to avoid
unnecessary focus and announcements, keeping assistive technology
focused on the actual reaction controls.

This ensures the UX centers on reaction actions rather than
auxiliary navigation elements.

Also, not related to this topic, I've reworked the scroll viewport styles.
2026-05-18 23:09:45 +02:00
lebaudantoine 318447f2b3 🩹(frontend) add missing disabled styling for primaryDarkText button
Implement the missing disabled state styling for the
primaryDarkText button variant to ensure consistent UI feedback
when the button is not interactive.
2026-05-18 23:09:45 +02:00
lebaudantoine 224707f4c7 (frontend) enable reactions on mobile devices
Allow reaction interactions on mobile.

Adding the toolbar toggle currently impacts the responsive layout
of the control bar on very small screens. This will be improved in
a future PR with an auto-collapsing mobile control bar.
2026-05-18 23:09:45 +02:00
lebaudantoine d47d13f041 🩹(frontend) improve reaction toolbar centering with dynamic positioning
Replace the previous hardcoded offset approach with a dedicated
hook that dynamically centers the reaction toolbar relative to the
reaction toggle.

This improves layout accuracy and removes reliance on fixed values.

Note: ResizeObserver is used for positioning updates and may not
be supported in older browsers. Additional testing will be
performed before production release.

Replace direct DOM queries using hardcoded IDs with proper constants.
2026-05-18 23:09:45 +02:00
lebaudantoine b4ced74b1f (frontend) make reaction toolbar responsive on small viewports
Add horizontal navigation support using left/right arrow controls
to scroll through available reactions on smaller screens.

This work is inspired by Cyril's implementation.
2026-05-18 23:09:45 +02:00
lebaudantoine ecf5d443d6 ♻️(frontend) refactor strip components and improve naming clarity
Rename strip-related components into a more generic container
abstraction.

Extract the buttons container into a dedicated component file and
update related naming to improve readability and maintainability.
2026-05-18 23:09:45 +02:00
lebaudantoine 0737974f6d ♻️(frontend) refactor reaction keyboard navigation into a component
Extract keyboard navigation logic for reactions into a separate
component/file to reduce file size and improve maintainability.
2026-05-18 23:09:45 +02:00
lebaudantoine 71f76a81e9 ♻️(backend) refactor caller identity getter
Enhance getting the caller's identity to prevent None.
2026-05-17 23:42:54 +02:00
lebaudantoine 385da86759 🔒️(backend) verify participant presence before mute operations
Ensure the participant requesting a mute action is still present
in the room before processing the request.

This mitigates scenarios where a previously issued token could be
reused after the meeting has ended.

Current token lifetime is intentionally long-lived and will be
refactored in the future to better align with LiveKit session
constraints. In the meantime, add this extra validation step to
reduce the attack surface.
2026-05-17 23:39:53 +02:00
lebaudantoine 81e3483f28 📝(changelog) update the changelog 2026-05-17 23:39:53 +02:00
lebaudantoine 5e030c2a07 ♻️(frontend) refactor useMuteParticipant hook
Fix unreachable code when notifying participants that they were
muted.

Prevent unnecessary function re-creations when props remain
unchanged.

Also guard against missing tokens by logging an error and
returning early when the token is undefined.
2026-05-17 23:39:53 +02:00
lebaudantoine 32fbedd358 (backend) extend live synchronization to lobby access level updates
Extend the existing live synchronization mechanism beyond room
configuration to also include lobby access level changes.

This ensures that all owners and admins sharing a room maintain a
consistent and up-to-date view of room state in the frontend,
including configuration and access control updates.
2026-05-17 23:39:53 +02:00
lebaudantoine aab90650f1 (frontend) add synchroniser for room metadata updates
Listen to room metadata change events and synchronize the React
Query cache with the latest room data fetched from the API.

This ensures clients react to live configuration updates, such as
showing or hiding mute controls when `everyone_can_mute` changes.
2026-05-17 23:39:53 +02:00
lebaudantoine 534cf000b2 (backend) expose room configuration to all API consumers
Update room serialization to include room configuration for all
users fetching the API response, not only room owners.
This behavior was inherited from the original upstream project.

At the moment, exposing this configuration does not appear to
introduce meaningful security concerns or provide attackers with
additional capabilities.

The decision will continue to be reviewed from a security
perspective, but sharing the configuration improves frontend
consistency and synchronization.
2026-05-17 23:39:53 +02:00
lebaudantoine 5bac1668fe ♻️(fullstack) simplify source serialization
Simplify source serialization and validation logic while improving
type safety around room configuration handling.

Introduce a dedicated TypeScript type matching the backend
Pydantic model more precisely.

Also harmonize track source casing between frontend and backend to
remove redundant conversion logic and resolve #1282.
2026-05-17 23:39:53 +02:00
lebaudantoine 5a7a0da923 (backend) add synchronization mechanism for room configuration updates
Introduce synchronization of room configuration changes across
active participants.

When a room configuration is updated through a PUT operation, the
backend now performs an additional LiveKit API call to notify room
participants through a room metadata update event.

This ensures admins and owners quickly see up-to-date settings in
their administration panel. It also prepares the frontend for
automatic updates of unprivileged participants room’s data without
refetching it from the API.

An event-driven design was chosen instead of storing the full room
configuration in LiveKit metadata. While embedding the state
directly in metadata would provide immediate synchronization, it
would also require initializing and maintaining configuration
state during room creation or webhook handling, increasing the
risk of operational failures and regressions.

Instead, the backend emits lightweight synchronization events and
active clients update their React Query cache, which remains the
single source of truth for room configuration data.
2026-05-17 23:39:53 +02:00
lebaudantoine c20daafd81 (fullstack) support everyone_can_mute room configuration
Introduce a new room setting controlling whether all participants,
including non-privileged users, can mute others.

Update API validation accordingly and add the frontend controls
allowing administrators to toggle the option and persist the
configuration through the API.
2026-05-17 23:39:53 +02:00
lebaudantoine 9846a61bd0 (frontend) update useCanMute hook to reflect room muting behavior
Allow non-privileged users to mute others when the
everyone_can_mute configuration is unset or true.

This setting is not yet customizable by room owners and will be
introduced in a future update.
2026-05-17 23:39:53 +02:00
lebaudantoine 388b7d172d (frontend) allow unauthenticated participants to mute via LiveKit token
Pass the LiveKit token when calling the mute-participant endpoint
to authenticate the request.

This enables non-authenticated participants to mute others through
the API while preserving proper authorization checks.
2026-05-17 23:39:53 +02:00
lebaudantoine 288562cc0e 🛂(backend) allow participants to mute others based on room configuration
Enable any participant to mute others when the room configuration
allows it. This is enabled by default for all meetings unless
explicitly disabled by an administrator.

Privileged users retain the ability to mute any participant
regardless of the room configuration.
2026-05-17 23:39:53 +02:00
leo 79400188d8 🔊(summary) improve logging of speaker assign
Structure logging of speaker assignment in json format to help
assess its performance.
2026-05-14 15:39:02 +02:00
lebaudantoine dcaa45ccfe 🩹(frontend) fix subtitle background regression
Restore transparent background as the default subtitle background
to match previous behavior.
2026-05-14 15:04:48 +02:00
99 changed files with 5873 additions and 1205 deletions
-1
View File
@@ -305,7 +305,6 @@ jobs:
working-directory: src/summary
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"}]'
AWS_STORAGE_BUCKET_NAME: "http://meet-media-storage"
AWS_S3_ENDPOINT_URL: "minio:9000"
+14
View File
@@ -8,6 +8,20 @@ and this project adheres to
## [Unreleased]
### Added
- ✨(fullstack) allow participants to mute others based on room configuration
- ✨(frontend) add synchronizer for room metadata updates
- ✨(frontend) make reaction toolbar responsive on small viewports
- ✨(frontend) enable reactions on mobile devices
- ✨(frontend) introduce picture-in-picture meeting
### Changed
- ♻️(fullstack) simplify source serialization
- ✨(backend) expose room configuration to all API consumers
- 🩹(frontend) improve reaction toolbar centering with dynamic positioning
## [1.16.0] - 2026-05-13
### Added
+2 -1
View File
@@ -64,8 +64,9 @@ ALLOW_UNREGISTERED_ROOMS=False
RECORDING_ENABLE=True
RECORDING_STORAGE_EVENT_ENABLE=True
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_WEBHOOK_API_TOKEN=webhook-password
RECORDING_DOWNLOAD_BASE_URL=http://localhost:3000/recording
# Recording encoding (LiveKit Egress advanced options).
+293 -31
View File
@@ -36,7 +36,7 @@
"source-map-loader": "^5.0.0",
"webpack": "^5.95.0",
"webpack-cli": "^5.1.4",
"webpack-dev-server": "5.2.1"
"webpack-dev-server": "5.2.4"
}
},
"node_modules/@apidevtools/json-schema-ref-parser": {
@@ -4319,6 +4319,195 @@
"node": ">= 4.0.0"
}
},
"node_modules/@noble/hashes": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.4.0.tgz",
"integrity": "sha512-V1JJ1WTRUqHHrOSh597hURcMqVKVGL/ea3kv0gSnEdsEZ0/+VyPghM1lMNGc00z7CIQorSvbKpuJkxvuHbvdbg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 16"
},
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/@peculiar/asn1-cms": {
"version": "2.7.0",
"resolved": "https://registry.npmjs.org/@peculiar/asn1-cms/-/asn1-cms-2.7.0.tgz",
"integrity": "sha512-hew63shtzzvBcSHbhm+cyAmKe6AIfinT9hzEqSPjDC6opTTMKmTkQ0gHuN2KsWlvqiKw1S/fS94fhag/FJkioQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@peculiar/asn1-schema": "^2.7.0",
"@peculiar/asn1-x509": "^2.7.0",
"@peculiar/asn1-x509-attr": "^2.7.0",
"asn1js": "^3.0.6",
"tslib": "^2.8.1"
}
},
"node_modules/@peculiar/asn1-csr": {
"version": "2.7.0",
"resolved": "https://registry.npmjs.org/@peculiar/asn1-csr/-/asn1-csr-2.7.0.tgz",
"integrity": "sha512-VVsAyGqErT9D1SY4aEqozThXMVI+ssVRiv2DDeYuvpBKLIgZ3hYs3Ay3u/VSoKq6ESFi9cf6rf3IOOzfwh7oMA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@peculiar/asn1-schema": "^2.7.0",
"@peculiar/asn1-x509": "^2.7.0",
"asn1js": "^3.0.6",
"tslib": "^2.8.1"
}
},
"node_modules/@peculiar/asn1-ecc": {
"version": "2.7.0",
"resolved": "https://registry.npmjs.org/@peculiar/asn1-ecc/-/asn1-ecc-2.7.0.tgz",
"integrity": "sha512-n7KEs/Q/wrB415cxy4fHOBhegp4NdJ15fkJPwcB/3/8iNBQC2L/N7SChJPKDJPZGYH0jD4Tg4/0vnHmwghnbKw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@peculiar/asn1-schema": "^2.7.0",
"@peculiar/asn1-x509": "^2.7.0",
"asn1js": "^3.0.6",
"tslib": "^2.8.1"
}
},
"node_modules/@peculiar/asn1-pfx": {
"version": "2.7.0",
"resolved": "https://registry.npmjs.org/@peculiar/asn1-pfx/-/asn1-pfx-2.7.0.tgz",
"integrity": "sha512-V/nrlQVmhg7lYAsM7E13UDL5erAwFv6kCIVFqNaMIHSVi7dngcT839JkRTkQBqznMG98l2XjxYk74ZztAohZzA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@peculiar/asn1-cms": "^2.7.0",
"@peculiar/asn1-pkcs8": "^2.7.0",
"@peculiar/asn1-rsa": "^2.7.0",
"@peculiar/asn1-schema": "^2.7.0",
"asn1js": "^3.0.6",
"tslib": "^2.8.1"
}
},
"node_modules/@peculiar/asn1-pkcs8": {
"version": "2.7.0",
"resolved": "https://registry.npmjs.org/@peculiar/asn1-pkcs8/-/asn1-pkcs8-2.7.0.tgz",
"integrity": "sha512-9GTl1nE8Mx1kTZ+7QyYatDyKsm34QcWRBFkY1iPvWC3X4Dona5s/tlLiQsx5WzVdZqiMBZNYT0buyw4/vbhnjw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@peculiar/asn1-schema": "^2.7.0",
"@peculiar/asn1-x509": "^2.7.0",
"asn1js": "^3.0.6",
"tslib": "^2.8.1"
}
},
"node_modules/@peculiar/asn1-pkcs9": {
"version": "2.7.0",
"resolved": "https://registry.npmjs.org/@peculiar/asn1-pkcs9/-/asn1-pkcs9-2.7.0.tgz",
"integrity": "sha512-Bh7m+OuIaSEllPQcSd9OSp93F4ROWH7sbITWV8MI+8dwsjE5111/87VxiWVvYFKyww3vp39geLv9ENqhwWHcew==",
"dev": true,
"license": "MIT",
"dependencies": {
"@peculiar/asn1-cms": "^2.7.0",
"@peculiar/asn1-pfx": "^2.7.0",
"@peculiar/asn1-pkcs8": "^2.7.0",
"@peculiar/asn1-schema": "^2.7.0",
"@peculiar/asn1-x509": "^2.7.0",
"@peculiar/asn1-x509-attr": "^2.7.0",
"asn1js": "^3.0.6",
"tslib": "^2.8.1"
}
},
"node_modules/@peculiar/asn1-rsa": {
"version": "2.7.0",
"resolved": "https://registry.npmjs.org/@peculiar/asn1-rsa/-/asn1-rsa-2.7.0.tgz",
"integrity": "sha512-/qvENQrXyTZURjMqSeofHul0JJt2sNSzSwk36pl2olkHbaioMQgrASDZAlHXl0xUlnVbHj0uGgOrBMTb5x2aJQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@peculiar/asn1-schema": "^2.7.0",
"@peculiar/asn1-x509": "^2.7.0",
"asn1js": "^3.0.6",
"tslib": "^2.8.1"
}
},
"node_modules/@peculiar/asn1-schema": {
"version": "2.7.0",
"resolved": "https://registry.npmjs.org/@peculiar/asn1-schema/-/asn1-schema-2.7.0.tgz",
"integrity": "sha512-W8ZfWzLmQnrcky+eh3tni4IozMdqBDiHWU0N+vve/UGjMaUs8c0L7A2oEdkBXS8rTpWDpK/aoI3DG/L/hxmxPg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@peculiar/utils": "^2.0.2",
"asn1js": "^3.0.6",
"tslib": "^2.8.1"
}
},
"node_modules/@peculiar/asn1-x509": {
"version": "2.7.0",
"resolved": "https://registry.npmjs.org/@peculiar/asn1-x509/-/asn1-x509-2.7.0.tgz",
"integrity": "sha512-mUn9RRrkGDnG4ALfunDmzyRW5dg+sWCj/pfnCCqEHYbkGxEpvUt6iVJv8Yw1cyp6SWZ26ZE5oSmI5SqEaen15g==",
"dev": true,
"license": "MIT",
"dependencies": {
"@peculiar/asn1-schema": "^2.7.0",
"@peculiar/utils": "^2.0.2",
"asn1js": "^3.0.6",
"tslib": "^2.8.1"
}
},
"node_modules/@peculiar/asn1-x509-attr": {
"version": "2.7.0",
"resolved": "https://registry.npmjs.org/@peculiar/asn1-x509-attr/-/asn1-x509-attr-2.7.0.tgz",
"integrity": "sha512-NS8e7SOgXipkzUPLF/sce7ukpMpWjhxYsH0n6Y+bHYo4TTxOb95Zv7hqwSuL212mj5YxovjdOKQOgH1As3E94w==",
"dev": true,
"license": "MIT",
"dependencies": {
"@peculiar/asn1-schema": "^2.7.0",
"@peculiar/asn1-x509": "^2.7.0",
"asn1js": "^3.0.6",
"tslib": "^2.8.1"
}
},
"node_modules/@peculiar/utils": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/@peculiar/utils/-/utils-2.0.3.tgz",
"integrity": "sha512-+oL3HPFRIZ1St2K50lWCXiioIgSoxzz7R1J3uF6neO2yl1sgmpgY6XXJH4BdpoDkMWznQTeYF6oWNDZLCdQ4eQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"tslib": "^2.8.1"
}
},
"node_modules/@peculiar/x509": {
"version": "1.14.3",
"resolved": "https://registry.npmjs.org/@peculiar/x509/-/x509-1.14.3.tgz",
"integrity": "sha512-C2Xj8FZ0uHWeCXXqX5B4/gVFQmtSkiuOolzAgutjTfseNOHT3pUjljDZsTSxXFGgio54bCzVFqmEOUrIVk8RDA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@peculiar/asn1-cms": "^2.6.0",
"@peculiar/asn1-csr": "^2.6.0",
"@peculiar/asn1-ecc": "^2.6.0",
"@peculiar/asn1-pkcs9": "^2.6.0",
"@peculiar/asn1-rsa": "^2.6.0",
"@peculiar/asn1-schema": "^2.6.0",
"@peculiar/asn1-x509": "^2.6.0",
"pvtsutils": "^1.3.6",
"reflect-metadata": "^0.2.2",
"tslib": "^2.8.1",
"tsyringe": "^4.10.0"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@peculiar/x509/node_modules/reflect-metadata": {
"version": "0.2.2",
"resolved": "https://registry.npmjs.org/reflect-metadata/-/reflect-metadata-0.2.2.tgz",
"integrity": "sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==",
"dev": true,
"license": "Apache-2.0"
},
"node_modules/@pkgr/core": {
"version": "0.2.9",
"resolved": "https://registry.npmjs.org/@pkgr/core/-/core-0.2.9.tgz",
@@ -4533,16 +4722,6 @@
"form-data": "^4.0.4"
}
},
"node_modules/@types/node-forge": {
"version": "1.3.14",
"resolved": "https://registry.npmjs.org/@types/node-forge/-/node-forge-1.3.14.tgz",
"integrity": "sha512-mhVF2BnD4BO+jtOp7z1CdzaK4mbuK0LLQYAvdOLqHTavxFNq4zA1EmYkpnFjP8HOUzedfQkRnp0E2ulSAYSzAw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@types/office-js": {
"version": "1.0.582",
"resolved": "https://registry.npmjs.org/@types/office-js/-/office-js-1.0.582.tgz",
@@ -5599,6 +5778,21 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/asn1js": {
"version": "3.0.10",
"resolved": "https://registry.npmjs.org/asn1js/-/asn1js-3.0.10.tgz",
"integrity": "sha512-S2s3aOytiKdFRdulw2qPE51MzjzVOisppcVv7jVFR+Kw0kxwvFrDcYA0h7Ndqbmj0HkMIXYWaoj7fli8kgx1eg==",
"dev": true,
"license": "BSD-3-Clause",
"dependencies": {
"pvtsutils": "^1.3.6",
"pvutils": "^1.1.5",
"tslib": "^2.8.1"
},
"engines": {
"node": ">=12.0.0"
}
},
"node_modules/assertion-error": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-1.1.0.tgz",
@@ -6070,6 +6264,16 @@
"node": ">= 0.8"
}
},
"node_modules/bytestreamjs": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/bytestreamjs/-/bytestreamjs-2.0.1.tgz",
"integrity": "sha512-U1Z/ob71V/bXfVABvNr/Kumf5VyeQRBEm6Txb0PQ6S7V5GpBM3w4Cbqz/xPDicR5tN0uvDifng8C+5qECeGwyQ==",
"dev": true,
"license": "BSD-3-Clause",
"engines": {
"node": ">=6.0.0"
}
},
"node_modules/call-bind": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.8.tgz",
@@ -12640,6 +12844,24 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/pkijs": {
"version": "3.4.0",
"resolved": "https://registry.npmjs.org/pkijs/-/pkijs-3.4.0.tgz",
"integrity": "sha512-emEcLuomt2j03vxD54giVB4SxTjnsqkU692xZOZXHDVoYyypEm+b3jpiTcc+Cf+myooc+/Ly0z01jqeNHVgJGw==",
"dev": true,
"license": "BSD-3-Clause",
"dependencies": {
"@noble/hashes": "1.4.0",
"asn1js": "^3.0.6",
"bytestreamjs": "^2.0.1",
"pvtsutils": "^1.3.6",
"pvutils": "^1.1.3",
"tslib": "^2.8.1"
},
"engines": {
"node": ">=16.0.0"
}
},
"node_modules/possible-typed-array-names": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz",
@@ -12823,6 +13045,26 @@
"node": ">=6"
}
},
"node_modules/pvtsutils": {
"version": "1.3.6",
"resolved": "https://registry.npmjs.org/pvtsutils/-/pvtsutils-1.3.6.tgz",
"integrity": "sha512-PLgQXQ6H2FWCaeRak8vvk1GW462lMxB5s3Jm673N82zI4vqtVUPuZdffdZbPDFRoU8kAhItWFtPCWiPpp4/EDg==",
"dev": true,
"license": "MIT",
"dependencies": {
"tslib": "^2.8.1"
}
},
"node_modules/pvutils": {
"version": "1.1.5",
"resolved": "https://registry.npmjs.org/pvutils/-/pvutils-1.1.5.tgz",
"integrity": "sha512-KTqnxsgGiQ6ZAzZCVlJH5eOjSnvlyEgx1m8bkRJfOhmGRqfo5KLvmAlACQkrjEtOQ4B7wF9TdSLIs9O90MX9xA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=16.0.0"
}
},
"node_modules/qs": {
"version": "6.14.2",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.14.2.tgz",
@@ -13551,17 +13793,17 @@
"license": "MIT"
},
"node_modules/selfsigned": {
"version": "2.4.1",
"resolved": "https://registry.npmjs.org/selfsigned/-/selfsigned-2.4.1.tgz",
"integrity": "sha512-th5B4L2U+eGLq1TVh7zNRGBapioSORUeymIydxgFpwww9d2qyKvtuPU2jJuHvYAwwqi2Y596QBL3eEqcPEYL8Q==",
"version": "5.5.0",
"resolved": "https://registry.npmjs.org/selfsigned/-/selfsigned-5.5.0.tgz",
"integrity": "sha512-ftnu3TW4+3eBfLRFnDEkzGxSF/10BJBkaLJuBHZX0kiPS7bRdlpZGu6YGt4KngMkdTwJE6MbjavFpqHvqVt+Ew==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node-forge": "^1.3.0",
"node-forge": "^1"
"@peculiar/x509": "^1.14.2",
"pkijs": "^3.3.3"
},
"engines": {
"node": ">=10"
"node": ">=18"
}
},
"node_modules/semver": {
@@ -14795,6 +15037,26 @@
"dev": true,
"license": "0BSD"
},
"node_modules/tsyringe": {
"version": "4.10.0",
"resolved": "https://registry.npmjs.org/tsyringe/-/tsyringe-4.10.0.tgz",
"integrity": "sha512-axr3IdNuVIxnaK5XGEUFTu3YmAQ6lllgrvqfEoR16g/HGnYY/6We4oWENtAnzK6/LpJ2ur9PAb80RBt7/U4ugw==",
"dev": true,
"license": "MIT",
"dependencies": {
"tslib": "^1.9.3"
},
"engines": {
"node": ">= 6.0.0"
}
},
"node_modules/tsyringe/node_modules/tslib": {
"version": "1.14.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz",
"integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==",
"dev": true,
"license": "0BSD"
},
"node_modules/tunnel-agent": {
"version": "0.6.0",
"resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz",
@@ -15416,15 +15678,15 @@
}
},
"node_modules/webpack-dev-server": {
"version": "5.2.1",
"resolved": "https://registry.npmjs.org/webpack-dev-server/-/webpack-dev-server-5.2.1.tgz",
"integrity": "sha512-ml/0HIj9NLpVKOMq+SuBPLHcmbG+TGIjXRHsYfZwocUBIqEvws8NnS/V9AFQ5FKP+tgn5adwVwRrTEpGL33QFQ==",
"version": "5.2.4",
"resolved": "https://registry.npmjs.org/webpack-dev-server/-/webpack-dev-server-5.2.4.tgz",
"integrity": "sha512-GqDPGZN9bRqKBTkp4aWkobDDHMsrXKoGSdOH56smIri8qR0JG8gfL8/v/f/OZR3/OKXjG8uwJbFVhKm/FNU/UA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/bonjour": "^3.5.13",
"@types/connect-history-api-fallback": "^1.5.4",
"@types/express": "^4.17.21",
"@types/express": "^4.17.25",
"@types/express-serve-static-core": "^4.17.21",
"@types/serve-index": "^1.9.4",
"@types/serve-static": "^1.15.5",
@@ -15434,17 +15696,17 @@
"bonjour-service": "^1.2.1",
"chokidar": "^3.6.0",
"colorette": "^2.0.10",
"compression": "^1.7.4",
"compression": "^1.8.1",
"connect-history-api-fallback": "^2.0.0",
"express": "^4.21.2",
"express": "^4.22.1",
"graceful-fs": "^4.2.6",
"http-proxy-middleware": "^2.0.7",
"http-proxy-middleware": "^2.0.9",
"ipaddr.js": "^2.1.0",
"launch-editor": "^2.6.1",
"open": "^10.0.3",
"p-retry": "^6.2.0",
"schema-utils": "^4.2.0",
"selfsigned": "^2.4.1",
"selfsigned": "^5.5.0",
"serve-index": "^1.9.1",
"sockjs": "^0.3.24",
"spdy": "^4.0.2",
@@ -15500,9 +15762,9 @@
}
},
"node_modules/webpack-dev-server/node_modules/ipaddr.js": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-2.3.0.tgz",
"integrity": "sha512-Zv/pA+ciVFbCSBBjGfaKUya/CcGmUHzTydLMaTwrUUEM2DIEO3iZvueGxmacvmN50fGpGVKeTXpb2LcYQxeVdg==",
"version": "2.4.0",
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-2.4.0.tgz",
"integrity": "sha512-9VGk3HGanVE6JoZXHiCpnGy5X0jYDnN4EA4lntFPj+1vIWlFhIylq2CrrCOJH9EAhc5CYhq18F2Av2tgoAPsYQ==",
"dev": true,
"license": "MIT",
"engines": {
@@ -15529,9 +15791,9 @@
}
},
"node_modules/webpack-dev-server/node_modules/ws": {
"version": "8.20.0",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.20.0.tgz",
"integrity": "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA==",
"version": "8.20.1",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.20.1.tgz",
"integrity": "sha512-It4dO0K5v//JtTXuPkfEOaI3uUN87iYPnqo/ZzqCoG3g8uhA66QUMs/SrM0YK7/NAu+r4LMh/9dq2A7k+rHs+w==",
"dev": true,
"license": "MIT",
"engines": {
+1 -1
View File
@@ -53,7 +53,7 @@
"source-map-loader": "^5.0.0",
"webpack": "^5.95.0",
"webpack-cli": "^5.1.4",
"webpack-dev-server": "5.2.1"
"webpack-dev-server": "5.2.4"
},
"prettier": "office-addin-prettier-config",
"browserslist": [
+30
View File
@@ -136,3 +136,33 @@ class FilePermission(IsAuthenticated):
raise Http404
return obj.get_abilities(request.user).get(view.action, False)
class CanMuteParticipant(permissions.BasePermission):
"""
Grant muting rights based on role or room configuration.
- Admins and owners can always mute.
- When `everyone_can_mute` is enabled on the room, any participant
currently in the room (proven by a valid LiveKit token for that room)
can mute.
"""
def has_object_permission(self, request, view, obj):
"""Check if the requesting user is allowed to mute a participant in the given room."""
is_livekit_token_auth = request.auth and hasattr(request.auth, "video")
# Always allow admins/owners when authenticated with session cookie
if not is_livekit_token_auth and obj.is_administrator_or_owner(request.user):
return True
everyone_can_mute = obj.configuration.get("everyone_can_mute", True)
if not everyone_can_mute:
return False
if not is_livekit_token_auth:
return False
# LiveKit token scoped to this room
return request.auth.video.room == str(obj.id)
+9 -14
View File
@@ -13,7 +13,7 @@ from django.core.exceptions import SuspiciousOperation
from django.utils.translation import gettext_lazy as _
from django_pydantic_field.rest_framework import SchemaField
from pydantic import BaseModel, Field
from pydantic import BaseModel, Field, field_serializer
from pydantic import ValidationError as PydanticValidationError
from rest_framework import serializers
from rest_framework.exceptions import PermissionDenied
@@ -166,11 +166,6 @@ class RoomSerializer(serializers.ModelSerializer):
)
output["accesses"] = access_serializer.data
configuration = output["configuration"]
if not is_admin_or_owner:
del output["configuration"]
should_access_room = (
(
instance.access_level == models.RoomAccessLevel.TRUSTED
@@ -187,7 +182,7 @@ class RoomSerializer(serializers.ModelSerializer):
room_id=room_id,
user=request.user,
username=username,
configuration=configuration,
configuration=output["configuration"],
is_admin_or_owner=is_admin_or_owner,
)
else:
@@ -317,9 +312,7 @@ class MuteParticipantSerializer(BaseParticipantsManagementSerializer):
)
RoomConfigurationTrackSource = Literal[
"camera", "microphone", "screen_share", "screen_share_audio"
]
TrackSource = Literal["camera", "microphone", "screen_share", "screen_share_audio"]
class RoomConfiguration(BaseModel):
@@ -328,14 +321,12 @@ class RoomConfiguration(BaseModel):
Unknown fields are rejected.
"""
can_publish_sources: list[RoomConfigurationTrackSource] | None = None
can_publish_sources: list[TrackSource] | None = None
everyone_can_mute: bool | None = None
model_config = {"extra": "forbid"}
TrackSource = Literal["SCREEN_SHARE", "SCREEN_SHARE_AUDIO", "CAMERA", "MICROPHONE"]
class ParticipantPermission(BaseModel):
"""Mirror the LiveKit ParticipantPermission protobuf.
@@ -355,6 +346,10 @@ class ParticipantPermission(BaseModel):
model_config = {"extra": "forbid"}
@field_serializer("can_publish_sources")
def _serialize_sources(self, sources: list[str]) -> list[str]:
return [s.upper() for s in sources]
class UpdateParticipantSerializer(BaseParticipantsManagementSerializer):
"""Validate participant update data."""
+111 -2
View File
@@ -33,12 +33,16 @@ from rest_framework import (
from rest_framework import (
status as drf_status,
)
from rest_framework.settings import api_settings
from core import enums, models, utils
from core.api.filters import ListFileFilter
from core.enums import MEDIA_STORAGE_URL_PATTERN
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 (
InvalidBucketError,
InvalidFilepathError,
@@ -76,6 +80,11 @@ from core.services.participants_management import (
ParticipantsManagementException,
)
from core.services.room_creation import RoomCreation
from core.services.room_management import (
RoomManagement,
RoomManagementException,
RoomNotFoundException,
)
from core.services.subtitle import SubtitleException, SubtitleService
from core.tasks.file import process_file_deletion
@@ -299,6 +308,41 @@ class RoomViewSet(
if callback_id := self.request.data.get("callback_id"):
RoomCreation().persist_callback_state(callback_id, room)
def perform_update(self, serializer):
"""Persist the room update, then sync metadata to LiveKit."""
old_configuration = serializer.instance.configuration
old_access_level = serializer.instance.access_level
room = serializer.save()
if (
room.configuration == old_configuration
and room.access_level == old_access_level
):
return
metadata = {
"configuration": room.configuration,
"access_level": room.access_level,
}
try:
RoomManagement().update_metadata(
room_name=str(room.id),
metadata=metadata,
)
except RoomNotFoundException:
logger.info(
"LiveKit room %s does not exist yet, skipping metadata sync",
room.id,
)
except RoomManagementException:
logger.warning(
"Failed to sync metadata to LiveKit for room %s",
room.id,
)
@decorators.action(
detail=True,
methods=["post"],
@@ -614,7 +658,11 @@ class RoomViewSet(
methods=["post"],
url_path="mute-participant",
url_name="mute-participant",
permission_classes=[permissions.HasPrivilegesOnRoom],
permission_classes=[permissions.CanMuteParticipant],
authentication_classes=[
LiveKitTokenAuthentication,
*api_settings.DEFAULT_AUTHENTICATION_CLASSES,
],
)
def mute_participant(self, request, pk=None): # pylint: disable=unused-argument
"""Mute a specific track for a participant in the room."""
@@ -623,6 +671,26 @@ class RoomViewSet(
serializer = serializers.MuteParticipantSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
# TEMPORARY: a LiveKit token proves access was granted, not that the caller
# joined. Cross-check identity against the live participant list until auth
# is hardened. Skipped for non-LiveKit auth backends.
caller_identity = getattr(request.auth, "identity", None)
if caller_identity is not None:
try:
ParticipantsManagement().check_if_in_meeting(
room_name=str(room.pk),
identity=caller_identity,
)
except (ParticipantNotFoundException, ParticipantsManagementException):
logger.warning(
"Failed to verify caller presence for mute in room %s; denying",
room.pk,
)
return drf_response.Response(
{"error": "Could not verify caller presence"},
status=drf_status.HTTP_403_FORBIDDEN,
)
try:
ParticipantsManagement().mute(
room_name=str(room.pk),
@@ -930,6 +998,47 @@ class RecordingViewSet(
{"message": "Event processed."},
)
@decorators.action(
detail=False,
methods=["post"],
url_path="external-process-hook",
authentication_classes=[RecordingProcessWebhookAuthentication],
)
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)
data = request.data
if not data.get("job_id"):
raise drf_exceptions.ValidationError(detail="No job_id provided")
job_id = 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)
recording = None
changed = False
if recording and data.get("type") == "transcript":
if data.get("status") == "success":
logger.info("External process received for recording %s", job_id)
recording.status = (
models.RecordingStatusChoices.EXTERNAL_PROCESS_SUCCESSFUL
)
changed = True
if data.get("status") == "failure":
recording.status = models.RecordingStatusChoices.EXTERNAL_PROCESS_FAILED
changed = True
if changed and recording:
recording.save()
else:
logger.info("No changes to save for external process id %s", job_id)
return drf_response.Response(
{"message": "Event processed."},
)
def _auth_get_original_url(self, request):
"""
Extracts and parses the original URL from the "HTTP_X_ORIGINAL_URL" header.
@@ -0,0 +1,23 @@
# Generated by Django 5.2.14 on 2026-05-26 14:30
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('core', '0018_rename_active_application_is_active'),
]
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),
),
]
+18
View File
@@ -60,6 +60,11 @@ class RecordingStatusChoices(models.TextChoices):
FAILED_TO_START = "failed_to_start", _("Failed to Start")
FAILED_TO_STOP = "failed_to_stop", _("Failed to Stop")
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
def is_final(cls, status):
@@ -73,6 +78,8 @@ class RecordingStatusChoices(models.TextChoices):
cls.STOPPED,
cls.SAVED,
cls.ABORTED,
cls.EXTERNAL_PROCESS_SUCCESSFUL,
cls.EXTERNAL_PROCESS_FAILED,
cls.FAILED_TO_START,
cls.FAILED_TO_STOP,
}
@@ -388,6 +395,7 @@ class Room(Resource):
choices=RoomAccessLevel.choices,
default=settings.RESOURCE_DEFAULT_ACCESS_LEVEL,
)
# Public configuration exposed to any room participant via the API
configuration = models.JSONField(
blank=True,
default=dict,
@@ -590,6 +598,14 @@ class Recording(BaseModel):
verbose_name=_("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:
db_table = "meet_recording"
@@ -645,6 +661,8 @@ class Recording(BaseModel):
return self.status in {
RecordingStatusChoices.NOTIFICATION_SUCCEEDED,
RecordingStatusChoices.SAVED,
RecordingStatusChoices.EXTERNAL_PROCESS_SUCCESSFUL,
RecordingStatusChoices.EXTERNAL_PROCESS_FAILED,
}
@property
@@ -14,9 +14,9 @@ logger = logging.getLogger(__name__)
class MachineUser:
"""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.username = "storage_event_user"
self.username = username
self.is_active = True
@property
@@ -91,3 +91,29 @@ class StorageEventAuthentication(BaseAuthentication):
def authenticate_header(self, request):
"""Return the WWW-Authenticate header value."""
return f"{self.TOKEN_TYPE} realm='Storage event API'"
class RecordingProcessWebhookAuthentication(BaseAuthentication):
"""
Custom authentication class for recording process webhook requests.
Validates the API key in the Authorization header.
"""
def authenticate(self, request):
"""
Authenticate the request and return a two-tuple of (user, token).
"""
logger.info("Authentificating")
authorization_header: str = request.headers.get("Authorization") or ""
if not secrets.compare_digest(
authorization_header.removeprefix("Bearer "),
settings.SUMMARY_SERVICE_WEBHOOK_API_TOKEN,
):
logger.warning(
"Authentication failed: Bad Authorization header (ip: %s)",
request.META.get("REMOTE_ADDR"),
)
raise AuthenticationFailed()
return MachineUser("external_process_user"), None
@@ -1,8 +1,10 @@
"""Service to notify external services when a new recording is ready."""
import asyncio
import json
import logging
import smtplib
import warnings
from datetime import datetime, timezone
from django.conf import settings
@@ -17,6 +19,7 @@ from asgiref.sync import async_to_sync
from livekit import api as livekit_api
from core import models, utils
from core.utils import generate_download_s3_file_url
logger = logging.getLogger(__name__)
@@ -215,18 +218,45 @@ class NotificationService:
)(recording.worker_id)
payload = {
# Legacy V1 params to avoid a breaking change
"owner_id": str(owner_access.user.id),
"recording_filename": recording.key,
"metadata_filename": metadata_filename,
"email": owner_access.user.email,
"sub": owner_access.user.sub,
"room": recording.room.name,
"language": recording.options.get("language"),
"owner_timezone": str(owner_access.user.timezone),
"download_link": f"{get_recording_download_base_url()}/{recording.id}",
"context_language": owner_access.user.language,
"recording_start_at": (started_at.isoformat() if started_at else None),
"recording_end_at": (ended_at.isoformat() if ended_at else None),
# V2 params
"user_sub": owner_access.user.sub,
"user_email": owner_access.user.email,
"cloud_storage_url": generate_download_s3_file_url(
recording.key, expires_in=60 * 60 * 24, override_domain=False
),
"language": recording.options.get("language", "fr"),
"context_language": owner_access.user.language,
"push_to_docs_config": {
"user_email": owner_access.user.email,
"title": "TODO",
"download_link": f"{get_recording_download_base_url()}/{recording.id}",
# For now the feature flag logic is handled on summary side
"auto_create_summary": True,
},
"metadata": (
{
"cloud_storage_url": generate_download_s3_file_url(
metadata_filename,
expires_in=60 * 60 * 24,
override_domain=False,
),
"started_at": started_at.isoformat(),
"ended_at": ended_at.isoformat(),
}
if (started_at and ended_at)
else None
),
}
headers = {
@@ -242,6 +272,31 @@ class NotificationService:
timeout=30,
)
response.raise_for_status()
is_v2_implementation = False
try:
response_json = response.json()
# We do not require a job_id to avoid a breaking change
if job_id := response_json.get("job_id"):
recording.external_process_id = job_id
recording.save()
is_v2_implementation = True
except json.JSONDecodeError:
pass
if not is_v2_implementation:
warnings.warn(
"You are likely using your own implementation for the summary / "
"transcribe service."
"We have released a new version and API contract for that service, "
"we recommend checking it out"
# pylint: disable=line-too-long
"https://github.com/suitenumerique/meet/blob/19c2a378e7e66652afbfa3e749badd697e3917ac/src/summary/summary/api/route/tasks_v2.py "
"and mimicking it's behavior. We will remove the legacy "
"handling in a future version.",
DeprecationWarning,
stacklevel=2,
)
except requests.RequestException as exc:
logger.exception(
"Summary service error for recording %s. URL: %s. Exception: %s",
@@ -15,6 +15,7 @@ from livekit.api import (
TwirpError,
UpdateParticipantRequest,
)
from livekit.protocol.models import ParticipantInfo
from core import utils
@@ -154,3 +155,44 @@ class ParticipantsManagement:
finally:
await lkapi.aclose()
@async_to_sync
async def check_if_in_meeting(self, room_name: str, identity: str) -> bool:
"""Check whether `identity` is currently a participant in `room_name`.
Raises ParticipantsManagementException for unexpected LiveKit errors
so callers can fail closed rather than silently allowing the action.
"""
if not room_name or not identity:
return False
lkapi = utils.create_livekit_client()
try:
participant = await lkapi.room.get_participant(
RoomParticipantIdentity(
room=room_name,
identity=identity,
)
)
except TwirpError as e:
if e.code == "not_found":
raise ParticipantNotFoundException("Participant does not exist") from e
logger.exception(
"Unexpected error checking participant %s in room %s",
identity,
room_name,
)
raise ParticipantsManagementException(
"Could not verify participant presence"
) from e
finally:
await lkapi.aclose()
return (
participant is not None
and participant.state != ParticipantInfo.State.DISCONNECTED
)
@@ -0,0 +1,64 @@
"""Room management service for LiveKit rooms."""
# pylint: disable=no-name-in-module
import json
from logging import getLogger
from typing import Dict, Optional
from asgiref.sync import async_to_sync
from livekit.api import (
TwirpError,
UpdateRoomMetadataRequest,
)
from core import utils
logger = getLogger(__name__)
class RoomManagementException(Exception):
"""Exception raised when a room management operation fails."""
class RoomNotFoundException(RoomManagementException):
"""Raised when the target room does not exist in LiveKit."""
class RoomManagement:
"""Service for managing LiveKit rooms."""
@async_to_sync
async def update_metadata(self, room_name: str, metadata: Optional[Dict] = None):
"""Update a LiveKit room's metadata.
The `room_name` corresponds to the LiveKit room identifier
(i.e. the Room model's UUID as a string).
"""
lkapi = utils.create_livekit_client()
try:
await lkapi.room.update_room_metadata(
UpdateRoomMetadataRequest(
room=room_name,
metadata=json.dumps(metadata) if metadata is not None else "",
)
)
except TwirpError as e:
if e.code == "not_found":
logger.warning(
"Room %s not found in LiveKit, skipping metadata update",
room_name,
)
raise RoomNotFoundException("Room does not exist") from e
logger.exception(
"Unexpected error updating metadata for room %s",
room_name,
)
raise RoomManagementException("Could not update room metadata") from e
finally:
await lkapi.aclose()
@@ -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 == 403
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 == 403
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 "No job_id provided" in str(response.json())
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
@@ -2,20 +2,23 @@
Test rooms API endpoints in the Meet core app: participants management.
"""
# pylint: disable=redefined-outer-name,unused-argument,protected-access
# pylint: disable=redefined-outer-name,unused-argument,protected-access,no-name-in-module,too-many-lines
import random
from unittest import mock
from uuid import uuid4
from django.contrib.auth.models import AnonymousUser
from django.core.exceptions import SuspiciousOperation
from django.urls import reverse
import pytest
from livekit.api import TwirpError
from livekit.api import TwirpError, UpdateParticipantRequest
from livekit.protocol.models import ParticipantInfo
from rest_framework import status
from rest_framework.test import APIClient
from core import utils
from core.factories import RoomFactory, UserFactory, UserResourceAccessFactory
from core.services.lobby import LobbyService
@@ -31,8 +34,8 @@ def mock_livekit_client():
yield mock_client
def test_mute_participant_success(mock_livekit_client):
"""Test successful participant muting."""
def test_mute_participant_success_as_admin(mock_livekit_client):
"""Admins and owners should be able to mute without a LiveKit token."""
client = APIClient()
room = RoomFactory()
user = UserFactory()
@@ -41,10 +44,12 @@ def test_mute_participant_success(mock_livekit_client):
)
client.force_authenticate(user=user)
payload = {"participant_identity": str(uuid4()), "track_sid": "test-track-sid"}
url = reverse("rooms-mute-participant", kwargs={"pk": room.id})
response = client.post(url, payload, format="json")
response = client.post(
url,
{"participant_identity": str(uuid4()), "track_sid": "test-track-sid"},
format="json",
)
assert response.status_code == status.HTTP_200_OK
assert response.data == {"status": "success"}
@@ -53,23 +58,131 @@ def test_mute_participant_success(mock_livekit_client):
mock_livekit_client.aclose.assert_called_once()
def test_mute_participant_forbidden_without_access():
"""Test mute participant returns 403 when user lacks room privileges."""
def test_mute_participant_anonymous_no_token_forbidden(mock_livekit_client):
"""Should forbid muting when user is anonymous and no LiveKit token."""
client = APIClient()
room = RoomFactory()
user = UserFactory() # User without UserResourceAccess
client.force_authenticate(user=user)
payload = {"participant_identity": str(uuid4()), "track_sid": "test-track-sid"}
url = reverse("rooms-mute-participant", kwargs={"pk": room.id})
response = client.post(url, payload, format="json")
response = client.post(
url,
{"participant_identity": str(uuid4()), "track_sid": "test-track-sid"},
format="json",
)
assert response.status_code == status.HTTP_403_FORBIDDEN
mock_livekit_client.room.mute_published_track.assert_not_called()
def test_mute_participant_with_livekit_token_for_this_room(mock_livekit_client):
"""Should allow muting when the LiveKit token is scoped to this room."""
client = APIClient()
room = RoomFactory()
user = AnonymousUser()
token = utils.generate_token(str(room.id), user, is_admin_or_owner=False)
url = reverse("rooms-mute-participant", kwargs={"pk": room.id})
response = client.post(
url,
{"participant_identity": str(uuid4()), "track_sid": "test-track-sid"},
format="json",
HTTP_AUTHORIZATION=f"Bearer {token}",
)
assert response.status_code == status.HTTP_200_OK
assert response.data == {"status": "success"}
mock_livekit_client.room.mute_published_track.assert_called_once()
def test_mute_participant_with_livekit_token_for_another_room_forbidden(
mock_livekit_client,
):
"""Should forbid muting when the LiveKit token is scoped to a different room."""
client = APIClient()
target_room = RoomFactory()
other_room = RoomFactory()
user = AnonymousUser()
token = utils.generate_token(str(other_room.id), user, is_admin_or_owner=False)
url = reverse("rooms-mute-participant", kwargs={"pk": target_room.id})
response = client.post(
url,
{"participant_identity": str(uuid4()), "track_sid": "test-track-sid"},
format="json",
HTTP_AUTHORIZATION=f"Bearer {token}",
)
assert response.status_code == status.HTTP_403_FORBIDDEN
mock_livekit_client.room.mute_published_track.assert_not_called()
def test_mute_participant_authenticated_no_role_no_token_forbidden(mock_livekit_client):
"""Should forbid muting when user has no room role and no LiveKit token."""
client = APIClient()
room = RoomFactory() # everyone_can_mute defaults to True
user = UserFactory() # no UserResourceAccess for this room
client.force_authenticate(user=user)
url = reverse("rooms-mute-participant", kwargs={"pk": room.id})
response = client.post(
url,
{"participant_identity": str(uuid4()), "track_sid": "test-track-sid"},
format="json",
)
assert response.status_code == status.HTTP_403_FORBIDDEN
mock_livekit_client.room.mute_published_track.assert_not_called()
def test_mute_participant_everyone_can_mute_disabled_blocks_non_admin(
mock_livekit_client,
):
"""Should forbid muting when everyone_can_mute is False, even with a LiveKit token."""
client = APIClient()
room = RoomFactory(configuration={"everyone_can_mute": False})
user = AnonymousUser()
token = utils.generate_token(str(room.id), user, is_admin_or_owner=False)
url = reverse("rooms-mute-participant", kwargs={"pk": room.id})
response = client.post(
url,
{"participant_identity": str(uuid4()), "track_sid": "test-track-sid"},
format="json",
HTTP_AUTHORIZATION=f"Bearer {token}",
)
assert response.status_code == status.HTTP_403_FORBIDDEN
mock_livekit_client.room.mute_published_track.assert_not_called()
def test_mute_participant_everyone_can_mute_disabled_allows_admin(mock_livekit_client):
"""Should allow admins and owners to mute when everyone_can_mute is False."""
client = APIClient()
room = RoomFactory(configuration={"everyone_can_mute": False})
user = UserFactory()
UserResourceAccessFactory(
resource=room, user=user, role=random.choice(["administrator", "owner"])
)
client.force_authenticate(user=user)
url = reverse("rooms-mute-participant", kwargs={"pk": room.id})
response = client.post(
url,
{"participant_identity": str(uuid4()), "track_sid": "test-track-sid"},
format="json",
)
assert response.status_code == status.HTTP_200_OK
mock_livekit_client.room.mute_published_track.assert_called_once()
def test_mute_participant_invalid_payload():
"""Test mute participant with invalid payload."""
"""Should reject muting when the payload is invalid."""
client = APIClient()
room = RoomFactory()
user = UserFactory()
@@ -78,16 +191,16 @@ def test_mute_participant_invalid_payload():
)
client.force_authenticate(user=user)
payload = {"participant_identity": "invalid-uuid", "track_sid": ""}
url = reverse("rooms-mute-participant", kwargs={"pk": room.id})
response = client.post(url, payload, format="json")
response = client.post(
url, {"participant_identity": "invalid-uuid", "track_sid": ""}, format="json"
)
assert response.status_code == status.HTTP_400_BAD_REQUEST
def test_mute_participant_unexpected_twirp_error(mock_livekit_client):
"""Test mute participant when LiveKit API raises TwirpError."""
"""Should return 500 when the LiveKit API raises a TwirpError."""
client = APIClient()
mock_livekit_client.room.mute_published_track.side_effect = TwirpError(
@@ -101,10 +214,12 @@ def test_mute_participant_unexpected_twirp_error(mock_livekit_client):
)
client.force_authenticate(user=user)
payload = {"participant_identity": str(uuid4()), "track_sid": "test-track-sid"}
url = reverse("rooms-mute-participant", kwargs={"pk": room.id})
response = client.post(url, payload, format="json")
response = client.post(
url,
{"participant_identity": str(uuid4()), "track_sid": "test-track-sid"},
format="json",
)
assert response.status_code == status.HTTP_500_INTERNAL_SERVER_ERROR
assert response.data == {"error": "Failed to mute participant"}
@@ -112,6 +227,282 @@ def test_mute_participant_unexpected_twirp_error(mock_livekit_client):
mock_livekit_client.aclose.assert_called_once()
def test_mute_participant_participant_not_found(mock_livekit_client):
"""Should return 404 when the participant does not exist in the room."""
client = APIClient()
mock_livekit_client.room.mute_published_track.side_effect = TwirpError(
msg="participant does not exist", code="not_found", status=404
)
room = RoomFactory()
user = UserFactory()
UserResourceAccessFactory(
resource=room, user=user, role=random.choice(["administrator", "owner"])
)
client.force_authenticate(user=user)
url = reverse("rooms-mute-participant", kwargs={"pk": room.id})
response = client.post(
url,
{"participant_identity": str(uuid4()), "track_sid": "test-track-sid"},
format="json",
)
assert response.status_code == status.HTTP_404_NOT_FOUND
assert response.data == {"error": "Participant not found"}
mock_livekit_client.aclose.assert_called_once()
def test_mute_participant_management_exception(mock_livekit_client):
"""Should return 500 when ParticipantsManagement raises an unexpected error."""
client = APIClient()
mock_livekit_client.room.mute_published_track.side_effect = TwirpError(
msg="boom", code="internal", status=503
)
room = RoomFactory()
user = UserFactory()
UserResourceAccessFactory(
resource=room, user=user, role=random.choice(["administrator", "owner"])
)
client.force_authenticate(user=user)
url = reverse("rooms-mute-participant", kwargs={"pk": room.id})
response = client.post(
url,
{"participant_identity": str(uuid4()), "track_sid": "test-track-sid"},
format="json",
)
assert response.status_code == status.HTTP_500_INTERNAL_SERVER_ERROR
assert response.data == {"error": "Failed to mute participant"}
mock_livekit_client.aclose.assert_called_once()
def test_mute_participant_admin_with_token_for_this_room(mock_livekit_client):
"""Should allow muting when user is admin and LiveKit token is scoped to this room."""
client = APIClient()
room = RoomFactory()
user = UserFactory()
UserResourceAccessFactory(
resource=room, user=user, role=random.choice(["administrator", "owner"])
)
# Token identity matches the admin user so LiveKitTokenAuthentication
# resolves request.user back to the admin.
token = utils.generate_token(str(room.id), user, is_admin_or_owner=True)
url = reverse("rooms-mute-participant", kwargs={"pk": room.id})
response = client.post(
url,
{"participant_identity": str(uuid4()), "track_sid": "test-track-sid"},
format="json",
HTTP_AUTHORIZATION=f"Bearer {token}",
)
assert response.status_code == status.HTTP_200_OK
assert response.data == {"status": "success"}
mock_livekit_client.room.mute_published_track.assert_called_once()
def test_mute_participant_admin_with_token_for_another_room(mock_livekit_client):
"""Should not allow muting when user is admin and the LiveKit token is for another room."""
client = APIClient()
target_room = RoomFactory()
other_room = RoomFactory()
user = UserFactory()
UserResourceAccessFactory(
resource=target_room,
user=user,
role=random.choice(["administrator", "owner"]),
)
# Token is scoped to a DIFFERENT room, and admin status must only be
# honored when established via session, never via a LiveKit
# token, which can be replayed off-host.
token = utils.generate_token(str(other_room.id), user, is_admin_or_owner=True)
url = reverse("rooms-mute-participant", kwargs={"pk": target_room.id})
response = client.post(
url,
{"participant_identity": str(uuid4()), "track_sid": "test-track-sid"},
format="json",
HTTP_AUTHORIZATION=f"Bearer {token}",
)
assert response.status_code == status.HTTP_403_FORBIDDEN
assert response.data == {
"detail": "You do not have permission to perform this action."
}
mock_livekit_client.room.mute_published_track.assert_not_called()
def test_mute_participant_admin_token_replayed_does_not_grant_admin(
mock_livekit_client,
):
"""Should forbid muting when a LiveKit token issued for an admin is passed without a session."""
client = APIClient()
room = RoomFactory(configuration={"everyone_can_mute": False})
admin_user = UserFactory()
UserResourceAccessFactory(
resource=room,
user=admin_user,
role=random.choice(["administrator", "owner"]),
)
# The token is the only credential.
token = utils.generate_token(str(room.id), admin_user, is_admin_or_owner=True)
url = reverse("rooms-mute-participant", kwargs={"pk": room.id})
response = client.post(
url,
{"participant_identity": str(uuid4()), "track_sid": "test-track-sid"},
format="json",
HTTP_AUTHORIZATION=f"Bearer {token}",
)
assert response.status_code == status.HTTP_403_FORBIDDEN
mock_livekit_client.room.mute_published_track.assert_not_called()
def test_mute_participant_livekit_token_triggers_presence_check(mock_livekit_client):
"""Should check participant presence when authenticated via LiveKit token only."""
client = APIClient()
room = RoomFactory()
user = AnonymousUser()
token = utils.generate_token(str(room.id), user, is_admin_or_owner=False)
url = reverse("rooms-mute-participant", kwargs={"pk": room.id})
response = client.post(
url,
{"participant_identity": str(uuid4()), "track_sid": "test-track-sid"},
format="json",
HTTP_AUTHORIZATION=f"Bearer {token}",
)
assert response.status_code == status.HTTP_200_OK
# Presence is verified against LiveKit before the mute is issued.
mock_livekit_client.room.get_participant.assert_called_once()
mock_livekit_client.room.mute_published_track.assert_called_once()
def test_mute_participant_livekit_token_presence_check_returns_participant(
mock_livekit_client,
):
"""Should mute when the authentified participant is currently in the room."""
client = APIClient()
room = RoomFactory()
# Simulate LiveKit confirming the caller is currently in the room.
# State != DISCONNECTED (3) means present.
mock_livekit_client.room.get_participant.return_value = ParticipantInfo(
identity="caller-identity",
state=ParticipantInfo.State.ACTIVE,
)
user = AnonymousUser()
token = utils.generate_token(str(room.id), user, is_admin_or_owner=False)
url = reverse("rooms-mute-participant", kwargs={"pk": room.id})
response = client.post(
url,
{"participant_identity": str(uuid4()), "track_sid": "test-track-sid"},
format="json",
HTTP_AUTHORIZATION=f"Bearer {token}",
)
assert response.status_code == status.HTTP_200_OK
assert response.data == {"status": "success"}
mock_livekit_client.room.get_participant.assert_called_once()
mock_livekit_client.room.mute_published_track.assert_called_once()
def test_mute_participant_livekit_token_presence_check_participant_not_found(
mock_livekit_client,
):
"""Should not mute when the authentified participant is not found."""
client = APIClient()
room = RoomFactory()
mock_livekit_client.room.get_participant.side_effect = TwirpError(
msg="participant does not exist", code="not_found", status=404
)
user = AnonymousUser()
token = utils.generate_token(str(room.id), user, is_admin_or_owner=False)
url = reverse("rooms-mute-participant", kwargs={"pk": room.id})
response = client.post(
url,
{"participant_identity": str(uuid4()), "track_sid": "test-track-sid"},
format="json",
HTTP_AUTHORIZATION=f"Bearer {token}",
)
assert response.status_code == status.HTTP_403_FORBIDDEN
assert response.data == {"error": "Could not verify caller presence"}
mock_livekit_client.room.get_participant.assert_called_once()
# The presence check failed, so we never reach the mute call.
mock_livekit_client.room.mute_published_track.assert_not_called()
def test_mute_participant_livekit_token_presence_check_twirp_error_forbidden(
mock_livekit_client,
):
"""Should not mute when the presence check fail."""
client = APIClient()
room = RoomFactory()
mock_livekit_client.room.get_participant.side_effect = TwirpError(
msg="an error occured", code="not_found", status=500
)
user = AnonymousUser()
token = utils.generate_token(str(room.id), user, is_admin_or_owner=False)
url = reverse("rooms-mute-participant", kwargs={"pk": room.id})
response = client.post(
url,
{"participant_identity": str(uuid4()), "track_sid": "test-track-sid"},
format="json",
HTTP_AUTHORIZATION=f"Bearer {token}",
)
assert response.status_code == status.HTTP_403_FORBIDDEN
assert response.data == {"error": "Could not verify caller presence"}
mock_livekit_client.room.get_participant.assert_called_once()
# The presence check failed, so we never reach the mute call.
mock_livekit_client.room.mute_published_track.assert_not_called()
def test_mute_participant_session_auth_skips_presence_check(mock_livekit_client):
"""Should not check presence of the participant when authentified with a session cookie."""
client = APIClient()
room = RoomFactory()
user = UserFactory()
UserResourceAccessFactory(
resource=room, user=user, role=random.choice(["administrator", "owner"])
)
client.force_authenticate(user=user)
url = reverse("rooms-mute-participant", kwargs={"pk": room.id})
response = client.post(
url,
{"participant_identity": str(uuid4()), "track_sid": "test-track-sid"},
format="json",
)
assert response.status_code == status.HTTP_200_OK
# Session auth has no LiveKit identity to verify against, so the
# stop-gap presence check is skipped.
mock_livekit_client.room.get_participant.assert_not_called()
mock_livekit_client.room.mute_published_track.assert_called_once()
def test_update_participant_success(mock_livekit_client):
"""Test successful participant update."""
client = APIClient()
@@ -130,8 +521,8 @@ def test_update_participant_success(mock_livekit_client):
"can_publish": True,
"can_publish_data": True,
"can_publish_sources": [
"CAMERA",
"MICROPHONE",
"camera",
"microphone",
],
"can_update_metadata": True,
"can_subscribe_metrics": True,
@@ -158,8 +549,8 @@ def test_update_participant_success(mock_livekit_client):
{"can_publish_data": True},
{
"can_publish_sources": [
"CAMERA",
"MICROPHONE",
"camera",
"microphone",
]
},
{"can_update_metadata": True},
@@ -190,9 +581,41 @@ def test_update_participant_permission_fields_are_optional(
assert response.data == {"status": "success"}
mock_livekit_client.room.update_participant.assert_called_once()
(request_arg,), _ = mock_livekit_client.room.update_participant.call_args
assert isinstance(request_arg, UpdateParticipantRequest)
mock_livekit_client.aclose.assert_called_once()
def test_update_participant_permission_fields_invalid_case(mock_livekit_client):
"""Should raise bad request when can_publish_sources is uppercase."""
client = APIClient()
room = RoomFactory()
user = UserFactory()
UserResourceAccessFactory(
resource=room, user=user, role=random.choice(["administrator", "owner"])
)
client.force_authenticate(user=user)
payload = {
"participant_identity": str(uuid4()),
"permission": {
"can_publish_sources": [
"CAMERA",
"microphone",
]
},
}
url = reverse("rooms-update-participant", kwargs={"pk": room.id})
response = client.post(url, payload, format="json")
assert response.status_code == status.HTTP_400_BAD_REQUEST
mock_livekit_client.room.update_participant.assert_not_called()
mock_livekit_client.aclose.assert_not_called()
@pytest.mark.parametrize(
"value,permission_key",
[
@@ -28,6 +28,7 @@ def test_api_rooms_retrieve_anonymous_private_pk():
assert response.status_code == 200
assert response.json() == {
"configuration": {},
"access_level": "restricted",
"id": str(room.id),
"is_administrable": False,
@@ -47,6 +48,7 @@ def test_api_rooms_retrieve_anonymous_trusted_pk():
assert response.status_code == 200
assert response.json() == {
"configuration": {},
"access_level": "trusted",
"id": str(room.id),
"is_administrable": False,
@@ -65,6 +67,7 @@ def test_api_rooms_retrieve_anonymous_private_pk_no_dashes():
assert response.status_code == 200
assert response.json() == {
"configuration": {},
"access_level": "restricted",
"id": str(room.id),
"is_administrable": False,
@@ -81,6 +84,7 @@ def test_api_rooms_retrieve_anonymous_private_slug():
assert response.status_code == 200
assert response.json() == {
"configuration": {},
"access_level": "restricted",
"id": str(room.id),
"is_administrable": False,
@@ -97,6 +101,7 @@ def test_api_rooms_retrieve_anonymous_private_slug_not_normalized():
assert response.status_code == 200
assert response.json() == {
"configuration": {},
"access_level": "restricted",
"id": str(room.id),
"is_administrable": False,
@@ -200,6 +205,7 @@ def test_api_rooms_retrieve_anonymous_public(mock_token):
assert response.status_code == 200
expected_name = f"{room.id!s}"
assert response.json() == {
"configuration": {},
"access_level": str(room.access_level),
"id": str(room.id),
"is_administrable": False,
@@ -246,6 +252,7 @@ def test_api_rooms_retrieve_authenticated_public(mock_token):
expected_name = f"{room.id!s}"
assert response.json() == {
"configuration": {"can_publish_sources": ["camera"]},
"access_level": str(room.access_level),
"id": str(room.id),
"is_administrable": False,
@@ -297,6 +304,7 @@ def test_api_rooms_retrieve_authenticated_trusted(mock_token):
expected_name = f"{room.id!s}"
assert response.json() == {
"configuration": {},
"access_level": str(room.access_level),
"id": str(room.id),
"is_administrable": False,
@@ -338,6 +346,7 @@ def test_api_rooms_retrieve_authenticated():
assert response.status_code == 200
assert response.json() == {
"configuration": {},
"access_level": "restricted",
"id": str(room.id),
"is_administrable": False,
@@ -383,6 +392,7 @@ def test_api_rooms_retrieve_members(mock_token, django_assert_num_queries, setti
expected_name = str(room.id)
assert content_dict == {
"configuration": {"can_publish_sources": ["camera"]},
"access_level": str(room.access_level),
"id": str(room.id),
"is_administrable": False,
@@ -3,12 +3,18 @@ Test rooms API endpoints in the Meet core app: update.
"""
import random
from unittest.mock import patch
import pytest
from rest_framework.test import APIClient
from ...factories import RoomFactory, UserFactory
from ...models import RoomAccessLevel
from ...services.room_management import (
RoomManagement,
RoomManagementException,
RoomNotFoundException,
)
pytestmark = pytest.mark.django_db
@@ -79,12 +85,14 @@ def test_api_rooms_update_members():
assert room.configuration == {}
def test_api_rooms_update_administrators():
"""Administrators or owners of a room should be allowed to update it."""
@patch.object(RoomManagement, "update_metadata")
def test_api_rooms_update_administrators(mock_update_metadata):
"""Should sync LiveKit metadata when both configuration and access level change."""
user = UserFactory()
room = RoomFactory(
access_level=RoomAccessLevel.RESTRICTED,
users=[(user, random.choice(["administrator", "owner"]))],
configuration={"can_publish_sources": ["camera"]},
)
client = APIClient()
client.force_login(user)
@@ -106,11 +114,120 @@ def test_api_rooms_update_administrators():
assert room.access_level == RoomAccessLevel.PUBLIC
assert room.configuration == {"can_publish_sources": ["camera", "microphone"]}
mock_update_metadata.assert_called_once_with(
room_name=str(room.id),
metadata={
"access_level": "public",
"configuration": {"can_publish_sources": ["camera", "microphone"]},
},
)
@patch.object(RoomManagement, "update_metadata")
def test_api_rooms_update_administrators_configuration_only(mock_update_metadata):
"""Should sync LiveKit metadata when only configuration changes."""
user = UserFactory()
room = RoomFactory(
access_level=RoomAccessLevel.RESTRICTED,
users=[(user, random.choice(["administrator", "owner"]))],
configuration={},
)
client = APIClient()
client.force_login(user)
response = client.put(
f"/api/v1.0/rooms/{room.id!s}/",
{
"name": "New name",
"slug": "should-be-ignored",
"configuration": {"can_publish_sources": ["camera", "microphone"]},
},
format="json",
)
assert response.status_code == 200
room.refresh_from_db()
assert room.name == "New name"
assert room.slug == "new-name"
assert room.access_level == RoomAccessLevel.RESTRICTED
assert room.configuration == {"can_publish_sources": ["camera", "microphone"]}
mock_update_metadata.assert_called_once_with(
room_name=str(room.id),
metadata={
"access_level": "restricted",
"configuration": {"can_publish_sources": ["camera", "microphone"]},
},
)
@patch.object(RoomManagement, "update_metadata")
def test_api_rooms_update_administrators_access_level_only(mock_update_metadata):
"""Should sync LiveKit metadata when only access level changes."""
user = UserFactory()
room = RoomFactory(
access_level=RoomAccessLevel.RESTRICTED,
users=[(user, random.choice(["administrator", "owner"]))],
configuration={"can_publish_sources": ["camera"]},
)
client = APIClient()
client.force_login(user)
response = client.put(
f"/api/v1.0/rooms/{room.id!s}/",
{
"name": "New name",
"access_level": RoomAccessLevel.PUBLIC,
},
format="json",
)
assert response.status_code == 200
room.refresh_from_db()
assert room.name == "New name"
assert room.slug == "new-name"
assert room.access_level == RoomAccessLevel.PUBLIC
assert room.configuration == {"can_publish_sources": ["camera"]}
mock_update_metadata.assert_called_once_with(
room_name=str(room.id),
metadata={
"access_level": "public",
"configuration": {"can_publish_sources": ["camera"]},
},
)
@patch.object(RoomManagement, "update_metadata")
def test_api_rooms_update_administrators_name_only(mock_update_metadata):
"""Should not sync LiveKit metadata when neither configuration nor access level changes."""
user = UserFactory()
room = RoomFactory(
name="Old name",
access_level=RoomAccessLevel.PUBLIC,
configuration={"can_publish_sources": ["camera"]},
users=[(user, random.choice(["administrator", "owner"]))],
)
client = APIClient()
client.force_login(user)
response = client.patch(
f"/api/v1.0/rooms/{room.id!s}/",
{"name": "New name"},
format="json",
)
assert response.status_code == 200
room.refresh_from_db()
assert room.name == "New name"
assert room.slug == "new-name"
# Unrelated fields untouched
assert room.access_level == RoomAccessLevel.PUBLIC
assert room.configuration == {"can_publish_sources": ["camera"]}
mock_update_metadata.assert_not_called()
@pytest.mark.parametrize(
"configuration",
[
{},
{"can_publish_sources": ["camera", "microphone"]},
{
"can_publish_sources": [
@@ -122,12 +239,17 @@ def test_api_rooms_update_administrators():
},
{"can_publish_sources": []},
{"can_publish_sources": None},
{"can_publish_sources": None, "everyone_can_mute": True},
{"can_publish_sources": None, "everyone_can_mute": False},
{"can_publish_sources": None, "everyone_can_mute": "yes"},
{"can_publish_sources": None, "everyone_can_mute": "1"},
],
)
def test_api_rooms_update_configuration_valid(configuration):
@patch.object(RoomManagement, "update_metadata")
def test_api_rooms_update_configuration_valid(mock_update_metadata, configuration):
"""Administrators should be allowed to set valid configurations."""
user = UserFactory()
room = RoomFactory(users=[(user, "owner")])
room = RoomFactory(users=[(user, "owner")], configuration={})
client = APIClient()
client.force_login(user)
@@ -140,6 +262,28 @@ def test_api_rooms_update_configuration_valid(configuration):
room.refresh_from_db()
assert room.configuration == configuration
mock_update_metadata.assert_called_once()
@patch.object(RoomManagement, "update_metadata")
def test_api_rooms_update_configuration_unchanged_empty(mock_update_metadata):
"""Should not sync LiveKit metadata when patching an already empty configuration."""
user = UserFactory()
room = RoomFactory(users=[(user, "owner")], configuration={})
client = APIClient()
client.force_login(user)
response = client.patch(
f"/api/v1.0/rooms/{room.id!s}/",
{"configuration": {}},
format="json",
)
assert response.status_code == 200
room.refresh_from_db()
assert room.configuration == {}
mock_update_metadata.assert_not_called()
def test_api_rooms_update_configuration_extra_keys_rejected():
"""Extra keys in configuration should be rejected."""
@@ -198,6 +342,24 @@ def test_api_rooms_update_configuration_wrong_type():
assert room.configuration == {}
@pytest.mark.parametrize("invalid_value", ["test", [], {}])
def test_api_rooms_update_configuration_everyone_can_mute_wrong_type(invalid_value):
"""everyone_can_mute values with wrong types should be rejected."""
user = UserFactory()
room = RoomFactory(users=[(user, "owner")])
client = APIClient()
client.force_login(user)
response = client.patch(
f"/api/v1.0/rooms/{room.id!s}/",
{"configuration": {"everyone_can_mute": invalid_value}},
format="json",
)
assert response.status_code == 400
room.refresh_from_db()
assert room.configuration == {}
def test_api_rooms_update_administrators_of_another():
"""
Being administrator or owner of a room should not grant authorization to update
@@ -217,3 +379,61 @@ def test_api_rooms_update_administrators_of_another():
other_room.refresh_from_db()
assert other_room.name == "Old name"
assert other_room.slug == "old-name"
@patch.object(RoomManagement, "update_metadata", side_effect=RoomNotFoundException)
def test_api_rooms_update_livekit_room_not_found(mock_update_metadata):
"""Should not fail the API request when the LiveKit room does not exist yet."""
user = UserFactory()
room = RoomFactory(
users=[(user, random.choice(["administrator", "owner"]))],
configuration={},
)
client = APIClient()
client.force_login(user)
response = client.patch(
f"/api/v1.0/rooms/{room.id!s}/",
{"configuration": {"can_publish_sources": ["camera"]}},
format="json",
)
assert response.status_code == 200
room.refresh_from_db()
assert room.configuration == {"can_publish_sources": ["camera"]}
mock_update_metadata.assert_called_once_with(
room_name=str(room.id),
metadata={
"access_level": room.access_level,
"configuration": {"can_publish_sources": ["camera"]},
},
)
@patch.object(RoomManagement, "update_metadata", side_effect=RoomManagementException)
def test_api_rooms_update_livekit_sync_failure(mock_update_metadata):
"""Should not fail the API request when the LiveKit metadata sync fails."""
user = UserFactory()
room = RoomFactory(
users=[(user, random.choice(["administrator", "owner"]))],
configuration={},
)
client = APIClient()
client.force_login(user)
response = client.patch(
f"/api/v1.0/rooms/{room.id!s}/",
{"configuration": {"can_publish_sources": ["camera"]}},
format="json",
)
assert response.status_code == 200
room.refresh_from_db()
assert room.configuration == {"can_publish_sources": ["camera"]}
mock_update_metadata.assert_called_once_with(
room_name=str(room.id),
metadata={
"access_level": room.access_level,
"configuration": {"can_publish_sources": ["camera"]},
},
)
+35
View File
@@ -455,3 +455,38 @@ def generate_upload_policy(file):
)
return policy
def generate_download_s3_file_url(
key, *, expires_in: int, override_domain: bool = True
):
"""
Generate a S3 signed download url for a given key.
"""
# This settings should be used if the backend application and the frontend application
# can't connect to the object storage with the same domain. This is the case in the
# docker compose stack used in development. The frontend application will use localhost
# to connect to the object storage while the backend application will use the object storage
# service name declared in the docker compose stack.
# This is needed because the domain name is used to compute the signature. So it can't be
# changed dynamically by the frontend application.
if settings.AWS_S3_DOMAIN_REPLACE and override_domain:
s3_client = boto3.client(
"s3",
aws_access_key_id=settings.AWS_S3_ACCESS_KEY_ID,
aws_secret_access_key=settings.AWS_S3_SECRET_ACCESS_KEY,
endpoint_url=settings.AWS_S3_DOMAIN_REPLACE,
config=botocore.client.Config(
region_name=settings.AWS_S3_REGION_NAME,
signature_version=settings.AWS_S3_SIGNATURE_VERSION,
),
)
else:
s3_client = default_storage.connection.meta.client
return s3_client.generate_presigned_url(
ClientMethod="get_object",
Params={"Bucket": default_storage.bucket_name, "Key": key},
ExpiresIn=expires_in,
)
+100 -82
View File
@@ -8,7 +8,7 @@ msgid ""
msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-04-21 14:09+0000\n"
"POT-Creation-Date: 2026-05-26 16:20+0000\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language-Team: LANGUAGE <LL@li.org>\n"
@@ -88,17 +88,17 @@ msgstr "Scopes"
msgid "Creator is me"
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."
msgstr ""
"Sie müssen Administrator oder Eigentümer eines Raums sein, um Zugriffe "
"hinzuzufügen."
#: core/api/serializers.py:509
#: core/api/serializers.py:534
msgid "This file extension is not allowed."
msgstr "Diese Dateiendung ist nicht erlaubt."
#: core/api/viewsets.py:1090
#: core/api/viewsets.py:1228
msgid "You have reached the maximum number of files for this type."
msgstr "Sie haben die maximale Anzahl an Dateien dieses Typs erreicht."
@@ -146,51 +146,59 @@ msgstr "Stopp fehlgeschlagen"
msgid "Notification succeeded"
msgstr "Benachrichtigung erfolgreich"
#: core/models.py:89
#: core/models.py:65
msgid "External process successful"
msgstr ""
#: core/models.py:67
msgid "External process failed"
msgstr ""
#: core/models.py:96
msgid "SCREEN_RECORDING"
msgstr "BILDSCHIRMAUFZEICHNUNG"
#: core/models.py:90
#: core/models.py:97
msgid "TRANSCRIPT"
msgstr "TRANSKRIPT"
#: core/models.py:96
#: core/models.py:103
msgid "Public Access"
msgstr "Öffentlicher Zugriff"
#: core/models.py:97
#: core/models.py:104
msgid "Trusted Access"
msgstr "Vertrauenswürdiger Zugriff"
#: core/models.py:98
#: core/models.py:105
msgid "Restricted Access"
msgstr "Eingeschränkter Zugriff"
#: core/models.py:110
#: core/models.py:117
msgid "id"
msgstr "ID"
#: core/models.py:111
#: core/models.py:118
msgid "primary key for the record as UUID"
msgstr "Primärschlüssel des Eintrags als UUID"
#: core/models.py:117
#: core/models.py:124
msgid "created on"
msgstr "erstellt am"
#: core/models.py:118
#: core/models.py:125
msgid "date and time at which a record was created"
msgstr "Datum und Uhrzeit der Erstellung eines Eintrags"
#: core/models.py:123
#: core/models.py:130
msgid "updated on"
msgstr "aktualisiert am"
#: core/models.py:124
#: core/models.py:131
msgid "date and time at which a record was last updated"
msgstr "Datum und Uhrzeit der letzten Aktualisierung eines Eintrags"
#: core/models.py:144
#: core/models.py:151
msgid ""
"Enter a valid sub. This value may contain only letters, numbers, and @/./+/-/"
"_ characters."
@@ -198,11 +206,11 @@ msgstr ""
"Geben Sie einen gültigen Sub ein. Dieser Wert darf nur Buchstaben, Zahlen "
"und die Zeichen @/./+/-/_ enthalten."
#: core/models.py:150
#: core/models.py:157
msgid "sub"
msgstr "Sub"
#: core/models.py:152
#: core/models.py:159
msgid ""
"Optional for pending users; required upon account activation. 255 characters "
"or fewer. Letters, numbers, and @/./+/-/_ characters only."
@@ -210,55 +218,55 @@ msgstr ""
"Optional für ausstehende Benutzer; erforderlich nach Kontoaktivierung. "
"Maximal 255 Zeichen. Nur Buchstaben, Zahlen und @/./+/-/_ Zeichen erlaubt."
#: core/models.py:161
#: core/models.py:168
msgid "identity email address"
msgstr "Identitäts-E-Mail-Adresse"
#: core/models.py:166
#: core/models.py:173
msgid "admin email address"
msgstr "Administrator-E-Mail-Adresse"
#: core/models.py:168
#: core/models.py:175
msgid "full name"
msgstr "Vollständiger Name"
#: core/models.py:170
#: core/models.py:177
msgid "short name"
msgstr "Kurzname"
#: core/models.py:176
#: core/models.py:183
msgid "language"
msgstr "Sprache"
#: core/models.py:177
#: core/models.py:184
msgid "The language in which the user wants to see the interface."
msgstr "Die Sprache, in der der Benutzer die Oberfläche sehen möchte."
#: core/models.py:183
#: core/models.py:190
msgid "The timezone in which the user wants to see times."
msgstr "Die Zeitzone, in der der Benutzer die Zeiten sehen möchte."
#: core/models.py:186
#: core/models.py:193
msgid "device"
msgstr "Gerät"
#: core/models.py:188
#: core/models.py:195
msgid "Whether the user is a device or a real user."
msgstr "Ob es sich um ein Gerät oder einen echten Benutzer handelt."
#: core/models.py:191
#: core/models.py:198
msgid "staff status"
msgstr "Mitarbeiterstatus"
#: core/models.py:193
#: core/models.py:200
msgid "Whether the user can log into this admin site."
msgstr "Ob der Benutzer sich bei dieser Admin-Seite anmelden kann."
#: core/models.py:196
#: core/models.py:203
msgid "active"
msgstr "aktiv"
#: core/models.py:199
#: core/models.py:206
msgid ""
"Whether this user should be treated as active. Unselect this instead of "
"deleting accounts."
@@ -266,66 +274,66 @@ msgstr ""
"Ob dieser Benutzer als aktiv behandelt werden soll. Deaktivieren Sie dies "
"anstelle des Löschens des Kontos."
#: core/models.py:212
#: core/models.py:219
msgid "user"
msgstr "Benutzer"
#: core/models.py:213
#: core/models.py:220
msgid "users"
msgstr "Benutzer"
#: core/models.py:272
#: core/models.py:279
msgid "Resource"
msgstr "Ressource"
#: core/models.py:273
#: core/models.py:280
msgid "Resources"
msgstr "Ressourcen"
#: core/models.py:331
#: core/models.py:338
msgid "Resource access"
msgstr "Ressourcenzugriff"
#: core/models.py:332
#: core/models.py:339
msgid "Resource accesses"
msgstr "Ressourcenzugriffe"
#: core/models.py:338
#: core/models.py:345
msgid "Resource access with this User and Resource already exists."
msgstr ""
"Ein Ressourcenzugriff mit diesem Benutzer und dieser Ressource existiert "
"bereits."
#: core/models.py:394
#: core/models.py:402
msgid "Visio room configuration"
msgstr "Visio-Raumkonfiguration"
#: core/models.py:395
#: core/models.py:403
msgid "Values for Visio parameters to configure the room."
msgstr "Werte für Visio-Parameter zur Konfiguration des Raums."
#: core/models.py:402
#: core/models.py:410
msgid "Room PIN code"
msgstr "PIN-Code für den Raum"
#: core/models.py:403
#: core/models.py:411
msgid "Unique n-digit code that identifies this room in telephony mode."
msgstr ""
"Eindeutiger n-stelliger Code, der diesen Raum im Telephonmodus identifiziert."
#: core/models.py:409 core/models.py:563
#: core/models.py:417 core/models.py:571
msgid "Room"
msgstr "Raum"
#: core/models.py:410
#: core/models.py:418
msgid "Rooms"
msgstr "Räume"
#: core/models.py:574
#: core/models.py:582
msgid "Worker ID"
msgstr "Worker-ID"
#: core/models.py:576
#: core/models.py:584
msgid ""
"Enter an identifier for the worker recording.This ID is retained even when "
"the worker stops, allowing for easy tracking."
@@ -334,141 +342,149 @@ msgstr ""
"erhalten, auch wenn der Worker stoppt, was ein einfaches Nachverfolgen "
"ermöglicht."
#: core/models.py:584
#: core/models.py:592
msgid "Recording mode"
msgstr "Aufzeichnungsmodus"
#: core/models.py:585
#: core/models.py:593
msgid "Defines the mode of recording being called."
msgstr "Definiert den aufgerufenen Aufzeichnungsmodus."
#: core/models.py:590 core/models.py:591
#: core/models.py:598 core/models.py:599
msgid "Recording options"
msgstr "Aufnahmeoptionen"
#: core/models.py:597
#: core/models.py:606
msgid "External Process ID"
msgstr ""
#: core/models.py:607
msgid "ID of the external process associated with the recording."
msgstr ""
#: core/models.py:613
msgid "Recording"
msgstr "Aufzeichnung"
#: core/models.py:598
#: core/models.py:614
msgid "Recordings"
msgstr "Aufzeichnungen"
#: core/models.py:706
#: core/models.py:724
msgid "Recording/user relation"
msgstr "Beziehung Aufzeichnung/Benutzer"
#: core/models.py:707
#: core/models.py:725
msgid "Recording/user relations"
msgstr "Beziehungen Aufzeichnung/Benutzer"
#: core/models.py:713
#: core/models.py:731
msgid "This user is already in this recording."
msgstr "Dieser Benutzer ist bereits Teil dieser Aufzeichnung."
#: core/models.py:719
#: core/models.py:737
msgid "This team is already in this recording."
msgstr "Dieses Team ist bereits Teil dieser Aufzeichnung."
#: core/models.py:725
#: core/models.py:743
msgid "Either user or team must be set, not both."
msgstr "Entweder Benutzer oder Team muss festgelegt werden, nicht beides."
#: core/models.py:742
#: core/models.py:760
msgid "Create rooms"
msgstr "Räume erstellen"
#: core/models.py:743
#: core/models.py:761
msgid "List rooms"
msgstr "Räume auflisten"
#: core/models.py:744
#: core/models.py:762
msgid "Retrieve room details"
msgstr "Raumdetails abrufen"
#: core/models.py:745
#: core/models.py:763
msgid "Update rooms"
msgstr "Räume aktualisieren"
#: core/models.py:746
#: core/models.py:764
msgid "Delete rooms"
msgstr "Räume löschen"
#: core/models.py:759
#: core/models.py:777
msgid "Application name"
msgstr "Anwendungsname"
#: core/models.py:760
#: core/models.py:778
msgid "Descriptive name for this application."
msgstr "Beschreibender Name für diese Anwendung."
#: core/models.py:770
#: core/models.py:788
msgid "Hashed on Save. Copy it now if this is a new secret."
msgstr ""
"Beim Speichern gehasht. Jetzt kopieren, wenn dies ein neues Geheimnis ist."
#: core/models.py:781
#: core/models.py:799
msgid "Application"
msgstr "Anwendung"
#: core/models.py:782
#: core/models.py:800
msgid "Applications"
msgstr "Anwendungen"
#: core/models.py:805
#: core/models.py:823
msgid "Enter a valid domain"
msgstr "Geben Sie eine gültige Domain ein"
#: core/models.py:808
#: core/models.py:826
msgid "Domain"
msgstr "Domain"
#: core/models.py:809
#: core/models.py:827
msgid "Email domain this application can act on behalf of."
msgstr "E-Mail-Domain, im Namen der diese Anwendung handeln kann."
#: core/models.py:821
#: core/models.py:839
msgid "Application domain"
msgstr "Anwendungsdomain"
#: core/models.py:822
#: core/models.py:840
msgid "Application domains"
msgstr "Anwendungsdomains"
#: core/models.py:840
#: core/models.py:858
msgid "Pending"
msgstr "Ausstehend"
#: core/models.py:848
#: core/models.py:866
msgid "Ready"
msgstr "Bereit"
#: core/models.py:854
#: core/models.py:872
msgid "Background image"
msgstr "Hintergrundbild"
#: core/models.py:866
#: core/models.py:884
msgid "title"
msgstr "Titel"
#: core/models.py:890
#: core/models.py:908
msgid "Malware detection info when the analysis status is unsafe."
msgstr ""
"Informationen zur Malware-Erkennung, wenn der Analyse-Status unsicher ist."
#: core/models.py:895
#: core/models.py:913
msgid "File"
msgstr "Datei"
#: core/models.py:896
#: core/models.py:914
msgid "Files"
msgstr "Dateien"
#: core/models.py:1000
#: core/models.py:1018
msgid "This file is already hard deleted."
msgstr "Diese Datei wurde bereits endgültig gelöscht."
#: core/models.py:1010
#: core/models.py:1028
#, 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."
@@ -476,7 +492,7 @@ msgstr ""
"Um eine Datei endgültig zu löschen, muss sie zuvor weich gelöscht worden "
"sein."
#: core/recording/event/notification.py:116
#: core/recording/event/notification.py:124
msgid "Your recording is ready"
msgstr "Ihre Aufzeichnung ist bereit"
@@ -576,7 +592,9 @@ msgstr "So speichern Sie diese Aufzeichnung dauerhaft:"
#: core/templates/mail/html/screen_recording.html:208
#: core/templates/mail/text/screen_recording.txt:13
msgid "Click the \"<a href=\"%(link)s\">Open</a>\" link below "
#, fuzzy
#| msgid "Click the \"<a href=\"%(link)s\">Open</a>\" link below "
msgid "Click the \"Open\" button below "
msgstr "Klicken Sie auf den Link „<a href=\"%(link)s\">Öffnen</a>\" unten "
#: core/templates/mail/html/screen_recording.html:209
+100 -82
View File
@@ -8,7 +8,7 @@ msgid ""
msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-04-21 14:09+0000\n"
"POT-Creation-Date: 2026-05-26 16:20+0000\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language-Team: LANGUAGE <LL@li.org>\n"
@@ -88,15 +88,15 @@ msgstr "Scopes"
msgid "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."
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."
msgstr "This file extension is not allowed."
#: core/api/viewsets.py:1090
#: core/api/viewsets.py:1228
msgid "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 +144,59 @@ msgstr "Failed to Stop"
msgid "Notification succeeded"
msgstr "Notification succeeded"
#: core/models.py:89
#: core/models.py:65
msgid "External process successful"
msgstr ""
#: core/models.py:67
msgid "External process failed"
msgstr ""
#: core/models.py:96
msgid "SCREEN_RECORDING"
msgstr "SCREEN_RECORDING"
#: core/models.py:90
#: core/models.py:97
msgid "TRANSCRIPT"
msgstr "TRANSCRIPT"
#: core/models.py:96
#: core/models.py:103
msgid "Public Access"
msgstr "Public Access"
#: core/models.py:97
#: core/models.py:104
msgid "Trusted Access"
msgstr "Trusted Access"
#: core/models.py:98
#: core/models.py:105
msgid "Restricted Access"
msgstr "Restricted Access"
#: core/models.py:110
#: core/models.py:117
msgid "id"
msgstr "id"
#: core/models.py:111
#: core/models.py:118
msgid "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"
msgstr "created on"
#: core/models.py:118
#: core/models.py:125
msgid "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"
msgstr "updated on"
#: core/models.py:124
#: core/models.py:131
msgid "date and time at which a record was last updated"
msgstr "date and time at which a record was last updated"
#: core/models.py:144
#: core/models.py:151
msgid ""
"Enter a valid sub. This value may contain only letters, numbers, and @/./+/-/"
"_ characters."
@@ -196,11 +204,11 @@ msgstr ""
"Enter a valid sub. This value may contain only letters, numbers, and @/./+/-/"
"_ characters."
#: core/models.py:150
#: core/models.py:157
msgid "sub"
msgstr "sub"
#: core/models.py:152
#: core/models.py:159
msgid ""
"Optional for pending users; required upon account activation. 255 characters "
"or fewer. Letters, numbers, and @/./+/-/_ characters only."
@@ -208,55 +216,55 @@ msgstr ""
"Required. 255 characters or fewer. Letters, numbers, and @/./+/-/_ "
"characters only."
#: core/models.py:161
#: core/models.py:168
msgid "identity email address"
msgstr "identity email address"
#: core/models.py:166
#: core/models.py:173
msgid "admin email address"
msgstr "admin email address"
#: core/models.py:168
#: core/models.py:175
msgid "full name"
msgstr "full name"
#: core/models.py:170
#: core/models.py:177
msgid "short name"
msgstr "short name"
#: core/models.py:176
#: core/models.py:183
msgid "language"
msgstr "language"
#: core/models.py:177
#: core/models.py:184
msgid "The language in which the user wants to see the interface."
msgstr "The language in which the user wants to see the interface."
#: core/models.py:183
#: core/models.py:190
msgid "The timezone in which the user wants to see times."
msgstr "The timezone in which the user wants to see times."
#: core/models.py:186
#: core/models.py:193
msgid "device"
msgstr "device"
#: core/models.py:188
#: core/models.py:195
msgid "Whether the user is a device or a real user."
msgstr "Whether the user is a device or a real user."
#: core/models.py:191
#: core/models.py:198
msgid "staff status"
msgstr "staff status"
#: core/models.py:193
#: core/models.py:200
msgid "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"
msgstr "active"
#: core/models.py:199
#: core/models.py:206
msgid ""
"Whether this user should be treated as active. Unselect this instead of "
"deleting accounts."
@@ -264,63 +272,63 @@ msgstr ""
"Whether this user should be treated as active. Unselect this instead of "
"deleting accounts."
#: core/models.py:212
#: core/models.py:219
msgid "user"
msgstr "user"
#: core/models.py:213
#: core/models.py:220
msgid "users"
msgstr "users"
#: core/models.py:272
#: core/models.py:279
msgid "Resource"
msgstr "Resource"
#: core/models.py:273
#: core/models.py:280
msgid "Resources"
msgstr "Resources"
#: core/models.py:331
#: core/models.py:338
msgid "Resource access"
msgstr "Resource access"
#: core/models.py:332
#: core/models.py:339
msgid "Resource accesses"
msgstr "Resource accesses"
#: core/models.py:338
#: core/models.py:345
msgid "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:402
msgid "Visio room configuration"
msgstr "Visio room configuration"
#: core/models.py:395
#: core/models.py:403
msgid "Values for Visio parameters to configure the room."
msgstr "Values for Visio parameters to configure the room."
#: core/models.py:402
#: core/models.py:410
msgid "Room PIN code"
msgstr "Room PIN code"
#: core/models.py:403
#: core/models.py:411
msgid "Unique n-digit code that identifies this room in telephony mode."
msgstr "Unique n-digit code that identifies this room in telephony mode."
#: core/models.py:409 core/models.py:563
#: core/models.py:417 core/models.py:571
msgid "Room"
msgstr "Room"
#: core/models.py:410
#: core/models.py:418
msgid "Rooms"
msgstr "Rooms"
#: core/models.py:574
#: core/models.py:582
msgid "Worker ID"
msgstr "Worker ID"
#: core/models.py:576
#: core/models.py:584
msgid ""
"Enter an identifier for the worker recording.This ID is retained even when "
"the worker stops, allowing for easy tracking."
@@ -328,151 +336,159 @@ msgstr ""
"Enter an identifier for the worker recording.This ID is retained even when "
"the worker stops, allowing for easy tracking."
#: core/models.py:584
#: core/models.py:592
msgid "Recording mode"
msgstr "Recording mode"
#: core/models.py:585
#: core/models.py:593
msgid "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:598 core/models.py:599
msgid "Recording options"
msgstr "Recording options"
#: core/models.py:597
#: core/models.py:606
msgid "External Process ID"
msgstr ""
#: core/models.py:607
msgid "ID of the external process associated with the recording."
msgstr ""
#: core/models.py:613
msgid "Recording"
msgstr "Recording"
#: core/models.py:598
#: core/models.py:614
msgid "Recordings"
msgstr "Recordings"
#: core/models.py:706
#: core/models.py:724
msgid "Recording/user relation"
msgstr "Recording/user relation"
#: core/models.py:707
#: core/models.py:725
msgid "Recording/user relations"
msgstr "Recording/user relations"
#: core/models.py:713
#: core/models.py:731
msgid "This user is already in this recording."
msgstr "This user is already in this recording."
#: core/models.py:719
#: core/models.py:737
msgid "This team is already in this recording."
msgstr "This team is already in this recording."
#: core/models.py:725
#: core/models.py:743
msgid "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:760
#, fuzzy
#| msgid "created on"
msgid "Create rooms"
msgstr "Create rooms"
#: core/models.py:743
#: core/models.py:761
msgid "List rooms"
msgstr "List rooms"
#: core/models.py:744
#: core/models.py:762
msgid "Retrieve room details"
msgstr "Retrieve room details"
#: core/models.py:745
#: core/models.py:763
#, fuzzy
#| msgid "updated on"
msgid "Update rooms"
msgstr "Update rooms"
#: core/models.py:746
#: core/models.py:764
msgid "Delete rooms"
msgstr "Delete rooms"
#: core/models.py:759
#: core/models.py:777
msgid "Application name"
msgstr "Application name"
#: core/models.py:760
#: core/models.py:778
msgid "Descriptive name for this application."
msgstr "Descriptive name for this application."
#: core/models.py:770
#: core/models.py:788
msgid "Hashed on Save. Copy it now if this is a new secret."
msgstr "Hashed on Save. Copy it now if this is a new secret."
#: core/models.py:781
#: core/models.py:799
msgid "Application"
msgstr "Application"
#: core/models.py:782
#: core/models.py:800
msgid "Applications"
msgstr "Applications"
#: core/models.py:805
#: core/models.py:823
msgid "Enter a valid domain"
msgstr "Enter a valid domain"
#: core/models.py:808
#: core/models.py:826
msgid "Domain"
msgstr "Domain"
#: core/models.py:809
#: core/models.py:827
msgid "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:839
msgid "Application domain"
msgstr "Application domain"
#: core/models.py:822
#: core/models.py:840
msgid "Application domains"
msgstr "Application domains"
#: core/models.py:840
#: core/models.py:858
#, fuzzy
#| msgid "Recording"
msgid "Pending"
msgstr "Pending"
#: core/models.py:848
#: core/models.py:866
msgid "Ready"
msgstr "Ready"
#: core/models.py:854
#: core/models.py:872
msgid "Background image"
msgstr "Background image"
#: core/models.py:866
#: core/models.py:884
msgid "title"
msgstr "title"
#: core/models.py:890
#: core/models.py:908
msgid "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:913
msgid "File"
msgstr "File"
#: core/models.py:896
#: core/models.py:914
msgid "Files"
msgstr "Files"
#: core/models.py:1000
#: core/models.py:1018
#, fuzzy
#| msgid "This user is already in this recording."
msgid "This file is already hard deleted."
msgstr "This file is already hard deleted."
#: core/models.py:1010
#: core/models.py:1028
msgid "To hard delete a file, it must first be soft deleted."
msgstr "To hard delete a file, it must first be soft deleted."
#: core/recording/event/notification.py:116
#: core/recording/event/notification.py:124
msgid "Your recording is ready"
msgstr "Your recording is ready"
@@ -572,7 +588,9 @@ msgstr "To keep this recording permanently:"
#: core/templates/mail/html/screen_recording.html:208
#: core/templates/mail/text/screen_recording.txt:13
msgid "Click the \"<a href=\"%(link)s\">Open</a>\" link below "
#, fuzzy
#| msgid "Click the \"<a href=\"%(link)s\">Open</a>\" link below "
msgid "Click the \"Open\" button below "
msgstr "Click the \"<a href=\"%(link)s\">Open</a>\" link below "
#: core/templates/mail/html/screen_recording.html:209
+100 -82
View File
@@ -8,7 +8,7 @@ msgid ""
msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-04-21 14:09+0000\n"
"POT-Creation-Date: 2026-05-26 16:20+0000\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: antoine.lebaud@mail.numerique.gouv.fr\n"
"Language-Team: LANGUAGE <LL@li.org>\n"
@@ -89,17 +89,17 @@ msgstr "Scopes"
msgid "Creator is me"
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."
msgstr ""
"Vous devez être administrateur ou propriétaire d'une salle pour y ajouter "
"des accès."
#: core/api/serializers.py:509
#: core/api/serializers.py:534
msgid "This file extension is not allowed."
msgstr "Cette extension n'est pas autorisée"
#: core/api/viewsets.py:1090
#: core/api/viewsets.py:1228
msgid "You have reached the maximum number of files for this type."
msgstr "Vous avez atteint le nombre maximum de fichiers de ce type"
@@ -147,53 +147,61 @@ msgstr "Échec à l'arrêt"
msgid "Notification succeeded"
msgstr "Notification réussie"
#: core/models.py:89
#: core/models.py:65
msgid "External process successful"
msgstr ""
#: core/models.py:67
msgid "External process failed"
msgstr ""
#: core/models.py:96
msgid "SCREEN_RECORDING"
msgstr "ENREGISTREMENT_ÉCRAN"
#: core/models.py:90
#: core/models.py:97
msgid "TRANSCRIPT"
msgstr "TRANSCRIPTION"
#: core/models.py:96
#: core/models.py:103
msgid "Public Access"
msgstr "Accès public"
#: core/models.py:97
#: core/models.py:104
msgid "Trusted Access"
msgstr "Accès de confiance"
#: core/models.py:98
#: core/models.py:105
msgid "Restricted Access"
msgstr "Accès restreint"
#: core/models.py:110
#: core/models.py:117
msgid "id"
msgstr "id"
#: core/models.py:111
#: core/models.py:118
msgid "primary key for the record as UUID"
msgstr "clé primaire pour l'enregistrement sous forme d'UUID"
#: core/models.py:117
#: core/models.py:124
msgid "created on"
msgstr "créé le"
#: core/models.py:118
#: core/models.py:125
msgid "date and time at which a record was created"
msgstr "date et heure auxquelles un enregistrement a été créé"
#: core/models.py:123
#: core/models.py:130
msgid "updated on"
msgstr "mis à jour le"
#: core/models.py:124
#: core/models.py:131
msgid "date and time at which a record was last updated"
msgstr ""
"date et heure auxquelles un enregistrement a été mis à jour pour la dernière "
"fois"
#: core/models.py:144
#: core/models.py:151
msgid ""
"Enter a valid sub. This value may contain only letters, numbers, and @/./+/-/"
"_ characters."
@@ -201,11 +209,11 @@ msgstr ""
"Entrez un sub valide. Cette valeur ne peut contenir que des lettres, des "
"chiffres et les caractères @/./+/-/_."
#: core/models.py:150
#: core/models.py:157
msgid "sub"
msgstr "sub"
#: core/models.py:152
#: core/models.py:159
msgid ""
"Optional for pending users; required upon account activation. 255 characters "
"or fewer. Letters, numbers, and @/./+/-/_ characters only."
@@ -213,55 +221,55 @@ msgstr ""
"Optionnel pour les utilisateurs en attente ; requis lors de l'activation du "
"compte. 255 caractères maximum. Lettres, chiffres et @/./+/-/_ uniquement."
#: core/models.py:161
#: core/models.py:168
msgid "identity email address"
msgstr "adresse e-mail d'identité"
#: core/models.py:166
#: core/models.py:173
msgid "admin email address"
msgstr "adresse e-mail d'administrateur"
#: core/models.py:168
#: core/models.py:175
msgid "full name"
msgstr "nom complet"
#: core/models.py:170
#: core/models.py:177
msgid "short name"
msgstr "nom court"
#: core/models.py:176
#: core/models.py:183
msgid "language"
msgstr "langue"
#: core/models.py:177
#: core/models.py:184
msgid "The language in which the user wants to see the interface."
msgstr "La langue dans laquelle l'utilisateur souhaite voir l'interface."
#: core/models.py:183
#: core/models.py:190
msgid "The timezone in which the user wants to see times."
msgstr "Le fuseau horaire dans lequel l'utilisateur souhaite voir les heures."
#: core/models.py:186
#: core/models.py:193
msgid "device"
msgstr "appareil"
#: core/models.py:188
#: core/models.py:195
msgid "Whether the user is a device or a real user."
msgstr "Si l'utilisateur est un appareil ou un utilisateur réel."
#: core/models.py:191
#: core/models.py:198
msgid "staff status"
msgstr "statut du personnel"
#: core/models.py:193
#: core/models.py:200
msgid "Whether the user can log into this admin site."
msgstr "Si l'utilisateur peut se connecter à ce site d'administration."
#: core/models.py:196
#: core/models.py:203
msgid "active"
msgstr "actif"
#: core/models.py:199
#: core/models.py:206
msgid ""
"Whether this user should be treated as active. Unselect this instead of "
"deleting accounts."
@@ -269,65 +277,65 @@ msgstr ""
"Si cet utilisateur doit être traité comme actif. Désélectionnez cette option "
"au lieu de supprimer des comptes."
#: core/models.py:212
#: core/models.py:219
msgid "user"
msgstr "utilisateur"
#: core/models.py:213
#: core/models.py:220
msgid "users"
msgstr "utilisateurs"
#: core/models.py:272
#: core/models.py:279
msgid "Resource"
msgstr "Ressource"
#: core/models.py:273
#: core/models.py:280
msgid "Resources"
msgstr "Ressources"
#: core/models.py:331
#: core/models.py:338
msgid "Resource access"
msgstr "Accès aux ressources"
#: core/models.py:332
#: core/models.py:339
msgid "Resource accesses"
msgstr "Accès aux ressources"
#: core/models.py:338
#: core/models.py:345
msgid "Resource access with this User and Resource already exists."
msgstr ""
"L'accès à la ressource avec cet utilisateur et cette ressource existe déjà."
#: core/models.py:394
#: core/models.py:402
msgid "Visio room configuration"
msgstr "Configuration de la salle de visioconférence"
#: core/models.py:395
#: core/models.py:403
msgid "Values for Visio parameters to configure the room."
msgstr "Valeurs des paramètres de visioconférence pour configurer la salle."
#: core/models.py:402
#: core/models.py:410
msgid "Room PIN code"
msgstr "Code PIN de la salle"
#: core/models.py:403
#: core/models.py:411
msgid "Unique n-digit code that identifies this room in telephony mode."
msgstr ""
"Code unique à n chiffres qui identifie cette salle en mode téléphonique."
#: core/models.py:409 core/models.py:563
#: core/models.py:417 core/models.py:571
msgid "Room"
msgstr "Salle"
#: core/models.py:410
#: core/models.py:418
msgid "Rooms"
msgstr "Salles"
#: core/models.py:574
#: core/models.py:582
msgid "Worker ID"
msgstr "ID du Worker"
#: core/models.py:576
#: core/models.py:584
msgid ""
"Enter an identifier for the worker recording.This ID is retained even when "
"the worker stops, allowing for easy tracking."
@@ -335,150 +343,158 @@ msgstr ""
"Entrez un identifiant pour l'enregistrement du Worker. Cet identifiant est "
"conservé même lorsque le Worker s'arrête, permettant un suivi facile."
#: core/models.py:584
#: core/models.py:592
msgid "Recording mode"
msgstr "Mode d'enregistrement"
#: core/models.py:585
#: core/models.py:593
msgid "Defines the mode of recording being called."
msgstr "Définit le mode d'enregistrement appelé."
#: core/models.py:590 core/models.py:591
#: core/models.py:598 core/models.py:599
msgid "Recording options"
msgstr "Options d'enregistrement"
#: core/models.py:597
#: core/models.py:606
msgid "External Process ID"
msgstr ""
#: core/models.py:607
msgid "ID of the external process associated with the recording."
msgstr ""
#: core/models.py:613
msgid "Recording"
msgstr "Enregistrement"
#: core/models.py:598
#: core/models.py:614
msgid "Recordings"
msgstr "Enregistrements"
#: core/models.py:706
#: core/models.py:724
msgid "Recording/user relation"
msgstr "Relation enregistrement/utilisateur"
#: core/models.py:707
#: core/models.py:725
msgid "Recording/user relations"
msgstr "Relations enregistrement/utilisateur"
#: core/models.py:713
#: core/models.py:731
msgid "This user is already in this recording."
msgstr "Cet utilisateur est déjà dans cet enregistrement."
#: core/models.py:719
#: core/models.py:737
msgid "This team is already in this recording."
msgstr "Cette équipe est déjà dans cet enregistrement."
#: core/models.py:725
#: core/models.py:743
msgid "Either user or team must be set, not both."
msgstr "Soit l'utilisateur, soit l'équipe doit être défini, pas les deux."
#: core/models.py:742
#: core/models.py:760
msgid "Create rooms"
msgstr "Créer des salles"
#: core/models.py:743
#: core/models.py:761
msgid "List rooms"
msgstr "Lister les salles"
#: core/models.py:744
#: core/models.py:762
msgid "Retrieve room details"
msgstr "Afficher les détails dune salle"
#: core/models.py:745
#: core/models.py:763
msgid "Update rooms"
msgstr "Mettre à jour les salles"
#: core/models.py:746
#: core/models.py:764
msgid "Delete rooms"
msgstr "Supprimer les salles"
#: core/models.py:759
#: core/models.py:777
msgid "Application name"
msgstr "Nom de lapplication"
#: core/models.py:760
#: core/models.py:778
msgid "Descriptive name for this application."
msgstr "Nom descriptif de cette application."
#: core/models.py:770
#: core/models.py:788
msgid "Hashed on Save. Copy it now if this is a new secret."
msgstr ""
"Haché lors de lenregistrement. Copiez-le maintenant sil sagit dun "
"nouveau secret."
#: core/models.py:781
#: core/models.py:799
msgid "Application"
msgstr "Application"
#: core/models.py:782
#: core/models.py:800
msgid "Applications"
msgstr "Applications"
#: core/models.py:805
#: core/models.py:823
msgid "Enter a valid domain"
msgstr "Saisissez un domaine valide"
#: core/models.py:808
#: core/models.py:826
msgid "Domain"
msgstr "Domaine"
#: core/models.py:809
#: core/models.py:827
msgid "Email domain this application can act on behalf of."
msgstr "Domaine de messagerie au nom duquel cette application peut agir."
#: core/models.py:821
#: core/models.py:839
msgid "Application domain"
msgstr "Domaine dapplication"
#: core/models.py:822
#: core/models.py:840
msgid "Application domains"
msgstr "Domaines dapplication"
#: core/models.py:840
#: core/models.py:858
msgid "Pending"
msgstr "En attente"
#: core/models.py:848
#: core/models.py:866
msgid "Ready"
msgstr "Prêt"
#: core/models.py:854
#: core/models.py:872
msgid "Background image"
msgstr "Image de fond"
#: core/models.py:866
#: core/models.py:884
msgid "title"
msgstr "Titre"
#: core/models.py:890
#: core/models.py:908
msgid "Malware detection info when the analysis status is unsafe."
msgstr ""
"Information concernant la détection de Malware cand le statut n'est pas sain"
#: core/models.py:895
#: core/models.py:913
msgid "File"
msgstr "Fichier"
#: core/models.py:896
#: core/models.py:914
msgid "Files"
msgstr "Fichiers"
#: core/models.py:1000
#: core/models.py:1018
#, fuzzy
#| msgid "This user is already in this recording."
msgid "This file is already hard deleted."
msgstr "Ce fichier a été supprimé."
#: core/models.py:1010
#: core/models.py:1028
msgid "To hard delete a file, it must first be soft deleted."
msgstr ""
"Pour supprimer définitivement un fichier il doit d'abord avoir été marqué "
"comme supprimé (soft delete)"
#: core/recording/event/notification.py:116
#: core/recording/event/notification.py:124
msgid "Your recording is ready"
msgstr "Votre enregistrement est prêt"
@@ -578,7 +594,9 @@ msgstr "Pour conserver cet enregistrement de façon permanente :"
#: core/templates/mail/html/screen_recording.html:208
#: core/templates/mail/text/screen_recording.txt:13
msgid "Click the \"<a href=\"%(link)s\">Open</a>\" link below "
#, fuzzy
#| msgid "Click the \"<a href=\"%(link)s\">Open</a>\" link below "
msgid "Click the \"Open\" button below "
msgstr "Cliquez sur le lien \"<a href=\"%(link)s\">Ouvrir</a>\" ci-dessous "
#: core/templates/mail/html/screen_recording.html:209
+100 -82
View File
@@ -8,7 +8,7 @@ msgid ""
msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-04-21 14:09+0000\n"
"POT-Creation-Date: 2026-05-26 16:20+0000\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language-Team: LANGUAGE <LL@li.org>\n"
@@ -88,16 +88,16 @@ msgstr "Scopes"
msgid "Creator is me"
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."
msgstr ""
"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."
msgstr "Deze bestandsextensie is niet toegestaan."
#: core/api/viewsets.py:1090
#: core/api/viewsets.py:1228
msgid "You have reached the maximum number of files for this type."
msgstr "Het maximale aantal bestanden voor dit type is bereikt."
@@ -145,51 +145,59 @@ msgstr "Stoppen mislukt"
msgid "Notification succeeded"
msgstr "Notificatie geslaagd"
#: core/models.py:89
#: core/models.py:65
msgid "External process successful"
msgstr ""
#: core/models.py:67
msgid "External process failed"
msgstr ""
#: core/models.py:96
msgid "SCREEN_RECORDING"
msgstr "SCHERM_OPNAME"
#: core/models.py:90
#: core/models.py:97
msgid "TRANSCRIPT"
msgstr "TRANSCRIPT"
#: core/models.py:96
#: core/models.py:103
msgid "Public Access"
msgstr "Openbare toegang"
#: core/models.py:97
#: core/models.py:104
msgid "Trusted Access"
msgstr "Vertrouwde toegang"
#: core/models.py:98
#: core/models.py:105
msgid "Restricted Access"
msgstr "Beperkte toegang"
#: core/models.py:110
#: core/models.py:117
msgid "id"
msgstr "id"
#: core/models.py:111
#: core/models.py:118
msgid "primary key for the record as UUID"
msgstr "primaire sleutel voor het record als UUID"
#: core/models.py:117
#: core/models.py:124
msgid "created on"
msgstr "aangemaakt op"
#: core/models.py:118
#: core/models.py:125
msgid "date and time at which a record was created"
msgstr "datum en tijd waarop een record werd aangemaakt"
#: core/models.py:123
#: core/models.py:130
msgid "updated on"
msgstr "bijgewerkt op"
#: core/models.py:124
#: core/models.py:131
msgid "date and time at which a record was last updated"
msgstr "datum en tijd waarop een record voor het laatst werd bijgewerkt"
#: core/models.py:144
#: core/models.py:151
msgid ""
"Enter a valid sub. This value may contain only letters, numbers, and @/./+/-/"
"_ characters."
@@ -197,11 +205,11 @@ msgstr ""
"Voer een geldige sub in. Deze waarde mag alleen letters, cijfers en @/./+/-/"
"_ tekens bevatten."
#: core/models.py:150
#: core/models.py:157
msgid "sub"
msgstr "sub"
#: core/models.py:152
#: core/models.py:159
msgid ""
"Optional for pending users; required upon account activation. 255 characters "
"or fewer. Letters, numbers, and @/./+/-/_ characters only."
@@ -209,55 +217,55 @@ msgstr ""
"Optioneel voor gebruikers in afwachting; vereist bij accountactivering. "
"Maximum 255 tekens. Alleen letters, cijfers en @/./+/-/_ toegestaan."
#: core/models.py:161
#: core/models.py:168
msgid "identity email address"
msgstr "identiteit e-mailadres"
#: core/models.py:166
#: core/models.py:173
msgid "admin email address"
msgstr "beheerder e-mailadres"
#: core/models.py:168
#: core/models.py:175
msgid "full name"
msgstr "volledige naam"
#: core/models.py:170
#: core/models.py:177
msgid "short name"
msgstr "korte naam"
#: core/models.py:176
#: core/models.py:183
msgid "language"
msgstr "taal"
#: core/models.py:177
#: core/models.py:184
msgid "The language in which the user wants to see the interface."
msgstr "De taal waarin de gebruiker de interface wil zien."
#: core/models.py:183
#: core/models.py:190
msgid "The timezone in which the user wants to see times."
msgstr "De tijdzone waarin de gebruiker tijden wil zien."
#: core/models.py:186
#: core/models.py:193
msgid "device"
msgstr "apparaat"
#: core/models.py:188
#: core/models.py:195
msgid "Whether the user is a device or a real user."
msgstr "Of de gebruiker een apparaat is of een echte gebruiker."
#: core/models.py:191
#: core/models.py:198
msgid "staff status"
msgstr "personeelsstatus"
#: core/models.py:193
#: core/models.py:200
msgid "Whether the user can log into this admin site."
msgstr "Of de gebruiker kan inloggen op deze beheersite."
#: core/models.py:196
#: core/models.py:203
msgid "active"
msgstr "actief"
#: core/models.py:199
#: core/models.py:206
msgid ""
"Whether this user should be treated as active. Unselect this instead of "
"deleting accounts."
@@ -265,64 +273,64 @@ msgstr ""
"Of deze gebruiker als actief moet worden behandeld. Deselecteer dit in "
"plaats van accounts te verwijderen."
#: core/models.py:212
#: core/models.py:219
msgid "user"
msgstr "gebruiker"
#: core/models.py:213
#: core/models.py:220
msgid "users"
msgstr "gebruikers"
#: core/models.py:272
#: core/models.py:279
msgid "Resource"
msgstr "Bron"
#: core/models.py:273
#: core/models.py:280
msgid "Resources"
msgstr "Bronnen"
#: core/models.py:331
#: core/models.py:338
msgid "Resource access"
msgstr "Brontoegang"
#: core/models.py:332
#: core/models.py:339
msgid "Resource accesses"
msgstr "Brontoegangsrechten"
#: core/models.py:338
#: core/models.py:345
msgid "Resource access with this User and Resource already exists."
msgstr "Brontoegang met deze gebruiker en bron bestaat al."
#: core/models.py:394
#: core/models.py:402
msgid "Visio room configuration"
msgstr "Visio-ruimteconfiguratie"
#: core/models.py:395
#: core/models.py:403
msgid "Values for Visio parameters to configure the room."
msgstr "Waarden voor Visio-parameters om de ruimte te configureren."
#: core/models.py:402
#: core/models.py:410
msgid "Room PIN code"
msgstr "Pincode van de kamer"
#: core/models.py:403
#: core/models.py:411
msgid "Unique n-digit code that identifies this room in telephony mode."
msgstr ""
"Unieke n-cijferige code die deze kamer identificeert in telefonie-modus."
#: core/models.py:409 core/models.py:563
#: core/models.py:417 core/models.py:571
msgid "Room"
msgstr "Ruimte"
#: core/models.py:410
#: core/models.py:418
msgid "Rooms"
msgstr "Ruimtes"
#: core/models.py:574
#: core/models.py:582
msgid "Worker ID"
msgstr "Worker ID"
#: core/models.py:576
#: core/models.py:584
msgid ""
"Enter an identifier for the worker recording.This ID is retained even when "
"the worker stops, allowing for easy tracking."
@@ -330,140 +338,148 @@ msgstr ""
"Voer een identificatie in voor de worker-opname. Deze ID blijft behouden, "
"zelfs wanneer de worker stopt, waardoor eenvoudige tracking mogelijk is."
#: core/models.py:584
#: core/models.py:592
msgid "Recording mode"
msgstr "Opnamemodus"
#: core/models.py:585
#: core/models.py:593
msgid "Defines the mode of recording being called."
msgstr "Definieert de modus van opname die wordt aangeroepen."
#: core/models.py:590 core/models.py:591
#: core/models.py:598 core/models.py:599
msgid "Recording options"
msgstr "Opnameopties"
#: core/models.py:597
#: core/models.py:606
msgid "External Process ID"
msgstr ""
#: core/models.py:607
msgid "ID of the external process associated with the recording."
msgstr ""
#: core/models.py:613
msgid "Recording"
msgstr "Opname"
#: core/models.py:598
#: core/models.py:614
msgid "Recordings"
msgstr "Opnames"
#: core/models.py:706
#: core/models.py:724
msgid "Recording/user relation"
msgstr "Opname/gebruiker-relatie"
#: core/models.py:707
#: core/models.py:725
msgid "Recording/user relations"
msgstr "Opname/gebruiker-relaties"
#: core/models.py:713
#: core/models.py:731
msgid "This user is already in this recording."
msgstr "Deze gebruiker is al in deze opname."
#: core/models.py:719
#: core/models.py:737
msgid "This team is already in this recording."
msgstr "Dit team is al in deze opname."
#: core/models.py:725
#: core/models.py:743
msgid "Either user or team must be set, not both."
msgstr "Ofwel gebruiker of team moet worden ingesteld, niet beide."
#: core/models.py:742
#: core/models.py:760
msgid "Create rooms"
msgstr "Ruimtes aanmaken"
#: core/models.py:743
#: core/models.py:761
msgid "List rooms"
msgstr "Ruimtes weergeven"
#: core/models.py:744
#: core/models.py:762
msgid "Retrieve room details"
msgstr "Details van een ruimte ophalen"
#: core/models.py:745
#: core/models.py:763
msgid "Update rooms"
msgstr "Ruimtes bijwerken"
#: core/models.py:746
#: core/models.py:764
msgid "Delete rooms"
msgstr "Ruimtes verwijderen"
#: core/models.py:759
#: core/models.py:777
msgid "Application name"
msgstr "Naam van de applicatie"
#: core/models.py:760
#: core/models.py:778
msgid "Descriptive name for this application."
msgstr "Beschrijvende naam voor deze applicatie."
#: core/models.py:770
#: core/models.py:788
msgid "Hashed on Save. Copy it now if this is a new secret."
msgstr ""
"Wordt gehasht bij het opslaan. Kopieer het nu als dit een nieuw geheim is."
#: core/models.py:781
#: core/models.py:799
msgid "Application"
msgstr "Applicatie"
#: core/models.py:782
#: core/models.py:800
msgid "Applications"
msgstr "Applicaties"
#: core/models.py:805
#: core/models.py:823
msgid "Enter a valid domain"
msgstr "Voer een geldig domein in"
#: core/models.py:808
#: core/models.py:826
msgid "Domain"
msgstr "Domein"
#: core/models.py:809
#: core/models.py:827
msgid "Email domain this application can act on behalf of."
msgstr "E-maildomein namens welke deze applicatie kan handelen."
#: core/models.py:821
#: core/models.py:839
msgid "Application domain"
msgstr "Applicatiedomein"
#: core/models.py:822
#: core/models.py:840
msgid "Application domains"
msgstr "Applicatiedomeinen"
#: core/models.py:840
#: core/models.py:858
msgid "Pending"
msgstr "In afwachting"
#: core/models.py:848
#: core/models.py:866
msgid "Ready"
msgstr "Klaar"
#: core/models.py:854
#: core/models.py:872
msgid "Background image"
msgstr "Achtergrondafbeelding"
#: core/models.py:866
#: core/models.py:884
msgid "title"
msgstr "Titel"
#: core/models.py:890
#: core/models.py:908
msgid "Malware detection info when the analysis status is unsafe."
msgstr "Informatie over malwaredetectie wanneer de analysestatus onveilig is."
#: core/models.py:895
#: core/models.py:913
msgid "File"
msgstr "Bestand"
#: core/models.py:896
#: core/models.py:914
msgid "Files"
msgstr "Bestanden"
#: core/models.py:1000
#: core/models.py:1018
msgid "This file is already hard deleted."
msgstr "Dit bestand is al definitief verwijderd."
#: core/models.py:1010
#: core/models.py:1028
#, 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."
@@ -471,7 +487,7 @@ msgstr ""
"Om een bestand definitief te verwijderen, moet het eerst zacht verwijderd "
"zijn."
#: core/recording/event/notification.py:116
#: core/recording/event/notification.py:124
msgid "Your recording is ready"
msgstr "Je opname is klaar"
@@ -571,7 +587,9 @@ msgstr "Om deze opname permanent te bewaren:"
#: core/templates/mail/html/screen_recording.html:208
#: core/templates/mail/text/screen_recording.txt:13
msgid "Click the \"<a href=\"%(link)s\">Open</a>\" link below "
#, fuzzy
#| msgid "Click the \"<a href=\"%(link)s\">Open</a>\" link below "
msgid "Click the \"Open\" button below "
msgstr "Klik op de \"<a href=\"%(link)s\">Openen</a>\"-link hieronder "
#: core/templates/mail/html/screen_recording.html:209
+3
View File
@@ -744,6 +744,9 @@ class Base(Configuration):
SUMMARY_SERVICE_API_TOKEN = SecretFileValue(
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
)
SCREEN_RECORDING_BASE_URL = values.Value(
None, environ_name="SCREEN_RECORDING_BASE_URL", environ_prefix=None
)
+1
View File
@@ -279,6 +279,7 @@ const config: Config = {
'room-side-panel-margin': { value: '1.5rem' },
'room-control-bar': { value: '80px' },
'room-reaction-toolbar-height': { value: '42px' },
'tooltip-spacing': { value: '8px' },
},
spacing,
}),
+121
View File
@@ -0,0 +1,121 @@
<svg width="102" height="72" viewBox="0 0 102 72" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_13227_1168)">
<g filter="url(#filter0_d_13227_1168)">
<rect x="16" y="8.77759" width="51.852" height="41.4816" rx="7.71815" fill="#969EB0"/>
<rect x="16" y="8.77759" width="51.852" height="41.4816" rx="7.71815" fill="#181B24" fill-opacity="0.7"/>
<rect x="16.5963" y="9.37389" width="50.6595" height="40.289" rx="7.12186" stroke="#969EB0" stroke-width="1.1926"/>
<rect x="16.5963" y="9.37389" width="50.6595" height="40.289" rx="7.12186" stroke="#181B24" stroke-opacity="0.6" stroke-width="1.1926"/>
<g filter="url(#filter1_d_13227_1168)">
<rect x="21.1851" y="13.9629" width="19.4445" height="14.2593" rx="2.5926" fill="#969EB0"/>
<rect x="21.1851" y="13.9629" width="19.4445" height="14.2593" rx="2.5926" fill="#181B24" fill-opacity="0.6"/>
</g>
<g filter="url(#filter2_d_13227_1168)">
<rect x="21.1851" y="30.8149" width="19.4445" height="14.2593" rx="2.5926" fill="#969EB0"/>
<rect x="21.1851" y="30.8149" width="19.4445" height="14.2593" rx="2.5926" fill="#181B24" fill-opacity="0.6"/>
</g>
<g filter="url(#filter3_d_13227_1168)">
<rect x="43.2222" y="13.9629" width="19.4445" height="14.2593" rx="2.5926" fill="#969EB0"/>
<rect x="43.2222" y="13.9629" width="19.4445" height="14.2593" rx="2.5926" fill="#181B24" fill-opacity="0.6"/>
</g>
<g filter="url(#filter4_d_13227_1168)">
<rect x="43.2222" y="30.8149" width="19.4445" height="14.2593" rx="2.5926" fill="#969EB0"/>
<rect x="43.2222" y="30.8149" width="19.4445" height="14.2593" rx="2.5926" fill="#181B24" fill-opacity="0.6"/>
</g>
</g>
<g filter="url(#filter5_d_13227_1168)">
<rect x="52.2964" y="34.7036" width="33.7038" height="28.5186" rx="7.71815" fill="#7E98FF"/>
<rect x="52.2964" y="34.7036" width="33.7038" height="28.5186" rx="7.71815" fill="#181B24" fill-opacity="0.7"/>
<rect x="52.2964" y="34.7036" width="33.7038" height="28.5186" rx="7.71815" fill="url(#paint0_linear_13227_1168)" fill-opacity="0.05"/>
<rect x="52.8927" y="35.2999" width="32.5112" height="27.326" rx="7.12186" stroke="#7E98FF" stroke-width="1.1926"/>
<rect x="52.8927" y="35.2999" width="32.5112" height="27.326" rx="7.12186" stroke="#181B24" stroke-opacity="0.45" stroke-width="1.1926"/>
<rect width="5.1852" height="5.1852" rx="1.94445" transform="matrix(-1 0 0 1 65.2593 54.1482)" fill="#7E98FF"/>
<rect width="5.1852" height="5.1852" rx="1.94445" transform="matrix(-1 0 0 1 65.2593 54.1482)" fill="#181B24" fill-opacity="0.45"/>
<rect width="5.1852" height="5.1852" rx="1.94445" transform="matrix(-1 0 0 1 71.7407 54.1482)" fill="#7E98FF"/>
<rect width="5.1852" height="5.1852" rx="1.94445" transform="matrix(-1 0 0 1 71.7407 54.1482)" fill="#181B24" fill-opacity="0.45"/>
<rect width="5.1852" height="5.1852" rx="1.94445" transform="matrix(-1 0 0 1 78.2222 54.1482)" fill="#FF706E"/>
<rect width="5.1852" height="5.1852" rx="1.94445" transform="matrix(-1 0 0 1 78.2222 54.1482)" fill="#181B24" fill-opacity="0.45"/>
</g>
<g filter="url(#filter6_d_13227_1168)">
<path d="M50.3653 53.4428C51.0628 53.4428 51.6941 53.2682 52.2593 52.9191C52.8304 52.576 53.2844 52.1135 53.6211 51.5316C53.9638 50.9558 54.1352 50.3156 54.1352 49.6112C54.1352 48.9006 53.9638 48.2543 53.6211 47.6724C53.2844 47.0965 52.8304 46.634 52.2593 46.2849C51.6941 45.9418 51.0628 45.7703 50.3653 45.7703H47.344C46.1836 45.7703 45.1555 45.6539 44.2596 45.4211C43.3637 45.1884 42.5731 44.7871 41.8876 44.2174C41.2022 43.6539 40.598 42.8698 40.0749 41.8652C39.9185 41.5711 39.7412 41.3843 39.5428 41.3046C39.3504 41.225 39.158 41.1852 38.9656 41.1852C38.7251 41.1852 38.5086 41.2924 38.3162 41.5068C38.1298 41.7151 38.0366 42.0183 38.0366 42.4165C38.0366 44.1133 38.217 45.6386 38.5777 46.9924C38.9445 48.3523 39.5037 49.5131 40.2552 50.4749C41.0128 51.4366 41.9778 52.1717 43.1503 52.6801C44.3287 53.1886 45.7266 53.4428 47.344 53.4428H50.3653ZM47.6056 42.2603V56.9161C47.6056 57.2224 47.7048 57.4858 47.9032 57.7063C48.1076 57.9268 48.3692 58.0371 48.6878 58.0371C48.9043 58.0371 49.0997 57.985 49.274 57.8809C49.4544 57.7829 49.6649 57.6175 49.9054 57.3847L57.0212 50.6035C57.1955 50.4381 57.3158 50.2697 57.3819 50.0981C57.4481 49.9266 57.4811 49.7643 57.4811 49.6112C57.4811 49.4641 57.4481 49.3049 57.3819 49.1333C57.3158 48.9618 57.1955 48.7934 57.0212 48.628L49.9054 41.7825C49.6889 41.5742 49.4815 41.4241 49.2831 41.3322C49.0907 41.2342 48.8862 41.1852 48.6698 41.1852C48.3631 41.1852 48.1076 41.2863 47.9032 41.4884C47.7048 41.6906 47.6056 41.9478 47.6056 42.2603Z" fill="#969EB0"/>
</g>
</g>
<defs>
<filter id="filter0_d_13227_1168" x="7.82784" y="4.69151" width="68.1964" height="57.826" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
<feOffset dy="4.08608"/>
<feGaussianBlur stdDeviation="4.08608"/>
<feComposite in2="hardAlpha" operator="out"/>
<feColorMatrix type="matrix" values="0 0 0 0 0.0941176 0 0 0 0 0.105882 0 0 0 0 0.141176 0 0 0 0.05 0"/>
<feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow_13227_1168"/>
<feBlend mode="normal" in="SourceGraphic" in2="effect1_dropShadow_13227_1168" result="shape"/>
</filter>
<filter id="filter1_d_13227_1168" x="17.3569" y="10.1348" width="27.1006" height="21.9156" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
<feOffset/>
<feGaussianBlur stdDeviation="1.91407"/>
<feComposite in2="hardAlpha" operator="out"/>
<feColorMatrix type="matrix" values="0 0 0 0 1 0 0 0 0 1 0 0 0 0 1 0 0 0 0.02 0"/>
<feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow_13227_1168"/>
<feBlend mode="normal" in="SourceGraphic" in2="effect1_dropShadow_13227_1168" result="shape"/>
</filter>
<filter id="filter2_d_13227_1168" x="17.3569" y="26.9868" width="27.1006" height="21.9156" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
<feOffset/>
<feGaussianBlur stdDeviation="1.91407"/>
<feComposite in2="hardAlpha" operator="out"/>
<feColorMatrix type="matrix" values="0 0 0 0 1 0 0 0 0 1 0 0 0 0 1 0 0 0 0.02 0"/>
<feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow_13227_1168"/>
<feBlend mode="normal" in="SourceGraphic" in2="effect1_dropShadow_13227_1168" result="shape"/>
</filter>
<filter id="filter3_d_13227_1168" x="39.394" y="10.1348" width="27.1006" height="21.9156" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
<feOffset/>
<feGaussianBlur stdDeviation="1.91407"/>
<feComposite in2="hardAlpha" operator="out"/>
<feColorMatrix type="matrix" values="0 0 0 0 1 0 0 0 0 1 0 0 0 0 1 0 0 0 0.02 0"/>
<feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow_13227_1168"/>
<feBlend mode="normal" in="SourceGraphic" in2="effect1_dropShadow_13227_1168" result="shape"/>
</filter>
<filter id="filter4_d_13227_1168" x="39.394" y="26.9868" width="27.1006" height="21.9156" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
<feOffset/>
<feGaussianBlur stdDeviation="1.91407"/>
<feComposite in2="hardAlpha" operator="out"/>
<feColorMatrix type="matrix" values="0 0 0 0 1 0 0 0 0 1 0 0 0 0 1 0 0 0 0.02 0"/>
<feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow_13227_1168"/>
<feBlend mode="normal" in="SourceGraphic" in2="effect1_dropShadow_13227_1168" result="shape"/>
</filter>
<filter id="filter5_d_13227_1168" x="44.1242" y="30.6175" width="50.0479" height="44.8629" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
<feOffset dy="4.08608"/>
<feGaussianBlur stdDeviation="4.08608"/>
<feComposite in2="hardAlpha" operator="out"/>
<feColorMatrix type="matrix" values="0 0 0 0 0.0941176 0 0 0 0 0.105882 0 0 0 0 0.141176 0 0 0 0.05 0"/>
<feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow_13227_1168"/>
<feBlend mode="normal" in="SourceGraphic" in2="effect1_dropShadow_13227_1168" result="shape"/>
</filter>
<filter id="filter6_d_13227_1168" x="29.8645" y="37.0992" width="35.7887" height="33.1961" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
<feOffset dy="4.08608"/>
<feGaussianBlur stdDeviation="4.08608"/>
<feComposite in2="hardAlpha" operator="out"/>
<feColorMatrix type="matrix" values="0 0 0 0 0.0941176 0 0 0 0 0.105882 0 0 0 0 0.141176 0 0 0 0.05 0"/>
<feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow_13227_1168"/>
<feBlend mode="normal" in="SourceGraphic" in2="effect1_dropShadow_13227_1168" result="shape"/>
</filter>
<linearGradient id="paint0_linear_13227_1168" x1="69.1483" y1="34.7036" x2="69.1483" y2="63.2222" gradientUnits="userSpaceOnUse">
<stop stop-color="#F6F8F9" stop-opacity="0.975"/>
<stop offset="1" stop-color="#F6F8F9" stop-opacity="0"/>
</linearGradient>
<clipPath id="clip0_13227_1168">
<rect width="102" height="72" fill="white"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 9.7 KiB

+3 -1
View File
@@ -2,6 +2,8 @@ import { fetchApi } from './fetchApi'
import { keys } from './queryKeys'
import { useQuery } from '@tanstack/react-query'
import { RecordingMode } from '@/features/recording'
import { Track } from 'livekit-client'
import Source = Track.Source
export interface ApiConfig {
analytics?: {
@@ -50,7 +52,7 @@ export interface ApiConfig {
url: string
force_wss_protocol: boolean
enable_firefox_proxy_workaround: boolean
default_sources: string[]
default_sources: Source[]
}
transcription_destination?: string
}
@@ -22,6 +22,8 @@ const controlBarRegion = cva({
},
})
export const CONTROL_BAR_REGION_ID = 'control-bar-region'
export type ControlBarRegionProps = React.HTMLAttributes<HTMLDivElement> &
RecipeVariantProps<typeof controlBarRegion>
@@ -34,6 +36,7 @@ export function ControlBarRegion({
return (
<div
role="region"
id={CONTROL_BAR_REGION_ID}
aria-label={t('controls.region')}
className={controlBarRegion({ mobile })}
{...props}
@@ -3,18 +3,17 @@ import { useRoomContext } from '@livekit/components-react'
import { Participant, RemoteParticipant, RoomEvent } from 'livekit-client'
import { ChatMessage, isMobileBrowser } from '@livekit/components-core'
import { useTranslation } from 'react-i18next'
import { Div } from '@/primitives'
import { NotificationType } from './NotificationType'
import { NotificationDuration } from './NotificationDuration'
import { decodeNotificationDataReceived } from './utils'
import { useNotificationSound } from '@/features/notifications/hooks/useSoundNotification'
import { ToastProvider, toastQueue } from './components/ToastProvider'
import { WaitingParticipantNotification } from './components/WaitingParticipantNotification'
import { toastQueue } from './components/ToastProvider'
import { layoutStore } from '@/stores/layout'
import { PanelId } from '@/features/rooms/livekit/hooks/useSidePanel'
import { useScreenReaderAnnounce } from '@/hooks/useScreenReaderAnnounce'
import { Emoji } from '@/features/reactions/types'
import { useReactions } from '@/features/reactions/hooks/useReactions'
import { NotificationProvider } from './NotificationProvider'
export const MainNotificationToast = () => {
const room = useRoomContext()
@@ -233,10 +232,5 @@ export const MainNotificationToast = () => {
// the 'notifications' namespace might not be loaded yet
useTranslation(['notifications'])
return (
<Div position="absolute" bottom={0} right={5} zIndex={1000}>
<ToastProvider />
<WaitingParticipantNotification />
</Div>
)
return <NotificationProvider />
}
@@ -0,0 +1,16 @@
import { Div } from '@/primitives'
import { ToastProvider } from './components/ToastProvider'
import { WaitingParticipantNotification } from './components/WaitingParticipantNotification'
export const NotificationProvider = ({
bottom = 0,
right = 5,
}: {
bottom?: number
right?: number
}) => (
<Div position="absolute" bottom={bottom} right={right} zIndex={1000}>
<ToastProvider />
<WaitingParticipantNotification />
</Div>
)
@@ -0,0 +1,8 @@
import { PictureInPicturePortal } from '@/features/pip/components/PictureInPicturePortal'
import { PipView } from '@/features/pip/components/PipView'
export const PictureInPictureConference = () => (
<PictureInPicturePortal>
<PipView />
</PictureInPicturePortal>
)
@@ -0,0 +1,56 @@
import { usePictureInPicture } from '../hooks/usePictureInPicture'
import { createPortal } from 'react-dom'
import { UNSAFE_PortalProvider } from '@react-aria/overlays'
import { documentPictureInPictureStore } from '@/stores/documentPictureInPicture'
import { useSnapshot } from 'valtio'
import { useEffect, useMemo } from 'react'
import { CrossDocumentOverlaysContext } from '@/primitives/CrossDocumentOverlaysContext'
const InternalPortal = ({ children }: { children: React.ReactNode }) => {
const pipStoreSnap = useSnapshot(documentPictureInPictureStore)
const container = useMemo(() => {
return pipStoreSnap?.window?.document.getElementById('root')
}, [pipStoreSnap.window])
useEffect(() => {
return () => {
documentPictureInPictureStore.window?.close()
}
}, [])
if (!container) return null
return createPortal(
/**
* UNSAFE_PortalProvider is marked unsafe because overlays are normally
* portalled to `document.body` to avoid clipping, stacking, and accessibility
* issues. Redirecting them to another container can break those guarantees.
*
* We accept that risk here because the PiP window is a separate document with
* its own `body`. Rendering overlays into the main document would make them
* invisible in PiP, so we intentionally portal them into the PiP document root
* instead.
*/
<UNSAFE_PortalProvider getContainer={() => container}>
{/*React Aria computes overlay position based on the main window size, so*/}
{/*we must disable it to position overlays correctly across documents.*/}
<CrossDocumentOverlaysContext.Provider value={true}>
{children}
</CrossDocumentOverlaysContext.Provider>
</UNSAFE_PortalProvider>,
container
)
}
export const PictureInPicturePortal = ({
children,
}: {
children: React.ReactNode
}): React.ReactNode => {
const { isSupported } = usePictureInPicture()
if (!isSupported) return null
return <InternalPortal>{children}</InternalPortal>
}
@@ -0,0 +1,116 @@
import { styled } from '@/styled-system/jsx'
import { useTranslation } from 'react-i18next'
import { AudioDevicesControl } from '@/features/rooms/livekit/components/controls/Device/AudioDevicesControl'
import { VideoDeviceControl } from '@/features/rooms/livekit/components/controls/Device/VideoDeviceControl'
import { ScreenShareToggle } from '@/features/rooms/livekit/components/controls/ScreenShareToggle'
import { LeaveButton } from '@/features/rooms/livekit/components/controls/LeaveButton'
import { HandToggle } from '@/features/rooms/livekit/components/controls/HandToggle'
import { StartMediaButton } from '@/features/rooms/livekit/components/controls/StartMediaButton'
import { ReactionsToggle } from '@/features/reactions/components/ReactionsToggle'
import { PipOptionsMenu } from './controls/PipOptionsMenu'
import { usePipElementSize } from '../hooks/usePipElementSize'
import { useMemo, useRef } from 'react'
export const CollapsibleControls = {
HAND: 'hand',
SCREEN_SHARE: 'screenShare',
REACTIONS: 'reactions',
} as const
export type CollapsibleControl =
(typeof CollapsibleControls)[keyof typeof CollapsibleControls]
const COLLAPSE_ORDER: CollapsibleControl[] = [
CollapsibleControls.HAND,
CollapsibleControls.SCREEN_SHARE,
CollapsibleControls.REACTIONS,
]
const BUTTON_SLOT = 50
const ESSENTIAL_WIDTH = 260
const getHiddenControls = (
containerWidth: number,
showScreenShare: boolean
): Set<CollapsibleControl> => {
const hidden = new Set<CollapsibleControl>()
if (containerWidth <= 0) return hidden
const collapsible = showScreenShare
? COLLAPSE_ORDER
: COLLAPSE_ORDER.filter((c) => c !== CollapsibleControls.SCREEN_SHARE)
const available = containerWidth - ESSENTIAL_WIDTH
const maxVisible = Math.max(0, Math.floor(available / BUTTON_SLOT))
for (let i = 0; i < collapsible.length - maxVisible; i++) {
hidden.add(collapsible[i])
}
return hidden
}
export const PipControlBar = ({
showScreenShare,
}: {
showScreenShare: boolean
}) => {
const containerRef = useRef<HTMLDivElement>(null)
const { width } = usePipElementSize(containerRef)
const { t } = useTranslation('rooms', {
keyPrefix: 'pictureInPicture',
})
const hidden = useMemo(
() => getHiddenControls(width, showScreenShare),
[width, showScreenShare]
)
return (
<PipControls
ref={containerRef}
id="pip-control-bar"
role="toolbar"
aria-label={t('controlBar')}
>
<PipControlsCenter>
<AudioDevicesControl hideMenu />
<VideoDeviceControl hideMenu />
{!hidden.has(CollapsibleControls.REACTIONS) && <ReactionsToggle />}
{showScreenShare && !hidden.has(CollapsibleControls.SCREEN_SHARE) && (
<ScreenShareToggle />
)}
{!hidden.has(CollapsibleControls.HAND) && <HandToggle />}
<StartMediaButton />
<PipOptionsMenu overflowControls={hidden} />
<LeaveButton />
</PipControlsCenter>
</PipControls>
)
}
const PipControls = styled('div', {
base: {
flex: '0 0 auto',
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
gap: '0.5rem',
padding: '1.125rem',
width: '100%',
position: 'absolute',
bottom: 0,
left: 0,
right: 0,
},
})
const PipControlsCenter = styled('div', {
base: {
display: 'flex',
flexWrap: 'nowrap',
justifyContent: 'center',
alignItems: 'center',
gap: '0.4rem',
flex: '1 1 auto',
},
})
@@ -0,0 +1,46 @@
import { useSnapshot } from 'valtio'
import { reactionsStore } from '@/stores/reactions'
import { useMemo, useRef } from 'react'
import { FloatingReaction } from '@/features/reactions/components/ReactionPortals'
import { Reaction } from '@/features/reactions/types'
import { css } from '@/styled-system/css'
export const PipFloatingReactions = () => {
const { reactions } = useSnapshot(reactionsStore)
return (
<>
{reactions.map((reaction) => (
<PipFloatingReaction key={reaction.id} reaction={reaction} />
))}
</>
)
}
const PipFloatingReaction = ({ reaction }: { reaction: Reaction }) => {
const containerRef = useRef<HTMLDivElement>(null)
const speed = useMemo(() => Math.random() * 1.5 + 0.5, [])
const scale = useMemo(() => Math.max(Math.random() + 0.5, 1), [])
return (
<div
ref={containerRef}
className={css({
position: 'absolute',
top: 0,
left: 0,
width: '100%',
height: '100%',
pointerEvents: 'none',
overflow: 'hidden',
})}
>
<FloatingReaction
emoji={reaction.emoji}
speed={speed}
scale={scale}
name={reaction.participantName}
isLocal={reaction.isLocal}
/>
</div>
)
}
@@ -0,0 +1,48 @@
import { useTranslation } from 'react-i18next'
import { styled } from '@/styled-system/jsx'
import { usePictureInPicture } from '../hooks/usePictureInPicture'
import { Button, Text } from '@/primitives'
export const PipRoomPlaceholder = () => {
const { t } = useTranslation('rooms', {
keyPrefix: 'pictureInPicture.placeholder',
})
const { close } = usePictureInPicture()
return (
<Container>
<img
src="/assets/pip.svg"
alt=""
width={300}
height={72}
aria-hidden="true"
style={{
marginBottom: '0.25rem',
}}
/>
<Text variant="body">{t('title')}</Text>
<Text variant="sm" style={{ maxWidth: '312px' }}>
{t('description')}
</Text>
<Button variant="primaryTextDark" onPress={close}>
{t('bringBack')}
</Button>
</Container>
)
}
const Container = styled('div', {
base: {
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
width: '100%',
height: '100%',
gap: '0.5rem',
padding: '1.5rem',
textAlign: 'center',
color: 'white',
},
})
@@ -0,0 +1,76 @@
import { styled } from '@/styled-system/jsx'
import { PipControlBar } from './PipControlBar'
import { PipFloatingReactions } from './PipFloatingReactions'
import { PipStage } from './layout/PipStage'
import { ReactionsToolbar } from '@/features/reactions/components/toolbar/ReactionsToolbar'
import { useReactionsToolbar } from '@/features/reactions/hooks/useReactionsToolbar'
import { NotificationProvider } from '@/features/notifications/NotificationProvider'
import { ConnectionStateToast } from '@livekit/components-react'
const Container = styled('div', {
base: {
position: 'relative',
width: '100%',
height: '100%',
display: 'grid',
gridTemplateRows: 'minmax(0, 1fr) auto auto',
backgroundColor: 'primaryDark.50',
transition: 'padding .5s cubic-bezier(0.4,0,0.2,1) 5ms',
// Disable LiveKit's own border-radius on tiles so our containers
// (GridCell, Thumbnail, StageFrame) own the clipping exclusively.
'--lk-border-radius': '4px',
'& .lk-participant-tile': {
height: '100%',
},
'& .lk-participant-media': {
height: '100%',
},
'& .lk-participant-media-video': {
height: '100%',
objectFit: 'cover',
},
'& .lk-grid-layout': {
height: '100%',
width: '100%',
},
},
variants: {
isReactionToolbarOpen: {
true: {
paddingBottom:
'calc(var(--sizes-room-reaction-toolbar-height) + var(--sizes-room-control-bar) + 1.125rem)',
},
false: {
paddingBottom: 'var(--sizes-room-control-bar)',
},
},
},
})
const ConnectionStateWrapper = styled('div', {
base: {
position: 'fixed',
top: 0,
left: 0,
right: 0,
zIndex: 1000,
pointerEvents: 'none',
'& > *': { pointerEvents: 'auto' },
},
})
export const PipView = () => {
const { isOpen: isReactionToolbarOpen } = useReactionsToolbar()
return (
<Container isReactionToolbarOpen={isReactionToolbarOpen}>
<ConnectionStateWrapper>
<ConnectionStateToast />
</ConnectionStateWrapper>
<PipStage />
<ReactionsToolbar adjustedCentering={false} />
<PipControlBar showScreenShare={false} />
<PipFloatingReactions />
<NotificationProvider bottom={30} />
</Container>
)
}
@@ -0,0 +1,129 @@
import { useEffect, useRef, useState } from 'react'
import { RiMoreFill } from '@remixicon/react'
import { FocusScope } from '@react-aria/focus'
import { Box, Button } from '@/primitives'
import { css } from '@/styled-system/css'
import { useTranslation } from 'react-i18next'
import { PipOptionsMenuItems } from './PipOptionsMenuItems'
import { useDismissOnEscape } from '../../hooks/useDismissOnEscape'
import { CollapsibleControl } from '@/features/pip/components/PipControlBar'
type PipOptionsMenuProps = {
overflowControls: Set<CollapsibleControl>
}
/**
* PiP-native options menu.
*
* Why not use the shared `<Menu>` primitive (React Aria's MenuTrigger +
* Popover)?
*
* React Aria positions popovers by reading `window.innerWidth` and
* `window.innerHeight` to know where the viewport edges are. The problem:
* it reads them from the *module-global* `window`, which is always the
* main browser window — even when the trigger button lives inside the
* Picture-in-Picture window (a separate `document` with its own, smaller
* viewport). React Aria therefore thinks it has the full main-window
* space to work with, and places the popover using those coordinates.
* The result is a menu that appears off-screen, clipped, or in the wrong
* corner of the PiP.
*
* The same single-document assumption breaks focus: React Aria's focus
* management and outside-click detection listen on the main `document`,
* so when the user interacts in the PiP, the menu doesn't receive focus
* on open, Escape doesn't restore focus to the trigger, and clicking
* outside doesn't dismiss it.
*
* These aren't bugs we can fix from the outside — the `window` and
* `document` references are baked into React Aria internals, with no
* prop or context to override them.
*
* So in PiP we replace the primitive with this component.
*/
export const PipOptionsMenu = ({ overflowControls }: PipOptionsMenuProps) => {
const { t } = useTranslation('rooms')
const wrapperRef = useRef<HTMLDivElement>(null)
const triggerRef = useRef<HTMLButtonElement>(null)
const [isOpen, setIsOpen] = useState(false)
const label = t('options.buttonLabel')
useDismissOnEscape(wrapperRef, isOpen, () => {
setIsOpen(false)
requestAnimationFrame(() => triggerRef.current?.focus())
})
useEffect(() => {
if (!isOpen) return
const doc = wrapperRef.current?.ownerDocument ?? document
const handleMenuItemClick = (event: MouseEvent) => {
const target = event.target as HTMLElement | null
const wrapper = wrapperRef.current
if (!wrapper || !target) return
if (wrapper.querySelector('button')?.contains(target)) return
if (target.closest('[role="menuitem"]')) {
requestAnimationFrame(() => {
setIsOpen(false)
triggerRef.current?.focus()
})
}
}
const handleOutsideClick = (event: MouseEvent) => {
const target = event.target as HTMLElement | null
const wrapper = wrapperRef.current
if (!wrapper || !target) return
if (wrapper.contains(target)) return
setIsOpen(false)
}
doc.addEventListener('click', handleMenuItemClick, true)
doc.addEventListener('mousedown', handleOutsideClick, true)
return () => {
doc.removeEventListener('click', handleMenuItemClick, true)
doc.removeEventListener('mousedown', handleOutsideClick, true)
}
}, [isOpen])
return (
<div
ref={wrapperRef}
className={css({
position: 'relative',
})}
>
<Button
ref={triggerRef}
id="room-options-trigger"
square
variant="primaryDark"
aria-label={label}
aria-haspopup="menu"
aria-expanded={isOpen}
tooltip={label}
onPress={() => setIsOpen(!isOpen)}
>
<RiMoreFill />
</Button>
{isOpen && (
<div
className={css({
position: 'absolute',
left: '50%',
bottom: 'calc(100% + 0.85rem)',
transform: 'translateX(-50%)',
zIndex: 10,
})}
>
{/* eslint-disable-next-line jsx-a11y/no-autofocus */}
<FocusScope autoFocus>
<Box size="sm" type="popover" variant="dark">
<PipOptionsMenuItems overflowControls={overflowControls} />
</Box>
</FocusScope>
</div>
)}
</div>
)
}
@@ -0,0 +1,71 @@
import { Menu as RACMenu, MenuItem } from 'react-aria-components'
import { PictureInPictureMenuItem } from '@/features/rooms/livekit/components/controls/Options/PictureInPictureMenuItem'
import { CollapsibleControl, CollapsibleControls } from '../PipControlBar'
import { RiArrowUpLine, RiEmotionLine, RiHand } from '@remixicon/react'
import { menuRecipe } from '@/primitives/menuRecipe.ts'
import { useReactionsToolbar } from '@/features/reactions/hooks/useReactionsToolbar'
import { useRoomContext, useTrackToggle } from '@livekit/components-react'
import { useRaisedHand } from '@/features/rooms/livekit/hooks/useRaisedHand'
import { useTranslation } from 'react-i18next'
import { Track } from 'livekit-client'
type PipOverflowItemsProps = {
overflowControls: Set<CollapsibleControl>
}
export const PipOptionsMenuItems = ({
overflowControls,
}: PipOverflowItemsProps) => {
const { t } = useTranslation('rooms')
const room = useRoomContext()
const { isHandRaised, toggleRaisedHand } = useRaisedHand({
participant: room.localParticipant,
})
const { buttonProps: screenShareProps, enabled: isScreenSharing } =
useTrackToggle({
source: Track.Source.ScreenShare,
captureOptions: { audio: true, selfBrowserSurface: 'include' },
})
const { toggle: toggleReactions } = useReactionsToolbar()
const itemClass = menuRecipe({ icon: true, variant: 'dark' }).item
return (
<RACMenu
style={{
minWidth: '150px',
width: '300px',
}}
>
<PictureInPictureMenuItem />
{overflowControls.has(CollapsibleControls.REACTIONS) && (
<MenuItem onAction={toggleReactions} className={itemClass}>
<RiEmotionLine size={20} />
{t('controls.reactions.button')}
</MenuItem>
)}
{overflowControls.has(CollapsibleControls.SCREEN_SHARE) && (
<MenuItem
onAction={() =>
screenShareProps.onClick?.(
{} as React.MouseEvent<HTMLButtonElement>
)
}
className={itemClass}
>
<RiArrowUpLine size={20} />
{t(
isScreenSharing
? 'controls.screenShare.stop'
: 'controls.screenShare.start'
)}
</MenuItem>
)}
{overflowControls.has(CollapsibleControls.HAND) && (
<MenuItem onAction={toggleRaisedHand} className={itemClass}>
<RiHand size={20} />
{isHandRaised ? t('controls.hand.lower') : t('controls.hand.raise')}
</MenuItem>
)}
</RACMenu>
)
}
@@ -0,0 +1,81 @@
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 PipFocusLayoutProps = {
mainTrack?: TrackReferenceOrPlaceholder
thumbnailTrack?: TrackReferenceOrPlaceholder
}
/**
* Focus layout used when 1-2 tracks are visible in the PiP window.
*
* The main tile is letterboxed (object-fit: contain) so the camera is
* never stretched to a non-video aspect and leaves dark padding
* above/below when the window shape doesn't match the source.
* The thumbnail keeps the usual cover fill.
*/
export const PipFocusLayout = memo(
({ mainTrack, thumbnailTrack }: PipFocusLayoutProps) => {
return (
<FocusContainer>
{mainTrack && (
<MainSlot>
<ParticipantTile
key={getTrackKey(mainTrack)}
trackRef={mainTrack}
/>
</MainSlot>
)}
{thumbnailTrack && (
<Thumbnail>
<ParticipantTile
key={getTrackKey(thumbnailTrack)}
trackRef={thumbnailTrack}
/>
</Thumbnail>
)}
</FocusContainer>
)
}
)
PipFocusLayout.displayName = 'PipFocusLayout'
const FocusContainer = styled('div', {
base: {
position: 'relative',
width: '100%',
height: '100%',
borderRadius: '4px',
overflow: 'hidden',
backgroundColor: 'primaryDark.100',
},
})
const MainSlot = styled('div', {
base: {
width: '100%',
height: '100%',
'& .lk-participant-media-video': {
objectFit: 'contain',
},
},
})
const Thumbnail = styled('div', {
base: {
position: 'absolute',
right: '1rem',
bottom: '1rem',
width: '42%',
maxWidth: '220px',
minWidth: '140px',
aspectRatio: '16 / 9',
borderRadius: '4px',
overflow: 'hidden',
boxShadow: 'md',
zIndex: 2,
},
})
@@ -0,0 +1,76 @@
import { memo, useMemo, useRef } from 'react'
import type { TrackReferenceOrPlaceholder } from '@livekit/components-core'
import { styled } from '@/styled-system/jsx'
import { ParticipantTile } from '@/features/rooms/livekit/components/ParticipantTile'
import { usePipElementSize } from '../../hooks/usePipElementSize'
import { computePipGridLayout } from '../../utils/pipGrid'
import { getTrackKey } from '../../utils/pipTrackSelection'
type PipGridLayoutProps = {
tracks: TrackReferenceOrPlaceholder[]
}
/**
* Adaptive grid used when 3+ tracks are visible in the PiP window.
*
* All grid math (shape choice + partial-row stretching) is delegated to
* `computePipGridLayout`. This component only measures the container,
* applies the returned placements, and plays a FLIP animation when the
* tile set or grid shape changes (participant joins/leaves or shape shift).
*
* Tiles keep a stable key so resizing never remounts <video> elements.
*/
export const PipGridLayout = memo(({ tracks }: PipGridLayoutProps) => {
const containerRef = useRef<HTMLDivElement>(null)
const { width, height } = usePipElementSize(containerRef)
const { rows, subColumns, placements } = useMemo(
() => computePipGridLayout(tracks.length, width, height),
[tracks.length, width, height]
)
const gridStyle = useMemo(
() => ({
gridTemplateColumns: `repeat(${subColumns}, minmax(0, 1fr))`,
gridTemplateRows: `repeat(${rows}, minmax(0, 1fr))`,
}),
[subColumns, rows]
)
return (
<GridContainer ref={containerRef} style={gridStyle}>
{tracks.map((track, index) => (
<GridCell key={getTrackKey(track)} style={placements[index]}>
<ParticipantTile trackRef={track} />
</GridCell>
))}
</GridContainer>
)
})
PipGridLayout.displayName = 'PipGridLayout'
const GridContainer = styled('div', {
base: {
width: '100%',
height: '100%',
display: 'grid',
gap: '0.25rem',
},
})
const GridCell = styled('div', {
base: {
position: 'relative',
minWidth: 0,
minHeight: 0,
borderRadius: '4px',
overflow: 'hidden',
backgroundColor: 'primaryDark.100',
// Paint on own layer so FLIP transforms don't trigger layout thrash.
willChange: 'transform',
'& .lk-participant-tile': {
width: '100%',
height: '100%',
},
},
})
@@ -0,0 +1,77 @@
import { useMemo } from 'react'
import { useTracks } from '@livekit/components-react'
import { RoomEvent, Track } from 'livekit-client'
import { PipFocusLayout } from './PipFocusLayout'
import { PipGridLayout } from './PipGridLayout'
import { StageFrame } from './StageFrame'
import {
isTrackReference,
TrackReferenceOrPlaceholder,
} from '@livekit/components-core'
/**
* PipStage picks between two layouts based on track count:
* - Focus mode (≤ 2 tracks): one main track + one thumbnail overlay.
* - Grid mode (3+ tracks): adaptive tiling.
*/
export const PipStage = () => {
const tracks = useTracks(
[
{ source: Track.Source.Camera, withPlaceholder: true },
{ source: Track.Source.ScreenShare, withPlaceholder: false },
],
{ updateOnlyOn: [RoomEvent.ActiveSpeakersChanged], onlySubscribed: false }
)
const screenShareTrack = useMemo(() => {
return tracks
.filter((track) => isTrackReference(track))
.find((track) => track.publication.source === Track.Source.ScreenShare)
}, [tracks])
const cameraTracks = useMemo(
() =>
tracks.filter(
(track: TrackReferenceOrPlaceholder) =>
track.source === Track.Source.Camera
),
[tracks]
)
if (tracks.length === 0) return null
/**
* The focus layout shows one main track with one thumbnail overlay,
* so it can only fit 2 tracks. Beyond that we switch to the grid.
*/
if (tracks.length > 2) {
// Grid mode: 3+ tracks. Screen share goes first so it leads the grid.
const gridTracks = screenShareTrack
? [screenShareTrack, ...cameraTracks]
: cameraTracks
return (
<StageFrame>
<PipGridLayout tracks={gridTracks} />
</StageFrame>
)
}
const localCameraTrack = cameraTracks.find(
(track) => track.participant?.isLocal
)
const remoteCameraTrack = cameraTracks.find(
(track) => !track.participant?.isLocal
)
const mainTrack = screenShareTrack ?? remoteCameraTrack ?? localCameraTrack
const thumbnailTrack =
mainTrack === localCameraTrack ? undefined : localCameraTrack
return (
<StageFrame>
<PipFocusLayout mainTrack={mainTrack} thumbnailTrack={thumbnailTrack} />
</StageFrame>
)
}
@@ -0,0 +1,25 @@
import { useTranslation } from 'react-i18next'
import { styled } from '@/styled-system/jsx'
export const StageFrame = ({ children }: { children: React.ReactNode }) => {
const { t } = useTranslation('rooms', {
keyPrefix: 'pictureInPicture',
})
return (
<Container role="region" aria-label={t('stage')} {...{ inert: '' }}>
{children}
</Container>
)
}
const Container = styled('div', {
base: {
position: 'relative',
minWidth: 0,
minHeight: 0,
marginLeft: '0.5rem',
marginRight: '0.5rem',
borderRadius: '4px',
overflow: 'hidden',
},
})
@@ -0,0 +1,28 @@
import { useEffect, useRef, type RefObject } from 'react'
export const useDismissOnEscape = (
ref: RefObject<HTMLElement | null>,
isActive: boolean,
onDismiss: () => void
) => {
const latestOnDismiss = useRef(onDismiss)
useEffect(() => {
latestOnDismiss.current = onDismiss
})
useEffect(() => {
if (!isActive) return
const el = ref.current
if (!el) return
const handler = (event: KeyboardEvent) => {
if (event.key !== 'Escape' || event.defaultPrevented) return
event.preventDefault()
event.stopPropagation()
latestOnDismiss.current()
}
el.addEventListener('keydown', handler)
return () => el.removeEventListener('keydown', handler)
}, [ref, isActive])
}
@@ -0,0 +1,116 @@
import { ref, useSnapshot } from 'valtio'
import { useCallback, useMemo } from 'react'
import { documentPictureInPictureStore } from '@/stores/documentPictureInPicture'
import { useTranslation } from 'react-i18next'
export const IS_PIP_SUPPORTED =
typeof globalThis !== 'undefined' && 'documentPictureInPicture' in globalThis
export const usePictureInPicture = () => {
const { t } = useTranslation('rooms', {
keyPrefix: 'pictureInPicture',
})
const { window: pipWindowRef } = useSnapshot(documentPictureInPictureStore)
const isOpen = useMemo(() => {
return !!pipWindowRef && !pipWindowRef.closed
}, [pipWindowRef])
const syncStyles = useCallback((pipWindow: Window) => {
document.head
.querySelectorAll('link[rel="stylesheet"], style')
.forEach((node) => {
pipWindow.document.head.appendChild(node.cloneNode(true))
})
pipWindow.document.documentElement.className =
document.documentElement.className
pipWindow.document.documentElement.style.cssText =
document.documentElement.style.cssText
const theme = document.documentElement.dataset.lkTheme
if (theme) {
pipWindow.document.documentElement.dataset.lkTheme = theme
}
}, [])
const initializePortalContainer = useCallback((pipWindow: Window) => {
const existing = pipWindow.document.getElementById('root')
if (existing) return existing
const newContainer = pipWindow.document.createElement('div')
newContainer.id = 'root'
newContainer.style.width = '100%'
newContainer.style.height = '100%'
pipWindow.document.body.appendChild(newContainer)
}, [])
const initializeTitleAndLanguage = useCallback(
(pipWindow: Window, title: string) => {
const parentLang = document?.documentElement.lang || 'en'
pipWindow.document.documentElement.setAttribute('lang', parentLang)
pipWindow.document.title = title
},
[]
)
const open = useCallback(
async (width = 400, height = 480) => {
if (!IS_PIP_SUPPORTED) return null
if (isOpen) return null
try {
const pipWindow =
await // eslint-disable-next-line @typescript-eslint/no-explicit-any
(window as any).documentPictureInPicture.requestWindow({
width,
height,
})
initializeTitleAndLanguage(pipWindow, t('title'))
initializePortalContainer(pipWindow)
syncStyles(pipWindow)
const cleanUp = () => {
if (documentPictureInPictureStore.window === pipWindow) {
documentPictureInPictureStore.window = null
}
}
pipWindow.addEventListener('pagehide', () => cleanUp(), { once: true })
pipWindow.addEventListener('beforeunload', () => cleanUp(), {
once: true,
})
documentPictureInPictureStore.window = ref(pipWindow)
} catch (error) {
// Avoid unhandled rejections if the user blocks or closes the request.
console.error('Failed to open Picture-in-Picture window', error)
return null
}
},
[
initializePortalContainer,
initializeTitleAndLanguage,
isOpen,
syncStyles,
t,
]
)
const close = useCallback(() => {
documentPictureInPictureStore.window?.close()
documentPictureInPictureStore.window = null
}, [])
const toggle = useCallback(async () => {
if (isOpen) close()
else await open()
}, [isOpen, close, open])
return {
isSupported: IS_PIP_SUPPORTED,
isOpen,
open,
close,
toggle,
}
}
@@ -0,0 +1,42 @@
import { useCallback, useEffect, useState, type RefObject } from 'react'
type Size = { width: number; height: number }
/**
* Observes an element's size, even when mounted in the PiP document.
* Resolves `ResizeObserver` from the element's own window.
*/
export const usePipElementSize = <T extends HTMLElement>(
ref: RefObject<T | null>
): Size => {
const [size, setSize] = useState<Size>({ width: 0, height: 0 })
const measure = useCallback(() => {
const el = ref.current
if (!el) return
const rect = el.getBoundingClientRect()
setSize({ width: rect.width, height: rect.height })
}, [ref])
useEffect(() => {
const el = ref.current
if (!el) return
measure()
const RO =
el.ownerDocument.defaultView?.ResizeObserver ?? globalThis.ResizeObserver
if (!RO) return
const observer = new RO((entries) => {
const entry = entries[0]
if (!entry) return
const { width, height } = entry.contentRect
setSize({ width, height })
})
observer.observe(el)
return () => observer.disconnect()
}, [ref, measure])
return size
}
@@ -0,0 +1,114 @@
export type PipTilePlacement = {
gridColumn: string
gridRow: number
}
export type PipGridLayout = {
cols: number
rows: number
/** Number of CSS sub-columns; use as `repeat(subColumns, 1fr)`. */
subColumns: number
/** One entry per tile, in input order. */
placements: PipTilePlacement[]
}
/**
* Target tile aspect ratio used to score candidate grid shapes.
*
* Video sources are 16:9, but picking 16:9 as the target makes the
* scorer indifferent between a stretched 2-col slab (aspect ~2.7) and a
* squarer 3-col tile (aspect ~1.2) because log distance is symmetric.
* The UI works better with square, face-friendly tiles. This target keeps
* wide windows from collapsing to 2 columns with short, stretched rows
* and pushes the scorer to add a column instead.
*/
const TARGET_TILE_ASPECT = 1
/**
* Smallest count from which we force at least two columns.
* For 1-3 participants it is acceptable to stack vertically in tall
* windows, but from 4 people onwards we keep >=2 columns to
* avoid endless vertical scrolling; the scorer handles the rest.
*/
const FORCE_TWO_COLS_COUNT = 4
const pickGridShape = (
count: number,
width: number,
height: number
): { cols: number; rows: number } => {
if (count <= 1) return { cols: 1, rows: Math.max(1, count) }
if (width <= 0 || height <= 0) return { cols: count, rows: 1 }
const minCols = count >= FORCE_TWO_COLS_COUNT ? 2 : 1
let best = {
cols: minCols,
rows: Math.ceil(count / minCols),
score: -Infinity,
}
for (let cols = minCols; cols <= count; cols++) {
const rows = Math.ceil(count / cols)
const tileW = width / cols
const tileH = height / rows
if (tileW <= 0 || tileH <= 0) continue
// Score: aspect close to target, few empty cells, large tile area,
// and a tiny bias toward fewer rows so ties (perfectly square shapes)
// resolve in favour of a shorter, wider grid.
const aspectScore = -Math.abs(Math.log(tileW / tileH / TARGET_TILE_ASPECT))
const emptyCells = cols * rows - count
const fillScore = -emptyCells * 0.1
const areaScore = Math.log(tileW * tileH) * 0.5
const rowsPenalty = -rows * 0.01
const score = aspectScore * 2 + fillScore + areaScore + rowsPenalty
if (score > best.score) best = { cols, rows, score }
}
return { cols: best.cols, rows: best.rows }
}
/**
* Pure function. Given a tile count and stage dimensions, returns the CSS
* grid layout for the PiP stage:
*
* - picks a cols x rows shape close to 16:9 tiles,
* - stretches any partial last row so its tiles share the full row width
* (no empty cells, no small centered tile).
*
* Callers consume the result directly: `subColumns` feeds
* `grid-template-columns: repeat(N, 1fr)` and each tile reads its own
* `gridColumn`/`gridRow` from `placements`.
*/
export const computePipGridLayout = (
count: number,
width: number,
height: number
): PipGridLayout => {
if (count <= 0) {
return { cols: 1, rows: 1, subColumns: 1, placements: [] }
}
const { cols, rows } = pickGridShape(count, width, height)
const tilesInLastRow = count - cols * (rows - 1)
const hasPartialRow = tilesInLastRow > 0 && tilesInLastRow < cols
const subColumns = hasPartialRow ? cols * tilesInLastRow : cols
const fullRowSpan = hasPartialRow ? tilesInLastRow : 1
const lastRowSpan = hasPartialRow ? cols : 1
const placements: PipTilePlacement[] = []
for (let i = 0; i < count; i++) {
const row = Math.floor(i / cols)
const colIndex = i % cols
const isLastRow = row === rows - 1 && hasPartialRow
const span = isLastRow ? lastRowSpan : fullRowSpan
const colStart = colIndex * span + 1
placements.push({
gridColumn: `${colStart} / span ${span}`,
gridRow: row + 1,
})
}
return { cols, rows, subColumns, placements }
}
@@ -0,0 +1,16 @@
import {
isTrackReference,
TrackReferenceOrPlaceholder,
} from '@livekit/components-core'
/**
* Produces a stable React key for a track so resizes/reshuffles of the grid
* do not remount the underlying <video> element.
*/
export const getTrackKey = (track: TrackReferenceOrPlaceholder): string => {
const identity = track.participant?.identity ?? 'unknown'
if (isTrackReference(track)) {
return `${identity}::${track.source}::${track.publication.trackSid}`
}
return `${identity}::${track.source}::placeholder`
}
@@ -15,6 +15,8 @@ const focusReactionsToolbar = () => {
?.focus()
}
export const REACTIONS_TOGGLE_ID = 'reactions-toggle'
export const ReactionsToggle = () => {
const { t } = useTranslation('rooms', { keyPrefix: 'controls.reactions' })
@@ -35,7 +37,7 @@ export const ReactionsToggle = () => {
return (
<ToggleButton
id="reactions-toggle"
id={REACTIONS_TOGGLE_ID}
data-attr="reactions-toggle"
square
variant="primaryDark"
@@ -0,0 +1,227 @@
import {
useCallback,
useEffect,
useLayoutEffect,
useRef,
useState,
} from 'react'
import { styled } from '@/styled-system/jsx'
import { useReactionsToolbar } from '../../hooks/useReactionsToolbar'
import { useIsMobile } from '@/utils/useIsMobile'
import { useSize } from '@/features/rooms/livekit/hooks/useResizeObserver'
import { RiArrowLeftSLine, RiArrowRightSLine } from '@remixicon/react'
import { Button } from '@/primitives'
import { ReactionsKeyboardNavigation } from './ReactionsKeyboardNavigation'
import { FocusScope } from '@react-aria/focus'
import { CONTROL_BAR_REGION_ID } from '@/features/layout/components/ControlBarRegion'
import { REACTIONS_TOGGLE_ID } from '../ReactionsToggle'
const StyledContainer = styled('div', {
base: {
display: 'flex',
alignItems: 'center',
gap: '0.2rem',
borderRadius: '21px',
backgroundColor: 'primaryDark.100',
maxWidth: '100%',
opacity: 0,
transform: 'translateY(3.25rem)',
transition: 'opacity, transform',
transitionDuration: '0.5s',
transitionTimingFunction: 'cubic-bezier(0.4, 0, 0.2, 1)',
pointerEvents: 'none',
},
variants: {
isVisible: {
true: {
opacity: 1,
transform: 'translateY(0)',
pointerEvents: 'auto',
},
},
},
})
const StyledScrollViewport = styled('div', {
base: {
display: 'flex',
gap: '0.2rem',
overflowX: 'auto',
padding: '0.19rem',
scrollBehavior: 'smooth',
minWidth: 0,
flex: '1 1 auto',
scrollbarWidth: 'none',
'&::-webkit-scrollbar': { display: 'none' },
'& > *': {
flexShrink: 0,
},
},
})
const SCROLL_AMOUNT = 120 // roughly 3 buttons
export const ReactionButtonsContainer = ({
children,
adjustedCentering = true,
}: {
children: React.ReactNode
adjustedCentering?: boolean
}) => {
const { isOpen } = useReactionsToolbar()
const isMobile = useIsMobile()
const containerRef = useRef<HTMLDivElement>(null)
const scrollRef = useRef<HTMLDivElement>(null)
const { width } = useSize(scrollRef)
const [isVisible, setIsVisible] = useState(false)
const [overflowing, setOverflowing] = useState(false)
const [atStart, setAtStart] = useState(true)
const [atEnd, setAtEnd] = useState(false)
const [
shouldBeCenteredWithToggleButton,
setShouldBeCenteredWithToggleButton,
] = useState(false)
const [rightOffset, setRightOffset] = useState(0)
const updateArrows = useCallback(() => {
const el = scrollRef.current
if (!el) return
setOverflowing(el.scrollWidth > el.clientWidth + 1)
setAtStart(el.scrollLeft <= 0)
setAtEnd(el.scrollLeft + el.clientWidth >= el.scrollWidth - 1)
}, [])
useEffect(() => {
if (isMobile || !adjustedCentering) return
const region = document.getElementById(CONTROL_BAR_REGION_ID)
if (!region) return
const check = () => {
setShouldBeCenteredWithToggleButton(
window.innerWidth > region.clientWidth
)
}
check()
const ro = new ResizeObserver(check)
ro.observe(region)
window.addEventListener('resize', check)
return () => {
ro.disconnect()
window.removeEventListener('resize', check)
}
}, [isMobile, adjustedCentering])
useLayoutEffect(() => {
if (!shouldBeCenteredWithToggleButton || isMobile) {
setRightOffset(0)
return
}
const container = containerRef.current
if (!container) return
let frame = 0
const align = () => {
const toggle = document.getElementById(REACTIONS_TOGGLE_ID)
if (!toggle) return
const toggleRect = toggle.getBoundingClientRect()
const containerRect = container.getBoundingClientRect()
const toggleCenterX = toggleRect.left + toggleRect.width / 2
const containerCenterX = containerRect.left + containerRect.width / 2
const shift = toggleCenterX - containerCenterX
if (Math.abs(shift) < 0.5) return
setRightOffset((prev) => prev - shift * 2)
}
const schedule = () => {
cancelAnimationFrame(frame)
frame = requestAnimationFrame(align)
}
schedule()
const ro = new ResizeObserver(schedule)
ro.observe(container)
const region = document.getElementById(CONTROL_BAR_REGION_ID)
if (region) ro.observe(region)
const toggle = document.getElementById(REACTIONS_TOGGLE_ID)
if (toggle) ro.observe(toggle)
window.addEventListener('resize', schedule)
return () => {
cancelAnimationFrame(frame)
ro.disconnect()
window.removeEventListener('resize', schedule)
}
}, [shouldBeCenteredWithToggleButton, isMobile, isOpen])
useEffect(() => {
if (isOpen) {
const id = requestAnimationFrame(() => setIsVisible(true))
return () => cancelAnimationFrame(id)
}
setIsVisible(false)
}, [isOpen])
useEffect(() => {
updateArrows()
}, [width, updateArrows])
const scrollBy = (delta: number) => {
scrollRef.current?.scrollBy({ left: delta, behavior: 'smooth' })
}
return (
<StyledContainer
ref={containerRef}
aria-hidden={!isOpen}
isVisible={isVisible}
style={
shouldBeCenteredWithToggleButton && !isMobile && adjustedCentering
? { marginRight: `${rightOffset}px` }
: { margin: '0 15px' }
}
>
{overflowing && (
<div aria-hidden="true">
<Button
onPress={() => scrollBy(-SCROLL_AMOUNT)}
variant="primaryTextDark"
size="sm"
isDisabled={atStart}
round
excludeFromTabOrder
>
<RiArrowLeftSLine />
</Button>
</div>
)}
{/* eslint-disable-next-line jsx-a11y/no-autofocus*/}
<FocusScope autoFocus>
<ReactionsKeyboardNavigation>
<StyledScrollViewport ref={scrollRef} onScroll={updateArrows}>
{children}
</StyledScrollViewport>
</ReactionsKeyboardNavigation>
</FocusScope>
{overflowing && (
<div aria-hidden="true">
<Button
onPress={() => scrollBy(SCROLL_AMOUNT)}
variant="primaryTextDark"
size="sm"
isDisabled={atEnd}
round
excludeFromTabOrder
>
<RiArrowRightSLine />
</Button>
</div>
)}
</StyledContainer>
)
}
@@ -0,0 +1,63 @@
import { useTranslation } from 'react-i18next'
import { useFocusManager } from '@react-aria/focus'
import { getFirstControlBarFocusable } from '@/utils/dom'
import { REACTIONS_TOOLBAR_ID } from '../../constants'
import { useReactionsToolbar } from '../../hooks/useReactionsToolbar'
type Props = {
children: React.ReactNode
toggleId?: string
controlBarId?: string
}
export const ReactionsKeyboardNavigation = ({
children,
toggleId = 'reactions-toggle',
controlBarId = 'control-bar',
}: Props) => {
const { t } = useTranslation('rooms', { keyPrefix: 'controls.reactions' })
const focusManager = useFocusManager()
const { close } = useReactionsToolbar()
const onFocus = (e: React.FocusEvent<HTMLDivElement>) => {
if (!e.target.matches(':focus-visible')) return
const comingFromOutside = !e.currentTarget.contains(e.relatedTarget)
if (comingFromOutside) {
focusManager?.focusFirst()
}
}
const onKeyDown = (e: React.KeyboardEvent<HTMLDivElement>) => {
switch (e.key) {
case 'ArrowRight':
focusManager?.focusNext({ wrap: true })
break
case 'ArrowLeft':
focusManager?.focusPrevious({ wrap: true })
break
case 'Escape':
e.preventDefault()
document.getElementById(toggleId)?.focus()
close()
break
case 'Tab':
if (!e.shiftKey) {
e.preventDefault()
getFirstControlBarFocusable(controlBarId)?.focus()
}
break
}
}
return (
<div
id={REACTIONS_TOOLBAR_ID}
role="toolbar"
aria-label={t('toolbar')}
onKeyDown={onKeyDown}
onFocus={onFocus}
>
{children}
</div>
)
}
@@ -1,15 +1,9 @@
import { FocusScope, useFocusManager } from '@react-aria/focus'
import { REACTIONS_TOOLBAR_ID } from '../../constants'
import { useReactionsToolbar } from '../../hooks/useReactionsToolbar'
import { ReactionButton } from './ReactionButton'
import { Emoji } from '../../types'
import { styled } from '@/styled-system/jsx'
import { layoutStore } from '@/stores/layout'
import { getFirstControlBarFocusable } from '@/utils/dom'
import { useIsMobile } from '@/utils/useIsMobile'
import { useEffect, useRef, useState } from 'react'
import { useDelayUnmount } from '@/hooks/useDelayUnmount'
import { useTranslation } from 'react-i18next'
import { ReactionButtonsContainer } from './ReactionButtonsContainer'
const Container = styled('div', {
base: {
@@ -23,116 +17,11 @@ const Container = styled('div', {
},
})
const StyledStrip = styled('div', {
base: {
display: 'flex',
gap: '0.2rem',
borderRadius: '21px',
padding: '0.15rem',
backgroundColor: 'primaryDark.100',
opacity: 0,
transform: 'translateY(3.25rem)',
transition: 'opacity, transform',
transitionDuration: '0.5s',
transitionTimingFunction: 'cubic-bezier(0.4, 0, 0.2, 1)',
pointerEvents: 'none',
},
variants: {
isVisible: {
true: {
opacity: 1,
transform: 'translateY(0)',
pointerEvents: 'auto',
},
},
desktopOffset: {
true: {
// Ideally this value should be calculated dynamically in JavaScript to keep
// the reaction toolbar perfectly centered relative to the reaction toggle.
// However, for simplicity and to follow a pragmatic 80/20 approach,
// this value is currently hardcoded in CSS.
marginRight: '30px',
},
},
},
})
const Strip = ({ children }: { children: React.ReactNode }) => {
const { isOpen } = useReactionsToolbar()
const isMobile = useIsMobile()
const ref = useRef<HTMLDivElement>(null)
const [isVisible, setIsVisible] = useState(false)
useEffect(() => {
if (isOpen) {
// defer one frame so the browser paints opacity:0 first
const id = requestAnimationFrame(() => setIsVisible(true))
return () => cancelAnimationFrame(id)
} else {
setIsVisible(false)
}
}, [isOpen])
return (
<StyledStrip
ref={ref}
aria-hidden={!isOpen}
isVisible={isVisible}
desktopOffset={!isMobile}
>
{children}
</StyledStrip>
)
}
const KeyboardNavigation = ({ children }: { children: React.ReactNode }) => {
const { t } = useTranslation('rooms', { keyPrefix: 'controls.reactions' })
const focusManager = useFocusManager()
const onFocus = (e: React.FocusEvent<HTMLDivElement>) => {
const comingFromOutside = !e.currentTarget.contains(e.relatedTarget)
if (comingFromOutside) {
focusManager?.focusFirst()
}
}
const onKeyDown = (e: React.KeyboardEvent<HTMLDivElement>) => {
switch (e.key) {
case 'ArrowRight':
focusManager?.focusNext({ wrap: true })
break
case 'ArrowLeft':
focusManager?.focusPrevious({ wrap: true })
break
case 'Escape':
e.preventDefault()
document.getElementById('reactions-toggle')?.focus()
layoutStore.showReactionsToolbar = false
break
case 'Tab':
if (!e.shiftKey) {
e.preventDefault()
getFirstControlBarFocusable('control-bar')?.focus()
}
break
}
}
return (
<div
id={REACTIONS_TOOLBAR_ID}
role="toolbar"
aria-label={t('toolbar')}
onKeyDown={onKeyDown}
onFocus={onFocus}
>
{children}
</div>
)
}
export const ReactionsToolbar = () => {
export const ReactionsToolbar = ({
adjustedCentering,
}: {
adjustedCentering?: boolean
}) => {
const { isOpen } = useReactionsToolbar()
const shouldMount = useDelayUnmount(isOpen, 300)
@@ -140,16 +29,11 @@ export const ReactionsToolbar = () => {
return (
<Container>
{/* eslint-disable-next-line jsx-a11y/no-autofocus*/}
<FocusScope autoFocus>
<KeyboardNavigation>
<Strip>
{Object.values(Emoji).map((emoji) => (
<ReactionButton key={emoji} emoji={emoji} />
))}
</Strip>
</KeyboardNavigation>
</FocusScope>
<ReactionButtonsContainer adjustedCentering={adjustedCentering}>
{Object.values(Emoji).map((emoji) => (
<ReactionButton key={emoji} emoji={emoji} />
))}
</ReactionButtonsContainer>
</Container>
)
}
@@ -9,5 +9,8 @@ export const useReactionsToolbar = () => {
toggle: () => {
layoutStore.showReactionsToolbar = !layoutSnap.showReactionsToolbar
},
close: () => {
layoutStore.showReactionsToolbar = false
},
}
}
@@ -1,3 +1,6 @@
import { Track } from 'livekit-client'
import Source = Track.Source
export type ApiLiveKit = {
url: string
room: string
@@ -10,6 +13,11 @@ export enum ApiAccessLevel {
RESTRICTED = 'restricted',
}
export type RoomConfiguration = {
can_publish_sources?: Source[] | null
everyone_can_mute?: boolean | null
}
export type ApiRoom = {
id: string
name: string
@@ -18,7 +26,5 @@ export type ApiRoom = {
is_administrable: boolean
access_level: ApiAccessLevel
livekit?: ApiLiveKit
configuration?: {
[key: string]: string | number | boolean | string[]
}
configuration?: RoomConfiguration
}
@@ -6,44 +6,74 @@ import {
NotificationType,
} from '@/features/notifications'
import { fetchApi } from '@/api/fetchApi'
import { useIsAdminOrOwner } from '../livekit/hooks/useIsAdminOrOwner'
import { useCallback } from 'react'
export const useMuteParticipant = () => {
const data = useRoomData()
const apiRoomData = useRoomData()
const { notifyParticipants } = useNotifyParticipants()
const isAdminOrOwner = useIsAdminOrOwner()
const muteParticipant = async (participant: Participant) => {
if (!data?.id) {
throw new Error('Room id is not available')
}
const trackSid = participant.getTrackPublication(
Source.Microphone
)?.trackSid
const muteParticipant = useCallback(
async (participant: Participant) => {
if (!apiRoomData?.livekit?.room) {
throw new Error('Room id is not available')
}
if (!trackSid) {
return
}
const trackSid = participant.getTrackPublication(
Source.Microphone
)?.trackSid
try {
const response = await fetchApi(`rooms/${data.id}/mute-participant/`, {
method: 'POST',
body: JSON.stringify({
participant_identity: participant.identity,
track_sid: trackSid,
}),
})
if (!trackSid) {
return
}
await notifyParticipants({
type: NotificationType.ParticipantMuted,
destinationIdentities: [participant.identity],
})
// Guard against undefined token for non-admin users
if (!isAdminOrOwner && !apiRoomData.livekit.token) {
console.error('Cannot mute participant: missing auth token')
return
}
const headers = !isAdminOrOwner
? { Authorization: `Bearer ${apiRoomData.livekit.token}` }
: undefined
let response
try {
response = await fetchApi(
`rooms/${apiRoomData.livekit.room}/mute-participant/`,
{
method: 'POST',
headers,
body: JSON.stringify({
participant_identity: participant.identity,
track_sid: trackSid,
}),
}
)
} catch (error) {
console.error(
`Failed to mute participant ${participant.identity}: ${error instanceof Error ? error.message : 'Unknown error'}`
)
return
}
try {
await notifyParticipants({
type: NotificationType.ParticipantMuted,
destinationIdentities: [participant.identity],
})
} catch (e) {
console.error(
`Failed to notify muted participant ${participant.identity}: ${e}`
)
}
return response
} catch (error) {
console.error(
`Failed to mute participant ${participant.identity}: ${error instanceof Error ? error.message : 'Unknown error'}`
)
}
}
},
[apiRoomData, isAdminOrOwner, notifyParticipants]
)
return { muteParticipant }
}
@@ -8,7 +8,7 @@ export const useParticipantPermissions = () => {
const updateParticipantPermissions = async (
participant: Participant,
sources: Array<Source>
sources: Source[]
) => {
if (!data?.id) {
throw new Error('Room id is not available')
@@ -20,7 +20,7 @@ export const useParticipantPermissions = () => {
can_update_metadata: participant.permissions?.canUpdateMetadata,
can_subscribe_metrics: participant.permissions?.canSubscribeMetrics,
can_publish: sources.length > 0,
can_publish_sources: sources.map((source) => source.toUpperCase()),
can_publish_sources: sources,
}
try {
@@ -32,6 +32,7 @@ import { isFireFox } from '@/utils/livekit'
import { useIsMobile } from '@/utils/useIsMobile'
import { navigateTo } from '@/navigation/navigateTo'
import { connectionObserverStore } from '@/stores/connectionObserver'
import { PictureInPictureConference } from '@/features/pip/components/PictureInPictureConference'
export const Conference = ({
roomId,
@@ -291,6 +292,7 @@ export const Conference = ({
{...mediaDeviceError}
onClose={() => setMediaDeviceError({ error: null, kind: null })}
/>
<PictureInPictureConference />
</LiveKitRoom>
</Screen>
</QueryAware>
@@ -9,7 +9,8 @@ import { queryClient } from '@/api/queryClient'
import { keys } from '@/api/queryKeys'
import { useQuery } from '@tanstack/react-query'
import { useParams } from 'wouter'
import { usePublishSourcesManager } from '@/features/rooms/livekit/hooks/usePublishSourcesManager'
import { usePublishSourcesManager } from '../hooks/usePublishSourcesManager'
import { usePermissionsManager } from '../hooks/usePermissionsManager'
export const Admin = () => {
const { t } = useTranslation('rooms', { keyPrefix: 'admin' })
@@ -38,6 +39,8 @@ export const Admin = () => {
isScreenShareEnabled,
} = usePublishSourcesManager()
const { toggleMuting, isMutingEnabled } = usePermissionsManager()
return (
<Div
display="flex"
@@ -130,6 +133,17 @@ export const Admin = () => {
fullWidth: true,
}}
/>
<Field
type="switch"
label={t('moderation.mute.label')}
description={t('moderation.mute.description')}
isSelected={isMutingEnabled}
onChange={toggleMuting}
wrapperProps={{
noMargin: true,
fullWidth: true,
}}
/>
</div>
</div>
<div
@@ -7,6 +7,7 @@ import { EffectsMenuItem } from './EffectsMenuItem'
import { SupportMenuItem } from './SupportMenuItem'
import { TranscriptMenuItem } from './TranscriptMenuItem'
import { ScreenRecordingMenuItem } from './ScreenRecordingMenuItem'
import { PictureInPictureMenuItem } from '@/features/rooms/livekit/components/controls/Options/PictureInPictureMenuItem'
// @todo try refactoring it to use MenuList component
export const OptionsMenuItems = () => {
@@ -18,6 +19,7 @@ export const OptionsMenuItems = () => {
}}
>
<MenuSection>
<PictureInPictureMenuItem />
<TranscriptMenuItem />
<ScreenRecordingMenuItem />
<FullScreenMenuItem />
@@ -0,0 +1,22 @@
import { RiPictureInPicture2Line } from '@remixicon/react'
import { MenuItem } from 'react-aria-components'
import { useTranslation } from 'react-i18next'
import { menuRecipe } from '@/primitives/menuRecipe'
import { usePictureInPicture } from '@/features/pip/hooks/usePictureInPicture'
export const PictureInPictureMenuItem = () => {
const { t } = useTranslation('rooms', { keyPrefix: 'options.items' })
const { toggle, isOpen, isSupported } = usePictureInPicture()
if (!isSupported) return null
return (
<MenuItem
className={menuRecipe({ icon: true, variant: 'dark' }).item}
onAction={toggle}
>
<RiPictureInPicture2Line size={20} />
{t(`pictureInPicture.${isOpen ? 'exit' : 'enter'}`)}
</MenuItem>
)
}
@@ -1,7 +1,13 @@
import { useIsAdminOrOwner } from './useIsAdminOrOwner'
import { Participant } from 'livekit-client'
import { useRoomData } from '@/features/rooms/livekit/hooks/useRoomData'
export const useCanMute = (participant: Participant) => {
const apiRoomData = useRoomData()
const isAdminOrOwner = useIsAdminOrOwner()
return participant.isLocal || isAdminOrOwner
return (
participant.isLocal ||
isAdminOrOwner ||
apiRoomData?.configuration?.everyone_can_mute !== false
)
}
@@ -0,0 +1,46 @@
import { usePatchRoom } from '@/features/rooms/api/patchRoom'
import { useRoomData } from '@/features/rooms/livekit/hooks/useRoomData'
import { useCallback } from 'react'
import { queryClient } from '@/api/queryClient'
import { keys } from '@/api/queryKeys'
export const usePermissionsManager = () => {
const { mutateAsync: patchRoom } = usePatchRoom()
const data = useRoomData()
const configuration = data?.configuration
const roomId = data?.slug
const isMutingEnabled = configuration?.everyone_can_mute ?? true
const toggleMuting = useCallback(
async (enabled: boolean) => {
if (!roomId) return
try {
const newConfiguration = {
...configuration,
everyone_can_mute: enabled,
}
const room = await patchRoom({
roomId,
room: { configuration: newConfiguration },
})
queryClient.setQueryData([keys.room, roomId], room)
return { configuration: newConfiguration }
} catch (error) {
console.error('Failed to update muting permission:', error)
return { success: false, error }
}
},
[configuration, roomId, patchRoom]
)
return {
toggleMuting,
isMutingEnabled,
}
}
@@ -39,10 +39,6 @@ export const usePublishSourcesManager = () => {
const { notifyParticipants } = useNotifyParticipants()
const defaultSources = configData?.livekit?.default_sources?.map((source) => {
return source as Source
})
// The name can be misleading—use the slug instead to ensure the correct React Query key is updated.
const roomId = data?.slug
@@ -54,16 +50,16 @@ export const usePublishSourcesManager = () => {
)
const currentSources = useMemo(() => {
const defaultSources = configData?.livekit?.default_sources ?? []
if (
configuration?.can_publish_sources == undefined ||
!Array.isArray(configuration?.can_publish_sources)
) {
return defaultSources
}
return configuration.can_publish_sources.map((source) => {
return source as Source
})
}, [defaultSources, configuration?.can_publish_sources])
return configuration.can_publish_sources
}, [configData, configuration?.can_publish_sources])
const updateSource = useCallback(
async (sources: Source[], enabled: boolean) => {
@@ -78,7 +74,7 @@ export const usePublishSourcesManager = () => {
const newConfiguration = {
...configuration,
can_publish_sources: newSources as string[],
can_publish_sources: newSources,
}
const room = await patchRoom({
@@ -0,0 +1,86 @@
// features/rooms/hooks/useSyncLiveKitMetadata.ts
import { useEffect } from 'react'
import { RoomEvent } from 'livekit-client'
import { queryClient } from '@/api/queryClient'
import { keys } from '@/api/queryKeys'
import {
ApiAccessLevel,
ApiRoom,
RoomConfiguration,
} from '@/features/rooms/api/ApiRoom'
import { useRoomContext } from '@livekit/components-react'
import { useRoomData } from './useRoomData'
/**
* Shape of the LiveKit room metadata blob pushed by the backend.
* Matches RoomManagement.update_metadata → {"configuration": room.configuration}
*/
type RoomLiveKitMetadata = {
configuration?: RoomConfiguration
access_level?: ApiAccessLevel
}
const parseMetadata = (raw: string | undefined): RoomLiveKitMetadata | null => {
if (!raw) return null
try {
return JSON.parse(raw) as RoomLiveKitMetadata
} catch {
console.warn('useSyncLiveKitMetadata: failed to parse room metadata')
return null
}
}
/**
* Sync LiveKit room metadata into the React Query cache.
*
* The backend pushes room configuration into LiveKit's room metadata
* whenever it changes. This hook listens for those changes and patches
* the ApiRoom cache so every `useRoomData()`
* consumer sees the fresh value automatically.
*
* Mount once, at the level where the LiveKit Room instance lives.
*/
export const useSyncLiveKitMetadata = () => {
const room = useRoomContext()
const roomData = useRoomData()
const roomSlug = roomData?.slug
useEffect(() => {
if (!room || !roomSlug) return
const applyMetadata = (raw: string | undefined) => {
const parsed = parseMetadata(raw)
if (!parsed) return
queryClient.setQueryData<ApiRoom>([keys.room, roomSlug], (prev) => {
if (!prev) return prev
const nextConfiguration = parsed.configuration ?? prev.configuration
const nextAccessLevel = parsed.access_level ?? prev.access_level
if (
nextConfiguration === prev.configuration &&
nextAccessLevel === prev.access_level
) {
return prev
}
return {
...prev,
configuration: nextConfiguration,
access_level: nextAccessLevel,
}
})
}
// Apply whatever metadata is currently set (covers the case where we
// joined the room AFTER the last metadata change, so no event will fire).
applyMetadata(room.metadata)
const handler = (raw: string) => applyMetadata(raw)
room.on(RoomEvent.RoomMetadataChanged, handler)
return () => {
room.off(RoomEvent.RoomMetadataChanged, handler)
}
}, [room, roomSlug])
}
@@ -26,6 +26,7 @@ import { AudioDevicesControl } from '../../components/controls/Device/AudioDevic
import { VideoDeviceControl } from '../../components/controls/Device/VideoDeviceControl'
import { useSettingsDialog } from '@/features/settings/hook/useSettingsDialog'
import { ControlBarRegion } from '@/features/layout/components/ControlBarRegion'
import { ReactionsToggle } from '@/features/reactions/components/ReactionsToggle'
export function MobileControlBar({
onDeviceError,
@@ -62,6 +63,7 @@ export function MobileControlBar({
}
hideMenu={true}
/>
<ReactionsToggle />
<HandToggle />
<Button
id="room-options-trigger"
@@ -32,6 +32,7 @@ import { useRegisterKeyboardShortcut } from '@/features/shortcuts/useRegisterKey
import { useSettingsDialog } from '@/features/settings'
import { SettingsDialogExtendedKey } from '@/features/settings/type'
import { useVideoResolutionSubscription } from '../hooks/useVideoResolutionSubscription'
import { useSyncLiveKitMetadata } from '../hooks/useSyncLiveKitMetadata'
import { SettingsDialogProvider } from '@/features/settings/components/SettingsDialogProvider'
import { IsIdleDisconnectModal } from '../components/IsIdleDisconnectModal'
import { getParticipantName } from '@/features/rooms/utils/getParticipantName'
@@ -40,6 +41,8 @@ import { ReactionPortals } from '@/features/reactions/components/ReactionPortals
import { CarouselLayout } from '@/features/layout/components/CarouselLayout'
import { GridLayout } from '@/features/layout/components/GridLayout'
import { RoomContentArea } from '@/features/layout/components/RoomContentArea'
import { usePictureInPicture } from '@/features/pip/hooks/usePictureInPicture'
import { PipRoomPlaceholder } from '@/features/pip/components/PipRoomPlaceholder'
/**
* @public
@@ -90,6 +93,7 @@ export function VideoConference({ ...props }: VideoConferenceProps) {
useConnectionObserver()
useRoomPageTitle()
useVideoResolutionSubscription()
useSyncLiveKitMetadata()
useRegisterKeyboardShortcut({
id: 'open-shortcuts',
@@ -119,6 +123,8 @@ export function VideoConference({ ...props }: VideoConferenceProps) {
(track) => !isEqualTrackRef(track, focusTrack)
)
const { isOpen: isPictureInPictureOpen } = usePictureInPicture()
// handle pin announcements
useEffect(() => {
@@ -248,32 +254,38 @@ export function VideoConference({ ...props }: VideoConferenceProps) {
/>
<IsIdleDisconnectModal />
<RoomContentArea>
{!focusTrack ? (
<div
className="lk-grid-layout-wrapper"
style={{ height: 'auto' }}
>
<GridLayout tracks={tracks} style={{ padding: 0 }}>
<ParticipantTile />
</GridLayout>
</div>
{isPictureInPictureOpen ? (
<PipRoomPlaceholder />
) : (
<div
className="lk-focus-layout-wrapper"
style={{ height: 'auto' }}
>
<FocusLayoutContainer style={{ padding: 0 }}>
<CarouselLayout
tracks={carouselTracks}
style={{
minWidth: '200px',
}}
<>
{!focusTrack ? (
<div
className="lk-grid-layout-wrapper"
style={{ height: 'auto' }}
>
<ParticipantTile />
</CarouselLayout>
{focusTrack && <FocusLayout trackRef={focusTrack} />}
</FocusLayoutContainer>
</div>
<GridLayout tracks={tracks} style={{ padding: 0 }}>
<ParticipantTile />
</GridLayout>
</div>
) : (
<div
className="lk-focus-layout-wrapper"
style={{ height: 'auto' }}
>
<FocusLayoutContainer style={{ padding: 0 }}>
<CarouselLayout
tracks={carouselTracks}
style={{
minWidth: '200px',
}}
>
<ParticipantTile />
</CarouselLayout>
{focusTrack && <FocusLayout trackRef={focusTrack} />}
</FocusLayoutContainer>
</div>
)}
</>
)}
</RoomContentArea>
<ControlBar
+18
View File
@@ -231,6 +231,16 @@
}
}
},
"pictureInPicture": {
"placeholder": {
"title": "Ihr Videoanruf befindet sich in einem anderen Fenster.",
"description": "Im Bild-im-Bild-Modus bleiben Sie mit dem Anruf verbunden, während Sie andere Aufgaben erledigen.",
"bringBack": "Anruf hierher zurückholen"
},
"stage": "Teilnehmer",
"controlBar": "Besprechungssteuerung",
"title": "Bild-im-Bild Besprechung"
},
"options": {
"buttonLabel": "Weitere Optionen",
"items": {
@@ -241,6 +251,10 @@
"username": "Deinen Namen aktualisieren",
"effects": "Effekte anwenden",
"switchCamera": "Kamera wechseln",
"pictureInPicture": {
"enter": "Bild-im-Bild",
"exit": "Bild-im-Bild schließen"
},
"fullscreen": {
"enter": "Vollbild",
"exit": "Vollbildmodus verlassen"
@@ -528,6 +542,10 @@
"screenshare": {
"label": "Bildschirm teilen",
"description": "Wenn du diese Option deaktivierst, können Teilnehmende ihren Bildschirm nicht mehr teilen. Laufende Bildschirmfreigaben werden sofort beendet."
},
"mute": {
"label": "Andere stummschalten",
"description": "Wenn deaktiviert, können Teilnehmer andere Teilnehmer nicht mehr stummschalten."
}
}
},
+18
View File
@@ -231,6 +231,16 @@
}
}
},
"pictureInPicture": {
"placeholder": {
"title": "Your video call is in another window.",
"description": "Picture-in-Picture mode allows you to stay connected to the call while performing other tasks.",
"bringBack": "Bring the call back here"
},
"stage": "Participants",
"controlBar": "Meeting controls",
"title": "Picture-in-picture meeting"
},
"options": {
"buttonLabel": "More Options",
"items": {
@@ -241,6 +251,10 @@
"username": "Update Your Name",
"effects": "Backgrounds and Effects",
"switchCamera": "Switch camera",
"pictureInPicture": {
"enter": "Picture-in-picture",
"exit": "Close picture-in-picture"
},
"fullscreen": {
"enter": "Fullscreen",
"exit": "Exit fullscreen mode"
@@ -527,6 +541,10 @@
"screenshare": {
"label": "Share their screen",
"description": "Disabling this option will prevent participants from sharing their screen, and any ongoing screen sharing will be stopped immediately."
},
"mute": {
"label": "Mute others",
"description": "When disabled, participants will no longer be able to mute other participants."
}
}
},
+18
View File
@@ -231,6 +231,16 @@
}
}
},
"pictureInPicture": {
"placeholder": {
"title": "Votre appel vidéo est dans une autre fenêtre.",
"description": "Le mode image dans l'image vous permet de rester connecté à l'appel tout en effectuant d'autres tâches.",
"bringBack": "Ramener l'appel ici"
},
"stage": "Participants",
"controlBar": "Commandes de la réunion",
"title": "Réunion en image dans l'image"
},
"options": {
"buttonLabel": "Plus d'options",
"items": {
@@ -241,6 +251,10 @@
"username": "Choisir votre nom",
"effects": "Arrière-plans et effets",
"switchCamera": "Changer de caméra",
"pictureInPicture": {
"enter": "Image dans l'image",
"exit": "Fermer l'image dans l'image"
},
"fullscreen": {
"enter": "Plein écran",
"exit": "Quitter le mode plein écran"
@@ -527,6 +541,10 @@
"screenshare": {
"label": "Partager leur écran",
"description": "En désactivant cette option, les participants ne pourront plus partager leur écran et tout partage en cours sera immédiatement interrompu."
},
"mute": {
"label": "Muter les autres",
"description": "En désactivant cette option, les participants ne pourront plus muter d'autres participants."
}
}
},
+18
View File
@@ -231,6 +231,16 @@
}
}
},
"pictureInPicture": {
"placeholder": {
"title": "Uw videogesprek bevindt zich in een ander venster.",
"description": "Met de beeld-in-beeld-modus kunt u verbonden blijven met het gesprek terwijl u andere taken uitvoert.",
"bringBack": "Gesprek hier terughalen"
},
"stage": "Deelnemers",
"controlBar": "Vergaderbesturing",
"title": "Beeld-in-beeld vergadering"
},
"options": {
"buttonLabel": "Meer opties",
"items": {
@@ -241,6 +251,10 @@
"username": "Verander uw naam",
"effects": "Pas effecten toe",
"switchCamera": "Selecteer camera",
"pictureInPicture": {
"enter": "Beeld-in-beeld",
"exit": "Beeld-in-beeld sluiten"
},
"fullscreen": {
"enter": "Volledig scherm",
"exit": "Stop volledig scherm stand"
@@ -527,6 +541,10 @@
"screenshare": {
"label": "Hun scherm delen",
"description": "Als u deze optie uitschakelt, kunnen deelnemers hun scherm niet meer delen en wordt elke lopende schermdeling onmiddellijk gestopt."
},
"mute": {
"label": "Anderen dempen",
"description": "Wanneer uitgeschakeld, kunnen deelnemers andere deelnemers niet meer dempen."
}
}
},
@@ -0,0 +1,15 @@
import { createContext, useContext } from 'react'
/**
* Signals that React Aria's default overlay positioning can't be trusted in
* this subtree because the trigger and the rendered overlay live in different
* documents (currently: picture-in-picture windows) React Aria measures
* against the main window's viewport, so tooltips, popovers, and menus end up
* mispositioned in the host that actually displays them. When `true`, consumers
* should bypass React Aria's positioning and handle placement themselves
* (e.g. a visual-only tooltip); triggers should still carry an accessible name.
*/
export const CrossDocumentOverlaysContext = createContext(false)
export const useCrossDocumentOverlays = () =>
useContext(CrossDocumentOverlaysContext)
+16 -9
View File
@@ -6,6 +6,8 @@ import {
type TooltipProps,
} from 'react-aria-components'
import { styled } from '@/styled-system/jsx'
import { useCrossDocumentOverlays } from '@/primitives/CrossDocumentOverlaysContext'
import { VisualOnlyTooltip } from '@/primitives/VisualOnlyTooltip'
export type TooltipWrapperProps = {
tooltip?: string
@@ -24,13 +26,18 @@ export const TooltipWrapper = ({
}: {
children: ReactNode
} & TooltipWrapperProps) => {
return tooltip ? (
const isCrossDocumentOverlays = useCrossDocumentOverlays()
if (!tooltip) return children
if (isCrossDocumentOverlays)
return <VisualOnlyTooltip tooltip={tooltip}>{children}</VisualOnlyTooltip>
return (
<TooltipTrigger delay={tooltipType === 'instant' ? 150 : 1000}>
{children}
<Tooltip>{tooltip}</Tooltip>
</TooltipTrigger>
) : (
children
)
}
@@ -41,31 +48,31 @@ export const TooltipWrapper = ({
*/
const StyledTooltip = styled(RACTooltip, {
base: {
boxShadow: '0 8px 20px rgba(0 0 0 / 0.1)',
boxShadow: '0 var(--sizes-tooltip-spacing) 20px rgba(0 0 0 / 0.1)',
borderRadius: '4px',
backgroundColor: 'primaryDark.100',
color: 'gray.100',
forcedColorAdjust: 'none',
outline: 'none',
padding: '2px 8px',
padding: '2px var(--sizes-tooltip-spacing)',
maxWidth: '200px',
textAlign: 'center',
fontSize: 14,
transform: 'translate3d(0, 0, 0)',
'&[data-placement=top]': {
marginBottom: '8px',
marginBottom: 'var(--sizes-tooltip-spacing)',
'--origin': 'translateY(4px)',
},
'&[data-placement=bottom]': {
marginTop: '8px',
marginTop: 'var(--sizes-tooltip-spacing)',
'--origin': 'translateY(-4px)',
},
'&[data-placement=right]': {
marginLeft: '8px',
marginLeft: 'var(--sizes-tooltip-spacing)',
'--origin': 'translateX(-4px)',
},
'&[data-placement=left]': {
marginRight: '8px',
marginRight: 'var(--sizes-tooltip-spacing)',
'--origin': 'translateX(4px)',
},
'& .react-aria-OverlayArrow svg': {
@@ -1,15 +1,18 @@
import {
type ReactElement,
cloneElement,
isValidElement,
useLayoutEffect,
useMemo,
useRef,
useState,
ReactNode,
} from 'react'
import { createPortal } from 'react-dom'
import { css } from '@/styled-system/css'
import { useUNSAFE_PortalContext } from '@react-aria/overlays'
export type VisualOnlyTooltipProps = {
children: ReactElement
children: ReactNode
tooltip: string
ariaLabel?: string
tooltipPosition?: 'top' | 'bottom'
@@ -32,19 +35,29 @@ export const VisualOnlyTooltip = ({
tooltipPosition = 'top',
}: VisualOnlyTooltipProps) => {
const [isVisible, setIsVisible] = useState(false)
const { getContainer } = useUNSAFE_PortalContext()
const wrapperRef = useRef<HTMLDivElement>(null)
const tooltipRef = useRef<HTMLDivElement>(null)
const [position, setPosition] = useState<{
top: number
left: number
} | null>(null)
const [computedStyle, setComputedStyle] = useState<{
left: number
arrowLeft: number
} | null>(null)
const isBottom = tooltipPosition === 'bottom'
const [effectiveBottom, setEffectiveBottom] = useState(
tooltipPosition === 'bottom'
)
const showTooltip = () => {
if (!wrapperRef.current) return
const rect = wrapperRef.current.getBoundingClientRect()
const preferBottom = tooltipPosition === 'bottom'
setEffectiveBottom(preferBottom)
setPosition({
top: isBottom ? rect.bottom + 8 : rect.top - 8,
top: preferBottom ? rect.bottom + 8 : rect.top - 8,
left: rect.left + rect.width / 2,
})
setIsVisible(true)
@@ -53,15 +66,67 @@ export const VisualOnlyTooltip = ({
const hideTooltip = () => {
setIsVisible(false)
setPosition(null)
setComputedStyle(null)
}
const tooltipData = isVisible && position ? { isVisible, position } : null
useLayoutEffect(() => {
if (!tooltipRef.current || !wrapperRef.current || !isVisible || !position)
return
const tooltipRect = tooltipRef.current.getBoundingClientRect()
const triggerRect = wrapperRef.current.getBoundingClientRect()
const doc = tooltipRef.current.ownerDocument
const viewportWidth = doc.defaultView?.innerWidth ?? globalThis.innerWidth
const padding = 8
// Vertical flip: if tooltip overflows the top, switch to bottom
if (!effectiveBottom && position.top - tooltipRect.height < 0) {
const flippedTop = triggerRect.bottom + 8
setEffectiveBottom(true)
setPosition({ top: flippedTop, left: position.left })
return
}
// Horizontal clamping (both edges)
const desiredLeft = position.left - tooltipRect.width / 2
const minLeft = padding
const maxLeft = viewportWidth - padding - tooltipRect.width
if (desiredLeft >= minLeft && desiredLeft <= maxLeft) {
setComputedStyle(null)
return
}
const clampedLeft = Math.max(minLeft, Math.min(maxLeft, desiredLeft))
setComputedStyle({
left: clampedLeft,
arrowLeft: position.left - clampedLeft,
})
}, [isVisible, position, effectiveBottom])
const portalContainer = useMemo(() => {
if (getContainer) return getContainer()
return wrapperRef.current?.ownerDocument?.body ?? document.body
}, [getContainer])
const wrappedChild = isValidElement(children)
? cloneElement(children, {
...(ariaLabel ? { 'aria-label': ariaLabel } : {}),
})
: children
const translateY = effectiveBottom ? 'translateY(0)' : 'translateY(-100%)'
const translateXY = effectiveBottom
? 'translate(-50%, 0)'
: 'translate(-50%, -100%)'
const tooltipInlineStyle: React.CSSProperties & Record<string, string> = {
top: `${position?.top}px`,
left: computedStyle ? `${computedStyle.left}px` : `${position?.left}px`,
transform: computedStyle ? translateY : translateXY,
...(computedStyle
? { '--tooltip-arrow-left': `${computedStyle.arrowLeft}px` }
: null),
}
return (
<>
<div
@@ -73,11 +138,14 @@ export const VisualOnlyTooltip = ({
>
{wrappedChild}
</div>
{tooltipData &&
{isVisible &&
position &&
portalContainer &&
createPortal(
<div
aria-hidden="true"
role="presentation"
ref={tooltipRef}
className={css({
position: 'fixed',
padding: '2px 8px',
@@ -87,15 +155,15 @@ export const VisualOnlyTooltip = ({
fontSize: 14,
whiteSpace: 'nowrap',
pointerEvents: 'none',
zIndex: 9999,
zIndex: 100001,
boxShadow: '0 8px 20px rgba(0 0 0 / 0.1)',
'&::after': {
content: '""',
position: 'absolute',
left: '50%',
left: 'var(--tooltip-arrow-left, 50%)',
transform: 'translateX(-50%)',
border: '4px solid transparent',
...(isBottom
...(effectiveBottom
? {
bottom: '100%',
borderBottomColor: 'primaryDark.100',
@@ -106,17 +174,11 @@ export const VisualOnlyTooltip = ({
}),
},
})}
style={{
top: `${tooltipData.position.top}px`,
left: `${tooltipData.position.left}px`,
transform: isBottom
? 'translate(-50%, 0)'
: 'translate(-50%, -100%)',
}}
style={tooltipInlineStyle}
>
{tooltip}
</div>,
document.body
portalContainer
)}
</>
)
@@ -211,6 +211,9 @@ export const buttonRecipe = cva({
outlineColor: 'focusRing',
outlineOffset: '2px',
},
'&[data-disabled]': {
opacity: 0.2,
},
},
quaternaryText: {
backgroundColor: 'transparent',
+1 -1
View File
@@ -59,7 +59,7 @@ export const CAPTION_FONT_COLOR_VALUES: Record<CaptionColor, string> = {
}
export const CAPTION_BACKGROUND_COLOR_VALUES: Record<CaptionColor, string> = {
default: 'rgba(0, 0, 0, 0.75)',
default: 'transparent',
black: 'rgba(0, 0, 0, 0.75)',
white: 'rgba(255, 255, 255, 0.75)',
blue: 'rgba(0, 0, 255, 0.75)',
@@ -0,0 +1,9 @@
import { proxy } from 'valtio'
type State = {
window: Window | null
}
export const documentPictureInPictureStore = proxy<State>({
window: null,
})
@@ -105,8 +105,9 @@ backend:
RECORDING_ENABLE: True
RECORDING_STORAGE_EVENT_ENABLE: True
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_WEBHOOK_API_TOKEN: webhook-password
RECORDING_DOWNLOAD_BASE_URL: https://meet.127.0.0.1.nip.io/recording
ROOM_TELEPHONY_ENABLED: True
SSL_CERT_FILE: /app/.venv/lib/python3.13/site-packages/certifi/cacert.pem
@@ -6,7 +6,26 @@ _summaryEnvVars: &summaryEnvVars
AWS_S3_ACCESS_KEY_ID: meet
AWS_S3_SECRET_ACCESS_KEY: password
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"}]'
# Note: setting allowed_push_to_docs false because it won't work in dev mode
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": "visio",
"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:
secretKeyRef:
name: secret-dev
@@ -109,8 +128,9 @@ backend:
RECORDING_ENABLE: True
RECORDING_STORAGE_EVENT_ENABLE: True
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_WEBHOOK_API_TOKEN: webhook-password
RECORDING_DOWNLOAD_BASE_URL: https://meet.127.0.0.1.nip.io/recording
ROOM_TELEPHONY_ENABLED: True
ROOM_TELEPHONY_DEFAULT_COUNTRY: 'FR'
@@ -258,6 +278,23 @@ celerySummaryBackend:
- "-Q"
- "call-webhook-queue-v2"
# Extra volume mounts to manage our local custom CA and avoid to set ssl_verify: false
extraVolumeMounts:
- name: certs
mountPath: /usr/local/lib/python3.13/site-packages/certifi/cacert.pem
subPath: cacert.pem
# Extra volumes to manage our local custom CA and avoid to set ssl_verify: false
extraVolumes:
- name: certs
configMap:
name: certifi
items:
- key: cacert.pem
path: cacert.pem
agentMetadata:
replicas: 1
envVars:
+2 -1
View File
@@ -14,7 +14,8 @@ dependencies = [
"posthog==7.9.12",
"requests==2.33.0",
"sentry-sdk[fastapi, celery]==2.54.0",
"langfuse==4.0.0"
"langfuse==4.0.0",
"ruff>=0.15.6",
]
[project.optional-dependencies]
+1 -4
View File
@@ -2,11 +2,8 @@
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
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.include_router(tasks_v2.router_tasks_v2, tags=["tasks"])
-79
View File
@@ -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}
+18 -2
View File
@@ -1,7 +1,10 @@
"""API routes related to application tasks (V2 / tenant friendly)."""
import logging
from datetime import datetime, timezone
from celery.result import AsyncResult
from fastapi import APIRouter, Depends, HTTPException, Request
from fastapi import APIRouter, Depends, HTTPException, Request, status
from summary.core.celery_worker import (
celery,
@@ -20,6 +23,7 @@ from summary.core.shared_models import (
TranscribeWebhookSuccessPayload,
)
logger = logging.getLogger(__name__)
router_tasks_v2 = APIRouter()
@@ -29,8 +33,20 @@ async def create_transcribe_task_v2(
request_tenant: AuthorizedTenant = Depends(verify_tenant_api_key_v2),
):
"""Create a transcription task."""
if (
request.push_to_docs_config is not None
and not request_tenant.allowed_push_to_docs
):
logger.error(
f"Push to docs is not allowed for this tenant ({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(
args=[{**request.model_dump(), "tenant_id": request_tenant.id}]
args=[{**request.model_dump(), "tenant_id": request_tenant.id, "received_at": datetime.now(timezone.utc)}]
)
return TranscribeWebhookPendingPayload(job_id=task.id).model_dump()
+10 -8
View File
@@ -4,12 +4,14 @@ import json
import time
from collections import Counter
from functools import lru_cache
from urllib.parse import urlsplit, urlunsplit
import redis
from celery.utils.log import get_task_logger
from posthog import Posthog
from summary.core.config import get_settings
from summary.core.models import TranscribeTaskV2Payload
logger = get_task_logger(__name__)
settings = get_settings()
@@ -107,23 +109,23 @@ class MetadataManager:
"""Check if task_id exists in tasks metadata cache."""
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: TranscribeTaskV2Payload):
"""Create initial metadata entry for a new task."""
if self._is_disabled or self.has_task_id(task_id):
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()
parts = urlsplit(task_payload.cloud_storage_url)
clean_url = urlunsplit((parts.scheme, parts.netloc, parts.path, "", ""))
initial_metadata = {
"start_time": start_time,
"asr_model": settings.whisperx_asr_model,
"retries": 0,
"filename": filename,
"email": email,
"queuing_time": round(start_time - received_at, 2),
"filename": clean_url,
"sub": task_payload.user_sub,
"email": task_payload.user_email,
"tenant_id": task_payload.tenant_id,
"queuing_time": round(start_time - task_payload.received_at.timestamp(), 2),
}
self._save_metadata(task_id, initial_metadata)
+100 -192
View File
@@ -4,7 +4,6 @@
import json
import time
from datetime import datetime
import openai
import sentry_sdk
@@ -14,10 +13,12 @@ from requests import exceptions
from summary.core.analytics import MetadataManager, get_analytics
from summary.core.config import get_settings
from summary.core.docs_service import create_document_in_docs
from summary.core.file_service import FileService, FileServiceException
from summary.core.llm_service import LLMException, LLMObservability, LLMService
from summary.core.locales import get_locale
from summary.core.models import (
RecordingMetadata,
SummarizeTaskV2Payload,
TranscribeTaskV2Payload,
)
@@ -43,7 +44,6 @@ from summary.core.transcript_formatter import TranscriptFormatter
from summary.core.user_assign import resolve_speaker_identities
from summary.core.webhook_service import (
call_webhook_v2,
submit_content,
)
settings = get_settings()
@@ -79,23 +79,17 @@ file_service = FileService()
def transcribe_audio(
*,
task_id: str,
recording_filename: str | None = None,
language: str,
cloud_storage_url=None,
cloud_storage_url: str,
raises: bool = False,
):
"""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.
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")
whisperx_client = openai.OpenAI(
api_key=settings.whisperx_api_key.get_secret_value(),
@@ -106,7 +100,6 @@ def transcribe_audio(
# Transcription
try:
with file_service.prepare_audio_file(
remote_object_key=recording_filename,
cloud_storage_url=cloud_storage_url,
) as (audio_file, metadata):
metadata_manager.track(task_id, {"audio_length": metadata["duration"]})
@@ -149,11 +142,7 @@ def transcribe_audio(
cloud_storage_url.split("?", 1)[0] if cloud_storage_url else None
)
logger.exception(
(
"Unexpected error while preparing file | filename: %s "
"| cloud_storage_url: %s"
),
recording_filename,
("Unexpected error while preparing file %s "),
redacted_cloud_storage_url,
)
return None
@@ -163,41 +152,31 @@ def transcribe_audio(
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.
Args:
transcription: output of meet-whisperx after transcription and diarization
recording_start_at: sourced from LiveKit FileInfo via the egress_ended webhook
recording_end_at: sourced from LiveKit FileInfo via the egress_ended webhook
metadata_filename: name of metadata file containing VAD information in S3
recording_metadata: Metadata of the recording
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(
"recording_start_dt: %s ; recording_end_dt: %s",
recording_start_dt,
recording_end_dt,
recording_metadata.started_at,
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")
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(
metadata,
transcription,
recording_start_dt,
recording_end_dt,
recording_metadata.started_at,
recording_metadata.ended_at,
)
new_transcription = speaker_mapping.apply_to(transcription.model_dump())
return new_transcription
@@ -225,11 +204,8 @@ def format_transcript(
transcription,
context_language: str | None,
language: str,
room: str | None,
recording_datetime: str | None,
owner_timezone: str | None,
download_link: str | None,
) -> tuple[str, str]:
) -> str:
"""Format a transcription into readable content with a title.
Resolves the locale from context_language / language, then uses
@@ -242,9 +218,6 @@ def format_transcript(
return formatter.format(
transcription,
room=room,
recording_datetime=recording_datetime,
owner_timezone=owner_timezone,
download_link=download_link,
)
@@ -267,130 +240,9 @@ def format_actions(llm_output: dict) -> str:
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,
)
# Format output
content, title = format_transcript(
transcription,
context_language,
language,
room,
recording_start_at,
owner_timezone,
download_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(
*, owner_id: str, transcript: str, session_id: str
*, user_sub: str, transcript: str, session_id: str
) -> str:
"""Generate a summary from the provided transcription text.
@@ -401,11 +253,11 @@ def summarize_transcription_internals(
"""
logger.info(
"Starting summarization task | Owner: %s",
owner_id,
user_sub,
)
user_has_tracing_consent = analytics.is_feature_enabled(
"summary-tracing-consent", distinct_id=owner_id
"summary-tracing-consent", distinct_id=user_sub
)
# NOTE: We must instantiate a new LLMObservability client for each task invocation
@@ -416,7 +268,7 @@ def summarize_transcription_internals(
llm_observability = LLMObservability(
user_has_tracing_consent=user_has_tracing_consent,
session_id=session_id,
user_id=owner_id,
user_id=user_sub,
)
llm_service = LLMService(llm_observability=llm_observability)
@@ -468,29 +320,6 @@ def summarize_transcription_internals(
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
##################################################################################
@@ -549,6 +378,64 @@ def process_audio_transcribe_v2_task(
).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 BaseException as e:
logger.error(f"Failed to resolve speaker identities, skipping: {e}")
# We do it synchronously for now
if (
payload.push_to_docs_config
and settings.is_docs_integration_enabled
and settings.get_authorized_tenant(
tenant_id=payload.tenant_id
).allowed_push_to_docs
):
# Format output
content = format_transcript(
transcription_res,
payload.context_language,
payload.language,
payload.push_to_docs_config.download_link,
)
create_document_in_docs(
content=content,
title=payload.push_to_docs_config.title,
email=payload.push_to_docs_config.user_email,
sub=payload.user_sub,
)
if (
payload.push_to_docs_config.auto_create_summary
and analytics.is_feature_enabled(
"summary-enabled", distinct_id=payload.user_sub
)
and settings.is_summary_enabled
):
summary = summarize_transcription_internals(
user_sub=payload.user_sub,
transcript=content,
session_id=self.request.id,
)
locale = get_locale(payload.context_language, payload.language)
create_document_in_docs(
content=summary,
title=locale.summary_title_template.format(
title=payload.push_to_docs_config.title
),
email=payload.push_to_docs_config.user_email,
sub=payload.user_sub,
)
metadata_manager.capture(job_id, settings.posthog_event_success)
file_service.store_transcript(
transcript=transcription_res,
job_id=job_id,
@@ -561,9 +448,30 @@ def process_audio_transcribe_v2_task(
call_webhook_v2_task.apply_async(
args=[success_payload.model_dump(), payload.tenant_id]
)
metadata_manager.capture(job_id, settings.posthog_event_success)
return success_payload.model_dump()
@signals.task_prerun.connect(sender=process_audio_transcribe_v2_task)
def task_started(task_id=None, task=None, args=None, **kwargs):
"""Signal handler called before task execution begins."""
if args:
metadata_manager.create(task_id, TranscribeTaskV2Payload.model_validate(args[0]))
@signals.task_retry.connect(sender=process_audio_transcribe_v2_task)
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_v2_task)
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)
@signals.task_failure.connect(sender=process_audio_transcribe_v2_task)
def handle_transcribe_v2_failed(
sender,
@@ -621,7 +529,7 @@ def summarize_v2_task(
"""
payload = SummarizeTaskV2Payload.model_validate(payload)
summary = summarize_transcription_internals(
owner_id=payload.user_sub,
user_sub=payload.user_sub,
transcript=payload.content,
session_id=self.request.id,
)
+14 -52
View File
@@ -1,9 +1,8 @@
"""Application configuration and settings."""
import logging
import os
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 pydantic import (
@@ -34,9 +33,12 @@ class AuthorizedTenant(BaseModel):
title="Webhook API Key",
description="The api_key to authenticate the webhook request.",
)
V1_DEFAULT_TENANT_ID = "__deprecated_meet_tenant__"
allowed_push_to_docs: bool = Field(
title="Allow Push to Docs",
description="Whether to allow pushing transcript"
" and summaries to docs for this tenant.",
default=False,
)
class Settings(BaseSettings):
@@ -45,14 +47,12 @@ class Settings(BaseSettings):
model_config = SettingsConfigDict(env_file=".env", frozen=True)
app_name: str = "summary"
app_api_v1_str: str = "/api/v1"
app_api_v2_str: str = "/api/v2"
# Authorized Tenants
# Using env variables to store authorized tenants for now
# to avoid any other external dependency (DB)
authorized_tenants: tuple[AuthorizedTenant, ...] = Field(default_factory=tuple)
v1_tenant_id: str = V1_DEFAULT_TENANT_ID
# Audio recordings
recording_max_duration: Optional[int] = None
@@ -72,8 +72,6 @@ class Settings(BaseSettings):
celery_result_backend: str = "redis://redis/0"
celery_max_retries: int = 1
transcribe_queue: str = "transcribe-queue"
summarize_queue: str = "summarize-queue"
# v2 tasks
transcribe_queue_v2: str = "transcribe-queue-v2"
summarize_queue_v2: str = "summarize-queue-v2"
@@ -114,15 +112,16 @@ class Settings(BaseSettings):
webhook_status_forcelist: List[int] = [502, 503, 504]
webhook_backoff_factor: float = 0.1
# 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
is_summary_enabled: bool = True
# Docs service configuration
is_docs_integration_enabled: bool = True
docs_base_url: str = "https://example.com"
docs_server_to_server_api_key: SecretStr = Field(
title="API key for using docs server to server api", default="NO_API_KEY"
)
# Sentry
sentry_is_enabled: bool = False
sentry_dsn: Optional[str] = None
@@ -145,33 +144,6 @@ class Settings(BaseSettings):
task_tracker_redis_url: str = "redis://redis/0"
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")
def validate_authorized_tenants(self):
"""Validate authorized tenants configuration."""
@@ -190,16 +162,6 @@ class Settings(BaseSettings):
raise ValueError("Duplicate application API api_keys are not allowed")
return self
@model_validator(mode="after")
def validate_default_v1_tenant(self):
"""Validate default v1 tenant configuration."""
if not any(
tenant.id == self.v1_tenant_id for tenant in self.authorized_tenants
):
raise ValueError("v1 tenant is not configured in authorized tenants")
return self
@cached_property
def authorized_tenant_api_keys(self) -> frozenset[str]:
"""Return a frozenset of authorized tenant API api_keys."""
+78
View File
@@ -0,0 +1,78 @@
"""Service for delivering content to external destinations."""
import json
import logging
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}"})
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_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 to %s", settings.docs_base_url)
logger.debug("Request payload: %s", json.dumps(data, indent=2))
response = _post_with_retries(
url=settings.docs_base_url,
api_key=settings.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"
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)
+6 -88
View File
@@ -13,7 +13,6 @@ from urllib.parse import urlparse
import requests
from minio import Minio
from minio.error import MinioException, S3Error
from summary.core.config import get_settings
from summary.core.shared_models import WhisperXResponse
@@ -144,54 +143,6 @@ class FileService:
self._allowed_extensions = settings.recording_allowed_extensions
self._max_duration = 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()
if extension not in self._allowed_extensions:
logger.warning("Invalid file extension '%s'", extension)
raise ValueError(f"Invalid file extension '{extension}'")
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:
"""Download file from a cloud storage URL to local temporary file."""
logger.info(
@@ -204,12 +155,6 @@ class FileService:
raise ValueError("Invalid cloud_storage_url")
extension = Path(urlparse(cloud_storage_url).path).suffix.lower()
if extension not in self._allowed_extensions:
logger.warning(
"Invalid file extension '%s' from cloud_storage_url", extension
)
raise ValueError(f"Invalid file extension '{extension}'")
try:
with requests.get(
cloud_storage_url,
@@ -302,33 +247,19 @@ class FileService:
os.remove(output_path)
raise RuntimeError("Failed to extract audio.") from e
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."""
logger.info("Reading JSON: %s", object_name)
if not object_name:
raise ValueError("Invalid object_name")
response = None
logger.info("Reading JSON: %s", cloud_storage_url)
local_path = self._download_from_cloud_storage_url(cloud_storage_url)
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
return json.load(local_path.open("r"))
except (json.JSONDecodeError, UnicodeDecodeError) as e:
raise FileServiceException("Invalid JSON content.") from e
finally:
if response:
response.close()
response.release_conn()
@contextmanager
def prepare_audio_file(
self,
remote_object_key: str | None = None,
cloud_storage_url: str | None = None,
cloud_storage_url: str,
):
"""Download and prepare audio file for processing.
@@ -341,20 +272,7 @@ class FileService:
file_handle = None
try:
if bool(remote_object_key) == bool(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)
downloaded_path = self._download_from_cloud_storage_url(cloud_storage_url)
duration = self._validate_duration(downloaded_path)
+1
View File
@@ -30,4 +30,5 @@ Einige Punkte, die wir Ihnen empfehlen zu überprüfen:
document_title_template=(
'Besprechung "{room}" am {room_recording_date} um {room_recording_time}'
),
summary_title_template="Zusammenfassung von {title}",
)
+1
View File
@@ -30,4 +30,5 @@ A few things we recommend you check:
document_title_template=(
'Meeting "{room}" on {room_recording_date} at {room_recording_time}'
),
summary_title_template="Summary of {title}",
)
+1
View File
@@ -30,4 +30,5 @@ Quelques points que nous vous conseillons de vérifier :
document_title_template=(
'Réunion "{room}" du {room_recording_date} à {room_recording_time}'
),
summary_title_template="Résumé de {title}",
)
+1
View File
@@ -30,4 +30,5 @@ Een paar punten die wij u aanraden te controleren:
document_title_template=(
'Vergadering "{room}" op {room_recording_date} om {room_recording_time}'
),
summary_title_template="Samenvatting van {title}",
)
@@ -13,3 +13,4 @@ class LocaleStrings:
hallucination_replacement_text: str
document_default_title: str
document_title_template: str
summary_title_template: str
+56 -2
View File
@@ -1,6 +1,7 @@
"""Models for the API & Celery tasks creation."""
from datetime import datetime
from pydantic import BaseModel, Field, field_validator
from pydantic import AwareDatetime, BaseModel, Field, field_validator
from summary.core.config import get_settings
from summary.core.types import Url
@@ -12,6 +13,47 @@ class SharedV2TaskCreation(BaseModel):
"""Model that holds basic information for task creation."""
user_sub: str = Field(title="User Sub", description="The user's sub.")
user_email: str | None = Field(
title="User Email", description="The user's email for analytics purposes."
)
class RecordingMetadata(BaseModel):
"""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: str = 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: str | None = Field(
title="Download Link", description="The link to download 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 TranscribeTaskV2Request(SharedV2TaskCreation):
@@ -27,7 +69,17 @@ class TranscribeTaskV2Request(SharedV2TaskCreation):
description="The language of the context text.",
)
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")
@@ -46,6 +98,7 @@ class TranscribeTaskV2Payload(TranscribeTaskV2Request):
"""Model for creating a transcribe and summarize task (used for actual task creation).""" # noqa: E501
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):
@@ -58,3 +111,4 @@ class SummarizeTaskV2Payload(SummarizeTaskV2Request):
"""Model for creating a summarize task (used for actual task creation)."""
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.")
@@ -159,4 +159,5 @@ __all__ = [
"SummarizeWebhookPayloads",
"WebhookPayloads",
"WhisperXResponse",
"webhook_payload_adapter",
]
@@ -1,9 +1,6 @@
"""Transcript formatting into readable conversation format with speaker labels."""
import logging
from datetime import datetime
from typing import Tuple
from zoneinfo import ZoneInfo
from summary.core.config import get_settings
from summary.core.locales import LocaleStrings
@@ -41,12 +38,9 @@ class TranscriptFormatter:
def format(
self,
transcription,
room: str | None = None,
recording_datetime: str | None = None,
owner_timezone: str | None = None,
download_link: str | None = None,
) -> Tuple[str, str]:
"""Format transcription into the final document and its title."""
) -> str:
"""Format transcription into the final document."""
segments = self._get_segments(transcription)
if not segments:
@@ -56,9 +50,7 @@ class TranscriptFormatter:
content = self._remove_hallucinations(content)
content = self._add_header(content, download_link)
title = self._generate_title(room, recording_datetime, owner_timezone)
return content, title
return content
def _remove_hallucinations(self, content: str) -> str:
"""Remove hallucination patterns from content."""
@@ -96,23 +88,3 @@ class TranscriptFormatter:
content = header + content
return content
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"),
)
+46 -12
View File
@@ -8,9 +8,10 @@ Multiple speakers can map to the same participant (e.g. two people sharing
one microphone). A participant with no matching speaker gets no assignment.
"""
import json
import logging
from collections import defaultdict
from dataclasses import dataclass, field
from dataclasses import asdict, dataclass, field, is_dataclass
from datetime import datetime
from typing import Any
@@ -318,6 +319,24 @@ def _build_speaker_timelines(transcription: Any) -> dict[str, list[Interval]]:
return intervals
def _json_default(obj: Any) -> Any:
"""Encode datetimes, dataclasses, and pydantic models for `json.dumps`.
Intended to be used for logging of `resolve_speaker_identities` (input
and computed variables)
"""
if isinstance(obj, datetime):
return obj.isoformat()
if is_dataclass(obj) and not isinstance(obj, type):
return asdict(obj)
if hasattr(obj, "segments") and hasattr(obj, "word_segments"):
return {"segments": obj.segments, "word_segments": obj.word_segments}
if hasattr(obj, "model_dump"):
return obj.model_dump(mode="json")
raise TypeError(f"Object of type {type(obj).__name__} is not JSON serializable")
def resolve_speaker_identities(
metadata: dict[str, Any],
transcription: Any,
@@ -344,17 +363,6 @@ def resolve_speaker_identities(
)
speaker_timelines = _build_speaker_timelines(transcription)
logger.debug(
"Assignment inputs: %d participants, %d speakers\n%s\n%s\n%s",
len(participant_timelines),
len(speaker_timelines),
participant_timelines,
speaker_timelines,
_format_timelines_debug(
participant_timelines, participant_names, speaker_timelines
),
)
result = AssignmentResult()
for speaker, speaker_intervals in speaker_timelines.items():
@@ -397,4 +405,30 @@ def resolve_speaker_identities(
overlap_threshold,
)
logger.debug(
json.dumps(
{
"input": {
"recording_start_datetime": recording_start_datetime.isoformat(),
"recording_end_datetime": recording_end_datetime.isoformat(),
"metadata": metadata,
"transcription": transcription,
},
"computed": {
"speaker_timelines": speaker_timelines,
"participant_timelines": participant_timelines,
"result": result,
},
},
default=_json_default,
indent=2,
ensure_ascii=False,
),
)
logger.debug(
_format_timelines_debug(
participant_timelines, participant_names, speaker_timelines
),
)
return result
@@ -4,9 +4,6 @@ import json
import logging
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.shared_models import (
@@ -18,83 +15,6 @@ 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}"})
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(
*,
tenant_id: str,
+1 -2
View File
@@ -4,7 +4,7 @@ import sentry_sdk
from fastapi import FastAPI
from summary.api import health
from summary.api.main import api_router_v1, api_router_v2
from summary.api.main import api_router_v2
from summary.core.config import get_settings
settings = get_settings()
@@ -17,6 +17,5 @@ app = FastAPI(
title=settings.app_name,
)
app.include_router(api_router_v1, prefix=settings.app_api_v1_str)
app.include_router(api_router_v2, prefix=settings.app_api_v2_str)
app.include_router(health.router)
-1
View File
@@ -11,7 +11,6 @@ from summary.main import app
def get_settings_override():
"""Return settings for tests."""
return Settings(
v1_tenant_id="test-tenant",
authorized_tenants=(
AuthorizedTenant(
webhook_url="https://example.com/webhook",
+1475
View File
File diff suppressed because it is too large Load Diff