mirror of
https://github.com/suitenumerique/meet.git
synced 2026-07-27 12:19:10 +00:00
Compare commits
11 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b56bcc38c8 | |||
| c3abd0441f | |||
| 79245389ce | |||
| 3b3f992834 | |||
| a98dc1484a | |||
| dca24a1b25 | |||
| 74b791e207 | |||
| df1495c97b | |||
| c95e1c67bd | |||
| f115c83752 | |||
| ebfcb42a7d |
@@ -8,6 +8,18 @@ and this project adheres to
|
|||||||
|
|
||||||
## [Unreleased]
|
## [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
|
## [1.22.0] - 2026-07-03
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
@@ -26,6 +38,7 @@ and this project adheres to
|
|||||||
- ♻️(env) refactor env variables handling
|
- ♻️(env) refactor env variables handling
|
||||||
- 🚸(frontend) use "Advanced" instead of "Premium" in the sidepanel
|
- 🚸(frontend) use "Advanced" instead of "Premium" in the sidepanel
|
||||||
- ♿️(frontend) make fullscreen share warning keyboard accessible #1459
|
- ♿️(frontend) make fullscreen share warning keyboard accessible #1459
|
||||||
|
- ⬆️(summary) update docker alpine to 3.24 & ffmpeg to 8.1.2 #1471
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
@$(COMPOSE_RUN_APP) pylint meet demo core
|
||||||
.PHONY: lint-pylint
|
.PHONY: lint-pylint
|
||||||
|
|
||||||
test: ## run project tests
|
test: ## run project tests; pass extra pytest args via ARGS, e.g. `make test ARGS="-vv"`
|
||||||
@$(MAKE) test-back-parallel
|
@args="$(ARGS) $(filter-out $@,$(MAKECMDGOALS))" && \
|
||||||
@$(MAKE) test-summary
|
$(MAKE) test-back-parallel ARGS="$${args}" && \
|
||||||
|
$(MAKE) test-summary ARGS="$${args}"
|
||||||
.PHONY: test
|
.PHONY: test
|
||||||
|
|
||||||
test-back: ## run back-end tests
|
test-back: ## run back-end tests (pass extra pytest args via ARGS)
|
||||||
@args="$(filter-out $@,$(MAKECMDGOALS))" && \
|
@args="$(ARGS) $(filter-out $@,$(MAKECMDGOALS))" && \
|
||||||
bin/pytest $${args:-${1}}
|
bin/pytest $${args}
|
||||||
.PHONY: test-back
|
.PHONY: test-back
|
||||||
|
|
||||||
test-back-parallel: ## run all back-end tests in parallel
|
test-back-parallel: ## run all back-end tests in parallel (pass extra pytest args via ARGS)
|
||||||
@args="$(filter-out $@,$(MAKECMDGOALS))" && \
|
@args="$(ARGS) $(filter-out $@,$(MAKECMDGOALS))" && \
|
||||||
bin/pytest -n auto $${args:-${1}}
|
bin/pytest -n auto $${args}
|
||||||
.PHONY: test-back-parallel
|
.PHONY: test-back-parallel
|
||||||
|
|
||||||
test-summary: ## run summary tests
|
test-summary: ## run summary tests (pass extra pytest args via ARGS)
|
||||||
@args="$(filter-out $@,$(MAKECMDGOALS))" && \
|
@args="$(ARGS) $(filter-out $@,$(MAKECMDGOALS))" && \
|
||||||
bin/pytest-summary $${args:-${1}}
|
bin/pytest-summary $${args}
|
||||||
.PHONY: test-summary
|
.PHONY: test-summary
|
||||||
|
|
||||||
makemigrations: ## run django makemigrations for the Meet project.
|
makemigrations: ## run django makemigrations for the Meet project.
|
||||||
|
|||||||
@@ -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
|
# Install system dependencies required by LiveKit
|
||||||
RUN apt-get update && apt-get install -y \
|
RUN apt-get update && apt-get install -y \
|
||||||
|
|||||||
@@ -4,18 +4,18 @@ name = "agents"
|
|||||||
version = "1.22.0"
|
version = "1.22.0"
|
||||||
requires-python = ">=3.12"
|
requires-python = ">=3.12"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"livekit-agents==1.5.13",
|
"livekit-agents==1.6.4",
|
||||||
"livekit-plugins-deepgram==1.5.13",
|
"livekit-plugins-deepgram==1.6.4",
|
||||||
"livekit-plugins-silero==1.5.13",
|
"livekit-plugins-silero==1.6.4",
|
||||||
"livekit-plugins-kyutai-lasuite==0.0.6",
|
"livekit-plugins-kyutai-lasuite==0.0.6",
|
||||||
"python-dotenv==1.2.2",
|
"python-dotenv==1.2.2",
|
||||||
"protobuf==6.33.6",
|
"protobuf>=6.33.5",
|
||||||
"minio==7.2.20"
|
"minio==7.2.20"
|
||||||
]
|
]
|
||||||
|
|
||||||
[project.optional-dependencies]
|
[project.optional-dependencies]
|
||||||
dev = [
|
dev = [
|
||||||
"ruff==0.15.14",
|
"ruff==0.15.19",
|
||||||
]
|
]
|
||||||
|
|
||||||
[tool.uv]
|
[tool.uv]
|
||||||
|
|||||||
Generated
+790
-717
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.core.management.base import BaseCommand, CommandError
|
||||||
from django.db import transaction
|
from django.db import transaction
|
||||||
from django.db.models import Count
|
from django.db.models import Count
|
||||||
|
from django.db.models.functions import Lower
|
||||||
|
|
||||||
from core.models import File, RecordingAccess, ResourceAccess, RoleChoices
|
from core.models import File, RecordingAccess, ResourceAccess, RoleChoices
|
||||||
|
|
||||||
@@ -20,11 +21,14 @@ ROLE_PRIORITY = {
|
|||||||
|
|
||||||
class Command(BaseCommand):
|
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
|
Emails are compared case-insensitively, so 'John@Example.com' and
|
||||||
accesses and files are transferred to it. When a conflict exists, the
|
'john@example.com' are treated as duplicates. The KEPT user is the most
|
||||||
higher-privilege role wins. Stale users are then deleted.
|
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.
|
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)
|
users_qs = users_qs.filter(email__icontains=email_filter)
|
||||||
self.stdout.write(f"[INFO] Filtering emails containing '{email_filter}'.\n")
|
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 = (
|
duplicate_emails = (
|
||||||
users_qs.exclude(email__isnull=True)
|
users_qs.exclude(email__isnull=True)
|
||||||
.exclude(email="")
|
.exclude(email="")
|
||||||
.values("email")
|
.annotate(email_lower=Lower("email"))
|
||||||
|
.values("email_lower")
|
||||||
.annotate(cnt=Count("id"))
|
.annotate(cnt=Count("id"))
|
||||||
.filter(cnt__gt=1)
|
.filter(cnt__gt=1)
|
||||||
.values_list("email", flat=True)
|
.values_list("email_lower", flat=True)
|
||||||
)
|
)
|
||||||
|
|
||||||
if not duplicate_emails:
|
if not duplicate_emails:
|
||||||
@@ -78,9 +85,12 @@ class Command(BaseCommand):
|
|||||||
failed_emails = []
|
failed_emails = []
|
||||||
|
|
||||||
for email in duplicate_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
|
# Secondary sort by id ensures a stable, deterministic order when
|
||||||
# created_at timestamps are equal (common in tests and bulk imports).
|
# 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]
|
kept_user = users[-1]
|
||||||
stale_users = users[:-1]
|
stale_users = users[:-1]
|
||||||
|
|
||||||
@@ -120,6 +130,10 @@ class Command(BaseCommand):
|
|||||||
failed_emails.append(email)
|
failed_emails.append(email)
|
||||||
self.stderr.write(f"[ERROR] Failed to merge '{email}': {exc}")
|
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:
|
if failed_emails:
|
||||||
raise CommandError(
|
raise CommandError(
|
||||||
f"Failed to merge {len(failed_emails)} email group(s): {', '.join(failed_emails)}"
|
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()
|
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():
|
def test_merge_deletes_all_stale_users():
|
||||||
"""Command should delete all stale users and keep only the most recently created one."""
|
"""Command should delete all stale users and keep only the most recently created one."""
|
||||||
email = "many@example.com"
|
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")
|
||||||
UserFactory(email="user1@Example.com")
|
UserFactory(email="user1@Example.com")
|
||||||
call_command("merge_duplicate_users", email_filter="@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
@@ -24,19 +24,19 @@ keywords = ["Django", "Contacts", "Templates", "RBAC"]
|
|||||||
license = "MIT"
|
license = "MIT"
|
||||||
requires-python = ">=3.13"
|
requires-python = ">=3.13"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"boto3==1.43.14",
|
"boto3==1.43.36",
|
||||||
"Brotli==1.2.0",
|
"Brotli==1.2.0",
|
||||||
"brevo-python==1.2.0",
|
"brevo-python==1.2.0",
|
||||||
"celery[redis]==5.6.3",
|
"celery[redis]==5.6.3",
|
||||||
"dj-database-url==3.1.2",
|
"dj-database-url==3.1.2",
|
||||||
"django-configurations==2.5.1",
|
"django-configurations==2.5.1",
|
||||||
"django-cors-headers==4.9.0",
|
"django-cors-headers==4.9.0",
|
||||||
"django-countries==8.2.0",
|
"django-countries==9.0.0",
|
||||||
"django-filter==25.2",
|
"django-filter==25.2",
|
||||||
"django-lasuite[all]==0.0.26",
|
"django-lasuite[all]==0.0.27",
|
||||||
"django-parler==2.4",
|
"django-parler==2.4",
|
||||||
"redis==5.2.1",
|
"redis==5.2.1",
|
||||||
"django-redis==6.0.0",
|
"django-redis==7.0.0",
|
||||||
"django-storages[s3]==1.14.6",
|
"django-storages[s3]==1.14.6",
|
||||||
"django-timezone-field>=5.1",
|
"django-timezone-field>=5.1",
|
||||||
"django-pydantic-field==0.5.4",
|
"django-pydantic-field==0.5.4",
|
||||||
@@ -57,13 +57,13 @@ dependencies = [
|
|||||||
"python-frontmatter==1.3.0",
|
"python-frontmatter==1.3.0",
|
||||||
"python-magic==0.4.27",
|
"python-magic==0.4.27",
|
||||||
"requests==2.34.2",
|
"requests==2.34.2",
|
||||||
"sentry-sdk==2.60.0",
|
"sentry-sdk==2.63.0",
|
||||||
"whitenoise==6.12.0",
|
"whitenoise==6.12.0",
|
||||||
"mozilla-django-oidc==5.0.2",
|
"mozilla-django-oidc==5.0.2",
|
||||||
"livekit-api==1.1.0",
|
"livekit-api==1.1.1",
|
||||||
"aiohttp==3.14.0",
|
"aiohttp==3.14.1",
|
||||||
"urllib3==2.7.0",
|
"urllib3==2.7.0",
|
||||||
"phonenumbers==9.0.31",
|
"phonenumbers==9.0.33",
|
||||||
]
|
]
|
||||||
|
|
||||||
[project.urls]
|
[project.urls]
|
||||||
@@ -75,20 +75,20 @@ dependencies = [
|
|||||||
[dependency-groups]
|
[dependency-groups]
|
||||||
dev = [
|
dev = [
|
||||||
"django-extensions==4.1",
|
"django-extensions==4.1",
|
||||||
"drf-spectacular-sidecar==2026.5.1",
|
"drf-spectacular-sidecar==2026.6.1",
|
||||||
"freezegun==1.5.5",
|
"freezegun==1.5.5",
|
||||||
"ipdb==0.13.13",
|
"ipdb==0.13.13",
|
||||||
"ipython==9.13.0",
|
"ipython==9.14.1",
|
||||||
"pyfakefs==6.2.0",
|
"pyfakefs==6.2.0",
|
||||||
"pylint-django==2.7.0",
|
"pylint-django==2.7.0",
|
||||||
"pylint<4.0.0",
|
"pylint<4.0.0",
|
||||||
"pytest-cov==7.1.0",
|
"pytest-cov==7.1.0",
|
||||||
"pytest-django==4.12.0",
|
"pytest-django==4.12.0",
|
||||||
"pytest==9.0.3",
|
"pytest==9.1.1",
|
||||||
"pytest-icdiff==0.9",
|
"pytest-icdiff==0.9",
|
||||||
"pytest-xdist==3.8.0",
|
"pytest-xdist==3.8.0",
|
||||||
"responses==0.26.1",
|
"responses==0.26.1",
|
||||||
"ruff==0.15.14",
|
"ruff==0.15.19",
|
||||||
"types-requests==2.33.0.20260518",
|
"types-requests==2.33.0.20260518",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
Generated
+507
-516
File diff suppressed because it is too large
Load Diff
Generated
+29
-1684
File diff suppressed because it is too large
Load Diff
@@ -23,7 +23,6 @@
|
|||||||
"@livekit/components-styles": "1.2.0",
|
"@livekit/components-styles": "1.2.0",
|
||||||
"@livekit/track-processors": "0.7.2",
|
"@livekit/track-processors": "0.7.2",
|
||||||
"@pandacss/preset-panda": "1.11.3",
|
"@pandacss/preset-panda": "1.11.3",
|
||||||
"@react-aria/toast": "3.0.10",
|
|
||||||
"@react-types/overlays": "3.10.0",
|
"@react-types/overlays": "3.10.0",
|
||||||
"@remixicon/react": "4.9.0",
|
"@remixicon/react": "4.9.0",
|
||||||
"@tanstack/react-query": "5.100.14",
|
"@tanstack/react-query": "5.100.14",
|
||||||
@@ -32,16 +31,18 @@
|
|||||||
"derive-valtio": "0.2.0",
|
"derive-valtio": "0.2.0",
|
||||||
"hoofd": "1.7.3",
|
"hoofd": "1.7.3",
|
||||||
"humanize-duration": "3.33.2",
|
"humanize-duration": "3.33.2",
|
||||||
"i18next": "26.2.0",
|
"i18next": "26.3.1",
|
||||||
"i18next-browser-languagedetector": "8.2.1",
|
"i18next-browser-languagedetector": "8.2.1",
|
||||||
"i18next-parser": "9.4.0",
|
"i18next-parser": "9.4.0",
|
||||||
"i18next-resources-to-backend": "1.2.1",
|
"i18next-resources-to-backend": "1.2.1",
|
||||||
"livekit-client": "2.19.0",
|
"livekit-client": "2.19.0",
|
||||||
"posthog-js": "1.382.0",
|
"posthog-js": "1.386.5",
|
||||||
"react": "18.3.1",
|
"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-dom": "18.3.1",
|
||||||
"react-i18next": "17.0.8",
|
"react-i18next": "17.0.8",
|
||||||
|
"react-stately": "3.47.0",
|
||||||
"use-sound": "5.0.0",
|
"use-sound": "5.0.0",
|
||||||
"valtio": "2.3.2",
|
"valtio": "2.3.2",
|
||||||
"wouter": "3.10.0"
|
"wouter": "3.10.0"
|
||||||
@@ -52,6 +53,7 @@
|
|||||||
"@tanstack/eslint-plugin-query": "5.100.14",
|
"@tanstack/eslint-plugin-query": "5.100.14",
|
||||||
"@tanstack/react-query-devtools": "5.100.14",
|
"@tanstack/react-query-devtools": "5.100.14",
|
||||||
"@types/humanize-duration": "3.27.4",
|
"@types/humanize-duration": "3.27.4",
|
||||||
|
"@types/node": "24.12.4",
|
||||||
"@types/react": "18.3.12",
|
"@types/react": "18.3.12",
|
||||||
"@types/react-dom": "18.3.1",
|
"@types/react-dom": "18.3.1",
|
||||||
"@vitejs/plugin-react": "6.0.2",
|
"@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 { Button } from '@/primitives'
|
||||||
import { RiCloseLine } from '@remixicon/react'
|
import { RiCloseLine } from '@remixicon/react'
|
||||||
import { useRef } from '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 { ToastData } from './ToastProvider'
|
||||||
import type { QueuedToast } from '@react-stately/toast'
|
import type { QueuedToast } from 'react-stately'
|
||||||
import { StyledToastContainer } from './StyledToastContainer'
|
import { StyledToastContainer } from './StyledToastContainer'
|
||||||
import { StyledToast } from './StyledToast'
|
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 { useMemo, useRef } from 'react'
|
||||||
|
|
||||||
import type { ToastProps } from './Toast'
|
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 { useRef } from 'react'
|
||||||
|
|
||||||
import { type ToastProps } from './Toast'
|
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 { useRef } from 'react'
|
||||||
import { Button as RACButton } from 'react-aria-components'
|
import { Button as RACButton } from 'react-aria-components'
|
||||||
import { Track } from 'livekit-client'
|
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 { useRef } from 'react'
|
||||||
|
|
||||||
import { type ToastProps } from './Toast'
|
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 { useEffect, useRef } from 'react'
|
||||||
|
|
||||||
import { type ToastProps } from './Toast'
|
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 { useRef } from 'react'
|
||||||
|
|
||||||
import { type ToastProps } from './Toast'
|
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 { useMemo, useRef } from 'react'
|
||||||
|
|
||||||
import { type ToastProps } from './Toast'
|
import { type ToastProps } from './Toast'
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
/* eslint-disable react-refresh/only-export-components */
|
/* 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 { ToastRegion } from './ToastRegion'
|
||||||
import { Participant } from 'livekit-client'
|
import { Participant } from 'livekit-client'
|
||||||
import type { NotificationType } from '../NotificationType'
|
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 { useRef } from 'react'
|
||||||
|
|
||||||
import { type ToastProps } from './Toast'
|
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 { useMemo, useRef } from 'react'
|
||||||
|
|
||||||
import { type ToastProps } from './Toast'
|
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 { useMemo, useRef } from 'react'
|
||||||
import { Text } from '@/primitives'
|
import { Text } from '@/primitives'
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { AriaToastRegionProps, useToastRegion } from '@react-aria/toast'
|
import { AriaToastRegionProps, useToastRegion } from 'react-aria'
|
||||||
import type { QueuedToast, ToastState } from '@react-stately/toast'
|
import type { QueuedToast, ToastState } from 'react-stately'
|
||||||
import { Toast } from './Toast'
|
import { Toast } from './Toast'
|
||||||
import { useRef } from 'react'
|
import { useRef } from 'react'
|
||||||
import { NotificationType } from '../NotificationType'
|
import { NotificationType } from '../NotificationType'
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useEffect, useRef, useState } from 'react'
|
import { useEffect, useRef, useState } from 'react'
|
||||||
import { RiMoreFill } from '@remixicon/react'
|
import { RiMoreFill } from '@remixicon/react'
|
||||||
import { FocusScope } from '@react-aria/focus'
|
import { FocusScope } from 'react-aria'
|
||||||
import { Box, Button } from '@/primitives'
|
import { Box, Button } from '@/primitives'
|
||||||
import { css } from '@/styled-system/css'
|
import { css } from '@/styled-system/css'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import { useSize } from '@/features/rooms/livekit/hooks/useResizeObserver'
|
|||||||
import { RiArrowLeftSLine, RiArrowRightSLine } from '@remixicon/react'
|
import { RiArrowLeftSLine, RiArrowRightSLine } from '@remixicon/react'
|
||||||
import { Button } from '@/primitives'
|
import { Button } from '@/primitives'
|
||||||
import { ReactionsKeyboardNavigation } from './ReactionsKeyboardNavigation'
|
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 { CONTROL_BAR_REGION_ID } from '@/features/layout/components/ControlBarRegion'
|
||||||
import { REACTIONS_TOGGLE_ID } from '../ReactionsToggle'
|
import { REACTIONS_TOGGLE_ID } from '../ReactionsToggle'
|
||||||
|
|||||||
+1
-1
@@ -1,5 +1,5 @@
|
|||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { useFocusManager } from '@react-aria/focus'
|
import { useFocusManager } from 'react-aria'
|
||||||
import { getFirstControlBarFocusable } from '@/utils/dom'
|
import { getFirstControlBarFocusable } from '@/utils/dom'
|
||||||
import { REACTIONS_TOOLBAR_ID } from '../../constants'
|
import { REACTIONS_TOOLBAR_ID } from '../../constants'
|
||||||
import { useReactionsToolbar } from '../../hooks/useReactionsToolbar'
|
import { useReactionsToolbar } from '../../hooks/useReactionsToolbar'
|
||||||
|
|||||||
@@ -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)
|
# Install ffmpeg for audio/video processing (format conversion, extraction, compression)
|
||||||
# See summary/core/file_service.py for usage.
|
# 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
|
FROM base AS builder
|
||||||
|
|
||||||
|
|||||||
@@ -10,17 +10,17 @@ dependencies = [
|
|||||||
"celery==5.6.3",
|
"celery==5.6.3",
|
||||||
"redis==5.2.1",
|
"redis==5.2.1",
|
||||||
"minio==7.2.20",
|
"minio==7.2.20",
|
||||||
"openai==2.38.0",
|
"openai==2.44.0",
|
||||||
"posthog==7.15.4",
|
"posthog==7.20.5",
|
||||||
"requests==2.34.2",
|
"requests==2.34.2",
|
||||||
"sentry-sdk[fastapi, celery]==2.60.0",
|
"sentry-sdk[fastapi, celery]==2.63.0",
|
||||||
"langfuse==4.6.1"
|
"langfuse==4.11.0"
|
||||||
]
|
]
|
||||||
|
|
||||||
[project.optional-dependencies]
|
[project.optional-dependencies]
|
||||||
dev = [
|
dev = [
|
||||||
"ruff==0.15.14",
|
"ruff==0.15.19",
|
||||||
"pytest==9.0.3",
|
"pytest==9.1.1",
|
||||||
"responses>=0.25.8",
|
"responses>=0.25.8",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
@@ -142,6 +142,7 @@ class MediaInfo:
|
|||||||
has_video: bool
|
has_video: bool
|
||||||
audio_duration_seconds: float | None
|
audio_duration_seconds: float | None
|
||||||
audio_codec_name: str | None
|
audio_codec_name: str | None
|
||||||
|
has_bad_stream: bool = False
|
||||||
|
|
||||||
|
|
||||||
def get_media_info(local_path: Path) -> MediaInfo:
|
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)
|
data = json.loads(result.stdout)
|
||||||
|
|
||||||
streams = data.get("streams", [])
|
streams = data.get("streams", [])
|
||||||
has_audio = any(el["codec_type"] == "audio" for el in streams)
|
has_audio = any(el.get("codec_type") == "audio" for el in streams)
|
||||||
has_video = any(el["codec_type"] == "video" 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(
|
audio_codec_name = next(
|
||||||
(
|
(
|
||||||
stream.get("codec_name")
|
stream.get("codec_name")
|
||||||
@@ -193,10 +195,11 @@ def get_media_info(local_path: Path) -> MediaInfo:
|
|||||||
has_video=has_video,
|
has_video=has_video,
|
||||||
audio_duration_seconds=audio_duration_seconds,
|
audio_duration_seconds=audio_duration_seconds,
|
||||||
audio_codec_name=audio_codec_name,
|
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.
|
"""Extracts the audio track from a video file and saves it as a separate audio file.
|
||||||
|
|
||||||
Based on the provided audio codec,
|
Based on the provided audio codec,
|
||||||
@@ -451,7 +454,12 @@ class FileService:
|
|||||||
|
|
||||||
if media_info.has_video:
|
if media_info.has_video:
|
||||||
logger.info("Video file detected, extracting audio...")
|
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:
|
else:
|
||||||
processed_path = downloaded_path
|
processed_path = downloaded_path
|
||||||
|
|
||||||
|
|||||||
@@ -1,12 +1,15 @@
|
|||||||
"""Unit tests for the file service."""
|
"""Unit tests for the file service."""
|
||||||
|
|
||||||
|
import json
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
from unittest.mock import Mock
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
|
from summary.core import file_service
|
||||||
from summary.core.file_service import (
|
from summary.core.file_service import (
|
||||||
MediaInfo,
|
MediaInfo,
|
||||||
extract_audio_from_video,
|
extract_audio_from_media,
|
||||||
get_media_info,
|
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():
|
def test_extract_audio_from_video():
|
||||||
"""Test that extract_audio_from_video can extract audio from a video file."""
|
"""Test that extract_audio_from_video can extract audio from a video file."""
|
||||||
path = None
|
path = None
|
||||||
# A bit of cleanup logic since this is not a generator
|
# A bit of cleanup logic since this is not a generator
|
||||||
try:
|
try:
|
||||||
path = extract_audio_from_video(MEDIA_INFO_SAMPLE_VISIO)
|
path = extract_audio_from_media(MEDIA_INFO_SAMPLE_VISIO)
|
||||||
assert path.name.endswith(".m4a")
|
assert path.name.endswith(".m4a")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
pytest.fail(f"Failed to extract audio from video: {e}")
|
pytest.fail(f"Failed to extract audio from video: {e}")
|
||||||
|
|||||||
Reference in New Issue
Block a user