feat(admin): рычаги нагрузки медиа — потолок качества публикации и лимит плиток
instance_settings.media_limits (publish_quality_cap: off/720p/360p/180p, stage_max_tiles: 4/9/16/25) — новая вкладка «Нагрузка» в админке, дефолты (off/25) сохраняют текущее поведение существующих инсталляций. Настройка отдаётся не только GET /admin/settings, но и в join-ответе (JoinOut) — участнику нужно иметь её на руках ДО публикации трека, а /admin/settings доступен только администратору. Потолок качества применяется через RoomOptions.publishDefaults (videoEncoding + videoSimulcastLayers на пресетах VideoPresets LiveKit) — режет битрейт верхнего слоя симулкаста, реальное разрешение WebRTC подстраивает сам. Лимит плиток — фильтрация STAGE_GRID_LAYOUTS по columns*rows в StageGrid, лишние участники уходят на страницу пагинации вместо подписки. Значение приезжает в joinState вместе с токеном ДО первого рендера LiveKitRoom (RoomPage не рендерит его, пока joinState не заполнен целиком), поэтому смена настройки не переподключает уже вошедшего участника — roomOptions пересчитывается по стабильной ссылке на joinState, которая после подключения не меняется. Проверено вживую на локальном стенде (docker compose --profile media): сохранение/персист настроек, join отдаёт актуальные значения, уже подключённый участник не разрывается при смене настройки в другом окне.
This commit is contained in:
@@ -9,6 +9,7 @@
|
||||
import json
|
||||
|
||||
from core.config import get_settings
|
||||
from core.plugins.config import PublishQualityCap, StageMaxTiles
|
||||
from core.security import verify_password
|
||||
from models.conference import Conference
|
||||
from schemas.conferences import JoinOut
|
||||
@@ -52,15 +53,18 @@ def build_join(
|
||||
identity: str,
|
||||
name: str,
|
||||
chat_enabled: bool,
|
||||
publish_quality_cap: PublishQualityCap,
|
||||
stage_max_tiles: StageMaxTiles,
|
||||
avatar_url: str | None = None,
|
||||
is_organizer: bool = False,
|
||||
) -> JoinOut:
|
||||
"""Построить ответ join: LiveKit access-токен для входа в комнату конференции.
|
||||
|
||||
Имя LiveKit-комнаты всегда равно `conference.slug` (ADR-001, п.4).
|
||||
`chat_enabled` — снятый вызывающей стороной тоггл `instance_settings`:
|
||||
читается здесь параметром, а не заново из БД, чтобы не плодить
|
||||
отдельный запрос настроек на каждый join. `avatar_url`/`is_organizer`
|
||||
`chat_enabled`/`publish_quality_cap`/`stage_max_tiles` — снятые вызывающей
|
||||
стороной значения `instance_settings`: читаются здесь параметрами, а не
|
||||
заново из БД, чтобы не плодить отдельный запрос настроек на каждый join.
|
||||
`avatar_url`/`is_organizer`
|
||||
прокидываются в метаданные токена как JSON `{"avatar_url": ..., "is_organizer": true}`
|
||||
— поля добавляются, только если заданы (гость без аватара и не-организатор
|
||||
получают токен вовсе без метаданных, как и раньше).
|
||||
@@ -87,4 +91,6 @@ def build_join(
|
||||
room_name=conference.slug,
|
||||
conference_id=conference.id,
|
||||
chat_enabled=chat_enabled,
|
||||
publish_quality_cap=publish_quality_cap,
|
||||
stage_max_tiles=stage_max_tiles,
|
||||
)
|
||||
|
||||
@@ -149,12 +149,14 @@ class ConferenceService:
|
||||
|
||||
join = None
|
||||
if is_instant:
|
||||
chat_enabled = (await InstanceSettingsService(self._session).get()).chat.enabled
|
||||
cfg = await InstanceSettingsService(self._session).get()
|
||||
join = build_join(
|
||||
conference,
|
||||
identity=str(owner_id),
|
||||
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),
|
||||
is_organizer=True,
|
||||
)
|
||||
@@ -243,12 +245,14 @@ class ConferenceService:
|
||||
"""Войти в конференцию зарегистрированным пользователем."""
|
||||
conference = await self._get_or_raise(conference_id)
|
||||
await ensure_joinable(conference, password=password)
|
||||
chat_enabled = (await InstanceSettingsService(self._session).get()).chat.enabled
|
||||
cfg = await InstanceSettingsService(self._session).get()
|
||||
return build_join(
|
||||
conference,
|
||||
identity=str(user.id),
|
||||
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),
|
||||
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.commit()
|
||||
|
||||
chat_enabled = (await InstanceSettingsService(self._session).get()).chat.enabled
|
||||
cfg = await InstanceSettingsService(self._session).get()
|
||||
return build_join(
|
||||
conference,
|
||||
identity=f"guest:{guest.id}",
|
||||
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(
|
||||
|
||||
@@ -2,7 +2,8 @@
|
||||
|
||||
Ключи зеркалят секции конфигурации (`transcriber`, `summarizer`, `chat`,
|
||||
`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`)
|
||||
импортирует дефолты `config/plugins.yaml` через `INSERT ... ON CONFLICT DO
|
||||
NOTHING` в lifespan backend — однократно и идемпотентно: повторный вызов
|
||||
@@ -28,7 +29,10 @@ from core.plugins.config import (
|
||||
AiLevel,
|
||||
ChatConfig,
|
||||
InstanceConfig,
|
||||
MediaLimitsConfig,
|
||||
PluginsConfig,
|
||||
PublishQualityCap,
|
||||
StageMaxTiles,
|
||||
SummarizerConfig,
|
||||
SummaryRecipientsMode,
|
||||
TranscriberConfig,
|
||||
@@ -47,6 +51,7 @@ _KEY_DISPLAY_TIMEZONE = "display_timezone"
|
||||
_KEY_REGISTRATION_TEAM_CHOICE = "registration_team_choice"
|
||||
_KEY_REGISTRATION_EMAIL_DOMAIN = "registration_email_domain"
|
||||
_KEY_CONTACT_EMAIL = "contact_email"
|
||||
_KEY_MEDIA_LIMITS = "media_limits"
|
||||
|
||||
BOOTSTRAP_MANAGED_KEYS: tuple[str, ...] = (
|
||||
_KEY_CHAT,
|
||||
@@ -70,6 +75,7 @@ _DEFAULT_REGISTRATION_EMAIL_DOMAIN_VALUE: dict[str, Any] = {"enabled": False, "d
|
||||
для обратной совместимости с уже развёрнутыми инстансами; при первом же
|
||||
`update()` значение переписывается в новую форму (см. `update`)."""
|
||||
_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
|
||||
contact_email_enabled: bool | None = None
|
||||
contact_email: str | None = None
|
||||
publish_quality_cap: PublishQualityCap | None = None
|
||||
stage_max_tiles: StageMaxTiles | None = None
|
||||
|
||||
|
||||
class BootstrapOverrides(BaseModel):
|
||||
@@ -152,6 +160,7 @@ def build_bootstrap_defaults(
|
||||
_KEY_REGISTRATION_TEAM_CHOICE: dict(_DEFAULT_REGISTRATION_TEAM_CHOICE_VALUE),
|
||||
_KEY_REGISTRATION_EMAIL_DOMAIN: dict(_DEFAULT_REGISTRATION_EMAIL_DOMAIN_VALUE),
|
||||
_KEY_CONTACT_EMAIL: dict(_DEFAULT_CONTACT_EMAIL_VALUE),
|
||||
_KEY_MEDIA_LIMITS: dict(_DEFAULT_MEDIA_LIMITS_VALUE),
|
||||
}
|
||||
if overrides is None:
|
||||
return defaults
|
||||
@@ -340,6 +349,20 @@ class InstanceSettingsService:
|
||||
await self._set(_KEY_TRANSCRIBER, cfg.transcriber.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()
|
||||
return cfg
|
||||
|
||||
@@ -489,4 +512,7 @@ def _build_config(rows: dict[str, Any]) -> InstanceConfig:
|
||||
"enabled", False
|
||||
),
|
||||
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)
|
||||
),
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user