Compare commits

...

15 Commits

Author SHA1 Message Date
leo 17496d646f 🐛(backend) preserve recording metadata when updating room access
Updating room access rewrote the entire metadata payload, removing information
about active recordings. This caused the frontend to lose track of ongoing
recordings and could trigger 409 errors when attempting to start a new
recording.

Consolidate the duplicated metadata update logic into
`RoomManagement.update_metadata()` and preserve merge behavior instead of
overwriting the full metadata object.
2026-07-22 16:48:42 +02:00
leo 5ba1885411 (transcription) fix broken speaker assignment tests
Fix broken speaker assignement tests following #1522.
2026-07-22 14:23:10 +02:00
snyk-bot 273af221e6 ⬆️(frontend) upgrade livekit-client from 2.19.2 to 2.20.0
Snyk has created this PR to upgrade livekit-client from 2.19.2 to 2.20.0.

See this package in npm:
livekit-client

See this project in Snyk:
https://app.eu.snyk.io/org/lasuite-dinum-default/project/96ea03d8-8d09-493d-86bf-363f274e129e?utm_source=github&utm_medium=referral&page=upgrade-pr
2026-07-21 19:00:04 +02:00
snyk-bot e8d6aba306 ⬆️(frontend) upgrade @tanstack/react-query from 5.101.0 to 5.101.1
Snyk has created this PR to upgrade @tanstack/react-query from 5.101.0 to 5.101.1.

See this package in npm:
@tanstack/react-query

See this project in Snyk:
https://app.eu.snyk.io/org/lasuite-dinum-default/project/96ea03d8-8d09-493d-86bf-363f274e129e?utm_source=github&utm_medium=referral&page=upgrade-pr
2026-07-21 18:41:15 +02:00
snyk-bot 449503208a ⬆️(frontend) upgrade posthog-js from 1.391.2 to 1.395.0
Snyk has created this PR to upgrade posthog-js from 1.391.2 to 1.395.0.

See this package in npm:
posthog-js

See this project in Snyk:
https://app.eu.snyk.io/org/lasuite-dinum-default/project/96ea03d8-8d09-493d-86bf-363f274e129e?utm_source=github&utm_medium=referral&page=upgrade-pr
2026-07-21 18:34:52 +02:00
snyk-bot 7eb3553bea ⬆️(frontend) upgrade i18next from 26.3.1 to 26.3.2
Snyk has created this PR to upgrade i18next from 26.3.1 to 26.3.2.

See this package in npm:
i18next

See this project in Snyk:
https://app.eu.snyk.io/org/lasuite-dinum-default/project/af693e79-8c43-4c09-ab65-60580515c9e8?utm_source=github&utm_medium=referral&page=upgrade-pr
2026-07-21 18:24:25 +02:00
snyk-bot f0356af365 ⬆️(frontend) upgrade @mediapipe/tasks-vision from 0.10.14 to 0.10.35
Snyk has created this PR to upgrade @mediapipe/tasks-vision from 0.10.14 to 0.10.35.

See this package in npm:
@mediapipe/tasks-vision

See this project in Snyk:
https://app.eu.snyk.io/org/lasuite-dinum-default/project/96ea03d8-8d09-493d-86bf-363f274e129e?utm_source=github&utm_medium=referral&page=upgrade-pr
2026-07-21 18:19:26 +02:00
leo 9921ef9d09 🐛(transcription) fix silent fail of speaker assignment
Fix bug introduced by #1362 which caused speaker assignment to
silently fail. Bug was due to change in input variable type.
2026-07-21 18:16:23 +02:00
lebaudantoine 68e999a037 🔖(minor) bump release to 1.24.0 2026-07-21 18:01:20 +02:00
lebaudantoine 34103bf326 🩹(agents) announce the update of uv.lock via prepare-release script
Announce it among the other files. Minor issue.
2026-07-21 18:01:20 +02:00
lebaudantoine e273ac9e43 🔖(helm) release chart 0.0.27 2026-07-21 17:26:01 +02:00
Florent Chehab adcdbc0695 🐛(summary) whisper call error handling
* Consider that HTTP 400 errors are due to corrupted
audio files by  default.
* Properly reraise the http error otherwise so that
the retry mechanism actually works.
2026-07-21 12:05:06 +02:00
lebaudantoine 8f9008f1e0 🔖(addons) remove the beta tag
Promote the addon to a stable v1 release.
2026-07-21 10:30:00 +02:00
lebaudantoine 9320a1af0c (addon) show add-in tools when creating meetings in shared calendars
The existing manifest was not exposing the add-in tools when
creating a meeting in a shared calendar. Amend the manifest with the
missing instructions so the add-in shows up in that context as well.

Manifest changes generated with Claude's assistance.
2026-07-21 10:30:00 +02:00
David Wagner e3827970c8 (docs) Fix CSS theming env var name
While here, also mention *where* this env var should be set.
2026-07-20 12:38:07 +02:00
33 changed files with 587 additions and 201 deletions
+17
View File
@@ -8,12 +8,28 @@ and this project adheres to
## [Unreleased]
### Changed
- ⬆️(frontend) upgrade @mediapipe/tasks-vision from 0.10.14 to 0.10.35
- ⬆️(frontend) upgrade i18next from 26.3.1 to 26.3.2
- ⬆️(frontend) upgrade posthog-js from 1.391.2 to 1.395.0
- ⬆️(frontend) upgrade @tanstack/react-query from 5.101.0 to 5.101.1
- ⬆️(frontend) upgrade livekit-client from 2.19.2 to 2.20.0
## Fixed
- 🐛(transcription) fix silent bug in speaker assignment
- 🐛(backend) fix bug linked to recording state inconsistency
## [1.24.0] - 2026-07-21
### Added
- ✨(backend) allow searching the recording admin table by owner email
- ✨(frontend) add participant color gradient when camera is off #1490
- ✨(all) allow forcing SSO display name for authenticated users
- (frontend) install vite-plugin-static-copy for MediaPipe WASM assets
- ✨(addon) show add-in tools when creating meetings in shared calendars
### Changed
@@ -33,6 +49,7 @@ and this project adheres to
- 🩹(backend) identify externally provisioned users to PostHog
- 🐛(backend) fix info panel crash for unregistered rooms
- ♿️(frontend) focus side panel container on open #1452
- 🐛(summary) whisper call error handling
## [1.23.0] - 2026-07-08
+1
View File
@@ -164,6 +164,7 @@ echo " - src/backend/pyproject.toml"
echo " - src/backend/uv.lock"
echo " - src/summary/pyproject.toml"
echo " - src/agents/pyproject.toml"
echo " - src/agents/uv.lock"
echo " - CHANGELOG.md"
echo ""
print_warning "Next steps:"
+4 -4
View File
@@ -11,14 +11,14 @@ There are two ways to customize LaSuite Meet:
### How to Use
To use this feature, simply set the `FRONTEND_CSS_URL` environment variable to the URL of your custom CSS file. For example:
To use this feature, simply set the `FRONTEND_CUSTOM_CSS_URL` environment variable (of the **backend** service) to the URL of your custom CSS file. For example:
```javascript
FRONTEND_CSS_URL=https://example.com/custom-style.css
FRONTEND_CUSTOM_CSS_URL=https://example.com/custom-style.css
```
> [!TIP]
> If you serve your CSS file on the same domain as LaSuite Meet, paths are supported, i.e. `FRONTEND_CSS_URL=/custom/style.css` will load `https://your-domain.com/custom/style.css`.
> If you serve your CSS file on the same domain as LaSuite Meet, paths are supported, i.e. `FRONTEND_CUSTOM_CSS_URL=/custom/style.css` will load `https://your-domain.com/custom/style.css`.
Setting this variable makes the app load your CSS at runtime, adding a `<link>` to `<head>` so you can override CSS variables and customize the frontend without rebuilding.
@@ -37,7 +37,7 @@ Let's say you want to change the font of our application to a custom font. You c
}
```
Then, set the `FRONTEND_CSS_URL` environment variable to the URL of your custom CSS file. Once you've done this, our application will load your custom CSS file and apply the styles, changing the default font to the one you specified.
Then, set the `FRONTEND_CUSTOM_CSS_URL` environment variable to the URL of your custom CSS file. Once you've done this, our application will load your custom CSS file and apply the styles, changing the default font to the one you specified.
> [!IMPORTANT]
> You can override any CSS token—semantic or palette. See [panda.config.ts](../src/frontend/panda.config.ts) for all defined semantic tokens.
+170 -1
View File
@@ -1,7 +1,7 @@
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<OfficeApp xmlns="http://schemas.microsoft.com/office/appforoffice/1.1" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:bt="http://schemas.microsoft.com/office/officeappbasictypes/1.0" xmlns:mailappor="http://schemas.microsoft.com/office/mailappversionoverrides/1.0" xsi:type="MailApp">
<Id>a025f0f6-757a-4790-97f3-99c66c4a5795</Id>
<Version>0.0.2.0</Version>
<Version>1.0.0.0</Version>
<ProviderName>__APP_NAME__</ProviderName>
<DefaultLocale>fr-FR</DefaultLocale>
<DisplayName DefaultValue="__APP_NAME__"/>
@@ -205,5 +205,174 @@
</bt:String>
</bt:LongStrings>
</Resources>
<!-- ─── V1.1 override: required for shared folder / delegate support ─── -->
<VersionOverrides xmlns="http://schemas.microsoft.com/office/mailappversionoverrides/1.1" xsi:type="VersionOverridesV1_1">
<Requirements>
<bt:Sets DefaultMinVersion="1.8">
<bt:Set Name="Mailbox"/>
</bt:Sets>
</Requirements>
<Hosts>
<Host xsi:type="MailHost">
<DesktopFormFactor>
<FunctionFile resid="Commands.Url"/>
<SupportsSharedFolders>true</SupportsSharedFolders>
<!-- ─── Mail: Read ─────────────────────────────────────────── -->
<ExtensionPoint xsi:type="MessageReadCommandSurface">
<OfficeTab id="TabDefault">
<Group id="msgReadGroup">
<Label resid="GroupLabel"/>
<Control xsi:type="Button" id="msgReadOpenPaneButton">
<Label resid="TaskpaneButton.Label"/>
<Supertip>
<Title resid="TaskpaneButton.Label"/>
<Description resid="TaskpaneButton.Tooltip"/>
</Supertip>
<Icon>
<bt:Image size="16" resid="Icon.16x16"/>
<bt:Image size="32" resid="Icon.32x32"/>
<bt:Image size="80" resid="Icon.80x80"/>
</Icon>
<Action xsi:type="ShowTaskpane">
<SourceLocation resid="Taskpane.Url"/>
</Action>
</Control>
</Group>
</OfficeTab>
</ExtensionPoint>
<!-- ─── Mail: Compose ─────────────────────────────────────── -->
<ExtensionPoint xsi:type="MessageComposeCommandSurface">
<OfficeTab id="TabDefault">
<Group id="msgComposeGroup">
<Label resid="GroupLabel"/>
<Control xsi:type="Button" id="msgComposeGenerateLinkButton">
<Label resid="GenerateLink.Label"/>
<Supertip>
<Title resid="GenerateLink.Label"/>
<Description resid="GenerateLink.Tooltip"/>
</Supertip>
<Icon>
<bt:Image size="16" resid="Icon.16x16"/>
<bt:Image size="32" resid="Icon.32x32"/>
<bt:Image size="80" resid="Icon.80x80"/>
</Icon>
<Action xsi:type="ExecuteFunction">
<FunctionName>generateMeetingLinkFromMail</FunctionName>
</Action>
</Control>
<Control xsi:type="Button" id="msgComposeOpenPaneButton">
<Label resid="TaskpaneButton.Label"/>
<Supertip>
<Title resid="TaskpaneButton.Label"/>
<Description resid="TaskpaneButton.Tooltip"/>
</Supertip>
<Icon>
<bt:Image size="16" resid="Settings.16x16"/>
<bt:Image size="32" resid="Settings.32x32"/>
<bt:Image size="80" resid="Settings.80x80"/>
</Icon>
<Action xsi:type="ShowTaskpane">
<SourceLocation resid="Taskpane.Url"/>
</Action>
</Control>
</Group>
</OfficeTab>
</ExtensionPoint>
<!-- ─── Calendar: Compose (New/Edit appointment) ──────────── -->
<ExtensionPoint xsi:type="AppointmentOrganizerCommandSurface">
<OfficeTab id="TabDefault">
<Group id="apptComposeGroup">
<Label resid="GroupLabel"/>
<Control xsi:type="Button" id="apptGenerateLinkButton">
<Label resid="GenerateLink.Label"/>
<Supertip>
<Title resid="GenerateLink.Label"/>
<Description resid="GenerateLink.Tooltip"/>
</Supertip>
<Icon>
<bt:Image size="16" resid="Icon.16x16"/>
<bt:Image size="32" resid="Icon.32x32"/>
<bt:Image size="80" resid="Icon.80x80"/>
</Icon>
<Action xsi:type="ExecuteFunction">
<FunctionName>generateMeetingLinkFromCalendar</FunctionName>
</Action>
</Control>
<Control xsi:type="Button" id="apptOpenSettingsButton">
<Label resid="OpenSettings.Label"/>
<Supertip>
<Title resid="OpenSettings.Label"/>
<Description resid="OpenSettings.Tooltip"/>
</Supertip>
<Icon>
<bt:Image size="16" resid="Settings.16x16"/>
<bt:Image size="32" resid="Settings.32x32"/>
<bt:Image size="80" resid="Settings.80x80"/>
</Icon>
<Action xsi:type="ShowTaskpane">
<SourceLocation resid="Taskpane.Url"/>
</Action>
</Control>
</Group>
</OfficeTab>
</ExtensionPoint>
</DesktopFormFactor>
</Host>
</Hosts>
<Resources>
<bt:Images>
<bt:Image id="Settings.16x16" DefaultValue="https://localhost:3000/addons/outlook/assets/settings-16.png"/>
<bt:Image id="Settings.32x32" DefaultValue="https://localhost:3000/addons/outlook/assets/settings-32.png"/>
<bt:Image id="Settings.80x80" DefaultValue="https://localhost:3000/addons/outlook/assets/settings-80.png"/>
<bt:Image id="Add.16x16" DefaultValue="https://localhost:3000/addons/outlook/assets/add-16.png"/>
<bt:Image id="Add.32x32" DefaultValue="https://localhost:3000/addons/outlook/assets/add-32.png"/>
<bt:Image id="Add.80x80" DefaultValue="https://localhost:3000/addons/outlook/assets/add-80.png"/>
<bt:Image id="Icon.16x16" DefaultValue="https://localhost:3000/addons/outlook/assets/icon-16.png"/>
<bt:Image id="Icon.32x32" DefaultValue="https://localhost:3000/addons/outlook/assets/icon-32.png"/>
<bt:Image id="Icon.80x80" DefaultValue="https://localhost:3000/addons/outlook/assets/icon-80.png"/>
</bt:Images>
<bt:Urls>
<bt:Url id="Commands.Url" DefaultValue="https://localhost:3000/addons/outlook/commands.html"/>
<bt:Url id="Taskpane.Url" DefaultValue="https://localhost:3000/addons/outlook/taskpane.html"/>
</bt:Urls>
<bt:ShortStrings>
<!-- Default (French) -->
<bt:String id="GroupLabel" DefaultValue="__APP_NAME__"/>
<bt:String id="GenerateLink.Label" DefaultValue="Ajouter un lien __APP_NAME__">
<bt:Override Locale="en-US" Value="Add a __APP_NAME__ link"/>
<bt:Override Locale="de-DE" Value="__APP_NAME__-Link hinzufügen"/>
</bt:String>
<bt:String id="TaskpaneButton.Label" DefaultValue="Ouvrir les paramètres">
<bt:Override Locale="en-US" Value="Open settings"/>
<bt:Override Locale="de-DE" Value="Einstellungen öffnen"/>
</bt:String>
<bt:String id="OpenSettings.Label" DefaultValue="Paramètres">
<bt:Override Locale="en-US" Value="Settings"/>
<bt:Override Locale="de-DE" Value="Einstellungen"/>
</bt:String>
</bt:ShortStrings>
<bt:LongStrings>
<bt:String id="GenerateLink.Tooltip" DefaultValue="Génère un lien de réunion __APP_NAME__ et l'insère dans l'événement.">
<bt:Override Locale="de-DE" Value="Generiert einen __APP_NAME__-Besprechungslink und fügt ihn in den Termin ein."/>
<bt:Override Locale="en-US" Value="Generates a __APP_NAME__ meeting link and inserts it into the item."/>
</bt:String>
<bt:String id="TaskpaneButton.Tooltip" DefaultValue="Ouvre les paramètres de connexion __APP_NAME__.">
<bt:Override Locale="de-DE" Value="Öffnet die __APP_NAME__-Verbindungseinstellungen."/>
<bt:Override Locale="en-US" Value="Opens the __APP_NAME__ connection settings."/>
</bt:String>
<bt:String id="OpenSettings.Tooltip" DefaultValue="Ouvre les paramètres de connexion __APP_NAME__.">
<bt:Override Locale="de-DE" Value="Öffnet die __APP_NAME__-Verbindungseinstellungen."/>
<bt:Override Locale="en-US" Value="Opens the __APP_NAME__ connection settings."/>
</bt:String>
</bt:LongStrings>
</Resources>
</VersionOverrides>
</VersionOverrides>
</OfficeApp>
+4 -4
View File
@@ -10,7 +10,7 @@
"license": "MIT",
"dependencies": {
"core-js": "3.49.0",
"i18next": "26.3.1",
"i18next": "^26.3.2",
"i18next-browser-languagedetector": "8.2.1",
"regenerator-runtime": "0.14.1"
},
@@ -9364,9 +9364,9 @@
}
},
"node_modules/i18next": {
"version": "26.3.1",
"resolved": "https://registry.npmjs.org/i18next/-/i18next-26.3.1.tgz",
"integrity": "sha512-txQqd5EULsqEh9OJqRH15aCaOuy/nLJyhw5EHCSKLKJE1aBbb3Zve2+uQIxgWhPm1QqUQoWyQBm2kfmmIrzkcQ==",
"version": "26.3.2",
"resolved": "https://registry.npmjs.org/i18next/-/i18next-26.3.2.tgz",
"integrity": "sha512-QQkXAM1sPDHqhxMQuBeHVMUn6mJchF+wdpOoQerciLAFqO3ZYdxO0EUbeEhruyutnNwpUQIITDVzLjwnNL0T1w==",
"funding": [
{
"type": "individual",
+1 -1
View File
@@ -27,7 +27,7 @@
},
"dependencies": {
"core-js": "3.49.0",
"i18next": "26.3.1",
"i18next": "26.3.2",
"i18next-browser-languagedetector": "8.2.1",
"regenerator-runtime": "0.14.1"
},
@@ -57,8 +57,7 @@
data-i18n="footer.feedback"
></a>
<div id="footer-right">
<span class="version-badge">beta</span>
<span class="version-number">0.0.2</span>
<span class="version-number">1.0.0</span>
</div>
</footer>
</body>
+1 -1
View File
@@ -1,7 +1,7 @@
[project]
name = "agents"
version = "1.23.0"
version = "1.24.0"
requires-python = ">=3.12"
dependencies = [
"livekit-agents==1.6.4",
+1 -1
View File
@@ -9,7 +9,7 @@ resolution-markers = [
[[package]]
name = "agents"
version = "1.23.0"
version = "1.24.0"
source = { virtual = "." }
dependencies = [
{ name = "livekit-agents" },
@@ -9,6 +9,11 @@ from livekit import api
from core import models, utils
from core.models import Recording
from core.recording.event.notification import notification_service
from core.services.room_management import (
RoomManagement,
RoomManagementException,
RoomNotFoundException,
)
logger = getLogger(__name__)
@@ -39,10 +44,15 @@ class RecordingEventsService:
recording_status = status_mapping.get(egress_status)
if recording_status:
try:
utils.update_room_metadata(
RoomManagement().update_metadata(
room_name, {"recording_status": recording_status}
)
except utils.MetadataUpdateException as e:
except RoomNotFoundException:
logger.info(
"LiveKit room %s no longer exists, skipping metadata update",
room_name,
)
except RoomManagementException as e:
logger.exception("Failed to update room's metadata: %s", e)
@staticmethod
+12 -3
View File
@@ -2,8 +2,12 @@
import logging
from core import utils
from core.models import Recording, RecordingStatusChoices
from core.services.room_management import (
RoomManagement,
RoomManagementException,
RoomNotFoundException,
)
from .exceptions import (
RecordingStartError,
@@ -64,10 +68,15 @@ class WorkerServiceMediator:
mode = recording.options.get("original_mode", None) or recording.mode
try:
utils.update_room_metadata(
RoomManagement().update_metadata(
room_name, {"recording_mode": mode, "recording_status": "starting"}
)
except utils.MetadataUpdateException as e:
except RoomNotFoundException:
logger.info(
"LiveKit room %s no longer exists, skipping metadata update",
room_name,
)
except RoomManagementException as e:
logger.exception("Failed to update room's metadata: %s", e)
logger.info(
+14 -4
View File
@@ -11,7 +11,7 @@ from django.conf import settings
from livekit import api
from core import models, utils
from core import models
from core.recording.services.metadata_collector import (
MetadataCollectorException,
MetadataCollectorService,
@@ -23,6 +23,11 @@ from core.recording.services.recording_events import (
)
from .lobby import LobbyService
from .room_management import (
RoomManagement,
RoomManagementException,
RoomNotFoundException,
)
from .telephony import TelephonyException, TelephonyService
logger = getLogger(__name__)
@@ -177,10 +182,15 @@ class LiveKitEventsService:
try:
room_name = str(recording.room.id)
utils.update_room_metadata(
room_name, {}, ["recording_mode", "recording_status"]
RoomManagement().update_metadata(
room_name, remove_keys=["recording_mode", "recording_status"]
)
except utils.MetadataUpdateException as e:
except RoomNotFoundException:
logger.info(
"LiveKit room %s no longer exists, skipping metadata update",
room_name,
)
except RoomManagementException as e:
logger.exception("Failed to update room's metadata: %s", e)
if recording.options.get("metadata_collector_dispatch_id", None) is not None:
+29 -3
View File
@@ -8,6 +8,7 @@ from typing import Dict, Optional
from asgiref.sync import async_to_sync
from livekit.api import (
ListRoomsRequest,
TwirpError,
UpdateRoomMetadataRequest,
)
@@ -29,20 +30,45 @@ 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.
async def update_metadata(
self,
room_name: str,
metadata: Optional[Dict] = None,
remove_keys: Optional[list[str]] = None,
):
"""Merge values into a LiveKit room's metadata.
The `room_name` corresponds to the LiveKit room identifier
(i.e. the Room model's UUID as a string).
Raises:
RoomNotFoundException: the room does not exist in LiveKit.
RoomManagementException: the metadata update otherwise fails.
"""
lkapi = utils.create_livekit_client()
try:
response = await lkapi.room.list_rooms(ListRoomsRequest(names=[room_name]))
if not response.rooms:
logger.warning(
"Room %s not found in LiveKit, skipping metadata update",
room_name,
)
raise RoomNotFoundException("Room does not exist")
existing_metadata = json.loads(response.rooms[0].metadata or "{}")
for key in remove_keys or []:
existing_metadata.pop(key, None)
updated_metadata = {**existing_metadata, **(metadata or {})}
await lkapi.room.update_room_metadata(
UpdateRoomMetadataRequest(
room=room_name,
metadata=json.dumps(metadata) if metadata is not None else "",
metadata=json.dumps(updated_metadata),
)
)
@@ -34,10 +34,8 @@ def mediator(mock_worker_service):
return WorkerServiceMediator(mock_worker_service)
@mock.patch("core.utils.update_room_metadata")
def test_start_recording_success(
mock_update_room_metadata, mediator, mock_worker_service
):
@mock.patch("core.services.room_management.RoomManagement.update_metadata")
def test_start_recording_success(mock_update_metadata, mediator, mock_worker_service):
"""Test successful recording start"""
# Setup
worker_id = "test-worker-123"
@@ -60,7 +58,7 @@ def test_start_recording_success(
assert mock_recording.worker_id == worker_id
assert mock_recording.status == RecordingStatusChoices.ACTIVE
mock_update_room_metadata.assert_called_once_with(
mock_update_metadata.assert_called_once_with(
str(mock_recording.room.id),
{"recording_mode": mock_recording.mode, "recording_status": "starting"},
)
@@ -69,9 +67,9 @@ def test_start_recording_success(
@pytest.mark.parametrize(
"error_class", [WorkerRequestError, WorkerConnectionError, WorkerResponseError]
)
@mock.patch("core.utils.update_room_metadata")
@mock.patch("core.services.room_management.RoomManagement.update_metadata")
def test_mediator_start_recording_worker_errors(
mock_update_room_metadata, mediator, mock_worker_service, error_class
mock_update_metadata, mediator, mock_worker_service, error_class
):
"""Test handling of various worker errors during start"""
# Setup
@@ -89,7 +87,7 @@ def test_mediator_start_recording_worker_errors(
assert mock_recording.status == RecordingStatusChoices.FAILED_TO_START
assert mock_recording.worker_id is None
mock_update_room_metadata.assert_not_called()
mock_update_metadata.assert_not_called()
@pytest.mark.parametrize(
@@ -103,9 +101,9 @@ def test_mediator_start_recording_worker_errors(
RecordingStatusChoices.ABORTED,
],
)
@mock.patch("core.utils.update_room_metadata")
@mock.patch("core.services.room_management.RoomManagement.update_metadata")
def test_mediator_start_recording_from_forbidden_status(
mock_update_room_metadata, mediator, mock_worker_service, status
mock_update_metadata, mediator, mock_worker_service, status
):
"""Test handling of various worker errors during start"""
# Setup
@@ -119,7 +117,7 @@ def test_mediator_start_recording_from_forbidden_status(
mock_recording.refresh_from_db()
assert mock_recording.status == status
mock_update_room_metadata.assert_not_called()
mock_update_metadata.assert_not_called()
def test_mediator_stop_recording_success(mediator, mock_worker_service):
@@ -20,8 +20,9 @@ from core.services.livekit_events import (
api,
)
from core.services.lobby import LobbyService
from core.services.room_management import RoomManagementException
from core.services.telephony import TelephonyException, TelephonyService
from core.utils import MetadataUpdateException, NotificationError
from core.utils import NotificationError
pytestmark = pytest.mark.django_db
@@ -70,9 +71,9 @@ def test_initialization(
),
)
@mock.patch("core.utils.notify_participants")
@mock.patch("core.utils.update_room_metadata")
@mock.patch("core.services.room_management.RoomManagement.update_metadata")
def test_handle_egress_ended_success(
mock_update_room_metadata, mock_notify, mode, notification_type, service
mock_update_metadata, mock_notify, mode, notification_type, service
):
"""Should successfully stop recording and notifies all participant."""
@@ -86,8 +87,8 @@ def test_handle_egress_ended_success(
mock_notify.assert_called_once_with(
room_name=str(recording.room.id), notification_data={"type": notification_type}
)
mock_update_room_metadata.assert_called_once_with(
str(recording.room.id), {}, ["recording_mode", "recording_status"]
mock_update_metadata.assert_called_once_with(
str(recording.room.id), remove_keys=["recording_mode", "recording_status"]
)
recording.refresh_from_db()
@@ -104,9 +105,9 @@ def test_handle_egress_ended_success(
(EgressStatus.EGRESS_ABORTED, "aborted"),
),
)
@mock.patch("core.utils.update_room_metadata")
@mock.patch("core.services.room_management.RoomManagement.update_metadata")
def test_handle_egress_updated_success(
mock_update_room_metadata, egress_status, status, service
mock_update_metadata, egress_status, status, service
):
"""Should successfully update room's metadata."""
@@ -117,7 +118,7 @@ def test_handle_egress_updated_success(
service._handle_egress_updated(mock_data)
mock_update_room_metadata.assert_called_once_with(
mock_update_metadata.assert_called_once_with(
str(recording.room.id), {"recording_status": status}
)
@@ -129,9 +130,9 @@ def test_handle_egress_updated_success(
EgressStatus.EGRESS_LIMIT_REACHED,
),
)
@mock.patch("core.utils.update_room_metadata")
@mock.patch("core.services.room_management.RoomManagement.update_metadata")
def test_handle_egress_updated_non_handled(
mock_update_room_metadata, egress_status, service
mock_update_metadata, egress_status, service
):
"""Should ignore certain egress status and don't trigger metadata updates."""
@@ -142,7 +143,7 @@ def test_handle_egress_updated_non_handled(
service._handle_egress_updated(mock_data)
mock_update_room_metadata.assert_not_called()
mock_update_metadata.assert_not_called()
@pytest.mark.parametrize(
@@ -153,9 +154,9 @@ def test_handle_egress_updated_non_handled(
),
)
@mock.patch("core.utils.notify_participants")
@mock.patch("core.utils.update_room_metadata")
@mock.patch("core.services.room_management.RoomManagement.update_metadata")
def test_handle_egress_ended_metadata_update_fails(
mock_update_room_metadata, mock_notify, mode, notification_type, service
mock_update_metadata, mock_notify, mode, notification_type, service
):
"""Should successfully stop and save recording when metadata's update fails."""
@@ -164,7 +165,7 @@ def test_handle_egress_ended_metadata_update_fails(
mock_data.egress_info.egress_id = recording.worker_id
mock_data.egress_info.status = EgressStatus.EGRESS_LIMIT_REACHED
mock_update_room_metadata.side_effect = MetadataUpdateException("Error notifying")
mock_update_metadata.side_effect = RoomManagementException("Error notifying")
service._handle_egress_ended(mock_data)
@@ -178,9 +179,9 @@ def test_handle_egress_ended_metadata_update_fails(
@mock.patch("core.utils.notify_participants")
@mock.patch("core.utils.update_room_metadata")
@mock.patch("core.services.room_management.RoomManagement.update_metadata")
def test_handle_egress_ended_notification_fails(
mock_update_room_metadata, mock_notify, service
mock_update_metadata, mock_notify, service
):
"""Should raise ActionFailedError when notification fails but still stop recording."""
@@ -200,15 +201,15 @@ def test_handle_egress_ended_notification_fails(
recording.refresh_from_db()
assert recording.status == "stopped"
mock_update_room_metadata.assert_called_once_with(
str(recording.room.id), {}, ["recording_mode", "recording_status"]
mock_update_metadata.assert_called_once_with(
str(recording.room.id), remove_keys=["recording_mode", "recording_status"]
)
@mock.patch("core.utils.notify_participants")
@mock.patch("core.utils.update_room_metadata")
@mock.patch("core.services.room_management.RoomManagement.update_metadata")
def test_handle_egress_ended_recording_not_found(
mock_update_room_metadata, mock_notify, service
mock_update_metadata, mock_notify, service
):
"""Should raise ActionFailedError when recording doesn't exist."""
@@ -223,16 +224,16 @@ def test_handle_egress_ended_recording_not_found(
service._handle_egress_ended(mock_data)
mock_notify.assert_not_called()
mock_update_room_metadata.assert_not_called()
mock_update_metadata.assert_not_called()
recording.refresh_from_db()
assert recording.status == "active"
@mock.patch("core.utils.notify_participants")
@mock.patch("core.utils.update_room_metadata")
@mock.patch("core.services.room_management.RoomManagement.update_metadata")
def test_handle_egress_ended_recording_not_active(
mock_update_room_metadata, mock_notify, service
mock_update_metadata, mock_notify, service
):
"""Should ignore non-active recordings."""
@@ -244,8 +245,8 @@ def test_handle_egress_ended_recording_not_active(
service._handle_egress_ended(mock_data)
mock_notify.assert_not_called()
mock_update_room_metadata.assert_called_once_with(
str(recording.room.id), {}, ["recording_mode", "recording_status"]
mock_update_metadata.assert_called_once_with(
str(recording.room.id), remove_keys=["recording_mode", "recording_status"]
)
recording.refresh_from_db()
@@ -253,9 +254,9 @@ def test_handle_egress_ended_recording_not_active(
@mock.patch("core.utils.notify_participants")
@mock.patch("core.utils.update_room_metadata")
@mock.patch("core.services.room_management.RoomManagement.update_metadata")
def test_handle_egress_ended_recording_not_limit_reached(
mock_update_room_metadata, mock_notify, service
mock_update_metadata, mock_notify, service
):
"""Should ignore egress non-limit-reached statuses."""
@@ -267,16 +268,16 @@ def test_handle_egress_ended_recording_not_limit_reached(
service._handle_egress_ended(mock_data)
mock_notify.assert_not_called()
mock_update_room_metadata.assert_called_once_with(
str(recording.room.id), {}, ["recording_mode", "recording_status"]
mock_update_metadata.assert_called_once_with(
str(recording.room.id), remove_keys=["recording_mode", "recording_status"]
)
assert recording.status == "stopped"
@mock.patch("core.services.livekit_events.MetadataCollectorService")
@mock.patch("core.utils.update_room_metadata")
@mock.patch("core.services.room_management.RoomManagement.update_metadata")
def test_handle_egress_ended_calls_metadata_collector_stop_when_conditions_are_met(
mock_update_room_metadata, mock_collector_class, service, settings
mock_update_metadata, mock_collector_class, service, settings
):
"""Should call MetadataCollectorService.stop when it exists."""
settings.METADATA_COLLECTOR_ENABLED = True
@@ -306,7 +307,7 @@ def test_handle_egress_ended_calls_metadata_collector_stop_when_conditions_are_m
],
)
@mock.patch("core.services.livekit_events.MetadataCollectorService")
@mock.patch("core.utils.update_room_metadata")
@mock.patch("core.services.room_management.RoomManagement.update_metadata")
def test_handle_egress_ended_does_not_call_metadata_collector_stop_when_conditions_not_met(
_, mock_collector_class, metadata_enabled, options, service, settings
): # pylint: disable=too-many-arguments,too-many-positional-arguments
@@ -335,7 +336,7 @@ def test_handle_egress_ended_does_not_call_metadata_collector_stop_when_conditio
"notify_external_services"
)
@mock.patch("core.utils.notify_participants")
@mock.patch("core.utils.update_room_metadata")
@mock.patch("core.services.room_management.RoomManagement.update_metadata")
@pytest.mark.parametrize(
"egress_status",
[EgressStatus.EGRESS_COMPLETE, EgressStatus.EGRESS_LIMIT_REACHED],
@@ -345,7 +346,7 @@ def test_handle_egress_ended_does_not_call_metadata_collector_stop_when_conditio
[(True, "notification_succeeded"), (False, "saved")],
)
def test_handle_egress_ended_finalizes_recording( # noqa: PLR0913
mock_update_room_metadata,
mock_update_metadata,
mock_notify,
mock_notify_external_services,
notify_return_value,
@@ -378,7 +379,7 @@ def test_handle_egress_ended_finalizes_recording( # noqa: PLR0913
"notify_external_services"
)
@mock.patch("core.utils.notify_participants")
@mock.patch("core.utils.update_room_metadata")
@mock.patch("core.services.room_management.RoomManagement.update_metadata")
@pytest.mark.parametrize(
"egress_status, expected_status",
[
@@ -387,7 +388,7 @@ def test_handle_egress_ended_finalizes_recording( # noqa: PLR0913
],
)
def test_handle_egress_ended_does_not_finalize_when_webhooks_enabled( # noqa: PLR0913
mock_update_room_metadata,
mock_update_metadata,
mock_notify,
mock_notify_external_services,
egress_status,
@@ -424,9 +425,9 @@ def test_handle_egress_ended_does_not_finalize_when_webhooks_enabled( # noqa: P
EgressStatus.EGRESS_ABORTED,
],
)
@mock.patch("core.utils.update_room_metadata")
@mock.patch("core.services.room_management.RoomManagement.update_metadata")
def test_handle_egress_ended_does_not_save_on_wrong_status(
mock_update_room_metadata, egress_status, service, settings
mock_update_metadata, egress_status, service, settings
):
"""Shouldn't save on invalid status."""
settings.RECORDING_STORAGE_EVENT_ENABLE = False
@@ -445,9 +446,9 @@ def test_handle_egress_ended_does_not_save_on_wrong_status(
@pytest.mark.parametrize(
"status", ["failed_to_start", "aborted", "failed_to_stop", "saved", "initiated"]
)
@mock.patch("core.utils.update_room_metadata")
@mock.patch("core.services.room_management.RoomManagement.update_metadata")
def test_handle_egress_ended_ignores_non_savable_recording(
mock_update_room_metadata, status, service, settings
mock_update_metadata, status, service, settings
):
"""Should handle non-savable recordings idempotently without raising.
-52
View File
@@ -31,7 +31,6 @@ from livekit.api import ( # pylint: disable=E0611
LiveKitAPI,
SendDataRequest,
TwirpError,
UpdateRoomMetadataRequest,
VideoGrants,
)
@@ -258,57 +257,6 @@ async def notify_participants(room_name: str, notification_data: dict):
await lkapi.aclose()
class MetadataUpdateException(Exception):
"""Room's metadata update fails."""
@async_to_sync
async def update_room_metadata(
room_name: str, metadata: dict, remove_keys: Optional[list[str]] = None
):
"""Update LiveKit room metadata by merging new values with existing metadata.
Args:
room_name: Name of the room to update
metadata: Dictionary of metadata key-values to add/update
remove_keys: Optional list of keys to remove from existing metadata.
"""
lkapi = create_livekit_client()
try:
response = await lkapi.room.list_rooms(
ListRoomsRequest(
names=[room_name],
)
)
if not response.rooms:
return
room = response.rooms[0]
existing_metadata = json.loads(room.metadata) if room.metadata else {}
if remove_keys:
for key in remove_keys:
existing_metadata.pop(key, None)
updated_metadata = {**existing_metadata, **metadata}
await lkapi.room.update_room_metadata(
UpdateRoomMetadataRequest(
room=room_name, metadata=json.dumps(updated_metadata).encode("utf-8")
)
)
except TwirpError as e:
raise MetadataUpdateException(
f"Failed to update metadata for room {room_name}: {e}"
) from e
finally:
await lkapi.aclose()
ALPHANUMERIC_CHARSET = string.ascii_letters + string.digits
+1 -1
View File
@@ -7,7 +7,7 @@ build-backend = "uv_build"
[project]
name = "meet"
version = "1.23.0"
version = "1.24.0"
authors = [{ "name" = "DINUM", "email" = "dev@mail.numerique.gouv.fr" }]
classifiers = [
"Development Status :: 5 - Production/Stable",
+1 -1
View File
@@ -1187,7 +1187,7 @@ wheels = [
[[package]]
name = "meet"
version = "1.23.0"
version = "1.24.0"
source = { editable = "." }
dependencies = [
{ name = "aiohttp" },
+32 -26
View File
@@ -1,12 +1,12 @@
{
"name": "meet",
"version": "1.23.0",
"version": "1.24.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "meet",
"version": "1.23.0",
"version": "1.24.0",
"dependencies": {
"@fontsource-variable/atkinson-hyperlegible-next": "5.2.6",
"@fontsource-variable/lexend": "5.2.11",
@@ -15,11 +15,11 @@
"@livekit/components-react": "2.9.21",
"@livekit/components-styles": "1.2.0",
"@livekit/track-processors": "0.7.2",
"@mediapipe/tasks-vision": "0.10.14",
"@mediapipe/tasks-vision": "0.10.35",
"@pandacss/preset-panda": "1.11.3",
"@react-types/overlays": "3.10.0",
"@remixicon/react": "4.9.0",
"@tanstack/react-query": "5.101.0",
"@tanstack/react-query": "5.101.1",
"@timephy/rnnoise-wasm": "1.0.0",
"crisp-sdk-web": "1.1.2",
"derive-valtio": "0.2.0",
@@ -29,8 +29,8 @@
"i18next-browser-languagedetector": "8.2.1",
"i18next-parser": "9.4.0",
"i18next-resources-to-backend": "1.2.1",
"livekit-client": "2.19.2",
"posthog-js": "1.391.2",
"livekit-client": "2.20.0",
"posthog-js": "1.395.0",
"react": "18.3.1",
"react-aria": "3.50.0",
"react-aria-components": "1.19.0",
@@ -1028,9 +1028,9 @@
"license": "Apache-2.0"
},
"node_modules/@livekit/protocol": {
"version": "1.45.8",
"resolved": "https://registry.npmjs.org/@livekit/protocol/-/protocol-1.45.8.tgz",
"integrity": "sha512-Q+l57E7w/xxOBFVWzdX5rkAZO7ffyF+rlDzNUYq2SU114+5aTyCq+PK4unaEVDNd4952Af7wteKr3sOgasGuaA==",
"version": "1.46.6",
"resolved": "https://registry.npmjs.org/@livekit/protocol/-/protocol-1.46.6.tgz",
"integrity": "sha512-upzlHP1vi/kZ/QqALZTFskQ0ifqc2f15RKucHYOsIHJsaXvEYanG75mAb7o+Yomfs4XhQ4BaRsdY+TFHXpaqrg==",
"license": "Apache-2.0",
"dependencies": {
"@bufbuild/protobuf": "^1.10.0"
@@ -1049,12 +1049,18 @@
"livekit-client": "^1.12.0 || ^2.1.0"
}
},
"node_modules/@mediapipe/tasks-vision": {
"node_modules/@livekit/track-processors/node_modules/@mediapipe/tasks-vision": {
"version": "0.10.14",
"resolved": "https://registry.npmjs.org/@mediapipe/tasks-vision/-/tasks-vision-0.10.14.tgz",
"integrity": "sha512-vOifgZhkndgybdvoRITzRkIueWWSiCKuEUXXK6Q4FaJsFvRJuwgg++vqFUMlL0Uox62U5aEXFhHxlhV7Ja5e3Q==",
"license": "Apache-2.0"
},
"node_modules/@mediapipe/tasks-vision": {
"version": "0.10.35",
"resolved": "https://registry.npmjs.org/@mediapipe/tasks-vision/-/tasks-vision-0.10.35.tgz",
"integrity": "sha512-HOvadwVRE6JC+45nyYhmnywnr5h/J8KZvOeUNVOG9q/0875pZgItznFB9bRTvLc264YSJqiZ1NsIpCStJw/egg==",
"license": "Apache-2.0"
},
"node_modules/@modelcontextprotocol/sdk": {
"version": "1.29.0",
"resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.29.0.tgz",
@@ -2415,9 +2421,9 @@
}
},
"node_modules/@tanstack/query-core": {
"version": "5.101.0",
"resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.101.0.tgz",
"integrity": "sha512-cQetA74EB+seWySv1TTKr828TnP0u39m6LykwDXIo84SNortpDkp30TMEjkqtYCNP9c40uT/iwl6MLiufEt0Ow==",
"version": "5.101.1",
"resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.101.1.tgz",
"integrity": "sha512-Y6Y92dkXtNqx67m2pMSxUsA3zOCwv862JexZRP8/EPwvKXMPu9m8rv43spiXWzOUIggQ3SQApttALStzhA8B4g==",
"license": "MIT",
"funding": {
"type": "github",
@@ -2436,12 +2442,12 @@
}
},
"node_modules/@tanstack/react-query": {
"version": "5.101.0",
"resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.101.0.tgz",
"integrity": "sha512-rLlJXSpkqfizLWgkR5+eLeIk0MvTx/meEIR7LRjxic+qxiQP8zVjq7BqQkiCMNLQBlLfuOLqqr6KO5GtrDlmSg==",
"version": "5.101.1",
"resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.101.1.tgz",
"integrity": "sha512-ZnONUuQKJe1bJMStXUL1s5uKN9FcfC28j5cK+iDZcdSHtUv1wtin1cGc/Oewhf2Oc4eKY7lggtpvT/AbMmhHew==",
"license": "MIT",
"dependencies": {
"@tanstack/query-core": "5.101.0"
"@tanstack/query-core": "5.101.1"
},
"funding": {
"type": "github",
@@ -8119,13 +8125,13 @@
"license": "MIT"
},
"node_modules/livekit-client": {
"version": "2.19.2",
"resolved": "https://registry.npmjs.org/livekit-client/-/livekit-client-2.19.2.tgz",
"integrity": "sha512-Kvk07QYDWRAbmYNLRll04ZIuxMQobW/oLPYnmR1kCy8GGHpU0gqyHf704Rz+29zfy8IJZRjKqeVbzGSKn9sumw==",
"version": "2.20.0",
"resolved": "https://registry.npmjs.org/livekit-client/-/livekit-client-2.20.0.tgz",
"integrity": "sha512-RIJcpvBmOmwz3jTj3rmdY6Dzr55HrhcaJjMgY+HSmoEM+yIRyA40m7r8UKv0hnZWM3z/AYhP1q8C8ciz5UWFKQ==",
"license": "Apache-2.0",
"dependencies": {
"@livekit/mutex": "1.1.1",
"@livekit/protocol": "1.45.8",
"@livekit/protocol": "1.46.6",
"events": "^3.3.0",
"jose": "^6.1.0",
"loglevel": "^1.9.2",
@@ -9130,13 +9136,13 @@
"license": "MIT"
},
"node_modules/posthog-js": {
"version": "1.391.2",
"resolved": "https://registry.npmjs.org/posthog-js/-/posthog-js-1.391.2.tgz",
"integrity": "sha512-q0DZN6ljchSnAFJIXf+sQFTPlsLjTlRa+TvrL+QRb6413BGtib/MNiQy1bnwLKt8KR+f6xJYvkqdLyty9s4Aww==",
"version": "1.395.0",
"resolved": "https://registry.npmjs.org/posthog-js/-/posthog-js-1.395.0.tgz",
"integrity": "sha512-5iTb00CGt2eQUUiBQysQiX89RAbCN6wK2sDNzvs9zv0alaY8mJ0ZySrUD3LQ+XyLhgM5pCpacBuUwChqiYDLDw==",
"license": "SEE LICENSE IN LICENSE",
"dependencies": {
"@posthog/core": "^1.35.3",
"@posthog/types": "^1.390.2",
"@posthog/core": "^1.38.0",
"@posthog/types": "^1.391.1",
"core-js": "^3.38.1",
"dompurify": "^3.3.2",
"fflate": "^0.4.8",
+5 -5
View File
@@ -1,7 +1,7 @@
{
"name": "meet",
"private": true,
"version": "1.23.0",
"version": "1.24.0",
"type": "module",
"scripts": {
"dev": "panda codegen && vite",
@@ -22,11 +22,11 @@
"@livekit/components-react": "2.9.21",
"@livekit/components-styles": "1.2.0",
"@livekit/track-processors": "0.7.2",
"@mediapipe/tasks-vision": "0.10.14",
"@mediapipe/tasks-vision": "0.10.35",
"@pandacss/preset-panda": "1.11.3",
"@react-types/overlays": "3.10.0",
"@remixicon/react": "4.9.0",
"@tanstack/react-query": "5.101.0",
"@tanstack/react-query": "5.101.1",
"@timephy/rnnoise-wasm": "1.0.0",
"crisp-sdk-web": "1.1.2",
"derive-valtio": "0.2.0",
@@ -36,8 +36,8 @@
"i18next-browser-languagedetector": "8.2.1",
"i18next-parser": "9.4.0",
"i18next-resources-to-backend": "1.2.1",
"livekit-client": "2.19.2",
"posthog-js": "1.391.2",
"livekit-client": "2.20.0",
"posthog-js": "1.395.0",
"react": "18.3.1",
"react-aria": "3.50.0",
"react-aria-components": "1.19.0",
@@ -13,8 +13,7 @@ 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}
* The subset of LiveKit's room metadata this hook actually uses.
*/
type RoomLiveKitMetadata = {
configuration?: RoomConfiguration
+1 -1
View File
@@ -1,4 +1,4 @@
apiVersion: v2
type: application
name: meet
version: 0.0.26
version: 0.0.27
+170 -1
View File
@@ -1,7 +1,7 @@
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<OfficeApp xmlns="http://schemas.microsoft.com/office/appforoffice/1.1" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:bt="http://schemas.microsoft.com/office/officeappbasictypes/1.0" xmlns:mailappor="http://schemas.microsoft.com/office/mailappversionoverrides/1.0" xsi:type="MailApp">
<Id>{{ .id }}</Id>
<Version>0.0.2.0</Version>
<Version>1.0.0.0</Version>
<ProviderName>{{ .appName }}</ProviderName>
<DefaultLocale>fr-FR</DefaultLocale>
<DisplayName DefaultValue="{{ .appName }}"/>
@@ -205,5 +205,174 @@
</bt:String>
</bt:LongStrings>
</Resources>
<!-- ─── V1.1 override: MUST be nested inside the V1.0 block, after Resources ─── -->
<VersionOverrides xmlns="http://schemas.microsoft.com/office/mailappversionoverrides/1.1" xsi:type="VersionOverridesV1_1">
<Requirements>
<bt:Sets DefaultMinVersion="1.8">
<bt:Set Name="Mailbox"/>
</bt:Sets>
</Requirements>
<Hosts>
<Host xsi:type="MailHost">
<DesktopFormFactor>
<SupportsSharedFolders>true</SupportsSharedFolders>
<FunctionFile resid="Commands.Url"/>
<!-- ─── Mail: Read ─────────────────────────────────────────── -->
<ExtensionPoint xsi:type="MessageReadCommandSurface">
<OfficeTab id="TabDefault">
<Group id="msgReadGroup">
<Label resid="GroupLabel"/>
<Control xsi:type="Button" id="msgReadOpenPaneButton">
<Label resid="TaskpaneButton.Label"/>
<Supertip>
<Title resid="TaskpaneButton.Label"/>
<Description resid="TaskpaneButton.Tooltip"/>
</Supertip>
<Icon>
<bt:Image size="16" resid="Icon.16x16"/>
<bt:Image size="32" resid="Icon.32x32"/>
<bt:Image size="80" resid="Icon.80x80"/>
</Icon>
<Action xsi:type="ShowTaskpane">
<SourceLocation resid="Taskpane.Url"/>
</Action>
</Control>
</Group>
</OfficeTab>
</ExtensionPoint>
<!-- ─── Mail: Compose ─────────────────────────────────────── -->
<ExtensionPoint xsi:type="MessageComposeCommandSurface">
<OfficeTab id="TabDefault">
<Group id="msgComposeGroup">
<Label resid="GroupLabel"/>
<Control xsi:type="Button" id="msgComposeGenerateLinkButton">
<Label resid="GenerateLink.Label"/>
<Supertip>
<Title resid="GenerateLink.Label"/>
<Description resid="GenerateLink.Tooltip"/>
</Supertip>
<Icon>
<bt:Image size="16" resid="Icon.16x16"/>
<bt:Image size="32" resid="Icon.32x32"/>
<bt:Image size="80" resid="Icon.80x80"/>
</Icon>
<Action xsi:type="ExecuteFunction">
<FunctionName>generateMeetingLinkFromMail</FunctionName>
</Action>
</Control>
<Control xsi:type="Button" id="msgComposeOpenPaneButton">
<Label resid="TaskpaneButton.Label"/>
<Supertip>
<Title resid="TaskpaneButton.Label"/>
<Description resid="TaskpaneButton.Tooltip"/>
</Supertip>
<Icon>
<bt:Image size="16" resid="Settings.16x16"/>
<bt:Image size="32" resid="Settings.32x32"/>
<bt:Image size="80" resid="Settings.80x80"/>
</Icon>
<Action xsi:type="ShowTaskpane">
<SourceLocation resid="Taskpane.Url"/>
</Action>
</Control>
</Group>
</OfficeTab>
</ExtensionPoint>
<!-- ─── Calendar: Compose (New/Edit appointment) ──────────── -->
<ExtensionPoint xsi:type="AppointmentOrganizerCommandSurface">
<OfficeTab id="TabDefault">
<Group id="apptComposeGroup">
<Label resid="GroupLabel"/>
<Control xsi:type="Button" id="apptGenerateLinkButton">
<Label resid="GenerateLink.Label"/>
<Supertip>
<Title resid="GenerateLink.Label"/>
<Description resid="GenerateLink.Tooltip"/>
</Supertip>
<Icon>
<bt:Image size="16" resid="Icon.16x16"/>
<bt:Image size="32" resid="Icon.32x32"/>
<bt:Image size="80" resid="Icon.80x80"/>
</Icon>
<Action xsi:type="ExecuteFunction">
<FunctionName>generateMeetingLinkFromCalendar</FunctionName>
</Action>
</Control>
<Control xsi:type="Button" id="apptOpenSettingsButton">
<Label resid="OpenSettings.Label"/>
<Supertip>
<Title resid="OpenSettings.Label"/>
<Description resid="OpenSettings.Tooltip"/>
</Supertip>
<Icon>
<bt:Image size="16" resid="Settings.16x16"/>
<bt:Image size="32" resid="Settings.32x32"/>
<bt:Image size="80" resid="Settings.80x80"/>
</Icon>
<Action xsi:type="ShowTaskpane">
<SourceLocation resid="Taskpane.Url"/>
</Action>
</Control>
</Group>
</OfficeTab>
</ExtensionPoint>
</DesktopFormFactor>
</Host>
</Hosts>
<Resources>
<bt:Images>
<bt:Image id="Settings.16x16" DefaultValue="{{ .baseUrl }}/assets/settings-16.png"/>
<bt:Image id="Settings.32x32" DefaultValue="{{ .baseUrl }}/assets/settings-32.png"/>
<bt:Image id="Settings.80x80" DefaultValue="{{ .baseUrl }}/assets/settings-80.png"/>
<bt:Image id="Add.16x16" DefaultValue="{{ .baseUrl }}/assets/add-16.png"/>
<bt:Image id="Add.32x32" DefaultValue="{{ .baseUrl }}/assets/add-32.png"/>
<bt:Image id="Add.80x80" DefaultValue="{{ .baseUrl }}/assets/add-80.png"/>
<bt:Image id="Icon.16x16" DefaultValue="{{ .baseUrl }}/assets/icon-16.png"/>
<bt:Image id="Icon.32x32" DefaultValue="{{ .baseUrl }}/assets/icon-32.png"/>
<bt:Image id="Icon.80x80" DefaultValue="{{ .baseUrl }}/assets/icon-80.png"/>
</bt:Images>
<bt:Urls>
<bt:Url id="Commands.Url" DefaultValue="{{ .baseUrl }}/commands.html"/>
<bt:Url id="Taskpane.Url" DefaultValue="{{ .baseUrl }}/taskpane.html"/>
</bt:Urls>
<bt:ShortStrings>
<!-- Default (French) -->
<bt:String id="GroupLabel" DefaultValue="{{ .appName }}"/>
<bt:String id="GenerateLink.Label" DefaultValue="Ajouter un lien {{ .appName }}">
<bt:Override Locale="en-US" Value="Add a {{ .appName }} link"/>
<bt:Override Locale="de-DE" Value="{{ .appName }}-Link hinzufügen"/>
</bt:String>
<bt:String id="TaskpaneButton.Label" DefaultValue="Ouvrir les paramètres">
<bt:Override Locale="en-US" Value="Open settings"/>
<bt:Override Locale="de-DE" Value="Einstellungen öffnen"/>
</bt:String>
<bt:String id="OpenSettings.Label" DefaultValue="Paramètres">
<bt:Override Locale="en-US" Value="Settings"/>
<bt:Override Locale="de-DE" Value="Einstellungen"/>
</bt:String>
</bt:ShortStrings>
<bt:LongStrings>
<bt:String id="GenerateLink.Tooltip" DefaultValue="Génère un lien de réunion {{ .appName }} et l'insère dans l'événement.">
<bt:Override Locale="de-DE" Value="Generiert einen {{ .appName }}-Besprechungslink und fügt ihn in den Termin ein."/>
<bt:Override Locale="en-US" Value="Generates a {{ .appName }} meeting link and inserts it into the item."/>
</bt:String>
<bt:String id="TaskpaneButton.Tooltip" DefaultValue="Ouvre les paramètres de connexion {{ .appName }}.">
<bt:Override Locale="de-DE" Value="Öffnet die {{ .appName }}-Verbindungseinstellungen."/>
<bt:Override Locale="en-US" Value="Opens the {{ .appName }} connection settings."/>
</bt:String>
<bt:String id="OpenSettings.Tooltip" DefaultValue="Ouvre les paramètres de connexion {{ .appName }}.">
<bt:Override Locale="de-DE" Value="Öffnet die {{ .appName }}-Verbindungseinstellungen."/>
<bt:Override Locale="en-US" Value="Opens the {{ .appName }} connection settings."/>
</bt:String>
</bt:LongStrings>
</Resources>
</VersionOverrides>
</VersionOverrides>
</OfficeApp>
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "mail_mjml",
"version": "1.23.0",
"version": "1.24.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "mail_mjml",
"version": "1.23.0",
"version": "1.24.0",
"license": "MIT",
"dependencies": {
"@html-to/text-cli": "0.6.0",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "mail_mjml",
"version": "1.23.0",
"version": "1.24.0",
"description": "An util to generate html and text django's templates from mjml templates",
"type": "module",
"dependencies": {
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "sdk",
"version": "1.23.0",
"version": "1.24.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "sdk",
"version": "1.23.0",
"version": "1.24.0",
"license": "ISC",
"workspaces": [
"./library",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "sdk",
"version": "1.23.0",
"version": "1.24.0",
"author": "",
"license": "ISC",
"description": "",
+1 -1
View File
@@ -1,7 +1,7 @@
[project]
name = "summary"
version = "1.23.0"
version = "1.24.0"
dependencies = [
"fastapi[standard]>=0.105.0",
"uvicorn>=0.24.0",
+18 -3
View File
@@ -18,7 +18,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_lasuite_docs
from summary.core.file_service import FileService, FileServiceException, TranscribeError
from summary.core.file_service import (
CorruptedAudioFile,
FileService,
FileServiceException,
TranscribeError,
)
from summary.core.llm_service import LLMException, LLMObservability, LLMService
from summary.core.locales import get_locale
from summary.core.models import (
@@ -147,10 +152,20 @@ def transcribe_audio(
# Mimic OpenAI's timeout settings
timeout=(60, 10 * 60),
)
if res.status_code == 400:
logger.info(
"WhisperX transcription failed, "
"likely due to a corrupted audio file: %s",
res.text,
)
raise CorruptedAudioFile("WhisperX coudln't decode the audio file.")
try:
res.raise_for_status()
except requests.exceptions.HTTPError as e:
raise RuntimeError("WhisperX transcription failed, %s", res.text) from e
except requests.exceptions.HTTPError:
logger.exception("WhisperX transcription failed")
# We reraise the error so that it can be retried by celery
raise
transcription_json: dict[str, Any] = res.json()
# We remove the "usage" key from the transcription_json dictionary
+6
View File
@@ -41,6 +41,12 @@ class NoAudioInFileError(TranscribeError):
error_code = "no_audio_in_file"
class CorruptedAudioFile(TranscribeError):
"""Raised when a media file does not contain any audio."""
error_code = "corrupted_audio_file"
def _get_duration_from_packets(local_path: Path) -> float:
"""Estimate duration from audio packet timestamps."""
# Run ffprobe to inspect the first audio stream in the file.
+7 -1
View File
@@ -96,7 +96,13 @@ class TranscribeWebhookFailurePayload(BaseWebhook):
# we authorized any other string than the one in the literal
# to avoid causing a breaking change on the client side
error_code: (
Literal["unknown_error", "no_audio_in_file", "media_duration_too_long"] | str
Literal[
"unknown_error",
"no_audio_in_file",
"media_duration_too_long",
"corrupted_audio_file",
]
| str
) = Field(title="Error code", description="The error code.")
+1 -1
View File
@@ -275,7 +275,7 @@ def _build_participant_timelines(
def _build_speaker_timelines(transcription: Any) -> dict[str, list[Interval]]:
"""Build interval timelines from WhisperX transcription segments."""
intervals: dict[str, list[Interval]] = {}
segments = transcription.segments if hasattr(transcription, "segments") else []
segments = transcription.get("segments") or []
max_word_duration = settings.resolve_speaker_identities_max_word_duration
for segment in segments:
+20 -23
View File
@@ -1,7 +1,6 @@
"""Tests for the speaker-to-user assignment service."""
import math
from dataclasses import dataclass, field
from datetime import datetime
from summary.core import user_assign
@@ -17,11 +16,9 @@ from summary.core.user_assign import (
)
@dataclass
class FakeTranscription:
"""Mimics the OpenAI Transcription pydantic model for testing."""
segments: list = field(default_factory=list)
def make_transcription(segments: list | None = None) -> dict:
"""Build a WhisperX transcription dict, matching WhisperXResponse.model_dump()."""
return {"segments": segments or []}
RECORDING_START = datetime.fromisoformat("2026-03-17T15:30:33.000001")
@@ -68,7 +65,7 @@ METADATA_SINGLE_USER = {
],
}
DIARIZATION_SINGLE_SPEAKER = FakeTranscription(
DIARIZATION_SINGLE_SPEAKER = make_transcription(
segments=[
{
"start": 1.363,
@@ -182,7 +179,7 @@ class TestBuildSpeakerTimelines:
def test_segment_without_words_falls_back_to_segment_bounds(self):
"""Segments missing a `words` key use the segment start/end as one interval."""
transcription = FakeTranscription(
transcription = make_transcription(
segments=[{"start": 1.5, "end": 3.5, "speaker": "SPEAKER_00"}],
)
result = _build_speaker_timelines(transcription)
@@ -190,7 +187,7 @@ class TestBuildSpeakerTimelines:
def test_segment_with_only_none_word_timestamps_falls_back(self):
"""If every word has None start/end, fall back to segment bounds."""
transcription = FakeTranscription(
transcription = make_transcription(
segments=[
{
"start": 1.0,
@@ -208,7 +205,7 @@ class TestBuildSpeakerTimelines:
def test_short_words_only_uses_segment_start_and_last_word_end(self):
"""With no overly long words, the interval runs segment start to end."""
transcription = FakeTranscription(
transcription = make_transcription(
segments=[
{
"start": 1.0,
@@ -231,7 +228,7 @@ class TestBuildSpeakerTimelines:
max_word_duration = (
user_assign.settings.resolve_speaker_identities_max_word_duration
)
transcription = FakeTranscription(
transcription = make_transcription(
segments=[
{
"start": 0.0,
@@ -252,7 +249,7 @@ class TestBuildSpeakerTimelines:
def test_long_word_in_middle_splits_segment(self):
"""Short words around a long word produce two intervals (before-cap + after)."""
transcription = FakeTranscription(
transcription = make_transcription(
segments=[
{
"start": 0.0,
@@ -275,7 +272,7 @@ class TestBuildSpeakerTimelines:
def test_tail_word_is_capped_at_max_duration(self):
"""The trailing word's end is capped at word.start + max_word_duration."""
transcription = FakeTranscription(
transcription = make_transcription(
segments=[
{
"start": 0.0,
@@ -302,7 +299,7 @@ class TestBuildSpeakerTimelines:
update={"resolve_speaker_identities_enable_split_on_words": False},
),
)
transcription = FakeTranscription(
transcription = make_transcription(
segments=[
{
"start": 0.0,
@@ -322,7 +319,7 @@ class TestBuildSpeakerTimelines:
def test_multiple_speakers_keep_separate_timelines(self):
"""Segments from different speakers populate independent timeline entries."""
transcription = FakeTranscription(
transcription = make_transcription(
segments=[
{
"start": 0.0,
@@ -402,7 +399,7 @@ class TestResolveSpeakerIdentities:
],
"participants": [{"participantId": "user-a", "name": "Shared Mic"}],
}
transcription = FakeTranscription(
transcription = make_transcription(
segments=[
{"start": 1.0, "end": 3.0, "speaker": "SPEAKER_00"},
{"start": 5.0, "end": 7.0, "speaker": "SPEAKER_01"},
@@ -445,7 +442,7 @@ class TestResolveSpeakerIdentities:
{"participantId": "user-b", "name": "Bob"},
],
}
transcription = FakeTranscription(
transcription = make_transcription(
segments=[
{"start": 1.5, "end": 3.5, "speaker": "SPEAKER_00"},
{"start": 5.5, "end": 7.5, "speaker": "SPEAKER_01"},
@@ -493,7 +490,7 @@ class TestResolveSpeakerIdentities:
{"participantId": "user-b", "name": "Bob"},
],
}
transcription = FakeTranscription(
transcription = make_transcription(
segments=[
{"start": 1.5, "end": 5.5, "speaker": "SPEAKER_00"},
{"start": 4.0, "end": 7.5, "speaker": "SPEAKER_01"},
@@ -529,7 +526,7 @@ class TestResolveSpeakerIdentities:
],
"participants": [{"participantId": "user-a", "name": "Brief User"}],
}
transcription = FakeTranscription(
transcription = make_transcription(
segments=[
{"start": 1.0, "end": 10.0, "speaker": "SPEAKER_00"},
],
@@ -561,7 +558,7 @@ class TestResolveSpeakerIdentities:
],
"participants": [{"participantId": "user-a", "name": "Early User"}],
}
transcription = FakeTranscription(
transcription = make_transcription(
segments=[
{"start": 0.0, "end": 3.0, "speaker": "SPEAKER_00"},
],
@@ -576,7 +573,7 @@ class TestResolveSpeakerIdentities:
"""No segments produces empty result."""
result = resolve_speaker_identities(
METADATA_SINGLE_USER,
FakeTranscription(segments=[]),
make_transcription(segments=[]),
RECORDING_START,
RECORDING_END,
)
@@ -584,7 +581,7 @@ class TestResolveSpeakerIdentities:
def test_segment_without_speaker_ignored(self):
"""Segments missing speaker key are skipped."""
transcription = FakeTranscription(
transcription = make_transcription(
segments=[
{"start": 1.0, "end": 3.0, "text": "no speaker"},
],
@@ -608,7 +605,7 @@ class TestResolveSpeakerIdentities:
],
"participants": [{"participantId": "user-a", "name": "Still Talking"}],
}
transcription = FakeTranscription(
transcription = make_transcription(
segments=[
{"start": 2.0, "end": 9.0, "speaker": "SPEAKER_00"},
],