feat(admin): рычаги нагрузки медиа — потолок качества публикации и лимит плиток
Some checks failed
CI / backend (push) Has been cancelled
CI / frontend (push) Has been cancelled

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:
2026-08-02 19:53:23 +03:00
parent 7b2427535a
commit 173d384f06
20 changed files with 389 additions and 23 deletions

View File

@@ -706,6 +706,68 @@ async def test_put_settings_contact_email_invalid_returns_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
# --- Тестовое письмо ----------------------------------------------------------------

View File

@@ -113,6 +113,10 @@ async def test_create_instant_conference_returns_active_with_join(
assert body["join"]["conference_id"] == body["id"]
assert body["join"]["room_name"] == body["slug"]
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(
@@ -841,6 +845,33 @@ async def test_guest_join_creates_guest_access_and_returns_join(
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(
client: httpx.AsyncClient, db_session: AsyncSession
) -> None:

View File

@@ -71,6 +71,7 @@ _MANAGED_KEYS = (
"registration_team_choice",
"registration_email_domain",
"contact_email",
"media_limits",
)
@@ -126,6 +127,7 @@ async def test_ensure_bootstrapped_imports_yaml_defaults(
"registration_team_choice",
"registration_email_domain",
"contact_email",
"media_limits",
}
cfg = await service.get()
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.contact_email_enabled is False
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(
@@ -538,6 +545,28 @@ async def test_transcription_enabled_flag_toggles_both_transcriber_and_summarize
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(
db_session: AsyncSession, clean_instance_settings: None
) -> None: