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:
@@ -467,6 +467,8 @@ def _to_settings_out(cfg: InstanceConfig, *, transcription_queue_served: bool) -
|
||||
registration_email_domains=cfg.registration_email_domains,
|
||||
contact_email_enabled=cfg.contact_email_enabled,
|
||||
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`)."""
|
||||
|
||||
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):
|
||||
"""Эффективная конфигурация инстанса (значения `instance_settings` поверх дефолтов
|
||||
@@ -87,3 +109,9 @@ class InstanceConfig(BaseModel):
|
||||
# адреса) — см. `services/instance_settings.py`, `services/email.py`.
|
||||
contact_email_enabled: bool = False
|
||||
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 core.plugins.config import AiLevel, SummaryRecipientsMode
|
||||
from core.plugins.config import AiLevel, PublishQualityCap, StageMaxTiles, SummaryRecipientsMode
|
||||
from schemas.conferences import ConferenceOut
|
||||
from services.ai_levels import AiLevelStatus
|
||||
|
||||
@@ -158,6 +158,10 @@ class SettingsOut(BaseModel):
|
||||
registration_email_domains: list[str] = Field(default_factory=list)
|
||||
contact_email_enabled: bool
|
||||
contact_email: str | None = None
|
||||
# Рычаги нагрузки медиа (`InstanceConfig.media_limits`) — потолок
|
||||
# качества публикации и максимум плиток сцены, см. `core/plugins/config.py`.
|
||||
publish_quality_cap: PublishQualityCap
|
||||
stage_max_tiles: StageMaxTiles
|
||||
|
||||
|
||||
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 core.plugins.config import SummaryRecipientsMode
|
||||
from core.plugins.config import PublishQualityCap, StageMaxTiles, SummaryRecipientsMode
|
||||
from schemas.room_events import ForcedMuteSource
|
||||
from services.recurrence import RecurrenceRule
|
||||
|
||||
@@ -130,6 +130,11 @@ class JoinOut(BaseModel):
|
||||
# Тоггл инстанса `chat.enabled` на момент входа — клиент решает,
|
||||
# показывать ли UI чата, не дожидаясь ошибки WS-подключения.
|
||||
chat_enabled: bool
|
||||
# Рычаги нагрузки медиа (`instance_settings.media_limits`) — отдаются
|
||||
# прямо в join-ответе, а не только в админке: участнику нужно иметь их
|
||||
# на руках ДО публикации своего трека (см. `services/conference_access.py`).
|
||||
publish_quality_cap: PublishQualityCap
|
||||
stage_max_tiles: StageMaxTiles
|
||||
|
||||
|
||||
class ConferenceOut(BaseModel):
|
||||
|
||||
@@ -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)
|
||||
),
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
# --- Тестовое письмо ----------------------------------------------------------------
|
||||
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user