Compare commits

...

11 Commits

Author SHA1 Message Date
leo b56bcc38c8 ⬆️(dependencies) update python dependencies
Update python dependencies.
2026-07-06 20:34:09 +02:00
snyk-bot c3abd0441f ⬆️(agents) upgrade to python 3.14 slim
The following vulnerabilities are fixed with an upgrade:
- https://snyk.io/vuln/SNYK-DEBIAN13-OPENSSL-17269387
- https://snyk.io/vuln/SNYK-DEBIAN13-OPENSSL-17269392
- https://snyk.io/vuln/SNYK-DEBIAN13-OPENSSL-17269400
- https://snyk.io/vuln/SNYK-DEBIAN13-OPENSSL-17269409
- https://snyk.io/vuln/SNYK-DEBIAN13-OPENSSL-17269415
2026-07-06 20:06:55 +02:00
snyk-bot 79245389ce ⬆️(frontend) dedupe react-stately, align react-aria packages
Add react-aria 3.49.0 + react-stately 3.47.0 as direct deps and bump
react-aria-components to 1.18.0 so react-stately resolves to one version.
Fixes the nominal Timer type clash and missing @react-stately/toast
imports introduced by the React Aria v1.17 monopackage consolidation.
2026-07-06 20:00:52 +02:00
Florent Chehab 3b3f992834 🐛(summary) support media files with bad streams
Rarely media files may have one or multiple
empty streams when they are badly formatted.
The extract metadata code would crash when that happened.
We now avoid crashing and create a clean file
from the bad one to make sure API calls with that data
works properly (observed some failures otherwise
in my tests).
2026-07-06 16:39:40 +02:00
lebaudantoine a98dc1484a 🩹(backend) fix case-insensitive email deduplication in merge command
The management command that merges users with duplicate emails was
comparing emails in a case-insensitive manner, which left some
duplicates in the database when their emails only differed by case.
2026-07-06 16:03:50 +02:00
snyk-bot dca24a1b25 ⬆️(frontend) upgrade i18next from 26.2.0 to 26.3.1
Snyk has created this PR to upgrade i18next from 26.2.0 to 26.3.1.

