Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4f5f336cd6 | |||
| 0441f4f72b | |||
| 9bc8d6174d | |||
| 45c997380f | |||
| 173d384f06 |
12
.env.example
12
.env.example
@@ -59,6 +59,16 @@ TURN_STATIC_AUTH_SECRET=change-me-turn-secret
|
|||||||
# скриптом deploy/render-templates.sh (вызывается install.sh).
|
# скриптом deploy/render-templates.sh (вызывается install.sh).
|
||||||
TURN_EXTERNAL_IP=127.0.0.1
|
TURN_EXTERNAL_IP=127.0.0.1
|
||||||
|
|
||||||
|
# TURN over TLS (5349) — единственный переключатель во всём проекте: пусто =
|
||||||
|
# TLS выключен везде (dev-дефолт, как ниже), непустое значение = coturn
|
||||||
|
# слушает TLS на 5349 (сертификат смонтирован из /etc/letsencrypt через
|
||||||
|
# coturn-certs-init, docker-compose.yml) И LiveKit объявляет клиентам запись
|
||||||
|
# protocol: tls. ⚠️ Обязан быть ДОМЕНОМ сертификата (например, vidconf.ru —
|
||||||
|
# тем же, что и NGINX_CERT_NAME), а НЕ IP-адресом, в отличие от
|
||||||
|
# TURN_EXTERNAL_IP выше: браузер проверяет TLS-сертификат TURN-сервера по
|
||||||
|
# имени хоста, а Let's Encrypt выписывает сертификат на домен.
|
||||||
|
TURN_TLS_HOST=
|
||||||
|
|
||||||
# --- Nginx: TLS (443) + список доменов — deploy/nginx/nginx.conf.template ---
|
# --- Nginx: TLS (443) + список доменов — deploy/nginx/nginx.conf.template ---
|
||||||
# Домены, которые обслуживает nginx (через пробел, все — в server_name).
|
# Домены, которые обслуживает nginx (через пробел, все — в server_name).
|
||||||
NGINX_SERVER_NAMES=example.com www.example.com
|
NGINX_SERVER_NAMES=example.com www.example.com
|
||||||
@@ -112,7 +122,7 @@ SMTP_TIMEOUT_S=30
|
|||||||
# --- Версия инстанса (релиз v0.0.1) ---
|
# --- Версия инстанса (релиз v0.0.1) ---
|
||||||
# install.sh копирует значение из корневого файла VERSION при каждой
|
# install.sh копирует значение из корневого файла VERSION при каждой
|
||||||
# установке/обновлении — руками менять не нужно.
|
# установке/обновлении — руками менять не нужно.
|
||||||
VIDCONF_VERSION=0.0.20
|
VIDCONF_VERSION=0.0.22
|
||||||
|
|
||||||
# --- Профили compose. Дефолт ниже (`media,monitoring`) — только для ручного
|
# --- Профили compose. Дефолт ниже (`media,monitoring`) — только для ручного
|
||||||
# `docker compose up` БЕЗ install.sh: медиа (LiveKit+coturn) + мониторинг,
|
# `docker compose up` БЕЗ install.sh: медиа (LiveKit+coturn) + мониторинг,
|
||||||
|
|||||||
44
CHANGELOG.md
44
CHANGELOG.md
@@ -3,6 +3,50 @@
|
|||||||
Формат основан на [Keep a Changelog](https://keepachangelog.com/ru/1.1.0/),
|
Формат основан на [Keep a Changelog](https://keepachangelog.com/ru/1.1.0/),
|
||||||
проект придерживается [семантического версионирования](https://semver.org/lang/ru/).
|
проект придерживается [семантического версионирования](https://semver.org/lang/ru/).
|
||||||
|
|
||||||
|
## [0.0.22] — 2026-08-02
|
||||||
|
|
||||||
|
TURN over TLS (5349) — для клиентов из сетей, где наружу открыт только 443.
|
||||||
|
|
||||||
|
### Добавлено
|
||||||
|
- coturn слушает TLS на `5349` (сертификат Let's Encrypt, обновляется
|
||||||
|
автоматически). Включается одним ключом `TURN_TLS_HOST` в `.env` (домен
|
||||||
|
сертификата, НЕ IP) — пусто оставляет прежнее поведение без единого следа
|
||||||
|
в рендеренных конфигах.
|
||||||
|
- LiveKit объявляет клиентам запись `protocol: tls` в `rtc.turn_servers`
|
||||||
|
(последней в списке, как самый дорогой фолбэк) — без анонса включённый
|
||||||
|
TLS был бы бесполезен: именно так уже случалось с обычным TURN до 0.0.14
|
||||||
|
(сервис работал healthy, но не обслужил ни одной аллокации).
|
||||||
|
- Новый init-контейнер `coturn-certs-init`: копирует fullchain/privkey из
|
||||||
|
`/etc/letsencrypt` в отдельный volume под правами `644` — coturn
|
||||||
|
(`nobody:nogroup`, без root-фазы в entrypoint) не может прочитать
|
||||||
|
оригинальный ключ (`root`, `0600`), а права на хосте ослаблять нельзя.
|
||||||
|
- Deploy-hook certbot дополнительно перекопирует сертификат и перезапускает
|
||||||
|
`coturn` при продлении — без этого TLS-TURN тихо остановился бы
|
||||||
|
обслуживать новые TLS-хендшейки примерно через 60 дней.
|
||||||
|
|
||||||
|
### Не сделано
|
||||||
|
- TURN over `443` — недостижимо без SNI-мультиплексора: порт уже занят
|
||||||
|
nginx (Docker port-publish), а coturn слушает в `network_mode: host` и не
|
||||||
|
может разделить с ним один и тот же сокет.
|
||||||
|
|
||||||
|
## [0.0.21] — 2026-08-02
|
||||||
|
|
||||||
|
Рычаги нагрузки медиа в админке: потолок качества публикации и лимит плиток.
|
||||||
|
|
||||||
|
### Добавлено
|
||||||
|
- Настройка инстанса «Потолок качества публикации видео» (без ограничения /
|
||||||
|
720p / 360p / 180p) — режет битрейт исходящего видео публикующего через
|
||||||
|
`publishDefaults` LiveKit, снижает нагрузку на его канал и устройство.
|
||||||
|
Дефолт — без ограничения, поведение существующих инсталляций не меняется.
|
||||||
|
- Настройка инстанса «Максимум плиток на экране» (25 / 16 / 9 / 4) — участники
|
||||||
|
сверх лимита уходят на следующую страницу сетки вместо подписки на их
|
||||||
|
видеотреки, меньше одновременных видеопотоков на канал и экран участника.
|
||||||
|
Дефолт — 25 (текущий максимум сетки 5×5), без изменений.
|
||||||
|
- Обе настройки доступны в новой карточке «Нагрузка» вкладки «Настройки»
|
||||||
|
админки и отдаются участнику вместе с токеном входа в конференцию — ещё до
|
||||||
|
подключения к комнате, чтобы применяться до публикации трека и не вызывать
|
||||||
|
переподключение уже вошедших участников при смене настройки.
|
||||||
|
|
||||||
## [0.0.20] — 2026-08-02
|
## [0.0.20] — 2026-08-02
|
||||||
|
|
||||||
Сеть LiveKit: один UDP-порт вместо диапазона на 101 порт.
|
Сеть LiveKit: один UDP-порт вместо диапазона на 101 порт.
|
||||||
|
|||||||
@@ -467,6 +467,8 @@ def _to_settings_out(cfg: InstanceConfig, *, transcription_queue_served: bool) -
|
|||||||
registration_email_domains=cfg.registration_email_domains,
|
registration_email_domains=cfg.registration_email_domains,
|
||||||
contact_email_enabled=cfg.contact_email_enabled,
|
contact_email_enabled=cfg.contact_email_enabled,
|
||||||
contact_email=cfg.contact_email,
|
contact_email=cfg.contact_email,
|
||||||
|
publish_quality_cap=cfg.media_limits.publish_quality_cap,
|
||||||
|
stage_max_tiles=cfg.media_limits.stage_max_tiles,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -62,6 +62,28 @@ SummaryRecipientsMode = Literal["all", "owner"]
|
|||||||
"""Режим рассылки саммари по умолчанию: всем участникам либо только
|
"""Режим рассылки саммари по умолчанию: всем участникам либо только
|
||||||
владельцу конференции (переопределяется на уровне `conferences.summary_recipients`)."""
|
владельцу конференции (переопределяется на уровне `conferences.summary_recipients`)."""
|
||||||
|
|
||||||
|
PublishQualityCap = Literal["off", "180p", "360p", "720p"]
|
||||||
|
"""Потолок качества исходящего видео участника (задача «рычаги качества
|
||||||
|
медиа»). `off` — без ограничения (дефолт, поведение как до появления
|
||||||
|
настройки). Остальные значения режут `publishDefaults.videoEncoding` и
|
||||||
|
`videoSimulcastLayers` на клиенте (`frontend/src/lib/publishQualityCap.ts`) —
|
||||||
|
именно битрейт верхнего слоя симулкаста, а не жёсткое разрешение захвата
|
||||||
|
камеры; фактическое разрешение WebRTC подстраивает под битрейт сам."""
|
||||||
|
|
||||||
|
StageMaxTiles = Literal[4, 9, 16, 25]
|
||||||
|
"""Потолок числа одновременно видимых плиток на сцене (`StageGrid`) —
|
||||||
|
режет набор доступных раскладок сетки, что бросает лишних участников на
|
||||||
|
следующую страницу пагинации вместо подписки на их треки. `25` — дефолт,
|
||||||
|
совпадает с текущим максимумом сетки (5×5), то есть без ограничения."""
|
||||||
|
|
||||||
|
|
||||||
|
class MediaLimitsConfig(BaseModel):
|
||||||
|
"""Рычаги нагрузки медиа для администратора инстанса (не логика состояния
|
||||||
|
конференции — статичные потолки, применяются на клиенте при входе)."""
|
||||||
|
|
||||||
|
publish_quality_cap: PublishQualityCap = "off"
|
||||||
|
stage_max_tiles: StageMaxTiles = 25
|
||||||
|
|
||||||
|
|
||||||
class InstanceConfig(BaseModel):
|
class InstanceConfig(BaseModel):
|
||||||
"""Эффективная конфигурация инстанса (значения `instance_settings` поверх дефолтов
|
"""Эффективная конфигурация инстанса (значения `instance_settings` поверх дефолтов
|
||||||
@@ -87,3 +109,9 @@ class InstanceConfig(BaseModel):
|
|||||||
# адреса) — см. `services/instance_settings.py`, `services/email.py`.
|
# адреса) — см. `services/instance_settings.py`, `services/email.py`.
|
||||||
contact_email_enabled: bool = False
|
contact_email_enabled: bool = False
|
||||||
contact_email: str | None = None
|
contact_email: str | None = None
|
||||||
|
# Потолок качества публикации + максимум плиток сцены — см.
|
||||||
|
# `services/instance_settings.py`. Отдаётся участнику ДО входа в
|
||||||
|
# LiveKit-комнату (в ответе join, `schemas/conferences.py::JoinOut`), а
|
||||||
|
# не только в админке — настройка должна быть на руках у клиента до
|
||||||
|
# публикации трека.
|
||||||
|
media_limits: MediaLimitsConfig = Field(default_factory=MediaLimitsConfig)
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ from typing import Literal
|
|||||||
|
|
||||||
from pydantic import BaseModel, ConfigDict, EmailStr, Field
|
from pydantic import BaseModel, ConfigDict, EmailStr, Field
|
||||||
|
|
||||||
from core.plugins.config import AiLevel, SummaryRecipientsMode
|
from core.plugins.config import AiLevel, PublishQualityCap, StageMaxTiles, SummaryRecipientsMode
|
||||||
from schemas.conferences import ConferenceOut
|
from schemas.conferences import ConferenceOut
|
||||||
from services.ai_levels import AiLevelStatus
|
from services.ai_levels import AiLevelStatus
|
||||||
|
|
||||||
@@ -158,6 +158,10 @@ class SettingsOut(BaseModel):
|
|||||||
registration_email_domains: list[str] = Field(default_factory=list)
|
registration_email_domains: list[str] = Field(default_factory=list)
|
||||||
contact_email_enabled: bool
|
contact_email_enabled: bool
|
||||||
contact_email: str | None = None
|
contact_email: str | None = None
|
||||||
|
# Рычаги нагрузки медиа (`InstanceConfig.media_limits`) — потолок
|
||||||
|
# качества публикации и максимум плиток сцены, см. `core/plugins/config.py`.
|
||||||
|
publish_quality_cap: PublishQualityCap
|
||||||
|
stage_max_tiles: StageMaxTiles
|
||||||
|
|
||||||
|
|
||||||
class TestEmailIn(BaseModel):
|
class TestEmailIn(BaseModel):
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ from datetime import UTC, datetime, timedelta
|
|||||||
|
|
||||||
from pydantic import BaseModel, EmailStr, Field, field_serializer, field_validator, model_validator
|
from pydantic import BaseModel, EmailStr, Field, field_serializer, field_validator, model_validator
|
||||||
|
|
||||||
from core.plugins.config import SummaryRecipientsMode
|
from core.plugins.config import PublishQualityCap, StageMaxTiles, SummaryRecipientsMode
|
||||||
from schemas.room_events import ForcedMuteSource
|
from schemas.room_events import ForcedMuteSource
|
||||||
from services.recurrence import RecurrenceRule
|
from services.recurrence import RecurrenceRule
|
||||||
|
|
||||||
@@ -130,6 +130,11 @@ class JoinOut(BaseModel):
|
|||||||
# Тоггл инстанса `chat.enabled` на момент входа — клиент решает,
|
# Тоггл инстанса `chat.enabled` на момент входа — клиент решает,
|
||||||
# показывать ли UI чата, не дожидаясь ошибки WS-подключения.
|
# показывать ли UI чата, не дожидаясь ошибки WS-подключения.
|
||||||
chat_enabled: bool
|
chat_enabled: bool
|
||||||
|
# Рычаги нагрузки медиа (`instance_settings.media_limits`) — отдаются
|
||||||
|
# прямо в join-ответе, а не только в админке: участнику нужно иметь их
|
||||||
|
# на руках ДО публикации своего трека (см. `services/conference_access.py`).
|
||||||
|
publish_quality_cap: PublishQualityCap
|
||||||
|
stage_max_tiles: StageMaxTiles
|
||||||
|
|
||||||
|
|
||||||
class ConferenceOut(BaseModel):
|
class ConferenceOut(BaseModel):
|
||||||
|
|||||||
@@ -9,6 +9,7 @@
|
|||||||
import json
|
import json
|
||||||
|
|
||||||
from core.config import get_settings
|
from core.config import get_settings
|
||||||
|
from core.plugins.config import PublishQualityCap, StageMaxTiles
|
||||||
from core.security import verify_password
|
from core.security import verify_password
|
||||||
from models.conference import Conference
|
from models.conference import Conference
|
||||||
from schemas.conferences import JoinOut
|
from schemas.conferences import JoinOut
|
||||||
@@ -52,15 +53,18 @@ def build_join(
|
|||||||
identity: str,
|
identity: str,
|
||||||
name: str,
|
name: str,
|
||||||
chat_enabled: bool,
|
chat_enabled: bool,
|
||||||
|
publish_quality_cap: PublishQualityCap,
|
||||||
|
stage_max_tiles: StageMaxTiles,
|
||||||
avatar_url: str | None = None,
|
avatar_url: str | None = None,
|
||||||
is_organizer: bool = False,
|
is_organizer: bool = False,
|
||||||
) -> JoinOut:
|
) -> JoinOut:
|
||||||
"""Построить ответ join: LiveKit access-токен для входа в комнату конференции.
|
"""Построить ответ join: LiveKit access-токен для входа в комнату конференции.
|
||||||
|
|
||||||
Имя LiveKit-комнаты всегда равно `conference.slug` (ADR-001, п.4).
|
Имя LiveKit-комнаты всегда равно `conference.slug` (ADR-001, п.4).
|
||||||
`chat_enabled` — снятый вызывающей стороной тоггл `instance_settings`:
|
`chat_enabled`/`publish_quality_cap`/`stage_max_tiles` — снятые вызывающей
|
||||||
читается здесь параметром, а не заново из БД, чтобы не плодить
|
стороной значения `instance_settings`: читаются здесь параметрами, а не
|
||||||
отдельный запрос настроек на каждый join. `avatar_url`/`is_organizer`
|
заново из БД, чтобы не плодить отдельный запрос настроек на каждый join.
|
||||||
|
`avatar_url`/`is_organizer`
|
||||||
прокидываются в метаданные токена как JSON `{"avatar_url": ..., "is_organizer": true}`
|
прокидываются в метаданные токена как JSON `{"avatar_url": ..., "is_organizer": true}`
|
||||||
— поля добавляются, только если заданы (гость без аватара и не-организатор
|
— поля добавляются, только если заданы (гость без аватара и не-организатор
|
||||||
получают токен вовсе без метаданных, как и раньше).
|
получают токен вовсе без метаданных, как и раньше).
|
||||||
@@ -87,4 +91,6 @@ def build_join(
|
|||||||
room_name=conference.slug,
|
room_name=conference.slug,
|
||||||
conference_id=conference.id,
|
conference_id=conference.id,
|
||||||
chat_enabled=chat_enabled,
|
chat_enabled=chat_enabled,
|
||||||
|
publish_quality_cap=publish_quality_cap,
|
||||||
|
stage_max_tiles=stage_max_tiles,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -149,12 +149,14 @@ class ConferenceService:
|
|||||||
|
|
||||||
join = None
|
join = None
|
||||||
if is_instant:
|
if is_instant:
|
||||||
chat_enabled = (await InstanceSettingsService(self._session).get()).chat.enabled
|
cfg = await InstanceSettingsService(self._session).get()
|
||||||
join = build_join(
|
join = build_join(
|
||||||
conference,
|
conference,
|
||||||
identity=str(owner_id),
|
identity=str(owner_id),
|
||||||
name=owner_name,
|
name=owner_name,
|
||||||
chat_enabled=chat_enabled,
|
chat_enabled=cfg.chat.enabled,
|
||||||
|
publish_quality_cap=cfg.media_limits.publish_quality_cap,
|
||||||
|
stage_max_tiles=cfg.media_limits.stage_max_tiles,
|
||||||
avatar_url=resolve_avatar_url(self._media_root, owner_avatar_path),
|
avatar_url=resolve_avatar_url(self._media_root, owner_avatar_path),
|
||||||
is_organizer=True,
|
is_organizer=True,
|
||||||
)
|
)
|
||||||
@@ -243,12 +245,14 @@ class ConferenceService:
|
|||||||
"""Войти в конференцию зарегистрированным пользователем."""
|
"""Войти в конференцию зарегистрированным пользователем."""
|
||||||
conference = await self._get_or_raise(conference_id)
|
conference = await self._get_or_raise(conference_id)
|
||||||
await ensure_joinable(conference, password=password)
|
await ensure_joinable(conference, password=password)
|
||||||
chat_enabled = (await InstanceSettingsService(self._session).get()).chat.enabled
|
cfg = await InstanceSettingsService(self._session).get()
|
||||||
return build_join(
|
return build_join(
|
||||||
conference,
|
conference,
|
||||||
identity=str(user.id),
|
identity=str(user.id),
|
||||||
name=user.name_user,
|
name=user.name_user,
|
||||||
chat_enabled=chat_enabled,
|
chat_enabled=cfg.chat.enabled,
|
||||||
|
publish_quality_cap=cfg.media_limits.publish_quality_cap,
|
||||||
|
stage_max_tiles=cfg.media_limits.stage_max_tiles,
|
||||||
avatar_url=resolve_avatar_url(self._media_root, user.avatar_path),
|
avatar_url=resolve_avatar_url(self._media_root, user.avatar_path),
|
||||||
is_organizer=conference.owner_id is not None and conference.owner_id == user.id,
|
is_organizer=conference.owner_id is not None and conference.owner_id == user.id,
|
||||||
)
|
)
|
||||||
@@ -265,12 +269,14 @@ class ConferenceService:
|
|||||||
await self._session.flush()
|
await self._session.flush()
|
||||||
await self._session.commit()
|
await self._session.commit()
|
||||||
|
|
||||||
chat_enabled = (await InstanceSettingsService(self._session).get()).chat.enabled
|
cfg = await InstanceSettingsService(self._session).get()
|
||||||
return build_join(
|
return build_join(
|
||||||
conference,
|
conference,
|
||||||
identity=f"guest:{guest.id}",
|
identity=f"guest:{guest.id}",
|
||||||
name=data.display_name,
|
name=data.display_name,
|
||||||
chat_enabled=chat_enabled,
|
chat_enabled=cfg.chat.enabled,
|
||||||
|
publish_quality_cap=cfg.media_limits.publish_quality_cap,
|
||||||
|
stage_max_tiles=cfg.media_limits.stage_max_tiles,
|
||||||
)
|
)
|
||||||
|
|
||||||
async def mute_participant(
|
async def mute_participant(
|
||||||
|
|||||||
@@ -2,7 +2,8 @@
|
|||||||
|
|
||||||
Ключи зеркалят секции конфигурации (`transcriber`, `summarizer`, `chat`,
|
Ключи зеркалят секции конфигурации (`transcriber`, `summarizer`, `chat`,
|
||||||
`ai_level`, `summary_recipients`, `display_timezone`,
|
`ai_level`, `summary_recipients`, `display_timezone`,
|
||||||
`registration_team_choice`, `registration_email_domain`, `contact_email`) —
|
`registration_team_choice`, `registration_email_domain`, `contact_email`,
|
||||||
|
`media_limits`) —
|
||||||
новая настройка не требует миграции, только новая строка. Бутстрап (`ensure_bootstrapped`)
|
новая настройка не требует миграции, только новая строка. Бутстрап (`ensure_bootstrapped`)
|
||||||
импортирует дефолты `config/plugins.yaml` через `INSERT ... ON CONFLICT DO
|
импортирует дефолты `config/plugins.yaml` через `INSERT ... ON CONFLICT DO
|
||||||
NOTHING` в lifespan backend — однократно и идемпотентно: повторный вызов
|
NOTHING` в lifespan backend — однократно и идемпотентно: повторный вызов
|
||||||
@@ -28,7 +29,10 @@ from core.plugins.config import (
|
|||||||
AiLevel,
|
AiLevel,
|
||||||
ChatConfig,
|
ChatConfig,
|
||||||
InstanceConfig,
|
InstanceConfig,
|
||||||
|
MediaLimitsConfig,
|
||||||
PluginsConfig,
|
PluginsConfig,
|
||||||
|
PublishQualityCap,
|
||||||
|
StageMaxTiles,
|
||||||
SummarizerConfig,
|
SummarizerConfig,
|
||||||
SummaryRecipientsMode,
|
SummaryRecipientsMode,
|
||||||
TranscriberConfig,
|
TranscriberConfig,
|
||||||
@@ -47,6 +51,7 @@ _KEY_DISPLAY_TIMEZONE = "display_timezone"
|
|||||||
_KEY_REGISTRATION_TEAM_CHOICE = "registration_team_choice"
|
_KEY_REGISTRATION_TEAM_CHOICE = "registration_team_choice"
|
||||||
_KEY_REGISTRATION_EMAIL_DOMAIN = "registration_email_domain"
|
_KEY_REGISTRATION_EMAIL_DOMAIN = "registration_email_domain"
|
||||||
_KEY_CONTACT_EMAIL = "contact_email"
|
_KEY_CONTACT_EMAIL = "contact_email"
|
||||||
|
_KEY_MEDIA_LIMITS = "media_limits"
|
||||||
|
|
||||||
BOOTSTRAP_MANAGED_KEYS: tuple[str, ...] = (
|
BOOTSTRAP_MANAGED_KEYS: tuple[str, ...] = (
|
||||||
_KEY_CHAT,
|
_KEY_CHAT,
|
||||||
@@ -70,6 +75,7 @@ _DEFAULT_REGISTRATION_EMAIL_DOMAIN_VALUE: dict[str, Any] = {"enabled": False, "d
|
|||||||
для обратной совместимости с уже развёрнутыми инстансами; при первом же
|
для обратной совместимости с уже развёрнутыми инстансами; при первом же
|
||||||
`update()` значение переписывается в новую форму (см. `update`)."""
|
`update()` значение переписывается в новую форму (см. `update`)."""
|
||||||
_DEFAULT_CONTACT_EMAIL_VALUE: dict[str, Any] = {"enabled": False, "email": None}
|
_DEFAULT_CONTACT_EMAIL_VALUE: dict[str, Any] = {"enabled": False, "email": None}
|
||||||
|
_DEFAULT_MEDIA_LIMITS_VALUE: dict[str, Any] = {"publish_quality_cap": "off", "stage_max_tiles": 25}
|
||||||
|
|
||||||
# Простой паттерн доменного имени: минимум один символ, минимум одна точка,
|
# Простой паттерн доменного имени: минимум один символ, минимум одна точка,
|
||||||
# метки из латинских букв/цифр/дефисов (без ведущего/конечного дефиса),
|
# метки из латинских букв/цифр/дефисов (без ведущего/конечного дефиса),
|
||||||
@@ -103,6 +109,8 @@ class SettingsUpdateIn(BaseModel):
|
|||||||
registration_email_domains: list[str] | None = None
|
registration_email_domains: list[str] | None = None
|
||||||
contact_email_enabled: bool | None = None
|
contact_email_enabled: bool | None = None
|
||||||
contact_email: str | None = None
|
contact_email: str | None = None
|
||||||
|
publish_quality_cap: PublishQualityCap | None = None
|
||||||
|
stage_max_tiles: StageMaxTiles | None = None
|
||||||
|
|
||||||
|
|
||||||
class BootstrapOverrides(BaseModel):
|
class BootstrapOverrides(BaseModel):
|
||||||
@@ -152,6 +160,7 @@ def build_bootstrap_defaults(
|
|||||||
_KEY_REGISTRATION_TEAM_CHOICE: dict(_DEFAULT_REGISTRATION_TEAM_CHOICE_VALUE),
|
_KEY_REGISTRATION_TEAM_CHOICE: dict(_DEFAULT_REGISTRATION_TEAM_CHOICE_VALUE),
|
||||||
_KEY_REGISTRATION_EMAIL_DOMAIN: dict(_DEFAULT_REGISTRATION_EMAIL_DOMAIN_VALUE),
|
_KEY_REGISTRATION_EMAIL_DOMAIN: dict(_DEFAULT_REGISTRATION_EMAIL_DOMAIN_VALUE),
|
||||||
_KEY_CONTACT_EMAIL: dict(_DEFAULT_CONTACT_EMAIL_VALUE),
|
_KEY_CONTACT_EMAIL: dict(_DEFAULT_CONTACT_EMAIL_VALUE),
|
||||||
|
_KEY_MEDIA_LIMITS: dict(_DEFAULT_MEDIA_LIMITS_VALUE),
|
||||||
}
|
}
|
||||||
if overrides is None:
|
if overrides is None:
|
||||||
return defaults
|
return defaults
|
||||||
@@ -340,6 +349,20 @@ class InstanceSettingsService:
|
|||||||
await self._set(_KEY_TRANSCRIBER, cfg.transcriber.model_dump(mode="json"))
|
await self._set(_KEY_TRANSCRIBER, cfg.transcriber.model_dump(mode="json"))
|
||||||
await self._set(_KEY_SUMMARIZER, cfg.summarizer.model_dump(mode="json"))
|
await self._set(_KEY_SUMMARIZER, cfg.summarizer.model_dump(mode="json"))
|
||||||
|
|
||||||
|
if patch.publish_quality_cap is not None or patch.stage_max_tiles is not None:
|
||||||
|
cap = (
|
||||||
|
patch.publish_quality_cap
|
||||||
|
if patch.publish_quality_cap is not None
|
||||||
|
else cfg.media_limits.publish_quality_cap
|
||||||
|
)
|
||||||
|
max_tiles = (
|
||||||
|
patch.stage_max_tiles
|
||||||
|
if patch.stage_max_tiles is not None
|
||||||
|
else cfg.media_limits.stage_max_tiles
|
||||||
|
)
|
||||||
|
cfg.media_limits = MediaLimitsConfig(publish_quality_cap=cap, stage_max_tiles=max_tiles)
|
||||||
|
await self._set(_KEY_MEDIA_LIMITS, cfg.media_limits.model_dump(mode="json"))
|
||||||
|
|
||||||
await self._session.commit()
|
await self._session.commit()
|
||||||
return cfg
|
return cfg
|
||||||
|
|
||||||
@@ -489,4 +512,7 @@ def _build_config(rows: dict[str, Any]) -> InstanceConfig:
|
|||||||
"enabled", False
|
"enabled", False
|
||||||
),
|
),
|
||||||
contact_email=rows.get(_KEY_CONTACT_EMAIL, _DEFAULT_CONTACT_EMAIL_VALUE).get("email"),
|
contact_email=rows.get(_KEY_CONTACT_EMAIL, _DEFAULT_CONTACT_EMAIL_VALUE).get("email"),
|
||||||
|
media_limits=MediaLimitsConfig.model_validate(
|
||||||
|
rows.get(_KEY_MEDIA_LIMITS, _DEFAULT_MEDIA_LIMITS_VALUE)
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -706,6 +706,68 @@ async def test_put_settings_contact_email_invalid_returns_400(
|
|||||||
assert response.status_code == 400
|
assert response.status_code == 400
|
||||||
|
|
||||||
|
|
||||||
|
async def test_get_settings_media_limits_defaults(
|
||||||
|
client: httpx.AsyncClient, db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
|
||||||
|
) -> None:
|
||||||
|
"""Дефолты рычагов нагрузки — без ограничения качества и с текущим
|
||||||
|
максимумом сетки (5×5) — существующие инсталляции после обновления не
|
||||||
|
получают внезапно ухудшенное качество."""
|
||||||
|
monkeypatch.setattr(admin_module, "transcription_queue_served", lambda: False)
|
||||||
|
admin = await _make_user(db_session, role="admin")
|
||||||
|
await db_session.commit()
|
||||||
|
|
||||||
|
response = await client.get("/api/v1/admin/settings", headers=_auth_headers(admin))
|
||||||
|
assert response.status_code == 200, response.text
|
||||||
|
body = response.json()
|
||||||
|
assert body["publish_quality_cap"] == "off"
|
||||||
|
assert body["stage_max_tiles"] == 25
|
||||||
|
|
||||||
|
|
||||||
|
async def test_put_settings_media_limits_partial_update(
|
||||||
|
client: httpx.AsyncClient, db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
|
||||||
|
) -> None:
|
||||||
|
monkeypatch.setattr(admin_module, "transcription_queue_served", lambda: False)
|
||||||
|
admin = await _make_user(db_session, role="admin")
|
||||||
|
await db_session.commit()
|
||||||
|
|
||||||
|
response = await client.put(
|
||||||
|
"/api/v1/admin/settings",
|
||||||
|
json={"publish_quality_cap": "360p", "stage_max_tiles": 9},
|
||||||
|
headers=_auth_headers(admin),
|
||||||
|
)
|
||||||
|
assert response.status_code == 200, response.text
|
||||||
|
body = response.json()
|
||||||
|
assert body["publish_quality_cap"] == "360p"
|
||||||
|
assert body["stage_max_tiles"] == 9
|
||||||
|
|
||||||
|
reloaded = await client.get("/api/v1/admin/settings", headers=_auth_headers(admin))
|
||||||
|
assert reloaded.json()["publish_quality_cap"] == "360p"
|
||||||
|
assert reloaded.json()["stage_max_tiles"] == 9
|
||||||
|
|
||||||
|
|
||||||
|
async def test_put_settings_media_limits_invalid_values_return_422(
|
||||||
|
client: httpx.AsyncClient, db_session: AsyncSession
|
||||||
|
) -> None:
|
||||||
|
"""Значения вне разрешённого набора (`Literal`) — ошибка валидации тела запроса
|
||||||
|
ДО сервисного слоя, ещё на уровне FastAPI/pydantic."""
|
||||||
|
admin = await _make_user(db_session, role="admin")
|
||||||
|
await db_session.commit()
|
||||||
|
|
||||||
|
bad_cap = await client.put(
|
||||||
|
"/api/v1/admin/settings",
|
||||||
|
json={"publish_quality_cap": "4k"},
|
||||||
|
headers=_auth_headers(admin),
|
||||||
|
)
|
||||||
|
assert bad_cap.status_code == 422
|
||||||
|
|
||||||
|
bad_tiles = await client.put(
|
||||||
|
"/api/v1/admin/settings",
|
||||||
|
json={"stage_max_tiles": 100},
|
||||||
|
headers=_auth_headers(admin),
|
||||||
|
)
|
||||||
|
assert bad_tiles.status_code == 422
|
||||||
|
|
||||||
|
|
||||||
# --- Тестовое письмо ----------------------------------------------------------------
|
# --- Тестовое письмо ----------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -113,6 +113,10 @@ async def test_create_instant_conference_returns_active_with_join(
|
|||||||
assert body["join"]["conference_id"] == body["id"]
|
assert body["join"]["conference_id"] == body["id"]
|
||||||
assert body["join"]["room_name"] == body["slug"]
|
assert body["join"]["room_name"] == body["slug"]
|
||||||
assert body["join"]["token"]
|
assert body["join"]["token"]
|
||||||
|
# Рычаги нагрузки медиа — дефолты без ограничения (существующие
|
||||||
|
# инсталляции не должны получить внезапно ухудшенное качество).
|
||||||
|
assert body["join"]["publish_quality_cap"] == "off"
|
||||||
|
assert body["join"]["stage_max_tiles"] == 25
|
||||||
|
|
||||||
|
|
||||||
async def test_create_instant_conference_join_metadata_contains_owner_avatar_url(
|
async def test_create_instant_conference_join_metadata_contains_owner_avatar_url(
|
||||||
@@ -841,6 +845,33 @@ async def test_guest_join_creates_guest_access_and_returns_join(
|
|||||||
assert guests[0].email == "alice-guest@example.com"
|
assert guests[0].email == "alice-guest@example.com"
|
||||||
|
|
||||||
|
|
||||||
|
async def test_guest_join_reflects_admin_configured_media_limits(
|
||||||
|
client: httpx.AsyncClient, db_session: AsyncSession
|
||||||
|
) -> None:
|
||||||
|
"""Настройки, сохранённые администратором в `PUT /admin/settings`, доезжают
|
||||||
|
до гостя в join-ответе ДО входа в комнату — публичный путь доставки
|
||||||
|
(см. `services/conference_access.py::build_join`), гость админку не видит."""
|
||||||
|
admin = await _make_user(db_session, role="admin")
|
||||||
|
conference = await _make_conference(db_session)
|
||||||
|
await db_session.commit()
|
||||||
|
|
||||||
|
settings_response = await client.put(
|
||||||
|
"/api/v1/admin/settings",
|
||||||
|
json={"publish_quality_cap": "180p", "stage_max_tiles": 4},
|
||||||
|
headers=_auth_headers(admin),
|
||||||
|
)
|
||||||
|
assert settings_response.status_code == 200, settings_response.text
|
||||||
|
|
||||||
|
response = await client.post(
|
||||||
|
f"/api/v1/conferences/{conference.id}/guest-join",
|
||||||
|
json={"display_name": "Guest Bob"},
|
||||||
|
)
|
||||||
|
assert response.status_code == 200, response.text
|
||||||
|
body = response.json()
|
||||||
|
assert body["publish_quality_cap"] == "180p"
|
||||||
|
assert body["stage_max_tiles"] == 4
|
||||||
|
|
||||||
|
|
||||||
async def test_guest_join_without_email_is_allowed(
|
async def test_guest_join_without_email_is_allowed(
|
||||||
client: httpx.AsyncClient, db_session: AsyncSession
|
client: httpx.AsyncClient, db_session: AsyncSession
|
||||||
) -> None:
|
) -> None:
|
||||||
|
|||||||
@@ -71,6 +71,7 @@ _MANAGED_KEYS = (
|
|||||||
"registration_team_choice",
|
"registration_team_choice",
|
||||||
"registration_email_domain",
|
"registration_email_domain",
|
||||||
"contact_email",
|
"contact_email",
|
||||||
|
"media_limits",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -126,6 +127,7 @@ async def test_ensure_bootstrapped_imports_yaml_defaults(
|
|||||||
"registration_team_choice",
|
"registration_team_choice",
|
||||||
"registration_email_domain",
|
"registration_email_domain",
|
||||||
"contact_email",
|
"contact_email",
|
||||||
|
"media_limits",
|
||||||
}
|
}
|
||||||
cfg = await service.get()
|
cfg = await service.get()
|
||||||
assert cfg.transcriber.provider == "faster_whisper_cpu"
|
assert cfg.transcriber.provider == "faster_whisper_cpu"
|
||||||
@@ -137,6 +139,11 @@ async def test_ensure_bootstrapped_imports_yaml_defaults(
|
|||||||
assert cfg.registration_email_domains == []
|
assert cfg.registration_email_domains == []
|
||||||
assert cfg.contact_email_enabled is False
|
assert cfg.contact_email_enabled is False
|
||||||
assert cfg.contact_email is None
|
assert cfg.contact_email is None
|
||||||
|
# Дефолты сохраняют текущее (до появления настройки) поведение —
|
||||||
|
# без ограничения качества и с максимумом сетки, равным фактическому
|
||||||
|
# потолку `StageGrid` (5×5).
|
||||||
|
assert cfg.media_limits.publish_quality_cap == "off"
|
||||||
|
assert cfg.media_limits.stage_max_tiles == 25
|
||||||
|
|
||||||
|
|
||||||
async def test_ensure_bootstrapped_is_idempotent_and_keeps_admin_edits(
|
async def test_ensure_bootstrapped_is_idempotent_and_keeps_admin_edits(
|
||||||
@@ -538,6 +545,28 @@ async def test_transcription_enabled_flag_toggles_both_transcriber_and_summarize
|
|||||||
assert cfg.summarizer.enabled is False
|
assert cfg.summarizer.enabled is False
|
||||||
|
|
||||||
|
|
||||||
|
async def test_media_limits_partial_update_keeps_untouched_field(
|
||||||
|
db_session: AsyncSession, clean_instance_settings: None
|
||||||
|
) -> None:
|
||||||
|
"""Патч только одного поля `media_limits` не сбрасывает соседнее — оба поля
|
||||||
|
живут в одной строке JSON, `update()` обязан подставлять текущее значение
|
||||||
|
несменённого поля, а не дефолт модели."""
|
||||||
|
service = InstanceSettingsService(db_session)
|
||||||
|
await service.ensure_bootstrapped(PLUGINS_YAML)
|
||||||
|
|
||||||
|
cfg = await service.update(SettingsUpdateIn(publish_quality_cap="360p"))
|
||||||
|
assert cfg.media_limits.publish_quality_cap == "360p"
|
||||||
|
assert cfg.media_limits.stage_max_tiles == 25 # дефолт не тронут
|
||||||
|
|
||||||
|
cfg = await service.update(SettingsUpdateIn(stage_max_tiles=9))
|
||||||
|
assert cfg.media_limits.stage_max_tiles == 9
|
||||||
|
assert cfg.media_limits.publish_quality_cap == "360p" # предыдущая правка сохранилась
|
||||||
|
|
||||||
|
reloaded = await service.get()
|
||||||
|
assert reloaded.media_limits.publish_quality_cap == "360p"
|
||||||
|
assert reloaded.media_limits.stage_max_tiles == 9
|
||||||
|
|
||||||
|
|
||||||
async def test_update_rejects_unavailable_ai_level(
|
async def test_update_rejects_unavailable_ai_level(
|
||||||
db_session: AsyncSession, clean_instance_settings: None
|
db_session: AsyncSession, clean_instance_settings: None
|
||||||
) -> None:
|
) -> None:
|
||||||
|
|||||||
@@ -12,8 +12,8 @@
|
|||||||
# затрутся при следующем рендере.
|
# затрутся при следующем рендере.
|
||||||
|
|
||||||
listening-port=3478
|
listening-port=3478
|
||||||
# Установить 5349 в проде для TURN over TLS, если будут смонтированы
|
# TLS-порт объявлен всегда; реально слушать TLS coturn начинает только когда
|
||||||
# реальные TLS-сертификаты (см. закомментированные cert/pkey ниже).
|
# заданы cert/pkey ниже (блок TLS-CERT) — без них строка ничего не включает.
|
||||||
tls-listening-port=5349
|
tls-listening-port=5349
|
||||||
|
|
||||||
# Диапазон relay-портов для TURN-аллокаций.
|
# Диапазон relay-портов для TURN-аллокаций.
|
||||||
@@ -31,10 +31,15 @@ fingerprint
|
|||||||
# Без CLI/telnet admin-интерфейса в этой поставке.
|
# Без CLI/telnet admin-интерфейса в этой поставке.
|
||||||
no-cli
|
no-cli
|
||||||
|
|
||||||
# Раскомментировать и смонтировать реальные сертификаты, чтобы включить
|
# Блок ниже рендерится ТОЛЬКО когда в .env задан TURN_TLS_HOST (см.
|
||||||
# TURN over TLS на 443:
|
# render-templates.sh) — тогда coturn-certs-init (docker-compose.yml) уже
|
||||||
# cert=/etc/coturn/certs/cert.pem
|
# скопировал fullchain/privkey из /etc/letsencrypt в volume coturn-certs.
|
||||||
# pkey=/etc/coturn/certs/key.pem
|
# Без TURN_TLS_HOST маркеры и всё, что между ними, вырезаются целиком —
|
||||||
|
# в файле не остаётся ни следа cert/pkey, а не просто закомментированных строк.
|
||||||
|
# BEGIN-TLS-CERT
|
||||||
|
cert=/etc/coturn/certs/cert.pem
|
||||||
|
pkey=/etc/coturn/certs/key.pem
|
||||||
|
# END-TLS-CERT
|
||||||
|
|
||||||
log-file=stdout
|
log-file=stdout
|
||||||
simple-log
|
simple-log
|
||||||
|
|||||||
@@ -89,7 +89,7 @@ services:
|
|||||||
MEDIA_ROOT: ${MEDIA_ROOT:-/app/media}
|
MEDIA_ROOT: ${MEDIA_ROOT:-/app/media}
|
||||||
# Версия инстанса (релиз v0.0.1) — install.sh копирует значение
|
# Версия инстанса (релиз v0.0.1) — install.sh копирует значение
|
||||||
# из файла VERSION (корень репозитория) в .env; отдаётся в GET /api/health.
|
# из файла VERSION (корень репозитория) в .env; отдаётся в GET /api/health.
|
||||||
VIDCONF_VERSION: ${VIDCONF_VERSION:-0.0.20}
|
VIDCONF_VERSION: ${VIDCONF_VERSION:-0.0.22}
|
||||||
# Число процессов uvicorn (см. backend/Dockerfile). Дефолт 2 рассчитан
|
# Число процессов uvicorn (см. backend/Dockerfile). Дефолт 2 рассчитан
|
||||||
# на 4-ядерный сервер, где ядра делятся с LiveKit. Поднимая значение,
|
# на 4-ядерный сервер, где ядра делятся с LiveKit. Поднимая значение,
|
||||||
# проверьте бюджет соединений с БД: каждый воркер держит свой пул
|
# проверьте бюджет соединений с БД: каждый воркер держит свой пул
|
||||||
@@ -432,6 +432,49 @@ services:
|
|||||||
profiles: ["media"]
|
profiles: ["media"]
|
||||||
logging: *default-logging
|
logging: *default-logging
|
||||||
|
|
||||||
|
# Образ coturn/coturn — Dockerfile прописывает `USER nobody:nogroup`, и это
|
||||||
|
# НЕ runtime-привилегия, которую можно сбросить: Docker exec'ает entrypoint
|
||||||
|
# сразу от этого uid, root-фазы внутри контейнера нет вовсе (в отличие от
|
||||||
|
# официального образа nginx, который стартует entrypoint от root и только
|
||||||
|
# nginx-воркеры позже понижают права по директиве в конфиге — см.
|
||||||
|
# deploy/nginx/docker-entrypoint-certs.sh). Значит coturn физически не может
|
||||||
|
# сам прочитать приватный ключ Let's Encrypt (root:root, обычно 0600) —
|
||||||
|
# никакой volume-опцией это не обойти, не ослабляя права на ключ на хосте.
|
||||||
|
#
|
||||||
|
# Решение — по образцу уже существующего `recordings-init`/`llm-models-init`
|
||||||
|
# в этом файле: отдельный init-контейнер (busybox, дефолтный root) читает
|
||||||
|
# /etc/letsencrypt (той же ro-монтировкой, что и у nginx) и копирует
|
||||||
|
# fullchain/privkey в СВОЙ volume под правами 644 — это копия, а не
|
||||||
|
# оригинал, оригинальный ключ на хосте прав не меняет. Копия достаточно
|
||||||
|
# открыта, чтобы её прочитал nobody:nogroup внутри coturn.
|
||||||
|
#
|
||||||
|
# Если /etc/letsencrypt/live/<домен> не существует (dev, нет реальных
|
||||||
|
# сертификатов) — команда ниже просто ничего не копирует и завершается
|
||||||
|
# успешно; coturn стартует как раньше, без TLS (см. TURN_TLS_HOST в
|
||||||
|
# render-templates.sh — вторая половина того же переключателя).
|
||||||
|
coturn-certs-init:
|
||||||
|
image: busybox:1.36
|
||||||
|
command: >
|
||||||
|
sh -c '
|
||||||
|
SRC="/etc/letsencrypt/live/$$NGINX_CERT_NAME";
|
||||||
|
if [ -f "$$SRC/fullchain.pem" ] && [ -f "$$SRC/privkey.pem" ]; then
|
||||||
|
cp "$$SRC/fullchain.pem" /certs/cert.pem;
|
||||||
|
cp "$$SRC/privkey.pem" /certs/key.pem;
|
||||||
|
chmod 644 /certs/cert.pem /certs/key.pem;
|
||||||
|
echo "[coturn-certs-init] сертификат $$SRC скопирован в volume coturn-certs";
|
||||||
|
else
|
||||||
|
echo "[coturn-certs-init] $$SRC не найден — TLS для coturn не настроен (норма для dev без TURN_TLS_HOST)";
|
||||||
|
fi
|
||||||
|
'
|
||||||
|
environment:
|
||||||
|
NGINX_CERT_NAME: ${NGINX_CERT_NAME:?NGINX_CERT_NAME не задан в .env}
|
||||||
|
volumes:
|
||||||
|
- /etc/letsencrypt:/etc/letsencrypt:ro
|
||||||
|
- coturn-certs:/certs
|
||||||
|
restart: "no"
|
||||||
|
profiles: ["media"]
|
||||||
|
logging: *default-logging
|
||||||
|
|
||||||
coturn:
|
coturn:
|
||||||
image: coturn/coturn:latest
|
image: coturn/coturn:latest
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
@@ -444,6 +487,10 @@ services:
|
|||||||
# (deploy/render-templates.sh, вызывается install.sh).
|
# (deploy/render-templates.sh, вызывается install.sh).
|
||||||
volumes:
|
volumes:
|
||||||
- ./coturn/turnserver.conf:/etc/coturn/turnserver.conf:ro
|
- ./coturn/turnserver.conf:/etc/coturn/turnserver.conf:ro
|
||||||
|
- coturn-certs:/etc/coturn/certs:ro
|
||||||
|
depends_on:
|
||||||
|
coturn-certs-init:
|
||||||
|
condition: service_completed_successfully
|
||||||
network_mode: host
|
network_mode: host
|
||||||
# Образ coturn/coturn — минимальный (debian-slim), в нём нет pgrep/ps/nc/
|
# Образ coturn/coturn — минимальный (debian-slim), в нём нет pgrep/ps/nc/
|
||||||
# curl/wget, поэтому проверка процесса по имени не работает
|
# curl/wget, поэтому проверка процесса по имени не работает
|
||||||
@@ -887,6 +934,11 @@ volumes:
|
|||||||
# Загруженные пользователями файлы (аватары) — общий том между
|
# Загруженные пользователями файлы (аватары) — общий том между
|
||||||
# backend (запись при загрузке) и nginx (раздача статики, `location /media/`).
|
# backend (запись при загрузке) и nginx (раздача статики, `location /media/`).
|
||||||
media:
|
media:
|
||||||
|
# Копия fullchain/privkey Let's Encrypt под правами 644 для coturn
|
||||||
|
# (nobody:nogroup) — источник в /etc/letsencrypt не трогаем, см.
|
||||||
|
# coturn-certs-init выше. Обновляется при каждом перезапуске
|
||||||
|
# coturn-certs-init (deploy-hook certbot делает это при продлении).
|
||||||
|
coturn-certs:
|
||||||
# Метрики Prometheus (профиль `monitoring`) — переживают пересоздание контейнера.
|
# Метрики Prometheus (профиль `monitoring`) — переживают пересоздание контейнера.
|
||||||
prometheus_data:
|
prometheus_data:
|
||||||
# Дашборды/настройки Grafana (профиль `monitoring`) — переживают пересоздание.
|
# Дашборды/настройки Grafana (профиль `monitoring`) — переживают пересоздание.
|
||||||
|
|||||||
@@ -54,10 +54,24 @@ rtc:
|
|||||||
# оба рендерятся из одного TURN_STATIC_AUTH_SECRET (deploy/render-templates.sh).
|
# оба рендерятся из одного TURN_STATIC_AUTH_SECRET (deploy/render-templates.sh).
|
||||||
# Логин/пароль LiveKit генерирует сам по механизму TURN REST API.
|
# Логин/пароль LiveKit генерирует сам по механизму TURN REST API.
|
||||||
#
|
#
|
||||||
# UDP и TCP на 3478 — оба порта уже открыты в ufw. TLS (5349) намеренно не
|
# UDP и TCP на 3478 — оба порта уже открыты в ufw.
|
||||||
# объявляем: в turnserver.conf сертификаты не смонтированы, и анонс
|
#
|
||||||
# неработающего `turns:` заставил бы клиента впустую ждать таймаута,
|
# TLS (5349) объявляется ТОЛЬКО когда в .env задан TURN_TLS_HOST (см.
|
||||||
# прежде чем перейти к рабочему кандидату.
|
# render-templates.sh) — до тех пор блок между маркерами вырезается
|
||||||
|
# целиком, и клиент его не увидит вовсе. Это осознанно: анонс
|
||||||
|
# неработающего `turns:` (без смонтированных в coturn сертификатов)
|
||||||
|
# заставил бы клиента впустую ждать TLS-таймаута, прежде чем перейти
|
||||||
|
# к рабочему кандидату — именно так это и стояло здесь до включения TLS.
|
||||||
|
#
|
||||||
|
# Хост для TLS-записи обязан быть ДОМЕНОМ, а не IP (в отличие от udp/tcp
|
||||||
|
# выше): браузер проверяет TLS-сертификат TURN-сервера по имени хоста,
|
||||||
|
# а сертификат Let's Encrypt выписан на домен, не на IP — с IP в host
|
||||||
|
# TLS-хендшейк упадёт на проверке имени, и это будет выглядеть как ещё
|
||||||
|
# один вариант «coturn healthy, но relay не работает».
|
||||||
|
#
|
||||||
|
# TLS-запись стоит ПОСЛЕДНЕЙ: клиент перебирает кандидатов по порядку,
|
||||||
|
# а TLS через TCP дороже прямого UDP — она должна быть фолбэком, а не
|
||||||
|
# выбираться первой.
|
||||||
turn_servers:
|
turn_servers:
|
||||||
- host: ${TURN_EXTERNAL_IP}
|
- host: ${TURN_EXTERNAL_IP}
|
||||||
port: 3478
|
port: 3478
|
||||||
@@ -69,6 +83,13 @@ rtc:
|
|||||||
protocol: tcp
|
protocol: tcp
|
||||||
secret: ${TURN_STATIC_AUTH_SECRET}
|
secret: ${TURN_STATIC_AUTH_SECRET}
|
||||||
ttl: 14400
|
ttl: 14400
|
||||||
|
# BEGIN-TLS-TURN
|
||||||
|
- host: ${TURN_TLS_HOST}
|
||||||
|
port: 5349
|
||||||
|
protocol: tls
|
||||||
|
secret: ${TURN_STATIC_AUTH_SECRET}
|
||||||
|
ttl: 14400
|
||||||
|
# END-TLS-TURN
|
||||||
|
|
||||||
# Redis обязателен для сервиса egress (см. deploy/egress/) — он использует
|
# Redis обязателен для сервиса egress (см. deploy/egress/) — он использует
|
||||||
# его как pub/sub и key-value хранилище состояния запущенных записей;
|
# его как pub/sub и key-value хранилище состояния запущенных записей;
|
||||||
|
|||||||
@@ -23,13 +23,27 @@ fi
|
|||||||
# значения вроде `SMTP_FROM=VidConf <no-reply@vidconf.example>`, где `<` —
|
# значения вроде `SMTP_FROM=VidConf <no-reply@vidconf.example>`, где `<` —
|
||||||
# валидный литерал для docker-compose/pydantic, но невалидный bash-синтаксис
|
# валидный литерал для docker-compose/pydantic, но невалидный bash-синтаксис
|
||||||
# (интерпретируется как редирект) при попытке `source` файла целиком.
|
# (интерпретируется как редирект) при попытке `source` файла целиком.
|
||||||
|
#
|
||||||
|
# `|| true` в конце обязателен: под `set -e -o pipefail` (см. выше) сборка
|
||||||
|
# `"$(env_var VAR)"` для ключа, которого в файле нет ВООБЩЕ (не просто
|
||||||
|
# пустое значение, а отсутствующая строка) иначе завершает весь скрипт
|
||||||
|
# ошибкой grep ДО того, как сработает дружелюбная проверка `:?` ниже —
|
||||||
|
# найдено на TURN_TLS_HOST (новый необязательный ключ, есть не во всех
|
||||||
|
# существующих .env).
|
||||||
env_var() {
|
env_var() {
|
||||||
grep -E "^${1}=" "$ENV_FILE" 2>/dev/null | tail -1 | cut -d= -f2-
|
grep -E "^${1}=" "$ENV_FILE" 2>/dev/null | tail -1 | cut -d= -f2- || true
|
||||||
}
|
}
|
||||||
|
|
||||||
TURN_STATIC_AUTH_SECRET="$(env_var TURN_STATIC_AUTH_SECRET)"
|
TURN_STATIC_AUTH_SECRET="$(env_var TURN_STATIC_AUTH_SECRET)"
|
||||||
TURN_REALM="$(env_var TURN_REALM)"
|
TURN_REALM="$(env_var TURN_REALM)"
|
||||||
TURN_EXTERNAL_IP="$(env_var TURN_EXTERNAL_IP)"
|
TURN_EXTERNAL_IP="$(env_var TURN_EXTERNAL_IP)"
|
||||||
|
# TURN_TLS_HOST — единственный переключатель TURN over TLS (5349) во всём
|
||||||
|
# проекте: непустой = TLS смонтирован и объявляется клиентам, пустой = TLS
|
||||||
|
# отсутствует везде (dev по умолчанию). Поэтому НЕ обязателен (без `:?`) —
|
||||||
|
# в отличие от TURN_EXTERNAL_IP, который должен быть IP хоста, TURN_TLS_HOST
|
||||||
|
# обязан быть ДОМЕНОМ сертификата (иначе браузер не пройдёт TLS-валидацию
|
||||||
|
# по имени хоста для `turns:`, см. комментарий в livekit.yaml.template).
|
||||||
|
TURN_TLS_HOST="$(env_var TURN_TLS_HOST)"
|
||||||
LIVEKIT_API_KEY="$(env_var LIVEKIT_API_KEY)"
|
LIVEKIT_API_KEY="$(env_var LIVEKIT_API_KEY)"
|
||||||
LIVEKIT_NODE_IP="$(env_var LIVEKIT_NODE_IP)"
|
LIVEKIT_NODE_IP="$(env_var LIVEKIT_NODE_IP)"
|
||||||
LIVEKIT_USE_EXTERNAL_IP="$(env_var LIVEKIT_USE_EXTERNAL_IP)"
|
LIVEKIT_USE_EXTERNAL_IP="$(env_var LIVEKIT_USE_EXTERNAL_IP)"
|
||||||
@@ -43,17 +57,31 @@ REDIS_PASSWORD="$(env_var REDIS_PASSWORD)"
|
|||||||
: "${LIVEKIT_USE_EXTERNAL_IP:?LIVEKIT_USE_EXTERNAL_IP не задан в .env (true/false)}"
|
: "${LIVEKIT_USE_EXTERNAL_IP:?LIVEKIT_USE_EXTERNAL_IP не задан в .env (true/false)}"
|
||||||
: "${REDIS_PASSWORD:?REDIS_PASSWORD не задан в .env (redis запускается с --requirepass, см. docker-compose.yml)}"
|
: "${REDIS_PASSWORD:?REDIS_PASSWORD не задан в .env (redis запускается с --requirepass, см. docker-compose.yml)}"
|
||||||
|
|
||||||
export TURN_STATIC_AUTH_SECRET TURN_REALM TURN_EXTERNAL_IP LIVEKIT_API_KEY LIVEKIT_NODE_IP LIVEKIT_USE_EXTERNAL_IP REDIS_PASSWORD
|
export TURN_STATIC_AUTH_SECRET TURN_REALM TURN_EXTERNAL_IP TURN_TLS_HOST LIVEKIT_API_KEY LIVEKIT_NODE_IP LIVEKIT_USE_EXTERNAL_IP REDIS_PASSWORD
|
||||||
|
|
||||||
|
# Вырезает блок между парой маркеров-комментариев (не только их самих), если
|
||||||
|
# TURN_TLS_HOST пуст — так рендер отражает реальное наличие TLS-сертификатов,
|
||||||
|
# а не просто закомментированные "на будущее" строки.
|
||||||
|
strip_tls_block_if_disabled() {
|
||||||
|
local file="$1" begin_marker="$2" end_marker="$3"
|
||||||
|
if [ -z "$TURN_TLS_HOST" ]; then
|
||||||
|
sed -i.bak "/${begin_marker}/,/${end_marker}/d" "$file" && rm -f "$file.bak"
|
||||||
|
else
|
||||||
|
sed -i.bak "/${begin_marker}/d; /${end_marker}/d" "$file" && rm -f "$file.bak"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
envsubst '${TURN_STATIC_AUTH_SECRET} ${TURN_REALM} ${TURN_EXTERNAL_IP}' \
|
envsubst '${TURN_STATIC_AUTH_SECRET} ${TURN_REALM} ${TURN_EXTERNAL_IP}' \
|
||||||
< "$SCRIPT_DIR/coturn/turnserver.conf.template" > "$SCRIPT_DIR/coturn/turnserver.conf"
|
< "$SCRIPT_DIR/coturn/turnserver.conf.template" > "$SCRIPT_DIR/coturn/turnserver.conf"
|
||||||
echo "[render] deploy/coturn/turnserver.conf готов"
|
strip_tls_block_if_disabled "$SCRIPT_DIR/coturn/turnserver.conf" "# BEGIN-TLS-CERT" "# END-TLS-CERT"
|
||||||
|
echo "[render] deploy/coturn/turnserver.conf готов$([ -n "$TURN_TLS_HOST" ] && echo " (TLS включён)" || echo " (TLS выключен — TURN_TLS_HOST пуст)")"
|
||||||
|
|
||||||
# TURN_EXTERNAL_IP и TURN_STATIC_AUTH_SECRET нужны и здесь: с 0.0.14 LiveKit
|
# TURN_EXTERNAL_IP и TURN_STATIC_AUTH_SECRET нужны и здесь: с 0.0.14 LiveKit
|
||||||
# анонсирует клиентам внешний coturn (секция `rtc.turn_servers`), и секрет
|
# анонсирует клиентам внешний coturn (секция `rtc.turn_servers`), и секрет
|
||||||
# обязан совпадать с `static-auth-secret` в turnserver.conf выше.
|
# обязан совпадать с `static-auth-secret` в turnserver.conf выше.
|
||||||
envsubst '${LIVEKIT_USE_EXTERNAL_IP} ${LIVEKIT_NODE_IP} ${LIVEKIT_API_KEY} ${REDIS_PASSWORD} ${TURN_EXTERNAL_IP} ${TURN_STATIC_AUTH_SECRET}' \
|
envsubst '${LIVEKIT_USE_EXTERNAL_IP} ${LIVEKIT_NODE_IP} ${LIVEKIT_API_KEY} ${REDIS_PASSWORD} ${TURN_EXTERNAL_IP} ${TURN_STATIC_AUTH_SECRET} ${TURN_TLS_HOST}' \
|
||||||
< "$SCRIPT_DIR/livekit/livekit.yaml.template" > "$SCRIPT_DIR/livekit/livekit.yaml"
|
< "$SCRIPT_DIR/livekit/livekit.yaml.template" > "$SCRIPT_DIR/livekit/livekit.yaml"
|
||||||
|
strip_tls_block_if_disabled "$SCRIPT_DIR/livekit/livekit.yaml" "# BEGIN-TLS-TURN" "# END-TLS-TURN"
|
||||||
echo "[render] deploy/livekit/livekit.yaml готов"
|
echo "[render] deploy/livekit/livekit.yaml готов"
|
||||||
|
|
||||||
envsubst '${REDIS_PASSWORD}' \
|
envsubst '${REDIS_PASSWORD}' \
|
||||||
|
|||||||
@@ -393,7 +393,9 @@ Email сразу считается подтверждённым (письмо
|
|||||||
"registration_team_choice": false,
|
"registration_team_choice": false,
|
||||||
"registration_email_domain_enabled": false,
|
"registration_email_domain_enabled": false,
|
||||||
"registration_email_domain": null,
|
"registration_email_domain": null,
|
||||||
"transcription_queue_served": true
|
"transcription_queue_served": true,
|
||||||
|
"publish_quality_cap": "off",
|
||||||
|
"stage_max_tiles": 25
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -408,6 +410,8 @@ Email сразу считается подтверждённым (письмо
|
|||||||
- `registration_email_domain_enabled` — включена ли верификация регистрирующихся по домену email (дефолт `false`)
|
- `registration_email_domain_enabled` — включена ли верификация регистрирующихся по домену email (дефолт `false`)
|
||||||
- `registration_email_domain` — эталонный домен email (нормализован: без ведущего `@`, в нижнем регистре); `null`, пока верификация не настроена
|
- `registration_email_domain` — эталонный домен email (нормализован: без ведущего `@`, в нижнем регистре); `null`, пока верификация не настроена
|
||||||
- `transcription_queue_served` — `true`, если хотя бы один Celery-воркер `transcriber` активно обслуживает очередь транскрибации; `false` = предупреждение в админке (см. ниже)
|
- `transcription_queue_served` — `true`, если хотя бы один Celery-воркер `transcriber` активно обслуживает очередь транскрибации; `false` = предупреждение в админке (см. ниже)
|
||||||
|
- `publish_quality_cap` — потолок качества исходящего видео публикующего: `off` (без ограничения, дефолт), `720p`, `360p` или `180p`; отдаётся участнику ещё и в join-ответе (`JoinOut`, `POST /api/v1/conferences/{id}/join`/`guest-join`) — до входа в LiveKit-комнату
|
||||||
|
- `stage_max_tiles` — максимум одновременно видимых плиток сетки конференции: `4`, `9`, `16` или `25` (дефолт, совпадает с текущим потолком сетки 5×5, то есть без ограничения); участники сверх лимита уходят на следующую страницу пагинации
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -237,7 +237,22 @@ docker compose -f deploy/docker-compose.yml --env-file .env restart nginx
|
|||||||
```bash
|
```bash
|
||||||
cat > /etc/letsencrypt/renewal-hooks/deploy/reload-nginx.sh << 'EOF'
|
cat > /etc/letsencrypt/renewal-hooks/deploy/reload-nginx.sh << 'EOF'
|
||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
|
# certbot renew копирует новый сертификат в /etc/letsencrypt/live/<домен>/,
|
||||||
|
# но и nginx, и coturn держат СВОИ копии (docker-entrypoint-certs.sh и
|
||||||
|
# coturn-certs-init в deploy/docker-compose.yml) — обе копируются только
|
||||||
|
# при СТАРТЕ/пересоздании соответствующего контейнера. Reload недостаточен
|
||||||
|
# ни для того, ни для другого — нужен restart.
|
||||||
docker restart vidconf-nginx-1
|
docker restart vidconf-nginx-1
|
||||||
|
|
||||||
|
# TURN over TLS (см. docs/deploy/DEPLOYMENT.md §8) — только если включён
|
||||||
|
# (TURN_TLS_HOST задан в .env). Без coturn-certs-init coturn продолжит
|
||||||
|
# держать в памяти старый сертификат ещё ~60 дней, до следующего продления,
|
||||||
|
# и TLS-хендшейк начнёт падать с ошибкой валидации сертификата у клиентов.
|
||||||
|
cd /opt/vidconf || exit 1
|
||||||
|
if grep -qE '^TURN_TLS_HOST=.+' .env; then
|
||||||
|
docker compose -f deploy/docker-compose.yml --env-file .env up -d coturn-certs-init
|
||||||
|
docker restart vidconf-coturn-1
|
||||||
|
fi
|
||||||
EOF
|
EOF
|
||||||
chmod +x /etc/letsencrypt/renewal-hooks/deploy/reload-nginx.sh
|
chmod +x /etc/letsencrypt/renewal-hooks/deploy/reload-nginx.sh
|
||||||
|
|
||||||
@@ -277,7 +292,8 @@ docker compose -f deploy/docker-compose.yml --env-file .env --profile monitoring
|
|||||||
docker compose -f deploy/docker-compose.yml --env-file .env ps
|
docker compose -f deploy/docker-compose.yml --env-file .env ps
|
||||||
|
|
||||||
# 2. Наружу открыто только ожидаемое (80/443/7881 + udp 54000,
|
# 2. Наружу открыто только ожидаемое (80/443/7881 + udp 54000,
|
||||||
# плюс 3478 tcp+udp, если включили TURN — раздел 8)
|
# плюс 3478 tcp+udp и 49160:49200/udp, если включили TURN, плюс 5349/tcp,
|
||||||
|
# если включили TURN over TLS — раздел 8)
|
||||||
ss -ltnp
|
ss -ltnp
|
||||||
|
|
||||||
# 3. Redis требует пароль (НЕ должен пускать без него)
|
# 3. Redis требует пароль (НЕ должен пускать без него)
|
||||||
@@ -359,14 +375,89 @@ state: connected`, но собеседник не видит видео/не с
|
|||||||
Ноль при живом звонке из-за NAT означает, что до coturn не дошли —
|
Ноль при живом звонке из-за NAT означает, что до coturn не дошли —
|
||||||
смотрите ufw и `TURN_EXTERNAL_IP`.
|
смотрите ufw и `TURN_EXTERNAL_IP`.
|
||||||
|
|
||||||
**TURN over TLS (порт 5349 или 443) — не настроен.** Это самый надёжный
|
### TURN over TLS (порт 5349)
|
||||||
фолбэк (проходит там, где режут UDP и нестандартные порты), но требует
|
|
||||||
смонтировать в coturn TLS-сертификат: раскомментировать `cert`/`pkey` в
|
Самый надёжный фолбэк: в жёстких корпоративных сетях наружу часто разрешён
|
||||||
`deploy/coturn/turnserver.conf.template`, добавить volume с
|
только `443/tcp`, и TLS-соединение на нестандартный порт (5349) выглядит для
|
||||||
`/etc/letsencrypt` (nginx его уже монтирует, coturn — нет), открыть порт и
|
firewall как обычный HTTPS. UDP/TCP на 3478 такие сети режут целиком.
|
||||||
не забыть про перезапуск coturn при обновлении сертификата. Пока этого нет,
|
|
||||||
`turns:` намеренно не анонсируется: анонс неработающего адреса заставил бы
|
**443 вместо 5349 невозможен без доп. усложнений**: `443/tcp` на хосте уже
|
||||||
клиента ждать таймаута перед переходом к рабочему кандидату.
|
занят nginx (Docker port-publish биндит хостовый сокет), а coturn слушает в
|
||||||
|
`network_mode: host` — оба не могут забрать один и тот же порт без
|
||||||
|
SNI-мультиплексора перед ними. Такой мультиплексор — отдельная, более
|
||||||
|
сложная система; в этом проекте её нет, и заводить её только ради 443 не
|
||||||
|
оправдано, пока 5349 проходит через те же firewall, что и 443.
|
||||||
|
|
||||||
|
**Включение — один флаг, `TURN_TLS_HOST` в `.env`:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Домен сертификата (НЕ IP — см. предупреждение ниже), тот же, что в
|
||||||
|
# NGINX_CERT_NAME:
|
||||||
|
TURN_TLS_HOST=vidconf.ru
|
||||||
|
```
|
||||||
|
|
||||||
|
Пусто (dev-дефолт) — TLS выключен полностью и без следов: `render-templates.sh`
|
||||||
|
вырезает cert/pkey из `turnserver.conf` и запись `protocol: tls` из
|
||||||
|
`rtc.turn_servers` в `livekit.yaml` (маркеры `BEGIN-TLS-*`/`END-TLS-*` в
|
||||||
|
`.template`-файлах). Непустое значение включает оба сразу — TLS без анонса
|
||||||
|
клиентам (и наоборот) не бывает, ровно один переключатель на всё.
|
||||||
|
|
||||||
|
⚠️ **`TURN_TLS_HOST` обязан быть ДОМЕНОМ, не IP** — в отличие от
|
||||||
|
`TURN_EXTERNAL_IP`, который остаётся IP-адресом для udp/tcp-записей. Браузер
|
||||||
|
проверяет TLS-сертификат TURN-сервера по имени хоста в `turns:`-URL, а
|
||||||
|
Let's Encrypt выписывает сертификат на домен. С IP в этом поле TLS-хендшейк
|
||||||
|
упадёт на проверке имени сертификата — внешне это будет выглядеть как ещё
|
||||||
|
один вариант «coturn healthy, а relay не работает», только на новом порту.
|
||||||
|
|
||||||
|
**Права на приватный ключ.** coturn (`nobody:nogroup` внутри контейнера, без
|
||||||
|
root-фазы в entrypoint — не то что у nginx, где `docker-entrypoint-certs.sh`
|
||||||
|
выполняется от root) физически не может прочитать
|
||||||
|
`/etc/letsencrypt/live/<домен>/privkey.pem` (root, обычно `0600`). Решение —
|
||||||
|
одноразовый init-контейнер `coturn-certs-init` (busybox, дефолтный root,
|
||||||
|
образец — уже существующий `recordings-init`/`llm-models-init` в этом же
|
||||||
|
compose-файле): копирует `fullchain.pem`/`privkey.pem` в свой volume
|
||||||
|
`coturn-certs` под правами `644`. Это копия, не оригинал — права на ключ на
|
||||||
|
хосте не меняются. coturn просто монтирует `coturn-certs:/etc/coturn/certs:ro`
|
||||||
|
и ждёт (`depends_on: condition: service_completed_successfully`), пока init
|
||||||
|
отработает.
|
||||||
|
|
||||||
|
**Обновление сертификата.** certbot продлевает Let's Encrypt раз в ~60 дней
|
||||||
|
и обновляет файлы в `/etc/letsencrypt`, но и nginx, и coturn держат свои
|
||||||
|
копии, которые перечитываются только при (пере)старте контейнера — reload
|
||||||
|
недостаточен ни для одного из них. Deploy-hook (см. шаг 5 выше,
|
||||||
|
`/etc/letsencrypt/renewal-hooks/deploy/reload-nginx.sh`) после `nginx`
|
||||||
|
дополнительно пересоздаёт `coturn-certs-init` (перекопировать свежий
|
||||||
|
сертификат в volume) и перезапускает `coturn`. Без этого шага TLS-TURN
|
||||||
|
тихо остановится обслуживать новые TLS-хендшейки примерно через два
|
||||||
|
месяца — coturn будет держать в памяти сертификат, у которого истёк срок
|
||||||
|
действия, и клиенты начнут получать ошибку валидации сертификата при
|
||||||
|
попытке TLS-хендшейка.
|
||||||
|
|
||||||
|
⚠️ Перезапуск `coturn` (и hook, и ручной после включения TLS) может задеть
|
||||||
|
активные звонки, идущие через relay, — как и с `livekit` (см. выше),
|
||||||
|
выбирайте окно или закладывайтесь на автопродление certbot (раз в ~60 дней,
|
||||||
|
непредсказуемое время суток).
|
||||||
|
|
||||||
|
Порядок включения:
|
||||||
|
|
||||||
|
1. `TURN_TLS_HOST=<домен>` в `.env`.
|
||||||
|
2. `./deploy/render-templates.sh` (перерендерит `turnserver.conf` и
|
||||||
|
`livekit.yaml` с TLS-блоками).
|
||||||
|
3. Открыть `5349/tcp` в ufw (IPv4 и IPv6).
|
||||||
|
4. `docker compose … up -d --force-recreate coturn-certs-init coturn` —
|
||||||
|
пересоздать (не просто restart: новый volume/depends_on).
|
||||||
|
5. Проверить, что TLS реально отвечает:
|
||||||
|
`openssl s_client -connect <домен>:5349 -servername <домен>` — должен
|
||||||
|
показать сертификат Let's Encrypt (`issuer=Let's Encrypt`), а не ошибку
|
||||||
|
соединения.
|
||||||
|
6. `docker compose … up -d --force-recreate livekit` — подхватить новую
|
||||||
|
запись `rtc.turn_servers`. ⚠️ Разрывает активные конференции.
|
||||||
|
7. Обновить deploy-hook certbot (см. шаг 5) и проверить
|
||||||
|
`certbot renew --dry-run`.
|
||||||
|
8. Провести звонок, принудительно загнав клиента в relay-режим (ICE
|
||||||
|
transport policy `relay` в браузере), и убедиться, что аллокации в
|
||||||
|
`docker logs vidconf-coturn-1` растут именно через TLS-соединение, а
|
||||||
|
обычный TURN на 3478 продолжает работать для остальных клиентов.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
* конверте пагинации `items`/`total`.
|
* конверте пагинации `items`/`total`.
|
||||||
*/
|
*/
|
||||||
import { apiRequest } from '@/api/client'
|
import { apiRequest } from '@/api/client'
|
||||||
import type { ConferenceRecurrence, ConferenceStatus, SummaryRecipientsMode } from '@/api/conferences'
|
import type { ConferenceRecurrence, ConferenceStatus, PublishQualityCap, SummaryRecipientsMode } from '@/api/conferences'
|
||||||
|
|
||||||
/** Уровень качества AI-обработки (транскрибация + суммаризация). */
|
/** Уровень качества AI-обработки (транскрибация + суммаризация). */
|
||||||
export type AiLevel = 'min' | 'medium' | 'max'
|
export type AiLevel = 'min' | 'medium' | 'max'
|
||||||
@@ -39,6 +39,10 @@ export interface SettingsOut {
|
|||||||
contact_email_enabled: boolean
|
contact_email_enabled: boolean
|
||||||
/** Контактный адрес — `null`, если не задан/выключен. */
|
/** Контактный адрес — `null`, если не задан/выключен. */
|
||||||
contact_email: string | null
|
contact_email: string | null
|
||||||
|
/** Потолок качества исходящего видео публикующего — см. `PublishQualityCap`. */
|
||||||
|
publish_quality_cap: PublishQualityCap
|
||||||
|
/** Максимум одновременно видимых плиток сцены (`StageGrid`). */
|
||||||
|
stage_max_tiles: number
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Тело частичного обновления настроек инстанса — все поля опциональны. */
|
/** Тело частичного обновления настроек инстанса — все поля опциональны. */
|
||||||
@@ -56,6 +60,8 @@ export interface SettingsUpdateIn {
|
|||||||
/** Включение без email или невалидный email — backend отвечает 400. */
|
/** Включение без email или невалидный email — backend отвечает 400. */
|
||||||
contact_email_enabled?: boolean
|
contact_email_enabled?: boolean
|
||||||
contact_email?: string | null
|
contact_email?: string | null
|
||||||
|
publish_quality_cap?: PublishQualityCap
|
||||||
|
stage_max_tiles?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Тело запроса тестовой отправки письма (`POST /admin/settings/test-email`). */
|
/** Тело запроса тестовой отправки письма (`POST /admin/settings/test-email`). */
|
||||||
|
|||||||
@@ -19,6 +19,15 @@ export type RecurrenceType = 'weekly' | 'biweekly' | 'monthly' | 'every_n_days'
|
|||||||
*/
|
*/
|
||||||
export type SummaryRecipientsMode = 'all' | 'owner'
|
export type SummaryRecipientsMode = 'all' | 'owner'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Потолок качества исходящего видео участника (`instance_settings.media_limits`,
|
||||||
|
* см. `SettingsOut`/`SettingsUpdateIn` в `src/api/admin.ts`). `off` — без
|
||||||
|
* ограничения. Применяется на клиенте через `publishDefaults`
|
||||||
|
* (`lib/publishQualityCap.ts`) — режет битрейт верхнего слоя симулкаста, а не
|
||||||
|
* жёсткое разрешение захвата камеры.
|
||||||
|
*/
|
||||||
|
export type PublishQualityCap = 'off' | '180p' | '360p' | '720p'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Правило повторения закреплённой конференции — форма 1:1 с pydantic-моделью
|
* Правило повторения закреплённой конференции — форма 1:1 с pydantic-моделью
|
||||||
* `backend/services/recurrence.py::RecurrenceRule` (истина о форме — там).
|
* `backend/services/recurrence.py::RecurrenceRule` (истина о форме — там).
|
||||||
@@ -52,6 +61,10 @@ export interface ConferenceJoinData {
|
|||||||
conference_id: string
|
conference_id: string
|
||||||
/** Включён ли чат для этой конференции — при `false` панель/кнопка чата не рендерятся. */
|
/** Включён ли чат для этой конференции — при `false` панель/кнопка чата не рендерятся. */
|
||||||
chat_enabled: boolean
|
chat_enabled: boolean
|
||||||
|
/** Потолок качества публикации видео на момент входа — см. `PublishQualityCap`. */
|
||||||
|
publish_quality_cap: PublishQualityCap
|
||||||
|
/** Максимум одновременно видимых плиток сцены (`StageGrid`) на момент входа. */
|
||||||
|
stage_max_tiles: number
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import {
|
|||||||
type SettingsUpdateIn,
|
type SettingsUpdateIn,
|
||||||
type TestEmailOut,
|
type TestEmailOut,
|
||||||
} from '@/api/admin'
|
} from '@/api/admin'
|
||||||
import type { SummaryRecipientsMode } from '@/api/conferences'
|
import type { PublishQualityCap, SummaryRecipientsMode } from '@/api/conferences'
|
||||||
import { ApiError, errorDetail } from '@/api/client'
|
import { ApiError, errorDetail } from '@/api/client'
|
||||||
import { useAuth } from '@/auth/useAuth'
|
import { useAuth } from '@/auth/useAuth'
|
||||||
import { Select } from '@/components/ui/Select'
|
import { Select } from '@/components/ui/Select'
|
||||||
@@ -21,6 +21,20 @@ const SUMMARY_RECIPIENTS_OPTIONS = [
|
|||||||
{ value: 'owner', label: 'Только организатору' },
|
{ value: 'owner', label: 'Только организатору' },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
const PUBLISH_QUALITY_CAP_OPTIONS = [
|
||||||
|
{ value: 'off', label: 'Без ограничения' },
|
||||||
|
{ value: '720p', label: 'Не выше 720p' },
|
||||||
|
{ value: '360p', label: 'Не выше 360p' },
|
||||||
|
{ value: '180p', label: 'Не выше 180p' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const STAGE_MAX_TILES_OPTIONS = [
|
||||||
|
{ value: '25', label: '25 (5×5, без ограничения)' },
|
||||||
|
{ value: '16', label: '16 (4×4)' },
|
||||||
|
{ value: '9', label: '9 (3×3)' },
|
||||||
|
{ value: '4', label: '4 (2×2)' },
|
||||||
|
]
|
||||||
|
|
||||||
const AI_LEVEL_LABEL: Record<AiLevel, string> = {
|
const AI_LEVEL_LABEL: Record<AiLevel, string> = {
|
||||||
min: 'Минимальный (CPU, faster-whisper small + Qwen2.5-3B)',
|
min: 'Минимальный (CPU, faster-whisper small + Qwen2.5-3B)',
|
||||||
medium: 'Средний',
|
medium: 'Средний',
|
||||||
@@ -64,6 +78,8 @@ function AdminSettingsForm({ data }: { data: SettingsOut }) {
|
|||||||
const [newDomainInput, setNewDomainInput] = useState('')
|
const [newDomainInput, setNewDomainInput] = useState('')
|
||||||
const [contactEmailEnabled, setContactEmailEnabled] = useState(data.contact_email_enabled)
|
const [contactEmailEnabled, setContactEmailEnabled] = useState(data.contact_email_enabled)
|
||||||
const [contactEmail, setContactEmail] = useState(data.contact_email ?? '')
|
const [contactEmail, setContactEmail] = useState(data.contact_email ?? '')
|
||||||
|
const [publishQualityCap, setPublishQualityCap] = useState<PublishQualityCap>(data.publish_quality_cap)
|
||||||
|
const [stageMaxTiles, setStageMaxTiles] = useState(data.stage_max_tiles)
|
||||||
const [testEmailTo, setTestEmailTo] = useState('')
|
const [testEmailTo, setTestEmailTo] = useState('')
|
||||||
const [testEmailResult, setTestEmailResult] = useState<TestEmailOut | null>(null)
|
const [testEmailResult, setTestEmailResult] = useState<TestEmailOut | null>(null)
|
||||||
|
|
||||||
@@ -136,6 +152,8 @@ function AdminSettingsForm({ data }: { data: SettingsOut }) {
|
|||||||
if (trimmedContactEmail !== (data.contact_email ?? null)) {
|
if (trimmedContactEmail !== (data.contact_email ?? null)) {
|
||||||
payload.contact_email = trimmedContactEmail
|
payload.contact_email = trimmedContactEmail
|
||||||
}
|
}
|
||||||
|
if (publishQualityCap !== data.publish_quality_cap) payload.publish_quality_cap = publishQualityCap
|
||||||
|
if (stageMaxTiles !== data.stage_max_tiles) payload.stage_max_tiles = stageMaxTiles
|
||||||
mutation.mutate(payload)
|
mutation.mutate(payload)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -361,6 +379,51 @@ function AdminSettingsForm({ data }: { data: SettingsOut }) {
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<section className="settings-card">
|
||||||
|
<h2>Нагрузка</h2>
|
||||||
|
<p className="desc">
|
||||||
|
Рычаги для инстансов на слабом канале/железе — режут исходящий трафик и нагрузку на
|
||||||
|
устройство участника. Дефолты сохраняют прежнее поведение (без ограничений).
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="settings-card-body settings-card-body--spread">
|
||||||
|
<div className="field" style={{ marginBottom: 0 }}>
|
||||||
|
<label id="settings-quality-cap-label" htmlFor="settings-quality-cap">
|
||||||
|
Потолок качества публикации видео
|
||||||
|
</label>
|
||||||
|
<Select
|
||||||
|
id="settings-quality-cap"
|
||||||
|
aria-labelledby="settings-quality-cap-label"
|
||||||
|
value={publishQualityCap}
|
||||||
|
onChange={(v) => setPublishQualityCap(v as PublishQualityCap)}
|
||||||
|
options={PUBLISH_QUALITY_CAP_OPTIONS}
|
||||||
|
/>
|
||||||
|
<p className="field-hint">
|
||||||
|
Ограничивает битрейт исходящего видео публикующего — снижает нагрузку на его канал и
|
||||||
|
устройство, независимо от размера плитки у смотрящих (adaptiveStream режет с их
|
||||||
|
стороны отдельно)
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="field" style={{ marginBottom: 0 }}>
|
||||||
|
<label id="settings-max-tiles-label" htmlFor="settings-max-tiles">
|
||||||
|
Максимум плиток на экране
|
||||||
|
</label>
|
||||||
|
<Select
|
||||||
|
id="settings-max-tiles"
|
||||||
|
aria-labelledby="settings-max-tiles-label"
|
||||||
|
value={String(stageMaxTiles)}
|
||||||
|
onChange={(v) => setStageMaxTiles(Number(v))}
|
||||||
|
options={STAGE_MAX_TILES_OPTIONS}
|
||||||
|
/>
|
||||||
|
<p className="field-hint">
|
||||||
|
Сверх лимита участники уходят на следующую страницу сетки вместо подписки — меньше
|
||||||
|
одновременных видеопотоков на канал и экран участника
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
<section className="settings-card">
|
<section className="settings-card">
|
||||||
<h2>Контактный адрес</h2>
|
<h2>Контактный адрес</h2>
|
||||||
<p className="desc">
|
<p className="desc">
|
||||||
|
|||||||
@@ -206,6 +206,7 @@ export function RoomStage({
|
|||||||
onPinFocus,
|
onPinFocus,
|
||||||
raisedHandIdentities,
|
raisedHandIdentities,
|
||||||
conferenceId,
|
conferenceId,
|
||||||
|
stageMaxTiles,
|
||||||
}: {
|
}: {
|
||||||
variant?: 'full' | 'pip'
|
variant?: 'full' | 'pip'
|
||||||
/** Выбранный пользователем режим показа; игнорируется при `variant="pip"`. */
|
/** Выбранный пользователем режим показа; игнорируется при `variant="pip"`. */
|
||||||
@@ -232,6 +233,8 @@ export function RoomStage({
|
|||||||
raisedHandIdentities?: Set<string>
|
raisedHandIdentities?: Set<string>
|
||||||
/** Id конференции (задача B2) — кнопки принудительного мьюта на чужих плитках; игнорируется при `variant="pip"`. */
|
/** Id конференции (задача B2) — кнопки принудительного мьюта на чужих плитках; игнорируется при `variant="pip"`. */
|
||||||
conferenceId?: string
|
conferenceId?: string
|
||||||
|
/** Потолок админки на число плиток `StageGrid` (`instance_settings.media_limits`); игнорируется при `variant="pip"`. */
|
||||||
|
stageMaxTiles?: number
|
||||||
}) {
|
}) {
|
||||||
const room = useRoomContext()
|
const room = useRoomContext()
|
||||||
const isCompact = useIsCompactViewport()
|
const isCompact = useIsCompactViewport()
|
||||||
@@ -429,14 +432,14 @@ export function RoomStage({
|
|||||||
function renderMain(): ReactNode {
|
function renderMain(): ReactNode {
|
||||||
if (effectiveMode === 'tiles') {
|
if (effectiveMode === 'tiles') {
|
||||||
return (
|
return (
|
||||||
<StageGrid tracks={tracks}>
|
<StageGrid tracks={tracks} maxTiles={stageMaxTiles}>
|
||||||
<RoomParticipantTile {...tileProps} />
|
<RoomParticipantTile {...tileProps} />
|
||||||
</StageGrid>
|
</StageGrid>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
if (effectiveMode === 'live-tiles') {
|
if (effectiveMode === 'live-tiles') {
|
||||||
return (
|
return (
|
||||||
<StageGrid tracks={liveCameraTracks.length > 0 ? liveCameraTracks : cameraTracks}>
|
<StageGrid tracks={liveCameraTracks.length > 0 ? liveCameraTracks : cameraTracks} maxTiles={stageMaxTiles}>
|
||||||
<RoomParticipantTile {...tileProps} />
|
<RoomParticipantTile {...tileProps} />
|
||||||
</StageGrid>
|
</StageGrid>
|
||||||
)
|
)
|
||||||
@@ -445,7 +448,7 @@ export function RoomStage({
|
|||||||
// прежнее поведение: равномерная сетка на всю сцену, а не фокус-плитка.
|
// прежнее поведение: равномерная сетка на всю сцену, а не фокус-плитка.
|
||||||
if (sideTracks.length === 0 && !hideOthers) {
|
if (sideTracks.length === 0 && !hideOthers) {
|
||||||
return (
|
return (
|
||||||
<StageGrid tracks={tracks}>
|
<StageGrid tracks={tracks} maxTiles={stageMaxTiles}>
|
||||||
<RoomParticipantTile {...tileProps} />
|
<RoomParticipantTile {...tileProps} />
|
||||||
</StageGrid>
|
</StageGrid>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useRef, type ReactNode, type RefObject } from 'react'
|
import { useMemo, useRef, type ReactNode, type RefObject } from 'react'
|
||||||
import { ChevronLeft, ChevronRight } from 'lucide-react'
|
import { ChevronLeft, ChevronRight } from 'lucide-react'
|
||||||
import {
|
import {
|
||||||
TrackLoop,
|
TrackLoop,
|
||||||
@@ -47,6 +47,17 @@ interface StageGridProps {
|
|||||||
tracks: TrackReferenceOrPlaceholder[]
|
tracks: TrackReferenceOrPlaceholder[]
|
||||||
/** Шаблон плитки — рендерится для каждого трека страницы (как у `GridLayout`, через `TrackLoop`). */
|
/** Шаблон плитки — рендерится для каждого трека страницы (как у `GridLayout`, через `TrackLoop`). */
|
||||||
children: ReactNode
|
children: ReactNode
|
||||||
|
/**
|
||||||
|
* Потолок админки на число одновременно видимых плиток (`instance_settings.media_limits.stage_max_tiles`,
|
||||||
|
* см. `RoomPage`). Не задан — все раскладки `STAGE_GRID_LAYOUTS` доступны как
|
||||||
|
* раньше (текущий максимум сетки — 25, 5×5). Реализовано отсечением раскладок
|
||||||
|
* КРУПНЕЕ потолка из набора, который видит `useGridLayout`: она сама выбирает
|
||||||
|
* бОльшую свободную раскладку, укладывающую всех участников без пагинации,
|
||||||
|
* поэтому урезанный набор просто не даёт ей раздуть сетку сверх лимита —
|
||||||
|
* лишние участники уходят на следующую страницу пагинации (`usePagination`),
|
||||||
|
* то есть перестают быть подписанными треками, а не просто визуально мельче.
|
||||||
|
*/
|
||||||
|
maxTiles?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -64,7 +75,7 @@ interface StageGridProps {
|
|||||||
* `.stage-grid-pages` (кнопки со стрелками + счётчик, доступен и мышью, и с
|
* `.stage-grid-pages` (кнопки со стрелками + счётчик, доступен и мышью, и с
|
||||||
* клавиатуры; на тач-экране страницы листаются ещё и свайпом).
|
* клавиатуры; на тач-экране страницы листаются ещё и свайпом).
|
||||||
*/
|
*/
|
||||||
export function StageGrid({ tracks, children }: StageGridProps) {
|
export function StageGrid({ tracks, children, maxTiles }: StageGridProps) {
|
||||||
const gridEl = useRef<HTMLDivElement | null>(null)
|
const gridEl = useRef<HTMLDivElement | null>(null)
|
||||||
// Хуки библиотеки объявлены с `RefObject<HTMLDivElement>` (типы React 18, где
|
// Хуки библиотеки объявлены с `RefObject<HTMLDivElement>` (типы React 18, где
|
||||||
// `current` был readonly и тип вёл себя ковариантно). В типах React 19
|
// `current` был readonly и тип вёл себя ковариантно). В типах React 19
|
||||||
@@ -72,7 +83,11 @@ export function StageGrid({ tracks, children }: StageGridProps) {
|
|||||||
// параметр уже не присваивается — приведение безопасно: оба хука только
|
// параметр уже не присваивается — приведение безопасно: оба хука только
|
||||||
// читают `.current` (ResizeObserver и слушатели touch-событий).
|
// читают `.current` (ResizeObserver и слушатели touch-событий).
|
||||||
const gridRef = gridEl as RefObject<HTMLDivElement>
|
const gridRef = gridEl as RefObject<HTMLDivElement>
|
||||||
const { layout } = useGridLayout(gridRef, tracks.length, { gridLayouts: STAGE_GRID_LAYOUTS })
|
const gridLayouts = useMemo(
|
||||||
|
() => (maxTiles == null ? STAGE_GRID_LAYOUTS : STAGE_GRID_LAYOUTS.filter((l) => l.columns * l.rows <= maxTiles)),
|
||||||
|
[maxTiles],
|
||||||
|
)
|
||||||
|
const { layout } = useGridLayout(gridRef, tracks.length, { gridLayouts })
|
||||||
const pagination = usePagination(layout.maxTiles, tracks)
|
const pagination = usePagination(layout.maxTiles, tracks)
|
||||||
|
|
||||||
useSwipe(gridRef, {
|
useSwipe(gridRef, {
|
||||||
|
|||||||
36
frontend/src/lib/publishQualityCap.ts
Normal file
36
frontend/src/lib/publishQualityCap.ts
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
import { VideoPresets, type TrackPublishDefaults } from 'livekit-client'
|
||||||
|
import type { PublishQualityCap } from '@/api/conferences'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Потолок качества публикации → `TrackPublishDefaults` для `RoomOptions.publishDefaults`
|
||||||
|
* (см. `RoomPage.tsx`, `roomOptions`).
|
||||||
|
*
|
||||||
|
* Ограничивается только `videoEncoding` (битрейт/framerate верхнего слоя
|
||||||
|
* симулкаста) и набор дополнительных слоёв `videoSimulcastLayers` — НЕ
|
||||||
|
* фактическое разрешение захвата камеры (`videoCaptureDefaults`, трогать
|
||||||
|
* его не входит в задачу). WebRTC сам подстраивает реальное разрешение
|
||||||
|
* кодирования под урезанный битрейт (`degradationPreference`), поэтому
|
||||||
|
* проверять эффект нужно по фактическому битрейту исходящего видео, а не по
|
||||||
|
* заявленному разрешению потока.
|
||||||
|
*
|
||||||
|
* Слои каждого потолка — все пресеты LiveKit НИЖЕ и РАВНО потолку (без
|
||||||
|
* дефолтного «h180, h360», который иначе подставился бы сам при пустом
|
||||||
|
* `videoSimulcastLayers` и мог бы превысить потолок 180p).
|
||||||
|
*/
|
||||||
|
const PUBLISH_DEFAULTS_BY_CAP: Record<Exclude<PublishQualityCap, 'off'>, TrackPublishDefaults> = {
|
||||||
|
'180p': { videoEncoding: VideoPresets.h180.encoding, videoSimulcastLayers: [] },
|
||||||
|
'360p': { videoEncoding: VideoPresets.h360.encoding, videoSimulcastLayers: [VideoPresets.h180] },
|
||||||
|
'720p': {
|
||||||
|
videoEncoding: VideoPresets.h720.encoding,
|
||||||
|
videoSimulcastLayers: [VideoPresets.h180, VideoPresets.h360],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `off` — `undefined`: `publishDefaults` не задаётся вовсе, поведение
|
||||||
|
* библиотеки не отличается от состояния до появления настройки (см.
|
||||||
|
* критерий готовности «дефолты сохраняют текущее поведение»).
|
||||||
|
*/
|
||||||
|
export function buildPublishDefaults(cap: PublishQualityCap): TrackPublishDefaults | undefined {
|
||||||
|
return cap === 'off' ? undefined : PUBLISH_DEFAULTS_BY_CAP[cap]
|
||||||
|
}
|
||||||
@@ -146,6 +146,8 @@ export function JoinPage() {
|
|||||||
title: resolved.title,
|
title: resolved.title,
|
||||||
conferenceId: data.conference_id,
|
conferenceId: data.conference_id,
|
||||||
chatEnabled: data.chat_enabled,
|
chatEnabled: data.chat_enabled,
|
||||||
|
publishQualityCap: data.publish_quality_cap,
|
||||||
|
stageMaxTiles: data.stage_max_tiles,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|||||||
@@ -43,6 +43,8 @@ export function LobbyPage() {
|
|||||||
title: conference.title,
|
title: conference.title,
|
||||||
conferenceId: conference.join.conference_id,
|
conferenceId: conference.join.conference_id,
|
||||||
chatEnabled: conference.join.chat_enabled,
|
chatEnabled: conference.join.chat_enabled,
|
||||||
|
publishQualityCap: conference.join.publish_quality_cap,
|
||||||
|
stageMaxTiles: conference.join.stage_max_tiles,
|
||||||
number: conference.number,
|
number: conference.number,
|
||||||
slug: conference.slug,
|
slug: conference.slug,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { LiveKitRoom, usePersistentUserChoices } from '@livekit/components-react
|
|||||||
import type { RoomOptions } from 'livekit-client'
|
import type { RoomOptions } from 'livekit-client'
|
||||||
import '@livekit/components-styles'
|
import '@livekit/components-styles'
|
||||||
import '@/styles/room.css'
|
import '@/styles/room.css'
|
||||||
import { joinConference, resolveConference } from '@/api/conferences'
|
import { joinConference, resolveConference, type PublishQualityCap } from '@/api/conferences'
|
||||||
import { ApiError, errorDetail } from '@/api/client'
|
import { ApiError, errorDetail } from '@/api/client'
|
||||||
import { useAuth } from '@/auth/useAuth'
|
import { useAuth } from '@/auth/useAuth'
|
||||||
import { useChat } from '@/hooks/useChat'
|
import { useChat } from '@/hooks/useChat'
|
||||||
@@ -19,6 +19,7 @@ import { ChatPanel } from '@/components/room/ChatPanel'
|
|||||||
import { ForcedMuteWatcher } from '@/components/room/ForcedMuteWatcher'
|
import { ForcedMuteWatcher } from '@/components/room/ForcedMuteWatcher'
|
||||||
import { DeviceSettingsDialog } from '@/components/room/DeviceSettingsDialog'
|
import { DeviceSettingsDialog } from '@/components/room/DeviceSettingsDialog'
|
||||||
import { loadAudioOutputDeviceId } from '@/lib/audioOutputDevice'
|
import { loadAudioOutputDeviceId } from '@/lib/audioOutputDevice'
|
||||||
|
import { buildPublishDefaults } from '@/lib/publishQualityCap'
|
||||||
import { loadStageLayoutMode, saveStageLayoutMode, type StageLayoutMode } from '@/lib/stageLayoutMode'
|
import { loadStageLayoutMode, saveStageLayoutMode, type StageLayoutMode } from '@/lib/stageLayoutMode'
|
||||||
|
|
||||||
interface RoomJoinState {
|
interface RoomJoinState {
|
||||||
@@ -31,6 +32,18 @@ interface RoomJoinState {
|
|||||||
number?: string
|
number?: string
|
||||||
/** `JoinOut.chat_enabled` — при `false` кнопка чата и панель не рендерятся. */
|
/** `JoinOut.chat_enabled` — при `false` кнопка чата и панель не рендерятся. */
|
||||||
chatEnabled?: boolean
|
chatEnabled?: boolean
|
||||||
|
/**
|
||||||
|
* Рычаги нагрузки медиа (`JoinOut.publish_quality_cap`/`stage_max_tiles`,
|
||||||
|
* `instance_settings.media_limits`) — приезжают вместе с токеном, ДО
|
||||||
|
* первого рендера `LiveKitRoom` (см. `roomOptions` ниже и докстринг про
|
||||||
|
* стабильность его ссылки): `joinState` целиком появляется одним актом
|
||||||
|
* (`setJoinState`), а до этого момента `LiveKitRoom` не рендерится вовсе
|
||||||
|
* (ранний `return` на «Подключаемся…» ниже) — значит, оба значения уже
|
||||||
|
* на руках к моменту публикации трека, без отдельного асинхронного
|
||||||
|
* похода за настройками после подключения и без риска переподключения.
|
||||||
|
*/
|
||||||
|
publishQualityCap?: PublishQualityCap
|
||||||
|
stageMaxTiles?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -97,6 +110,8 @@ export function RoomPage() {
|
|||||||
title: info.title,
|
title: info.title,
|
||||||
conferenceId: result.conference_id,
|
conferenceId: result.conference_id,
|
||||||
chatEnabled: result.chat_enabled,
|
chatEnabled: result.chat_enabled,
|
||||||
|
publishQualityCap: result.publish_quality_cap,
|
||||||
|
stageMaxTiles: result.stage_max_tiles,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -202,9 +217,16 @@ export function RoomPage() {
|
|||||||
// потому что `userChoices` ЭТОГО вызова хука меняется, только если МЫ САМИ
|
// потому что `userChoices` ЭТОГО вызова хука меняется, только если МЫ САМИ
|
||||||
// вызовем saveAudioInputDeviceId/saveVideoInputDeviceId НА НЁМ — а мы этого
|
// вызовем saveAudioInputDeviceId/saveVideoInputDeviceId НА НЁМ — а мы этого
|
||||||
// не делаем (сохранение — только в DeviceSettingsDialog).
|
// не делаем (сохранение — только в DeviceSettingsDialog).
|
||||||
|
//
|
||||||
|
// `joinState?.publishQualityCap` в зависимостях безопасен по той же
|
||||||
|
// причине: `joinState` выставляется РОВНО ОДИН раз (см. докстринг
|
||||||
|
// `RoomJoinState.publishQualityCap`) до первого рендера `LiveKitRoom`, а
|
||||||
|
// не меняется постфактум — значит, `roomOptions` не пересоздастся у уже
|
||||||
|
// подключённого участника.
|
||||||
const { userChoices } = usePersistentUserChoices()
|
const { userChoices } = usePersistentUserChoices()
|
||||||
const roomOptions = useMemo<RoomOptions>(
|
const roomOptions = useMemo<RoomOptions>(
|
||||||
() => ({
|
() => ({
|
||||||
|
publishDefaults: buildPublishDefaults(joinState?.publishQualityCap ?? 'off'),
|
||||||
// Оба флага в LiveKit по умолчанию выключены, и без них каждый клиент
|
// Оба флага в LiveKit по умолчанию выключены, и без них каждый клиент
|
||||||
// подписан на полное качество всех чужих треков независимо от размера
|
// подписан на полное качество всех чужих треков независимо от размера
|
||||||
// плитки, а каждый паблишер шлёт все слои симулкаста, даже если их никто
|
// плитки, а каждый паблишер шлёт все слои симулкаста, даже если их никто
|
||||||
@@ -230,7 +252,7 @@ export function RoomPage() {
|
|||||||
// (setActiveMediaDevice), а не пересозданием roomOptions.
|
// (setActiveMediaDevice), а не пересозданием roomOptions.
|
||||||
audioOutput: { deviceId: loadAudioOutputDeviceId() || undefined },
|
audioOutput: { deviceId: loadAudioOutputDeviceId() || undefined },
|
||||||
}),
|
}),
|
||||||
[userChoices],
|
[userChoices, joinState?.publishQualityCap],
|
||||||
)
|
)
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
@@ -287,6 +309,7 @@ export function RoomPage() {
|
|||||||
onPinFocus={handlePinFocus}
|
onPinFocus={handlePinFocus}
|
||||||
raisedHandIdentities={raisedHandIdentities}
|
raisedHandIdentities={raisedHandIdentities}
|
||||||
conferenceId={joinState.conferenceId}
|
conferenceId={joinState.conferenceId}
|
||||||
|
stageMaxTiles={joinState.stageMaxTiles}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{chatVisible && chatOpen && (
|
{chatVisible && chatOpen && (
|
||||||
|
|||||||
@@ -412,6 +412,10 @@ ensure_default NGINX_CERT_NAME "localhost"
|
|||||||
ensure_default LIVEKIT_USE_EXTERNAL_IP "false"
|
ensure_default LIVEKIT_USE_EXTERNAL_IP "false"
|
||||||
ensure_default LIVEKIT_NODE_IP "127.0.0.1"
|
ensure_default LIVEKIT_NODE_IP "127.0.0.1"
|
||||||
ensure_default TURN_EXTERNAL_IP "127.0.0.1"
|
ensure_default TURN_EXTERNAL_IP "127.0.0.1"
|
||||||
|
# Пусто = TURN over TLS выключен (см. docs/deploy/DEPLOYMENT.md §8) — не
|
||||||
|
# генерируем и не требуем здесь, только гарантируем, что ключ явно есть в
|
||||||
|
# .env (для discoverability), а не отсутствует молча.
|
||||||
|
ensure_default TURN_TLS_HOST ""
|
||||||
|
|
||||||
# Профили compose и модели по пресету. GPU-профили — ТОЛЬКО для пресета 5
|
# Профили compose и модели по пресету. GPU-профили — ТОЛЬКО для пресета 5
|
||||||
# (max): в текущей матрице уровней (backend/services/ai_tiers.py, ADR-004)
|
# (max): в текущей матрице уровней (backend/services/ai_tiers.py, ADR-004)
|
||||||
|
|||||||
Reference in New Issue
Block a user