Files
vidconf/backend/services/conference_access.py
Max Ronzhin 173d384f06
Some checks failed
CI / backend (push) Has been cancelled
CI / frontend (push) Has been cancelled
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 отдаёт актуальные значения, уже
подключённый участник не разрывается при смене настройки в другом окне.
2026-08-02 19:53:23 +03:00

97 lines
4.9 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Проверка права входа в конференцию (статус/пароль) и генерация ответа join.
Заменяет `services/room_access.py`: комнаты больше не конкурируют за
время («правило часа» удалено вместе с бронированием, ADR-001, п.5), но
пароль закрытой конференции по-прежнему проверяется здесь же — для обычного
пользователя и гостя одинаково.
"""
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
from services.livekit_tokens import create_room_access_token
class ConferenceEndedError(Exception):
"""Конференция завершена (терминальный статус) — повторный вход невозможен."""
class PasswordRequiredError(Exception):
"""Конференция закрыта паролем; пароль не передан."""
class InvalidPasswordError(Exception):
"""Указанный пароль не совпадает с паролем закрытой конференции."""
async def ensure_joinable(conference: Conference, *, password: str | None) -> None:
"""Проверить, что в конференцию можно войти прямо сейчас.
Бросает `ConferenceEndedError` для терминального статуса `ended`
(история и саммари остаются, но повторный вход невозможен — ADR-001,
п.2), либо `PasswordRequiredError`/`InvalidPasswordError` для закрытой
паролем конференции. Ничего не бросает для открытой конференции в
статусе `scheduled`/`active`.
"""
if conference.status == "ended":
raise ConferenceEndedError
if not conference.is_closed:
return
if conference.password_hash is None or password is None:
raise PasswordRequiredError
if not await verify_password(password, conference.password_hash):
raise InvalidPasswordError
def build_join(
conference: Conference,
*,
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`/`publish_quality_cap`/`stage_max_tiles` — снятые вызывающей
стороной значения `instance_settings`: читаются здесь параметрами, а не
заново из БД, чтобы не плодить отдельный запрос настроек на каждый join.
`avatar_url`/`is_organizer`
прокидываются в метаданные токена как JSON `{"avatar_url": ..., "is_organizer": true}`
— поля добавляются, только если заданы (гость без аватара и не-организатор
получают токен вовсе без метаданных, как и раньше).
⚠️ `is_organizer` в метаданных — только подсказка для UI клиента (показать/
скрыть кнопки организатора). Метаданным токена доверять для АВТОРИЗАЦИИ
нельзя — участник технически может их подделать на своей стороне. Любое
серверное действие организатора (например, принудительный мьют) обязано
заново проверяться по `conference.owner_id` в БД, а не по этому полю.
"""
settings = get_settings()
metadata_payload: dict[str, object] = {}
if avatar_url:
metadata_payload["avatar_url"] = avatar_url
if is_organizer:
metadata_payload["is_organizer"] = True
metadata = json.dumps(metadata_payload) if metadata_payload else None
token = create_room_access_token(
room_name=conference.slug, identity=identity, name=name, metadata=metadata
)
return JoinOut(
livekit_url=settings.livekit_public_url,
token=token,
room_name=conference.slug,
conference_id=conference.id,
chat_enabled=chat_enabled,
publish_quality_cap=publish_quality_cap,
stage_max_tiles=stage_max_tiles,
)