See this package in npm:
i18next

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-06 16:00:58 +02:00
leo 74b791e207 🐛(makefile) fix passing args to tests in makefile
When using Makefile to launch tests, passing flags as well as specific
classes (using "::") was broken. This PR fixes this issue, by adding
an `ARGS` argument allowing to do
`make test ARGS="core/tests/foo.py::Test::x -vv"`.
2026-07-06 15:17:05 +02:00
Bastien Ogier df1495c97b 🚀(paas) fix scalingo frontend build failure
(paas) fix scalingo frontend build failure
2026-07-03 19:40:15 +02:00
Florent Chehab c95e1c67bd ⬆️(summary) update ffmpeg to 8.1.2
Update ffmpeg to 8.1.2 inside summary docker image.
Maintenance task.
2026-07-03 18:51:11 +02:00
Florent Chehab f115c83752 ⬆️(summary) update alpine base image
Update alpine docker base image to 3.24.
Maintenance routine task
2026-07-03 18:51:11 +02:00
snyk-bot ebfcb42a7d fix: upgrade posthog-js from 1.382.0 to 1.386.5
Snyk has created this PR to upgrade posthog-js from 1.382.0 to 1.386.5.

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-03 17:53:56 +02:00
31 changed files with 1490 additions and 2992 deletions
+13
View File
@@ -8,6 +8,18 @@ and this project adheres to
## [Unreleased]
### Changed
- ⬆️(agents) upgrade to python 3.14 slim
- ⬆️(dependencies) update python dependencies
### Fixed
- 🚀(front) fix frontend build failure
- 🐛(makefile) fix args in make test
- 🩹(backend) fix case-insensitive email deduplication in merge command
- 🐛(summary) support media files with bad streams #1478
## [1.22.0] - 2026-07-03
### Added
@@ -26,6 +38,7 @@ and this project adheres to
- ♻️(env) refactor env variables handling
- 🚸(frontend) use "Advanced" instead of "Premium" in the sidepanel
- ♿️(frontend) make fullscreen share warning keyboard accessible #1459
- ⬆️(summary) update docker alpine to 3.24 & ffmpeg to 8.1.2 #1471
### Fixed
+13 -12
View File
@@ -211,24 +211,25 @@ lint-pylint: ## lint back-end python sources with pylint only on changed files f
@$(COMPOSE_RUN_APP) pylint meet demo core
.PHONY: lint-pylint
test: ## run project tests
@$(MAKE) test-back-parallel
@$(MAKE) test-summary
test: ## run project tests; pass extra pytest args via ARGS, e.g. `make test ARGS="-vv"`
@args="$(ARGS) $(filter-out $@,$(MAKECMDGOALS))" && \
$(MAKE) test-back-parallel ARGS="$${args}" && \
$(MAKE) test-summary ARGS="$${args}"
.PHONY: test
test-back: ## run back-end tests
@args="$(filter-out $@,$(MAKECMDGOALS))" && \
bin/pytest $${args:-${1}}
test-back: ## run back-end tests (pass extra pytest args via ARGS)
@args="$(ARGS) $(filter-out $@,$(MAKECMDGOALS))" && \
bin/pytest $${args}
.PHONY: test-back
test-back-parallel: ## run all back-end tests in parallel
@args="$(filter-out $@,$(MAKECMDGOALS))" && \
bin/pytest -n auto $${args:-${1}}
test-back-parallel: ## run all back-end tests in parallel (pass extra pytest args via ARGS)
@args="$(ARGS) $(filter-out $@,$(MAKECMDGOALS))" && \
bin/pytest -n auto $${args}
.PHONY: test-back-parallel
test-summary: ## run summary tests
@args="$(filter-out $@,$(MAKECMDGOALS))" && \
bin/pytest-summary $${args:-${1}}
test-summary: ## run summary tests (pass extra pytest args via ARGS)
@args="$(ARGS) $(filter-out $@,$(MAKECMDGOALS))" && \
bin/pytest-summary $${args}
.PHONY: test-summary
makemigrations: ## run django makemigrations for the Meet project.
+1 -1
View File
@@ -1,4 +1,4 @@
FROM python:3.13.13-slim AS base
FROM python:3.14.6-slim AS base
# Install system dependencies required by LiveKit
RUN apt-get update && apt-get install -y \
+5 -5
View File
@@ -4,18 +4,18 @@ name = "agents"
version = "1.22.0"
requires-python = ">=3.12"
dependencies = [
"livekit-agents==1.5.13",
"livekit-plugins-deepgram==1.5.13",
"livekit-plugins-silero==1.5.13",
"livekit-agents==1.6.4",
"livekit-plugins-deepgram==1.6.4",
"livekit-plugins-silero==1.6.4",
"livekit-plugins-kyutai-lasuite==0.0.6",
"python-dotenv==1.2.2",
"protobuf==6.33.6",
"protobuf>=6.33.5",
"minio==7.2.20"
]
[project.optional-dependencies]
dev = [
"ruff==0.15.14",
"ruff==0.15.19",
]
[tool.uv]
+790 -717
View File
File diff suppressed because it is too large Load Diff
@@ -6,6 +6,7 @@ from django.contrib.auth import get_user_model
from django.core.management.base import BaseCommand, CommandError
from django.db import transaction
from django.db.models import Count
from django.db.models.functions import Lower
from core.models import File, RecordingAccess, ResourceAccess, RoleChoices
@@ -20,11 +21,14 @@ ROLE_PRIORITY = {
class Command(BaseCommand):
"""
Merge duplicate users sharing the same email into the most recently created one.
Merge duplicate users sharing the same email (case-insensitive) into the
most recently created one.
The KEPT user is the most recently created. All room memberships, recording
accesses and files are transferred to it. When a conflict exists, the
higher-privilege role wins. Stale users are then deleted.
Emails are compared case-insensitively, so 'John@Example.com' and
'john@example.com' are treated as duplicates. The KEPT user is the most
recently created. All room memberships, recording accesses and files are
transferred to it. When a conflict exists, the higher-privilege role wins.
Stale users are then deleted.
Each email group is processed inside a single database transaction.
"""
@@ -56,13 +60,16 @@ class Command(BaseCommand):
users_qs = users_qs.filter(email__icontains=email_filter)
self.stdout.write(f"[INFO] Filtering emails containing '{email_filter}'.\n")
# Group emails case-insensitively so 'John@X.com' and 'john@x.com'
# are detected as duplicates of each other.
duplicate_emails = (
users_qs.exclude(email__isnull=True)
.exclude(email="")
.values("email")
.annotate(email_lower=Lower("email"))
.values("email_lower")
.annotate(cnt=Count("id"))
.filter(cnt__gt=1)
.values_list("email", flat=True)
.values_list("email_lower", flat=True)
)
if not duplicate_emails:
@@ -78,9 +85,12 @@ class Command(BaseCommand):
failed_emails = []
for email in duplicate_emails:
# Case-insensitive lookup to fetch every casing variant of the email.
# Secondary sort by id ensures a stable, deterministic order when
# created_at timestamps are equal (common in tests and bulk imports).
users = list(User.objects.filter(email=email).order_by("created_at", "id"))
users = list(
User.objects.filter(email__iexact=email).order_by("created_at", "id")
)
kept_user = users[-1]
stale_users = users[:-1]
@@ -120,6 +130,10 @@ class Command(BaseCommand):
failed_emails.append(email)
self.stderr.write(f"[ERROR] Failed to merge '{email}': {exc}")
if not kept_user.email.islower():
kept_user.email = kept_user.email.lower()
kept_user.save(update_fields=["email"])
if failed_emails:
raise CommandError(
f"Failed to merge {len(failed_emails)} email group(s): {', '.join(failed_emails)}"
@@ -36,6 +36,26 @@ def test_merge_keeps_most_recently_created_user():
assert User.objects.filter(id=user2.id).exists()
def test_merge_user_case_insensitive():
"""Emails differing only by case should be treated as duplicates and merged,
keeping the most recently created user."""
user1 = UserFactory(email="Dup@example.com")
user2 = UserFactory(email="dup@example.com")
call_command("merge_duplicate_users")
assert not User.objects.filter(id=user1.id).exists()
assert User.objects.filter(id=user2.id).exists()
user3 = UserFactory(email="joe@example.com")
user4 = UserFactory(email="Joe@example.com")
call_command("merge_duplicate_users")
assert not User.objects.filter(id=user3.id).exists()
assert User.objects.filter(id=user4.id).exists()
user4.refresh_from_db()
assert user4.email.islower()
def test_merge_deletes_all_stale_users():
"""Command should delete all stale users and keep only the most recently created one."""
email = "many@example.com"
@@ -457,4 +477,4 @@ def test_merge_email_filter_is_case_insensitive():
UserFactory(email="user1@Example.com")
UserFactory(email="user1@Example.com")
call_command("merge_duplicate_users", email_filter="@example.com")
assert User.objects.filter(email="user1@Example.com").count() == 1
assert User.objects.filter(email="user1@example.com").count() == 1
+12 -12
View File
@@ -24,19 +24,19 @@ keywords = ["Django", "Contacts", "Templates", "RBAC"]
license = "MIT"
requires-python = ">=3.13"
dependencies = [
"boto3==1.43.14",
"boto3==1.43.36",
"Brotli==1.2.0",
"brevo-python==1.2.0",
"celery[redis]==5.6.3",
"dj-database-url==3.1.2",
"django-configurations==2.5.1",
"django-cors-headers==4.9.0",
"django-countries==8.2.0",
"django-countries==9.0.0",
"django-filter==25.2",
"django-lasuite[all]==0.0.26",
"django-lasuite[all]==0.0.27",
"django-parler==2.4",
"redis==5.2.1",
"django-redis==6.0.0",
"django-redis==7.0.0",
"django-storages[s3]==1.14.6",
"django-timezone-field>=5.1",
"django-pydantic-field==0.5.4",
@@ -57,13 +57,13 @@ dependencies = [
"python-frontmatter==1.3.0",
"python-magic==0.4.27",
"requests==2.34.2",
"sentry-sdk==2.60.0",
"sentry-sdk==2.63.0",
"whitenoise==6.12.0",
"mozilla-django-oidc==5.0.2",
"livekit-api==1.1.0",
"aiohttp==3.14.0",
"livekit-api==1.1.1",
"aiohttp==3.14.1",
"urllib3==2.7.0",
"phonenumbers==9.0.31",
"phonenumbers==9.0.33",
]
[project.urls]
@@ -75,20 +75,20 @@ dependencies = [
[dependency-groups]
dev = [
"django-extensions==4.1",
"drf-spectacular-sidecar==2026.5.1",
"drf-spectacular-sidecar==2026.6.1",
"freezegun==1.5.5",
"ipdb==0.13.13",
"ipython==9.13.0",
"ipython==9.14.1",
"pyfakefs==6.2.0",
"pylint-django==2.7.0",
"pylint<4.0.0",
"pytest-cov==7.1.0",
"pytest-django==4.12.0",
"pytest==9.0.3",
"pytest==9.1.1",
"pytest-icdiff==0.9",
"pytest-xdist==3.8.0",
"responses==0.26.1",
"ruff==0.15.14",
"ruff==0.15.19",
"types-requests==2.33.0.20260518",
]
+507 -516
View File
File diff suppressed because it is too large Load Diff
+29 -1684
View File
File diff suppressed because it is too large Load Diff
+6 -4
View File
@@ -23,7 +23,6 @@
"@livekit/components-styles": "1.2.0",
"@livekit/track-processors": "0.7.2",
"@pandacss/preset-panda": "1.11.3",
"@react-aria/toast": "3.0.10",
"@react-types/overlays": "3.10.0",
"@remixicon/react": "4.9.0",
"@tanstack/react-query": "5.100.14",
@@ -32,16 +31,18 @@
"derive-valtio": "0.2.0",
"hoofd": "1.7.3",
"humanize-duration": "3.33.2",
"i18next": "26.2.0",
"i18next": "26.3.1",
"i18next-browser-languagedetector": "8.2.1",
"i18next-parser": "9.4.0",
"i18next-resources-to-backend": "1.2.1",
"livekit-client": "2.19.0",
"posthog-js": "1.382.0",
"posthog-js": "1.386.5",
"react": "18.3.1",
"react-aria-components": "1.14.0",
"react-aria": "3.49.0",
"react-aria-components": "1.18.0",
"react-dom": "18.3.1",
"react-i18next": "17.0.8",
"react-stately": "3.47.0",
"use-sound": "5.0.0",
"valtio": "2.3.2",
"wouter": "3.10.0"
@@ -52,6 +53,7 @@
"@tanstack/eslint-plugin-query": "5.100.14",
"@tanstack/react-query-devtools": "5.100.14",
"@types/humanize-duration": "3.27.4",
"@types/node": "24.12.4",
"@types/react": "18.3.12",
"@types/react-dom": "18.3.1",
"@vitejs/plugin-react": "6.0.2",
@@ -1,10 +1,10 @@
import { useToast } from '@react-aria/toast'
import { useToast } from 'react-aria'
import { Button } from '@/primitives'
import { RiCloseLine } from '@remixicon/react'
import { useRef } from 'react'
import type { ToastState } from '@react-stately/toast'
import type { ToastState } from 'react-stately'
import type { ToastData } from './ToastProvider'
import type { QueuedToast } from '@react-stately/toast'
import type { QueuedToast } from 'react-stately'
import { StyledToastContainer } from './StyledToastContainer'
import { StyledToast } from './StyledToast'
@@ -1,4 +1,4 @@
import { useToast } from '@react-aria/toast'
import { useToast } from 'react-aria'
import { useMemo, useRef } from 'react'
import type { ToastProps } from './Toast'
@@ -1,4 +1,4 @@
import { useToast } from '@react-aria/toast'
import { useToast } from 'react-aria'
import { useRef } from 'react'
import { type ToastProps } from './Toast'
@@ -1,4 +1,4 @@
import { useToast } from '@react-aria/toast'
import { useToast } from 'react-aria'
import { useRef } from 'react'
import { Button as RACButton } from 'react-aria-components'
import { Track } from 'livekit-client'
@@ -1,4 +1,4 @@
import { useToast } from '@react-aria/toast'
import { useToast } from 'react-aria'
import { useRef } from 'react'
import { type ToastProps } from './Toast'
@@ -1,4 +1,4 @@
import { useToast } from '@react-aria/toast'
import { useToast } from 'react-aria'
import { useEffect, useRef } from 'react'
import { type ToastProps } from './Toast'
@@ -1,4 +1,4 @@
import { useToast } from '@react-aria/toast'
import { useToast } from 'react-aria'
import { useRef } from 'react'
import { type ToastProps } from './Toast'
@@ -1,4 +1,4 @@
import { useToast } from '@react-aria/toast'
import { useToast } from 'react-aria'
import { useMemo, useRef } from 'react'
import { type ToastProps } from './Toast'
@@ -1,5 +1,5 @@
/* eslint-disable react-refresh/only-export-components */
import { ToastQueue, useToastQueue } from '@react-stately/toast'
import { ToastQueue, useToastQueue } from 'react-stately'
import { ToastRegion } from './ToastRegion'
import { Participant } from 'livekit-client'
import type { NotificationType } from '../NotificationType'
@@ -1,4 +1,4 @@
import { useToast } from '@react-aria/toast'
import { useToast } from 'react-aria'
import { useRef } from 'react'
import { type ToastProps } from './Toast'
@@ -1,4 +1,4 @@
import { useToast } from '@react-aria/toast'
import { useToast } from 'react-aria'
import { useMemo, useRef } from 'react'
import { type ToastProps } from './Toast'
@@ -1,4 +1,4 @@
import { useToast } from '@react-aria/toast'
import { useToast } from 'react-aria'
import { useMemo, useRef } from 'react'
import { Text } from '@/primitives'
@@ -1,5 +1,5 @@
import { AriaToastRegionProps, useToastRegion } from '@react-aria/toast'
import type { QueuedToast, ToastState } from '@react-stately/toast'
import { AriaToastRegionProps, useToastRegion } from 'react-aria'
import type { QueuedToast, ToastState } from 'react-stately'
import { Toast } from './Toast'
import { useRef } from 'react'
import { NotificationType } from '../NotificationType'
@@ -1,6 +1,6 @@
import { useEffect, useRef, useState } from 'react'
import { RiMoreFill } from '@remixicon/react'
import { FocusScope } from '@react-aria/focus'
import { FocusScope } from 'react-aria'
import { Box, Button } from '@/primitives'
import { css } from '@/styled-system/css'
import { useTranslation } from 'react-i18next'
@@ -12,7 +12,7 @@ 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 { FocusScope } from 'react-aria'
import { CONTROL_BAR_REGION_ID } from '@/features/layout/components/ControlBarRegion'
import { REACTIONS_TOGGLE_ID } from '../ReactionsToggle'
@@ -1,5 +1,5 @@
import { useTranslation } from 'react-i18next'
import { useFocusManager } from '@react-aria/focus'
import { useFocusManager } from 'react-aria'
import { getFirstControlBarFocusable } from '@/utils/dom'
import { REACTIONS_TOOLBAR_ID } from '../../constants'
import { useReactionsToolbar } from '../../hooks/useReactionsToolbar'
+2 -2
View File
@@ -1,9 +1,9 @@
FROM python:3.13-alpine3.23 AS base
FROM python:3.13-alpine3.24 AS base
# Install ffmpeg for audio/video processing (format conversion, extraction, compression)
# See summary/core/file_service.py for usage.
RUN apk add --no-cache "ffmpeg=8.0.1-r1"
RUN apk add --no-cache "ffmpeg=8.1.2-r0"
FROM base AS builder
+6 -6
View File
@@ -10,17 +10,17 @@ dependencies = [
"celery==5.6.3",
"redis==5.2.1",
"minio==7.2.20",
"openai==2.38.0",
"posthog==7.15.4",
"openai==2.44.0",
"posthog==7.20.5",
"requests==2.34.2",
"sentry-sdk[fastapi, celery]==2.60.0",
"langfuse==4.6.1"
"sentry-sdk[fastapi, celery]==2.63.0",
"langfuse==4.11.0"
]
[project.optional-dependencies]
dev = [
"ruff==0.15.14",
"pytest==9.0.3",
"ruff==0.15.19",
"pytest==9.1.1",
"responses>=0.25.8",
]
+12 -4
View File
@@ -142,6 +142,7 @@ class MediaInfo:
has_video: bool
audio_duration_seconds: float | None
audio_codec_name: str | None
has_bad_stream: bool = False
def get_media_info(local_path: Path) -> MediaInfo:
@@ -174,8 +175,9 @@ def get_media_info(local_path: Path) -> MediaInfo:
data = json.loads(result.stdout)
streams = data.get("streams", [])
has_audio = any(el["codec_type"] == "audio" for el in streams)
has_video = any(el["codec_type"] == "video" for el in streams)
has_audio = any(el.get("codec_type") == "audio" for el in streams)
has_video = any(el.get("codec_type") == "video" for el in streams)
has_bad_stream = any(el.get("codec_type", None) is None for el in streams)
audio_codec_name = next(
(
stream.get("codec_name")
@@ -193,10 +195,11 @@ def get_media_info(local_path: Path) -> MediaInfo:
has_video=has_video,
audio_duration_seconds=audio_duration_seconds,
audio_codec_name=audio_codec_name,
has_bad_stream=has_bad_stream,
)
def extract_audio_from_video(media_info: MediaInfo) -> Path:
def extract_audio_from_media(media_info: MediaInfo) -> Path:
"""Extracts the audio track from a video file and saves it as a separate audio file.
Based on the provided audio codec,
@@ -451,7 +454,12 @@ class FileService:
if media_info.has_video:
logger.info("Video file detected, extracting audio...")
processed_path = extract_audio_from_video(media_info)
processed_path = extract_audio_from_media(media_info)
# Bad streams may cause transcription issues on WhisperX,
# So we extract the audio properly
elif media_info.has_bad_stream:
logger.info("Bad stream detected, extracting audio...")
processed_path = extract_audio_from_media(media_info)
else:
processed_path = downloaded_path
+33 -2
View File
@@ -1,12 +1,15 @@
"""Unit tests for the file service."""
import json
from pathlib import Path
from unittest.mock import Mock
import pytest
from summary.core import file_service
from summary.core.file_service import (
MediaInfo,
extract_audio_from_video,
extract_audio_from_media,
get_media_info,
)
@@ -110,12 +113,40 @@ def test_media_info_invalid_file() -> None:
)
def test_media_info_ignores_empty_stream_entry(monkeypatch: pytest.MonkeyPatch) -> None:
"""Test stream parsing when ffprobe returns an empty stream object."""
ffprobe_payload = {
"programs": [],
"stream_groups": [],
"streams": [
{"codec_name": "vorbis", "codec_type": "audio"},
{},
],
}
run_mock = Mock(
return_value=Mock(stdout=json.dumps(ffprobe_payload), stderr="", returncode=0)
)
monkeypatch.setattr(file_service.subprocess, "run", run_mock)
monkeypatch.setattr(
file_service, "get_media_duration_seconds", Mock(return_value=2.5)
)
media_info = get_media_info(BASE_PATH / "audio-sample-android-firefox.ogg")
assert media_info.has_audio is True
assert media_info.has_video is False
assert media_info.has_bad_stream is True
assert media_info.audio_codec_name == "vorbis"
assert media_info.audio_duration_seconds == 2.5
def test_extract_audio_from_video():
"""Test that extract_audio_from_video can extract audio from a video file."""
path = None
# A bit of cleanup logic since this is not a generator
try:
path = extract_audio_from_video(MEDIA_INFO_SAMPLE_VISIO)
path = extract_audio_from_media(MEDIA_INFO_SAMPLE_VISIO)
assert path.name.endswith(".m4a")
except Exception as e:
pytest.fail(f"Failed to extract audio from video: {e}")