Compare commits
97 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 41aca319fc | |||
| 1ab146e4b0 | |||
| 9abe5b5102 | |||
| 6db142d45a | |||
| 1c2a9cd24a | |||
| c4485d43b1 | |||
| 0a13589612 | |||
| 6554fa242b | |||
| 1089d874af | |||
| bed041db75 | |||
| b0e30ead57 | |||
| 17437880b1 | |||
| fec9255baa | |||
| 88401d6aa1 | |||
| ca0b1e23fa | |||
| 9463e64e73 | |||
| 7ee68b17ee | |||
| ba01548088 | |||
| 3a290c7fc2 | |||
| 0e56960714 | |||
| c906c97cb8 | |||
| 296ce60c78 | |||
| 451c18e42b | |||
| f7c4fb4176 | |||
| e25b8c28de | |||
| 4f82ebe17a | |||
| 0e029a2bf8 | |||
| f89bf1ad64 | |||
| aee76329c4 | |||
| 10a3f8b3b4 | |||
| 65fbcf952c | |||
| b44652d6a6 | |||
| d11b808e97 | |||
| ed6f9fff44 | |||
| 06455f2401 | |||
| 3847476798 | |||
| a895782250 | |||
| fa8270c156 | |||
| a9e24f6692 | |||
| 8b63c24332 | |||
| cd5399f88a | |||
| 2be799b19d | |||
| 6b9d0b833e | |||
| f4e8f91839 | |||
| fb50c5d8ea | |||
| 826a0a391a | |||
| 4f5f336cd6 | |||
| 0441f4f72b | |||
| 9bc8d6174d | |||
| 45c997380f | |||
| 173d384f06 | |||
| 7b2427535a | |||
| 8e45038251 | |||
| 286c01d93b | |||
| e8af2fce10 | |||
| d7ae462ed5 | |||
| 826b7639b1 | |||
| daaa480f03 | |||
| c60047c594 | |||
| f39c21e7e1 | |||
| 84b7f807f7 | |||
| e5c596f2bf | |||
| 4c60e092e5 | |||
| 8e5eda88a2 | |||
| 42bfb88a22 | |||
| 07dc1aaeef | |||
| 0162cc8a6d | |||
| 5d39e0f076 | |||
| 43cc1ae3e8 | |||
| 82204553e2 | |||
| 3290eb5d07 | |||
| f627d827af | |||
| 5e6c4f8bfd | |||
| 270926cc96 | |||
| e018837a1d | |||
| 705f160912 | |||
| 7a5e9d2d8a | |||
| eb4e5ea83f | |||
| b528785249 | |||
| a53ba7c827 | |||
| 71f150d1b6 | |||
| 7549b53ec9 | |||
| 6d65b620fe | |||
| 32949ebc66 | |||
| b8220f682d | |||
| 29a3e78836 | |||
| 3fb3a5d42c | |||
| 975763a3a6 | |||
| c5b241ae02 | |||
| 356bc57a2a | |||
| afd4daee65 | |||
| ace5bf7a0d | |||
| 7f5c88869a | |||
| 54fd1a26d7 | |||
| 456cc58b25 | |||
| dccc369d0b | |||
| 77ee26014d |
56
.env.example
56
.env.example
@@ -2,6 +2,11 @@
|
||||
POSTGRES_USER=vidconf
|
||||
POSTGRES_PASSWORD=vidconf
|
||||
POSTGRES_DB=vidconf
|
||||
# DATABASE_URL нужен ТОЛЬКО для запуска backend/worker на хосте вне docker-сети
|
||||
# (см. docs/deploy/dev-setup.md) — внутри контейнеров docker-compose.yml
|
||||
# всегда собирает postgresql+asyncpg://${POSTGRES_USER}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB}
|
||||
# сам (сессия 37), это значение игнорируя. Держите пароль в этой строке
|
||||
# синхронным с POSTGRES_PASSWORD.
|
||||
DATABASE_URL=postgresql+asyncpg://vidconf:vidconf@localhost:5432/vidconf
|
||||
|
||||
# --- Redis ---
|
||||
@@ -59,6 +64,16 @@ TURN_STATIC_AUTH_SECRET=change-me-turn-secret
|
||||
# скриптом deploy/render-templates.sh (вызывается install.sh).
|
||||
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 (через пробел, все — в server_name).
|
||||
NGINX_SERVER_NAMES=example.com www.example.com
|
||||
@@ -81,6 +96,26 @@ LIVEKIT_NODE_IP=127.0.0.1
|
||||
# с точкой монтирования тома в обоих сервисах.
|
||||
RECORDINGS_DIR=/recordings
|
||||
|
||||
# --- Производительность backend ---
|
||||
# Число процессов uvicorn. Один процесс означает, что любой блокирующий вызов
|
||||
# в обработчике останавливает весь event loop: параллельные входы в конференцию
|
||||
# и WS-чат всех участников встают в очередь. Дефолт 2 рассчитан на 4-ядерный
|
||||
# сервер, где ядра делятся с LiveKit (медиа важнее API).
|
||||
UVICORN_WORKERS=2
|
||||
# Пул соединений с БД НА КАЖДЫЙ воркер. Общий расход инстанса —
|
||||
# UVICORN_WORKERS × (DB_POOL_SIZE + DB_MAX_OVERFLOW), плюс соединения Celery
|
||||
# и alembic. Держите сумму заметно ниже max_connections у Postgres (по
|
||||
# умолчанию 100), иначе вместо понятной ошибки приложения получите отказ БД.
|
||||
DB_POOL_SIZE=10
|
||||
DB_MAX_OVERFLOW=10
|
||||
DB_POOL_TIMEOUT=10
|
||||
# Пул соединений с Redis НА КАЖДЫЙ воркер. Считается по УЧАСТНИКАМ, а не по
|
||||
# запросам: WS-подключение комнаты держит собственную pub/sub-подписку всё
|
||||
# время, пока человек в конференции. Дефолт redis-py (100) упирался в потолок
|
||||
# примерно на сотом одновременном участнике на воркер. Сверху ограничивает
|
||||
# maxclients самого Redis (по умолчанию 10000) — на все процессы разом.
|
||||
REDIS_MAX_CONNECTIONS=500
|
||||
|
||||
# --- Email (рассылка саммари + .ics-приглашения) ---
|
||||
# `console` — дефолт для dev (письмо только логируется, ссылка подтверждения
|
||||
# email берётся из логов); `smtp` — реальная отправка через aiosmtplib.
|
||||
@@ -98,7 +133,7 @@ SMTP_TIMEOUT_S=30
|
||||
# --- Версия инстанса (релиз v0.0.1) ---
|
||||
# install.sh копирует значение из корневого файла VERSION при каждой
|
||||
# установке/обновлении — руками менять не нужно.
|
||||
VIDCONF_VERSION=0.0.8
|
||||
VIDCONF_VERSION=0.0.37
|
||||
|
||||
# --- Профили compose. Дефолт ниже (`media,monitoring`) — только для ручного
|
||||
# `docker compose up` БЕЗ install.sh: медиа (LiveKit+coturn) + мониторинг,
|
||||
@@ -160,7 +195,22 @@ GRAFANA_ADMIN_PASSWORD=change-me-grafana
|
||||
# ссылаются на них (проверено grep'ом при харденинге репозитория). Оставлены
|
||||
# здесь только для полноты покрытия реальных ключей сервера; вычистить или
|
||||
# начать использовать — по итогам Сессии 2 (bug hunt). ---
|
||||
MEDIA_URL=http://localhost/media/
|
||||
VITE_API_URL=http://localhost
|
||||
VITE_LIVEKIT_URL=ws://localhost:7880
|
||||
NEXT_PUBLIC_LIVEKIT_URL=ws://localhost:7880
|
||||
|
||||
# --- Медиа (аватары пользователей, backend/core/config.py: media_root) ---
|
||||
# Каталог, куда сохраняются загруженные файлы (аватары); раздаётся статикой
|
||||
# по /media (dev) либо через nginx location /media/ в проде.
|
||||
MEDIA_ROOT=media
|
||||
|
||||
# --- Egress (backend/core/config.py: egress_start_timeout_s) ---
|
||||
# Таймаут ожидания ответа Track Egress (секунды). Когда egress-сервис в
|
||||
# деплое не поднят (профиль transcribe отсутствует), не ждать его штатный
|
||||
# (гораздо более долгий) таймаут на каждый вызов.
|
||||
EGRESS_START_TIMEOUT_S=3.0
|
||||
|
||||
# --- LiveKit Egress (deploy/docker-compose.yml, сервис egress; см. также
|
||||
# deploy/egress/egress.yaml) — WS-адрес LiveKit ИЗНУТРИ docker-сети, отдельно
|
||||
# от LIVEKIT_URL/LIVEKIT_PUBLIC_URL выше (те — для backend/браузера). В
|
||||
# backend/core/config.py не читается — потребитель только egress. ---
|
||||
LIVEKIT_WS_URL=ws://livekit:7880
|
||||
|
||||
8
.gitignore
vendored
8
.gitignore
vendored
@@ -54,3 +54,11 @@ backend/media/
|
||||
# Локальные конфиги инструментов сессий (launch.json dev-сервера и т.п.) —
|
||||
# привязаны к конкретной машине, в репозиторий не идут.
|
||||
.claude/
|
||||
|
||||
# Wasm-рантайм MediaPipe — копируется из node_modules плагином `mediapipeWasm`
|
||||
# (frontend/vite.config.ts), ~19 МБ, в репозитории ему не место.
|
||||
frontend/public/mediapipe/wasm/
|
||||
|
||||
# Рабочая страница замера CPU (сессия 35), в репозиторий не идёт.
|
||||
frontend/bg-bench.html
|
||||
frontend/bg-support.html
|
||||
|
||||
776
CHANGELOG.md
776
CHANGELOG.md
@@ -3,6 +3,780 @@
|
||||
Формат основан на [Keep a Changelog](https://keepachangelog.com/ru/1.1.0/),
|
||||
проект придерживается [семантического версионирования](https://semver.org/lang/ru/).
|
||||
|
||||
## [0.0.37] — 2026-08-10
|
||||
|
||||
Закрытие технического долга. Без изменений в продуктовой логике, кроме
|
||||
исправления лишнего отказа при первом входе в комнату (см. «Исправлено»).
|
||||
|
||||
### Исправлено
|
||||
- Тесты бэкенда были не изолированы от состояния `instance_settings`
|
||||
(`chat`, `hand_queue` и др.) в общей dev-БД: выключение любого модуля в
|
||||
админке локального стенда роняло не связанные с ним тесты. Изоляция —
|
||||
через фикстуру `clean_instance_settings`, явно подключаемую в тестах,
|
||||
которым нужен «чистый стол» по умолчанным значениям.
|
||||
- `RoomPage.tsx`: сохранённый выбор устройства «по умолчанию»
|
||||
(`usePersistentUserChoices`, литерал `"default"`) не отсекался
|
||||
`|| undefined` — в Chrome это давало лишний `OverconstrainedError` на
|
||||
первом визите (то же самое уже было исправлено для страницы проверки
|
||||
устройств в 0.0.33).
|
||||
- Локальный контейнерный стенд (`docker compose --profile media up`) не
|
||||
поднимался «как есть» — `DATABASE_URL` из корневого `.env` (указывает на
|
||||
`localhost`, нужен только для запуска backend на хосте) утекал внутрь
|
||||
контейнеров и ronял подключение к БД. Теперь compose всегда собирает
|
||||
строку подключения сам из `POSTGRES_USER`/`PASSWORD`/`DB` с хостом
|
||||
`postgres`, как уже было сделано для `REDIS_URL`.
|
||||
- `vidconf_pipeline_sessions` в `/metrics` читалась через основной пул БД —
|
||||
на нагрузке могла отвалиться вместе с остальным API ровно тогда, когда
|
||||
метрика нужнее всего (см. инцидент 07.08). Теперь читается через отдельный
|
||||
движок, как `vidconf_db_up`/`vidconf_db_pool_*`.
|
||||
|
||||
### Изменено
|
||||
- `.env.example`: убраны неиспользуемые `MEDIA_URL`/`VITE_API_URL`,
|
||||
добавлены живые `MEDIA_ROOT`/`EGRESS_START_TIMEOUT_S`/`LIVEKIT_WS_URL`.
|
||||
- 13 предупреждений `ruff` (длина строки): реальный код переформатирован,
|
||||
текст регламента персональных данных вынесен в отдельный модуль
|
||||
(`services/consent_policy_text.py`) — как и вёрстка email-писем, это
|
||||
прозаический текст, а не код.
|
||||
|
||||
## [0.0.36] — 2026-08-10
|
||||
|
||||
Исправление к 0.0.35: замена фона не появлялась в Safari и Firefox.
|
||||
|
||||
### Исправлено
|
||||
- Проверка поддержки замены фона была строже, чем требует библиотека, и
|
||||
молча отрезала два браузера — кнопки выбора фона не было ни в превью, ни
|
||||
в комнате. У библиотеки два конвейера обработки кадров: современный
|
||||
(только Chrome и производные) и запасной через `canvas.captureStream()`,
|
||||
который работает и в Safari, и в Firefox; проверка требовала именно
|
||||
современный. Теперь условие буквально повторяет проверку самой библиотеки.
|
||||
|
||||
## [0.0.35] — 2026-08-10
|
||||
|
||||
Замена фона видео на картинку — отключаемый модуль (по умолчанию выключен).
|
||||
Три готовые сцены и свои картинки (до 10 штук, загружаются в профиле).
|
||||
**Только на компьютере:** сегментацию силуэта считает нейросеть на каждом
|
||||
кадре, и на телефоне это греет устройство, ест батарею и просаживает FPS
|
||||
в самой встрече — портить основное ради украшения нельзя.
|
||||
|
||||
### Добавлено
|
||||
- Кнопка «Фон» в тулбаре комнаты и выбор фона в превью на входе. Выбранный
|
||||
до входа фон применяется к публикуемому треку сразу в конференции.
|
||||
- Три дефолтные сцены (офис, пляж с пальмами, космическая станция) —
|
||||
собственные векторные рисунки (`design/backgrounds/`), а не фотографии
|
||||
из интернета: у нарисованной сцены нет чужой лицензии, а продукт
|
||||
расходится по инсталляциям, и проверять права на каждую копию некому.
|
||||
- Раздел «Свои фоны» в профиле: загрузка (не больше 10 штук) и удаление.
|
||||
Картинка уменьшается до 1280px по длинной стороне и переводится в WebP
|
||||
прямо в браузере перед отправкой — «чтобы не грузили БД». Файлы лежат
|
||||
на диске, в БД только пути, как у аватаров.
|
||||
- Тумблер «Замена фона видео» в админке. Лимит на число картинок и формат
|
||||
файла проверяются на сервере, а не только в интерфейсе.
|
||||
|
||||
### Особенности реализации
|
||||
- **Модель сегментации и wasm-рантайм отдаются со своего домена.** По
|
||||
умолчанию библиотека тянет их с внешних CDN (jsdelivr и
|
||||
storage.googleapis.com), и в закрытом контуре фича молча не работала бы.
|
||||
Модель (Apache 2.0) лежит в репозитории, рантайм копируется из
|
||||
node_modules при сборке; nginx отдаёт их сжатыми и с долгим кэшем.
|
||||
- Библиотека и модель грузятся **лениво**, только когда фон реально
|
||||
включают: вход в конференцию не стал медленнее, основной бандл не вырос.
|
||||
- «Десктоп» определяется по возможностям устройства (точный указатель +
|
||||
hover + число точек касания), а НЕ по ширине окна: узкое окно на
|
||||
десктопе — всё ещё десктоп, а широкий планшет — всё ещё планшет.
|
||||
На мобильном кнопки нет вовсе, а не задизейбленной.
|
||||
- Фон переживает выключение и включение камеры и смену устройства съёмки.
|
||||
Удаление применённой сейчас картинки сбрасывает выбор на «без фона».
|
||||
|
||||
## [0.0.34] — 2026-08-09
|
||||
|
||||
Правка к проверке устройств на входе (0.0.33): превью и кнопки
|
||||
камеры/микрофона теперь и на шаге «Подключиться к конференции», не только
|
||||
на карточке «Как вас зовут?». Закрывает реальный пробел — авторизованный
|
||||
пользователь, входящий через `/join`, карточку «Как вас зовут?» не проходит
|
||||
вовсе (сразу подключение) и раньше проверку устройств не видел никогда.
|
||||
|
||||
### Исправлено
|
||||
- Превью камеры и кнопки вкл/выкл — на шаге ввода ссылки/номера конференции,
|
||||
тем же компонентом, что и на карточке гостя. Поток живёт непрерывно на
|
||||
обоих шагах — переход между ними не гасит и не переоткрывает камеру.
|
||||
- Состояние кнопок «войти с камерой/микрофоном» теперь доезжает до комнаты
|
||||
и для гостя (`input → guest-info → комната`), и для авторизованного
|
||||
пользователя (`input → connecting → комната`).
|
||||
- Починена грабля с превью между шагами: `<video>` на разных шагах —
|
||||
разные DOM-узлы, обычный `ref` не переживал переезд между ними (картинка
|
||||
гасла в чёрный прямоугольник, хотя поток оставался жив). `videoRef` хука
|
||||
стал callback-ref, переподключающим поток к новому узлу автоматически.
|
||||
|
||||
## [0.0.33] — 2026-08-09
|
||||
|
||||
Проверка устройств на входе — отключаемый модуль (по умолчанию выключен).
|
||||
Раньше при первом включении микрофона/камеры уже внутри конференции у
|
||||
участника всплывал системный диалог разрешения браузера; теперь его можно
|
||||
пройти заранее, на странице логина и в карточке «Как вас зовут?» при
|
||||
гостевом входе, вместе с живым превью камеры.
|
||||
|
||||
### Добавлено
|
||||
- Запрос доступа к камере и микрофону на `LoginPage` и в карточке
|
||||
гостевого входа `JoinPage` — привязан к первому жесту на карточке
|
||||
(клик/тап/клавиша), не спрашивается повторно, если браузер уже помнит
|
||||
разрешение (`navigator.permissions`, с фолбэком на жест там, где API
|
||||
недоступен, например в Safari). Камера и микрофон запрашиваются
|
||||
независимо — отказ в одном не блокирует другой.
|
||||
- Живое зеркальное превью камеры на `JoinPage` (карточка «Как вас
|
||||
зовут?») с кнопками-пиктограммами «микрофон»/«камера»: реально
|
||||
останавливают и перезапускают поток, а не просто прячут картинку.
|
||||
На `JoinPage` кнопки определяют, с чем гость войдёт в конференцию
|
||||
(передаётся в `RoomPage`); на `LoginPage` — только пре-авторизация
|
||||
разрешения без интерфейса (страница ведёт в лобби, а не в конкретную
|
||||
конференцию, применить выбор там негде).
|
||||
- Публичный `GET /api/v1/public/settings` — флаг модуля, нужный обеим
|
||||
страницам до аутентификации.
|
||||
- Тумблер «Проверка устройств на входе» в админке (Настройки → Модули).
|
||||
|
||||
### Изменено
|
||||
- Отказ в доступе не блокирует ни вход в систему, ни в конференцию —
|
||||
показывается понятная подсказка, дальше можно идти как раньше.
|
||||
- Камера гарантированно освобождается при уходе с карточки, сабмите
|
||||
формы, размонтировании и любой ошибке — включая явный `release()`
|
||||
перед переходом в комнату, чтобы устройство не досталось LiveKit
|
||||
«занятым».
|
||||
|
||||
## [0.0.32] — 2026-08-09
|
||||
|
||||
Метрики состояния БД и пулов соединений + алерты в Prometheus — по решению
|
||||
оператора на отказ БД реагируем сигналом, а не автолечением (перезапуск
|
||||
контейнера при недоступной БД оборвал бы WS у всех, кто в конференциях).
|
||||
См. разбор инцидента 07.08.2026 (0.0.31): `/api/health` во время отказа
|
||||
отдавал 200 с `db: false`, а Prometheus скрейпит `/metrics`, где метрик
|
||||
состояния БД не было вообще — строить алерт было не на чем.
|
||||
|
||||
### Добавлено
|
||||
- **`vidconf_db_up`** — доступность БД (1/0), проверяется отдельным от
|
||||
основного пула соединением с коротким таймаутом. Позволяет отличить
|
||||
«БД лежит» от «основной пул занят под нагрузкой» — это два разных
|
||||
состояния, и до этого релиза их нечем было различить.
|
||||
- **`vidconf_db_pool_size`/`_max_overflow`/`_checked_out`** — конфигурация
|
||||
и занятость основного пула SQLAlchemy. Читаются синхронно из объекта
|
||||
пула (`engine.pool`), без единого запроса к БД — это единственный
|
||||
способ получить сигнал именно в момент, когда пул исчерпан.
|
||||
- **`vidconf_redis_pool_in_use`/`_max_connections`** — занятость пула
|
||||
Redis (второй потолок того же рода, закрыт в 0.0.31).
|
||||
- Алерты `deploy/monitoring/alerts.yml` (группа `vidconf-db`):
|
||||
`DatabaseUnavailable` (`vidconf_db_up == 0`, `for: 30s`, critical) и
|
||||
`DbConnectionPoolNearExhaustion`/`RedisConnectionPoolNearExhaustion`
|
||||
(занято > 80% дольше минуты, warning) — ранний сигнал: в инциденте
|
||||
07.08 пул заполнялся постепенно по мере входа участников в комнату,
|
||||
а не рывком от HTTP-нагрузки.
|
||||
- Дашборд Grafana **«БД и пулы соединений»**
|
||||
(`deploy/monitoring/grafana/dashboards/db-pool.json`).
|
||||
|
||||
### Технические детали
|
||||
- `GET /metrics` больше не падает и не виснет при недоступности основного
|
||||
пула БД: gauge'и о состоянии пула читаются первыми и не зависят от него
|
||||
(отдельное NullPool-соединение для `db_up`, синхронный снимок для
|
||||
занятости пула), а зависящий от основного пула `vidconf_pipeline_sessions`
|
||||
обёрнут таймаутом (2с) — при недоступности оставляет прежнее значение,
|
||||
не роняя остальные метрики. Полностью развести его с основным пулом не
|
||||
стали: тестовый харнесс подменяет `get_session` на savepoint-сессию
|
||||
(`tests/conftest.py`), отдельное соединение не увидело бы несознанные
|
||||
тестом данные — тот же компромисс, что и в 0.0.31 для `api/chat.py`.
|
||||
- Проверено вживую на локальном стенде (не только по синтаксису конфига):
|
||||
остановка Postgres → `vidconf_db_up` = 0, `/metrics` продолжает отвечать,
|
||||
алерт `DatabaseUnavailable` переходит в `firing`; временно урезанный
|
||||
пул под нагрузкой → `DbConnectionPoolNearExhaustion` переходит в
|
||||
`firing` ровно через заявленный `for: 1m`; снятие нагрузки/восстановление
|
||||
БД — алерты гаснут.
|
||||
|
||||
## [0.0.31] — 2026-08-09
|
||||
|
||||
Разбор провала входа на нагрузочном тесте 07.08.2026: комната держала
|
||||
соединения с БД и Redis на каждого участника.
|
||||
|
||||
### Исправлено
|
||||
- **Вход в систему переставал работать, когда в конференции набиралось
|
||||
около сорока человек.** WS-подключение комнаты (чат и очередь рук)
|
||||
держало занятым одно соединение с БД всё время, пока участник сидел
|
||||
в конференции: SELECT'ы хендшейка открывали транзакцию, а закрыть её
|
||||
было некому. Пул — 20 соединений на воркер (40 на инстанс), поэтому
|
||||
сороковой вошедший выгребал его досуха, и все остальные запросы —
|
||||
резолв конференции, гостевой вход, логин, обновление токена — начинали
|
||||
отвечать 500. Теперь соединение возвращается в пул сразу после
|
||||
хендшейка; на локальном стенде 120 участников на одном воркере не
|
||||
занимают ни одного соединения в простое (было: 20 из 20 при 20
|
||||
участниках, дальше вход не работал вовсе).
|
||||
- **Пользователя выкидывало из системы, когда серверу было плохо.**
|
||||
Фоновое обновление access-токена считало неудачей любой отрицательный
|
||||
ответ и на каждую такую неудачу сбрасывало сессию с переходом на
|
||||
страницу входа. Ответ 5xx (и обрыв сети) теперь означает «сервер
|
||||
временно недоступен»: сессия сохраняется, пользователь остаётся
|
||||
в системе и получает обычную ошибку запроса. Разлогинивание осталось
|
||||
только там, где backend прямо сказал, что сессия недействительна.
|
||||
Восстановление сессии при старте приложения повторяет попытку трижды,
|
||||
прежде чем показать страницу входа.
|
||||
|
||||
### Технические детали
|
||||
- Размер пула соединений с Redis задан явно (`REDIS_MAX_CONNECTIONS`,
|
||||
по умолчанию 500): на нём висят долгоживущие pub/sub-подписки комнаты —
|
||||
по одной на участника, — а дефолт redis-py 8 (100) упирался в потолок
|
||||
примерно на сотом участнике на воркер. Второй потолок того же рода,
|
||||
что и пул БД; найден при проверке правки выше на 120 участниках.
|
||||
- Размеры пулов БД (`DB_POOL_SIZE`/`DB_MAX_OVERFLOW`) не менялись
|
||||
осознанно: соединение больше не удерживается впустую, поэтому
|
||||
расширение пула лечило бы симптом и лишь отодвинуло порог.
|
||||
|
||||
## [0.0.30] — 2026-08-04
|
||||
|
||||
Согласие на обработку персональных данных при регистрации + отключаемый модуль.
|
||||
|
||||
### Добавлено
|
||||
- На форме регистрации — галочка согласия на обработку персональных данных
|
||||
со ссылкой на публичную страницу регламента (`/legal/personal-data-consent`).
|
||||
Кнопка регистрации неактивна, пока галочка не отмечена; сервер тоже
|
||||
отказывает без согласия (`POST /auth/register` → 400 `consent_required`,
|
||||
второй эшелон проверки — тот же принцип, что у `hand_queue_disabled`).
|
||||
- Текст регламента — настройка инстанса, редактируемая в админке
|
||||
(вкладка «Настройки» → карточка «Согласие на обработку персональных
|
||||
данных»): текстовое поле + тумблер «требовать согласие при регистрации».
|
||||
Дефолтный текст — типовой шаблон с плейсхолдерами под организацию
|
||||
(наименование оператора, адрес, контакты, цели и срок обработки),
|
||||
**не проходил юридическую проверку** — в карточке администратора
|
||||
об этом явное предупреждение.
|
||||
- Номер редакции текста растёт автоматически при каждой правке —
|
||||
у каждого пользователя, давшего согласие, в БД фиксируется и версия
|
||||
документа, и дата согласия (`users.consent_version`, `consent_given_at`).
|
||||
- Модуль отключаем (`instance_settings.consent_policy`), по умолчанию
|
||||
выключен — поведение существующих инсталляций не меняется. У уже
|
||||
зарегистрированных пользователей согласие не запрашивалось и не
|
||||
запрашивается задним числом, вход не блокируется.
|
||||
|
||||
### Технические детали
|
||||
- Миграция Alembic добавляет `users.consent_version`/`consent_given_at`
|
||||
(nullable — `NULL` означает «согласие не запрашивалось»).
|
||||
- Публичный `GET /auth/registration-options` (уже существующий, без нового
|
||||
эндпоинта) дополнен полями `consent_required`/`consent_text`/`consent_version` —
|
||||
тем же ответом пользуется и страница регламента, доступная всегда,
|
||||
независимо от того, включён ли модуль.
|
||||
|
||||
## [0.0.29] — 2026-08-04
|
||||
|
||||
Кнопка «демонстрация экрана» в мини-окне конференции.
|
||||
|
||||
### Добавлено
|
||||
- В мини-окне (Document PiP) рядом с кнопкой микрофона появилась кнопка
|
||||
демонстрации экрана: начать и остановить показ можно, не разворачивая
|
||||
основное окно. Кнопка и кнопка основного тулбара отражают одно
|
||||
состояние — обе читают его из комнаты, а не из разметки.
|
||||
- Своя демонстрация показывается в мини-окне по обычным правилам сцены:
|
||||
забирает крупную плитку при старте, держится, пока говорят другие, и
|
||||
уступает говорящему либо закреплённому участнику после остановки.
|
||||
|
||||
### Примечания
|
||||
- Кнопка есть только там, где мини-окно — настоящее окно со своей
|
||||
разметкой, то есть в Chrome и Edge. В Safari мини-окно выводится
|
||||
средствами системы (нативный «картинка в картинке»), собственных
|
||||
кнопок в нём быть не может; в Firefox мини-окна нет вовсе. Это
|
||||
ограничение браузеров, а не недоработка.
|
||||
- При выборе «весь экран» мини-окно попадает в собственную
|
||||
демонстрацию — как и у всех остальных участников. Чтобы этого
|
||||
избежать, показывайте конкретное окно, а не экран целиком.
|
||||
|
||||
## [0.0.28] — 2026-08-04
|
||||
|
||||
Очередь поднятых рук видна всем участникам + отключаемый модуль.
|
||||
|
||||
### Добавлено
|
||||
- Очередь поднятых рук больше не спрятана от обычных участников — её
|
||||
видит любой, кто в конференции, не только организатор. Опустить чужую
|
||||
руку по-прежнему может только организатор: сервер это уже проверял
|
||||
(`api/chat.py`, задача B1), правки — только на фронте, кнопка
|
||||
«Опустить» показывается у записи, если это своя рука либо пользователь
|
||||
сам организатор.
|
||||
- «Поднятие руки» (кнопка «Рука» + очередь целиком) — отключаемый модуль
|
||||
в админке (`instance_settings.hand_queue`, дефолт `enabled=true` —
|
||||
поведение существующих инсталляций не меняется). Настройка едет
|
||||
участнику в `JoinOut` (как `chat_enabled`) до входа в комнату.
|
||||
Выключенный модуль гасит кнопки на фронте и отклоняет
|
||||
`raise_hand`/`lower_hand` на сервере кодом `hand_queue_disabled` —
|
||||
вторая линия защиты для клиента со старым `JoinOut` на руках.
|
||||
Переключение применяется со следующего входа в комнату (та же
|
||||
застылость на время жизни соединения, что и у `chat_enabled`).
|
||||
|
||||
## [0.0.27] — 2026-08-04
|
||||
|
||||
Разрыв связи больше не выбрасывает участника из конференции.
|
||||
|
||||
### Исправлено
|
||||
- Телефон с погасшим экраном выпадал из конференции, а при возвращении
|
||||
мог оказаться в лобби вместо комнаты. Причина установлена по логам
|
||||
прода: Chrome срезает ресурсы фоновой вкладке, телефон перестаёт
|
||||
отвечать по ICE, и LiveKit закрывает участника через 5 с после потери
|
||||
соединения (замерено: 37 с после блокировки экрана, 23 с после
|
||||
сворачивания браузера — то есть достаточно просто убрать вкладку в
|
||||
фон, гасить экран не обязательно). Восстановить сессию после этого
|
||||
нельзя — участника на сервере уже нет; нужен полный повторный вход,
|
||||
и `livekit-client` пытается сделать его сам, но его бюджет повторов
|
||||
(10 попыток за ~44 с) в фоновой вкладке успевает сгореть. Тогда
|
||||
приходило событие `Disconnected`, и страница комнаты уводила
|
||||
пользователя в лобби.
|
||||
|
||||
Теперь непреднамеренный разрыв не уводит со страницы: сбрасывается
|
||||
состояние входа, и работает уже имевшийся путь авто-перезахода —
|
||||
резолв конференции, свежий токен, вход заново. Намеренный выход
|
||||
отличается по нажатию кнопки «Выйти», а не по коду причины: причину
|
||||
`CLIENT_INITIATED` присылает и кнопка, и сам `livekit-client`, который
|
||||
при заморозке вкладки (событие `freeze`) вызывает `disconnect()`
|
||||
самостоятельно. Разрывы, после которых возвращаться нельзя (участника
|
||||
выгнал организатор, конференция закрыта, вход той же личностью с
|
||||
другого устройства), уводят в лобби как раньше. От цикла «вошёл —
|
||||
сразу выбросило» защищает лимит в 5 перезаходов подряд; соединение,
|
||||
прожившее дольше 30 с, счётчик обнуляет.
|
||||
|
||||
## [0.0.26] — 2026-08-04
|
||||
|
||||
Мобильная комната: свайп шторки настроек и полноэкранный режим.
|
||||
|
||||
### Исправлено
|
||||
- Шторка «Настройки» на мобильном не закрывалась свайпом вниз почти никогда:
|
||||
обработчики висели только на ручке-волоске (`.room-sheet-handle`,
|
||||
40×4px) — палец в неё практически невозможно попасть, и палец почти
|
||||
всегда приземлялся на панель, где обработчиков не было вовсе. Теперь
|
||||
свайп закрывает шторку при жесте по любому месту панели, но только
|
||||
когда её содержимое проскроллено в самый верх (`scrollTop === 0` на
|
||||
начало жеста) — иначе свайп вниз листает список устройств, как и должен
|
||||
стандартный bottom sheet.
|
||||
|
||||
### Добавлено
|
||||
- Полноэкранный режим комнаты — на мобильном кнопка «Экран» в тулбаре (как
|
||||
на десктопе), при активации топбар и тулбар уходят из потока и лежат
|
||||
оверлеем поверх сцены: показываются по тапу/клику по сцене вне элементов
|
||||
управления и сами прячутся через несколько секунд бездействия. На
|
||||
десктопе — та же логика (топбар/тулбар тоже прячутся в полноэкранном
|
||||
режиме), дополнительный способ вернуть их — навести мышь в нижнюю полосу
|
||||
экрана.
|
||||
|
||||
### Изменено
|
||||
- Кнопка настроек устройств в тулбаре на мобильном подписана «Настройки»
|
||||
вместо «Устройства».
|
||||
- Раскладка кнопок мобильного тулбара, когда они не помещаются в один ряд
|
||||
(7 и больше — с полноэкранным режимом и «Очередью» у организатора это
|
||||
обычный случай), стала равномерной сеткой на 4 колонки (7 → 4+3,
|
||||
8 → 4+4) вместо переноса «как получится» через `flex-wrap`.
|
||||
- `deploy/render-templates.sh` умеет читать значения из файла, заданного
|
||||
переменной `ENV_FILE`, а не только из корневого `.env` (см. коммит
|
||||
`3847476`, вошёл в этот релиз) — для локальных стендов, где корневой
|
||||
`.env` указывает на боевые адреса.
|
||||
|
||||
## [0.0.25] — 2026-08-03
|
||||
|
||||
Два дефекта мини-окна конференции (Document PiP), оба видны только в Chrome.
|
||||
|
||||
### Исправлено
|
||||
- В Chrome пропадал звук других участников после возврата сцены из
|
||||
мини-окна в основное. `RoomAudioRenderer` жил внутри `RoomStage` и
|
||||
рендерился в обеих её ветках, поэтому скрытые `<audio>` с чужими
|
||||
аудиотреками физически переезжали в документ PiP-окна и обратно.
|
||||
После такого переезда Chrome теряет аудиовыход у remote-трека: пакеты
|
||||
продолжают приходить (`packetsReceived` растёт), а
|
||||
`totalSamplesDuration` и `totalAudioEnergy` замирают, и трек молчит
|
||||
даже в свежесозданном `<audio>` со свежим `MediaStream`. Тот же цикл
|
||||
detach/attach в пределах одного документа безвреден — дело именно в
|
||||
переезде между документами. Рендерер вынесен в `RoomPage`: один
|
||||
экземпляр, всегда в основном документе, в PiP-окне аудиоэлементов нет
|
||||
вовсе. В Safari бага не было — там Document PiP не используется
|
||||
(video-фолбэк), сцена из основного окна не уезжает.
|
||||
- В мини-окне не действовало закрепление участника, а демонстрация
|
||||
экрана слетала на говорящего от любой чужой реплики. Мини-плеер
|
||||
намеренно ходил с упрощёнными правилами выбора фокуса (без
|
||||
`holdScreenShare`, без приоритета говорящего с камерой, без
|
||||
антидребезга) и с собственным локальным состоянием закрепления.
|
||||
Теперь `pickStageFocus` получает одинаковые правила в обоих вариантах
|
||||
сцены, а закрепление переезжает между окнами тем же мостиком через
|
||||
`RoomPage`, что и фокус. Сознательно оставлено одно отличие —
|
||||
фолбэк «показать себя» (`localKey`) только у мини-плеера: это защита
|
||||
от дефекта 0.0.11, когда мини-окно открывалось на самом пользователе.
|
||||
- Закрепление сбрасывалось при каждом монтировании сцены: на первом
|
||||
рендере `useTracks` отдаёт пустой набор треков, и правило «закреплённый
|
||||
вышел из комнаты» принимало это за уход участника.
|
||||
|
||||
### Изменено
|
||||
- Кнопка-булавка на плитке подписана «Закрепить» вместо «Закрепить в
|
||||
основном окне» — закрепление больше не ограничено основным окном.
|
||||
|
||||
## [0.0.24] — 2026-08-03
|
||||
|
||||
Три артефакта вёрстки, вылезающие за границы блоков (эмодзи-поповер чата,
|
||||
заголовок брендовой панели, чат в Firefox) + аудит похожих мест.
|
||||
|
||||
### Исправлено
|
||||
- Заголовок «Ваша инфраструктура» на странице входа вылезал за край
|
||||
брендовой панели на широком окне с узкой панелью (`.layout` — flex
|
||||
42/58) — старый фикс уменьшал кегль только по ширине ОКНА (`@media`),
|
||||
а не панели. Кегль `.brand-headline` теперь считается через container
|
||||
query (`container-type: inline-size` + `cqw`) — тот же приём, что у
|
||||
аватара участника в комнате. Тем же механизмом обрезались плашки
|
||||
статистики («AI-саммари») — `.brand-stats` теперь переносит их на
|
||||
мобильном и десктопе одинаково.
|
||||
- Эмодзи-поповер в чате комнаты: последняя (5-я) колонка вылезала за
|
||||
правый край поповера. Причина — гонка CSS-специфичности: правило
|
||||
круглой кнопки «Отправить» (`.chat-input-row button`, 42×42px)
|
||||
продолжало красить размер и кнопкам эмодзи внутри поповера (та же
|
||||
гонка чинилась для цвета в 0.0.19, но не для размера). Добавлены явные
|
||||
`width`/`height: auto` нужной специфичности + `max-width` на попап как
|
||||
общая страховка.
|
||||
- Чат комнаты вылезал за границы панели в Firefox: `.chat-messages`
|
||||
(flex-колонка) без `min-height: 0` не сжималась в Firefox, `textarea`
|
||||
поля ввода без `min-width: 0` упиралась в автоматическую минимальную
|
||||
ширину (Firefox считает её от атрибута `cols`, жёстче Chrome/Safari).
|
||||
|
||||
## [0.0.23] — 2026-08-02
|
||||
|
||||
Документация по сайзингу под медиа-нагрузку + видимость TURN-аллокаций в логах.
|
||||
|
||||
### Добавлено
|
||||
- `docs/deploy/hardware-sizing.md` — таблица «профиль нагрузки → CPU/RAM/
|
||||
полоса» для медиа (видеоконференции), на реальных боевых замерах
|
||||
28.07 и 31.07.2026, с формулой для расчёта под свой сценарий.
|
||||
|
||||
### Исправлено
|
||||
- coturn: включено verbose-логирование — дефолтный уровень не писал
|
||||
построчно `ALLOCATE`/`CreatePermission`/`Refresh` даже при рабочем
|
||||
relay-соединении (найдено на релизе 0.0.22 — звонок через TURN работал,
|
||||
а `grep -ci allocate` по логам coturn показывал 0).
|
||||
|
||||
## [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
|
||||
|
||||
Сеть LiveKit: один UDP-порт вместо диапазона на 101 порт.
|
||||
|
||||
### Исправлено
|
||||
- Весь медиа-трафик конференций шёл через userland-прокси Docker: диапазон
|
||||
`54000-54100/udp` заставлял поднимать по отдельному процессу `docker-proxy`
|
||||
на каждый порт. LiveKit переведён на `rtc.udp_port` (один порт,
|
||||
мультиплексирование ICE-сессий по ufrag внутри самого сервера) — проброс
|
||||
портов схлопнут до одного, TURN не затронут. Проверено локально
|
||||
синтетической нагрузкой (`lk load-test`, 2 видео + 2 аудио publisher'а,
|
||||
2 subscriber'а) — 0% потерь пакетов, ICE во всех сессиях выбирает новый
|
||||
единственный порт.
|
||||
|
||||
## [0.0.19] — 2026-08-02
|
||||
|
||||
Правки по замечаниям к части B (комната конференции).
|
||||
|
||||
### Исправлено
|
||||
- Очередь поднятых рук открывалась боковой панелью во весь экран на
|
||||
мобильном — теперь компактный поповер над кнопкой (как «Вид»), размер
|
||||
подстраивается под число записей, после ~10 строк список скроллится.
|
||||
- Кнопки нижнего тулбара при сужении окна вылезали за края блока (задачи
|
||||
B1/B2 добавили «Рука»/«Очередь», в тулбаре стало до 11 кнопок вместо
|
||||
восьми) — теперь плавно уменьшаются на диапазоне 1200–600px вместо
|
||||
жёсткого скачка на мобильный вид.
|
||||
- Подпись «Мини-окно» на промежуточных ширинах переносилась на 2 строки и
|
||||
делала эту кнопку выше соседних — ниже 1200px показывается короткое
|
||||
«Мини».
|
||||
- Эмодзи-поповер в чате красил все свои кнопки в зелёный цвет кнопки
|
||||
«Отправить» (гонка специфичности CSS-селекторов) — исправлено; заодно
|
||||
сетка приведена к ровным 5×6 без неполной строки, добавлены
|
||||
🦾 🚀 🦞 💯 🤷♂️.
|
||||
|
||||
## [0.0.18] — 2026-08-01
|
||||
|
||||
Ограничение частоты запросов больше не блокирует вход целой конференции.
|
||||
|
||||
### Исправлено
|
||||
- Лимит на резолв конференции и гостевой вход считался по адресу контейнера
|
||||
nginx, а не клиента, — то есть «10 запросов в минуту» действовали на весь
|
||||
сервер разом. Одиннадцатый человек, открывший ссылку в течение минуты,
|
||||
получал отказ и видел «Не удалось найти конференцию» для существующей и
|
||||
активной конференции. Теперь адрес берётся из заголовка, который nginx уже
|
||||
передаёт.
|
||||
- Счётчик считает только неудачные попытки — конференция не найдена или
|
||||
пароль неверен. Именно так выглядит перебор номера, от которого защищает
|
||||
ограничение; массовый вход по рабочей ссылке к нему отношения не имеет.
|
||||
На общий поток с адреса оставлен потолок в 300 запросов в минуту.
|
||||
|
||||
## [0.0.17] — 2026-08-01
|
||||
|
||||
Массовый вход в систему и в конференцию перестаёт упираться в проверку пароля.
|
||||
|
||||
### Исправлено
|
||||
- Проверка пароля больше не останавливает весь backend. Хэширование argon2 —
|
||||
это десятки миллисекунд счёта, и выполнялось оно синхронно внутри
|
||||
асинхронного обработчика: пока считался один пароль, процесс не обслуживал
|
||||
ничего другого. На нагрузочном тесте 31.07 с примерно семью десятками
|
||||
одновременных входов это дало p95 логина 7.28 секунды, p95 входа в
|
||||
конференцию 7.06 секунды и 33 соединения к базе, висящих в открытой
|
||||
транзакции при одном активном запросе. Страдали и посторонние запросы —
|
||||
вход в конференцию отказывал, хотя пароль там не проверялся вовсе.
|
||||
Теперь хэширование считается в пуле потоков.
|
||||
- Параметры argon2id приведены к рекомендации OWASP (t=2, m=19 МБ, p=1)
|
||||
вместо дефолтов библиотеки (t=3, m=64 МБ, p=4): 95 мс против 42 мс на
|
||||
проверку. Прежнее значение `parallelism=4` вдобавок занимало все четыре
|
||||
ядра сервера — те же, на которых работает медиа-сервер.
|
||||
- Пароли, сохранённые со старыми параметрами, продолжают работать и
|
||||
перевыпускаются автоматически при первом успешном входе.
|
||||
|
||||
## [0.0.16] — 2026-08-01
|
||||
|
||||
Роль организатора в комнате: поднятие руки с очередью и принудительный мьют.
|
||||
|
||||
### Добавлено
|
||||
- Роль организатора прокинута в комнату — метаданные LiveKit-токена несут
|
||||
подсказку `is_organizer` для UI (владелец конференции/администратор);
|
||||
любое серверное действие организатора перепроверяется по владельцу
|
||||
конференции в БД, метаданным токена для авторизации не доверяем.
|
||||
- Поднятие руки: кнопка «Рука» в тулбаре (у всех участников, с общим
|
||||
счётчиком), бейдж на плитке говорящего, видимый всем, и панель «Очередь»
|
||||
для организатора — участники в порядке поднятия руки, с возможностью
|
||||
опустить любую руку. Состояние — в Redis (не в БД): очередь существует
|
||||
ровно во время звонка. Участник, вышедший из конференции, автоматически
|
||||
исчезает из очереди; переподключение место не теряет.
|
||||
- Принудительный мьют: организатор может выключить микрофон или камеру
|
||||
любого участника — кнопки на его плитке. Участник получает уведомление,
|
||||
что его выключил организатор, и может включить себя обратно сразу тем же
|
||||
тулбаром.
|
||||
- Эмодзи скачущего коня 🐎 в наборе смайликов чата.
|
||||
|
||||
### Изменено
|
||||
- Транспорт чата (`WS /conferences/{id}/chat`) расширен: очередь поднятых
|
||||
рук и уведомления о мьюте едут по тому же аутентифицированному
|
||||
соединению, что и сообщения чата — без нового эндпоинта.
|
||||
|
||||
Миграций БД нет — новое состояние (очередь поднятых рук) хранится в Redis,
|
||||
не в Postgres.
|
||||
|
||||
## [0.0.15] — 2026-07-30
|
||||
|
||||
Шесть доработок UI комнаты конференции.
|
||||
|
||||
### Добавлено
|
||||
- Выбор эмодзи в чате — поповер с небольшим собственным набором популярных
|
||||
эмодзи (без библиотеки-пикера), вставка в позицию курсора.
|
||||
- Кнопка микрофона в мини-окне (Document PiP) — раньше в PiP не было
|
||||
никакого способа замьютить себя, не разворачивая основное окно.
|
||||
- Закрепление участника доступно из любого режима показа («Стандарт»,
|
||||
«Плитки», «Живые плитки») — раньше булавка рендерилась только в
|
||||
«Стандарте». Само закрепление автоматически переключает вид на «Стандарт»
|
||||
(закреплённого иначе негде показать крупно); открепление вид назад не
|
||||
меняет.
|
||||
|
||||
### Изменено
|
||||
- Участник заходит в конференцию с выключенными микрофоном и камерой,
|
||||
включает их сам уже в комнате.
|
||||
- В топбаре комнаты вместо технического `room_name` LiveKit (вида
|
||||
`hC-Dmos9KEM`) показывается название конференции; при его отсутствии —
|
||||
«Конференция без названия». Номер и ссылка-приглашение не задеты.
|
||||
|
||||
## [0.0.14] — 2026-07-28
|
||||
|
||||
TURN-фолбэк для участников из сетей с жёстким NAT.
|
||||
|
||||
### Исправлено
|
||||
- LiveKit теперь анонсирует клиентам внешний TURN-сервер (`rtc.turn_servers`
|
||||
в конфигурации, UDP и TCP на 3478). Раньше coturn поднимался и был healthy,
|
||||
но клиенты о нём не знали: внешний TURN в конфиге объявлен не был, встроенный
|
||||
выключен, а фронтенд `iceServers` не задаёт. За всё время работы в логах
|
||||
coturn не было ни одной аллокации — то есть relay не использовался никогда,
|
||||
и участники из сетей, где прямое UDP-соединение не проходит, теряли связь
|
||||
(`PEER_CONNECTION_DISCONNECTED`). На нагрузочном тесте 28.07 все такие
|
||||
разрывы пришлись на внешних участников и ни одного — на офисных.
|
||||
- Credentials TURN генерируются по механизму TURN REST API из общего
|
||||
`TURN_STATIC_AUTH_SECRET`, то есть тот же секрет, что и у coturn.
|
||||
|
||||
### Изменено
|
||||
- `deploy/render-templates.sh` подставляет `TURN_EXTERNAL_IP` и
|
||||
`TURN_STATIC_AUTH_SECRET` также в конфигурацию LiveKit.
|
||||
- Раздел 8 руководства по развёртыванию переписан: TURN больше не «план на
|
||||
будущее», а рабочая конфигурация. Отдельно описано правило ufw для
|
||||
relay-диапазона `49160:49200/udp` — без него TURN отвечает на запросы, но
|
||||
релей не работает, причём в логах coturn при этом тишина.
|
||||
|
||||
TURN over TLS (5349/443) по-прежнему не настроен: требует монтирования
|
||||
сертификата в coturn. Неработающий `turns:` намеренно не анонсируется, иначе
|
||||
клиент ждал бы таймаута перед переходом к рабочему кандидату.
|
||||
|
||||
## [0.0.13] — 2026-07-28
|
||||
|
||||
Снижение нагрузки на сеть: клиент перестаёт получать полное качество всех
|
||||
чужих камер независимо от того, какого размера плитка на экране.
|
||||
|
||||
### Изменено
|
||||
- Включены `adaptiveStream` и `dynacast` в опциях комнаты. Оба флага в LiveKit
|
||||
выключены по умолчанию, из-за чего каждый участник был подписан на полное
|
||||
качество всех чужих треков, а каждый паблишер слал все слои симулкаста, даже
|
||||
когда их никто не смотрит. Теперь качество подписки выбирается по фактическому
|
||||
размеру плитки, а неотрисованные треки уходят в паузу. На локальном стенде
|
||||
(9 участников, паблишеры 720p) входящий поток одного клиента упал с
|
||||
11 110 до 778 кбит/с.
|
||||
|
||||
Вместе с этим начинает экономить уже написанный код, который до сих пор не
|
||||
давал выигрыша: «скрыть остальных» не рендерит карусель (исходящий трафик
|
||||
LiveKit 0.76 → 0.03 Мбит/с), пагинация сетки участников рендерит только
|
||||
текущую страницу, а пауза чужого видео в свёрнутой вкладке работает лишь
|
||||
при включённом `adaptiveStream`.
|
||||
- Контейнеры больше не пересобирают окружение Python при запуске: во все
|
||||
вызовы `uv run` в прод-путях (CMD образа, `command`/`entrypoint`/`healthcheck`
|
||||
сервисов, миграции и seed в `install.sh`) добавлен `--no-sync`. Раньше
|
||||
окружение, собранное на этапе build с `--no-dev`, при каждом старте
|
||||
синхронизировалось заново и подтягивало dev-группу: ~26 МБ загрузок,
|
||||
замедленный старт и ruff/mypy/pytest в рантайме. Healthcheck'и делали то же
|
||||
самое каждые 15 секунд всю жизнь контейнера. Размер `/app/.venv` после
|
||||
запуска: 574 → 456 МБ. Локальная разработка не затронута — там dev-группа
|
||||
нужна и ставится как раньше.
|
||||
|
||||
## [0.0.12] — 2026-07-28
|
||||
|
||||
Разблокировка backend под нагрузкой: вход в конференцию перестаёт отваливаться,
|
||||
когда участники включают микрофоны.
|
||||
|
||||
### Исправлено
|
||||
- Обработчик webhook `track_published` больше не запускает Track Egress внутри
|
||||
своей транзакции. Раньше каждый опубликованный микрофон уходил в сетевой
|
||||
вызов, а на инстансе без профиля `transcribe` (egress-сервиса в деплое нет)
|
||||
этот вызов висел 20–25 секунд, всё это время удерживая соединение с БД. На
|
||||
нагрузочном тесте с 19 участниками пул соединений выгребался за секунды, и
|
||||
вход в конференцию начинал отвечать 500. Теперь запуск уходит в фоновую
|
||||
задачу со своей сессией, а webhook отвечает сразу — LiveKit перестаёт копить
|
||||
очередь доставки и терять события.
|
||||
- Track Egress не запускается вовсе, если транскрибация выключена в настройках
|
||||
инстанса — тот же guard, что уже был в обработчике `room_finished`.
|
||||
- Запуск Track Egress ограничен таймаутом (по умолчанию 3 секунды) вместо
|
||||
ожидания собственного таймаута LiveKit.
|
||||
|
||||
### Изменено
|
||||
- Пул соединений с БД задаётся явно (`DB_POOL_SIZE`, `DB_MAX_OVERFLOW`,
|
||||
`DB_POOL_TIMEOUT`) вместо дефолта SQLAlchemy 5 + 10. Считайте бюджет на весь
|
||||
инстанс: каждый воркер держит свой пул, и сумма должна оставаться заметно
|
||||
ниже `max_connections` у Postgres.
|
||||
- Backend запускается с несколькими процессами uvicorn (`UVICORN_WORKERS`,
|
||||
по умолчанию 2). Одиночный процесс означал, что любой блокирующий вызов
|
||||
останавливает и параллельные запросы, и WS-чат всех участников. Дефолт 2, а
|
||||
не по числу ядер: на четырёхъядерном сервере ядра делятся с LiveKit.
|
||||
|
||||
## [0.0.11] — 2026-07-28
|
||||
|
||||
Выбор режима показа участников в конференции, скрытие остальных и круглая
|
||||
иконка участника при выключенной камере.
|
||||
|
||||
### Добавлено
|
||||
- Выбор режима показа участников на сцене вместо автоматической раскладки:
|
||||
«Стандарт» (крупная плитка плюс карусель остальных сбоку, как было),
|
||||
«Плитки» (все участники равными плитками) и «Живые плитки» (плитками —
|
||||
только те, у кого включена камера, остальные в карусели). Кто именно
|
||||
показан крупно в «Стандарте», по-прежнему решает прежняя логика фокуса
|
||||
(новая демонстрация → закреплённый → говорящий с видео → говорящий).
|
||||
Выбор сохраняется между заходами в комнату.
|
||||
- Скрытие остальных участников — на сцене остаётся только основная плитка.
|
||||
Скрыть можно из меню «Вид», из шторки настроек на мобильном и кнопкой над
|
||||
колонкой миниатюр; вернуть — кнопкой «Показать остальных (N)», которая
|
||||
видна всё время, пока кто-то скрыт. В режиме «Плитки» скрывать нечего,
|
||||
переключатель там заблокирован.
|
||||
- Переключатель вида: кнопка «Вид» с поповером в тулбаре на широком экране;
|
||||
на мобильном — секция «Вид» в шторке настроек, чтобы не добавлять шестую
|
||||
кнопку в уже ужатый тулбар.
|
||||
|
||||
### Изменено
|
||||
- Демонстрация экрана временно перебивает выбранный режим: пока идёт шэр,
|
||||
сцена ведёт себя как «Стандарт», а по его завершении возвращается к
|
||||
выбранному виду.
|
||||
- Равномерная сетка плиток стала пригодна для телефона: 2 колонки от 360px
|
||||
ширины и портретная раскладка 2×3 вместо двух плиток на страницу, которые
|
||||
давал набор раскладок библиотеки. Лишние участники уходят на следующую
|
||||
страницу — листается свайпом и стрелками.
|
||||
|
||||
### Исправлено
|
||||
- Иконка участника при выключенной камере больше не превращается в эллипс:
|
||||
размер считается от узкой стороны плитки (container-запросы), а не как
|
||||
проценты от разных сторон. В крупной плитке иконка занимает примерно её
|
||||
половину, в миниатюрах карусели — всю узкую сторону с отступом 5px; на
|
||||
большой плитке фокуса иконка больше не упирается в прежний потолок 96px.
|
||||
- Мини-плеер в Chrome открывался на самом пользователе вместо того, что он
|
||||
видел крупно: сцена в PiP-окне — отдельный экземпляр компонента и выбирала
|
||||
фокус с нуля, доходя до фолбэка «показать себя». Теперь ключ фокуса
|
||||
переживает переезд в мини-окно и обратно. В Safari (video-фолбэк вместо
|
||||
Document PiP) поведение не менялось.
|
||||
|
||||
## [0.0.10] — 2026-07-28
|
||||
|
||||
Разбивка метрик по контейнерам в Grafana и плашка с характеристиками сервера.
|
||||
|
||||
### Добавлено
|
||||
- Метрики по контейнерам (CPU/память/сеть каждого отдельно) — замена
|
||||
`cAdvisor`, несовместимого с containerd-снапшоттером Docker Engine на
|
||||
сервере `1gb` (убран в `0.0.9`). Свой минимальный сервис
|
||||
`container-exporter` (`deploy/monitoring/container-exporter/`) опрашивает
|
||||
Docker Engine API параллельно в фоновой задаче, независимо от
|
||||
storage-driver'а. Доступ к докер-сокету изолирован через
|
||||
`docker-socket-proxy` — экспортеру разрешены только `GET`-запросы по
|
||||
контейнерам, любые изменяющие запросы блокируются на уровне прокси.
|
||||
Панели «Топ контейнеров по CPU/памяти» вернулись в дашборд
|
||||
«Хост и контейнеры».
|
||||
- Плашка «Характеристики сервера» в том же дашборде: живые «Ядра CPU» /
|
||||
«Объём RAM» / «Диск (/)» из `node-exporter`, GPU — из новой info-метрики
|
||||
backend `vidconf_host_info` (данные `install.sh`, `HW_*` в `.env`).
|
||||
|
||||
## [0.0.9] — 2026-07-28
|
||||
|
||||
Метрики CPU/RAM/диска хоста в Grafana; дефолтные подвкладки конференций и
|
||||
пользователей в админке.
|
||||
|
||||
### Добавлено
|
||||
- Мониторинг железа: `node-exporter` (CPU/RAM/диск/сеть/load average хоста)
|
||||
— профиль compose `monitoring`, без публикации портов наружу. Новый
|
||||
дашборд Grafana «Хост и контейнеры». Три новых алерта Prometheus:
|
||||
`HostMemoryLow`, `HostDiskLow`, `HostCpuHigh` (пороги — под сервер `1gb`:
|
||||
8 ГБ RAM, 4 CPU, 50 ГБ диска). `cAdvisor` (разбивка по контейнерам)
|
||||
сознательно не включён — несовместим с containerd-снапшоттером Docker
|
||||
Engine на `1gb`, см. `docs/deploy/monitoring.md`.
|
||||
|
||||
### Изменено
|
||||
- Админка, вкладка «Конференции»: вкладка «Все» перенесена в конец списка
|
||||
фильтров (после «Завершённые»), по умолчанию открываются «Запланированные».
|
||||
- Админка, вкладка «Пользователи»: по умолчанию открываются «Активные»
|
||||
(было «Все»).
|
||||
|
||||
## [0.0.8] — 2026-07-27
|
||||
|
||||
Админка: несколько mail-доменов для регистрации, фильтр и поиск по команде в
|
||||
@@ -206,6 +980,8 @@
|
||||
|
||||
Первоначальная версия VidConf.
|
||||
|
||||
[0.0.10]: https://github.com/MaxRonzhin/vidconf_rel/releases/tag/v0.0.10
|
||||
[0.0.9]: https://github.com/MaxRonzhin/vidconf_rel/releases/tag/v0.0.9
|
||||
[0.0.8]: https://github.com/MaxRonzhin/vidconf_rel/releases/tag/v0.0.8
|
||||
[0.0.7]: https://github.com/MaxRonzhin/vidconf_rel/releases/tag/v0.0.7
|
||||
[0.0.6]: https://github.com/MaxRonzhin/vidconf_rel/releases/tag/v0.0.6
|
||||
|
||||
@@ -264,6 +264,8 @@ VidConf использует единый инсталлятор `install.sh` с
|
||||
```
|
||||
|
||||
Требования к оборудованию и полное описание см. в [docs/deploy/install.md](docs/deploy/install.md) и [docs/architecture/adr/004-ai-tier-matrix.md](docs/architecture/adr/004-ai-tier-matrix.md).
|
||||
Это требования под AI; сколько CPU/RAM/полосы нужно под саму видео-нагрузку (профиль
|
||||
«сколько человек и камер») — [docs/deploy/hardware-sizing.md](docs/deploy/hardware-sizing.md).
|
||||
|
||||
### Docker Compose профили (низкоуровневый контроль)
|
||||
|
||||
|
||||
@@ -37,4 +37,20 @@ EXPOSE 8000
|
||||
HEALTHCHECK --interval=10s --timeout=5s --retries=10 --start-period=15s \
|
||||
CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:8000/api/health')" || exit 1
|
||||
|
||||
CMD ["uv", "run", "uvicorn", "main:create_app", "--factory", "--host", "0.0.0.0", "--port", "8000"]
|
||||
# Число воркеров — из окружения (`UVICORN_WORKERS`, см. docker-compose.yml).
|
||||
# Один процесс означает, что любой блокирующий вызов в обработчике
|
||||
# останавливает весь event loop: параллельные запросы и WS-чат всех
|
||||
# участников встают в очередь. Дефолт 2, а не «по числу ядер»: медиа
|
||||
# важнее API, и на 4-ядерном сервере LiveKit в пике забирает 1.6 ядра.
|
||||
#
|
||||
# `sh -c` нужен ради подстановки переменной (exec-форма её не делает),
|
||||
# `exec` — чтобы uvicorn получил PID 1 и корректно принимал SIGTERM.
|
||||
#
|
||||
# `--no-sync`: окружение уже собрано выше (`uv sync --frozen --no-dev`), и
|
||||
# пересобирать его в рантайме незачем. Без флага `uv run` перед каждым
|
||||
# запуском заново синхронизирует venv И ПОДТЯГИВАЕТ dev-группу (ruff, mypy,
|
||||
# pytest — ~30 МБ загрузок на каждый старт контейнера, dev-инструменты в
|
||||
# проде и раздутый venv). То же касается healthcheck'ов в
|
||||
# deploy/docker-compose.yml, которые дёргают `uv run` каждые 15 секунд.
|
||||
CMD ["sh", "-c", \
|
||||
"exec uv run --no-sync uvicorn main:create_app --factory --host 0.0.0.0 --port 8000 --workers ${UVICORN_WORKERS:-2}"]
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
"""user consent to personal data processing
|
||||
|
||||
Согласие на обработку персональных данных при регистрации (сессия 30):
|
||||
- `users.consent_version` — редакция регламента (`instance_settings.consent_policy.version`),
|
||||
с которой согласился пользователь;
|
||||
- `users.consent_given_at` — момент согласия.
|
||||
|
||||
Оба поля nullable: у существующих пользователей и у зарегистрированных при
|
||||
выключенном модуле согласие не запрашивалось — `NULL` трактуется как
|
||||
«согласие не запрашивалось», вход таким пользователям не блокируется.
|
||||
|
||||
Revision ID: 4d08a44ad153
|
||||
Revises: d87681e12784
|
||||
Create Date: 2026-08-04 21:41:33.813206
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = '4d08a44ad153'
|
||||
down_revision: Union[str, Sequence[str], None] = 'd87681e12784'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
"""Upgrade schema."""
|
||||
op.add_column('users', sa.Column('consent_version', sa.Integer(), nullable=True))
|
||||
op.add_column(
|
||||
'users', sa.Column('consent_given_at', sa.DateTime(timezone=True), nullable=True)
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""Downgrade schema."""
|
||||
op.drop_column('users', 'consent_given_at')
|
||||
op.drop_column('users', 'consent_version')
|
||||
@@ -0,0 +1,61 @@
|
||||
"""user virtual background images
|
||||
|
||||
Свои картинки пользователя для замены фона видео (сессия 35):
|
||||
- таблица `user_backgrounds` — id, владелец (`ON DELETE CASCADE`), путь к файлу
|
||||
относительно `MEDIA_ROOT`, время загрузки.
|
||||
|
||||
Сами файлы лежат на диске в томе `media` (`backgrounds/{user_id}/{id}.{ext}`),
|
||||
в БД только путь — как у аватаров (`users.avatar_path`). Лимит «не более 10
|
||||
картинок на пользователя» — политика продукта, проверяется в
|
||||
`services/backgrounds.py`, а не ограничением БД.
|
||||
|
||||
Настройка отключаемого модуля (`instance_settings.virtual_background`)
|
||||
миграции не требует: `instance_settings` — key-value JSONB, новая настройка
|
||||
это новая строка (см. `services/instance_settings.py`).
|
||||
|
||||
Revision ID: a37c1b9e0f42
|
||||
Revises: 4d08a44ad153
|
||||
Create Date: 2026-08-09 23:30:00.000000
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.dialects import postgresql
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = 'a37c1b9e0f42'
|
||||
down_revision: Union[str, Sequence[str], None] = '4d08a44ad153'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
"""Upgrade schema."""
|
||||
op.create_table(
|
||||
'user_backgrounds',
|
||||
sa.Column(
|
||||
'id',
|
||||
postgresql.UUID(as_uuid=True),
|
||||
server_default=sa.text('gen_random_uuid()'),
|
||||
nullable=False,
|
||||
),
|
||||
sa.Column('user_id', postgresql.UUID(as_uuid=True), nullable=False),
|
||||
sa.Column('path', sa.String(length=512), nullable=False),
|
||||
sa.Column(
|
||||
'created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False
|
||||
),
|
||||
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ondelete='CASCADE'),
|
||||
sa.PrimaryKeyConstraint('id'),
|
||||
)
|
||||
op.create_index(
|
||||
'ix_user_backgrounds_user_created', 'user_backgrounds', ['user_id', 'created_at']
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""Downgrade schema."""
|
||||
op.drop_index('ix_user_backgrounds_user_created', table_name='user_backgrounds')
|
||||
op.drop_table('user_backgrounds')
|
||||
@@ -62,6 +62,7 @@ from services.email import EmailSendError, create_email_backend
|
||||
from services.instance_settings import (
|
||||
InstanceSettingsService,
|
||||
InvalidAiLevelError,
|
||||
InvalidConsentPolicyError,
|
||||
InvalidContactEmailError,
|
||||
InvalidEmailDomainError,
|
||||
InvalidTimezoneError,
|
||||
@@ -217,7 +218,7 @@ async def create_user(
|
||||
user = await repo.create(
|
||||
email=data.email,
|
||||
name_user=data.name_user,
|
||||
password_hash=hash_password(data.password),
|
||||
password_hash=await hash_password(data.password),
|
||||
team_id=data.team_id,
|
||||
)
|
||||
user.email_verified = True
|
||||
@@ -404,6 +405,7 @@ async def update_settings(
|
||||
InvalidTimezoneError,
|
||||
InvalidEmailDomainError,
|
||||
InvalidContactEmailError,
|
||||
InvalidConsentPolicyError,
|
||||
) as exc:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc
|
||||
queue_served = await anyio.to_thread.run_sync(transcription_queue_served)
|
||||
@@ -456,6 +458,7 @@ def _to_settings_out(cfg: InstanceConfig, *, transcription_queue_served: bool) -
|
||||
"""Собрать `SettingsOut` из эффективной конфигурации + доступность уровней AI."""
|
||||
return SettingsOut(
|
||||
chat_enabled=cfg.chat.enabled,
|
||||
hand_queue_enabled=cfg.hand_queue.enabled,
|
||||
transcription_enabled=cfg.transcriber.enabled,
|
||||
ai_level=cfg.ai_level,
|
||||
ai_levels=detect_ai_levels(cfg),
|
||||
@@ -467,6 +470,13 @@ 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,
|
||||
consent_required=cfg.consent_required,
|
||||
consent_policy_text=cfg.consent_policy_text,
|
||||
consent_policy_version=cfg.consent_policy_version,
|
||||
device_check_enabled=cfg.device_check_enabled,
|
||||
virtual_background_enabled=cfg.virtual_background_enabled,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -21,6 +21,7 @@ from schemas.auth import (
|
||||
)
|
||||
from services.auth import (
|
||||
AuthService,
|
||||
ConsentRequiredError,
|
||||
EmailAlreadyRegisteredError,
|
||||
EmailNotVerifiedError,
|
||||
InvalidCredentialsError,
|
||||
@@ -63,7 +64,12 @@ async def registration_options(
|
||||
teams = [RegistrationTeamOptionOut(id=team.id, name=team.name) for team in items]
|
||||
email_domains = cfg.registration_email_domains if cfg.registration_email_domain_enabled else []
|
||||
return RegistrationOptionsOut(
|
||||
team_choice_enabled=cfg.registration_team_choice, teams=teams, email_domains=email_domains
|
||||
team_choice_enabled=cfg.registration_team_choice,
|
||||
teams=teams,
|
||||
email_domains=email_domains,
|
||||
consent_required=cfg.consent_required,
|
||||
consent_text=cfg.consent_policy_text,
|
||||
consent_version=cfg.consent_policy_version,
|
||||
)
|
||||
|
||||
|
||||
@@ -78,6 +84,7 @@ async def register(
|
||||
name_user=data.name_user,
|
||||
password=data.password,
|
||||
team_id=data.team_id,
|
||||
consent_accepted=data.consent_accepted,
|
||||
)
|
||||
except EmailAlreadyRegisteredError as exc:
|
||||
raise HTTPException(
|
||||
@@ -91,6 +98,10 @@ async def register(
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST, detail="invalid_email_domain"
|
||||
) from exc
|
||||
except ConsentRequiredError as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST, detail="consent_required"
|
||||
) from exc
|
||||
|
||||
|
||||
@router.post("/verify-email", status_code=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
@@ -1,10 +1,17 @@
|
||||
"""WS-роутер текстового чата конференции: `WS /api/v1/conferences/{id}/chat`.
|
||||
"""WS-роутер комнаты конференции: `WS /api/v1/conferences/{id}/chat`.
|
||||
|
||||
Протокол: `connect` -> `accept()` -> клиент шлёт `{"type":"auth","token":...}`
|
||||
первым сообщением (таймаут 10 с; токен не query-параметр — не палим его в
|
||||
логах nginx) -> сервер проверяет тоггл `chat.enabled` и LiveKit-токен ->
|
||||
история последних 50 сообщений открытой сессии -> двунаправленный обмен
|
||||
`{"type":"message","text":...}` через Redis pub/sub (echo отправителю тоже).
|
||||
история последних 50 сообщений чата + текущая очередь поднятых рук ->
|
||||
двунаправленный обмен: `{"type":"message","text":...}` (чат, Redis pub/sub,
|
||||
echo отправителю тоже), `{"type":"raise_hand"}`/`{"type":"lower_hand"}`
|
||||
(очередь рук, задача B1 — состояние в Redis, см. `services/hand_queue.py`,
|
||||
НЕ в БД: это эфемерное состояние звонка, а не история). Название файла и
|
||||
эндпоинта («чат») оставлено как есть — эндпоинт исторически первый и
|
||||
единственный аутентифицированный WS комнаты, поэтому очередь рук едет по
|
||||
нему же, а не заводит отдельное соединение (дешевле: сервер уже держит
|
||||
это соединение на каждого участника).
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
@@ -13,7 +20,7 @@ import uuid
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Depends, WebSocket, WebSocketDisconnect
|
||||
from pydantic import ValidationError
|
||||
from pydantic import Field, TypeAdapter, ValidationError
|
||||
from redis.asyncio.client import PubSub
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
@@ -28,6 +35,8 @@ from schemas.chat import (
|
||||
ChatMessageIn,
|
||||
ChatMessageOut,
|
||||
)
|
||||
from schemas.room_events import LowerHandIn, RaiseHandIn
|
||||
from services import hand_queue
|
||||
from services.chat import ChatAuthError, ChatIdentity, ChatService, InvalidTokenError, chat_channel
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -37,6 +46,14 @@ router = APIRouter(prefix="/api/v1/conferences", tags=["chat"])
|
||||
# Таймаут ожидания первого (auth) сообщения клиента.
|
||||
AUTH_TIMEOUT_SECONDS = 10.0
|
||||
|
||||
# Дискриминированное объединение сообщений клиента ПОСЛЕ auth — по полю `type`.
|
||||
_ClientEnvelope = Annotated[
|
||||
ChatMessageIn | RaiseHandIn | LowerHandIn, Field(discriminator="type")
|
||||
]
|
||||
_client_envelope_adapter: TypeAdapter[ChatMessageIn | RaiseHandIn | LowerHandIn] = TypeAdapter(
|
||||
_ClientEnvelope
|
||||
)
|
||||
|
||||
|
||||
@router.websocket("/{conference_id}/chat")
|
||||
async def chat_websocket(
|
||||
@@ -44,7 +61,7 @@ async def chat_websocket(
|
||||
conference_id: uuid.UUID,
|
||||
session: Annotated[AsyncSession, Depends(get_session)],
|
||||
) -> None:
|
||||
"""WS-эндпоинт текстового чата конференции — единая аутентификация LiveKit-токеном."""
|
||||
"""WS-эндпоинт комнаты конференции — единая аутентификация LiveKit-токеном."""
|
||||
await websocket.accept()
|
||||
service = ChatService(session)
|
||||
|
||||
@@ -54,25 +71,55 @@ async def chat_websocket(
|
||||
except ChatAuthError as exc:
|
||||
await _close_quietly(websocket, exc.close_code)
|
||||
return
|
||||
hand_queue_enabled = await service.hand_queue_enabled()
|
||||
|
||||
pubsub = redis_client.pubsub()
|
||||
channel = chat_channel(conference.id)
|
||||
# Подписка ДО чтения истории: сообщение,
|
||||
# опубликованное другим клиентом в окне между SELECT истории и
|
||||
# subscribe, иначе теряется для подключающегося клиента — Redis начинает
|
||||
room_channel = hand_queue.hand_queue_channel(conference.id)
|
||||
# Подписка ДО чтения истории/снапшота очереди: событие,
|
||||
# опубликованное другим клиентом в окне между SELECT/HGETALL и subscribe,
|
||||
# иначе теряется для подключающегося клиента — Redis начинает
|
||||
# буферизовать входящие publish для этого соединения сразу после
|
||||
# subscribe, до первого вызова `get_message`. На стыке возможен дубликат
|
||||
# (то же сообщение и в history, и в первом pub/sub-сообщении) — безопаснее
|
||||
# дедуплицировать по `id`, чем потерять сообщение.
|
||||
await pubsub.subscribe(channel)
|
||||
# (то же сообщение чата и в history, и в первом pub/sub-сообщении) —
|
||||
# безопаснее дедуплицировать по `id`, чем потерять сообщение; снапшот
|
||||
# очереди дублировать безвредно (полная замена состояния на клиенте).
|
||||
await pubsub.subscribe(channel, room_channel)
|
||||
try:
|
||||
history = await service.history(conference)
|
||||
# 🔑 Вернуть соединение с БД в пул ДО входа в долгоживущие насосы.
|
||||
#
|
||||
# Хендшейк выше сделал несколько SELECT'ов (тоггл чата, конференция,
|
||||
# тоггл рук, история) — SQLAlchemy открыла транзакцию на первом же из
|
||||
# них и держала бы её, а с ней и соединение из пула, ВСЁ время жизни
|
||||
# WS: участник сидит в комнате час — час занято соединение. Пул это
|
||||
# `db_pool_size + db_max_overflow` на воркер (10 + 10), то есть
|
||||
# 40 на инстанс из двух воркеров, и сороковой вошедший выгребал его
|
||||
# досуха: `pg_stat_activity` показывал 40 соединений
|
||||
# `idle in transaction` при одном `active`, а посторонние ручки
|
||||
# (резолв, гостевой вход, логин, refresh) начинали падать в
|
||||
# `QueuePool limit ... timed out` и отдавать 500. Ровно это положило
|
||||
# вход на нагрузочном тесте 07.08.2026 при ~50 участниках.
|
||||
#
|
||||
# Соединение здесь больше не нужно: оба насоса ниже работают через
|
||||
# Redis, а единственная запись в БД (`persist_and_publish`) сама
|
||||
# открывает транзакцию и коммитит её, освобождая соединение сразу.
|
||||
# ⚠️ Любое чтение из БД, добавленное между этой строкой и концом
|
||||
# обработчика, обязано так же завершаться commit/rollback.
|
||||
await session.commit()
|
||||
await websocket.send_json(ChatHistoryOut(messages=history).model_dump(mode="json"))
|
||||
seen_ids = {item.id for item in history}
|
||||
|
||||
queue_out = await hand_queue.get_snapshot_out(conference.id)
|
||||
await websocket.send_json(queue_out.model_dump(mode="json"))
|
||||
|
||||
async with asyncio.TaskGroup() as tg:
|
||||
tg.create_task(_pump_pubsub_to_websocket(websocket, pubsub, seen_ids))
|
||||
tg.create_task(_pump_websocket_to_service(websocket, service, conference, identity))
|
||||
tg.create_task(_pump_pubsub_to_websocket(websocket, pubsub, channel, seen_ids))
|
||||
tg.create_task(
|
||||
_pump_websocket_to_service(
|
||||
websocket, service, conference, identity, hand_queue_enabled
|
||||
)
|
||||
)
|
||||
except* WebSocketDisconnect:
|
||||
# Штатное закрытие соединения клиентом — не ошибка.
|
||||
pass
|
||||
@@ -91,7 +138,7 @@ async def chat_websocket(
|
||||
finally:
|
||||
# Всегда отписываемся и закрываем pubsub-соединение, иначе при частых
|
||||
# обрывах соединений копятся забытые подписки на стороне Redis.
|
||||
await pubsub.unsubscribe(channel)
|
||||
await pubsub.unsubscribe(channel, room_channel)
|
||||
# `PubSub.aclose` в redis-py не аннотирован (untyped def) несмотря на
|
||||
# `py.typed` пакета — узкий игнор именно этого вызова.
|
||||
await pubsub.aclose() # type: ignore[no-untyped-call]
|
||||
@@ -111,37 +158,92 @@ async def _authenticate(websocket: WebSocket, service: ChatService) -> ChatIdent
|
||||
|
||||
|
||||
async def _pump_pubsub_to_websocket(
|
||||
websocket: WebSocket, pubsub: PubSub, seen_ids: set[int]
|
||||
websocket: WebSocket, pubsub: PubSub, chat_channel_name: str, seen_ids: set[int]
|
||||
) -> None:
|
||||
"""Читать сообщения Redis pub/sub канала чата и пересылать их подключённому клиенту.
|
||||
"""Читать оба Redis pub/sub канала комнаты (чат + очередь рук) и пересылать клиенту.
|
||||
|
||||
`seen_ids` — id сообщений, уже отправленных клиенту в `history` (на
|
||||
`seen_ids` — id сообщений чата, уже отправленных клиенту в `history` (на
|
||||
стыке подписки и SELECT истории возможен дубликат, см. докстринг
|
||||
`chat_websocket`) — такие сообщения не пересылаются повторно.
|
||||
`chat_websocket`) — такие сообщения не пересылаются повторно. Снапшоты
|
||||
очереди рук такой дедупликации не требуют (полная замена состояния).
|
||||
"""
|
||||
while True:
|
||||
raw = await pubsub.get_message(ignore_subscribe_messages=True, timeout=None)
|
||||
if raw is None:
|
||||
continue
|
||||
if raw["channel"] == chat_channel_name:
|
||||
message = ChatMessageOut.model_validate_json(raw["data"])
|
||||
if message.id in seen_ids:
|
||||
continue
|
||||
seen_ids.add(message.id)
|
||||
await websocket.send_json(ChatMessageEventOut(message=message).model_dump(mode="json"))
|
||||
await websocket.send_json(
|
||||
ChatMessageEventOut(message=message).model_dump(mode="json")
|
||||
)
|
||||
else:
|
||||
# Канал комнаты (`hand_queue.hand_queue_channel`) — уже готовый
|
||||
# JSON исходящего конверта (`HandQueueOut`/`ForcedMuteOut`,
|
||||
# см. `services/hand_queue.py::publish_snapshot` и эндпоинт мьюта
|
||||
# в `api/conferences.py`), пересылаем как есть без пересборки.
|
||||
await websocket.send_text(raw["data"])
|
||||
|
||||
|
||||
async def _pump_websocket_to_service(
|
||||
websocket: WebSocket, service: ChatService, conference: Conference, identity: ChatIdentity
|
||||
websocket: WebSocket,
|
||||
service: ChatService,
|
||||
conference: Conference,
|
||||
identity: ChatIdentity,
|
||||
hand_queue_enabled: bool,
|
||||
) -> None:
|
||||
"""Читать текстовые сообщения клиента, валидировать и сохранять+публиковать их."""
|
||||
"""Читать сообщения клиента (текст чата / поднять-опустить руку), валидировать и обработать.
|
||||
|
||||
`hand_queue_enabled` — снятый один раз при подключении тоггл модуля
|
||||
«поднятие руки» (см. `ChatService.hand_queue_enabled`): при `False`
|
||||
`raise_hand`/`lower_hand` отклоняются кодом `hand_queue_disabled` — вторая
|
||||
линия защиты сверх того, что фронт при выключенном модуле вообще не
|
||||
рисует кнопки (см. `RoomToolbar`/`HandQueueMenu`).
|
||||
"""
|
||||
is_organizer = conference.owner_id is not None and conference.owner_id == identity.user_id
|
||||
while True:
|
||||
raw = await websocket.receive_text()
|
||||
try:
|
||||
envelope = ChatMessageIn.model_validate_json(raw)
|
||||
envelope = _client_envelope_adapter.validate_json(raw)
|
||||
except ValidationError:
|
||||
await websocket.send_json(ChatErrorOut(code="invalid_message").model_dump(mode="json"))
|
||||
continue
|
||||
|
||||
if isinstance(envelope, ChatMessageIn):
|
||||
await service.persist_and_publish(conference, identity=identity, text=envelope.text)
|
||||
elif isinstance(envelope, RaiseHandIn):
|
||||
if not hand_queue_enabled:
|
||||
await websocket.send_json(
|
||||
ChatErrorOut(code="hand_queue_disabled").model_dump(mode="json")
|
||||
)
|
||||
continue
|
||||
await hand_queue.raise_hand(
|
||||
conference.id, identity=_identity_key(identity), name=identity.author_name
|
||||
)
|
||||
await hand_queue.publish_snapshot(conference.id)
|
||||
else:
|
||||
if not hand_queue_enabled:
|
||||
await websocket.send_json(
|
||||
ChatErrorOut(code="hand_queue_disabled").model_dump(mode="json")
|
||||
)
|
||||
continue
|
||||
target = envelope.identity or _identity_key(identity)
|
||||
if target != _identity_key(identity) and not is_organizer:
|
||||
await websocket.send_json(
|
||||
ChatErrorOut(code="forbidden").model_dump(mode="json")
|
||||
)
|
||||
continue
|
||||
await hand_queue.lower_hand(conference.id, identity=target)
|
||||
await hand_queue.publish_snapshot(conference.id)
|
||||
|
||||
|
||||
def _identity_key(identity: ChatIdentity) -> str:
|
||||
"""Identity участника в формате LiveKit/очереди рук — `str(user_id)` либо `guest:{id}`."""
|
||||
if identity.user_id is not None:
|
||||
return str(identity.user_id)
|
||||
return f"guest:{identity.guest_access_id}"
|
||||
|
||||
|
||||
async def _close_quietly(websocket: WebSocket, code: int) -> None:
|
||||
|
||||
@@ -9,7 +9,12 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from api.deps import get_current_user
|
||||
from core.db import get_session
|
||||
from core.rate_limit import enforce_rate_limit
|
||||
from core.rate_limit import (
|
||||
RATE_LIMIT_MISS_MAX_REQUESTS,
|
||||
RATE_LIMIT_SOFT_MAX_REQUESTS,
|
||||
client_ip,
|
||||
enforce_rate_limit,
|
||||
)
|
||||
from models.user import User
|
||||
from schemas.conferences import (
|
||||
ConferenceCreateIn,
|
||||
@@ -18,6 +23,8 @@ from schemas.conferences import (
|
||||
GuestJoinIn,
|
||||
JoinIn,
|
||||
JoinOut,
|
||||
MuteParticipantIn,
|
||||
MuteParticipantOut,
|
||||
OccurrenceOut,
|
||||
ResolveOut,
|
||||
)
|
||||
@@ -34,6 +41,7 @@ from services.conferences import (
|
||||
InviteeUserNotFoundError,
|
||||
NotConferenceOwnerError,
|
||||
)
|
||||
from services.room_control import ParticipantNotInRoomError
|
||||
|
||||
router = APIRouter(prefix="/api/v1/conferences", tags=["conferences"])
|
||||
|
||||
@@ -100,10 +108,18 @@ async def resolve_conference(
|
||||
п.4, уточнение резолва): вход в неё невозможен в любом случае (410 у
|
||||
join/guest-join), а признак закрытости неактуален для мёртвой конференции.
|
||||
"""
|
||||
await enforce_rate_limit(f"resolve:{_client_ip(request)}")
|
||||
ip = client_ip(request)
|
||||
# Мягкий потолок против флуда: успешные резолвы легитимны и массовы —
|
||||
# вся конференция открывает ссылку в одну минуту.
|
||||
await enforce_rate_limit(f"resolve:{ip}", max_requests=RATE_LIMIT_SOFT_MAX_REQUESTS)
|
||||
service = ConferenceService(session)
|
||||
conference = await service.resolve(q)
|
||||
if conference is None:
|
||||
# Жёсткий счётчик — только на промахи: перебор номера конференции
|
||||
# выглядит именно так (см. core/rate_limit.py и ADR-001, п.4).
|
||||
await enforce_rate_limit(
|
||||
f"resolve_miss:{ip}", max_requests=RATE_LIMIT_MISS_MAX_REQUESTS
|
||||
)
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="not_found")
|
||||
if conference.status == "ended":
|
||||
return ResolveOut(id=conference.id, title=conference.title, status=conference.status)
|
||||
@@ -151,11 +167,15 @@ async def guest_join_conference(
|
||||
session: Annotated[AsyncSession, Depends(get_session)],
|
||||
) -> JoinOut:
|
||||
"""Войти гостем: представиться (имя обязательно, email факультативен) — без auth, rate limit."""
|
||||
await enforce_rate_limit(f"guest_join:{_client_ip(request)}")
|
||||
ip = client_ip(request)
|
||||
# Мягкий потолок: успешный гостевой вход — обычное дело для всей
|
||||
# конференции сразу, ограничивать его числом «10 в минуту» нельзя.
|
||||
await enforce_rate_limit(f"guest_join:{ip}", max_requests=RATE_LIMIT_SOFT_MAX_REQUESTS)
|
||||
service = ConferenceService(session)
|
||||
try:
|
||||
return await service.join_as_guest(conference_id, data=data)
|
||||
except ConferenceNotFoundError as exc:
|
||||
await _count_guest_join_miss(ip)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND, detail="conference_not_found"
|
||||
) from exc
|
||||
@@ -166,11 +186,45 @@ async def guest_join_conference(
|
||||
status_code=status.HTTP_403_FORBIDDEN, detail="password_required"
|
||||
) from exc
|
||||
except InvalidPasswordError as exc:
|
||||
# Подбор пароля закрытой конференции — тот же класс атаки, что и
|
||||
# перебор номера, поэтому считается жёстким счётчиком.
|
||||
await _count_guest_join_miss(ip)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN, detail="invalid_password"
|
||||
) from exc
|
||||
|
||||
|
||||
@router.post("/{conference_id}/mute-participant", response_model=MuteParticipantOut)
|
||||
async def mute_participant(
|
||||
conference_id: uuid.UUID,
|
||||
data: MuteParticipantIn,
|
||||
user: Annotated[User, Depends(get_current_user)],
|
||||
session: Annotated[AsyncSession, Depends(get_session)],
|
||||
) -> MuteParticipantOut:
|
||||
"""Принудительно выключить микрофон/камеру участника (задача B2) — владелец/администратор.
|
||||
|
||||
Права проверяются ЗАНОВО по владельцу конференции в БД
|
||||
(`ConferenceService.mute_participant`), а не по метаданным LiveKit-токена
|
||||
вызывающего — те лишь подсказка для UI и потенциально подделываемы клиентом.
|
||||
"""
|
||||
service = ConferenceService(session)
|
||||
try:
|
||||
muted = await service.mute_participant(
|
||||
conference_id, actor=user, target_identity=data.identity, source=data.source
|
||||
)
|
||||
except ConferenceNotFoundError as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND, detail="conference_not_found"
|
||||
) from exc
|
||||
except NotConferenceOwnerError as exc:
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="not_owner") from exc
|
||||
except ParticipantNotInRoomError as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND, detail="participant_not_in_room"
|
||||
) from exc
|
||||
return MuteParticipantOut(muted=muted)
|
||||
|
||||
|
||||
@router.get("/{conference_id}", response_model=ConferenceOut)
|
||||
async def get_conference(
|
||||
conference_id: uuid.UUID,
|
||||
@@ -250,6 +304,11 @@ def _require_utc(value: datetime) -> datetime:
|
||||
return value.astimezone(UTC)
|
||||
|
||||
|
||||
def _client_ip(request: Request) -> str:
|
||||
"""IP-адрес клиента для rate limit (без auth — ключ по IP, а не по пользователю)."""
|
||||
return request.client.host if request.client else "unknown"
|
||||
async def _count_guest_join_miss(ip: str) -> None:
|
||||
"""Учесть неудачную попытку гостевого входа в жёстком счётчике.
|
||||
|
||||
Вынесено отдельно, потому что вызывается из двух веток обработки ошибок
|
||||
(несуществующая конференция и неверный пароль) и обязано бросать 429
|
||||
ровно так же, как обычный `enforce_rate_limit`.
|
||||
"""
|
||||
await enforce_rate_limit(f"guest_join_miss:{ip}", max_requests=RATE_LIMIT_MISS_MAX_REQUESTS)
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import logging
|
||||
from typing import Annotated, Any, cast
|
||||
|
||||
from fastapi import APIRouter, Depends, Header, HTTPException, Request, status
|
||||
from fastapi import APIRouter, BackgroundTasks, Depends, Header, HTTPException, Request, status
|
||||
from livekit import api
|
||||
from sqlalchemy import CursorResult
|
||||
from sqlalchemy.dialects.postgresql import insert as pg_insert
|
||||
@@ -22,6 +22,7 @@ router = APIRouter(prefix="/api/v1/livekit", tags=["livekit"])
|
||||
@router.post("/webhook")
|
||||
async def receive_webhook(
|
||||
request: Request,
|
||||
background: BackgroundTasks,
|
||||
session: Annotated[AsyncSession, Depends(get_session)],
|
||||
authorization: Annotated[str | None, Header()] = None,
|
||||
) -> dict[str, str]:
|
||||
@@ -30,6 +31,11 @@ async def receive_webhook(
|
||||
Дедупликация по `event.id`: `INSERT ... ON CONFLICT DO NOTHING` в
|
||||
`livekit_webhook_events` в одной транзакции с эффектами обработчика —
|
||||
при конфликте (дубль) эффекты пропускаются, но ответ всё равно 200.
|
||||
|
||||
Всё, что требует сети (запуск Track Egress), уходит в `background` и
|
||||
выполняется уже после ответа: обработчик держит соединение с БД и
|
||||
открытую транзакцию, а LiveKit при медленном ответе копит очередь
|
||||
доставки и в итоге дропает события (см. `services.egress.run_track_egress`).
|
||||
"""
|
||||
settings = get_settings()
|
||||
raw_body = await request.body()
|
||||
@@ -57,7 +63,7 @@ async def receive_webhook(
|
||||
await session.commit()
|
||||
return {"status": "duplicate"}
|
||||
|
||||
dispatcher = WebhookDispatcher(session)
|
||||
dispatcher = WebhookDispatcher(session, schedule=background.add_task)
|
||||
await dispatcher.dispatch(event)
|
||||
await session.commit()
|
||||
return {"status": "ok"}
|
||||
|
||||
@@ -1,17 +1,35 @@
|
||||
"""Метрики Prometheus: латентность HTTP + gauge'и пайплайна и очередей.
|
||||
"""Метрики Prometheus: латентность HTTP + gauge'и пайплайна, очередей, БД и железа.
|
||||
|
||||
`GET /metrics` — без авторизации (снаружи закрывается на уровне nginx, вне
|
||||
периметра backend, см. `docs/deploy/scaling.md`/monitoring-часть devops):
|
||||
Prometheus-серверы традиционно ходят напрямую в контейнер по внутренней
|
||||
сети, а не через публичный `/api/`-гейтвей.
|
||||
|
||||
Gauge'и `vidconf_pipeline_sessions`/`vidconf_celery_queue_depth` намеренно
|
||||
НЕ обновляются фоновой задачей — значения пересчитываются прямо в обработчике
|
||||
запроса при каждом scrape (см. докстринг `metrics_endpoint`), поэтому их
|
||||
асинхронные источники (БД, Redis) можно опросить обычным `await` вместо
|
||||
реализации синхронного `prometheus_client.registry.Collector`.
|
||||
Gauge'и `vidconf_pipeline_sessions`/`vidconf_celery_queue_depth`/
|
||||
`vidconf_host_info` намеренно НЕ обновляются фоновой задачей — значения
|
||||
пересчитываются прямо в обработчике запроса при каждом scrape (см.
|
||||
докстринг `metrics_endpoint`), поэтому их асинхронные источники (БД,
|
||||
Redis) можно опросить обычным `await` вместо реализации синхронного
|
||||
`prometheus_client.registry.Collector` (у `vidconf_host_info` источник
|
||||
и вовсе синхронный — настройки уже в памяти процесса).
|
||||
|
||||
🔴 Метрики о состоянии основного пула БД (`vidconf_db_up`,
|
||||
`vidconf_db_pool_*`, `vidconf_pipeline_sessions`) обязаны читаться БЕЗ
|
||||
обращения к самому пулу — иначе в момент его исчерпания (см.
|
||||
`.forcc/session-results/32-loadtest-07-08-debug.md`) эндпоинт метрик падал бы
|
||||
вместе со всем остальным ровно тогда, когда нужнее всего. `vidconf_db_pool_*`
|
||||
— синхронный снимок `engine.pool` (см. `core/db.py::db_pool_stats`),
|
||||
`vidconf_db_up` — отдельное соединение вне основного пула
|
||||
(`core/db.py::check_db_up`). `_refresh_pipeline_sessions_gauge` с сессии 37
|
||||
тоже читает через отдельный движок (`Depends(get_metrics_session)`,
|
||||
`core/db.py`) — тестовый харнесс подменяет её на savepoint-сессию теста так
|
||||
же, как `get_session` (см. `tests/conftest.py`). До сессии 37 она ходила
|
||||
через основной пул (`Depends(get_session)`) — прецедент `f7c4fb4`/session 32;
|
||||
дополнительная обёртка таймаутом и try/except ниже осталась как вторая
|
||||
линия обороны на случай, если сама БД (а не пул) не отвечает.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import time
|
||||
from collections.abc import Awaitable, Callable
|
||||
|
||||
@@ -20,7 +38,8 @@ from prometheus_client import CONTENT_TYPE_LATEST, Gauge, Histogram, generate_la
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from starlette.routing import Match
|
||||
|
||||
from core.db import get_session
|
||||
from core.config import get_settings
|
||||
from core.db import check_db_up, db_pool_checked_out, get_metrics_session
|
||||
from core.redis import redis_client
|
||||
from models.session import PIPELINE_STATUSES
|
||||
from repositories.conferences import ConferenceSessionRepository
|
||||
@@ -77,9 +96,30 @@ PIPELINE_SESSIONS = Gauge(
|
||||
)
|
||||
|
||||
|
||||
# Сколько ждать основной пул под этой конкретной метрикой, прежде чем
|
||||
# сдаться и оставить прежнее значение gauge. Меньше `db_pool_timeout` (10с,
|
||||
# `core/config.py`) — Prometheus скрейпит раз в 15с, и эта метрика не должна
|
||||
# в одиночку съедать бюджет всего окна scrape.
|
||||
_PIPELINE_GAUGE_TIMEOUT_S = 2.0
|
||||
|
||||
|
||||
async def _refresh_pipeline_sessions_gauge(session: AsyncSession) -> None:
|
||||
"""Пересчитать `vidconf_pipeline_sessions` по всем статусам `pipeline_status`."""
|
||||
counts = await ConferenceSessionRepository(session).count_by_pipeline_status()
|
||||
"""Пересчитать `vidconf_pipeline_sessions` по всем статусам `pipeline_status`.
|
||||
|
||||
`session` — из `Depends(get_metrics_session)` (отдельный от основного
|
||||
пула движок, см. докстринг модуля и `core/db.py`). Таймаут и try/except
|
||||
ниже — вторая линия обороны на случай, если недоступна сама БД (а не
|
||||
только основной пул): запрос не должен держать весь `/metrics`, gauge
|
||||
остаётся на прежнем значении (не обнуляется — обнулять его при
|
||||
недоступности БД так же неверно, как считать сеансы пропавшими).
|
||||
"""
|
||||
try:
|
||||
counts = await asyncio.wait_for(
|
||||
ConferenceSessionRepository(session).count_by_pipeline_status(),
|
||||
timeout=_PIPELINE_GAUGE_TIMEOUT_S,
|
||||
)
|
||||
except Exception: # noqa: BLE001
|
||||
return
|
||||
for status in PIPELINE_STATUSES:
|
||||
PIPELINE_SESSIONS.labels(status=status).set(counts.get(status, 0))
|
||||
|
||||
@@ -110,8 +150,108 @@ async def _refresh_celery_queue_depth_gauge() -> None:
|
||||
CELERY_QUEUE_DEPTH.labels(queue=queue).set(depth)
|
||||
|
||||
|
||||
# --- Доступность БД и занятость основного пула (сессия 33) -----------------
|
||||
#
|
||||
# Ранний сигнал важнее самого факта отказа: в инциденте 07.08 пул заполнялся
|
||||
# постепенно (`idle in transaction` 3→8→16→26→35→39→40 участников) —
|
||||
# `vidconf_db_pool_checked_out` показал бы это задолго до первого 500.
|
||||
# Обе метрики читаются без обращения к основному пулу (см. докстринг модуля
|
||||
# и `core/db.py`), поэтому доступны и в момент, когда сам пул исчерпан.
|
||||
|
||||
DB_UP = Gauge(
|
||||
"vidconf_db_up",
|
||||
"Доступность БД (1/0) — проверяется отдельным соединением вне основного пула",
|
||||
)
|
||||
|
||||
DB_POOL_SIZE = Gauge(
|
||||
"vidconf_db_pool_size",
|
||||
"Настроенный размер основного пула БД без overflow (db_pool_size)",
|
||||
)
|
||||
DB_POOL_MAX_OVERFLOW = Gauge(
|
||||
"vidconf_db_pool_max_overflow",
|
||||
"Настроенный максимум overflow-соединений сверх db_pool_size (db_max_overflow)",
|
||||
)
|
||||
DB_POOL_CHECKED_OUT = Gauge(
|
||||
"vidconf_db_pool_checked_out",
|
||||
"Число соединений основного пула БД, занятых прямо сейчас (в пуле + overflow)",
|
||||
)
|
||||
|
||||
|
||||
async def _refresh_db_up_gauge() -> None:
|
||||
"""Пересчитать `vidconf_db_up` отдельным от основного пула соединением."""
|
||||
DB_UP.set(1 if await check_db_up() else 0)
|
||||
|
||||
|
||||
def _refresh_db_pool_gauges() -> None:
|
||||
"""Пересчитать gauge'и занятости основного пула — синхронно, без I/O."""
|
||||
settings = get_settings()
|
||||
DB_POOL_SIZE.set(settings.db_pool_size)
|
||||
DB_POOL_MAX_OVERFLOW.set(settings.db_max_overflow)
|
||||
DB_POOL_CHECKED_OUT.set(db_pool_checked_out())
|
||||
|
||||
|
||||
# --- Занятость пула Redis (сессия 33, второй потолок из session 32) --------
|
||||
#
|
||||
# Тот же класс отказа, что и у пула БД: каждое WS-подключение комнаты держит
|
||||
# pub/sub-соединение всё время, пока участник в конференции (`core/redis.py`,
|
||||
# `redis_max_connections`). Снимок — синхронный (атрибуты пула в памяти
|
||||
# процесса redis-py), Redis для этого спрашивать не нужно.
|
||||
|
||||
REDIS_POOL_IN_USE = Gauge(
|
||||
"vidconf_redis_pool_in_use",
|
||||
"Число занятых соединений пула Redis прямо сейчас",
|
||||
)
|
||||
REDIS_POOL_MAX = Gauge(
|
||||
"vidconf_redis_pool_max_connections",
|
||||
"Настроенный максимум соединений пула Redis (redis_max_connections)",
|
||||
)
|
||||
|
||||
|
||||
def _refresh_redis_pool_gauges() -> None:
|
||||
"""Пересчитать gauge'и занятости пула Redis — синхронно, без I/O."""
|
||||
pool = redis_client.connection_pool
|
||||
REDIS_POOL_IN_USE.set(len(pool._in_use_connections)) # noqa: SLF001
|
||||
REDIS_POOL_MAX.set(pool.max_connections)
|
||||
|
||||
|
||||
# --- Info-метрика обнаруженного железа (install.sh, ADR-004) ---------------
|
||||
|
||||
HOST_INFO = Gauge(
|
||||
"vidconf_host_info",
|
||||
"Обнаруженное установщиком железо (info-метрика, значение всегда 1, данные в лейблах)",
|
||||
labelnames=("cpus", "ram_mb", "gpu_name", "vram_mb"),
|
||||
)
|
||||
|
||||
|
||||
def _hw_label(value: int | None) -> str:
|
||||
"""`None` (install.sh не запускался либо GPU не обнаружен) → «—», не пустая строка.
|
||||
|
||||
Пустой лейбл Grafana отрисовала бы как пустой текст на панели —
|
||||
прочерк однозначно читается как «не определено».
|
||||
"""
|
||||
return str(value) if value is not None else "—"
|
||||
|
||||
|
||||
def _refresh_host_info_gauge() -> None:
|
||||
"""Пересчитать `vidconf_host_info` по текущим `HW_*` настройкам (`core/config.py`).
|
||||
|
||||
Источник — `install.sh`, который пишет `HW_CPUS`/`HW_RAM_MB`/
|
||||
`HW_GPU_NAME`/`HW_VRAM_MB` в `.env` при установке (ADR-004). Живые
|
||||
CPU/RAM/диск хоста в дашборде «Хост и контейнеры» берутся из
|
||||
node-exporter напрямую — здесь только то, чего node-exporter не
|
||||
знает (GPU), плюс дублирование CPU/RAM install.sh для сверки.
|
||||
"""
|
||||
settings = get_settings()
|
||||
HOST_INFO.labels(
|
||||
cpus=_hw_label(settings.hw_cpus),
|
||||
ram_mb=_hw_label(settings.hw_ram_mb),
|
||||
gpu_name=settings.hw_gpu_name or "—",
|
||||
vram_mb=_hw_label(settings.hw_vram_mb),
|
||||
).set(1)
|
||||
|
||||
|
||||
@router.get("/metrics")
|
||||
async def metrics_endpoint(session: AsyncSession = Depends(get_session)) -> Response:
|
||||
async def metrics_endpoint(session: AsyncSession = Depends(get_metrics_session)) -> Response:
|
||||
"""Отдать метрики Prometheus в формате text exposition.
|
||||
|
||||
Gauge'и пересчитываются прямо здесь (а не по расписанию/периодическим
|
||||
@@ -119,7 +259,17 @@ async def metrics_endpoint(session: AsyncSession = Depends(get_session)) -> Resp
|
||||
ценой одного SELECT (группировка по `pipeline_status`) и `LLEN` на
|
||||
каждую из 4 отслеживаемых очередей per запрос — Prometheus скрейпит
|
||||
редко (обычно раз в 15–30с), нагрузка пренебрежимо мала.
|
||||
|
||||
Порядок важен: метрики о состоянии основного пула БД (`_refresh_db_up_gauge`,
|
||||
`_refresh_db_pool_gauges`) считаются первыми — они гарантированно попадут в
|
||||
ответ, даже если следующая за ними `_refresh_pipeline_sessions_gauge`
|
||||
(отдельный движок, но всё ещё сама БД) зависнет или упадёт (таймаут/
|
||||
try-except внутри неё гасят это, не роняя остальные метрики).
|
||||
"""
|
||||
await _refresh_db_up_gauge()
|
||||
_refresh_db_pool_gauges()
|
||||
_refresh_redis_pool_gauges()
|
||||
await _refresh_pipeline_sessions_gauge(session)
|
||||
await _refresh_celery_queue_depth_gauge()
|
||||
_refresh_host_info_gauge()
|
||||
return Response(content=generate_latest(), media_type=CONTENT_TYPE_LATEST)
|
||||
|
||||
24
backend/api/public.py
Normal file
24
backend/api/public.py
Normal file
@@ -0,0 +1,24 @@
|
||||
"""Роутер публичных настроек клиента — доступен без аутентификации."""
|
||||
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from core.db import get_session
|
||||
from schemas.public import PublicSettingsOut
|
||||
from services.instance_settings import InstanceSettingsService
|
||||
|
||||
router = APIRouter(prefix="/api/v1/public", tags=["public"])
|
||||
|
||||
|
||||
@router.get("/settings", response_model=PublicSettingsOut)
|
||||
async def public_settings(
|
||||
session: Annotated[AsyncSession, Depends(get_session)],
|
||||
) -> PublicSettingsOut:
|
||||
"""Флаги инстанса, нужные публичным страницам логина/входа гостя до аутентификации."""
|
||||
cfg = await InstanceSettingsService(session).get()
|
||||
return PublicSettingsOut(
|
||||
device_check_enabled=cfg.device_check_enabled,
|
||||
virtual_background_enabled=cfg.virtual_background_enabled,
|
||||
)
|
||||
@@ -1,5 +1,6 @@
|
||||
"""Роутер профиля текущего пользователя, аватара и списка пользователей."""
|
||||
"""Роутер профиля текущего пользователя, аватара, картинок фона и списка пользователей."""
|
||||
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
from typing import Annotated
|
||||
|
||||
@@ -11,9 +12,27 @@ from core.config import get_settings
|
||||
from core.db import get_session
|
||||
from core.security import hash_password, verify_password
|
||||
from models.user import User
|
||||
from models.user_background import UserBackground
|
||||
from repositories.users import UserRepository
|
||||
from schemas.auth import PasswordChangeIn, ProfileUpdateIn, UserListItemOut, UserProfileOut
|
||||
from schemas.auth import (
|
||||
PasswordChangeIn,
|
||||
ProfileUpdateIn,
|
||||
UserBackgroundOut,
|
||||
UserBackgroundsOut,
|
||||
UserListItemOut,
|
||||
UserProfileOut,
|
||||
)
|
||||
from services.avatars import AvatarInvalidTypeError, AvatarTooLargeError, avatar_url
|
||||
from services.backgrounds import (
|
||||
MAX_BACKGROUNDS_PER_USER,
|
||||
BackgroundInvalidTypeError,
|
||||
BackgroundLimitReachedError,
|
||||
BackgroundTooLargeError,
|
||||
add_background,
|
||||
background_url,
|
||||
delete_background,
|
||||
list_backgrounds,
|
||||
)
|
||||
from services.profile import (
|
||||
TeamNotFoundError,
|
||||
clear_avatar,
|
||||
@@ -89,6 +108,60 @@ async def delete_current_user_avatar(
|
||||
await session.commit()
|
||||
|
||||
|
||||
@router.get("/me/backgrounds", response_model=UserBackgroundsOut)
|
||||
async def list_current_user_backgrounds(
|
||||
user: Annotated[User, Depends(get_current_user)],
|
||||
session: Annotated[AsyncSession, Depends(get_session)],
|
||||
) -> UserBackgroundsOut:
|
||||
"""Свои картинки для замены фона видео + лимит на их число."""
|
||||
items = await list_backgrounds(session, user.id)
|
||||
return _to_backgrounds_out(items)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/me/backgrounds", response_model=UserBackgroundsOut, status_code=status.HTTP_201_CREATED
|
||||
)
|
||||
async def upload_current_user_background(
|
||||
user: Annotated[User, Depends(get_current_user)],
|
||||
session: Annotated[AsyncSession, Depends(get_session)],
|
||||
file: Annotated[UploadFile, File()],
|
||||
) -> UserBackgroundsOut:
|
||||
"""Загрузить свою картинку фона (jpeg/png/webp, до 2 МБ, не более 10 штук).
|
||||
|
||||
Возвращает весь список заново, а не одну добавленную запись: интерфейсу всё
|
||||
равно нужен свежий список с актуальным остатком лимита, и лишний GET следом
|
||||
за POST не нужен.
|
||||
"""
|
||||
try:
|
||||
await add_background(session, _media_root(), user.id, file)
|
||||
except BackgroundLimitReachedError as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT, detail="background_limit_reached"
|
||||
) from exc
|
||||
except BackgroundTooLargeError as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_413_CONTENT_TOO_LARGE, detail="background_too_large"
|
||||
) from exc
|
||||
except BackgroundInvalidTypeError as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_415_UNSUPPORTED_MEDIA_TYPE, detail="background_invalid_type"
|
||||
) from exc
|
||||
await session.commit()
|
||||
return _to_backgrounds_out(await list_backgrounds(session, user.id))
|
||||
|
||||
|
||||
@router.delete("/me/backgrounds/{background_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
async def delete_current_user_background(
|
||||
background_id: uuid.UUID,
|
||||
user: Annotated[User, Depends(get_current_user)],
|
||||
session: Annotated[AsyncSession, Depends(get_session)],
|
||||
) -> None:
|
||||
"""Удалить свою картинку фона вместе с файлом на диске; чужую — 404."""
|
||||
if not await delete_background(session, _media_root(), user.id, background_id):
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="background_not_found")
|
||||
await session.commit()
|
||||
|
||||
|
||||
@router.post("/me/password", status_code=status.HTTP_204_NO_CONTENT)
|
||||
async def change_current_user_password(
|
||||
data: PasswordChangeIn,
|
||||
@@ -101,11 +174,11 @@ async def change_current_user_password(
|
||||
вместе со сбросом пароля по email (v0.1.0, см. ADR-005
|
||||
`docs/architecture/adr/005-password-reset-deferred.md`).
|
||||
"""
|
||||
if not verify_password(data.current_password, user.password_hash):
|
||||
if not await verify_password(data.current_password, user.password_hash):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST, detail="invalid_current_password"
|
||||
)
|
||||
user.password_hash = hash_password(data.new_password)
|
||||
user.password_hash = await hash_password(data.new_password)
|
||||
await session.commit()
|
||||
|
||||
|
||||
@@ -126,6 +199,14 @@ async def list_users(
|
||||
]
|
||||
|
||||
|
||||
def _to_backgrounds_out(items: list[UserBackground]) -> UserBackgroundsOut:
|
||||
"""Собрать ответ списка картинок фона: id + публичный URL, плюс лимит с сервера."""
|
||||
return UserBackgroundsOut(
|
||||
items=[UserBackgroundOut(id=item.id, url=background_url(item.path)) for item in items],
|
||||
limit=MAX_BACKGROUNDS_PER_USER,
|
||||
)
|
||||
|
||||
|
||||
def _media_root() -> Path:
|
||||
"""Каталог загруженных медиа-файлов (см. `core/config.py::Settings.media_root`)."""
|
||||
return Path(get_settings().media_root)
|
||||
|
||||
@@ -20,6 +20,45 @@ class Settings(BaseSettings):
|
||||
redis_url: str = "redis://localhost:6379/0"
|
||||
plugins_config_path: str = "../config/plugins.yaml"
|
||||
|
||||
# --- Пул соединений с БД ---
|
||||
# Дефолт SQLAlchemy (5 + 10) на нагрузочном тесте 28.07.2026 выгребался
|
||||
# за секунды: 226 ошибок `QueuePool limit of size 5 overflow 10 reached`
|
||||
# и 37 ответов 500 на путях входа в конференцию.
|
||||
#
|
||||
# ⚠️ Бюджет соединений считается на ВЕСЬ инстанс, а не на процесс: каждый
|
||||
# воркер uvicorn (`UVICORN_WORKERS`) держит собственный пул, плюс
|
||||
# соединения нужны Celery-воркерам и alembic при миграциях. При
|
||||
# `max_connections=100` у Postgres и двух воркерах 2 × (10 + 10) = 40
|
||||
# оставляет запас. Поднимая значения на более крупном сервере, поднимайте
|
||||
# и `max_connections` — иначе вместо понятной ошибки приложения получите
|
||||
# отказ Postgres, который диагностируется куда хуже.
|
||||
db_pool_size: int = 10
|
||||
db_max_overflow: int = 10
|
||||
# 10 секунд вместо дефолтных 30 — сознательно: пусть запрос падает быстро
|
||||
# и показывает проблему, а не висит полминуты, делая вид, что всё живо.
|
||||
db_pool_timeout: int = 10
|
||||
|
||||
# --- Проверка доступности БД вне основного пула (`core/db.py::check_db_up`) ---
|
||||
# Таймаут TCP/auth отдельного соединения-пробы (не путать с
|
||||
# `db_pool_timeout` выше — тот про очередь на основной пул). Дефолт
|
||||
# asyncpg — 60с, для сигнала мониторинга это неприемлемо долго: пусть
|
||||
# `vidconf_db_up` станет 0 за секунды, а не через минуту.
|
||||
db_probe_timeout_s: float = 3.0
|
||||
|
||||
# --- Пул соединений с Redis ---
|
||||
# Считается по УЧАСТНИКАМ, а не по запросам: каждое WS-подключение комнаты
|
||||
# (`api/chat.py`) держит собственное pub/sub-соединение всё время, пока
|
||||
# человек сидит в конференции, — и берёт его из этого же пула, что и
|
||||
# обычные команды. redis-py 8 поставил дефолт `max_connections=100`
|
||||
# (раньше предел был условно бесконечным), поэтому сотый участник на
|
||||
# воркер выгребал пул досуха и WS падал уже на `hgetall` очереди рук —
|
||||
# воспроизведено локально при 99 одновременных подключениях.
|
||||
# 500 — с запасом на инстанс, рассчитанный на пару сотен участников
|
||||
# на воркер; соединения создаются по мере надобности, само по себе
|
||||
# значение ничего не стоит. Потолок сверху — `maxclients` у Redis
|
||||
# (дефолт 10000) на ВСЕ процессы вместе, включая Celery-воркеры.
|
||||
redis_max_connections: int = 500
|
||||
|
||||
# --- Версия инстанса (релиз v0.0.1) ---
|
||||
# install.sh копирует значение из файла `VERSION` (корень репозитория) в
|
||||
# `.env` при каждой установке/обновлении — здесь только чтение готового
|
||||
@@ -59,6 +98,13 @@ class Settings(BaseSettings):
|
||||
# (см. `deploy/docker-compose.yml`); в тестах переопределяется на `tmp_path`.
|
||||
recordings_dir: str = "/recordings"
|
||||
|
||||
# Таймаут запуска Track Egress. Когда egress-сервиса в деплое нет (профиль
|
||||
# `transcribe` не поднят), LiveKit ждёт ответа воркера через Redis до
|
||||
# собственного таймаута psrpc — на тесте 28.07.2026 это давало по 20–25
|
||||
# секунд на каждый вызов. Ждать столько бессмысленно: если egress жив, он
|
||||
# отвечает за доли секунды.
|
||||
egress_start_timeout_s: float = 3.0
|
||||
|
||||
# --- Email (SMTP-бэкенд) ---
|
||||
# `console` — дефолт для dev (письмо только логируется); `smtp` — реальная
|
||||
# отправка через aiosmtplib. Секреты SMTP — только в `.env` (инвариант №6),
|
||||
|
||||
@@ -1,19 +1,31 @@
|
||||
"""Настройка асинхронного движка SQLAlchemy и сеанса."""
|
||||
|
||||
from collections.abc import AsyncGenerator
|
||||
from typing import cast
|
||||
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.ext.asyncio import (
|
||||
AsyncEngine,
|
||||
AsyncSession,
|
||||
async_sessionmaker,
|
||||
create_async_engine,
|
||||
)
|
||||
from sqlalchemy.pool import NullPool, QueuePool
|
||||
|
||||
from core.config import get_settings
|
||||
|
||||
settings = get_settings()
|
||||
|
||||
engine: AsyncEngine = create_async_engine(settings.database_url, pool_pre_ping=True)
|
||||
engine: AsyncEngine = create_async_engine(
|
||||
settings.database_url,
|
||||
pool_pre_ping=True,
|
||||
# Параметры пула — в настройках (`core/config.py`, там же расчёт бюджета
|
||||
# соединений на инстанс). Дефолт SQLAlchemy 5 + 10 под нагрузкой
|
||||
# выгребался за секунды.
|
||||
pool_size=settings.db_pool_size,
|
||||
max_overflow=settings.db_max_overflow,
|
||||
pool_timeout=settings.db_pool_timeout,
|
||||
)
|
||||
|
||||
async_session_maker = async_sessionmaker(engine, expire_on_commit=False)
|
||||
|
||||
@@ -22,3 +34,77 @@ async def get_session() -> AsyncGenerator[AsyncSession, None]:
|
||||
"""Зависимость FastAPI, возвращающая `AsyncSession`."""
|
||||
async with async_session_maker() as session:
|
||||
yield session
|
||||
|
||||
|
||||
# --- Проверка доступности БД вне основного пула (сессия 33) ----------------
|
||||
#
|
||||
# Отдельный движок с `NullPool`: каждый вызов открывает новое соединение и
|
||||
# закрывает его сразу после — бюджет соединений не пересекается с
|
||||
# `engine.pool` (10 + 10 overflow × число воркеров uvicorn). Это единственный
|
||||
# способ отличить «БД лежит» от «основной пул занят под нагрузкой»: проверка
|
||||
# через `get_session()` в момент исчерпания пула сама встала бы в очередь на
|
||||
# `db_pool_timeout` и не смогла бы ответить, пока не появится случайно
|
||||
# освободившееся место — то есть не отличила бы два принципиально разных
|
||||
# состояния. Короткий `timeout` на соединение (не путать с `db_pool_timeout`
|
||||
# основного пула) — чтобы зависший, а не оборванный TCP (Postgres отвечает,
|
||||
# но не может продвинуться) не держал проверку до дефолтных 60 секунд asyncpg.
|
||||
_probe_engine: AsyncEngine = create_async_engine(
|
||||
settings.database_url,
|
||||
poolclass=NullPool,
|
||||
connect_args={"timeout": settings.db_probe_timeout_s},
|
||||
)
|
||||
|
||||
|
||||
async def check_db_up() -> bool:
|
||||
"""`True`, если БД отвечает на `SELECT 1` по отдельному от основного пула соединению."""
|
||||
try:
|
||||
async with _probe_engine.connect() as connection:
|
||||
await connection.execute(text("SELECT 1"))
|
||||
except Exception: # noqa: BLE001
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
# --- Сессия для метрик, читающих данные (не только «жив/мёртв»), вне основного
|
||||
# пула (сессия 37, доделка session 32/33 — см. докстринг `api/metrics.py`) ----
|
||||
#
|
||||
# `check_db_up` выше обходится голым соединением ("SELECT 1"), но
|
||||
# `vidconf_pipeline_sessions` нужна полноценная ORM-сессия (репозиторий,
|
||||
# группировка по статусу) — `NullPool`, как и у `_probe_engine`: каждый вызов
|
||||
# открывает новое соединение и сразу закрывает его, бюджет основного пула
|
||||
# (`engine.pool`) не расходуется. `async_sessionmaker` — тот же паттерн, что
|
||||
# `async_session_maker` выше, просто на другом движке.
|
||||
_metrics_probe_engine: AsyncEngine = create_async_engine(
|
||||
settings.database_url,
|
||||
poolclass=NullPool,
|
||||
connect_args={"timeout": settings.db_probe_timeout_s},
|
||||
)
|
||||
_metrics_session_maker = async_sessionmaker(_metrics_probe_engine, expire_on_commit=False)
|
||||
|
||||
|
||||
async def get_metrics_session() -> AsyncGenerator[AsyncSession, None]:
|
||||
"""Зависимость FastAPI для метрик, которым нужна БД, но не основной пул.
|
||||
|
||||
В отличие от `get_session()` (основной пул `engine.pool`, конкурирует за
|
||||
те же 10+10×воркеров соединений, что и API-запросы), сессия здесь открыта
|
||||
на `_metrics_probe_engine` — исчерпание основного пула эту метрику не
|
||||
заденет, как и `vidconf_db_up`/`vidconf_db_pool_*`. Тестовый харнесс
|
||||
(`tests/conftest.py`) подменяет и её на savepoint-сессию теста — так же,
|
||||
как `get_session` — иначе тест `vidconf_pipeline_sessions` не видел бы
|
||||
данные, ещё не закоммиченные за пределы savepoint.
|
||||
"""
|
||||
async with _metrics_session_maker() as session:
|
||||
yield session
|
||||
|
||||
|
||||
def db_pool_checked_out() -> int:
|
||||
"""Число соединений основного пула, занятых прямо сейчас — без обращения к БД.
|
||||
|
||||
SQLAlchemy держит счётчик в памяти самого объекта пула (`engine.pool`),
|
||||
поэтому его можно прочитать в любой момент, даже когда все соединения
|
||||
заняты или БД недоступна — именно это нужно алерту на исчерпание пула
|
||||
(метрика не должна зависеть от того, что измеряет). Размер и лимит
|
||||
overflow — конфигурация (`Settings.db_pool_size`/`db_max_overflow`),
|
||||
их не нужно снимать с объекта пула отдельно.
|
||||
"""
|
||||
return cast(QueuePool, engine.pool).checkedout()
|
||||
|
||||
@@ -33,12 +33,19 @@ class ChatConfig(BaseModel):
|
||||
enabled: bool = True
|
||||
|
||||
|
||||
class HandQueueConfig(BaseModel):
|
||||
"""Конфигурация переключателя модуля «поднятие руки» (кнопка + очередь целиком)."""
|
||||
|
||||
enabled: bool = True
|
||||
|
||||
|
||||
class PluginsConfig(BaseModel):
|
||||
"""Корневая модель конфигурации для `config/plugins.yaml`."""
|
||||
|
||||
transcriber: TranscriberConfig = Field(default_factory=TranscriberConfig)
|
||||
summarizer: SummarizerConfig = Field(default_factory=SummarizerConfig)
|
||||
chat: ChatConfig = Field(default_factory=ChatConfig)
|
||||
hand_queue: HandQueueConfig = Field(default_factory=HandQueueConfig)
|
||||
|
||||
|
||||
def load_plugins_config(path: str | Path) -> PluginsConfig:
|
||||
@@ -62,6 +69,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` поверх дефолтов
|
||||
@@ -70,6 +99,7 @@ class InstanceConfig(BaseModel):
|
||||
transcriber: TranscriberConfig
|
||||
summarizer: SummarizerConfig
|
||||
chat: ChatConfig
|
||||
hand_queue: HandQueueConfig = Field(default_factory=HandQueueConfig)
|
||||
ai_level: AiLevel = "min"
|
||||
summary_recipients: SummaryRecipientsMode = "all"
|
||||
display_timezone: str = "Europe/Moscow"
|
||||
@@ -87,3 +117,30 @@ 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)
|
||||
# Согласие на обработку персональных данных при регистрации: галочка
|
||||
# обязательна только при `consent_required=True`, текст/версия — редактируемая
|
||||
# администратором настройка (дефолт — типовой шаблон, не юридический документ) —
|
||||
# см. `services/instance_settings.py`. `consent_policy_text`/`_version`
|
||||
# отдаются публично (`GET /auth/registration-options`) независимо от
|
||||
# `consent_required`, чтобы страница регламента была осмысленной и при
|
||||
# выключенном модуле.
|
||||
consent_required: bool = False
|
||||
consent_policy_text: str = ""
|
||||
consent_policy_version: int = 1
|
||||
# Проверка устройств на входе (сессия 33): запрос доступа к микрофону/камере
|
||||
# + превью камеры на странице логина и в карточке «Как вас зовут?» (JoinPage).
|
||||
# Дефолт False сохраняет поведение существующих инсталляций — см.
|
||||
# `services/instance_settings.py`.
|
||||
device_check_enabled: bool = False
|
||||
# Замена фона видео на картинку (сессия 35): отключаемый модуль, дефолт
|
||||
# False сохраняет поведение существующих инсталляций. Нужен клиенту в двух
|
||||
# местах и потому едет двумя путями: на публичные страницы входа — через
|
||||
# `GET /public/settings`, участнику комнаты — в ответе join
|
||||
# (`schemas/conferences.py::JoinOut`), см. `services/instance_settings.py`.
|
||||
virtual_background_enabled: bool = False
|
||||
|
||||
@@ -3,15 +3,59 @@
|
||||
Используется резолвом конференций и гостевым входом (`api/conferences.py`) —
|
||||
эндпоинтами без аутентификации, уязвимыми к перебору номера/ссылки конференции
|
||||
(см. ADR-001, п.4 — оценка энтропии и рекомендуемый лимит 10 запросов/мин на IP).
|
||||
|
||||
## Два счётчика вместо одного (0.0.18)
|
||||
|
||||
Прежняя схема считала ВСЕ запросы подряд с лимитом 10/мин. На нагрузочном
|
||||
тесте 31.07.2026 это остановило вход целой конференции: люди открывали ссылку
|
||||
одновременно, одиннадцатый получал 429, а фронтенд показывал «Не удалось найти
|
||||
конференцию» — при том что конференция существовала и была активна.
|
||||
|
||||
Смысл лимита по ADR-001 — защита от ПЕРЕБОРА номера конференции. Перебор — это
|
||||
поток промахов; легитимный участник открывает существующую ссылку и получает
|
||||
успех. Поэтому:
|
||||
|
||||
- `RATE_LIMIT_MISS_MAX_REQUESTS` — жёсткий счётчик промахов (конференция не
|
||||
найдена, неверный пароль). Именно он защищает от перебора, и он остался
|
||||
прежним — 10/мин;
|
||||
- `RATE_LIMIT_SOFT_MAX_REQUESTS` — мягкий потолок на общее число обращений с
|
||||
одного адреса. Нужен только против тупого флуда; рассчитан так, чтобы сотня
|
||||
человек из офиса за общим NAT спокойно зашла в одну конференцию.
|
||||
"""
|
||||
|
||||
from fastapi import HTTPException, status
|
||||
from fastapi import HTTPException, Request, status
|
||||
|
||||
from core.redis import redis_client
|
||||
|
||||
RATE_LIMIT_MAX_REQUESTS = 10
|
||||
RATE_LIMIT_WINDOW_SECONDS = 60
|
||||
|
||||
# Промахи: перебор номера/ссылки или подбор пароля конференции.
|
||||
RATE_LIMIT_MISS_MAX_REQUESTS = 10
|
||||
# Общий поток с одного IP. Офис за общим NAT — это ОДИН адрес, поэтому потолок
|
||||
# заведомо выше правдоподобного числа участников одной конференции.
|
||||
RATE_LIMIT_SOFT_MAX_REQUESTS = 300
|
||||
|
||||
|
||||
def client_ip(request: Request) -> str:
|
||||
"""IP клиента для rate limit — с учётом того, что backend стоит за nginx.
|
||||
|
||||
`request.client.host` — это TCP-peer, то есть контейнер nginx, один и тот же
|
||||
для всех пользователей. С ним лимит превращался в общий на весь инстанс:
|
||||
на проде в Redis лежал единственный ключ `rate_limit:resolve:172.18.0.13`,
|
||||
и десяти запросов в минуту хватало, чтобы заблокировать вход всем сразу.
|
||||
|
||||
Берём `X-Real-IP`, а НЕ первый элемент `X-Forwarded-For`: nginx заполняет
|
||||
его через `$proxy_add_x_forwarded_for`, то есть ДОПИСЫВАЕТ к присланному
|
||||
клиентом. Первый элемент там подделывается одним заголовком, и лимит
|
||||
обходился бы тривиально. `X-Real-IP` nginx всегда перезаписывает своим
|
||||
`$remote_addr` (см. deploy/nginx/nginx.conf.template).
|
||||
"""
|
||||
real_ip = request.headers.get("x-real-ip")
|
||||
if real_ip:
|
||||
return real_ip.strip()
|
||||
return request.client.host if request.client else "unknown"
|
||||
|
||||
|
||||
async def enforce_rate_limit(
|
||||
key: str,
|
||||
|
||||
@@ -6,4 +6,11 @@ from core.config import get_settings
|
||||
|
||||
settings = get_settings()
|
||||
|
||||
redis_client: Redis = Redis.from_url(settings.redis_url, decode_responses=True)
|
||||
redis_client: Redis = Redis.from_url(
|
||||
settings.redis_url,
|
||||
decode_responses=True,
|
||||
# Размер пула задаём явно: дефолт redis-py (100) рассчитан на команды, а у
|
||||
# нас на нём же висят долгоживущие pub/sub-подписки комнаты — по одной на
|
||||
# участника (см. `core/config.py`, `redis_max_connections`).
|
||||
max_connections=settings.redis_max_connections,
|
||||
)
|
||||
|
||||
@@ -1,33 +1,85 @@
|
||||
"""Хэширование паролей (argon2) и выпуск/проверка JWT (access + refresh)."""
|
||||
|
||||
import asyncio
|
||||
import uuid
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from typing import Any
|
||||
|
||||
import jwt
|
||||
from argon2 import PasswordHasher
|
||||
from argon2.exceptions import VerifyMismatchError
|
||||
from argon2.exceptions import InvalidHashError, VerifyMismatchError
|
||||
|
||||
from core.config import get_settings
|
||||
|
||||
JWT_ALGORITHM = "HS256"
|
||||
|
||||
_hasher = PasswordHasher()
|
||||
# Параметры argon2id по рекомендации OWASP (Password Storage Cheat Sheet):
|
||||
# t=2, m=19 МБ, p=1. Раньше использовались дефолты argon2-cffi
|
||||
# (t=3, m=64 МБ, p=4) — это был не выбор, а «что было в коробке».
|
||||
#
|
||||
# Замер на боевом сервере (4 ядра): 95 мс против 42 мс на одну проверку.
|
||||
# Отдельно важен `parallelism`: при p=4 ОДНА проверка пароля занимала все
|
||||
# четыре ядра, конкурируя с LiveKit за то же железо ровно в момент, когда
|
||||
# люди массово заходят в конференцию.
|
||||
#
|
||||
# Существующие хэши не ломаются: параметры хранятся внутри самой строки хэша
|
||||
# и читаются при verify. Старые хэши перевыпускаются постепенно — см.
|
||||
# `needs_rehash` и его использование при успешном входе.
|
||||
_hasher = PasswordHasher(time_cost=2, memory_cost=19456, parallelism=1)
|
||||
|
||||
|
||||
def hash_password(password: str) -> str:
|
||||
"""Захэшировать пароль алгоритмом argon2 для хранения в БД."""
|
||||
def _hash_password_sync(password: str) -> str:
|
||||
return _hasher.hash(password)
|
||||
|
||||
|
||||
def verify_password(password: str, password_hash: str) -> bool:
|
||||
"""Сверить пароль с сохранённым argon2-хэшем; пароль/хэш никогда не логируются."""
|
||||
def _verify_password_sync(password: str, password_hash: str) -> bool:
|
||||
try:
|
||||
return _hasher.verify(password_hash, password)
|
||||
except VerifyMismatchError:
|
||||
return False
|
||||
|
||||
|
||||
async def hash_password(password: str) -> str:
|
||||
"""Захэшировать пароль алгоритмом argon2 для хранения в БД.
|
||||
|
||||
Считается в отдельном потоке — argon2 это CPU-bound работа на десятки
|
||||
миллисекунд, и в event loop ей не место (см. `verify_password`).
|
||||
"""
|
||||
return await asyncio.to_thread(_hash_password_sync, password)
|
||||
|
||||
|
||||
async def verify_password(password: str, password_hash: str) -> bool:
|
||||
"""Сверить пароль с сохранённым argon2-хэшем; пароль/хэш никогда не логируются.
|
||||
|
||||
Выполняется в пуле потоков, а не в event loop. Причина — нагрузочный тест
|
||||
31.07.2026: синхронный вызов останавливал весь процесс на 95–155 мс, и при
|
||||
массовом входе (около 70 человек разом) это давало p95 логина 7.28 секунды,
|
||||
33 соединения к БД в состоянии `idle in transaction` при одном активном
|
||||
запросе и отказы на совершенно посторонних ручках — включая вход в
|
||||
конференцию, где никакого пароля не проверялось.
|
||||
|
||||
Потоки здесь работают по-настоящему параллельно: argon2-cffi — это
|
||||
C-расширение, освобождающее GIL на время вычисления.
|
||||
"""
|
||||
return await asyncio.to_thread(_verify_password_sync, password, password_hash)
|
||||
|
||||
|
||||
def needs_rehash(password_hash: str) -> bool:
|
||||
"""Проверить, что хэш выпущен устаревшими параметрами argon2.
|
||||
|
||||
Дешёвая операция: разбор строки хэша, без вычислений. Вызывается после
|
||||
успешной проверки пароля — только тогда у нас на руках открытый пароль,
|
||||
которым можно перевыпустить хэш.
|
||||
|
||||
Невалидную строку считаем требующей перевыпуска: если в базе оказался
|
||||
мусор, лучше заменить его корректным хэшем, чем падать при каждом входе.
|
||||
"""
|
||||
try:
|
||||
return _hasher.check_needs_rehash(password_hash)
|
||||
except InvalidHashError:
|
||||
return True
|
||||
|
||||
|
||||
def create_access_token(user_id: uuid.UUID, role: str) -> str:
|
||||
"""Выпустить access-токен: `sub`=user_id, `role`=роль, TTL из настроек."""
|
||||
settings = get_settings()
|
||||
|
||||
@@ -16,6 +16,7 @@ from api.health import router as health_router
|
||||
from api.livekit_webhook import router as livekit_webhook_router
|
||||
from api.metrics import prometheus_latency_middleware
|
||||
from api.metrics import router as metrics_router
|
||||
from api.public import router as public_router
|
||||
from api.teams import router as teams_router
|
||||
from api.users import router as users_router
|
||||
from core.config import get_settings
|
||||
@@ -76,6 +77,7 @@ def create_app() -> FastAPI:
|
||||
app.middleware("http")(prometheus_latency_middleware)
|
||||
app.include_router(health_router)
|
||||
app.include_router(metrics_router)
|
||||
app.include_router(public_router)
|
||||
app.include_router(auth_router)
|
||||
app.include_router(users_router)
|
||||
app.include_router(teams_router)
|
||||
|
||||
@@ -18,6 +18,7 @@ from models.phrase import Phrase
|
||||
from models.session import ConferenceSession
|
||||
from models.team import Team
|
||||
from models.user import User
|
||||
from models.user_background import UserBackground
|
||||
from models.webhook_event import LivekitWebhookEvent
|
||||
|
||||
__all__ = [
|
||||
@@ -36,4 +37,5 @@ __all__ = [
|
||||
"SessionAudioTrack",
|
||||
"Team",
|
||||
"User",
|
||||
"UserBackground",
|
||||
]
|
||||
|
||||
@@ -3,7 +3,17 @@
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import Boolean, CheckConstraint, DateTime, ForeignKey, String, Text, func, text
|
||||
from sqlalchemy import (
|
||||
Boolean,
|
||||
CheckConstraint,
|
||||
DateTime,
|
||||
ForeignKey,
|
||||
Integer,
|
||||
String,
|
||||
Text,
|
||||
func,
|
||||
text,
|
||||
)
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
@@ -35,6 +45,15 @@ class User(Base):
|
||||
# Путь к загруженному аватару (относительно `MEDIA_ROOT`):
|
||||
# `avatars/{user_id}.{ext}`; `NULL` — заглушка с инициалами на фронте.
|
||||
avatar_path: Mapped[str | None] = mapped_column(String(512), nullable=True)
|
||||
# Согласие на обработку персональных данных при регистрации: редакция
|
||||
# регламента (`instance_settings.consent_policy.version` на момент
|
||||
# согласия) и время. `NULL` у обоих — согласие не запрашивалось (модуль
|
||||
# был выключен либо пользователь зарегистрирован до появления этой
|
||||
# настройки); вход таким пользователям не блокируется.
|
||||
consent_version: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
consent_given_at: Mapped[datetime | None] = mapped_column(
|
||||
DateTime(timezone=True), nullable=True
|
||||
)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), nullable=False, server_default=func.now()
|
||||
)
|
||||
|
||||
45
backend/models/user_background.py
Normal file
45
backend/models/user_background.py
Normal file
@@ -0,0 +1,45 @@
|
||||
"""Модель UserBackground — своя картинка пользователя для замены фона видео.
|
||||
|
||||
В БД хранится только путь к файлу относительно `MEDIA_ROOT`
|
||||
(`backgrounds/{user_id}/{background_id}.{ext}`) — ровно тот же приём, что и у
|
||||
аватаров (`users.avatar_path`, `services/avatars.py`): «чтобы не грузили БД»
|
||||
(требование оператора). Сами файлы лежат на диске в томе `media`, который
|
||||
nginx раздаёт напрямую по `location /media/`.
|
||||
|
||||
Лимит на число картинок (`MAX_BACKGROUNDS_PER_USER`) проверяется в сервисе, а
|
||||
не ограничением БД: он про политику продукта, а не про целостность данных, и
|
||||
администратор может захотеть его поменять.
|
||||
"""
|
||||
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import DateTime, ForeignKey, Index, String, func, text
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from models.base import Base
|
||||
|
||||
|
||||
class UserBackground(Base):
|
||||
"""Загруженная пользователем картинка фона."""
|
||||
|
||||
__tablename__ = "user_backgrounds"
|
||||
__table_args__ = (
|
||||
# Выборка всегда одна и та же — «все фоны этого пользователя, свежие
|
||||
# сверху» (`services/backgrounds.py::list_backgrounds`), и она же
|
||||
# считает лимит при загрузке.
|
||||
Index("ix_user_backgrounds_user_created", "user_id", "created_at"),
|
||||
)
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True), primary_key=True, server_default=text("gen_random_uuid()")
|
||||
)
|
||||
user_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE"), nullable=False
|
||||
)
|
||||
# Путь относительно `MEDIA_ROOT`: `backgrounds/{user_id}/{id}.{ext}`.
|
||||
path: Mapped[str] = mapped_column(String(512), nullable=False)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), nullable=False, server_default=func.now()
|
||||
)
|
||||
@@ -61,6 +61,9 @@ ignore = ["B008"] # FastAPI's `Depends(...)` default-argument pattern is idioma
|
||||
# большинство из них игнорирует) — длина строки не показатель качества здесь,
|
||||
# оборачивать вёрстку ради line-length бессмысленно.
|
||||
"services/email_templates.py" = ["E501"]
|
||||
# Дефолтный текст регламента обработки персональных данных — прозаический
|
||||
# шаблон документа, не код (см. докстринг модуля).
|
||||
"services/consent_policy_text.py" = ["E501"]
|
||||
|
||||
[tool.ruff.lint.isort]
|
||||
# `workers/` — соседний пакет монорепо (см. `pythonpath` в
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"""Репозиторий доступа к таблице `users`."""
|
||||
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import or_, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
@@ -30,9 +31,24 @@ class UserRepository:
|
||||
name_user: str,
|
||||
password_hash: str,
|
||||
team_id: uuid.UUID | None = None,
|
||||
consent_version: int | None = None,
|
||||
consent_given_at: datetime | None = None,
|
||||
) -> User:
|
||||
"""Создать нового пользователя (role='user', email_verified=False по умолчанию)."""
|
||||
user = User(email=email, name_user=name_user, password_hash=password_hash, team_id=team_id)
|
||||
"""Создать нового пользователя (role='user', email_verified=False по умолчанию).
|
||||
|
||||
`consent_version`/`consent_given_at` — редакция регламента обработки
|
||||
персональных данных, с которой согласился пользователь, и время
|
||||
согласия; `None` у обоих, если согласие не запрашивалось (модуль
|
||||
выключен) — см. `services.auth.AuthService.register`.
|
||||
"""
|
||||
user = User(
|
||||
email=email,
|
||||
name_user=name_user,
|
||||
password_hash=password_hash,
|
||||
team_id=team_id,
|
||||
consent_version=consent_version,
|
||||
consent_given_at=consent_given_at,
|
||||
)
|
||||
self._session.add(user)
|
||||
await self._session.flush()
|
||||
return user
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -147,6 +147,7 @@ class SettingsOut(BaseModel):
|
||||
"""
|
||||
|
||||
chat_enabled: bool
|
||||
hand_queue_enabled: bool
|
||||
transcription_enabled: bool
|
||||
ai_level: AiLevel
|
||||
ai_levels: list[AiLevelStatus]
|
||||
@@ -158,6 +159,19 @@ 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
|
||||
# Согласие на обработку персональных данных при регистрации — см.
|
||||
# `core/plugins/config.py::InstanceConfig`.
|
||||
consent_required: bool
|
||||
consent_policy_text: str
|
||||
consent_policy_version: int
|
||||
# Проверка устройств на входе (сессия 33) — см. `core/plugins/config.py::InstanceConfig`.
|
||||
device_check_enabled: bool
|
||||
# Замена фона видео (сессия 35) — см. `core/plugins/config.py::InstanceConfig`.
|
||||
virtual_background_enabled: bool
|
||||
|
||||
|
||||
class TestEmailIn(BaseModel):
|
||||
|
||||
@@ -11,12 +11,18 @@ class RegisterIn(BaseModel):
|
||||
`team_id` допустим только при включённой настройке инстанса
|
||||
`registration_team_choice` (см. `GET /auth/registration-options`) и
|
||||
существующей команде — иначе `POST /auth/register` вернёт 400.
|
||||
`consent_accepted` обязан быть `True`, если в настройках инстанса
|
||||
включено `consent_required` (согласие на обработку персональных
|
||||
данных) — иначе `POST /auth/register` вернёт 400. Игнорируется, если
|
||||
настройка выключена (второй эшелон проверки — фронт тоже блокирует
|
||||
кнопку, но сервер не полагается на это).
|
||||
"""
|
||||
|
||||
email: EmailStr
|
||||
name_user: str = Field(min_length=1, max_length=255)
|
||||
password: str = Field(min_length=8)
|
||||
team_id: uuid.UUID | None = None
|
||||
consent_accepted: bool = False
|
||||
|
||||
|
||||
class VerifyEmailIn(BaseModel):
|
||||
@@ -80,6 +86,29 @@ class UserProfileOut(UserOut):
|
||||
team_name: str | None = None
|
||||
|
||||
|
||||
class UserBackgroundOut(BaseModel):
|
||||
"""Своя картинка пользователя для замены фона видео (`GET /users/me/backgrounds`).
|
||||
|
||||
Отдаётся только URL файла (`/media/backgrounds/...`, раздаёт nginx) и id для
|
||||
удаления — путь на диске наружу не показывается.
|
||||
"""
|
||||
|
||||
id: uuid.UUID
|
||||
url: str
|
||||
|
||||
|
||||
class UserBackgroundsOut(BaseModel):
|
||||
"""Список своих картинок фона вместе с лимитом.
|
||||
|
||||
Лимит приезжает с сервера, а не зашит в интерфейс: он проверяется на
|
||||
сервере (`services/backgrounds.py`), и фронт не должен угадывать его
|
||||
отдельной константой, которая разъедется при первой же правке.
|
||||
"""
|
||||
|
||||
items: list[UserBackgroundOut]
|
||||
limit: int
|
||||
|
||||
|
||||
class PasswordChangeIn(BaseModel):
|
||||
"""Тело смены пароля текущим пользователем (`POST /users/me/password`).
|
||||
|
||||
@@ -111,3 +140,10 @@ class RegistrationOptionsOut(BaseModel):
|
||||
team_choice_enabled: bool
|
||||
teams: list[RegistrationTeamOptionOut]
|
||||
email_domains: list[str] = Field(default_factory=list)
|
||||
# Согласие на обработку персональных данных: `consent_required` — обязательна
|
||||
# ли галочка на форме регистрации; `consent_text`/`consent_version` отдаются
|
||||
# ВСЕГДА, независимо от `consent_required` — той же строкой пользуется
|
||||
# публичная страница регламента, доступная и при выключенном модуле.
|
||||
consent_required: bool = False
|
||||
consent_text: str = ""
|
||||
consent_version: int = 1
|
||||
|
||||
@@ -5,7 +5,8 @@ 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
|
||||
|
||||
# Допуск в прошлое при плановом создании/правке — небольшой запас на задержку
|
||||
@@ -129,6 +130,19 @@ class JoinOut(BaseModel):
|
||||
# Тоггл инстанса `chat.enabled` на момент входа — клиент решает,
|
||||
# показывать ли UI чата, не дожидаясь ошибки WS-подключения.
|
||||
chat_enabled: bool
|
||||
# Тоггл инстанса `hand_queue.enabled` на момент входа — гасит модуль
|
||||
# «поднятие руки» целиком (кнопка + очередь), см. `HandQueueConfig`.
|
||||
hand_queue_enabled: bool
|
||||
# Рычаги нагрузки медиа (`instance_settings.media_limits`) — отдаются
|
||||
# прямо в join-ответе, а не только в админке: участнику нужно иметь их
|
||||
# на руках ДО публикации своего трека (см. `services/conference_access.py`).
|
||||
publish_quality_cap: PublishQualityCap
|
||||
stage_max_tiles: StageMaxTiles
|
||||
# Тоггл инстанса `virtual_background.enabled` — гасит замену фона целиком
|
||||
# (кнопка в тулбаре комнаты и применение процессора к треку). Едет здесь,
|
||||
# а не через `/admin/settings` (доступен только админу): значение нужно
|
||||
# участнику — в том числе гостю — до первого рендера комнаты.
|
||||
virtual_background_enabled: bool
|
||||
|
||||
|
||||
class ConferenceOut(BaseModel):
|
||||
@@ -210,3 +224,21 @@ class GuestJoinIn(BaseModel):
|
||||
display_name: str = Field(min_length=1, max_length=255)
|
||||
email: EmailStr | None = None
|
||||
password: str | None = None
|
||||
|
||||
|
||||
class MuteParticipantIn(BaseModel):
|
||||
"""Тело запроса принудительного мьюта участника организатором (задача B2).
|
||||
|
||||
`identity` — тот же формат, что и `Participant.identity` в LiveKit
|
||||
(`str(user_id)` либо `guest:{id}`); клиент берёт его из `useParticipants()`
|
||||
LiveKit, не подбирает вручную.
|
||||
"""
|
||||
|
||||
identity: str = Field(min_length=1)
|
||||
source: ForcedMuteSource
|
||||
|
||||
|
||||
class MuteParticipantOut(BaseModel):
|
||||
"""Ответ на принудительный мьют — `muted=False`, если трек и так не был опубликован."""
|
||||
|
||||
muted: bool
|
||||
|
||||
23
backend/schemas/public.py
Normal file
23
backend/schemas/public.py
Normal file
@@ -0,0 +1,23 @@
|
||||
"""Схемы публичного эндпоинта настроек клиента (`GET /public/settings`)."""
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class PublicSettingsOut(BaseModel):
|
||||
"""Настройки инстанса, нужные клиенту ДО аутентификации.
|
||||
|
||||
Общая точка для флагов, которые должны быть на руках у страницы логина
|
||||
и гостевой карточки входа (`LoginPage`/`JoinPage`) — обе публичные,
|
||||
`GET /admin/settings` им недоступен (только для админа). Отдельно от
|
||||
`GET /auth/registration-options`: тот про опции конкретно карточки
|
||||
регистрации, а не про настройки инстанса в целом (сессия 33).
|
||||
"""
|
||||
|
||||
# Проверка устройств на входе (сессия 33) — см.
|
||||
# `core/plugins/config.py::InstanceConfig.device_check_enabled`.
|
||||
device_check_enabled: bool
|
||||
# Замена фона видео (сессия 35) — нужен превью на `JoinPage`, чтобы
|
||||
# показать выбор фона ещё до входа в комнату. Участнику УЖЕ в комнате тот
|
||||
# же флаг приезжает в join-ответе (`JoinOut.virtual_background_enabled`):
|
||||
# эта страница публичная и `GET /admin/settings` ей недоступен.
|
||||
virtual_background_enabled: bool
|
||||
61
backend/schemas/room_events.py
Normal file
61
backend/schemas/room_events.py
Normal file
@@ -0,0 +1,61 @@
|
||||
"""Pydantic-схемы событий комнаты, мультиплексируемых поверх WS-чата (`api/chat.py`).
|
||||
|
||||
Отдельный протокол от собственно чата (`schemas/chat.py`): очередь поднятых
|
||||
рук и уведомления о принудительном мьюте — эфемерное состояние звонка
|
||||
(Redis, не БД, см. `services/hand_queue.py`) и не должны попадать в
|
||||
персистентную историю сообщений чата, хотя и едут по тому же соединению.
|
||||
"""
|
||||
|
||||
from datetime import UTC, datetime
|
||||
from typing import Literal
|
||||
|
||||
from pydantic import BaseModel, Field, field_serializer
|
||||
|
||||
|
||||
class RaiseHandIn(BaseModel):
|
||||
"""Клиент поднимает свою руку."""
|
||||
|
||||
type: Literal["raise_hand"]
|
||||
|
||||
|
||||
class LowerHandIn(BaseModel):
|
||||
"""Клиент опускает руку — свою (без `identity`) либо, только для организатора, чужую."""
|
||||
|
||||
type: Literal["lower_hand"]
|
||||
identity: str | None = None
|
||||
|
||||
|
||||
class HandQueueEntryOut(BaseModel):
|
||||
"""Один участник в очереди поднятых рук."""
|
||||
|
||||
identity: str
|
||||
name: str
|
||||
raised_at: datetime
|
||||
|
||||
@field_serializer("raised_at")
|
||||
def _serialize_raised_at(self, value: datetime) -> str:
|
||||
return value.astimezone(UTC).isoformat().replace("+00:00", "Z")
|
||||
|
||||
|
||||
class HandQueueOut(BaseModel):
|
||||
"""Снапшот очереди поднятых рук — рассылается всем участникам при любом изменении."""
|
||||
|
||||
type: Literal["hand_queue"] = "hand_queue"
|
||||
queue: list[HandQueueEntryOut] = Field(default_factory=list)
|
||||
|
||||
|
||||
ForcedMuteSource = Literal["microphone", "camera"]
|
||||
|
||||
|
||||
class ForcedMuteOut(BaseModel):
|
||||
"""Организатор принудительно выключил трек участника (задача B2) — уведомление всем.
|
||||
|
||||
Рассылается всем (не только затронутому), как и `HandQueueOut`: канал —
|
||||
общий broadcast, а не адресная доставка одному соединению; получатели,
|
||||
для которых `identity` не совпадает с их собственной, событие
|
||||
игнорируют.
|
||||
"""
|
||||
|
||||
type: Literal["forced_mute"] = "forced_mute"
|
||||
identity: str
|
||||
source: ForcedMuteSource
|
||||
@@ -24,6 +24,7 @@ from core.security import (
|
||||
create_refresh_token,
|
||||
decode_token,
|
||||
hash_password,
|
||||
needs_rehash,
|
||||
verify_password,
|
||||
)
|
||||
from models.email_verification import EmailVerificationToken
|
||||
@@ -57,6 +58,15 @@ class InvalidEmailDomainError(Exception):
|
||||
"""
|
||||
|
||||
|
||||
class ConsentRequiredError(Exception):
|
||||
"""Согласие на обработку персональных данных не отмечено.
|
||||
|
||||
Поднимается только при включённой настройке инстанса `consent_required`
|
||||
(см. `InstanceSettingsService`) — второй эшелон проверки, фронт уже не
|
||||
даёт отправить форму без галочки, но сервер не полагается на это.
|
||||
"""
|
||||
|
||||
|
||||
class InvalidVerificationTokenError(Exception):
|
||||
"""Токен подтверждения email не найден, просрочен или уже использован."""
|
||||
|
||||
@@ -98,6 +108,7 @@ class AuthService:
|
||||
name_user: str,
|
||||
password: str,
|
||||
team_id: uuid.UUID | None = None,
|
||||
consent_accepted: bool = False,
|
||||
) -> User:
|
||||
"""Зарегистрировать пользователя и отправить письмо для подтверждения email.
|
||||
|
||||
@@ -108,7 +119,12 @@ class AuthService:
|
||||
email (`registration_email_domain_enabled`), домен `email` (часть
|
||||
после `@`, без учёта регистра) должен совпадать с одним из
|
||||
эталонных доменов (`registration_email_domains`) — иначе
|
||||
`InvalidEmailDomainError`. Обе проверки — до создания пользователя.
|
||||
`InvalidEmailDomainError`. Если включено согласие на обработку
|
||||
персональных данных (`consent_required`), `consent_accepted` обязан
|
||||
быть `True` — иначе `ConsentRequiredError`; при принятии согласия
|
||||
в `User` пишутся `consent_version`/`consent_given_at` (редакция
|
||||
регламента на момент согласия и время). Все проверки — до создания
|
||||
пользователя.
|
||||
"""
|
||||
existing = await self._users.get_by_email(email)
|
||||
if existing is not None:
|
||||
@@ -128,11 +144,22 @@ class AuthService:
|
||||
if team is None:
|
||||
raise InvalidTeamSelectionError(team_id)
|
||||
|
||||
if cfg.consent_required and not consent_accepted:
|
||||
raise ConsentRequiredError
|
||||
|
||||
consent_version: int | None = None
|
||||
consent_given_at: datetime | None = None
|
||||
if cfg.consent_required and consent_accepted:
|
||||
consent_version = cfg.consent_policy_version
|
||||
consent_given_at = datetime.now(UTC)
|
||||
|
||||
user = await self._users.create(
|
||||
email=email,
|
||||
name_user=name_user,
|
||||
password_hash=hash_password(password),
|
||||
password_hash=await hash_password(password),
|
||||
team_id=team_id,
|
||||
consent_version=consent_version,
|
||||
consent_given_at=consent_given_at,
|
||||
)
|
||||
reply_to = cfg.contact_email if cfg.contact_email_enabled else None
|
||||
await self._issue_verification_email(user, reply_to=reply_to)
|
||||
@@ -161,10 +188,21 @@ class AuthService:
|
||||
async def login(self, *, email: str, password: str) -> TokenPair:
|
||||
"""Проверить учётные данные и выдать пару access/refresh токенов."""
|
||||
user = await self._users.get_by_email(email)
|
||||
if user is None or not verify_password(password, user.password_hash):
|
||||
if user is None or not await verify_password(password, user.password_hash):
|
||||
raise InvalidCredentialsError
|
||||
if not user.email_verified:
|
||||
raise EmailNotVerifiedError
|
||||
|
||||
# Постепенная миграция на актуальные параметры argon2 (см. core/security.py):
|
||||
# параметры зашиты в саму строку хэша, поэтому старые записи так и
|
||||
# проверялись бы вдвое дольше. Открытый пароль есть только здесь и
|
||||
# только сейчас — другого места для перевыпуска не будет.
|
||||
if needs_rehash(user.password_hash):
|
||||
user.password_hash = await hash_password(password)
|
||||
# Явный commit: выдача токенов идёт через Redis и БД не трогает,
|
||||
# поэтому без него перевыпущенный хэш откатился бы вместе с сессией.
|
||||
await self._session.commit()
|
||||
|
||||
return await self._issue_token_pair(user.id, user.role)
|
||||
|
||||
async def refresh(self, refresh_token: str) -> TokenPair:
|
||||
|
||||
@@ -3,80 +3,49 @@
|
||||
Файл лежит на диске `MEDIA_ROOT/avatars/{user_id}.{ext}`; в БД (`users.avatar_path`)
|
||||
хранится путь относительно `MEDIA_ROOT` (`avatars/{user_id}.{ext}`) — тот же
|
||||
приём, что и у записей аудиотреков (`recordings_dir`, `core/config.py`).
|
||||
|
||||
Сама проверка содержимого (допустимые форматы, магические байты, реальный
|
||||
размер) живёт в `services/images.py` — она общая с картинками фона видео
|
||||
(`services/backgrounds.py`).
|
||||
"""
|
||||
|
||||
import uuid
|
||||
from collections.abc import Callable
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import UploadFile
|
||||
|
||||
from services.images import (
|
||||
ImageInvalidTypeError,
|
||||
ImageTooLargeError,
|
||||
read_and_validate_image,
|
||||
)
|
||||
|
||||
# Лимит размера загружаемого аватара — 2 МБ.
|
||||
MAX_AVATAR_SIZE_BYTES = 2 * 1024 * 1024
|
||||
|
||||
# Читаем файл чанками, не доверяя заголовку `Content-Length` (клиент может
|
||||
# солгать о размере) — реальный размер считается по факту прочитанных байт.
|
||||
_CHUNK_SIZE_BYTES = 64 * 1024
|
||||
|
||||
# Допустимые типы изображений -> расширение файла на диске.
|
||||
_ALLOWED_CONTENT_TYPES: dict[str, str] = {
|
||||
"image/jpeg": "jpg",
|
||||
"image/png": "png",
|
||||
"image/webp": "webp",
|
||||
}
|
||||
|
||||
# Магические байты (сигнатуры) форматов — заголовку `Content-Type` от клиента
|
||||
# доверять нельзя (легко подделать), реальный формат определяется по
|
||||
# содержимому файла.
|
||||
_MAGIC_CHECKS: dict[str, Callable[[bytes], bool]] = {
|
||||
"image/jpeg": lambda head: head[:3] == b"\xff\xd8\xff",
|
||||
"image/png": lambda head: head[:8] == b"\x89PNG\r\n\x1a\n",
|
||||
"image/webp": lambda head: head[:4] == b"RIFF" and head[8:12] == b"WEBP",
|
||||
}
|
||||
|
||||
# Достаточно первых 12 байт, чтобы проверить все сигнатуры выше (WebP —
|
||||
# самая длинная проверка, требует байты 8..11 включительно).
|
||||
_MAGIC_HEAD_SIZE = 12
|
||||
|
||||
|
||||
class AvatarTooLargeError(Exception):
|
||||
class AvatarTooLargeError(ImageTooLargeError):
|
||||
"""Загружаемый файл превышает `MAX_AVATAR_SIZE_BYTES` (413)."""
|
||||
|
||||
|
||||
class AvatarInvalidTypeError(Exception):
|
||||
class AvatarInvalidTypeError(ImageInvalidTypeError):
|
||||
"""`Content-Type` не входит в список допустимых либо не совпадает с содержимым (415)."""
|
||||
|
||||
|
||||
async def read_and_validate_avatar(file: UploadFile) -> tuple[bytes, str]:
|
||||
"""Прочитать содержимое файла аватара чанками и провалидировать тип/размер.
|
||||
"""Прочитать содержимое файла аватара и провалидировать тип/размер.
|
||||
|
||||
Возвращает `(содержимое, расширение)`. Порядок проверок: сначала
|
||||
заявленный `Content-Type` (быстрый отсев), затем фактический размер по
|
||||
мере чтения, затем магические байты содержимого — заявленный тип должен
|
||||
совпасть с реальным (иначе подделка `Content-Type` не даст загрузить,
|
||||
например, исполняемый файл под видом `image/png`).
|
||||
Возвращает `(содержимое, расширение)`. Ошибки общего валидатора
|
||||
перезаворачиваются в «аватарные» — вызывающий код (`api/users.py`,
|
||||
`api/admin.py`) отображает их в 413/415 и не должен знать про
|
||||
`services/images.py`.
|
||||
"""
|
||||
declared_type = file.content_type
|
||||
if declared_type not in _ALLOWED_CONTENT_TYPES:
|
||||
raise AvatarInvalidTypeError(f"unsupported_content_type: {declared_type}")
|
||||
|
||||
chunks: list[bytes] = []
|
||||
total_size = 0
|
||||
while True:
|
||||
chunk = await file.read(_CHUNK_SIZE_BYTES)
|
||||
if not chunk:
|
||||
break
|
||||
total_size += len(chunk)
|
||||
if total_size > MAX_AVATAR_SIZE_BYTES:
|
||||
raise AvatarTooLargeError(f"file exceeds {MAX_AVATAR_SIZE_BYTES} bytes")
|
||||
chunks.append(chunk)
|
||||
content = b"".join(chunks)
|
||||
|
||||
magic_check = _MAGIC_CHECKS[declared_type]
|
||||
if not magic_check(content[:_MAGIC_HEAD_SIZE]):
|
||||
raise AvatarInvalidTypeError("content_does_not_match_declared_content_type")
|
||||
|
||||
return content, _ALLOWED_CONTENT_TYPES[declared_type]
|
||||
try:
|
||||
return await read_and_validate_image(file, MAX_AVATAR_SIZE_BYTES)
|
||||
except ImageTooLargeError as exc:
|
||||
raise AvatarTooLargeError(str(exc)) from exc
|
||||
except ImageInvalidTypeError as exc:
|
||||
raise AvatarInvalidTypeError(str(exc)) from exc
|
||||
|
||||
|
||||
def _avatar_relative_path(user_id: uuid.UUID, ext: str) -> str:
|
||||
|
||||
135
backend/services/backgrounds.py
Normal file
135
backend/services/backgrounds.py
Normal file
@@ -0,0 +1,135 @@
|
||||
"""Свои картинки пользователя для замены фона видео: лимит, файлы на диске, URL.
|
||||
|
||||
Как и аватары (`services/avatars.py`), картинки лежат **файлами на диске**
|
||||
(`MEDIA_ROOT/backgrounds/{user_id}/{background_id}.{ext}`), а в БД — только
|
||||
путь (`user_backgrounds.path`): требование оператора «чтобы не грузили БД».
|
||||
Раздаёт их nginx напрямую (`location /media/`), в обход backend.
|
||||
|
||||
Картинку ужимает КЛИЕНТ (canvas → WebP, см. `frontend/src/lib/imageResize.ts`):
|
||||
фон всё равно рендерится в браузере, и ставить Pillow на сервер ради одной
|
||||
операции не нужно. Но валидация здесь остаётся полноценной — запрос может
|
||||
прийти и мимо интерфейса.
|
||||
|
||||
Имя файла — id самой записи, а не порядковый номер: запись никогда не
|
||||
перезаписывается (загрузка всегда создаёт новую), поэтому URL картинки
|
||||
неизменен и его можно кэшировать браузером без cache-busting-параметра,
|
||||
в отличие от аватара.
|
||||
"""
|
||||
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import UploadFile
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from models.user import User
|
||||
from models.user_background import UserBackground
|
||||
from services.images import (
|
||||
ImageInvalidTypeError,
|
||||
ImageTooLargeError,
|
||||
read_and_validate_image,
|
||||
)
|
||||
|
||||
# Сколько своих картинок разрешено одному пользователю — прямое требование
|
||||
# задачи («но не более 10»). Проверяется здесь, на сервере: ограничение только
|
||||
# в интерфейсе обходится curl'ом.
|
||||
MAX_BACKGROUNDS_PER_USER = 10
|
||||
|
||||
# Лимит размера загружаемого файла — 2 МБ, как у аватара. Клиент присылает
|
||||
# ужатый WebP (обычно 100–300 КБ), так что до лимита доходит только тот, кто
|
||||
# шлёт запрос в обход интерфейса.
|
||||
MAX_BACKGROUND_SIZE_BYTES = 2 * 1024 * 1024
|
||||
|
||||
|
||||
class BackgroundTooLargeError(ImageTooLargeError):
|
||||
"""Загружаемый файл превышает `MAX_BACKGROUND_SIZE_BYTES` (413)."""
|
||||
|
||||
|
||||
class BackgroundInvalidTypeError(ImageInvalidTypeError):
|
||||
"""`Content-Type` не входит в список допустимых либо не совпадает с содержимым (415)."""
|
||||
|
||||
|
||||
class BackgroundLimitReachedError(Exception):
|
||||
"""У пользователя уже `MAX_BACKGROUNDS_PER_USER` картинок (409)."""
|
||||
|
||||
|
||||
async def list_backgrounds(session: AsyncSession, user_id: uuid.UUID) -> list[UserBackground]:
|
||||
"""Все картинки пользователя, свежие сверху."""
|
||||
result = await session.execute(
|
||||
select(UserBackground)
|
||||
.where(UserBackground.user_id == user_id)
|
||||
.order_by(UserBackground.created_at.desc(), UserBackground.id.desc())
|
||||
)
|
||||
return list(result.scalars().all())
|
||||
|
||||
|
||||
async def add_background(
|
||||
session: AsyncSession, media_root: Path, user_id: uuid.UUID, file: UploadFile
|
||||
) -> UserBackground:
|
||||
"""Провалидировать, сохранить на диск и завести запись о новой картинке.
|
||||
|
||||
Бросает `BackgroundTooLargeError`/`BackgroundInvalidTypeError`/
|
||||
`BackgroundLimitReachedError`. Коммит — за вызывающим (роутером), как и в
|
||||
остальных эндпоинтах профиля.
|
||||
|
||||
Строка пользователя блокируется (`FOR UPDATE`) на время проверки лимита:
|
||||
без этого две одновременные загрузки (двойной клик по кнопке) обе
|
||||
увидели бы «уже 9» и обе прошли бы — лимит, проверяемый на сервере,
|
||||
обязан держаться и в этом случае.
|
||||
"""
|
||||
await session.execute(select(User.id).where(User.id == user_id).with_for_update())
|
||||
count = await session.scalar(
|
||||
select(func.count()).select_from(UserBackground).where(UserBackground.user_id == user_id)
|
||||
)
|
||||
if (count or 0) >= MAX_BACKGROUNDS_PER_USER:
|
||||
raise BackgroundLimitReachedError(f"limit is {MAX_BACKGROUNDS_PER_USER}")
|
||||
|
||||
try:
|
||||
content, ext = await read_and_validate_image(file, MAX_BACKGROUND_SIZE_BYTES)
|
||||
except ImageTooLargeError as exc:
|
||||
raise BackgroundTooLargeError(str(exc)) from exc
|
||||
except ImageInvalidTypeError as exc:
|
||||
raise BackgroundInvalidTypeError(str(exc)) from exc
|
||||
|
||||
# id генерируем здесь, а не полагаемся на `server_default`: он нужен ДО
|
||||
# вставки, чтобы собрать имя файла на диске.
|
||||
background_id = uuid.uuid4()
|
||||
relative_path = f"backgrounds/{user_id}/{background_id}.{ext}"
|
||||
background = UserBackground(id=background_id, user_id=user_id, path=relative_path)
|
||||
session.add(background)
|
||||
# Запись сначала, файл потом: если вставка не пройдёт (лимит, гонка,
|
||||
# отвалившаяся БД), на диске не останется мусора.
|
||||
await session.flush()
|
||||
|
||||
file_path = media_root / relative_path
|
||||
file_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
file_path.write_bytes(content)
|
||||
return background
|
||||
|
||||
|
||||
async def delete_background(
|
||||
session: AsyncSession, media_root: Path, user_id: uuid.UUID, background_id: uuid.UUID
|
||||
) -> bool:
|
||||
"""Удалить картинку пользователя вместе с файлом. `False` — записи нет (404).
|
||||
|
||||
`user_id` в условии обязателен: без него владелец записи не проверялся бы
|
||||
и любой аутентифицированный пользователь мог бы удалить чужую картинку,
|
||||
зная её id.
|
||||
"""
|
||||
background = await session.scalar(
|
||||
select(UserBackground).where(
|
||||
UserBackground.id == background_id, UserBackground.user_id == user_id
|
||||
)
|
||||
)
|
||||
if background is None:
|
||||
return False
|
||||
file_path = media_root / background.path
|
||||
file_path.unlink(missing_ok=True)
|
||||
await session.delete(background)
|
||||
return True
|
||||
|
||||
|
||||
def background_url(path: str) -> str:
|
||||
"""Публичный URL картинки фона (раздаётся nginx из тома `media`)."""
|
||||
return f"/media/{path}"
|
||||
@@ -148,6 +148,15 @@ class ChatService:
|
||||
raise WrongRoomError
|
||||
return conference
|
||||
|
||||
async def hand_queue_enabled(self) -> bool:
|
||||
"""Тоггл инстанса `hand_queue.enabled` — снимается один раз при подключении WS
|
||||
(см. `api/chat.py::chat_websocket`), а не на каждое сообщение: та же
|
||||
осознанная «застылость» на время жизни соединения, что и у
|
||||
`is_organizer` в `_pump_websocket_to_service` — переключение модуля
|
||||
администратором применяется со следующего подключения."""
|
||||
cfg = await InstanceSettingsService(self._session).get()
|
||||
return cfg.hand_queue.enabled
|
||||
|
||||
async def history(self, conference: Conference) -> list[ChatMessageOut]:
|
||||
"""Последние сообщения открытой сессии конференции (пусто, если сессии ещё нет)."""
|
||||
session_record = await self._sessions.get_open_by_conference(conference.id)
|
||||
|
||||
@@ -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
|
||||
@@ -27,7 +28,7 @@ class InvalidPasswordError(Exception):
|
||||
"""Указанный пароль не совпадает с паролем закрытой конференции."""
|
||||
|
||||
|
||||
def ensure_joinable(conference: Conference, *, password: str | None) -> None:
|
||||
async def ensure_joinable(conference: Conference, *, password: str | None) -> None:
|
||||
"""Проверить, что в конференцию можно войти прямо сейчас.
|
||||
|
||||
Бросает `ConferenceEndedError` для терминального статуса `ended`
|
||||
@@ -42,7 +43,7 @@ def ensure_joinable(conference: Conference, *, password: str | None) -> None:
|
||||
return
|
||||
if conference.password_hash is None or password is None:
|
||||
raise PasswordRequiredError
|
||||
if not verify_password(password, conference.password_hash):
|
||||
if not await verify_password(password, conference.password_hash):
|
||||
raise InvalidPasswordError
|
||||
|
||||
|
||||
@@ -52,20 +53,39 @@ def build_join(
|
||||
identity: str,
|
||||
name: str,
|
||||
chat_enabled: bool,
|
||||
hand_queue_enabled: bool,
|
||||
publish_quality_cap: PublishQualityCap,
|
||||
stage_max_tiles: StageMaxTiles,
|
||||
virtual_background_enabled: bool,
|
||||
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` прокидывается
|
||||
в метаданные токена как JSON
|
||||
`{"avatar_url": ...}`; `None` (гость либо пользователь без аватара) —
|
||||
метаданные не выставляются вовсе.
|
||||
`chat_enabled`/`hand_queue_enabled`/`publish_quality_cap`/`stage_max_tiles`/
|
||||
`virtual_background_enabled`
|
||||
— снятые вызывающей стороной значения `instance_settings`: читаются здесь
|
||||
параметрами, а не заново из БД, чтобы не плодить отдельный запрос настроек
|
||||
на каждый join.
|
||||
`avatar_url`/`is_organizer`
|
||||
прокидываются в метаданные токена как JSON `{"avatar_url": ..., "is_organizer": true}`
|
||||
— поля добавляются, только если заданы (гость без аватара и не-организатор
|
||||
получают токен вовсе без метаданных, как и раньше).
|
||||
|
||||
⚠️ `is_organizer` в метаданных — только подсказка для UI клиента (показать/
|
||||
скрыть кнопки организатора). Метаданным токена доверять для АВТОРИЗАЦИИ
|
||||
нельзя — участник технически может их подделать на своей стороне. Любое
|
||||
серверное действие организатора (например, принудительный мьют) обязано
|
||||
заново проверяться по `conference.owner_id` в БД, а не по этому полю.
|
||||
"""
|
||||
settings = get_settings()
|
||||
metadata = json.dumps({"avatar_url": avatar_url}) if avatar_url else None
|
||||
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
|
||||
)
|
||||
@@ -75,4 +95,8 @@ def build_join(
|
||||
room_name=conference.slug,
|
||||
conference_id=conference.id,
|
||||
chat_enabled=chat_enabled,
|
||||
hand_queue_enabled=hand_queue_enabled,
|
||||
publish_quality_cap=publish_quality_cap,
|
||||
stage_max_tiles=stage_max_tiles,
|
||||
virtual_background_enabled=virtual_background_enabled,
|
||||
)
|
||||
|
||||
@@ -33,12 +33,14 @@ from schemas.conferences import (
|
||||
JoinOut,
|
||||
OccurrenceOut,
|
||||
)
|
||||
from services import hand_queue
|
||||
from services.avatars import avatar_url as resolve_avatar_url
|
||||
from services.conference_access import build_join, ensure_joinable
|
||||
from services.conference_ids import generate_number, generate_slug
|
||||
from services.instance_settings import InstanceSettingsService
|
||||
from services.invitations_producer import enqueue_invitations
|
||||
from services.recurrence import RecurrenceRule, expand_occurrences
|
||||
from services.room_control import MuteSource, mute_participant_track
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -99,7 +101,7 @@ class ConferenceService:
|
||||
повторением без явного `scheduled_at` — плановая конференция,
|
||||
ожидающая своего первого вхождения, а не мгновенный вход.
|
||||
"""
|
||||
password_hash = hash_password(data.password) if data.password else None
|
||||
password_hash = await hash_password(data.password) if data.password else None
|
||||
is_instant = data.scheduled_at is None and data.recurrence is None
|
||||
conference_status = "active" if is_instant else "scheduled"
|
||||
recurrence_json = data.recurrence.model_dump(mode="json") if data.recurrence else None
|
||||
@@ -147,13 +149,18 @@ 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,
|
||||
hand_queue_enabled=cfg.hand_queue.enabled,
|
||||
publish_quality_cap=cfg.media_limits.publish_quality_cap,
|
||||
stage_max_tiles=cfg.media_limits.stage_max_tiles,
|
||||
virtual_background_enabled=cfg.virtual_background_enabled,
|
||||
avatar_url=resolve_avatar_url(self._media_root, owner_avatar_path),
|
||||
is_organizer=True,
|
||||
)
|
||||
else:
|
||||
# Плановая (разовая) либо закреплённая с повторением/датой — есть
|
||||
@@ -239,20 +246,25 @@ class ConferenceService:
|
||||
) -> JoinOut:
|
||||
"""Войти в конференцию зарегистрированным пользователем."""
|
||||
conference = await self._get_or_raise(conference_id)
|
||||
ensure_joinable(conference, password=password)
|
||||
chat_enabled = (await InstanceSettingsService(self._session).get()).chat.enabled
|
||||
await ensure_joinable(conference, password=password)
|
||||
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,
|
||||
hand_queue_enabled=cfg.hand_queue.enabled,
|
||||
publish_quality_cap=cfg.media_limits.publish_quality_cap,
|
||||
stage_max_tiles=cfg.media_limits.stage_max_tiles,
|
||||
virtual_background_enabled=cfg.virtual_background_enabled,
|
||||
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,
|
||||
)
|
||||
|
||||
async def join_as_guest(self, conference_id: uuid.UUID, *, data: GuestJoinIn) -> JoinOut:
|
||||
"""Войти в конференцию гостем: создать `GuestAccess` и выдать токен."""
|
||||
conference = await self._get_or_raise(conference_id)
|
||||
ensure_joinable(conference, password=data.password)
|
||||
await ensure_joinable(conference, password=data.password)
|
||||
|
||||
guest = GuestAccess(
|
||||
conference_id=conference.id, display_name=data.display_name, email=data.email
|
||||
@@ -261,14 +273,48 @@ 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,
|
||||
hand_queue_enabled=cfg.hand_queue.enabled,
|
||||
publish_quality_cap=cfg.media_limits.publish_quality_cap,
|
||||
stage_max_tiles=cfg.media_limits.stage_max_tiles,
|
||||
virtual_background_enabled=cfg.virtual_background_enabled,
|
||||
)
|
||||
|
||||
async def mute_participant(
|
||||
self,
|
||||
conference_id: uuid.UUID,
|
||||
*,
|
||||
actor: User,
|
||||
target_identity: str,
|
||||
source: MuteSource,
|
||||
) -> bool:
|
||||
"""Принудительно замьютить трек участника (задача B2); владелец/администратор.
|
||||
|
||||
Права — ТОЛЬКО отсюда (`_ensure_owner_or_admin` по `conference.owner_id`
|
||||
в БД), не по метаданным LiveKit-токена вызывающего: те лишь подсказка
|
||||
для UI (см. `services/conference_access.py::build_join`) и потенциально
|
||||
подделываемы клиентом. `target_identity` НИКАК не валидируется против
|
||||
состава участников заранее — если его сейчас нет в комнате LiveKit,
|
||||
`mute_participant_track` бросит `ParticipantNotInRoomError` (ловит
|
||||
API-роутер).
|
||||
"""
|
||||
conference = await self._get_or_raise(conference_id)
|
||||
self._ensure_owner_or_admin(conference, actor)
|
||||
|
||||
muted = await mute_participant_track(
|
||||
conference.slug, identity=target_identity, source=source
|
||||
)
|
||||
if muted:
|
||||
await hand_queue.publish_forced_mute(
|
||||
conference.id, identity=target_identity, source=source
|
||||
)
|
||||
return muted
|
||||
|
||||
async def update(
|
||||
self, conference_id: uuid.UUID, *, actor: User, data: ConferenceUpdateIn
|
||||
) -> Conference:
|
||||
@@ -296,7 +342,7 @@ class ConferenceService:
|
||||
if data.is_closed is not None:
|
||||
conference.is_closed = data.is_closed
|
||||
if data.password is not None:
|
||||
conference.password_hash = hash_password(data.password)
|
||||
conference.password_hash = await hash_password(data.password)
|
||||
if "summary_recipients" in data.model_fields_set:
|
||||
# Явная передача (в т.ч. `null`) — сбросить/установить
|
||||
# переопределение; отсутствие поля в запросе значение не трогает.
|
||||
|
||||
35
backend/services/consent_policy_text.py
Normal file
35
backend/services/consent_policy_text.py
Normal file
@@ -0,0 +1,35 @@
|
||||
"""Дефолтный текст регламента обработки персональных данных (ключ `instance_settings.consent_policy`).
|
||||
|
||||
Вынесен из `services/instance_settings.py` в отдельный модуль ради `E501`:
|
||||
это прозаический шаблон документа, а не код, оборачивать его строки ради
|
||||
лимита длины строки бессмысленно — так же, как `email_templates.py`
|
||||
(см. `[tool.ruff.lint.per-file-ignores]` в `pyproject.toml`).
|
||||
"""
|
||||
|
||||
DEFAULT_CONSENT_POLICY_TEXT = """Это типовой шаблон для предварительной демонстрации. Текст не проходил проверку юриста и не может использоваться как окончательная редакция без такой проверки. Администратор обязан заменить плейсхолдеры в квадратных скобках и, при необходимости, весь текст — под свою организацию и юрисдикцию.
|
||||
|
||||
1. Оператор персональных данных
|
||||
Оператором персональных данных, обрабатываемых при использовании сервиса [название сервиса], является: [полное наименование организации], [ОГРН/ИНН], адрес места нахождения: [адрес]. Контакты по вопросам обработки персональных данных: [email], [телефон].
|
||||
|
||||
2. Правовое основание обработки
|
||||
Обработка персональных данных осуществляется в соответствии с Конституцией Российской Федерации, Федеральным законом от 27.07.2006 № 152-ФЗ «О персональных данных» и принятыми в соответствии с ним нормативными правовыми актами, на основании согласия субъекта персональных данных (статья 9 Федерального закона № 152-ФЗ).
|
||||
|
||||
3. Состав и цели обработки
|
||||
При регистрации в сервисе обрабатываются следующие персональные данные: адрес электронной почты, имя и фамилия (или иное указанное пользователем имя), пароль (в виде хеша) [дополнить при необходимости].
|
||||
Цели обработки: [указать цели — например: создание учётной записи, идентификация пользователя, обеспечение доступа к видеоконференциям, направление служебных уведомлений].
|
||||
|
||||
4. Срок обработки и хранения
|
||||
Персональные данные хранятся в течение [указать срок — например: срока действия учётной записи и установленного законом срока после её удаления] либо до отзыва согласия, если это не противоречит требованиям законодательства.
|
||||
|
||||
5. Действия с персональными данными
|
||||
В отношении персональных данных совершаются следующие действия: сбор, запись, систематизация, накопление, хранение, уточнение, извлечение, использование, передача (в объёме, необходимом для функционирования сервиса), обезличивание, блокирование, удаление, уничтожение.
|
||||
|
||||
6. Права субъекта персональных данных
|
||||
Субъект персональных данных вправе получать информацию о том, как обрабатываются его персональные данные, требовать их уточнения, блокирования или уничтожения, а также отозвать согласие на обработку, обратившись по контактам, указанным в разделе 1.
|
||||
|
||||
7. Согласие
|
||||
Регистрируясь в сервисе, пользователь подтверждает, что ознакомлен с настоящим регламентом и даёт согласие на обработку своих персональных данных на условиях, изложенных выше."""
|
||||
"""Дефолтный текст регламента (ключ `consent_policy`) — согласован с оператором
|
||||
до встраивания в код (сессия 30). Шаблон с плейсхолдерами в квадратных
|
||||
скобках, без указания конкретной организации — администратор обязан
|
||||
заменить их под свою организацию перед вводом в эксплуатацию."""
|
||||
@@ -5,14 +5,24 @@
|
||||
транскодирования в `.ogg` на общий volume `recordings_dir`. Финализация
|
||||
результата (итоговый `location`/ошибка) приходит асинхронно через webhook
|
||||
`egress_ended` — здесь только сам запуск и `egress_id`/`started_at` из ответа.
|
||||
|
||||
Запуск вынесен из тела webhook-обработчика в фоновую задачу
|
||||
(`run_track_egress`) — см. докстринг этой функции.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import uuid
|
||||
from dataclasses import dataclass
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from livekit import api
|
||||
|
||||
from core.config import get_settings
|
||||
from core.db import async_session_maker
|
||||
from repositories.conferences import AudioTrackRepository
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
@@ -37,6 +47,10 @@ async def start_track_egress(room_name: str, track_sid: str, filepath: str) -> E
|
||||
api_secret=settings.livekit_api_secret,
|
||||
)
|
||||
try:
|
||||
# Без таймаута вызов висит до собственного таймаута psrpc LiveKit
|
||||
# (20–25 с, когда egress-воркера в деплое нет). `TimeoutError`
|
||||
# ловит вызывающая сторона наравне с прочими ошибками запуска.
|
||||
async with asyncio.timeout(settings.egress_start_timeout_s):
|
||||
info = await lkapi.egress.start_track_egress(
|
||||
api.TrackEgressRequest(
|
||||
room_name=room_name,
|
||||
@@ -56,3 +70,65 @@ async def start_track_egress(room_name: str, track_sid: str, filepath: str) -> E
|
||||
else datetime.now(UTC)
|
||||
)
|
||||
return EgressStartResult(egress_id=info.egress_id, started_at=started_at)
|
||||
|
||||
|
||||
async def run_track_egress(
|
||||
*,
|
||||
room_name: str,
|
||||
track_sid: str,
|
||||
filepath: str,
|
||||
session_id: uuid.UUID,
|
||||
participant_id: uuid.UUID,
|
||||
) -> None:
|
||||
"""Запустить Track Egress и записать строку трека — ФОНОВАЯ задача вебхука.
|
||||
|
||||
Почему не внутри обработчика. `POST /api/v1/livekit/webhook` держит
|
||||
соединение с БД и открытую транзакцию всё время своей работы (INSERT в
|
||||
`livekit_webhook_events` сделан, commit — после обработчика). Пока здесь
|
||||
жил сетевой вызов к egress, каждое событие `track_published` занимало
|
||||
соединение на 20–25 секунд, и на нагрузочном тесте 28.07.2026 пул
|
||||
выгребался за секунды: 226 ошибок `QueuePool limit`, 37 ответов 500 на
|
||||
путях входа в конференцию, 33 `dropped webhook` со стороны LiveKit
|
||||
(очередь доставки росла до 56 секунд).
|
||||
|
||||
Поэтому обработчик отвечает 200 сразу, а сюда попадает только то, что
|
||||
требует сети. Своя сессия БД (`async_session_maker`) обязательна: сессия
|
||||
запроса к этому моменту уже закрыта вместе с ответом.
|
||||
|
||||
Идемпотентность сохраняется: `AudioTrackRepository.create` — это
|
||||
`INSERT ... ON CONFLICT DO NOTHING` по `uq_session_track`, а проверка
|
||||
«трек уже пишется» осталась в обработчике.
|
||||
"""
|
||||
try:
|
||||
result = await start_track_egress(room_name, track_sid, filepath)
|
||||
except Exception as exc: # noqa: BLE001 — недоступность egress не должна ронять фон
|
||||
# Деплой-профиль (блок D): egress — необязательный сервис профиля
|
||||
# `transcribe`; без него запись просто не стартует для этого трека.
|
||||
# Строку `session_audio_tracks` не создаём — у нас нет `egress_id`,
|
||||
# по которому её мог бы финализировать `egress_ended`.
|
||||
logger.warning(
|
||||
"track_published: не удалось запустить egress для трека %s сеанса %s: %s",
|
||||
track_sid,
|
||||
session_id,
|
||||
exc,
|
||||
)
|
||||
return
|
||||
|
||||
async with async_session_maker() as session:
|
||||
await AudioTrackRepository(session).create(
|
||||
session_id=session_id,
|
||||
participant_id=participant_id,
|
||||
track_sid=track_sid,
|
||||
egress_id=result.egress_id,
|
||||
file_path=filepath,
|
||||
started_at=result.started_at,
|
||||
)
|
||||
await session.commit()
|
||||
|
||||
logger.info(
|
||||
"track_published: сеанс=%s участник=%s трек=%s egress=%s",
|
||||
session_id,
|
||||
participant_id,
|
||||
track_sid,
|
||||
result.egress_id,
|
||||
)
|
||||
|
||||
144
backend/services/hand_queue.py
Normal file
144
backend/services/hand_queue.py
Normal file
@@ -0,0 +1,144 @@
|
||||
"""Очередь поднятых рук конференции — состояние в Redis, не в Postgres (задача B1).
|
||||
|
||||
Транспорт для клиентов — тот же аутентифицированный WS чата (`api/chat.py`):
|
||||
переиспользуем уже открытые и держащиеся сервером соединения вместо отдельного
|
||||
эндпоинта. Хранение — Redis, а не БД: очередь существует ровно во время звонка
|
||||
и не должна переживать его завершение (в отличие от истории чата), а два
|
||||
процесса uvicorn (`UVICORN_WORKERS`) делают наивное состояние в памяти одного
|
||||
процесса недостаточным — организатор и участник могут оказаться на разных
|
||||
воркерах.
|
||||
|
||||
Один Redis-ключ (HASH) на конференцию: поле — identity участника (тот же
|
||||
формат, что в LiveKit-токене и вебхуках — `str(user_id)` или
|
||||
`guest:{guest_access.id}`), значение — JSON `{"name": ..., "raised_at": <unix
|
||||
epoch>}`. `HSETNX` даёт атомарное «добавить, только если ещё нет» — повторное
|
||||
поднятие уже поднятой руки НЕ сбрасывает её место в очереди (идемпотентно).
|
||||
Порядок — сортировкой по `raised_at` при чтении снапшота (участников в одной
|
||||
конференции — единицы-десятки, сортировка в Python здесь дешевле, чем держать
|
||||
вторую структуру (ZSET) синхронно с первой).
|
||||
"""
|
||||
|
||||
import json
|
||||
import time
|
||||
import uuid
|
||||
from dataclasses import dataclass
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from core.redis import redis_client
|
||||
from schemas.room_events import ForcedMuteOut, ForcedMuteSource, HandQueueEntryOut, HandQueueOut
|
||||
|
||||
# TTL ключа очереди — подстраховка на случай пропущенного webhook
|
||||
# `room_finished` (см. `services/webhook_handlers.py::_on_room_finished`,
|
||||
# который чистит очередь явно при штатном завершении). Сама конференция
|
||||
# столько не длится ни при каких сценариях.
|
||||
HAND_QUEUE_TTL_SECONDS = 24 * 60 * 60
|
||||
|
||||
|
||||
def hand_queue_key(conference_id: uuid.UUID) -> str:
|
||||
"""Redis-ключ HASH очереди поднятых рук конкретной конференции."""
|
||||
return f"hand_queue:{conference_id}"
|
||||
|
||||
|
||||
def hand_queue_channel(conference_id: uuid.UUID) -> str:
|
||||
"""Redis pub/sub канал событий комнаты (очередь рук + принудительный мьют, задача B2)."""
|
||||
return f"room_events:{conference_id}"
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class HandQueueEntry:
|
||||
"""Один участник в очереди поднятых рук."""
|
||||
|
||||
identity: str
|
||||
name: str
|
||||
raised_at: float
|
||||
|
||||
|
||||
async def raise_hand(conference_id: uuid.UUID, *, identity: str, name: str) -> bool:
|
||||
"""Поднять руку участника; `True` — рука реально поднялась (не была поднята раньше).
|
||||
|
||||
`HSETNX` — атомарная проверка-и-запись: если участник уже в очереди,
|
||||
ничего не меняет (в т.ч. НЕ обновляет `raised_at`) — переподключение и
|
||||
повторный клик не переставляют его в конец очереди.
|
||||
"""
|
||||
key = hand_queue_key(conference_id)
|
||||
payload = json.dumps({"name": name, "raised_at": time.time()})
|
||||
added = await redis_client.hsetnx(key, identity, payload)
|
||||
await redis_client.expire(key, HAND_QUEUE_TTL_SECONDS)
|
||||
return bool(added)
|
||||
|
||||
|
||||
async def lower_hand(conference_id: uuid.UUID, *, identity: str) -> bool:
|
||||
"""Опустить руку участника; `True` — рука была поднята и теперь снята."""
|
||||
removed = await redis_client.hdel(hand_queue_key(conference_id), identity)
|
||||
return bool(removed)
|
||||
|
||||
|
||||
async def snapshot(conference_id: uuid.UUID) -> list[HandQueueEntry]:
|
||||
"""Текущая очередь, упорядоченная по времени поднятия (раньше — раньше в списке)."""
|
||||
raw = await redis_client.hgetall(hand_queue_key(conference_id))
|
||||
entries = []
|
||||
for identity, payload in raw.items():
|
||||
try:
|
||||
data = json.loads(payload)
|
||||
entries.append(
|
||||
HandQueueEntry(
|
||||
identity=str(identity), name=data["name"], raised_at=data["raised_at"]
|
||||
)
|
||||
)
|
||||
except (ValueError, KeyError, TypeError):
|
||||
# Побитый/устаревшего формата элемент — пропускаем, а не роняем всю очередь.
|
||||
continue
|
||||
entries.sort(key=lambda entry: entry.raised_at)
|
||||
return entries
|
||||
|
||||
|
||||
async def clear(conference_id: uuid.UUID) -> None:
|
||||
"""Полностью снести очередь конференции (штатное завершение — `room_finished`)."""
|
||||
await redis_client.delete(hand_queue_key(conference_id))
|
||||
|
||||
|
||||
def _to_out(entries: list[HandQueueEntry]) -> HandQueueOut:
|
||||
"""Собрать исходящий снапшот из внутренних записей очереди."""
|
||||
return HandQueueOut(
|
||||
queue=[
|
||||
HandQueueEntryOut(
|
||||
identity=entry.identity,
|
||||
name=entry.name,
|
||||
raised_at=datetime.fromtimestamp(entry.raised_at, tz=UTC),
|
||||
)
|
||||
for entry in entries
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
async def get_snapshot_out(conference_id: uuid.UUID) -> HandQueueOut:
|
||||
"""Текущая очередь в исходящем формате — для отправки сразу после подключения к WS."""
|
||||
return _to_out(await snapshot(conference_id))
|
||||
|
||||
|
||||
async def publish_snapshot(conference_id: uuid.UUID) -> None:
|
||||
"""Опубликовать текущий снапшот очереди всем подписчикам канала комнаты.
|
||||
|
||||
Вызывается после любого изменения очереди (`raise_hand`/`lower_hand` —
|
||||
из `api/chat.py`, а также `participant_left`/`room_finished` — из
|
||||
`services/webhook_handlers.py`), чтобы у всех участников (и особенно у
|
||||
организатора, зашедшего позже) была всегда актуальная картина.
|
||||
"""
|
||||
payload = _to_out(await snapshot(conference_id))
|
||||
await redis_client.publish(hand_queue_channel(conference_id), payload.model_dump_json())
|
||||
|
||||
|
||||
async def publish_forced_mute(
|
||||
conference_id: uuid.UUID, *, identity: str, source: ForcedMuteSource
|
||||
) -> None:
|
||||
"""Оповестить всех участников комнаты о принудительном мьюте (задача B2).
|
||||
|
||||
Тот же канал, что и у очереди рук (`hand_queue_channel`) — `api/chat.py`
|
||||
пересылает с него ЛЮБОЙ JSON как есть, различая события по полю `type`
|
||||
(см. `_pump_pubsub_to_websocket`). Рассылается ВСЕМ, а не адресно
|
||||
затронутому участнику: канал общий на конференцию, адресной доставки
|
||||
одному соединению тут нет, поэтому клиент сам сверяет `identity` со
|
||||
своей (см. `ForcedMuteOut` в `schemas/room_events.py`).
|
||||
"""
|
||||
payload = ForcedMuteOut(identity=identity, source=source)
|
||||
await redis_client.publish(hand_queue_channel(conference_id), payload.model_dump_json())
|
||||
79
backend/services/images.py
Normal file
79
backend/services/images.py
Normal file
@@ -0,0 +1,79 @@
|
||||
"""Общая валидация загружаемых картинок: тип по содержимому и реальный размер.
|
||||
|
||||
Выделено из `services/avatars.py`, когда те же правила понадобились фонам
|
||||
видео (`services/backgrounds.py`): списки допустимых форматов и сигнатур
|
||||
должны быть в одном месте — разъехавшись, они дали бы дыру ровно в том
|
||||
месте, ради которого проверка и написана.
|
||||
|
||||
Правила намеренно не смягчаются для «уже проверенных на клиенте» файлов:
|
||||
браузер жмёт картинку фона перед отправкой (canvas → WebP), но запрос к API
|
||||
может прийти и мимо интерфейса — клиенту верить нельзя.
|
||||
"""
|
||||
|
||||
from collections.abc import Callable
|
||||
|
||||
from fastapi import UploadFile
|
||||
|
||||
# Читаем файл чанками, не доверяя заголовку `Content-Length` (клиент может
|
||||
# солгать о размере) — реальный размер считается по факту прочитанных байт.
|
||||
_CHUNK_SIZE_BYTES = 64 * 1024
|
||||
|
||||
# Допустимые типы изображений -> расширение файла на диске.
|
||||
ALLOWED_IMAGE_CONTENT_TYPES: dict[str, str] = {
|
||||
"image/jpeg": "jpg",
|
||||
"image/png": "png",
|
||||
"image/webp": "webp",
|
||||
}
|
||||
|
||||
# Магические байты (сигнатуры) форматов — заголовку `Content-Type` от клиента
|
||||
# доверять нельзя (легко подделать), реальный формат определяется по
|
||||
# содержимому файла.
|
||||
_MAGIC_CHECKS: dict[str, Callable[[bytes], bool]] = {
|
||||
"image/jpeg": lambda head: head[:3] == b"\xff\xd8\xff",
|
||||
"image/png": lambda head: head[:8] == b"\x89PNG\r\n\x1a\n",
|
||||
"image/webp": lambda head: head[:4] == b"RIFF" and head[8:12] == b"WEBP",
|
||||
}
|
||||
|
||||
# Достаточно первых 12 байт, чтобы проверить все сигнатуры выше (WebP —
|
||||
# самая длинная проверка, требует байты 8..11 включительно).
|
||||
_MAGIC_HEAD_SIZE = 12
|
||||
|
||||
|
||||
class ImageTooLargeError(Exception):
|
||||
"""Загружаемый файл превышает переданный лимит размера (413)."""
|
||||
|
||||
|
||||
class ImageInvalidTypeError(Exception):
|
||||
"""`Content-Type` не входит в список допустимых либо не совпадает с содержимым (415)."""
|
||||
|
||||
|
||||
async def read_and_validate_image(file: UploadFile, max_size_bytes: int) -> tuple[bytes, str]:
|
||||
"""Прочитать файл чанками и провалидировать тип/размер.
|
||||
|
||||
Возвращает `(содержимое, расширение)`. Порядок проверок: сначала
|
||||
заявленный `Content-Type` (быстрый отсев), затем фактический размер по
|
||||
мере чтения, затем магические байты содержимого — заявленный тип должен
|
||||
совпасть с реальным (иначе подделка `Content-Type` не даст загрузить,
|
||||
например, исполняемый файл под видом `image/png`).
|
||||
"""
|
||||
declared_type = file.content_type
|
||||
if declared_type not in ALLOWED_IMAGE_CONTENT_TYPES:
|
||||
raise ImageInvalidTypeError(f"unsupported_content_type: {declared_type}")
|
||||
|
||||
chunks: list[bytes] = []
|
||||
total_size = 0
|
||||
while True:
|
||||
chunk = await file.read(_CHUNK_SIZE_BYTES)
|
||||
if not chunk:
|
||||
break
|
||||
total_size += len(chunk)
|
||||
if total_size > max_size_bytes:
|
||||
raise ImageTooLargeError(f"file exceeds {max_size_bytes} bytes")
|
||||
chunks.append(chunk)
|
||||
content = b"".join(chunks)
|
||||
|
||||
magic_check = _MAGIC_CHECKS[declared_type]
|
||||
if not magic_check(content[:_MAGIC_HEAD_SIZE]):
|
||||
raise ImageInvalidTypeError("content_does_not_match_declared_content_type")
|
||||
|
||||
return content, ALLOWED_IMAGE_CONTENT_TYPES[declared_type]
|
||||
@@ -1,8 +1,9 @@
|
||||
"""Хранилище настроек инстанса (`instance_settings`, key-value JSONB) и их бутстрап.
|
||||
|
||||
Ключи зеркалят секции конфигурации (`transcriber`, `summarizer`, `chat`,
|
||||
`ai_level`, `summary_recipients`, `display_timezone`,
|
||||
`registration_team_choice`, `registration_email_domain`, `contact_email`) —
|
||||
`hand_queue`, `ai_level`, `summary_recipients`, `display_timezone`,
|
||||
`registration_team_choice`, `registration_email_domain`, `contact_email`,
|
||||
`media_limits`) —
|
||||
новая настройка не требует миграции, только новая строка. Бутстрап (`ensure_bootstrapped`)
|
||||
импортирует дефолты `config/plugins.yaml` через `INSERT ... ON CONFLICT DO
|
||||
NOTHING` в lifespan backend — однократно и идемпотентно: повторный вызов
|
||||
@@ -27,8 +28,12 @@ from core.config import Settings
|
||||
from core.plugins.config import (
|
||||
AiLevel,
|
||||
ChatConfig,
|
||||
HandQueueConfig,
|
||||
InstanceConfig,
|
||||
MediaLimitsConfig,
|
||||
PluginsConfig,
|
||||
PublishQualityCap,
|
||||
StageMaxTiles,
|
||||
SummarizerConfig,
|
||||
SummaryRecipientsMode,
|
||||
TranscriberConfig,
|
||||
@@ -37,16 +42,22 @@ from core.plugins.config import (
|
||||
from models.instance_setting import InstanceSetting
|
||||
from services.ai_levels import detect_ai_levels
|
||||
from services.ai_tiers import TIERS
|
||||
from services.consent_policy_text import DEFAULT_CONSENT_POLICY_TEXT
|
||||
|
||||
_KEY_TRANSCRIBER = "transcriber"
|
||||
_KEY_SUMMARIZER = "summarizer"
|
||||
_KEY_CHAT = "chat"
|
||||
_KEY_HAND_QUEUE = "hand_queue"
|
||||
_KEY_AI_LEVEL = "ai_level"
|
||||
_KEY_SUMMARY_RECIPIENTS = "summary_recipients"
|
||||
_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"
|
||||
_KEY_CONSENT_POLICY = "consent_policy"
|
||||
_KEY_DEVICE_CHECK = "device_check"
|
||||
_KEY_VIRTUAL_BACKGROUND = "virtual_background"
|
||||
|
||||
BOOTSTRAP_MANAGED_KEYS: tuple[str, ...] = (
|
||||
_KEY_CHAT,
|
||||
@@ -59,6 +70,26 @@ BOOTSTRAP_MANAGED_KEYS: tuple[str, ...] = (
|
||||
`scripts/apply_preset_settings.py`, чтобы не дублировать список строковых
|
||||
имён ключей `instance_settings`."""
|
||||
|
||||
MANAGED_KEYS: tuple[str, ...] = (
|
||||
_KEY_TRANSCRIBER,
|
||||
_KEY_SUMMARIZER,
|
||||
_KEY_CHAT,
|
||||
_KEY_HAND_QUEUE,
|
||||
_KEY_AI_LEVEL,
|
||||
_KEY_SUMMARY_RECIPIENTS,
|
||||
_KEY_DISPLAY_TIMEZONE,
|
||||
_KEY_REGISTRATION_TEAM_CHOICE,
|
||||
_KEY_REGISTRATION_EMAIL_DOMAIN,
|
||||
_KEY_CONTACT_EMAIL,
|
||||
_KEY_MEDIA_LIMITS,
|
||||
_KEY_CONSENT_POLICY,
|
||||
_KEY_DEVICE_CHECK,
|
||||
_KEY_VIRTUAL_BACKGROUND,
|
||||
)
|
||||
"""Все ключи, которыми управляет `InstanceSettingsService` — единый источник истины
|
||||
для тестовой изоляции от состояния `instance_settings` в общей dev-БД
|
||||
(`tests/conftest.py::clean_instance_settings`)."""
|
||||
|
||||
_DEFAULT_AI_LEVEL_VALUE = {"level": "min"}
|
||||
_DEFAULT_SUMMARY_RECIPIENTS_VALUE = {"mode": "all"}
|
||||
_DEFAULT_DISPLAY_TIMEZONE_VALUE = {"tz": "Europe/Moscow"}
|
||||
@@ -70,6 +101,18 @@ _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}
|
||||
_DEFAULT_DEVICE_CHECK_VALUE = {"enabled": False}
|
||||
_DEFAULT_VIRTUAL_BACKGROUND_VALUE = {"enabled": False}
|
||||
"""Замена фона видео (сессия 35). Дефолт — выключено: фича постоянно считает
|
||||
нейросеть сегментации на клиенте, и включать её самим фактом обновления у тех,
|
||||
кто ничего не просил, нельзя (то же правило, что и у остальных модулей)."""
|
||||
|
||||
_DEFAULT_CONSENT_POLICY_VALUE: dict[str, Any] = {
|
||||
"enabled": False,
|
||||
"text": DEFAULT_CONSENT_POLICY_TEXT,
|
||||
"version": 1,
|
||||
}
|
||||
|
||||
# Простой паттерн доменного имени: минимум один символ, минимум одна точка,
|
||||
# метки из латинских букв/цифр/дефисов (без ведущего/конечного дефиса),
|
||||
@@ -94,6 +137,7 @@ class SettingsUpdateIn(BaseModel):
|
||||
"""
|
||||
|
||||
chat_enabled: bool | None = None
|
||||
hand_queue_enabled: bool | None = None
|
||||
transcription_enabled: bool | None = None
|
||||
ai_level: AiLevel | None = None
|
||||
summary_recipients: SummaryRecipientsMode | None = None
|
||||
@@ -103,6 +147,12 @@ 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
|
||||
consent_required: bool | None = None
|
||||
consent_policy_text: str | None = None
|
||||
device_check_enabled: bool | None = None
|
||||
virtual_background_enabled: bool | None = None
|
||||
|
||||
|
||||
class BootstrapOverrides(BaseModel):
|
||||
@@ -146,12 +196,17 @@ def build_bootstrap_defaults(
|
||||
_KEY_TRANSCRIBER: plugins.transcriber.model_dump(mode="json"),
|
||||
_KEY_SUMMARIZER: plugins.summarizer.model_dump(mode="json"),
|
||||
_KEY_CHAT: plugins.chat.model_dump(mode="json"),
|
||||
_KEY_HAND_QUEUE: plugins.hand_queue.model_dump(mode="json"),
|
||||
_KEY_AI_LEVEL: dict(_DEFAULT_AI_LEVEL_VALUE),
|
||||
_KEY_SUMMARY_RECIPIENTS: dict(_DEFAULT_SUMMARY_RECIPIENTS_VALUE),
|
||||
_KEY_DISPLAY_TIMEZONE: dict(_DEFAULT_DISPLAY_TIMEZONE_VALUE),
|
||||
_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),
|
||||
_KEY_CONSENT_POLICY: dict(_DEFAULT_CONSENT_POLICY_VALUE),
|
||||
_KEY_DEVICE_CHECK: dict(_DEFAULT_DEVICE_CHECK_VALUE),
|
||||
_KEY_VIRTUAL_BACKGROUND: dict(_DEFAULT_VIRTUAL_BACKGROUND_VALUE),
|
||||
}
|
||||
if overrides is None:
|
||||
return defaults
|
||||
@@ -188,6 +243,11 @@ class InvalidEmailDomainError(ValueError):
|
||||
"""
|
||||
|
||||
|
||||
class InvalidConsentPolicyError(ValueError):
|
||||
"""Попытка включить обязательное согласие при пустом тексте регламента
|
||||
(`consent_required=True` без непустого `consent_policy_text`)."""
|
||||
|
||||
|
||||
class InvalidContactEmailError(ValueError):
|
||||
"""Некорректная настройка контактного адреса инстанса.
|
||||
|
||||
@@ -277,6 +337,18 @@ class InstanceSettingsService:
|
||||
cfg.chat = ChatConfig(enabled=patch.chat_enabled)
|
||||
await self._set(_KEY_CHAT, cfg.chat.model_dump(mode="json"))
|
||||
|
||||
if patch.hand_queue_enabled is not None:
|
||||
cfg.hand_queue = HandQueueConfig(enabled=patch.hand_queue_enabled)
|
||||
await self._set(_KEY_HAND_QUEUE, cfg.hand_queue.model_dump(mode="json"))
|
||||
|
||||
if patch.device_check_enabled is not None:
|
||||
cfg.device_check_enabled = patch.device_check_enabled
|
||||
await self._set(_KEY_DEVICE_CHECK, {"enabled": patch.device_check_enabled})
|
||||
|
||||
if patch.virtual_background_enabled is not None:
|
||||
cfg.virtual_background_enabled = patch.virtual_background_enabled
|
||||
await self._set(_KEY_VIRTUAL_BACKGROUND, {"enabled": patch.virtual_background_enabled})
|
||||
|
||||
if patch.registration_team_choice is not None:
|
||||
cfg.registration_team_choice = patch.registration_team_choice
|
||||
await self._set(
|
||||
@@ -340,6 +412,50 @@ 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"))
|
||||
|
||||
if patch.consent_required is not None or patch.consent_policy_text is not None:
|
||||
consent_required = (
|
||||
patch.consent_required
|
||||
if patch.consent_required is not None
|
||||
else cfg.consent_required
|
||||
)
|
||||
consent_text = (
|
||||
patch.consent_policy_text.strip()
|
||||
if patch.consent_policy_text is not None
|
||||
else cfg.consent_policy_text
|
||||
)
|
||||
if consent_required and not consent_text:
|
||||
raise InvalidConsentPolicyError(
|
||||
"нельзя включить обязательное согласие с пустым текстом регламента"
|
||||
)
|
||||
# Версия — счётчик редакций текста, а не хеш/дата: администратору
|
||||
# проще сослаться на «редакцию №3», чем на хеш, а инкремент (в
|
||||
# отличие от даты) однозначно фиксирует факт правки даже при
|
||||
# повторном сохранении одного и того же текста в одну секунду.
|
||||
consent_version = cfg.consent_policy_version
|
||||
if consent_text != cfg.consent_policy_text:
|
||||
consent_version += 1
|
||||
cfg.consent_required = consent_required
|
||||
cfg.consent_policy_text = consent_text
|
||||
cfg.consent_policy_version = consent_version
|
||||
await self._set(
|
||||
_KEY_CONSENT_POLICY,
|
||||
{"enabled": consent_required, "text": consent_text, "version": consent_version},
|
||||
)
|
||||
|
||||
await self._session.commit()
|
||||
return cfg
|
||||
|
||||
@@ -376,6 +492,7 @@ async def load_effective_config(session: AsyncSession) -> InstanceConfig:
|
||||
transcriber=plugins.transcriber,
|
||||
summarizer=plugins.summarizer,
|
||||
chat=plugins.chat,
|
||||
hand_queue=plugins.hand_queue,
|
||||
)
|
||||
else:
|
||||
cfg = _build_config(rows)
|
||||
@@ -469,6 +586,7 @@ def _build_config(rows: dict[str, Any]) -> InstanceConfig:
|
||||
transcriber=TranscriberConfig.model_validate(rows.get(_KEY_TRANSCRIBER, {})),
|
||||
summarizer=SummarizerConfig.model_validate(rows.get(_KEY_SUMMARIZER, {})),
|
||||
chat=ChatConfig.model_validate(rows.get(_KEY_CHAT, {})),
|
||||
hand_queue=HandQueueConfig.model_validate(rows.get(_KEY_HAND_QUEUE, {})),
|
||||
ai_level=rows.get(_KEY_AI_LEVEL, _DEFAULT_AI_LEVEL_VALUE).get("level", "min"),
|
||||
summary_recipients=rows.get(_KEY_SUMMARY_RECIPIENTS, _DEFAULT_SUMMARY_RECIPIENTS_VALUE).get(
|
||||
"mode", "all"
|
||||
@@ -489,4 +607,22 @@ 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)
|
||||
),
|
||||
consent_required=rows.get(_KEY_CONSENT_POLICY, _DEFAULT_CONSENT_POLICY_VALUE).get(
|
||||
"enabled", False
|
||||
),
|
||||
consent_policy_text=rows.get(_KEY_CONSENT_POLICY, _DEFAULT_CONSENT_POLICY_VALUE).get(
|
||||
"text", DEFAULT_CONSENT_POLICY_TEXT
|
||||
),
|
||||
consent_policy_version=rows.get(_KEY_CONSENT_POLICY, _DEFAULT_CONSENT_POLICY_VALUE).get(
|
||||
"version", 1
|
||||
),
|
||||
device_check_enabled=rows.get(_KEY_DEVICE_CHECK, _DEFAULT_DEVICE_CHECK_VALUE).get(
|
||||
"enabled", False
|
||||
),
|
||||
virtual_background_enabled=rows.get(
|
||||
_KEY_VIRTUAL_BACKGROUND, _DEFAULT_VIRTUAL_BACKGROUND_VALUE
|
||||
).get("enabled", False),
|
||||
)
|
||||
|
||||
81
backend/services/room_control.py
Normal file
81
backend/services/room_control.py
Normal file
@@ -0,0 +1,81 @@
|
||||
"""Управление комнатой LiveKit от имени организатора (задача B2): принудительный мьют.
|
||||
|
||||
Тонкая обёртка над `RoomServiceClient` — тот же паттерн, что и
|
||||
`services/egress.py` (единственная точка мокирования в тестах, свой
|
||||
`api.LiveKitAPI` на вызов, аутентификация СЕРВЕРНЫМИ `api_key`/`api_secret`,
|
||||
а не токеном организатора). Именно поэтому организатору не нужен отдельный
|
||||
LiveKit-грант в собственном access-токене под это действие — мьютит backend
|
||||
от своего имени, клиент лишь инициирует вызов, а право на это проверяется
|
||||
по владельцу конференции в БД (`services/conferences.py::mute_participant`),
|
||||
ДО обращения сюда.
|
||||
"""
|
||||
|
||||
import logging
|
||||
|
||||
from livekit import api
|
||||
from livekit.protocol.models import TrackSource
|
||||
|
||||
from core.config import get_settings
|
||||
from schemas.room_events import ForcedMuteSource
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Переэкспорт под более общим именем — этот модуль не завязан на протокол WS
|
||||
# (`schemas/room_events.py`), которому концептуально принадлежит `ForcedMuteSource`.
|
||||
MuteSource = ForcedMuteSource
|
||||
|
||||
_TRACK_SOURCE_BY_NAME: dict[MuteSource, int] = {
|
||||
"microphone": TrackSource.MICROPHONE,
|
||||
"camera": TrackSource.CAMERA,
|
||||
}
|
||||
|
||||
|
||||
class ParticipantNotInRoomError(Exception):
|
||||
"""Участника с таким identity сейчас нет в комнате LiveKit (уже вышел/не заходил)."""
|
||||
|
||||
|
||||
async def mute_participant_track(room_name: str, *, identity: str, source: MuteSource) -> bool:
|
||||
"""Принудительно замьютить опубликованный трек участника; `True` — трек реально замьючен.
|
||||
|
||||
Если трек данного `source` сейчас не опубликован — не ошибка, а no-op:
|
||||
искомое состояние («трек не идёт») уже достигнуто. Обычный случай с
|
||||
0.0.15 — участники заходят с выключенными микрофоном/камерой (задача
|
||||
A1), трек попросту не существует, пока человек не включит его сам;
|
||||
мьютить в этот момент нечего, и это НЕ повод отвечать клиенту ошибкой.
|
||||
"""
|
||||
settings = get_settings()
|
||||
lkapi = api.LiveKitAPI(
|
||||
settings.livekit_url,
|
||||
api_key=settings.livekit_api_key,
|
||||
api_secret=settings.livekit_api_secret,
|
||||
)
|
||||
try:
|
||||
try:
|
||||
participant = await lkapi.room.get_participant(
|
||||
api.RoomParticipantIdentity(room=room_name, identity=identity)
|
||||
)
|
||||
except api.TwirpError as exc:
|
||||
if exc.status == 404:
|
||||
raise ParticipantNotInRoomError from exc
|
||||
raise
|
||||
|
||||
target_source = _TRACK_SOURCE_BY_NAME[source]
|
||||
track = next((t for t in participant.tracks if t.source == target_source), None)
|
||||
if track is None or track.muted:
|
||||
return False
|
||||
|
||||
await lkapi.room.mute_published_track(
|
||||
api.MuteRoomTrackRequest(
|
||||
room=room_name, identity=identity, track_sid=track.sid, muted=True
|
||||
)
|
||||
)
|
||||
logger.info(
|
||||
"room_control: принудительный мьют — комната=%s identity=%s source=%s трек=%s",
|
||||
room_name,
|
||||
identity,
|
||||
source,
|
||||
track.sid,
|
||||
)
|
||||
return True
|
||||
finally:
|
||||
await lkapi.aclose()
|
||||
@@ -13,6 +13,7 @@
|
||||
|
||||
import logging
|
||||
import uuid
|
||||
from collections.abc import Callable
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from livekit.protocol.egress import EgressStatus
|
||||
@@ -26,7 +27,8 @@ from repositories.conferences import (
|
||||
ConferenceRepository,
|
||||
ConferenceSessionRepository,
|
||||
)
|
||||
from services.egress import start_track_egress
|
||||
from services import hand_queue
|
||||
from services.egress import run_track_egress
|
||||
from services.instance_settings import InstanceSettingsService
|
||||
from services.pipeline_producer import enqueue_pipeline
|
||||
|
||||
@@ -53,13 +55,27 @@ def _egress_ns_to_datetime(nanoseconds: int) -> datetime | None:
|
||||
|
||||
|
||||
class WebhookDispatcher:
|
||||
"""Диспатчит `WebhookEvent` на обработчик по типу события."""
|
||||
"""Диспатчит `WebhookEvent` на обработчик по типу события.
|
||||
|
||||
def __init__(self, session: AsyncSession) -> None:
|
||||
`schedule` — планировщик фоновых задач: вызывается как
|
||||
`schedule(coro_func, **kwargs)` и обязан вернуть управление немедленно,
|
||||
не дожидаясь выполнения. В приложении это `BackgroundTasks.add_task`
|
||||
FastAPI (задача стартует после отправки ответа), в тестах — вызовы
|
||||
просто записываются. Через него уходит запуск Track Egress: сетевому
|
||||
вызову не место внутри транзакции вебхука (см. `_on_track_published`).
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
session: AsyncSession,
|
||||
*,
|
||||
schedule: Callable[..., object],
|
||||
) -> None:
|
||||
self._conferences = ConferenceRepository(session)
|
||||
self._sessions = ConferenceSessionRepository(session)
|
||||
self._audio_tracks = AudioTrackRepository(session)
|
||||
self._instance_settings = InstanceSettingsService(session)
|
||||
self._schedule = schedule
|
||||
|
||||
async def dispatch(self, event: WebhookEvent) -> None:
|
||||
"""Обработать одно webhook-событие; неизвестный тип события — no-op."""
|
||||
@@ -139,6 +155,17 @@ class WebhookDispatcher:
|
||||
return
|
||||
user_id, guest_id = identity
|
||||
|
||||
# Очередь поднятых рук живёт в Redis по `conference.id`, независимо
|
||||
# от `ConferenceSession` (задача B1) — снимаем руку СРАЗУ, до guard'а
|
||||
# на отсутствующий открытый сеанс ниже: пропущенный/задержанный
|
||||
# `room_started` не должен оставлять фантомную запись в очереди у
|
||||
# реально вышедшего участника. Не путать с обрывом WS-соединения
|
||||
# самой очереди рук — то живёт своей жизнью и переживается без
|
||||
# потери места (см. `services/hand_queue.py`).
|
||||
removed = await hand_queue.lower_hand(conference.id, identity=event.participant.identity)
|
||||
if removed:
|
||||
await hand_queue.publish_snapshot(conference.id)
|
||||
|
||||
session_record = await self._sessions.get_open_by_conference(conference.id)
|
||||
if session_record is None:
|
||||
logger.warning(
|
||||
@@ -161,15 +188,32 @@ class WebhookDispatcher:
|
||||
)
|
||||
|
||||
async def _on_track_published(self, event: WebhookEvent) -> None:
|
||||
"""Запустить Track Egress для опубликованного аудиотрека микрофона (ADR-002).
|
||||
"""Запланировать Track Egress для опубликованного аудиотрека микрофона (ADR-002).
|
||||
|
||||
Видео/скриншеринг и т.п. — no-op (диаризация не нужна: транскрибируем
|
||||
только речь, трек = спикер). Идемпотентно: если строка трека уже
|
||||
существует (гонка повторной доставки), egress повторно не запускается.
|
||||
|
||||
Сам запуск уходит в фоновую задачу (`services.egress.run_track_egress`):
|
||||
здесь остаются только быстрые проверки по БД, потому что обработчик
|
||||
выполняется внутри открытой транзакции вебхука. Обоснование с цифрами —
|
||||
в докстринге `run_track_egress`.
|
||||
"""
|
||||
if event.track.type != TrackType.AUDIO or event.track.source != TrackSource.MICROPHONE:
|
||||
return
|
||||
|
||||
# Транскрибация выключена — записывать нечего. Тот же guard, что и в
|
||||
# `_on_room_finished`: без него на инстансе без профиля `transcribe`
|
||||
# (egress-контейнера в деплое нет) каждый микрофон превращался в
|
||||
# заведомо безнадёжный сетевой вызов длиной в 20–25 секунд.
|
||||
cfg = await self._instance_settings.get()
|
||||
if not cfg.transcriber.enabled:
|
||||
logger.debug(
|
||||
"livekit webhook track_published: транскрибация выключена — трек %s пропущен",
|
||||
event.track.sid,
|
||||
)
|
||||
return
|
||||
|
||||
conference = await self._conferences.get_by_slug(event.room.name)
|
||||
if conference is None:
|
||||
logger.warning(
|
||||
@@ -216,37 +260,13 @@ class WebhookDispatcher:
|
||||
filepath = (
|
||||
f"{settings.recordings_dir}/{session_record.id}/{participant.id}_{event.track.sid}.ogg"
|
||||
)
|
||||
try:
|
||||
result = await start_track_egress(event.room.name, event.track.sid, filepath)
|
||||
except Exception as exc: # noqa: BLE001 — недоступность egress не должна ронять webhook
|
||||
# Деплой-профиль (блок D): egress — необязательный сервис профиля
|
||||
# `transcribe`; без него запись просто не стартует для этого трека
|
||||
# (риск «Потерян webhook track_published»).
|
||||
# Строку `session_audio_tracks` не создаём — у нас нет `egress_id`,
|
||||
# по которому её мог бы финализировать `egress_ended`.
|
||||
logger.warning(
|
||||
"livekit webhook track_published: не удалось запустить egress для трека %s "
|
||||
"сеанса %s: %s",
|
||||
event.track.sid,
|
||||
session_record.id,
|
||||
exc,
|
||||
)
|
||||
return
|
||||
|
||||
await self._audio_tracks.create(
|
||||
self._schedule(
|
||||
run_track_egress,
|
||||
room_name=event.room.name,
|
||||
track_sid=event.track.sid,
|
||||
filepath=filepath,
|
||||
session_id=session_record.id,
|
||||
participant_id=participant.id,
|
||||
track_sid=event.track.sid,
|
||||
egress_id=result.egress_id,
|
||||
file_path=filepath,
|
||||
started_at=result.started_at,
|
||||
)
|
||||
logger.info(
|
||||
"livekit webhook track_published: сеанс=%s участник=%s трек=%s egress=%s",
|
||||
session_record.id,
|
||||
participant.id,
|
||||
event.track.sid,
|
||||
result.egress_id,
|
||||
)
|
||||
|
||||
async def _on_egress_ended(self, event: WebhookEvent) -> None:
|
||||
@@ -300,6 +320,10 @@ class WebhookDispatcher:
|
||||
now = datetime.now(UTC)
|
||||
await self._sessions.close(session_record, t_end=now)
|
||||
await self._sessions.close_all_open_participants(session_id=session_record.id, left_at=now)
|
||||
# Очередь поднятых рук — состояние звонка, не история; следующий
|
||||
# заход (в т.ч. у закреплённой конференции) должен начинать с чистой
|
||||
# очереди, а не наследовать поднятые руки из прошлого раза.
|
||||
await hand_queue.clear(conference.id)
|
||||
|
||||
# Незакреплённая умирает по завершении (история/саммари остаются);
|
||||
# закреплённая возвращается в ожидание следующего вхождения (ADR-001, п.2).
|
||||
|
||||
@@ -21,10 +21,11 @@ from sqlalchemy.dialects.postgresql import insert as pg_insert
|
||||
from sqlalchemy.ext.asyncio import AsyncConnection, AsyncSession
|
||||
from starlette.types import Message, Scope
|
||||
|
||||
from core.db import engine, get_session
|
||||
from core.db import engine, get_metrics_session, get_session
|
||||
from core.redis import redis_client
|
||||
from main import create_app
|
||||
from models.instance_setting import InstanceSetting
|
||||
from services.instance_settings import MANAGED_KEYS
|
||||
|
||||
|
||||
@pytest_asyncio.fixture(autouse=True)
|
||||
@@ -88,6 +89,37 @@ async def _load_committed_instance_settings() -> dict[str, Any]:
|
||||
return {key: value for key, value in result.all()}
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def clean_instance_settings(db_session: AsyncSession) -> AsyncGenerator[None, None]:
|
||||
"""Изолировать тест от текущего состояния управляемых ключей `instance_settings` в
|
||||
общей dev-БД (тоггл чата, поднятия руки и т.п. — живые настройки разработчика, а не
|
||||
тестовые данные).
|
||||
|
||||
Удаляет строки `MANAGED_KEYS` внутри savepoint-транзакции теста (`db_session`) —
|
||||
последующее чтение конфигурации (`InstanceSettingsService.get`/`load_effective_config`)
|
||||
падает на дефолты pydantic-моделей (например, `ChatConfig.enabled == True`), одинаковые
|
||||
независимо от того, что реально сохранено в dev-БД в момент прогона. Savepoint
|
||||
откатывается в `db_connection` по завершении теста — восстанавливать исходные строки
|
||||
вручную не нужно, в отличие от `_preserve_instance_settings` (та фикстура страхует от
|
||||
записи МИМО savepoint, эта — от чтения ИЗ него состояния, унаследованного от dev-БД).
|
||||
|
||||
НЕ autouse и намеренно: `DELETE` внутри savepoint держит Postgres-блокировку на
|
||||
строке до конца ВНЕШНЕЙ транзакции теста (`db_connection`, откатывается только в
|
||||
teardown) — savepoint её не освобождает раньше срока. Тесты, которые параллельно
|
||||
внутри СЕБЯ же пишут в те же ключи через ОТДЕЛЬНОЕ реальное подключение
|
||||
(`async_session_maker`/`engine.connect()` — см. `test_transcription_disabled_setting_
|
||||
stops_run_pipeline`, `clean_bootstrap_managed_keys`), заблокировались бы сами на себе,
|
||||
если бы эта фикстура применялась к ним автоматически (наступили при первой попытке
|
||||
сделать её autouse — само-дедлок, тест висел до ручного убийства процесса). Поэтому
|
||||
запрашивать явно, только в тестах, где именно ОНА обеспечивает изоляцию (WS чата/
|
||||
очереди рук и т.п.), а не там, где тест сам управляет состоянием через реальные
|
||||
коммиты.
|
||||
"""
|
||||
await db_session.execute(delete(InstanceSetting).where(InstanceSetting.key.in_(MANAGED_KEYS)))
|
||||
await db_session.commit()
|
||||
yield
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def db_connection() -> AsyncGenerator[AsyncConnection, None]:
|
||||
async with engine.connect() as connection:
|
||||
@@ -113,13 +145,21 @@ async def db_session(db_connection: AsyncConnection) -> AsyncGenerator[AsyncSess
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def app(db_session: AsyncSession) -> AsyncGenerator[FastAPI, None]:
|
||||
"""Экземпляр FastAPI-приложения с `get_session`, подменённым на тестовую (savepoint) сессию."""
|
||||
"""Экземпляр FastAPI-приложения с `get_session`/`get_metrics_session`,
|
||||
подменёнными на тестовую (savepoint) сессию.
|
||||
|
||||
`get_metrics_session` (сессия 37, `core/db.py`) в проде — отдельный от
|
||||
основного пула движок, но в тестах должен указывать на ТУ ЖЕ savepoint-
|
||||
сессию, что и `get_session` — иначе `/metrics` не видел бы данные теста,
|
||||
ещё не закоммиченные за пределы savepoint (см. `test_metrics_api.py`).
|
||||
"""
|
||||
application = create_app()
|
||||
|
||||
async def _override_get_session() -> AsyncGenerator[AsyncSession, None]:
|
||||
yield db_session
|
||||
|
||||
application.dependency_overrides[get_session] = _override_get_session
|
||||
application.dependency_overrides[get_metrics_session] = _override_get_session
|
||||
yield application
|
||||
|
||||
|
||||
|
||||
@@ -33,7 +33,7 @@ async def _make_user(session: AsyncSession, *, role: str = "user") -> User:
|
||||
user = User(
|
||||
email=f"{uuid.uuid4()}@example.com",
|
||||
name_user="Admin API Tester",
|
||||
password_hash=hash_password("password123"),
|
||||
password_hash=await hash_password("password123"),
|
||||
email_verified=True,
|
||||
role=role,
|
||||
)
|
||||
@@ -646,12 +646,17 @@ async def test_put_settings_partial_update(
|
||||
|
||||
response = await client.put(
|
||||
"/api/v1/admin/settings",
|
||||
json={"chat_enabled": False, "display_timezone": "Asia/Yekaterinburg"},
|
||||
json={
|
||||
"chat_enabled": False,
|
||||
"hand_queue_enabled": False,
|
||||
"display_timezone": "Asia/Yekaterinburg",
|
||||
},
|
||||
headers=_auth_headers(admin),
|
||||
)
|
||||
assert response.status_code == 200, response.text
|
||||
body = response.json()
|
||||
assert body["chat_enabled"] is False
|
||||
assert body["hand_queue_enabled"] is False
|
||||
assert body["display_timezone"] == "Asia/Yekaterinburg"
|
||||
assert body["ai_level"] == "min"
|
||||
assert body["transcription_queue_served"] is False
|
||||
@@ -706,6 +711,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
|
||||
|
||||
|
||||
# --- Тестовое письмо ----------------------------------------------------------------
|
||||
|
||||
|
||||
|
||||
@@ -20,7 +20,7 @@ async def _make_user(session: AsyncSession, *, role: str = "user") -> User:
|
||||
user = User(
|
||||
email=f"{uuid.uuid4()}@example.com",
|
||||
name_user="Team API Tester",
|
||||
password_hash=hash_password("password123"),
|
||||
password_hash=await hash_password("password123"),
|
||||
email_verified=True,
|
||||
role=role,
|
||||
)
|
||||
|
||||
@@ -9,6 +9,7 @@ from typing import Annotated
|
||||
|
||||
import httpx
|
||||
import pytest_asyncio
|
||||
from argon2 import PasswordHasher
|
||||
from fastapi import Depends, FastAPI
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
@@ -16,6 +17,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from api.auth import get_auth_service
|
||||
from core.db import get_session
|
||||
from core.redis import redis_client
|
||||
from core.security import needs_rehash
|
||||
from models.team import Team
|
||||
from models.user import User
|
||||
from services.auth import AuthService
|
||||
@@ -38,7 +40,11 @@ async def _reset_registration_gating(db_session: AsyncSession) -> None:
|
||||
`conftest.py`).
|
||||
"""
|
||||
await InstanceSettingsService(db_session).update(
|
||||
SettingsUpdateIn(registration_team_choice=False, registration_email_domain_enabled=False)
|
||||
SettingsUpdateIn(
|
||||
registration_team_choice=False,
|
||||
registration_email_domain_enabled=False,
|
||||
consent_required=False,
|
||||
)
|
||||
)
|
||||
await db_session.commit()
|
||||
|
||||
@@ -491,3 +497,127 @@ async def test_register_no_reply_to_when_contact_email_disabled(
|
||||
|
||||
assert response.status_code == 201, response.text
|
||||
assert email_backend.reply_to[-1] is None
|
||||
|
||||
|
||||
async def test_registration_options_returns_consent_fields(
|
||||
client: httpx.AsyncClient, db_session: AsyncSession
|
||||
) -> None:
|
||||
"""`consent_text`/`consent_version` отдаются ВСЕГДА (нужны странице регламента),
|
||||
`consent_required` — по факту настройки инстанса."""
|
||||
await InstanceSettingsService(db_session).update(
|
||||
SettingsUpdateIn(consent_policy_text="Текст регламента для теста")
|
||||
)
|
||||
await db_session.commit()
|
||||
|
||||
response = await client.get("/api/v1/auth/registration-options")
|
||||
assert response.status_code == 200, response.text
|
||||
body = response.json()
|
||||
assert body["consent_required"] is False
|
||||
assert body["consent_text"] == "Текст регламента для теста"
|
||||
assert isinstance(body["consent_version"], int)
|
||||
|
||||
|
||||
async def test_register_without_consent_when_required_returns_400(
|
||||
client: httpx.AsyncClient, db_session: AsyncSession, email_backend: _CapturingEmailBackend
|
||||
) -> None:
|
||||
"""Сервер отказывает в регистрации без галочки, даже если фронт её не прислал —
|
||||
второй эшелон проверки (тот же принцип, что `hand_queue_disabled` в 0.0.28)."""
|
||||
await InstanceSettingsService(db_session).update(
|
||||
SettingsUpdateIn(consent_required=True, consent_policy_text="Текст регламента")
|
||||
)
|
||||
await db_session.commit()
|
||||
|
||||
response = await client.post(
|
||||
"/api/v1/auth/register",
|
||||
json={
|
||||
"email": "no-consent@example.com",
|
||||
"name_user": "No Consent",
|
||||
"password": "supersecret1",
|
||||
},
|
||||
)
|
||||
assert response.status_code == 400
|
||||
assert response.json()["detail"] == "consent_required"
|
||||
|
||||
result = await db_session.execute(select(User).where(User.email == "no-consent@example.com"))
|
||||
assert result.scalar_one_or_none() is None
|
||||
|
||||
|
||||
async def test_register_with_consent_when_required_writes_version_and_date(
|
||||
client: httpx.AsyncClient, db_session: AsyncSession, email_backend: _CapturingEmailBackend
|
||||
) -> None:
|
||||
"""Принятое согласие пишется в БД вместе с редакцией регламента и датой."""
|
||||
cfg = await InstanceSettingsService(db_session).update(
|
||||
SettingsUpdateIn(consent_required=True, consent_policy_text="Текст регламента для приёмки")
|
||||
)
|
||||
await db_session.commit()
|
||||
|
||||
response = await client.post(
|
||||
"/api/v1/auth/register",
|
||||
json={
|
||||
"email": "with-consent@example.com",
|
||||
"name_user": "With Consent",
|
||||
"password": "supersecret1",
|
||||
"consent_accepted": True,
|
||||
},
|
||||
)
|
||||
assert response.status_code == 201, response.text
|
||||
|
||||
result = await db_session.execute(select(User).where(User.email == "with-consent@example.com"))
|
||||
created = result.scalar_one()
|
||||
assert created.consent_version == cfg.consent_policy_version
|
||||
assert created.consent_given_at is not None
|
||||
|
||||
|
||||
async def test_register_without_consent_when_module_disabled_succeeds_and_leaves_it_null(
|
||||
client: httpx.AsyncClient, db_session: AsyncSession, email_backend: _CapturingEmailBackend
|
||||
) -> None:
|
||||
"""Модуль выключен (дефолт `_reset_registration_gating`) — регистрация не требует
|
||||
галочки, `consent_version`/`consent_given_at` остаются `NULL`."""
|
||||
response = await client.post(
|
||||
"/api/v1/auth/register",
|
||||
json={
|
||||
"email": "consent-disabled@example.com",
|
||||
"name_user": "Consent Disabled",
|
||||
"password": "supersecret1",
|
||||
},
|
||||
)
|
||||
assert response.status_code == 201, response.text
|
||||
|
||||
result = await db_session.execute(
|
||||
select(User).where(User.email == "consent-disabled@example.com")
|
||||
)
|
||||
created = result.scalar_one()
|
||||
assert created.consent_version is None
|
||||
assert created.consent_given_at is None
|
||||
|
||||
|
||||
async def test_login_rehashes_legacy_password(
|
||||
client: httpx.AsyncClient, db_session: AsyncSession, email_backend: _CapturingEmailBackend
|
||||
) -> None:
|
||||
"""Вход с паролем, захэшированным старыми параметрами, перевыпускает хэш.
|
||||
|
||||
Параметры argon2 зашиты в саму строку хэша, поэтому смена настроек
|
||||
(0.0.17: дефолты библиотеки → рекомендации OWASP) сама по себе не ускоряет
|
||||
проверку уже существующих паролей. Миграция идёт лениво — при первом
|
||||
успешном входе, когда открытый пароль есть на руках.
|
||||
"""
|
||||
email = "legacy-hash@example.com"
|
||||
password = "supersecret1"
|
||||
await _register_and_verify(client, email_backend, email=email, password=password)
|
||||
|
||||
# Подменяем хэш на выданный прежними параметрами (t=3, m=64 МБ, p=4).
|
||||
legacy_hash = PasswordHasher(time_cost=3, memory_cost=65536, parallelism=4).hash(password)
|
||||
user = await db_session.scalar(select(User).where(User.email == email))
|
||||
assert user is not None
|
||||
user.password_hash = legacy_hash
|
||||
await db_session.commit()
|
||||
|
||||
response = await client.post(
|
||||
"/api/v1/auth/token", data={"username": email, "password": password}
|
||||
)
|
||||
assert response.status_code == 200, response.text
|
||||
|
||||
await db_session.refresh(user)
|
||||
assert user.password_hash != legacy_hash, "старый хэш не был перевыпущен"
|
||||
assert "m=19456" in user.password_hash
|
||||
assert needs_rehash(user.password_hash) is False
|
||||
|
||||
@@ -7,13 +7,14 @@ websocket-тестов поверх нашей savepoint-сессии БД (см
|
||||
"""
|
||||
|
||||
import uuid
|
||||
from collections.abc import Callable
|
||||
from collections.abc import AsyncGenerator, Callable
|
||||
from datetime import UTC, datetime
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
import jwt
|
||||
import pytest
|
||||
import pytest_asyncio
|
||||
from pydantic import ValidationError
|
||||
from sqlalchemy import select, text
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
@@ -37,6 +38,16 @@ from tests.conftest import ASGIWebSocketSession
|
||||
WSFactory = Callable[[str], ASGIWebSocketSession]
|
||||
|
||||
|
||||
@pytest_asyncio.fixture(autouse=True)
|
||||
async def _isolated_instance_settings(clean_instance_settings: None) -> AsyncGenerator[None, None]:
|
||||
"""Autouse только в этом модуле — изолирует тесты от состояния `chat`/`hand_queue`
|
||||
(и остальных управляемых ключей) в общей dev-БД (см. `tests.conftest.clean_instance_settings`,
|
||||
почему не сделана глобально autouse). Безопасно именно здесь: ни один тест файла не
|
||||
открывает отдельного подключения к `instance_settings` — только `db_session`/`client`.
|
||||
"""
|
||||
yield
|
||||
|
||||
|
||||
# --- Хелперы ---------------------------------------------------------------
|
||||
|
||||
|
||||
@@ -44,7 +55,7 @@ async def _make_user(session: AsyncSession, *, name: str = "Chat Tester") -> Use
|
||||
user = User(
|
||||
email=f"{uuid.uuid4()}@example.com",
|
||||
name_user=name,
|
||||
password_hash=hash_password("password123"),
|
||||
password_hash=await hash_password("password123"),
|
||||
email_verified=True,
|
||||
)
|
||||
session.add(user)
|
||||
@@ -85,11 +96,19 @@ def _guest_token(conference: Conference, guest: GuestAccess) -> str:
|
||||
|
||||
|
||||
async def _connect_and_auth(session: ASGIWebSocketSession, token: str) -> dict[str, Any]:
|
||||
"""Подключиться, аутентифицироваться и вернуть первое сообщение (`history`)."""
|
||||
"""Подключиться, аутентифицироваться и вернуть первое сообщение (`history`).
|
||||
|
||||
После `history` сервер сразу шлёт снапшот очереди поднятых рук
|
||||
(`{"type":"hand_queue",...}`, задача B1) — здесь он молча вычитывается
|
||||
и отбрасывается, чтобы не путать существующие тесты чата, которым он
|
||||
не интересен (см. `tests/test_hand_queue_ws.py` для тестов самой очереди).
|
||||
"""
|
||||
accept = await session.connect()
|
||||
assert accept["type"] == "websocket.accept"
|
||||
await session.send_json({"type": "auth", "token": token})
|
||||
return await session.receive_json()
|
||||
history = await session.receive_json()
|
||||
await session.receive_json()
|
||||
return history
|
||||
|
||||
|
||||
# --- Основной сценарий: обмен сообщениями + история -------------------------
|
||||
@@ -229,6 +248,42 @@ async def test_no_duplicate_when_message_already_in_history(
|
||||
assert received["message"]["text"] == "genuinely new"
|
||||
|
||||
|
||||
# --- Удержание соединения с БД ------------------------------------------------
|
||||
|
||||
|
||||
async def test_handshake_releases_db_connection(
|
||||
db_session: AsyncSession, ws_client: WSFactory
|
||||
) -> None:
|
||||
"""Regression: после хендшейка WS не держит открытую транзакцию БД.
|
||||
|
||||
Обработчик получает `AsyncSession` на ВСЁ время жизни соединения, а
|
||||
SELECT'ы хендшейка (тоггл чата, конференция, тоггл рук, история)
|
||||
открывают транзакцию. Без явного `commit` она висела бы, пока участник
|
||||
сидит в комнате: одно занятое соединение из пула на каждого человека
|
||||
в конференции. На нагрузочном тесте 07.08.2026 это выгребло пул
|
||||
(`db_pool_size + db_max_overflow` = 20 на воркер, 40 на инстанс) при
|
||||
сорока участниках — и вход в систему начал отдавать 500 всем
|
||||
остальным. Проверяем именно отсутствие открытой транзакции, а не
|
||||
состояние пула: тестовая сессия привязана к своему соединению
|
||||
(см. докстринг `tests/conftest.py`) и пул не задействует.
|
||||
"""
|
||||
conference = await _make_conference(db_session)
|
||||
user = await _make_user(db_session)
|
||||
await db_session.commit()
|
||||
|
||||
ws = ws_client(_chat_path(conference.id))
|
||||
await _connect_and_auth(ws, _user_token(conference, user))
|
||||
|
||||
assert not db_session.in_transaction()
|
||||
|
||||
# Запись сообщения открывает транзакцию заново — и тоже обязана её
|
||||
# закрыть, иначе первый же чат вернул бы прежнее поведение.
|
||||
await ws.send_json({"type": "message", "text": "проверка"})
|
||||
echo = await ws.receive_json()
|
||||
assert echo["type"] == "message"
|
||||
assert not db_session.in_transaction()
|
||||
|
||||
|
||||
# --- Auth: коды закрытия ----------------------------------------------------
|
||||
|
||||
|
||||
|
||||
@@ -19,7 +19,7 @@ async def _make_user(session: AsyncSession) -> User:
|
||||
user = User(
|
||||
email=f"{uuid.uuid4()}@example.com",
|
||||
name_user="Invitee Tester",
|
||||
password_hash=hash_password("password123"),
|
||||
password_hash=await hash_password("password123"),
|
||||
email_verified=True,
|
||||
)
|
||||
session.add(user)
|
||||
|
||||
@@ -25,7 +25,7 @@ async def _make_user(session: AsyncSession, *, name: str = "Service Tester") ->
|
||||
user = User(
|
||||
email=f"{uuid.uuid4()}@example.com",
|
||||
name_user=name,
|
||||
password_hash=hash_password("password123"),
|
||||
password_hash=await hash_password("password123"),
|
||||
email_verified=True,
|
||||
)
|
||||
session.add(user)
|
||||
|
||||
@@ -21,6 +21,7 @@ from models.guest import GuestAccess
|
||||
from models.invitee import ConferenceInvitee
|
||||
from models.user import User
|
||||
from services.conference_ids import generate_number, generate_slug
|
||||
from services.instance_settings import InstanceSettingsService, SettingsUpdateIn
|
||||
|
||||
FUTURE = datetime.now(UTC) + timedelta(days=3)
|
||||
|
||||
@@ -33,7 +34,7 @@ async def _make_user(session: AsyncSession, *, role: str = "user") -> User:
|
||||
user = User(
|
||||
email=f"{uuid.uuid4()}@example.com",
|
||||
name_user="Conference Tester",
|
||||
password_hash=hash_password("password123"),
|
||||
password_hash=await hash_password("password123"),
|
||||
email_verified=True,
|
||||
role=role,
|
||||
)
|
||||
@@ -67,7 +68,7 @@ async def _make_conference(
|
||||
status=status,
|
||||
is_pinned=is_pinned,
|
||||
is_closed=is_closed,
|
||||
password_hash=hash_password(password) if password else None,
|
||||
password_hash=await hash_password(password) if password else None,
|
||||
ended_at=ended_at,
|
||||
scheduled_at=scheduled_at,
|
||||
duration_minutes=duration_minutes,
|
||||
@@ -113,6 +114,16 @@ 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
|
||||
# Замена фона — отключаемый модуль (сессия 35); значение нужно клиенту ДО
|
||||
# первого рендера комнаты, поэтому едет в join. Здесь проверяется только
|
||||
# НАЛИЧИЕ поля: тумблер живёт в общей dev-БД, и сравнение с дефолтом
|
||||
# ломалось бы от того, что разработчик включил модуль руками (см.
|
||||
# `test_join_reports_virtual_background_toggle` — там значение задаётся явно).
|
||||
assert isinstance(body["join"]["virtual_background_enabled"], bool)
|
||||
|
||||
|
||||
async def test_create_instant_conference_join_metadata_contains_owner_avatar_url(
|
||||
@@ -573,17 +584,75 @@ async def test_resolve_unknown_returns_uniform_404(client: httpx.AsyncClient) ->
|
||||
assert response.json()["detail"] == "not_found"
|
||||
|
||||
|
||||
async def test_resolve_is_rate_limited_after_10_requests_per_minute(
|
||||
client: httpx.AsyncClient,
|
||||
) -> None:
|
||||
def _ip_headers() -> dict[str, str]:
|
||||
"""Уникальный `X-Real-IP` на каждый тест.
|
||||
|
||||
Счётчики rate limit живут в Redis 60 секунд и общие для всего инстанса,
|
||||
поэтому без изоляции тесты влияли бы друг на друга через остаточные ключи.
|
||||
Заодно это проверяет, что заголовок вообще читается: раньше ключ строился
|
||||
по `request.client.host`, то есть по адресу nginx, одинаковому для всех.
|
||||
"""
|
||||
return {"X-Real-IP": f"198.51.100.{uuid.uuid4().int % 250 + 1}-{uuid.uuid4().hex[:8]}"}
|
||||
|
||||
|
||||
async def test_resolve_misses_are_rate_limited(client: httpx.AsyncClient) -> None:
|
||||
"""Перебор номера конференции упирается в жёсткий лимит промахов (ADR-001, п.4)."""
|
||||
headers = _ip_headers()
|
||||
for _ in range(10):
|
||||
response = await client.get("/api/v1/conferences/resolve", params={"q": "irrelevant-query"})
|
||||
response = await client.get(
|
||||
"/api/v1/conferences/resolve", params={"q": "irrelevant-query"}, headers=headers
|
||||
)
|
||||
assert response.status_code == 404
|
||||
|
||||
limited = await client.get("/api/v1/conferences/resolve", params={"q": "irrelevant-query"})
|
||||
limited = await client.get(
|
||||
"/api/v1/conferences/resolve", params={"q": "irrelevant-query"}, headers=headers
|
||||
)
|
||||
assert limited.status_code == 429
|
||||
|
||||
|
||||
async def test_successful_resolves_are_not_limited_by_miss_counter(
|
||||
client: httpx.AsyncClient, db_session: AsyncSession
|
||||
) -> None:
|
||||
"""Вся конференция может открыть ссылку одновременно (регресс теста 31.07.2026).
|
||||
|
||||
Прежняя схема считала любые запросы с лимитом 10/мин, и одиннадцатый
|
||||
участник получал 429 — фронтенд показывал «Не удалось найти конференцию»
|
||||
для существующей и активной конференции.
|
||||
"""
|
||||
conference = await _make_conference(db_session)
|
||||
await db_session.commit()
|
||||
headers = _ip_headers()
|
||||
|
||||
for _ in range(50):
|
||||
response = await client.get(
|
||||
"/api/v1/conferences/resolve", params={"q": conference.slug}, headers=headers
|
||||
)
|
||||
assert response.status_code == 200, response.text
|
||||
|
||||
|
||||
async def test_rate_limit_is_per_client_ip(client: httpx.AsyncClient) -> None:
|
||||
"""Счётчик привязан к адресу клиента, а не к адресу nginx.
|
||||
|
||||
Исчерпав лимит промахов с одного адреса, с другого по-прежнему можно
|
||||
работать. До исправления ключ был общим на весь инстанс.
|
||||
"""
|
||||
first, second = _ip_headers(), _ip_headers()
|
||||
for _ in range(11):
|
||||
await client.get(
|
||||
"/api/v1/conferences/resolve", params={"q": "no-such-conference"}, headers=first
|
||||
)
|
||||
|
||||
exhausted = await client.get(
|
||||
"/api/v1/conferences/resolve", params={"q": "no-such-conference"}, headers=first
|
||||
)
|
||||
assert exhausted.status_code == 429
|
||||
|
||||
other = await client.get(
|
||||
"/api/v1/conferences/resolve", params={"q": "no-such-conference"}, headers=second
|
||||
)
|
||||
assert other.status_code == 404, "лимит одного клиента не должен задевать другого"
|
||||
|
||||
|
||||
# --- Вход зарегистрированным пользователем ---------------------------------------
|
||||
|
||||
|
||||
@@ -656,6 +725,30 @@ async def test_join_closed_conference_correct_password_returns_200(
|
||||
assert response.json()["room_name"] == conference.slug
|
||||
|
||||
|
||||
@pytest.mark.parametrize("enabled", [True, False])
|
||||
async def test_join_reports_virtual_background_toggle(
|
||||
client: httpx.AsyncClient, db_session: AsyncSession, enabled: bool
|
||||
) -> None:
|
||||
"""Тумблер модуля «замена фона» доезжает до участника в ответе join.
|
||||
|
||||
Значение задаётся ЯВНО, а не берётся из общей dev-БД: клиенту оно нужно до
|
||||
первого рендера комнаты, и подмена тумблера обязана менять ответ join, а не
|
||||
только `/admin/settings` (тот доступен лишь администратору).
|
||||
"""
|
||||
owner = await _make_user(db_session)
|
||||
conference = await _make_conference(db_session, owner_id=owner.id)
|
||||
await InstanceSettingsService(db_session).update(
|
||||
SettingsUpdateIn(virtual_background_enabled=enabled)
|
||||
)
|
||||
await db_session.commit()
|
||||
|
||||
response = await client.post(
|
||||
f"/api/v1/conferences/{conference.id}/join", json={}, headers=_auth_headers(owner)
|
||||
)
|
||||
assert response.status_code == 200, response.text
|
||||
assert response.json()["virtual_background_enabled"] is enabled
|
||||
|
||||
|
||||
# --- Аватар в метаданных LiveKit-токена -----------------------------------------
|
||||
|
||||
|
||||
@@ -708,6 +801,38 @@ async def test_join_metadata_absent_for_user_without_avatar(
|
||||
assert "metadata" not in payload
|
||||
|
||||
|
||||
async def test_join_metadata_contains_is_organizer_for_owner(
|
||||
client: httpx.AsyncClient, db_session: AsyncSession
|
||||
) -> None:
|
||||
owner = await _make_user(db_session)
|
||||
conference = await _make_conference(db_session, owner_id=owner.id)
|
||||
await db_session.commit()
|
||||
|
||||
response = await client.post(
|
||||
f"/api/v1/conferences/{conference.id}/join", headers=_auth_headers(owner)
|
||||
)
|
||||
assert response.status_code == 200, response.text
|
||||
payload = _decode_livekit_token(response.json()["token"])
|
||||
metadata = json.loads(str(payload["metadata"]))
|
||||
assert metadata["is_organizer"] is True
|
||||
|
||||
|
||||
async def test_join_metadata_absent_is_organizer_for_non_owner(
|
||||
client: httpx.AsyncClient, db_session: AsyncSession
|
||||
) -> None:
|
||||
owner = await _make_user(db_session)
|
||||
other = await _make_user(db_session)
|
||||
conference = await _make_conference(db_session, owner_id=owner.id)
|
||||
await db_session.commit()
|
||||
|
||||
response = await client.post(
|
||||
f"/api/v1/conferences/{conference.id}/join", headers=_auth_headers(other)
|
||||
)
|
||||
assert response.status_code == 200, response.text
|
||||
payload = _decode_livekit_token(response.json()["token"])
|
||||
assert "metadata" not in payload
|
||||
|
||||
|
||||
async def test_guest_join_metadata_is_absent(
|
||||
client: httpx.AsyncClient, db_session: AsyncSession
|
||||
) -> None:
|
||||
@@ -751,6 +876,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:
|
||||
@@ -803,21 +955,69 @@ async def test_guest_join_ended_conference_returns_410(
|
||||
assert response.json()["detail"] == "conference_ended"
|
||||
|
||||
|
||||
async def test_guest_join_is_rate_limited_after_10_requests_per_minute(
|
||||
async def test_guest_join_allows_a_whole_conference_to_enter(
|
||||
client: httpx.AsyncClient, db_session: AsyncSession
|
||||
) -> None:
|
||||
"""Успешные гостевые входы не упираются в лимит промахов.
|
||||
|
||||
На нагрузочном тесте 31.07.2026 конференцию из семи десятков человек не
|
||||
пускало внутрь именно это ограничение — счётчик не различал легитимный
|
||||
массовый вход и перебор.
|
||||
"""
|
||||
conference = await _make_conference(db_session)
|
||||
await db_session.commit()
|
||||
headers = _ip_headers()
|
||||
|
||||
for i in range(30):
|
||||
response = await client.post(
|
||||
f"/api/v1/conferences/{conference.id}/guest-join",
|
||||
json={"display_name": f"Guest {i}"},
|
||||
headers=headers,
|
||||
)
|
||||
assert response.status_code == 200, response.text
|
||||
|
||||
|
||||
async def test_guest_join_misses_are_rate_limited(client: httpx.AsyncClient) -> None:
|
||||
"""Перебор идентификатора конференции по-прежнему упирается в лимит."""
|
||||
headers = _ip_headers()
|
||||
missing_id = uuid.uuid4()
|
||||
|
||||
for _ in range(10):
|
||||
response = await client.post(
|
||||
f"/api/v1/conferences/{missing_id}/guest-join",
|
||||
json={"display_name": "Bruteforce"},
|
||||
headers=headers,
|
||||
)
|
||||
assert response.status_code == 404
|
||||
|
||||
limited = await client.post(
|
||||
f"/api/v1/conferences/{missing_id}/guest-join",
|
||||
json={"display_name": "Bruteforce"},
|
||||
headers=headers,
|
||||
)
|
||||
assert limited.status_code == 429
|
||||
|
||||
|
||||
async def test_guest_join_wrong_password_is_rate_limited(
|
||||
client: httpx.AsyncClient, db_session: AsyncSession
|
||||
) -> None:
|
||||
"""Подбор пароля закрытой конференции считается тем же жёстким счётчиком."""
|
||||
conference = await _make_conference(db_session, is_closed=True, password="right-password")
|
||||
await db_session.commit()
|
||||
headers = _ip_headers()
|
||||
|
||||
for _ in range(10):
|
||||
response = await client.post(
|
||||
f"/api/v1/conferences/{conference.id}/guest-join",
|
||||
json={"display_name": "Repeat Guest"},
|
||||
json={"display_name": "Guesser", "password": "wrong"},
|
||||
headers=headers,
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert response.status_code == 403
|
||||
|
||||
limited = await client.post(
|
||||
f"/api/v1/conferences/{conference.id}/guest-join", json={"display_name": "Repeat Guest"}
|
||||
f"/api/v1/conferences/{conference.id}/guest-join",
|
||||
json={"display_name": "Guesser", "password": "wrong"},
|
||||
headers=headers,
|
||||
)
|
||||
assert limited.status_code == 429
|
||||
|
||||
|
||||
345
backend/tests/test_hand_queue_ws.py
Normal file
345
backend/tests/test_hand_queue_ws.py
Normal file
@@ -0,0 +1,345 @@
|
||||
"""Тесты очереди поднятых рук поверх WS комнаты (`WS /api/v1/conferences/{id}/chat`, задача B1).
|
||||
|
||||
Протокол и аутентификация — общие с чатом (`api/chat.py`), поэтому структура
|
||||
тестов и хелперы намеренно зеркалят `tests/test_chat_ws.py`.
|
||||
"""
|
||||
|
||||
import uuid
|
||||
from collections.abc import AsyncGenerator, Callable
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
import pytest_asyncio
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from core.security import create_access_token, hash_password
|
||||
from models.conference import Conference
|
||||
from models.guest import GuestAccess
|
||||
from models.user import User
|
||||
from services.conference_ids import generate_number, generate_slug
|
||||
from services.instance_settings import InstanceSettingsService, SettingsUpdateIn
|
||||
from services.livekit_tokens import create_room_access_token
|
||||
from tests.conftest import ASGIWebSocketSession
|
||||
|
||||
WSFactory = Callable[[str], ASGIWebSocketSession]
|
||||
|
||||
|
||||
@pytest_asyncio.fixture(autouse=True)
|
||||
async def _isolated_instance_settings(clean_instance_settings: None) -> AsyncGenerator[None, None]:
|
||||
"""Autouse только в этом модуле — см. `tests.test_chat_ws._isolated_instance_settings`
|
||||
и `tests.conftest.clean_instance_settings` (почему не глобальный autouse)."""
|
||||
yield
|
||||
|
||||
|
||||
# --- Хелперы (см. tests/test_chat_ws.py) ------------------------------------
|
||||
|
||||
|
||||
async def _make_user(session: AsyncSession, *, name: str = "Hand Tester") -> User:
|
||||
user = User(
|
||||
email=f"{uuid.uuid4()}@example.com",
|
||||
name_user=name,
|
||||
password_hash=await hash_password("password123"),
|
||||
email_verified=True,
|
||||
)
|
||||
session.add(user)
|
||||
await session.flush()
|
||||
return user
|
||||
|
||||
|
||||
async def _make_conference(
|
||||
session: AsyncSession, *, owner_id: uuid.UUID | None = None, status: str = "active"
|
||||
) -> Conference:
|
||||
conference = Conference(
|
||||
number=generate_number(),
|
||||
slug=generate_slug(),
|
||||
title="Hand Queue Test",
|
||||
status=status,
|
||||
owner_id=owner_id,
|
||||
)
|
||||
session.add(conference)
|
||||
await session.flush()
|
||||
return conference
|
||||
|
||||
|
||||
async def _make_guest(session: AsyncSession, conference: Conference, *, name: str) -> GuestAccess:
|
||||
guest = GuestAccess(conference_id=conference.id, display_name=name)
|
||||
session.add(guest)
|
||||
await session.flush()
|
||||
return guest
|
||||
|
||||
|
||||
def _chat_path(conference_id: uuid.UUID) -> str:
|
||||
return f"/api/v1/conferences/{conference_id}/chat"
|
||||
|
||||
|
||||
def _user_token(conference: Conference, user: User) -> str:
|
||||
return create_room_access_token(
|
||||
room_name=conference.slug, identity=str(user.id), name=user.name_user
|
||||
)
|
||||
|
||||
|
||||
def _guest_token(conference: Conference, guest: GuestAccess) -> str:
|
||||
return create_room_access_token(
|
||||
room_name=conference.slug, identity=f"guest:{guest.id}", name=guest.display_name
|
||||
)
|
||||
|
||||
|
||||
async def _connect_auth_and_queue(
|
||||
session: ASGIWebSocketSession, token: str
|
||||
) -> dict[str, Any]:
|
||||
"""Подключиться, аутентифицироваться, вычитать `history` и вернуть снапшот очереди."""
|
||||
accept = await session.connect()
|
||||
assert accept["type"] == "websocket.accept"
|
||||
await session.send_json({"type": "auth", "token": token})
|
||||
await session.receive_json() # history — не интересен этим тестам
|
||||
return await session.receive_json()
|
||||
|
||||
|
||||
def _identities(queue_frame: dict[str, Any]) -> list[str]:
|
||||
return [entry["identity"] for entry in queue_frame["queue"]]
|
||||
|
||||
|
||||
# --- Поднять/опустить свою руку -----------------------------------------------
|
||||
|
||||
|
||||
async def test_raise_and_lower_own_hand_broadcasts_to_everyone(
|
||||
db_session: AsyncSession, ws_client: WSFactory
|
||||
) -> None:
|
||||
conference = await _make_conference(db_session)
|
||||
alice = await _make_user(db_session, name="Alice")
|
||||
bob = await _make_user(db_session, name="Bob")
|
||||
await db_session.commit()
|
||||
|
||||
path = _chat_path(conference.id)
|
||||
ws1 = ws_client(path)
|
||||
await _connect_auth_and_queue(ws1, _user_token(conference, alice))
|
||||
ws2 = ws_client(path)
|
||||
initial2 = await _connect_auth_and_queue(ws2, _user_token(conference, bob))
|
||||
assert initial2 == {"type": "hand_queue", "queue": []}
|
||||
|
||||
await ws1.send_json({"type": "raise_hand"})
|
||||
|
||||
queue1 = await ws1.receive_json()
|
||||
assert _identities(queue1) == [str(alice.id)]
|
||||
assert queue1["queue"][0]["name"] == "Alice"
|
||||
assert queue1["queue"][0]["raised_at"].endswith("Z")
|
||||
|
||||
queue2 = await ws2.receive_json()
|
||||
assert queue2 == queue1
|
||||
|
||||
await ws1.send_json({"type": "lower_hand"})
|
||||
queue1_after = await ws1.receive_json()
|
||||
assert queue1_after == {"type": "hand_queue", "queue": []}
|
||||
queue2_after = await ws2.receive_json()
|
||||
assert queue2_after == queue1_after
|
||||
|
||||
|
||||
async def test_raise_hand_order_is_preserved(
|
||||
db_session: AsyncSession, ws_client: WSFactory
|
||||
) -> None:
|
||||
"""Порядок в очереди — по времени поднятия, не по алфавиту/подключению."""
|
||||
conference = await _make_conference(db_session)
|
||||
alice = await _make_user(db_session, name="Alice")
|
||||
bob = await _make_user(db_session, name="Bob")
|
||||
await db_session.commit()
|
||||
|
||||
path = _chat_path(conference.id)
|
||||
ws1 = ws_client(path)
|
||||
await _connect_auth_and_queue(ws1, _user_token(conference, alice))
|
||||
ws2 = ws_client(path)
|
||||
await _connect_auth_and_queue(ws2, _user_token(conference, bob))
|
||||
|
||||
# Боб поднимает руку ПЕРВЫМ, хотя подключился вторым — он и должен
|
||||
# оказаться первым в очереди.
|
||||
await ws2.send_json({"type": "raise_hand"})
|
||||
await ws2.receive_json()
|
||||
await ws1.receive_json()
|
||||
|
||||
await ws1.send_json({"type": "raise_hand"})
|
||||
queue = await ws1.receive_json()
|
||||
|
||||
assert _identities(queue) == [str(bob.id), str(alice.id)]
|
||||
|
||||
|
||||
async def test_re_raising_hand_does_not_move_position(
|
||||
db_session: AsyncSession, ws_client: WSFactory
|
||||
) -> None:
|
||||
"""Повторное поднятие уже поднятой руки — идемпотентно, место в очереди не меняется."""
|
||||
conference = await _make_conference(db_session)
|
||||
alice = await _make_user(db_session, name="Alice")
|
||||
bob = await _make_user(db_session, name="Bob")
|
||||
await db_session.commit()
|
||||
|
||||
path = _chat_path(conference.id)
|
||||
ws1 = ws_client(path)
|
||||
await _connect_auth_and_queue(ws1, _user_token(conference, alice))
|
||||
ws2 = ws_client(path)
|
||||
await _connect_auth_and_queue(ws2, _user_token(conference, bob))
|
||||
|
||||
await ws1.send_json({"type": "raise_hand"})
|
||||
first = await ws1.receive_json()
|
||||
await ws2.receive_json()
|
||||
|
||||
await ws2.send_json({"type": "raise_hand"})
|
||||
await ws2.receive_json()
|
||||
await ws1.receive_json()
|
||||
|
||||
# Алиса (уже в очереди первой) поднимает руку ещё раз.
|
||||
await ws1.send_json({"type": "raise_hand"})
|
||||
repeated = await ws1.receive_json()
|
||||
await ws2.receive_json()
|
||||
|
||||
assert _identities(repeated) == [str(alice.id), str(bob.id)]
|
||||
assert repeated["queue"][0]["raised_at"] == first["queue"][0]["raised_at"]
|
||||
|
||||
|
||||
async def test_guest_can_raise_hand(db_session: AsyncSession, ws_client: WSFactory) -> None:
|
||||
conference = await _make_conference(db_session)
|
||||
guest = await _make_guest(db_session, conference, name="Guest Carl")
|
||||
await db_session.commit()
|
||||
|
||||
ws = ws_client(_chat_path(conference.id))
|
||||
await _connect_auth_and_queue(ws, _guest_token(conference, guest))
|
||||
|
||||
await ws.send_json({"type": "raise_hand"})
|
||||
queue = await ws.receive_json()
|
||||
assert _identities(queue) == [f"guest:{guest.id}"]
|
||||
assert queue["queue"][0]["name"] == "Guest Carl"
|
||||
|
||||
|
||||
# --- Права организатора -------------------------------------------------------
|
||||
|
||||
|
||||
async def test_non_organizer_cannot_lower_someone_elses_hand(
|
||||
db_session: AsyncSession, ws_client: WSFactory
|
||||
) -> None:
|
||||
owner = await _make_user(db_session, name="Owner")
|
||||
conference = await _make_conference(db_session, owner_id=owner.id)
|
||||
alice = await _make_user(db_session, name="Alice")
|
||||
bob = await _make_user(db_session, name="Bob")
|
||||
await db_session.commit()
|
||||
|
||||
path = _chat_path(conference.id)
|
||||
ws1 = ws_client(path)
|
||||
await _connect_auth_and_queue(ws1, _user_token(conference, alice))
|
||||
ws2 = ws_client(path)
|
||||
await _connect_auth_and_queue(ws2, _user_token(conference, bob))
|
||||
|
||||
await ws1.send_json({"type": "raise_hand"})
|
||||
await ws1.receive_json()
|
||||
await ws2.receive_json()
|
||||
|
||||
# Боб (обычный участник, не организатор) пытается опустить руку Алисы.
|
||||
await ws2.send_json({"type": "lower_hand", "identity": str(alice.id)})
|
||||
error = await ws2.receive_json()
|
||||
assert error == {"type": "error", "code": "forbidden"}
|
||||
|
||||
|
||||
async def test_organizer_can_lower_someone_elses_hand(
|
||||
db_session: AsyncSession, ws_client: WSFactory
|
||||
) -> None:
|
||||
owner = await _make_user(db_session, name="Owner")
|
||||
conference = await _make_conference(db_session, owner_id=owner.id)
|
||||
alice = await _make_user(db_session, name="Alice")
|
||||
await db_session.commit()
|
||||
|
||||
path = _chat_path(conference.id)
|
||||
ws_alice = ws_client(path)
|
||||
await _connect_auth_and_queue(ws_alice, _user_token(conference, alice))
|
||||
ws_owner = ws_client(path)
|
||||
await _connect_auth_and_queue(ws_owner, _user_token(conference, owner))
|
||||
|
||||
await ws_alice.send_json({"type": "raise_hand"})
|
||||
await ws_alice.receive_json()
|
||||
await ws_owner.receive_json()
|
||||
|
||||
await ws_owner.send_json({"type": "lower_hand", "identity": str(alice.id)})
|
||||
queue_owner = await ws_owner.receive_json()
|
||||
queue_alice = await ws_alice.receive_json()
|
||||
|
||||
assert queue_owner == {"type": "hand_queue", "queue": []}
|
||||
assert queue_alice == queue_owner
|
||||
|
||||
|
||||
async def test_organizer_joining_late_sees_already_raised_hands(
|
||||
db_session: AsyncSession, ws_client: WSFactory
|
||||
) -> None:
|
||||
"""Организатор зашёл позже, когда руки уже подняты, — видит актуальную очередь сразу."""
|
||||
owner = await _make_user(db_session, name="Owner")
|
||||
conference = await _make_conference(db_session, owner_id=owner.id)
|
||||
alice = await _make_user(db_session, name="Alice")
|
||||
await db_session.commit()
|
||||
|
||||
path = _chat_path(conference.id)
|
||||
ws_alice = ws_client(path)
|
||||
await _connect_auth_and_queue(ws_alice, _user_token(conference, alice))
|
||||
await ws_alice.send_json({"type": "raise_hand"})
|
||||
await ws_alice.receive_json()
|
||||
|
||||
ws_owner = ws_client(path)
|
||||
initial_queue = await _connect_auth_and_queue(ws_owner, _user_token(conference, owner))
|
||||
|
||||
assert _identities(initial_queue) == [str(alice.id)]
|
||||
|
||||
|
||||
# --- Отключаемый модуль (`instance_settings.hand_queue`) ---------------------
|
||||
|
||||
|
||||
async def test_hand_queue_disabled_rejects_raise_and_lower(
|
||||
db_session: AsyncSession, ws_client: WSFactory
|
||||
) -> None:
|
||||
"""Выключенный модуль — вторая линия защиты сверх фронта: `raise_hand`/
|
||||
`lower_hand` отклоняются кодом `hand_queue_disabled`, очередь не меняется."""
|
||||
conference = await _make_conference(db_session)
|
||||
alice = await _make_user(db_session, name="Alice")
|
||||
await db_session.commit()
|
||||
|
||||
await InstanceSettingsService(db_session).update(SettingsUpdateIn(hand_queue_enabled=False))
|
||||
|
||||
ws = ws_client(_chat_path(conference.id))
|
||||
await _connect_auth_and_queue(ws, _user_token(conference, alice))
|
||||
|
||||
await ws.send_json({"type": "raise_hand"})
|
||||
error = await ws.receive_json()
|
||||
assert error == {"type": "error", "code": "hand_queue_disabled"}
|
||||
|
||||
await ws.send_json({"type": "lower_hand"})
|
||||
error = await ws.receive_json()
|
||||
assert error == {"type": "error", "code": "hand_queue_disabled"}
|
||||
|
||||
|
||||
async def test_join_out_reflects_hand_queue_enabled_toggle(
|
||||
client: httpx.AsyncClient, db_session: AsyncSession
|
||||
) -> None:
|
||||
user = await _make_user(db_session, name="Toggle Tester")
|
||||
await db_session.commit()
|
||||
headers = {"Authorization": f"Bearer {create_access_token(user.id, user.role)}"}
|
||||
|
||||
response = await client.post(
|
||||
"/api/v1/conferences", json={"title": "Standup"}, headers=headers
|
||||
)
|
||||
assert response.status_code == 201, response.text
|
||||
assert response.json()["join"]["hand_queue_enabled"] is True
|
||||
|
||||
await InstanceSettingsService(db_session).update(SettingsUpdateIn(hand_queue_enabled=False))
|
||||
|
||||
response = await client.post(
|
||||
"/api/v1/conferences", json={"title": "Standup 2"}, headers=headers
|
||||
)
|
||||
assert response.status_code == 201, response.text
|
||||
assert response.json()["join"]["hand_queue_enabled"] is False
|
||||
|
||||
|
||||
async def test_guest_join_out_reflects_hand_queue_enabled(
|
||||
client: httpx.AsyncClient, db_session: AsyncSession
|
||||
) -> None:
|
||||
conference = await _make_conference(db_session)
|
||||
await db_session.commit()
|
||||
|
||||
await InstanceSettingsService(db_session).update(SettingsUpdateIn(hand_queue_enabled=False))
|
||||
|
||||
response = await client.post(
|
||||
f"/api/v1/conferences/{conference.id}/guest-join", json={"display_name": "Dave"}
|
||||
)
|
||||
assert response.status_code == 200, response.text
|
||||
assert response.json()["hand_queue_enabled"] is False
|
||||
@@ -12,15 +12,12 @@
|
||||
видеть реально закоммиченную строку (см. docstring `test_pipeline.py`),
|
||||
поэтому там запись делается через отдельное подключение к `core.db.engine`.
|
||||
|
||||
Тесты, которым нужен «чистый стол» по управляемым ключам `instance_settings`
|
||||
(бутстрап дефолтов, дефолтные значения после патча), используют фикстуру
|
||||
`clean_instance_settings` — она сохраняет текущие строки этих ключей в
|
||||
рамках транзакции теста и восстанавливает их после (не `TRUNCATE`): в общей
|
||||
dev-БД эти строки могут быть легитимными данными разработчика, тест не
|
||||
должен от них зависеть, но и не должен их безвозвратно стирать. Тест с
|
||||
`run_pipeline_async` не может использовать эту фикстуру (пишет через
|
||||
отдельное подключение) — там то же сохранение/восстановление сделано вручную
|
||||
через реальный коннекшн.
|
||||
«Чистый стол» по управляемым ключам `instance_settings` обеспечивает общая (НЕ
|
||||
autouse — см. её докстринг про само-дедлок с тестами на реальных подключениях)
|
||||
фикстура `tests.conftest.clean_instance_settings` — тесты, которым нужен чистый
|
||||
стол, запрашивают её явно параметром. Тест с `run_pipeline_async` пишет через
|
||||
отдельное подключение (мимо savepoint) и её не запрашивает — там сохранение/
|
||||
восстановление сделано вручную через реальный коннекшн, см. его докстринг.
|
||||
"""
|
||||
|
||||
import uuid
|
||||
@@ -48,6 +45,7 @@ from services.instance_settings import (
|
||||
BootstrapOverrides,
|
||||
InstanceSettingsService,
|
||||
InvalidAiLevelError,
|
||||
InvalidConsentPolicyError,
|
||||
InvalidContactEmailError,
|
||||
InvalidEmailDomainError,
|
||||
InvalidTimezoneError,
|
||||
@@ -60,19 +58,6 @@ from workers.tasks.pipeline import run_pipeline_async
|
||||
PLUGINS_YAML = "../config/plugins.yaml"
|
||||
NOW = datetime.now(UTC)
|
||||
|
||||
# Все ключи, которыми управляет `InstanceSettingsService` (см. `_KEY_*` там же).
|
||||
_MANAGED_KEYS = (
|
||||
"transcriber",
|
||||
"summarizer",
|
||||
"chat",
|
||||
"ai_level",
|
||||
"summary_recipients",
|
||||
"display_timezone",
|
||||
"registration_team_choice",
|
||||
"registration_email_domain",
|
||||
"contact_email",
|
||||
)
|
||||
|
||||
|
||||
class _FakeTask:
|
||||
"""Минимальная заглушка bound-задачи Celery (см. `test_pipeline.py`)."""
|
||||
@@ -81,32 +66,6 @@ class _FakeTask:
|
||||
self.retry = MagicMock()
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def clean_instance_settings(db_session: AsyncSession) -> AsyncGenerator[None, None]:
|
||||
"""Изолировать тест от уже существующих строк управляемых ключей `instance_settings`.
|
||||
|
||||
Сохраняет текущие значения (если есть) в рамках `db_session` (savepoint,
|
||||
никогда не коммитится в реальную БД — см. `conftest.py`), удаляет их,
|
||||
отдаёт управление тесту, затем восстанавливает исходные значения —
|
||||
точечно, только эти ключи, не `TRUNCATE`.
|
||||
"""
|
||||
result = await db_session.execute(
|
||||
select(InstanceSetting).where(InstanceSetting.key.in_(_MANAGED_KEYS))
|
||||
)
|
||||
saved: dict[str, Any] = {row.key: row.value for row in result.scalars().all()}
|
||||
await db_session.execute(delete(InstanceSetting).where(InstanceSetting.key.in_(_MANAGED_KEYS)))
|
||||
await db_session.commit()
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
await db_session.execute(
|
||||
delete(InstanceSetting).where(InstanceSetting.key.in_(_MANAGED_KEYS))
|
||||
)
|
||||
for key, value in saved.items():
|
||||
db_session.add(InstanceSetting(key=key, value=value))
|
||||
await db_session.commit()
|
||||
|
||||
|
||||
async def test_ensure_bootstrapped_imports_yaml_defaults(
|
||||
db_session: AsyncSession, clean_instance_settings: None
|
||||
) -> None:
|
||||
@@ -120,16 +79,24 @@ async def test_ensure_bootstrapped_imports_yaml_defaults(
|
||||
"transcriber",
|
||||
"summarizer",
|
||||
"chat",
|
||||
"hand_queue",
|
||||
"ai_level",
|
||||
"summary_recipients",
|
||||
"display_timezone",
|
||||
"registration_team_choice",
|
||||
"registration_email_domain",
|
||||
"contact_email",
|
||||
"media_limits",
|
||||
"consent_policy",
|
||||
"device_check",
|
||||
"virtual_background",
|
||||
}
|
||||
cfg = await service.get()
|
||||
assert cfg.transcriber.provider == "faster_whisper_cpu"
|
||||
assert cfg.ai_level == "min"
|
||||
# Дефолт обязан сохранять поведение существующих инсталляций — модуль
|
||||
# «поднятие руки» был доступен всегда, тоггл включён по умолчанию.
|
||||
assert cfg.hand_queue.enabled is True
|
||||
assert cfg.summary_recipients == "all"
|
||||
assert cfg.display_timezone == "Europe/Moscow"
|
||||
assert cfg.registration_team_choice is False
|
||||
@@ -137,6 +104,24 @@ 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
|
||||
# Согласие на обработку персональных данных выключено по умолчанию
|
||||
# (дефолт сохраняет поведение существующих инсталляций), но дефолтный
|
||||
# текст-шаблон уже на месте — публичная страница регламента осмысленна
|
||||
# даже при выключенном модуле.
|
||||
assert cfg.consent_required is False
|
||||
assert cfg.consent_policy_text != ""
|
||||
assert cfg.consent_policy_version == 1
|
||||
# Проверка устройств на входе (сессия 33) — выключена по умолчанию,
|
||||
# существующие инсталляции не должны молча начать спрашивать доступ.
|
||||
assert cfg.device_check_enabled is False
|
||||
# Замена фона видео (сессия 35) — выключена по умолчанию: фича постоянно
|
||||
# считает сегментацию на клиенте, включать её обновлением нельзя.
|
||||
assert cfg.virtual_background_enabled is False
|
||||
|
||||
|
||||
async def test_ensure_bootstrapped_is_idempotent_and_keeps_admin_edits(
|
||||
@@ -155,6 +140,54 @@ async def test_ensure_bootstrapped_is_idempotent_and_keeps_admin_edits(
|
||||
assert cfg.display_timezone == "Asia/Yekaterinburg"
|
||||
|
||||
|
||||
async def test_update_hand_queue_enabled(
|
||||
db_session: AsyncSession, clean_instance_settings: None
|
||||
) -> None:
|
||||
service = InstanceSettingsService(db_session)
|
||||
await service.ensure_bootstrapped(PLUGINS_YAML)
|
||||
|
||||
cfg = await service.update(SettingsUpdateIn(hand_queue_enabled=False))
|
||||
assert cfg.hand_queue.enabled is False
|
||||
|
||||
cfg = await service.get()
|
||||
assert cfg.hand_queue.enabled is False
|
||||
|
||||
cfg = await service.update(SettingsUpdateIn(hand_queue_enabled=True))
|
||||
assert cfg.hand_queue.enabled is True
|
||||
|
||||
|
||||
async def test_update_device_check_enabled(
|
||||
db_session: AsyncSession, clean_instance_settings: None
|
||||
) -> None:
|
||||
service = InstanceSettingsService(db_session)
|
||||
await service.ensure_bootstrapped(PLUGINS_YAML)
|
||||
|
||||
cfg = await service.update(SettingsUpdateIn(device_check_enabled=True))
|
||||
assert cfg.device_check_enabled is True
|
||||
|
||||
cfg = await service.get()
|
||||
assert cfg.device_check_enabled is True
|
||||
|
||||
cfg = await service.update(SettingsUpdateIn(device_check_enabled=False))
|
||||
assert cfg.device_check_enabled is False
|
||||
|
||||
|
||||
async def test_update_virtual_background_enabled(
|
||||
db_session: AsyncSession, clean_instance_settings: None
|
||||
) -> None:
|
||||
service = InstanceSettingsService(db_session)
|
||||
await service.ensure_bootstrapped(PLUGINS_YAML)
|
||||
|
||||
cfg = await service.update(SettingsUpdateIn(virtual_background_enabled=True))
|
||||
assert cfg.virtual_background_enabled is True
|
||||
|
||||
cfg = await service.get()
|
||||
assert cfg.virtual_background_enabled is True
|
||||
|
||||
cfg = await service.update(SettingsUpdateIn(virtual_background_enabled=False))
|
||||
assert cfg.virtual_background_enabled is False
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("preset", "chat_enabled", "ai_enabled", "ai_level"),
|
||||
[
|
||||
@@ -323,6 +356,61 @@ async def test_registration_team_choice_toggle(
|
||||
assert reloaded.registration_team_choice is True
|
||||
|
||||
|
||||
async def test_consent_policy_toggle_without_text_change_keeps_version(
|
||||
db_session: AsyncSession, clean_instance_settings: None
|
||||
) -> None:
|
||||
"""Включение флага без правки текста не увеличивает версию."""
|
||||
service = InstanceSettingsService(db_session)
|
||||
await service.ensure_bootstrapped(PLUGINS_YAML)
|
||||
baseline = await service.get()
|
||||
assert baseline.consent_required is False
|
||||
|
||||
cfg = await service.update(SettingsUpdateIn(consent_required=True))
|
||||
|
||||
assert cfg.consent_required is True
|
||||
assert cfg.consent_policy_version == baseline.consent_policy_version
|
||||
|
||||
reloaded = await service.get()
|
||||
assert reloaded.consent_required is True
|
||||
assert reloaded.consent_policy_version == baseline.consent_policy_version
|
||||
|
||||
|
||||
async def test_consent_policy_text_change_bumps_version(
|
||||
db_session: AsyncSession, clean_instance_settings: None
|
||||
) -> None:
|
||||
"""Правка текста регламента увеличивает версию — иначе «версия согласия» в БД бессмысленна."""
|
||||
service = InstanceSettingsService(db_session)
|
||||
await service.ensure_bootstrapped(PLUGINS_YAML)
|
||||
baseline = await service.get()
|
||||
|
||||
cfg = await service.update(SettingsUpdateIn(consent_policy_text="Новый текст регламента"))
|
||||
assert cfg.consent_policy_text == "Новый текст регламента"
|
||||
assert cfg.consent_policy_version == baseline.consent_policy_version + 1
|
||||
|
||||
# Повторное сохранение ТОГО ЖЕ текста версию больше не двигает.
|
||||
cfg2 = await service.update(SettingsUpdateIn(consent_policy_text="Новый текст регламента"))
|
||||
assert cfg2.consent_policy_version == cfg.consent_policy_version
|
||||
|
||||
reloaded = await service.get()
|
||||
assert reloaded.consent_policy_version == cfg.consent_policy_version
|
||||
|
||||
|
||||
async def test_consent_policy_enable_with_empty_text_rejected(
|
||||
db_session: AsyncSession, clean_instance_settings: None
|
||||
) -> None:
|
||||
"""Нельзя включить обязательное согласие, если текст регламента пуст."""
|
||||
service = InstanceSettingsService(db_session)
|
||||
await service.ensure_bootstrapped(PLUGINS_YAML)
|
||||
|
||||
with pytest.raises(InvalidConsentPolicyError):
|
||||
await service.update(
|
||||
SettingsUpdateIn(consent_required=True, consent_policy_text=" ")
|
||||
)
|
||||
|
||||
cfg = await service.get()
|
||||
assert cfg.consent_required is False
|
||||
|
||||
|
||||
async def test_registration_email_domain_enable_without_domain_rejected(
|
||||
db_session: AsyncSession, clean_instance_settings: None
|
||||
) -> None:
|
||||
@@ -538,6 +626,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:
|
||||
|
||||
@@ -3,9 +3,13 @@
|
||||
цикл закреплённой/незакреплённой конференции — на фикстурах payload'ов LiveKit.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import base64
|
||||
import hashlib
|
||||
import json
|
||||
import uuid
|
||||
from collections.abc import AsyncGenerator
|
||||
from contextlib import asynccontextmanager
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from pathlib import Path
|
||||
from unittest.mock import AsyncMock, Mock
|
||||
@@ -13,10 +17,13 @@ from unittest.mock import AsyncMock, Mock
|
||||
import httpx
|
||||
import jwt
|
||||
import pytest
|
||||
from google.protobuf.json_format import ParseDict
|
||||
from livekit.protocol.webhook import WebhookEvent
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.dialects.postgresql import insert as pg_insert
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
import services.egress as egress_module
|
||||
import services.webhook_handlers as webhook_handlers_module
|
||||
from core.config import get_settings
|
||||
from core.security import hash_password
|
||||
@@ -27,6 +34,7 @@ from models.instance_setting import InstanceSetting
|
||||
from models.participant import ConferenceParticipant
|
||||
from models.session import ConferenceSession
|
||||
from models.user import User
|
||||
from services import hand_queue
|
||||
from services.conference_ids import generate_number, generate_slug
|
||||
from services.egress import EgressStartResult
|
||||
|
||||
@@ -54,6 +62,52 @@ def _sign(body: bytes) -> str:
|
||||
return jwt.encode(payload, settings.livekit_api_secret, algorithm="HS256")
|
||||
|
||||
|
||||
def _parse_fixture_event(name: str, **placeholders: str) -> WebhookEvent:
|
||||
"""Разобрать фикстуру в `WebhookEvent` — для тестов диспатчера без HTTP-слоя."""
|
||||
return ParseDict(json.loads(_load_fixture(name, **placeholders)), WebhookEvent())
|
||||
|
||||
|
||||
async def _set_transcriber_enabled(session: AsyncSession, *, enabled: bool) -> None:
|
||||
"""Выставить `instance_settings.transcriber.enabled`.
|
||||
|
||||
Пишется тем же `db_session` (savepoint), что и обработчик webhook (подмена
|
||||
`get_session` в фикстуре `app`) — видна обработчику без реального коммита
|
||||
в dev-БД. Тесты, которым важен `track_published`, обязаны выставлять флаг
|
||||
ЯВНО: значение в dev-БД непредсказуемо, а обработчик с версии 0.0.12
|
||||
выходит на выключенной транскрибации раньше всех остальных проверок.
|
||||
"""
|
||||
value: dict[str, object] = {
|
||||
"enabled": enabled,
|
||||
"provider": "faster_whisper_cpu" if enabled else "null",
|
||||
"model": "small" if enabled else None,
|
||||
"language": "ru",
|
||||
"options": {},
|
||||
}
|
||||
await session.execute(
|
||||
pg_insert(InstanceSetting)
|
||||
.values(key="transcriber", value=value)
|
||||
.on_conflict_do_update(index_elements=["key"], set_={"value": value})
|
||||
)
|
||||
|
||||
|
||||
def _use_test_session_in_background(
|
||||
monkeypatch: pytest.MonkeyPatch, db_session: AsyncSession
|
||||
) -> None:
|
||||
"""Заставить фоновую задачу egress работать с тестовой (savepoint) сессией.
|
||||
|
||||
`run_track_egress` намеренно берёт СВОЮ сессию (`async_session_maker`):
|
||||
в бою сессия запроса к моменту фоновой задачи уже закрыта. В тестах такое
|
||||
подключение шло бы мимо откатываемой транзакции и не увидело бы ни
|
||||
конференции, ни участника — поэтому подменяем фабрику на тестовую сессию.
|
||||
"""
|
||||
|
||||
@asynccontextmanager
|
||||
async def _maker() -> AsyncGenerator[AsyncSession, None]:
|
||||
yield db_session
|
||||
|
||||
monkeypatch.setattr(egress_module, "async_session_maker", _maker)
|
||||
|
||||
|
||||
async def _post_webhook(client: httpx.AsyncClient, body: bytes) -> httpx.Response:
|
||||
return await client.post(
|
||||
WEBHOOK_URL,
|
||||
@@ -77,7 +131,7 @@ async def _make_user(session: AsyncSession, email: str) -> User:
|
||||
user = User(
|
||||
email=email,
|
||||
name_user="Participant",
|
||||
password_hash=hash_password("password123"),
|
||||
password_hash=await hash_password("password123"),
|
||||
email_verified=True,
|
||||
)
|
||||
session.add(user)
|
||||
@@ -164,6 +218,53 @@ async def test_full_cycle_joined_left_finished(
|
||||
assert conference.ended_at is not None
|
||||
|
||||
|
||||
async def test_participant_left_removes_raised_hand_from_queue(
|
||||
client: httpx.AsyncClient, db_session: AsyncSession
|
||||
) -> None:
|
||||
"""Задача B1: участник с поднятой рукой вышел из конференции — рука исчезает из очереди."""
|
||||
conference = await _make_conference(db_session, generate_slug())
|
||||
user = await _make_user(db_session, "webhook-hand-1@example.com")
|
||||
await db_session.commit()
|
||||
|
||||
identity = str(user.id)
|
||||
await hand_queue.raise_hand(conference.id, identity=identity, name=user.name_user)
|
||||
assert [e.identity for e in await hand_queue.snapshot(conference.id)] == [identity]
|
||||
|
||||
left = _load_fixture(
|
||||
"participant_left.json",
|
||||
event_id=f"evt-{uuid.uuid4()}",
|
||||
room_name=conference.slug,
|
||||
identity=identity,
|
||||
)
|
||||
resp = await _post_webhook(client, left)
|
||||
assert resp.status_code == 200
|
||||
|
||||
assert await hand_queue.snapshot(conference.id) == []
|
||||
|
||||
|
||||
async def test_room_finished_clears_hand_queue(
|
||||
client: httpx.AsyncClient, db_session: AsyncSession
|
||||
) -> None:
|
||||
"""Задача B1: очередь поднятых рук — состояние звонка, не переживает его завершение."""
|
||||
conference = await _make_conference(db_session, generate_slug())
|
||||
await db_session.commit()
|
||||
|
||||
started = _load_fixture(
|
||||
"room_started.json", event_id=f"evt-{uuid.uuid4()}", room_name=conference.slug
|
||||
)
|
||||
assert (await _post_webhook(client, started)).status_code == 200
|
||||
|
||||
await hand_queue.raise_hand(conference.id, identity="guest:leftover", name="Leftover Guest")
|
||||
assert len(await hand_queue.snapshot(conference.id)) == 1
|
||||
|
||||
finished = _load_fixture(
|
||||
"room_finished.json", event_id=f"evt-{uuid.uuid4()}", room_name=conference.slug
|
||||
)
|
||||
assert (await _post_webhook(client, finished)).status_code == 200
|
||||
|
||||
assert await hand_queue.snapshot(conference.id) == []
|
||||
|
||||
|
||||
async def test_pinned_conference_returns_to_scheduled_on_finish(
|
||||
client: httpx.AsyncClient, db_session: AsyncSession
|
||||
) -> None:
|
||||
@@ -319,8 +420,10 @@ async def test_track_published_by_guest_starts_egress_and_creates_track_row(
|
||||
mock_start = AsyncMock(
|
||||
return_value=EgressStartResult(egress_id="EG_guest_track", started_at=started_at)
|
||||
)
|
||||
monkeypatch.setattr(webhook_handlers_module, "start_track_egress", mock_start)
|
||||
monkeypatch.setattr(egress_module, "start_track_egress", mock_start)
|
||||
_use_test_session_in_background(monkeypatch, db_session)
|
||||
|
||||
await _set_transcriber_enabled(db_session, enabled=True)
|
||||
conference = await _make_conference(db_session, generate_slug())
|
||||
guest = await _make_guest(db_session, conference)
|
||||
await db_session.commit()
|
||||
@@ -382,8 +485,10 @@ async def test_track_published_survives_egress_unavailable(
|
||||
) -> None:
|
||||
"""Недоступность egress не должна ронять webhook (блок D): 200 + warning, без строки трека."""
|
||||
mock_start = AsyncMock(side_effect=RuntimeError("egress service unavailable"))
|
||||
monkeypatch.setattr(webhook_handlers_module, "start_track_egress", mock_start)
|
||||
monkeypatch.setattr(egress_module, "start_track_egress", mock_start)
|
||||
_use_test_session_in_background(monkeypatch, db_session)
|
||||
|
||||
await _set_transcriber_enabled(db_session, enabled=True)
|
||||
conference = await _make_conference(db_session, generate_slug())
|
||||
user = await _make_user(db_session, "webhook-track-egress-down@example.com")
|
||||
await db_session.commit()
|
||||
@@ -417,13 +522,169 @@ async def test_track_published_survives_egress_unavailable(
|
||||
assert tracks == []
|
||||
|
||||
|
||||
async def test_track_published_skips_egress_when_transcription_disabled(
|
||||
client: httpx.AsyncClient, db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""Транскрибация выключена → egress не дёргается вовсе (релиз 0.0.12).
|
||||
|
||||
Симметрично guard'у в `_on_room_finished`. Без него на инстансе без профиля
|
||||
`transcribe` (egress-контейнера в деплое нет) каждый микрофонный трек
|
||||
превращался в заведомо безнадёжный вызов длиной в таймаут psrpc LiveKit —
|
||||
20–25 секунд внутри открытой транзакции вебхука. На нагрузочном тесте
|
||||
28.07.2026 это выгребало пул соединений и роняло вход в конференцию в 500.
|
||||
"""
|
||||
mock_start = AsyncMock()
|
||||
monkeypatch.setattr(egress_module, "start_track_egress", mock_start)
|
||||
_use_test_session_in_background(monkeypatch, db_session)
|
||||
|
||||
await _set_transcriber_enabled(db_session, enabled=False)
|
||||
conference = await _make_conference(db_session, generate_slug())
|
||||
user = await _make_user(db_session, "webhook-track-transcriber-off@example.com")
|
||||
await db_session.commit()
|
||||
|
||||
joined = _load_fixture(
|
||||
"participant_joined.json",
|
||||
event_id=f"evt-{uuid.uuid4()}",
|
||||
room_name=conference.slug,
|
||||
identity=str(user.id),
|
||||
)
|
||||
assert (await _post_webhook(client, joined)).status_code == 200
|
||||
|
||||
track_sid = "TR_transcriber_off"
|
||||
published = _load_fixture(
|
||||
"track_published.json",
|
||||
event_id=f"evt-{uuid.uuid4()}",
|
||||
room_name=conference.slug,
|
||||
identity=str(user.id),
|
||||
track_sid=track_sid,
|
||||
)
|
||||
assert (await _post_webhook(client, published)).status_code == 200
|
||||
|
||||
mock_start.assert_not_awaited()
|
||||
tracks = (
|
||||
await db_session.scalars(
|
||||
select(SessionAudioTrack).where(SessionAudioTrack.track_sid == track_sid)
|
||||
)
|
||||
).all()
|
||||
assert tracks == []
|
||||
|
||||
|
||||
async def test_track_published_does_not_call_egress_inside_transaction(
|
||||
db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""Запуск egress уходит в фон, а не выполняется внутри обработчика (релиз 0.0.12).
|
||||
|
||||
Проверяется не время ответа (в тестах ASGI-транспорт дожидается фоновых
|
||||
задач), а сама суть: пока открыта транзакция вебхука, сетевого вызова не
|
||||
происходит — обработчик только планирует задачу. Именно это разгружает пул
|
||||
соединений: до правки вызов жил внутри транзакции и держал соединение
|
||||
20–25 секунд, когда egress-сервиса в деплое нет.
|
||||
"""
|
||||
mock_start = AsyncMock(
|
||||
return_value=EgressStartResult(egress_id="EG_bg", started_at=datetime.now(UTC))
|
||||
)
|
||||
monkeypatch.setattr(egress_module, "start_track_egress", mock_start)
|
||||
|
||||
scheduled: list[tuple[object, dict[str, object]]] = []
|
||||
|
||||
def _schedule(func: object, **kwargs: object) -> None:
|
||||
scheduled.append((func, kwargs))
|
||||
|
||||
await _set_transcriber_enabled(db_session, enabled=True)
|
||||
conference = await _make_conference(db_session, generate_slug())
|
||||
user = await _make_user(db_session, "webhook-track-background@example.com")
|
||||
await db_session.commit()
|
||||
|
||||
dispatcher = webhook_handlers_module.WebhookDispatcher(db_session, schedule=_schedule)
|
||||
joined_event = _parse_fixture_event(
|
||||
"participant_joined.json",
|
||||
event_id=f"evt-{uuid.uuid4()}",
|
||||
room_name=conference.slug,
|
||||
identity=str(user.id),
|
||||
)
|
||||
await dispatcher.dispatch(joined_event)
|
||||
|
||||
track_sid = "TR_background"
|
||||
published_event = _parse_fixture_event(
|
||||
"track_published.json",
|
||||
event_id=f"evt-{uuid.uuid4()}",
|
||||
room_name=conference.slug,
|
||||
identity=str(user.id),
|
||||
track_sid=track_sid,
|
||||
)
|
||||
await dispatcher.dispatch(published_event)
|
||||
|
||||
# Сеть не тронута: обработчик только запланировал задачу.
|
||||
mock_start.assert_not_awaited()
|
||||
assert len(scheduled) == 1
|
||||
func, kwargs = scheduled[0]
|
||||
assert func is egress_module.run_track_egress
|
||||
assert kwargs["room_name"] == conference.slug
|
||||
assert kwargs["track_sid"] == track_sid
|
||||
|
||||
session_record = await db_session.scalar(
|
||||
select(ConferenceSession).where(ConferenceSession.conference_id == conference.id)
|
||||
)
|
||||
assert session_record is not None
|
||||
assert kwargs["session_id"] == session_record.id
|
||||
|
||||
# А вот запущенная задача действительно ходит в egress и пишет строку.
|
||||
_use_test_session_in_background(monkeypatch, db_session)
|
||||
await egress_module.run_track_egress(**kwargs) # type: ignore[arg-type]
|
||||
mock_start.assert_awaited_once()
|
||||
|
||||
track_row = await db_session.scalar(
|
||||
select(SessionAudioTrack).where(SessionAudioTrack.track_sid == track_sid)
|
||||
)
|
||||
assert track_row is not None
|
||||
assert track_row.egress_id == "EG_bg"
|
||||
|
||||
|
||||
async def test_start_track_egress_gives_up_on_timeout(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""Запуск egress не ждёт дольше `egress_start_timeout_s` (релиз 0.0.12).
|
||||
|
||||
Когда egress-воркера нет, LiveKit держит вызов до собственного таймаута
|
||||
psrpc — на тесте 28.07.2026 это было 20–25 секунд на каждый микрофонный
|
||||
трек. Живой egress отвечает за доли секунды, ждать столько незачем.
|
||||
"""
|
||||
settings = get_settings()
|
||||
monkeypatch.setattr(settings, "egress_start_timeout_s", 0.05, raising=False)
|
||||
|
||||
closed = False
|
||||
|
||||
class _HangingEgress:
|
||||
async def start_track_egress(self, _request: object) -> object:
|
||||
await asyncio.sleep(5)
|
||||
raise AssertionError("вызов должен был прерваться по таймауту")
|
||||
|
||||
class _HangingApi:
|
||||
def __init__(self, *_args: object, **_kwargs: object) -> None:
|
||||
self.egress = _HangingEgress()
|
||||
|
||||
async def aclose(self) -> None:
|
||||
nonlocal closed
|
||||
closed = True
|
||||
|
||||
# Строковая форма: `api` в `services.egress` — реэкспорт из livekit SDK,
|
||||
# обращение к нему атрибутом mypy считает неявным экспортом.
|
||||
monkeypatch.setattr("services.egress.api.LiveKitAPI", _HangingApi)
|
||||
|
||||
with pytest.raises(TimeoutError):
|
||||
await egress_module.start_track_egress("room", "TR_hang", "/recordings/x.ogg")
|
||||
|
||||
# Клиент закрывается и на неуспешном пути — иначе утекали бы соединения.
|
||||
assert closed is True
|
||||
|
||||
|
||||
async def test_track_published_video_is_noop(
|
||||
client: httpx.AsyncClient, db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""№9 плана (часть 1): video-трек — no-op, egress не запускается."""
|
||||
mock_start = AsyncMock()
|
||||
monkeypatch.setattr(webhook_handlers_module, "start_track_egress", mock_start)
|
||||
monkeypatch.setattr(egress_module, "start_track_egress", mock_start)
|
||||
_use_test_session_in_background(monkeypatch, db_session)
|
||||
|
||||
await _set_transcriber_enabled(db_session, enabled=True)
|
||||
conference = await _make_conference(db_session, generate_slug())
|
||||
user = await _make_user(db_session, "webhook-track-video@example.com")
|
||||
await db_session.commit()
|
||||
@@ -462,8 +723,10 @@ async def test_track_published_repeated_webhook_creates_single_row(
|
||||
mock_start = AsyncMock(
|
||||
return_value=EgressStartResult(egress_id="EG_repeat", started_at=datetime.now(UTC))
|
||||
)
|
||||
monkeypatch.setattr(webhook_handlers_module, "start_track_egress", mock_start)
|
||||
monkeypatch.setattr(egress_module, "start_track_egress", mock_start)
|
||||
_use_test_session_in_background(monkeypatch, db_session)
|
||||
|
||||
await _set_transcriber_enabled(db_session, enabled=True)
|
||||
conference = await _make_conference(db_session, generate_slug())
|
||||
user = await _make_user(db_session, "webhook-track-repeat@example.com")
|
||||
await db_session.commit()
|
||||
@@ -508,6 +771,8 @@ async def test_egress_ended_finalizes_track_success_and_failure(
|
||||
client: httpx.AsyncClient, db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""№10 плана (часть 1): `egress_ended` — 'recorded' на успехе, 'failed' на ошибке."""
|
||||
_use_test_session_in_background(monkeypatch, db_session)
|
||||
await _set_transcriber_enabled(db_session, enabled=True)
|
||||
conference = await _make_conference(db_session, generate_slug())
|
||||
user = await _make_user(db_session, "webhook-egress-ended@example.com")
|
||||
await db_session.commit()
|
||||
@@ -527,7 +792,7 @@ async def test_egress_ended_finalizes_track_success_and_failure(
|
||||
# Успешная запись.
|
||||
ok_started = datetime.now(UTC)
|
||||
monkeypatch.setattr(
|
||||
webhook_handlers_module,
|
||||
egress_module,
|
||||
"start_track_egress",
|
||||
AsyncMock(return_value=EgressStartResult(egress_id="EG_ok", started_at=ok_started)),
|
||||
)
|
||||
@@ -559,7 +824,7 @@ async def test_egress_ended_finalizes_track_success_and_failure(
|
||||
|
||||
# Ошибка записи.
|
||||
monkeypatch.setattr(
|
||||
webhook_handlers_module,
|
||||
egress_module,
|
||||
"start_track_egress",
|
||||
AsyncMock(
|
||||
return_value=EgressStartResult(egress_id="EG_fail", started_at=datetime.now(UTC))
|
||||
@@ -597,6 +862,10 @@ async def test_room_finished_enqueues_pipeline(
|
||||
mock_enqueue = Mock()
|
||||
monkeypatch.setattr(webhook_handlers_module, "enqueue_pipeline", mock_enqueue)
|
||||
|
||||
# Флаг выставляется явно: `_on_room_finished` ставит задачу в очередь только
|
||||
# при включённой транскрибации, а состояние `instance_settings` в dev-БД
|
||||
# непредсказуемо (тест падал, если в базе оставалось `enabled: false`).
|
||||
await _set_transcriber_enabled(db_session, enabled=True)
|
||||
conference = await _make_conference(db_session, generate_slug())
|
||||
await db_session.commit()
|
||||
|
||||
|
||||
@@ -20,6 +20,7 @@ from prometheus_client.samples import Sample
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from api import metrics as metrics_module
|
||||
from core.config import get_settings
|
||||
from core.redis import redis_client
|
||||
from models.conference import Conference
|
||||
from models.session import ConferenceSession
|
||||
@@ -57,6 +58,37 @@ async def test_metrics_endpoint_returns_prometheus_exposition_format(
|
||||
assert "vidconf_http_request_duration_seconds" in families
|
||||
assert "vidconf_pipeline_sessions" in families
|
||||
assert "vidconf_celery_queue_depth" in families
|
||||
assert "vidconf_host_info" in families
|
||||
assert "vidconf_db_up" in families
|
||||
assert "vidconf_db_pool_size" in families
|
||||
assert "vidconf_db_pool_max_overflow" in families
|
||||
assert "vidconf_db_pool_checked_out" in families
|
||||
assert "vidconf_redis_pool_in_use" in families
|
||||
assert "vidconf_redis_pool_max_connections" in families
|
||||
|
||||
|
||||
async def test_metrics_host_info_gauge_reflects_settings(
|
||||
client: httpx.AsyncClient, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""`vidconf_host_info` отдаёт `HW_*` из настроек как лейблы, значение всегда 1."""
|
||||
settings = get_settings()
|
||||
monkeypatch.setattr(settings, "hw_cpus", 4)
|
||||
monkeypatch.setattr(settings, "hw_ram_mb", 7937)
|
||||
monkeypatch.setattr(settings, "hw_gpu_name", None)
|
||||
monkeypatch.setattr(settings, "hw_vram_mb", None)
|
||||
|
||||
response = await client.get("/metrics")
|
||||
|
||||
samples = _samples(response.text, "vidconf_host_info")
|
||||
value = _sample_value(
|
||||
samples,
|
||||
suffix="vidconf_host_info",
|
||||
cpus="4",
|
||||
ram_mb="7937",
|
||||
gpu_name="—",
|
||||
vram_mb="—",
|
||||
)
|
||||
assert value == 1
|
||||
|
||||
|
||||
async def test_metrics_records_http_latency_by_route(client: httpx.AsyncClient) -> None:
|
||||
@@ -103,6 +135,72 @@ async def test_metrics_pipeline_sessions_gauge_reflects_new_session(
|
||||
assert after == before + 1
|
||||
|
||||
|
||||
async def test_metrics_db_up_gauge_reflects_real_connectivity(client: httpx.AsyncClient) -> None:
|
||||
"""Против реального тестового Postgres (см. докстринг conftest) `vidconf_db_up` == 1."""
|
||||
response = await client.get("/metrics")
|
||||
value = _sample_value(_samples(response.text, "vidconf_db_up"), suffix="vidconf_db_up")
|
||||
assert value == 1
|
||||
|
||||
|
||||
async def test_metrics_db_up_gauge_reports_down_without_crashing_endpoint(
|
||||
client: httpx.AsyncClient, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""Недоступность БД (проверка вне пула не удалась) не роняет `/metrics` — отдаёт 0, не 500."""
|
||||
|
||||
async def _fail() -> bool:
|
||||
return False
|
||||
|
||||
monkeypatch.setattr(metrics_module, "check_db_up", _fail)
|
||||
|
||||
response = await client.get("/metrics")
|
||||
|
||||
assert response.status_code == 200
|
||||
value = _sample_value(_samples(response.text, "vidconf_db_up"), suffix="vidconf_db_up")
|
||||
assert value == 0
|
||||
|
||||
|
||||
async def test_metrics_db_pool_gauges_reflect_settings_not_usage(
|
||||
client: httpx.AsyncClient,
|
||||
) -> None:
|
||||
"""`vidconf_db_pool_size`/`_max_overflow` — конфигурация из `Settings`, не текущая занятость."""
|
||||
settings = get_settings()
|
||||
response = await client.get("/metrics")
|
||||
|
||||
samples_size = _samples(response.text, "vidconf_db_pool_size")
|
||||
samples_overflow = _samples(response.text, "vidconf_db_pool_max_overflow")
|
||||
size = _sample_value(samples_size, suffix="vidconf_db_pool_size")
|
||||
max_overflow = _sample_value(samples_overflow, suffix="vidconf_db_pool_max_overflow")
|
||||
|
||||
assert size == settings.db_pool_size
|
||||
assert max_overflow == settings.db_max_overflow
|
||||
|
||||
|
||||
async def test_metrics_endpoint_survives_pipeline_gauge_failure(
|
||||
client: httpx.AsyncClient, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""Падение/таймаут основного пула на одном gauge не роняет весь `/metrics`.
|
||||
|
||||
Симулирует ровно ситуацию инцидента 07.08 (`api/metrics.py` падал вместе
|
||||
со всем остальным при исчерпанном пуле): `count_by_pipeline_status`
|
||||
поднимает исключение — `vidconf_db_up`/`vidconf_db_pool_*` (не зависящие
|
||||
от основного пула) при этом всё равно приходят в ответе.
|
||||
"""
|
||||
|
||||
async def _raise(*args: object, **kwargs: object) -> dict[str, int]:
|
||||
raise TimeoutError("основной пул занят (симуляция теста)")
|
||||
|
||||
monkeypatch.setattr(
|
||||
"repositories.conferences.ConferenceSessionRepository.count_by_pipeline_status",
|
||||
_raise,
|
||||
)
|
||||
|
||||
response = await client.get("/metrics")
|
||||
|
||||
assert response.status_code == 200
|
||||
db_up = _sample_value(_samples(response.text, "vidconf_db_up"), suffix="vidconf_db_up")
|
||||
assert db_up == 1
|
||||
|
||||
|
||||
async def test_metrics_celery_queue_depth_gauge(
|
||||
client: httpx.AsyncClient, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
|
||||
245
backend/tests/test_mute_participant_api.py
Normal file
245
backend/tests/test_mute_participant_api.py
Normal file
@@ -0,0 +1,245 @@
|
||||
"""Тесты `POST /api/v1/conferences/{id}/mute-participant` (задача B2).
|
||||
|
||||
`mute_participant_track` (реальный вызов LiveKit `RoomServiceClient`) мокается
|
||||
на уровне `services.conferences` — тот же паттерн, что и `start_track_egress`
|
||||
в `tests/test_livekit_webhook.py`: сетевой вызов к LiveKit в тестах не нужен,
|
||||
важна только бизнес-логика (права, маршрутизация ошибок, broadcast).
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import uuid
|
||||
from typing import Any
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
from redis.asyncio.client import PubSub
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
import services.conferences as conferences_module
|
||||
from core.redis import redis_client
|
||||
from core.security import create_access_token, hash_password
|
||||
from models.conference import Conference
|
||||
from models.user import User
|
||||
from services.conference_ids import generate_number, generate_slug
|
||||
from services.hand_queue import hand_queue_channel
|
||||
from services.room_control import ParticipantNotInRoomError
|
||||
|
||||
MUTE_URL = "{base}/mute-participant"
|
||||
|
||||
|
||||
async def _receive_within(pubsub: PubSub, *, max_wait: float) -> dict[str, Any] | None:
|
||||
"""Дождаться СОДЕРЖАТЕЛЬНОГО сообщения канала в пределах `max_wait` секунд.
|
||||
|
||||
`ignore_subscribe_messages=True` у `get_message` фильтрует служебное
|
||||
подтверждение подписки, но при этом всё равно может вернуть `None` для
|
||||
ЭТОГО конкретного вызова (см. `api/chat.py::_pump_pubsub_to_websocket`,
|
||||
ровно поэтому там `while True: ... if raw is None: continue`) — здесь тот
|
||||
же цикл, но с общим дедлайном вместо бесконечного ожидания.
|
||||
"""
|
||||
deadline = asyncio.get_event_loop().time() + max_wait
|
||||
while True:
|
||||
remaining = deadline - asyncio.get_event_loop().time()
|
||||
if remaining <= 0:
|
||||
return None
|
||||
raw: dict[str, Any] | None = await pubsub.get_message(
|
||||
ignore_subscribe_messages=True, timeout=remaining
|
||||
)
|
||||
if raw is not None:
|
||||
return raw
|
||||
|
||||
|
||||
async def _make_user(session: AsyncSession, *, role: str = "user") -> User:
|
||||
user = User(
|
||||
email=f"{uuid.uuid4()}@example.com",
|
||||
name_user="Mute Tester",
|
||||
password_hash=await hash_password("password123"),
|
||||
email_verified=True,
|
||||
role=role,
|
||||
)
|
||||
session.add(user)
|
||||
await session.flush()
|
||||
return user
|
||||
|
||||
|
||||
async def _make_conference(
|
||||
session: AsyncSession, *, owner_id: uuid.UUID | None = None
|
||||
) -> Conference:
|
||||
conference = Conference(
|
||||
number=generate_number(),
|
||||
slug=generate_slug(),
|
||||
title="Mute Test",
|
||||
status="active",
|
||||
owner_id=owner_id,
|
||||
)
|
||||
session.add(conference)
|
||||
await session.flush()
|
||||
return conference
|
||||
|
||||
|
||||
def _auth_headers(user: User) -> dict[str, str]:
|
||||
return {"Authorization": f"Bearer {create_access_token(user.id, user.role)}"}
|
||||
|
||||
|
||||
def _url(conference_id: uuid.UUID) -> str:
|
||||
return MUTE_URL.format(base=f"/api/v1/conferences/{conference_id}")
|
||||
|
||||
|
||||
async def test_owner_can_mute_participant_and_broadcast_is_published(
|
||||
client: httpx.AsyncClient, db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
owner = await _make_user(db_session)
|
||||
conference = await _make_conference(db_session, owner_id=owner.id)
|
||||
await db_session.commit()
|
||||
|
||||
mock_mute = AsyncMock(return_value=True)
|
||||
monkeypatch.setattr(conferences_module, "mute_participant_track", mock_mute)
|
||||
|
||||
pubsub = redis_client.pubsub()
|
||||
channel = hand_queue_channel(conference.id)
|
||||
await pubsub.subscribe(channel)
|
||||
try:
|
||||
response = await client.post(
|
||||
_url(conference.id),
|
||||
json={"identity": "some-identity", "source": "microphone"},
|
||||
headers=_auth_headers(owner),
|
||||
)
|
||||
assert response.status_code == 200, response.text
|
||||
assert response.json() == {"muted": True}
|
||||
mock_mute.assert_awaited_once_with(
|
||||
conference.slug, identity="some-identity", source="microphone"
|
||||
)
|
||||
|
||||
raw = await _receive_within(pubsub, max_wait=2)
|
||||
assert raw is not None
|
||||
assert raw["data"] == (
|
||||
'{"type":"forced_mute","identity":"some-identity","source":"microphone"}'
|
||||
)
|
||||
finally:
|
||||
await pubsub.unsubscribe(channel)
|
||||
await pubsub.aclose() # type: ignore[no-untyped-call]
|
||||
|
||||
|
||||
async def test_mute_already_off_does_not_broadcast(
|
||||
client: httpx.AsyncClient, db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""Трек не был опубликован (камера/мьют и так выключены, задача A1) — не ошибка.
|
||||
|
||||
Ответ `muted=false` (искомое состояние уже достигнуто), без broadcast'а.
|
||||
"""
|
||||
owner = await _make_user(db_session)
|
||||
conference = await _make_conference(db_session, owner_id=owner.id)
|
||||
await db_session.commit()
|
||||
|
||||
monkeypatch.setattr(
|
||||
conferences_module, "mute_participant_track", AsyncMock(return_value=False)
|
||||
)
|
||||
|
||||
pubsub = redis_client.pubsub()
|
||||
channel = hand_queue_channel(conference.id)
|
||||
await pubsub.subscribe(channel)
|
||||
try:
|
||||
response = await client.post(
|
||||
_url(conference.id),
|
||||
json={"identity": "some-identity", "source": "camera"},
|
||||
headers=_auth_headers(owner),
|
||||
)
|
||||
assert response.status_code == 200, response.text
|
||||
assert response.json() == {"muted": False}
|
||||
|
||||
raw = await _receive_within(pubsub, max_wait=0.5)
|
||||
assert raw is None
|
||||
finally:
|
||||
await pubsub.unsubscribe(channel)
|
||||
await pubsub.aclose() # type: ignore[no-untyped-call]
|
||||
|
||||
|
||||
async def test_admin_can_mute_participant_of_someone_elses_conference(
|
||||
client: httpx.AsyncClient, db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
owner = await _make_user(db_session)
|
||||
admin = await _make_user(db_session, role="admin")
|
||||
conference = await _make_conference(db_session, owner_id=owner.id)
|
||||
await db_session.commit()
|
||||
|
||||
monkeypatch.setattr(conferences_module, "mute_participant_track", AsyncMock(return_value=True))
|
||||
|
||||
response = await client.post(
|
||||
_url(conference.id),
|
||||
json={"identity": "some-identity", "source": "microphone"},
|
||||
headers=_auth_headers(admin),
|
||||
)
|
||||
assert response.status_code == 200, response.text
|
||||
|
||||
|
||||
async def test_regular_participant_cannot_mute_someone_else(
|
||||
client: httpx.AsyncClient, db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
owner = await _make_user(db_session)
|
||||
other = await _make_user(db_session)
|
||||
conference = await _make_conference(db_session, owner_id=owner.id)
|
||||
await db_session.commit()
|
||||
|
||||
mock_mute = AsyncMock()
|
||||
monkeypatch.setattr(conferences_module, "mute_participant_track", mock_mute)
|
||||
|
||||
response = await client.post(
|
||||
_url(conference.id),
|
||||
json={"identity": str(owner.id), "source": "microphone"},
|
||||
headers=_auth_headers(other),
|
||||
)
|
||||
assert response.status_code == 403
|
||||
assert response.json()["detail"] == "not_owner"
|
||||
mock_mute.assert_not_awaited()
|
||||
|
||||
|
||||
async def test_mute_conference_not_found(
|
||||
client: httpx.AsyncClient, db_session: AsyncSession
|
||||
) -> None:
|
||||
user = await _make_user(db_session)
|
||||
await db_session.commit()
|
||||
|
||||
response = await client.post(
|
||||
_url(uuid.uuid4()),
|
||||
json={"identity": "some-identity", "source": "microphone"},
|
||||
headers=_auth_headers(user),
|
||||
)
|
||||
assert response.status_code == 404
|
||||
assert response.json()["detail"] == "conference_not_found"
|
||||
|
||||
|
||||
async def test_mute_participant_not_in_room_returns_404(
|
||||
client: httpx.AsyncClient, db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
owner = await _make_user(db_session)
|
||||
conference = await _make_conference(db_session, owner_id=owner.id)
|
||||
await db_session.commit()
|
||||
|
||||
monkeypatch.setattr(
|
||||
conferences_module,
|
||||
"mute_participant_track",
|
||||
AsyncMock(side_effect=ParticipantNotInRoomError()),
|
||||
)
|
||||
|
||||
response = await client.post(
|
||||
_url(conference.id),
|
||||
json={"identity": "ghost", "source": "microphone"},
|
||||
headers=_auth_headers(owner),
|
||||
)
|
||||
assert response.status_code == 404
|
||||
assert response.json()["detail"] == "participant_not_in_room"
|
||||
|
||||
|
||||
async def test_mute_rejects_invalid_source(
|
||||
client: httpx.AsyncClient, db_session: AsyncSession
|
||||
) -> None:
|
||||
owner = await _make_user(db_session)
|
||||
conference = await _make_conference(db_session, owner_id=owner.id)
|
||||
await db_session.commit()
|
||||
|
||||
response = await client.post(
|
||||
_url(conference.id),
|
||||
json={"identity": "some-identity", "source": "screen_share"},
|
||||
headers=_auth_headers(owner),
|
||||
)
|
||||
assert response.status_code == 422
|
||||
128
backend/tests/test_password_hashing.py
Normal file
128
backend/tests/test_password_hashing.py
Normal file
@@ -0,0 +1,128 @@
|
||||
"""Проверка пароля не должна блокировать event loop (регресс после теста 31.07.2026).
|
||||
|
||||
Синхронный `verify_password` останавливал весь процесс backend на 95–155 мс.
|
||||
При массовом входе (около 70 человек разом) это давало p95 логина 7.28 секунды,
|
||||
33 соединения к БД в состоянии `idle in transaction` при одном активном запросе
|
||||
и отказы на посторонних ручках — включая вход в конференцию, где пароль вообще
|
||||
не проверялся. Разбор — `.forcc/LOGIN-BOTTLENECK.md`.
|
||||
|
||||
Тесты ниже проверяют не скорость (она зависит от железа), а **свойства**:
|
||||
event loop остаётся живым, проверки идут параллельно, старые хэши мигрируют.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import time
|
||||
|
||||
from argon2 import PasswordHasher
|
||||
|
||||
from core.security import hash_password, needs_rehash, verify_password
|
||||
|
||||
PASSWORD = "correct-horse-battery-staple"
|
||||
|
||||
|
||||
async def test_verify_password_does_not_block_event_loop() -> None:
|
||||
"""Пока считается argon2, event loop продолжает обслуживать другие задачи.
|
||||
|
||||
Это главное свойство правки. Фоновая корутина тикает каждую миллисекунду;
|
||||
если проверка пароля выполняется синхронно в loop, тиков за её время будет
|
||||
ноль или единицы — именно так и вело себя приложение до исправления.
|
||||
"""
|
||||
password_hash = await hash_password(PASSWORD)
|
||||
|
||||
ticks = 0
|
||||
stop = False
|
||||
|
||||
async def ticker() -> None:
|
||||
nonlocal ticks
|
||||
while not stop:
|
||||
ticks += 1
|
||||
await asyncio.sleep(0.001)
|
||||
|
||||
ticker_task = asyncio.create_task(ticker())
|
||||
await asyncio.sleep(0.005) # даём тикеру стартовать
|
||||
|
||||
ticks_before = ticks
|
||||
assert await verify_password(PASSWORD, password_hash) is True
|
||||
ticks_during = ticks - ticks_before
|
||||
|
||||
stop = True
|
||||
await ticker_task
|
||||
|
||||
# Даже на быстром железе argon2 занимает десятки миллисекунд — за это время
|
||||
# loop обязан прокрутить заметное число тиков. Порог намеренно щадящий:
|
||||
# при блокировке тиков будет 0–1, а не «мало».
|
||||
assert ticks_during >= 5, (
|
||||
f"event loop простоял во время проверки пароля: {ticks_during} тиков — "
|
||||
"похоже, argon2 снова считается синхронно"
|
||||
)
|
||||
|
||||
|
||||
async def test_parallel_verifications_are_concurrent() -> None:
|
||||
"""Параллельные проверки идут одновременно, а не выстраиваются в очередь.
|
||||
|
||||
argon2-cffi — C-расширение и освобождает GIL, поэтому пул потоков даёт
|
||||
настоящий параллелизм. Проверяем, что 8 проверок занимают заметно меньше,
|
||||
чем 8 последовательных: иначе массовый вход снова упрётся в сериализацию.
|
||||
"""
|
||||
password_hash = await hash_password(PASSWORD)
|
||||
|
||||
start = time.perf_counter()
|
||||
await verify_password(PASSWORD, password_hash)
|
||||
single = time.perf_counter() - start
|
||||
|
||||
start = time.perf_counter()
|
||||
results = await asyncio.gather(*(verify_password(PASSWORD, password_hash) for _ in range(8)))
|
||||
parallel = time.perf_counter() - start
|
||||
|
||||
assert all(results)
|
||||
# На 4-ядерном сервере 8 проверок идеально легли бы в 2×single; берём 5×
|
||||
# с большим запасом на шум CI и разное железо — важно лишь то, что это
|
||||
# НЕ 8× (последовательное выполнение).
|
||||
assert parallel < single * 5, (
|
||||
f"8 параллельных проверок заняли {parallel:.3f} с при {single:.3f} с на одну — "
|
||||
"похоже, они выполняются последовательно"
|
||||
)
|
||||
|
||||
|
||||
async def test_wrong_password_is_rejected() -> None:
|
||||
"""Асинхронная обёртка не сломала саму проверку."""
|
||||
password_hash = await hash_password(PASSWORD)
|
||||
assert await verify_password(PASSWORD, password_hash) is True
|
||||
assert await verify_password("wrong-password", password_hash) is False
|
||||
|
||||
|
||||
async def test_hasher_uses_owasp_parameters() -> None:
|
||||
"""Параметры argon2id — по рекомендации OWASP, а не дефолт библиотеки.
|
||||
|
||||
Дефолт argon2-cffi (t=3, m=64 МБ, p=4) стоил 95 мс на проверку, причём
|
||||
`parallelism=4` занимал все четыре ядра сервера — те же, на которых
|
||||
работает LiveKit.
|
||||
"""
|
||||
password_hash = await hash_password(PASSWORD)
|
||||
# Параметры зашиты в саму строку хэша: $argon2id$v=19$m=19456,t=2,p=1$...
|
||||
assert "m=19456" in password_hash
|
||||
assert "t=2" in password_hash
|
||||
assert "p=1" in password_hash
|
||||
|
||||
|
||||
async def test_legacy_hash_is_verified_and_marked_for_rehash() -> None:
|
||||
"""Хэш со старыми параметрами проверяется, но помечается на перевыпуск.
|
||||
|
||||
Гарантия обратной совместимости: пароли, выданные до смены параметров,
|
||||
продолжают работать. `AuthService.login` перевыпускает такой хэш при
|
||||
первом же успешном входе — другого момента, когда открытый пароль есть
|
||||
на руках, не будет.
|
||||
"""
|
||||
legacy_hasher = PasswordHasher(time_cost=3, memory_cost=65536, parallelism=4)
|
||||
legacy_hash = legacy_hasher.hash(PASSWORD)
|
||||
|
||||
assert await verify_password(PASSWORD, legacy_hash) is True
|
||||
assert needs_rehash(legacy_hash) is True
|
||||
|
||||
fresh_hash = await hash_password(PASSWORD)
|
||||
assert needs_rehash(fresh_hash) is False
|
||||
|
||||
|
||||
async def test_broken_hash_is_marked_for_rehash() -> None:
|
||||
"""Мусор вместо хэша не роняет вход, а помечается на замену."""
|
||||
assert needs_rehash("not-a-valid-argon2-hash") is True
|
||||
59
backend/tests/test_public_api.py
Normal file
59
backend/tests/test_public_api.py
Normal file
@@ -0,0 +1,59 @@
|
||||
"""Тесты публичного эндпоинта настроек клиента (`/api/v1/public/settings`, сессия 33)."""
|
||||
|
||||
import httpx
|
||||
import pytest_asyncio
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from services.instance_settings import InstanceSettingsService, SettingsUpdateIn
|
||||
|
||||
|
||||
@pytest_asyncio.fixture(autouse=True)
|
||||
async def _reset_public_flags(db_session: AsyncSession) -> None:
|
||||
"""Сбросить публичные тогглы перед каждым тестом — общая dev-БД не изолирована
|
||||
от ручных правок администратора (та же дисциплина, что и `_reset_registration_gating`
|
||||
в `test_auth.py`); `db_session` не коммитится в реальную БД, см. `conftest.py`."""
|
||||
await InstanceSettingsService(db_session).update(
|
||||
SettingsUpdateIn(device_check_enabled=False, virtual_background_enabled=False)
|
||||
)
|
||||
|
||||
|
||||
async def test_public_settings_disabled_by_default(
|
||||
client: httpx.AsyncClient, db_session: AsyncSession
|
||||
) -> None:
|
||||
response = await client.get("/api/v1/public/settings")
|
||||
assert response.status_code == 200, response.text
|
||||
assert response.json() == {"device_check_enabled": False, "virtual_background_enabled": False}
|
||||
|
||||
|
||||
async def test_public_settings_reflects_enabled(
|
||||
client: httpx.AsyncClient, db_session: AsyncSession
|
||||
) -> None:
|
||||
await InstanceSettingsService(db_session).update(SettingsUpdateIn(device_check_enabled=True))
|
||||
await db_session.commit()
|
||||
|
||||
response = await client.get("/api/v1/public/settings")
|
||||
assert response.status_code == 200, response.text
|
||||
assert response.json()["device_check_enabled"] is True
|
||||
|
||||
|
||||
async def test_public_settings_reflects_virtual_background_enabled(
|
||||
client: httpx.AsyncClient, db_session: AsyncSession
|
||||
) -> None:
|
||||
"""Замена фона нужна и на публичном превью входа (`JoinPage`) — до аутентификации."""
|
||||
await InstanceSettingsService(db_session).update(
|
||||
SettingsUpdateIn(virtual_background_enabled=True)
|
||||
)
|
||||
await db_session.commit()
|
||||
|
||||
response = await client.get("/api/v1/public/settings")
|
||||
assert response.status_code == 200, response.text
|
||||
assert response.json()["virtual_background_enabled"] is True
|
||||
|
||||
|
||||
async def test_public_settings_requires_no_auth(
|
||||
client: httpx.AsyncClient, db_session: AsyncSession
|
||||
) -> None:
|
||||
"""Эндпоинт публичный — работает без заголовка `Authorization`, как и обязан
|
||||
(обе страницы, которым он нужен, доступны до входа в систему)."""
|
||||
response = await client.get("/api/v1/public/settings")
|
||||
assert response.status_code == 200, response.text
|
||||
@@ -20,7 +20,7 @@ async def _make_user(session: AsyncSession, *, role: str = "user") -> User:
|
||||
user = User(
|
||||
email=f"{uuid.uuid4()}@example.com",
|
||||
name_user="Test User",
|
||||
password_hash=hash_password("password123"),
|
||||
password_hash=await hash_password("password123"),
|
||||
role=role,
|
||||
email_verified=True,
|
||||
)
|
||||
|
||||
@@ -18,7 +18,7 @@ async def _make_user(session: AsyncSession) -> User:
|
||||
user = User(
|
||||
email=f"{uuid.uuid4()}@example.com",
|
||||
name_user="Teams API Tester",
|
||||
password_hash=hash_password("password123"),
|
||||
password_hash=await hash_password("password123"),
|
||||
email_verified=True,
|
||||
)
|
||||
session.add(user)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""Интеграционные тесты `/api/v1/users`: список пользователей, профиль, аватар."""
|
||||
"""Интеграционные тесты `/api/v1/users`: список пользователей, профиль, аватар, картинки фона."""
|
||||
|
||||
import uuid
|
||||
from collections.abc import Generator
|
||||
@@ -12,6 +12,7 @@ from core.config import get_settings
|
||||
from core.security import create_access_token, hash_password
|
||||
from models.team import Team
|
||||
from models.user import User
|
||||
from services.backgrounds import MAX_BACKGROUNDS_PER_USER
|
||||
|
||||
# Минимальные валидные по магическим байтам содержимые (без полноценного
|
||||
# декодирования — `services/avatars.py` проверяет только сигнатуру/размер).
|
||||
@@ -24,7 +25,7 @@ async def _make_user(session: AsyncSession) -> User:
|
||||
user = User(
|
||||
email=f"{uuid.uuid4()}@example.com",
|
||||
name_user="List Tester",
|
||||
password_hash=hash_password("password123"),
|
||||
password_hash=await hash_password("password123"),
|
||||
email_verified=True,
|
||||
)
|
||||
session.add(user)
|
||||
@@ -98,7 +99,7 @@ async def test_get_me_with_reserved_tld_email_does_not_500(
|
||||
user = User(
|
||||
email=legacy_email,
|
||||
name_user="Legacy Admin",
|
||||
password_hash=hash_password("password123"),
|
||||
password_hash=await hash_password("password123"),
|
||||
email_verified=True,
|
||||
role="admin",
|
||||
)
|
||||
@@ -380,7 +381,7 @@ async def test_list_users_search_by_q_filters_by_name_or_email(
|
||||
match = User(
|
||||
email=f"{unique_marker}@example.com",
|
||||
name_user=f"Findable {unique_marker}",
|
||||
password_hash=hash_password("password123"),
|
||||
password_hash=await hash_password("password123"),
|
||||
email_verified=True,
|
||||
)
|
||||
db_session.add(match)
|
||||
@@ -393,3 +394,150 @@ async def test_list_users_search_by_q_filters_by_name_or_email(
|
||||
ids = {item["id"] for item in response.json()}
|
||||
assert str(match.id) in ids
|
||||
assert str(requester.id) not in ids
|
||||
|
||||
|
||||
# --- Свои картинки фона (`/users/me/backgrounds`) -----------------
|
||||
|
||||
|
||||
async def _upload_background(
|
||||
client: httpx.AsyncClient, user: User, *, name: str = "bg.webp"
|
||||
) -> httpx.Response:
|
||||
return await client.post(
|
||||
"/api/v1/users/me/backgrounds",
|
||||
headers=_auth_headers(user),
|
||||
files={"file": (name, _WEBP_BYTES, "image/webp")},
|
||||
)
|
||||
|
||||
|
||||
async def test_backgrounds_list_is_empty_by_default_and_reports_limit(
|
||||
media_root: Path, client: httpx.AsyncClient, db_session: AsyncSession
|
||||
) -> None:
|
||||
user = await _make_user(db_session)
|
||||
await db_session.commit()
|
||||
|
||||
response = await client.get("/api/v1/users/me/backgrounds", headers=_auth_headers(user))
|
||||
assert response.status_code == 200, response.text
|
||||
body = response.json()
|
||||
assert body["items"] == []
|
||||
assert body["limit"] == MAX_BACKGROUNDS_PER_USER
|
||||
|
||||
|
||||
async def test_upload_background_saves_file_and_returns_url(
|
||||
media_root: Path, client: httpx.AsyncClient, db_session: AsyncSession
|
||||
) -> None:
|
||||
user = await _make_user(db_session)
|
||||
await db_session.commit()
|
||||
|
||||
response = await _upload_background(client, user)
|
||||
assert response.status_code == 201, response.text
|
||||
items = response.json()["items"]
|
||||
assert len(items) == 1
|
||||
url = items[0]["url"]
|
||||
assert url == f"/media/backgrounds/{user.id}/{items[0]['id']}.webp"
|
||||
# Файл лежит на диске, в БД только путь — см. `services/backgrounds.py`.
|
||||
assert (media_root / url.removeprefix("/media/")).read_bytes() == _WEBP_BYTES
|
||||
|
||||
|
||||
async def test_upload_background_spoofed_content_type_returns_415(
|
||||
media_root: Path, client: httpx.AsyncClient, db_session: AsyncSession
|
||||
) -> None:
|
||||
"""`Content-Type: image/webp`, но байты — JPEG: клиенту не верим и здесь."""
|
||||
user = await _make_user(db_session)
|
||||
await db_session.commit()
|
||||
|
||||
response = await client.post(
|
||||
"/api/v1/users/me/backgrounds",
|
||||
headers=_auth_headers(user),
|
||||
files={"file": ("bg.webp", _JPEG_BYTES, "image/webp")},
|
||||
)
|
||||
assert response.status_code == 415
|
||||
assert response.json()["detail"] == "background_invalid_type"
|
||||
|
||||
|
||||
async def test_upload_background_too_large_returns_413(
|
||||
media_root: Path, client: httpx.AsyncClient, db_session: AsyncSession
|
||||
) -> None:
|
||||
user = await _make_user(db_session)
|
||||
await db_session.commit()
|
||||
oversized = _WEBP_BYTES + b"\x00" * (2 * 1024 * 1024)
|
||||
|
||||
response = await client.post(
|
||||
"/api/v1/users/me/backgrounds",
|
||||
headers=_auth_headers(user),
|
||||
files={"file": ("bg.webp", oversized, "image/webp")},
|
||||
)
|
||||
assert response.status_code == 413
|
||||
assert response.json()["detail"] == "background_too_large"
|
||||
|
||||
|
||||
async def test_upload_background_over_limit_returns_409_and_keeps_ten(
|
||||
media_root: Path, client: httpx.AsyncClient, db_session: AsyncSession
|
||||
) -> None:
|
||||
"""Лимит держится на СЕРВЕРЕ: интерфейс можно обойти прямым запросом."""
|
||||
user = await _make_user(db_session)
|
||||
await db_session.commit()
|
||||
for _ in range(MAX_BACKGROUNDS_PER_USER):
|
||||
assert (await _upload_background(client, user)).status_code == 201
|
||||
|
||||
response = await _upload_background(client, user)
|
||||
assert response.status_code == 409
|
||||
assert response.json()["detail"] == "background_limit_reached"
|
||||
|
||||
listing = await client.get("/api/v1/users/me/backgrounds", headers=_auth_headers(user))
|
||||
assert len(listing.json()["items"]) == MAX_BACKGROUNDS_PER_USER
|
||||
|
||||
|
||||
async def test_delete_background_removes_file_and_row(
|
||||
media_root: Path, client: httpx.AsyncClient, db_session: AsyncSession
|
||||
) -> None:
|
||||
user = await _make_user(db_session)
|
||||
await db_session.commit()
|
||||
created = (await _upload_background(client, user)).json()["items"][0]
|
||||
file_path = media_root / created["url"].removeprefix("/media/")
|
||||
assert file_path.exists()
|
||||
|
||||
response = await client.delete(
|
||||
f"/api/v1/users/me/backgrounds/{created['id']}", headers=_auth_headers(user)
|
||||
)
|
||||
assert response.status_code == 204
|
||||
assert not file_path.exists()
|
||||
|
||||
listing = await client.get("/api/v1/users/me/backgrounds", headers=_auth_headers(user))
|
||||
assert listing.json()["items"] == []
|
||||
|
||||
|
||||
async def test_delete_other_users_background_returns_404(
|
||||
media_root: Path, client: httpx.AsyncClient, db_session: AsyncSession
|
||||
) -> None:
|
||||
"""Знание id чужой картинки не даёт её удалить — владелец в условии запроса."""
|
||||
owner = await _make_user(db_session)
|
||||
stranger = await _make_user(db_session)
|
||||
await db_session.commit()
|
||||
created = (await _upload_background(client, owner)).json()["items"][0]
|
||||
|
||||
response = await client.delete(
|
||||
f"/api/v1/users/me/backgrounds/{created['id']}", headers=_auth_headers(stranger)
|
||||
)
|
||||
assert response.status_code == 404
|
||||
assert (media_root / created["url"].removeprefix("/media/")).exists()
|
||||
|
||||
|
||||
async def test_backgrounds_list_does_not_leak_other_users_images(
|
||||
media_root: Path, client: httpx.AsyncClient, db_session: AsyncSession
|
||||
) -> None:
|
||||
owner = await _make_user(db_session)
|
||||
stranger = await _make_user(db_session)
|
||||
await db_session.commit()
|
||||
await _upload_background(client, owner)
|
||||
|
||||
response = await client.get("/api/v1/users/me/backgrounds", headers=_auth_headers(stranger))
|
||||
assert response.json()["items"] == []
|
||||
|
||||
|
||||
async def test_backgrounds_require_authentication(
|
||||
media_root: Path, client: httpx.AsyncClient
|
||||
) -> None:
|
||||
assert (await client.get("/api/v1/users/me/backgrounds")).status_code == 401
|
||||
assert (
|
||||
await client.delete(f"/api/v1/users/me/backgrounds/{uuid.uuid4()}")
|
||||
).status_code == 401
|
||||
|
||||
@@ -33,3 +33,6 @@ summarizer:
|
||||
|
||||
chat:
|
||||
enabled: true
|
||||
|
||||
hand_queue:
|
||||
enabled: true
|
||||
|
||||
@@ -12,8 +12,8 @@
|
||||
# затрутся при следующем рендере.
|
||||
|
||||
listening-port=3478
|
||||
# Установить 5349 в проде для TURN over TLS, если будут смонтированы
|
||||
# реальные TLS-сертификаты (см. закомментированные cert/pkey ниже).
|
||||
# TLS-порт объявлен всегда; реально слушать TLS coturn начинает только когда
|
||||
# заданы cert/pkey ниже (блок TLS-CERT) — без них строка ничего не включает.
|
||||
tls-listening-port=5349
|
||||
|
||||
# Диапазон relay-портов для TURN-аллокаций.
|
||||
@@ -31,14 +31,30 @@ fingerprint
|
||||
# Без CLI/telnet admin-интерфейса в этой поставке.
|
||||
no-cli
|
||||
|
||||
# Раскомментировать и смонтировать реальные сертификаты, чтобы включить
|
||||
# TURN over TLS на 443:
|
||||
# cert=/etc/coturn/certs/cert.pem
|
||||
# pkey=/etc/coturn/certs/key.pem
|
||||
# Блок ниже рендерится ТОЛЬКО когда в .env задан TURN_TLS_HOST (см.
|
||||
# render-templates.sh) — тогда coturn-certs-init (docker-compose.yml) уже
|
||||
# скопировал fullchain/privkey из /etc/letsencrypt в volume coturn-certs.
|
||||
# Без 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
|
||||
simple-log
|
||||
|
||||
# Умеренная verbose-логика (`-v`/`verbose` в терминах coturn, НЕ
|
||||
# `Verbose`/`-V` — тот режим сам coturn документирует как "very annoying",
|
||||
# построчный дамп пакетов). Без этого флага дефолтный уровень логов не
|
||||
# печатает ни строки на ALLOCATE/CreatePermission/Refresh, даже когда TURN
|
||||
# реально обслуживает relay — проверено на релизе 0.0.22 (`docker logs
|
||||
# vidconf-coturn-1 | grep -ci allocate` = 0 при живом рабочем звонке,
|
||||
# подтверждение пришлось брать из логов LiveKit). С этим флагом coturn
|
||||
# печатает по сессии: create/delete allocation, create permission, refresh —
|
||||
# достаточно, чтобы дальше проверять относительно скромный вывод.
|
||||
verbose
|
||||
|
||||
# Внешний IP сервера — обязателен для клиентов вне docker-сети
|
||||
# (network_mode: host здесь не даёт coturn определить публичный IP
|
||||
# автоматически). Для локальной разработки (без внешних участников)
|
||||
|
||||
@@ -1,10 +1,17 @@
|
||||
name: vidconf
|
||||
|
||||
# Глубина логов рассчитана на РАЗБОР ИНЦИДЕНТОВ, а не только на просмотр
|
||||
# последних сообщений. При 10 МБ × 3 (прежнее значение) логи LiveKit на
|
||||
# конференции в полсотни человек перезаписывались за часы — а именно по ним
|
||||
# восстанавливаются вещи, которых нет в метриках: сколько камер было включено
|
||||
# одновременно, кого и почему отключило, какие события congestion шли.
|
||||
# 50 МБ × 5 = 250 МБ на контейнер; на сервере с 15 ГБ свободного места это
|
||||
# незаметно, зато ретроспектива живёт неделями.
|
||||
x-logging: &default-logging
|
||||
driver: json-file
|
||||
options:
|
||||
max-size: "10m"
|
||||
max-file: "3"
|
||||
max-size: "50m"
|
||||
max-file: "5"
|
||||
|
||||
services:
|
||||
postgres:
|
||||
@@ -62,7 +69,12 @@ services:
|
||||
env_file:
|
||||
- ../.env
|
||||
environment:
|
||||
DATABASE_URL: ${DATABASE_URL:-postgresql+asyncpg://vidconf:vidconf@postgres:5432/vidconf}
|
||||
# Собран из POSTGRES_USER/POSTGRES_PASSWORD/POSTGRES_DB с жёстким хостом
|
||||
# `postgres` (сессия 37) — так же, как REDIS_URL ниже, а не читается из
|
||||
# .env.DATABASE_URL напрямую: тот в корневом .env указывает на `localhost`
|
||||
# (для запуска backend на хосте вне контейнера, см. CONTEXT §2.1.0), а
|
||||
# `localhost` внутри контейнера — это сам контейнер, не соседний postgres.
|
||||
DATABASE_URL: postgresql+asyncpg://${POSTGRES_USER:-vidconf}:${POSTGRES_PASSWORD:-vidconf}@postgres:5432/${POSTGRES_DB:-vidconf}
|
||||
REDIS_URL: redis://:${REDIS_PASSWORD:?REDIS_PASSWORD не задан в .env}@redis:6379/0
|
||||
PLUGINS_CONFIG_PATH: ${PLUGINS_CONFIG_PATH:-config/plugins.yaml}
|
||||
LIVEKIT_API_KEY: ${LIVEKIT_API_KEY:?LIVEKIT_API_KEY не задан в .env}
|
||||
@@ -82,7 +94,12 @@ services:
|
||||
MEDIA_ROOT: ${MEDIA_ROOT:-/app/media}
|
||||
# Версия инстанса (релиз v0.0.1) — install.sh копирует значение
|
||||
# из файла VERSION (корень репозитория) в .env; отдаётся в GET /api/health.
|
||||
VIDCONF_VERSION: ${VIDCONF_VERSION:-0.0.8}
|
||||
VIDCONF_VERSION: ${VIDCONF_VERSION:-0.0.37}
|
||||
# Число процессов uvicorn (см. backend/Dockerfile). Дефолт 2 рассчитан
|
||||
# на 4-ядерный сервер, где ядра делятся с LiveKit. Поднимая значение,
|
||||
# проверьте бюджет соединений с БД: каждый воркер держит свой пул
|
||||
# (DB_POOL_SIZE + DB_MAX_OVERFLOW), а у Postgres есть max_connections.
|
||||
UVICORN_WORKERS: ${UVICORN_WORKERS:-2}
|
||||
# config/ лежит в корне репозитория и не попадает в образ (контекст сборки —
|
||||
# только backend/), поэтому plugins.yaml монтируется отдельно.
|
||||
volumes:
|
||||
@@ -120,12 +137,18 @@ services:
|
||||
context: ../backend
|
||||
dockerfile: Dockerfile
|
||||
restart: unless-stopped
|
||||
command: ["uv", "run", "celery", "-A", "workers.celery_app", "worker", "-B",
|
||||
# `--no-sync` во ВСЕХ вызовах `uv run` в этом файле: окружение собрано на
|
||||
# этапе build образа (`uv sync --frozen --no-dev`, backend/Dockerfile), а
|
||||
# без флага `uv run` синхронизирует venv заново при каждом запуске — и
|
||||
# тянет dev-группу (ruff, mypy, pytest), которой в проде делать нечего.
|
||||
# Для healthcheck'ов это особенно дорого: они дёргаются каждые 15 секунд
|
||||
# всю жизнь контейнера.
|
||||
command: ["uv", "run", "--no-sync", "celery", "-A", "workers.celery_app", "worker", "-B",
|
||||
"-Q", "celery,summarize,notify", "--loglevel=info"]
|
||||
env_file:
|
||||
- ../.env
|
||||
environment:
|
||||
DATABASE_URL: ${DATABASE_URL:-postgresql+asyncpg://vidconf:vidconf@postgres:5432/vidconf}
|
||||
DATABASE_URL: postgresql+asyncpg://${POSTGRES_USER:-vidconf}:${POSTGRES_PASSWORD:-vidconf}@postgres:5432/${POSTGRES_DB:-vidconf}
|
||||
REDIS_URL: redis://:${REDIS_PASSWORD:?REDIS_PASSWORD не задан в .env}@redis:6379/0
|
||||
PLUGINS_CONFIG_PATH: ${PLUGINS_CONFIG_PATH:-config/plugins.yaml}
|
||||
PYTHONPATH: /app
|
||||
@@ -147,7 +170,7 @@ services:
|
||||
# нет HTTP-сервера на 8000. Проверяем воркер через `celery ... inspect
|
||||
# ping`, как рекомендует документация Celery.
|
||||
healthcheck:
|
||||
test: ["CMD", "uv", "run", "celery", "-A", "workers.celery_app", "inspect", "ping", "--timeout", "5"]
|
||||
test: ["CMD", "uv", "run", "--no-sync", "celery", "-A", "workers.celery_app", "inspect", "ping", "--timeout", "5"]
|
||||
interval: 15s
|
||||
timeout: 10s
|
||||
retries: 5
|
||||
@@ -181,7 +204,7 @@ services:
|
||||
build:
|
||||
context: ../backend
|
||||
dockerfile: Dockerfile
|
||||
entrypoint: ["uv", "run", "python", "/download-model.py"]
|
||||
entrypoint: ["uv", "run", "--no-sync", "python", "/download-model.py"]
|
||||
environment:
|
||||
WHISPER_MODEL: ${WHISPER_MODEL:-small}
|
||||
WHISPER_MODELS_ROOT: /models/whisper
|
||||
@@ -216,13 +239,13 @@ services:
|
||||
# `worker`, и `celery inspect ping` без `--destination` опросит ВЕСЬ
|
||||
# кластер — упавший worker-transcriber остался бы "healthy", потому что
|
||||
# ответил бы базовый worker.
|
||||
command: ["uv", "run", "celery", "-A", "workers.celery_app", "worker",
|
||||
command: ["uv", "run", "--no-sync", "celery", "-A", "workers.celery_app", "worker",
|
||||
"-Q", "transcription", "--pool=solo", "--concurrency=1",
|
||||
"--hostname=worker-transcriber@localhost", "--loglevel=info"]
|
||||
env_file:
|
||||
- ../.env
|
||||
environment:
|
||||
DATABASE_URL: ${DATABASE_URL:-postgresql+asyncpg://vidconf:vidconf@postgres:5432/vidconf}
|
||||
DATABASE_URL: postgresql+asyncpg://${POSTGRES_USER:-vidconf}:${POSTGRES_PASSWORD:-vidconf}@postgres:5432/${POSTGRES_DB:-vidconf}
|
||||
REDIS_URL: redis://:${REDIS_PASSWORD:?REDIS_PASSWORD не задан в .env}@redis:6379/0
|
||||
PLUGINS_CONFIG_PATH: ${PLUGINS_CONFIG_PATH:-config/plugins.yaml}
|
||||
RECORDINGS_DIR: ${RECORDINGS_DIR:-/recordings}
|
||||
@@ -247,7 +270,7 @@ services:
|
||||
# HTTP-эндпоинта нет — пинг celery, но именно этого узла (см. --hostname
|
||||
# в command выше), а не первого ответившего в общем кластере.
|
||||
healthcheck:
|
||||
test: ["CMD", "uv", "run", "celery", "-A", "workers.celery_app", "inspect", "ping", "--timeout", "5", "--destination", "worker-transcriber@localhost"]
|
||||
test: ["CMD", "uv", "run", "--no-sync", "celery", "-A", "workers.celery_app", "inspect", "ping", "--timeout", "5", "--destination", "worker-transcriber@localhost"]
|
||||
interval: 15s
|
||||
timeout: 10s
|
||||
retries: 5
|
||||
@@ -272,13 +295,13 @@ services:
|
||||
args:
|
||||
WITH_GPU_EXTRA: "true"
|
||||
restart: unless-stopped
|
||||
command: ["uv", "run", "celery", "-A", "workers.celery_app", "worker",
|
||||
command: ["uv", "run", "--no-sync", "celery", "-A", "workers.celery_app", "worker",
|
||||
"-Q", "transcription", "--pool=solo", "--concurrency=1",
|
||||
"--hostname=worker-transcriber-gpu@localhost", "--loglevel=info"]
|
||||
env_file:
|
||||
- ../.env
|
||||
environment:
|
||||
DATABASE_URL: ${DATABASE_URL:-postgresql+asyncpg://vidconf:vidconf@postgres:5432/vidconf}
|
||||
DATABASE_URL: postgresql+asyncpg://${POSTGRES_USER:-vidconf}:${POSTGRES_PASSWORD:-vidconf}@postgres:5432/${POSTGRES_DB:-vidconf}
|
||||
REDIS_URL: redis://:${REDIS_PASSWORD:?REDIS_PASSWORD не задан в .env}@redis:6379/0
|
||||
PLUGINS_CONFIG_PATH: ${PLUGINS_CONFIG_PATH:-config/plugins.yaml}
|
||||
RECORDINGS_DIR: ${RECORDINGS_DIR:-/recordings}
|
||||
@@ -305,7 +328,7 @@ services:
|
||||
whisper-model-init:
|
||||
condition: service_completed_successfully
|
||||
healthcheck:
|
||||
test: ["CMD", "uv", "run", "celery", "-A", "workers.celery_app", "inspect", "ping", "--timeout", "5", "--destination", "worker-transcriber-gpu@localhost"]
|
||||
test: ["CMD", "uv", "run", "--no-sync", "celery", "-A", "workers.celery_app", "inspect", "ping", "--timeout", "5", "--destination", "worker-transcriber-gpu@localhost"]
|
||||
interval: 15s
|
||||
timeout: 10s
|
||||
retries: 5
|
||||
@@ -389,16 +412,19 @@ services:
|
||||
# 7880 (signaling) — ТОЛЬКО loopback: nginx проксирует /livekit/ по имени
|
||||
# `livekit:7880` внутри docker-сети (см. nginx.conf.template), браузеры
|
||||
# снаружи ходят через nginx/443 (wss://), прямой доступ к 7880 им не
|
||||
# нужен. 7881/tcp и UDP-диапазон ниже — реальные медиа-порты, остаются
|
||||
# нужен. 7881/tcp и UDP-порт ниже — реальные медиа-порты, остаются
|
||||
# публичными.
|
||||
ports:
|
||||
- "127.0.0.1:7880:7880" # HTTP/WebSocket signaling
|
||||
- "7881:7881" # RTC TCP fallback
|
||||
# Узкий диапазон для dev на macOS: широкий (50000-60000) почти всегда
|
||||
# конфликтует с занятыми UDP-портами хоста и тормозит Docker Desktop.
|
||||
# 54000+ выбран после конфликтов: нижние диапазоны (50000+, 52000+)
|
||||
# занимают Steam/системные процессы macOS и эфемерные QUIC-соединения.
|
||||
- "54000-54100:54000-54100/udp" # WebRTC media (ICE)
|
||||
# Один порт вместо диапазона (был 54000-54100/udp) — LiveKit
|
||||
# мультиплексирует все ICE-сессии через него (rtc.udp_port в
|
||||
# livekit.yaml.template), а не открывает по порту на участника.
|
||||
# На диапазон Docker поднимал по docker-proxy на КАЖДЫЙ порт —
|
||||
# 101 порт держали 101 лишний userland-процесс на медиапути.
|
||||
# 54000 выбран, как раньше: нижние диапазоны (50000+, 52000+) на
|
||||
# macOS заняты Steam/системными процессами и эфемерными QUIC.
|
||||
- "54000:54000/udp" # WebRTC media (ICE, мультиплекс)
|
||||
depends_on:
|
||||
redis:
|
||||
condition: service_healthy
|
||||
@@ -411,6 +437,49 @@ services:
|
||||
profiles: ["media"]
|
||||
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:
|
||||
image: coturn/coturn:latest
|
||||
restart: unless-stopped
|
||||
@@ -423,6 +492,10 @@ services:
|
||||
# (deploy/render-templates.sh, вызывается install.sh).
|
||||
volumes:
|
||||
- ./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
|
||||
# Образ coturn/coturn — минимальный (debian-slim), в нём нет pgrep/ps/nc/
|
||||
# curl/wget, поэтому проверка процесса по имени не работает
|
||||
@@ -653,10 +726,26 @@ services:
|
||||
# Тег закреплён по версии (не `:latest`) — та же причина, что и у llm-образов.
|
||||
image: prom/prometheus:v3.13.1
|
||||
restart: unless-stopped
|
||||
# Первые два флага — дефолт образа; повторяем их явно, потому что
|
||||
# `command` перекрывает CMD целиком. Третий — срок хранения: дефолтных
|
||||
# 15 суток мало, когда нагрузку набирают неделями (наблюдение за
|
||||
# реальными конференциями вместо разового теста), и разбирать её потом
|
||||
# приходится задним числом. 30 суток при нынешних 100 МБ TSDB стоят
|
||||
# копеек — база растёт медленнее, чем кажется.
|
||||
command:
|
||||
- '--config.file=/etc/prometheus/prometheus.yml'
|
||||
- '--storage.tsdb.path=/prometheus'
|
||||
- '--storage.tsdb.retention.time=30d'
|
||||
volumes:
|
||||
- ./monitoring/prometheus.yml:/etc/prometheus/prometheus.yml:ro
|
||||
- ./monitoring/alerts.yml:/etc/prometheus/alerts.yml:ro
|
||||
- prometheus_data:/prometheus
|
||||
# node-exporter живёт в host-сети (см. комментарий у него) и по имени
|
||||
# сервиса в docker-сети больше не резолвится. `host-gateway` — штатный
|
||||
# способ дать контейнеру адрес хоста, не завязываясь на конкретный IP
|
||||
# docker-моста.
|
||||
extra_hosts:
|
||||
- "host.docker.internal:host-gateway"
|
||||
# Loopback-only: админ-доступ по ssh-туннелю, наружу не публикуется.
|
||||
ports:
|
||||
- "127.0.0.1:9090:9090"
|
||||
@@ -704,6 +793,104 @@ services:
|
||||
profiles: ["monitoring"]
|
||||
logging: *default-logging
|
||||
|
||||
node-exporter:
|
||||
# Метрики железа хоста (CPU, память, диск, сеть, load average) — то,
|
||||
# чего нет ни в одном из приложенческих экспортеров выше.
|
||||
#
|
||||
# `network_mode: host` ОБЯЗАТЕЛЕН, и вот почему (проверено 2026-07-28,
|
||||
# до этого экспортер работал в bridge-сети и отдавал неверные данные).
|
||||
# Bind-mount'а `/proc` достаточно для CPU, памяти и диска, но НЕ для сети:
|
||||
# `/proc/net` — это симлинк на `self/net`, который резолвится в сетевом
|
||||
# namespace ЧИТАЮЩЕГО процесса. В bridge-сети экспортер видел собственные
|
||||
# `lo` и `eth0` (56 МБ трафика) вместо хостового `enp3s0` (39.8 ГБ), то
|
||||
# есть `node_network_*` показывал трафик контейнера, а не сервера. При
|
||||
# разборе нагрузочного теста 28.07 сетевых метрик хоста не оказалось
|
||||
# вовсе — см. .forcc/LOAD-FINDINGS.md.
|
||||
#
|
||||
# Порт 9100 при этом слушается на хосте. Наружу он не торчит: ufw
|
||||
# пропускает только 22/80/443/3478/7881/51820 и UDP-диапазон LiveKit
|
||||
# (проверено `ufw status`). Prometheus обращается к нему через
|
||||
# `host.docker.internal` (см. `extra_hosts` у сервиса prometheus и
|
||||
# таргет `node` в deploy/monitoring/prometheus.yml).
|
||||
image: prom/node-exporter:v1.8.2
|
||||
restart: unless-stopped
|
||||
pid: host
|
||||
network_mode: host
|
||||
volumes:
|
||||
- /proc:/host/proc:ro
|
||||
- /sys:/host/sys:ro
|
||||
- /:/rootfs:ro
|
||||
command:
|
||||
- '--path.procfs=/host/proc'
|
||||
- '--path.sysfs=/host/sys'
|
||||
- '--path.rootfs=/rootfs'
|
||||
- '--collector.filesystem.mount-points-exclude=^/(sys|proc|dev|host|etc)($$|/)'
|
||||
# Секции `ports` нет и с host-сетью быть не может: контейнер слушает
|
||||
# прямо на интерфейсах хоста. От внешнего мира порт закрывает ufw.
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "wget -q -O- http://127.0.0.1:9100/metrics >/dev/null || exit 1"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 10
|
||||
start_period: 10s
|
||||
profiles: ["monitoring"]
|
||||
logging: *default-logging
|
||||
|
||||
# Метрики по контейнерам (CPU/память/сеть каждого отдельно) — замена
|
||||
# cAdvisor, который на сервере `1gb` несовместим с Docker Engine
|
||||
# (containerd-снапшоттер вместо classic overlay2 — подробности и
|
||||
# перепробованные варианты см. `.forcc/JOURNAL.md`, раздел релиза 0.0.9).
|
||||
# Вместо интроспекции graph-driver'а — Docker Engine API напрямую
|
||||
# (`GET /containers/json` + `/stats`), он не зависит от storage-driver.
|
||||
#
|
||||
# Доступ к докер-сокету равносилен root на хосте (`:ro` при монтировании
|
||||
# ограничивает права на файл-ноду, а не на протокол — через сокет можно
|
||||
# поднять привилегированный контейнер и получить хост). Поэтому сокет
|
||||
# монтируется СЮДА, в доверенный прокси-образ, а НЕ в наш собственный
|
||||
# `container-exporter` ниже — прокси разрешает ему только
|
||||
# `GET /containers/json` и `GET /containers/*/stats` (`CONTAINERS=1`),
|
||||
# любые изменяющие запросы заблокированы (`POST=0`). Полная компрометация
|
||||
# `container-exporter` не даёт управлять Docker. Обоснование и разбор —
|
||||
# `docs/deploy/monitoring.md`.
|
||||
docker-socket-proxy:
|
||||
image: tecnativa/docker-socket-proxy:v0.5.0
|
||||
restart: unless-stopped
|
||||
volumes:
|
||||
- /var/run/docker.sock:/var/run/docker.sock:ro
|
||||
environment:
|
||||
CONTAINERS: 1
|
||||
# POST=0 — дефолт образа; выписан явно, потому что от него зависит
|
||||
# безопасность всей схемы (полагаться на невыписанный дефолт для
|
||||
# критичного флага не стоит).
|
||||
POST: 0
|
||||
EVENTS: 0
|
||||
# Не публикуем порт наружу вообще — container-exporter достаёт API
|
||||
# по имени сервиса во внутренней сети compose, тот же принцип, что и
|
||||
# у node-exporter/postgres-exporter выше.
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "-q", "-O-", "http://127.0.0.1:2375/_ping"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 10
|
||||
start_period: 10s
|
||||
profiles: ["monitoring"]
|
||||
logging: *default-logging
|
||||
|
||||
container-exporter:
|
||||
build:
|
||||
context: ./monitoring/container-exporter
|
||||
dockerfile: Dockerfile
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
DOCKER_API_BASE: "http://docker-socket-proxy:2375"
|
||||
depends_on:
|
||||
docker-socket-proxy:
|
||||
condition: service_healthy
|
||||
# Без ports — тот же принцип, что и у прокси выше; healthcheck
|
||||
# прописан в самом Dockerfile экспортера (как у backend/worker).
|
||||
profiles: ["monitoring"]
|
||||
logging: *default-logging
|
||||
|
||||
grafana:
|
||||
image: grafana/grafana:13.1.0
|
||||
restart: unless-stopped
|
||||
@@ -752,6 +939,11 @@ volumes:
|
||||
# Загруженные пользователями файлы (аватары) — общий том между
|
||||
# backend (запись при загрузке) и nginx (раздача статики, `location /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_data:
|
||||
# Дашборды/настройки Grafana (профиль `monitoring`) — переживают пересоздание.
|
||||
|
||||
@@ -15,16 +15,20 @@ port: 7880
|
||||
|
||||
rtc:
|
||||
tcp_port: 7881
|
||||
# Диапазон сужен для dev (см. комментарий в docker-compose.yml); в проде
|
||||
# расширить и синхронизировать с пробросом портов.
|
||||
port_range_start: 54000
|
||||
port_range_end: 54100
|
||||
# Один UDP-порт с мультиплексированием ICE вместо диапазона портов.
|
||||
# Раньше здесь был port_range_start/port_range_end (54000-54100) — под
|
||||
# каждый порт диапазона Docker поднимал отдельный процесс docker-proxy
|
||||
# (userland-прокси на весь медиатрафик), на 101 порт — 101 процесс.
|
||||
# udp_port переключает LiveKit на единственный сокет с демультиплексацией
|
||||
# по ICE ufrag; port_range_start/end при заданном udp_port игнорируются
|
||||
# (проверено по исходникам сервера) — оставлять их рядом бессмысленно.
|
||||
udp_port: 54000
|
||||
# use_external_ip: false + node_ip=127.0.0.1 — режим для локальной
|
||||
# разработки (Docker Desktop): use_external_ip=true определяет публичный
|
||||
# IP через STUN, что в контейнере на macOS даёт недостижимый изнутри хоста
|
||||
# внутренний IP (172.18.x.x) — DTLS-хендшейк по data-каналам не проходит
|
||||
# ("dtls timeout" в логах). node_ip=127.0.0.1 работает, потому что порты
|
||||
# 7881/tcp и 54000-54100/udp проброшены на loopback хоста, а браузер-клиент
|
||||
# 7881/tcp и 54000/udp проброшены на loopback хоста, а браузер-клиент
|
||||
# запускается на том же хосте.
|
||||
# В проде (LIVEKIT_USE_EXTERNAL_IP=true, LIVEKIT_NODE_IP=<внешний IP/домен
|
||||
# сервера> в .env) клиенты снаружи хоста подключаются по этому адресу —
|
||||
@@ -32,6 +36,61 @@ rtc:
|
||||
use_external_ip: ${LIVEKIT_USE_EXTERNAL_IP}
|
||||
node_ip: ${LIVEKIT_NODE_IP}
|
||||
|
||||
# Внешний TURN (сервис coturn, профиль `media`) — АНОНС КЛИЕНТАМ.
|
||||
# Сам SFU через эти серверы не ходит: LiveKit лишь отдаёт их браузеру в
|
||||
# списке ICE-серверов при подключении (см. iceServersForParticipant в
|
||||
# pkg/service/roommanager.go), а клиент уже решает, нужен ли ему relay.
|
||||
#
|
||||
# Зачем. До 28.07.2026 coturn работал, но КЛИЕНТЫ О НЁМ НЕ ЗНАЛИ: секция
|
||||
# `turn` ниже выключена (встроенный TURN не поднимаем), внешний в конфиге
|
||||
# объявлен не был, а фронтенд `iceServers` не задаёт. За всё время работы
|
||||
# в логах coturn — ноль ALLOCATE. Итог: у клиентов из сетей с жёстким NAT
|
||||
# не было relay-фолбэка вообще, только прямой UDP и TCP 7881. Именно так
|
||||
# объясняются `PEER_CONNECTION_DISCONNECTED` на нагрузочном тесте — все
|
||||
# у внешних участников, ни одного у офисных (.forcc/LOAD-FINDINGS.md,
|
||||
# причина C).
|
||||
#
|
||||
# `secret` обязан совпадать с `static-auth-secret` в turnserver.conf —
|
||||
# оба рендерятся из одного TURN_STATIC_AUTH_SECRET (deploy/render-templates.sh).
|
||||
# Логин/пароль LiveKit генерирует сам по механизму TURN REST API.
|
||||
#
|
||||
# UDP и TCP на 3478 — оба порта уже открыты в ufw.
|
||||
#
|
||||
# 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:
|
||||
- host: ${TURN_EXTERNAL_IP}
|
||||
port: 3478
|
||||
protocol: udp
|
||||
secret: ${TURN_STATIC_AUTH_SECRET}
|
||||
ttl: 14400
|
||||
- host: ${TURN_EXTERNAL_IP}
|
||||
port: 3478
|
||||
protocol: tcp
|
||||
secret: ${TURN_STATIC_AUTH_SECRET}
|
||||
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/) — он использует
|
||||
# его как pub/sub и key-value хранилище состояния запущенных записей;
|
||||
# без него egress не может получать room/track-события от LiveKit
|
||||
|
||||
@@ -60,3 +60,142 @@ groups:
|
||||
# суммаризация встанет (задачи будут копиться в очереди summarize,
|
||||
# см. также алерт QueueGrowing). Проверить
|
||||
# `docker compose ps llm` / `llm-gpu` и `docker compose logs llm`.
|
||||
|
||||
# Состояние БД и пулов соединений (сессия 33, разбор инцидента 07.08 —
|
||||
# `.forcc/session-results/32-loadtest-07-08-debug.md`). `/api/health`
|
||||
# отдавал 200 с `db: false` во время отказа — Prometheus его не скрейпит и
|
||||
# не умеет разобрать JSON-тело, поэтому оба сигнала строятся на метриках
|
||||
# `backend/api/metrics.py`, которые читаются вне основного пула.
|
||||
- name: vidconf-db
|
||||
rules:
|
||||
# `vidconf_db_up` — отдельное соединение вне основного пула
|
||||
# (`core/db.py::check_db_up`), поэтому 0 означает именно «БД не
|
||||
# отвечает», а не «пул занят» (для второго см. DbConnectionPoolNearExhaustion
|
||||
# ниже — раздельные метрики нарочно, см. «Главное требование» промпта
|
||||
# сессии 33). `for: 30s` — два цикла скрейпа (`scrape_interval: 15s`),
|
||||
# чтобы не среагировать на одиночный неудачный `connect()` (сеть/GC-пауза),
|
||||
# но не тянуть с сигналом дольше: это самый критичный алерт в проекте.
|
||||
- alert: DatabaseUnavailable
|
||||
expr: vidconf_db_up == 0
|
||||
for: 30s
|
||||
labels:
|
||||
severity: critical
|
||||
annotations:
|
||||
summary: "БД недоступна"
|
||||
description: >-
|
||||
vidconf_db_up == 0 дольше 30 секунд — backend не может открыть
|
||||
отдельное (вне основного пула) соединение с Postgres. НЕ значит
|
||||
автоматически «нужен рестарт контейнера» — по решению оператора
|
||||
от 09.08 healthcheck backend'а остаётся мягким (не хардфейлится
|
||||
на недоступной БД — рестарт-петля в разгар инцидента оборвала бы
|
||||
WS у всех, кто в конференциях), это сигнал оператору, не
|
||||
автолечение. Смотреть
|
||||
`docker compose ps postgres`, `docker compose logs postgres`,
|
||||
`pg_isready`.
|
||||
|
||||
# Раннее предупреждение — тот самый сигнал, которого не хватило
|
||||
# 07.08: пул заполнялся постепенно (idle in transaction 3→8→16→26→35→
|
||||
# 39→40 участников комнаты, см. session 32), а `up{job="backend"}`
|
||||
# ничего не показывал, потому что backend отвечал исправно вплоть до
|
||||
# самого потолка. Порог 80% — предложение из промпта сессии 33,
|
||||
# `for: 1m` — фильтр от секундных всплесков (короткий пик параллельных
|
||||
# запросов рассасывается за секунды, устойчивый рост участников
|
||||
# комнаты — нет). На нагрузочном тесте 07.08 от пересечения 80% до
|
||||
# исчерпания пула прошло по грубой оценке меньше двух минут — порог
|
||||
# НЕ даёт большого запаса и это осознанный компромисс, а не идеал:
|
||||
# цель — успеть до 500-х у пользователей, а не за много минут
|
||||
# заранее. Перепроверить оба числа на следующем нагрузочном тесте
|
||||
# (см. .forcc/session-results/33-db-health-alert.md) и подстроить,
|
||||
# если реальный запас окажется у́же ожидаемого.
|
||||
- alert: DbConnectionPoolNearExhaustion
|
||||
expr: >-
|
||||
(vidconf_db_pool_checked_out
|
||||
/ (vidconf_db_pool_size + vidconf_db_pool_max_overflow)) * 100 > 80
|
||||
for: 1m
|
||||
labels:
|
||||
severity: warning
|
||||
annotations:
|
||||
summary: "Основной пул соединений с БД близок к исчерпанию"
|
||||
description: >-
|
||||
Занято {{ $value | printf "%.0f" }}% основного пула БД дольше
|
||||
минуты (порог 80%). Частая причина в этом проекте — долгоживущие
|
||||
WS-подключения комнат (`api/chat.py`) держат соединение на
|
||||
каждого сидящего в конференции; смотреть
|
||||
`vidconf_db_pool_checked_out` и число открытых WS чата в логах,
|
||||
не только текущую HTTP-нагрузку.
|
||||
|
||||
# Тот же класс отказа, что у пула БД (см. выше), только пул Redis —
|
||||
# закрыт в 0.0.31 (`451c18e`) заданием явного max_connections, но без
|
||||
# метрики занятости прошлый потолок нашёлся только руками на
|
||||
# нагрузочном тесте. Бонус к задаче сессии 33 («потолки в этом
|
||||
# проекте стоят лесенкой»), не отдельно запрошен промптом — пороги
|
||||
# взяты по аналогии с пулом БД, не проверялись отдельным нагрузочным
|
||||
# тестом именно на Redis.
|
||||
- alert: RedisConnectionPoolNearExhaustion
|
||||
expr: (vidconf_redis_pool_in_use / vidconf_redis_pool_max_connections) * 100 > 80
|
||||
for: 1m
|
||||
labels:
|
||||
severity: warning
|
||||
annotations:
|
||||
summary: "Пул соединений Redis близок к исчерпанию"
|
||||
description: >-
|
||||
Занято {{ $value | printf "%.0f" }}% пула Redis дольше минуты
|
||||
(порог 80%). Каждое WS-подключение комнаты держит собственную
|
||||
pub/sub-подписку из этого же пула — смотреть число открытых WS
|
||||
чата, не только команды Celery/кэша.
|
||||
|
||||
# Железо хоста (job `node` — node-exporter). Пороги подобраны под
|
||||
# конкретный сервер 1gb: 8 ГБ RAM, 4 CPU, 50 ГБ диска — если сервер
|
||||
# сменится, пересчитать.
|
||||
- name: vidconf-host
|
||||
rules:
|
||||
# MemAvailable — уже честная оценка Linux с учётом того, что легко
|
||||
# освобождаемый page cache/buffers не в счёт (в отличие от naive
|
||||
# used = total - free). 10% от 8 ГБ ≈ 800 МБ — `for: 10m`, чтобы не
|
||||
# дёргать на кратковременный всплеск (например, разовый всплеск
|
||||
# transcribe/summarize), но успеть среагировать до OOM killer.
|
||||
- alert: HostMemoryLow
|
||||
expr: (node_memory_MemAvailable_bytes / node_memory_MemTotal_bytes) * 100 < 10
|
||||
for: 10m
|
||||
labels:
|
||||
severity: warning
|
||||
annotations:
|
||||
summary: "Мало свободной памяти на хосте"
|
||||
description: >-
|
||||
Свободно {{ $value | printf "%.1f" }}% RAM дольше 10 минут
|
||||
(порог 10% ≈ 800 МБ из 8 ГБ). Смотреть, какой контейнер ест
|
||||
память — дашборд «Хост и контейнеры», топ по памяти
|
||||
(container-exporter, см. deploy/monitoring/container-exporter/).
|
||||
|
||||
# 50 ГБ диска — 10% ≈ 5 ГБ. `for: 15m` (не мгновенно): диск не растёт
|
||||
# так же резко, как память, ложные срабатывания на всплеск не грозят,
|
||||
# но и разовая проверка на границе некритична — 15 минут отсекает шум.
|
||||
- alert: HostDiskLow
|
||||
expr: (node_filesystem_avail_bytes{mountpoint="/",fstype!="tmpfs"} / node_filesystem_size_bytes{mountpoint="/",fstype!="tmpfs"}) * 100 < 10
|
||||
for: 15m
|
||||
labels:
|
||||
severity: warning
|
||||
annotations:
|
||||
summary: "Мало места на диске хоста"
|
||||
description: >-
|
||||
Свободно {{ $value | printf "%.1f" }}% диска дольше 15 минут
|
||||
(порог 10% ≈ 5 ГБ из 50 ГБ). Частые причины на этом проекте —
|
||||
записи транскрибации (`recordings`), логи docker, образы/слои
|
||||
после пересборки — проверить `docker system df`.
|
||||
|
||||
# 4 CPU. Порог 90% и `for: 15m` — сознательно строже по времени, чем
|
||||
# у памяти/диска: кратковременные пики от пайплайна пост-обработки
|
||||
# (транскрибация/суммаризация) — это ожидаемая, не аварийная нагрузка,
|
||||
# алерт должен ловить именно устойчивую перегрузку, а не обычный всплеск.
|
||||
- alert: HostCpuHigh
|
||||
expr: 100 - (avg(rate(node_cpu_seconds_total{mode="idle"}[5m])) * 100) > 90
|
||||
for: 15m
|
||||
labels:
|
||||
severity: warning
|
||||
annotations:
|
||||
summary: "Устойчиво высокая загрузка CPU хоста"
|
||||
description: >-
|
||||
Загрузка CPU {{ $value | printf "%.1f" }}% дольше 15 минут
|
||||
подряд (порог 90% из 4 ядер). Смотреть топ контейнеров по CPU
|
||||
(дашборд «Хост и контейнеры») и латентность API — возможно,
|
||||
не хватает уровня AI/ресурсов под нагрузку.
|
||||
|
||||
32
deploy/monitoring/container-exporter/Dockerfile
Normal file
32
deploy/monitoring/container-exporter/Dockerfile
Normal file
@@ -0,0 +1,32 @@
|
||||
FROM python:3.12-slim AS base
|
||||
|
||||
# uv binary (тот же приём, что и backend/Dockerfile).
|
||||
COPY --from=ghcr.io/astral-sh/uv:latest /uv /uvx /usr/local/bin/
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
ENV UV_COMPILE_BYTECODE=1 \
|
||||
UV_LINK_MODE=copy \
|
||||
PYTHONUNBUFFERED=1
|
||||
|
||||
COPY pyproject.toml uv.lock ./
|
||||
RUN uv sync --frozen --no-dev --no-install-project
|
||||
|
||||
COPY exporter.py ./
|
||||
|
||||
# Непривилегированный пользователь — сокет Docker сервис не видит вообще
|
||||
# (доступ только к socket-proxy по сети compose), но принцип наименьших
|
||||
# привилегий соблюдаем и здесь.
|
||||
RUN useradd --no-create-home --shell /usr/sbin/nologin exporter
|
||||
USER exporter
|
||||
|
||||
EXPOSE 9419
|
||||
|
||||
HEALTHCHECK --interval=10s --timeout=5s --retries=10 --start-period=15s \
|
||||
CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:9419/health')" || exit 1
|
||||
|
||||
# Не `uv run` — тому нужен писабельный кэш в $HOME, а у непривилегированного
|
||||
# пользователя без домашней директории (--no-create-home выше) его нет;
|
||||
# зависимости уже установлены в .venv на шаге сборки, `uv run` в рантайме
|
||||
# не добавляет ничего, кроме этой проблемы.
|
||||
CMD [".venv/bin/python", "exporter.py"]
|
||||
234
deploy/monitoring/container-exporter/exporter.py
Normal file
234
deploy/monitoring/container-exporter/exporter.py
Normal file
@@ -0,0 +1,234 @@
|
||||
"""Экспортер метрик Prometheus по контейнерам поверх Docker Engine API.
|
||||
|
||||
Отдельный минимальный сервис (не часть backend — backend не должен иметь
|
||||
доступ к докер-сокету, см. `docs/deploy/monitoring.md`). Опрашивает Docker
|
||||
Engine API ЧЕРЕЗ `docker-socket-proxy` (`deploy/docker-compose.yml`) —
|
||||
самому экспортеру `/var/run/docker.sock` не примонтирован, только доступ
|
||||
по внутренней сети compose к прокси, который разрешает исключительно
|
||||
`GET /containers/json` и `GET /containers/{id}/stats` (только чтение, без
|
||||
прав управлять Docker — см. обоснование в `docs/deploy/monitoring.md`).
|
||||
|
||||
Стек — `aiohttp` (не `httpx`/FastAPI, как у backend): это отдельный
|
||||
процесс вне зависимостей backend, а `aiohttp` даёт async HTTP-клиент и
|
||||
сервер в одной небольшой библиотеке, без лишних зависимостей ради пяти
|
||||
строк веб-сервера.
|
||||
|
||||
Метрики (КОНТРАКТ с `deploy/monitoring/prometheus.yml` и
|
||||
`deploy/monitoring/grafana/dashboards/host.json` — при переименовании
|
||||
поправить оба файла), лейбл везде — `name` (имя контейнера):
|
||||
|
||||
- `vidconf_container_cpu_percent` — доля CPU, использованная контейнером
|
||||
за последний интервал опроса, тот же расчёт, что у `docker stats`
|
||||
(0-100 на одно ядро, суммарно может быть больше 100 при использовании
|
||||
нескольких ядер).
|
||||
- `vidconf_container_memory_usage_bytes` / `_limit_bytes` — из
|
||||
`stats.memory_stats`, `usage` очищен от переиспользуемого page cache
|
||||
(`inactive_file`) тем же приёмом, что cAdvisor использовал для
|
||||
`container_memory_working_set_bytes` — именно это число cgroup
|
||||
учитывает при OOM. Без явного лимита памяти у сервиса в
|
||||
docker-compose `limit` равен общему объёму RAM хоста — это не баг.
|
||||
- `vidconf_container_network_rx_bytes` / `_tx_bytes` — суммарно по всем
|
||||
сетевым интерфейсам контейнера, счётчик со старта контейнера
|
||||
(обнуляется при пересоздании) — для скорости передачи взять `rate()`.
|
||||
|
||||
Опрос контейнеров идёт ПАРАЛЛЕЛЬНО (`asyncio.gather`) в фоновой задаче со
|
||||
своим интервалом (`POLL_INTERVAL_S`), а не синхронно на каждый scrape
|
||||
Prometheus: `GET /containers/{id}/stats?stream=false` не мгновенен (Docker
|
||||
считает CPU-дельту по двум внутренним замерам с разницей ~1с — проверено
|
||||
на реальном стенде), и линейный опрос дюжины контейнеров легко вышел бы за
|
||||
scrape-интервал Prometheus (15с). Параллельный опрос укладывается в ~1-2с
|
||||
независимо от числа контейнеров (проверено на 3 контейнерах, 4 запроса на
|
||||
каждый параллельно — 2с суммарно). Фоновая задача пишет значения прямо в
|
||||
метрики `prometheus_client` (они и есть кэш), обработчик `/metrics` не
|
||||
трогает Docker API вообще — стабильная латентность ответа независимо от
|
||||
состояния Docker API.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import os
|
||||
import time
|
||||
from typing import Any
|
||||
|
||||
import aiohttp
|
||||
from aiohttp import web
|
||||
from prometheus_client import CONTENT_TYPE_LATEST, Gauge, generate_latest
|
||||
|
||||
logging.basicConfig(level=logging.INFO)
|
||||
logger = logging.getLogger("container_exporter")
|
||||
|
||||
DOCKER_API_BASE = os.environ.get("DOCKER_API_BASE", "http://docker-socket-proxy:2375")
|
||||
POLL_INTERVAL_S = float(os.environ.get("POLL_INTERVAL_S", "15"))
|
||||
REQUEST_TIMEOUT_S = float(os.environ.get("REQUEST_TIMEOUT_S", "10"))
|
||||
LISTEN_PORT = int(os.environ.get("PORT", "9419"))
|
||||
|
||||
CPU_PERCENT = Gauge(
|
||||
"vidconf_container_cpu_percent",
|
||||
"Использование CPU контейнером, % от одного ядра (как `docker stats`)",
|
||||
labelnames=("name",),
|
||||
)
|
||||
MEMORY_USAGE_BYTES = Gauge(
|
||||
"vidconf_container_memory_usage_bytes",
|
||||
"Память контейнера без переиспользуемого page cache (working set)",
|
||||
labelnames=("name",),
|
||||
)
|
||||
MEMORY_LIMIT_BYTES = Gauge(
|
||||
"vidconf_container_memory_limit_bytes",
|
||||
"Лимит памяти контейнера (= RAM хоста, если лимит не задан явно)",
|
||||
labelnames=("name",),
|
||||
)
|
||||
NETWORK_RX_BYTES = Gauge(
|
||||
"vidconf_container_network_rx_bytes",
|
||||
"Байт получено контейнером по сети, суммарно по интерфейсам, с момента старта контейнера",
|
||||
labelnames=("name",),
|
||||
)
|
||||
NETWORK_TX_BYTES = Gauge(
|
||||
"vidconf_container_network_tx_bytes",
|
||||
"Байт отправлено контейнером по сети, суммарно по интерфейсам, с момента старта контейнера",
|
||||
labelnames=("name",),
|
||||
)
|
||||
|
||||
|
||||
class PollState:
|
||||
"""Флаг успешности последнего опроса — источник `/health` для compose."""
|
||||
|
||||
last_poll_ok: bool = False
|
||||
|
||||
|
||||
def _container_name(container: dict[str, Any]) -> str:
|
||||
"""Имя контейнера без ведущего `/` (Docker API отдаёт список имён с ним)."""
|
||||
names = container.get("Names") or []
|
||||
if names:
|
||||
return str(names[0]).lstrip("/")
|
||||
return str(container["Id"])[:12]
|
||||
|
||||
|
||||
def _cpu_percent(stats: dict[str, Any]) -> float | None:
|
||||
"""Доля CPU за интервал между `precpu_stats` и `cpu_stats` — расчёт `docker stats` (CLI).
|
||||
|
||||
Оба поля Docker кладёт в один ответ `stats?stream=false` сам (снимает
|
||||
дважды с разницей ~1с внутри демона) — считать дельту вручную между
|
||||
двумя нашими запросами не нужно.
|
||||
"""
|
||||
cpu = stats.get("cpu_stats") or {}
|
||||
precpu = stats.get("precpu_stats") or {}
|
||||
cpu_total: int | None = (cpu.get("cpu_usage") or {}).get("total_usage")
|
||||
precpu_total: int | None = (precpu.get("cpu_usage") or {}).get("total_usage")
|
||||
system: int | None = cpu.get("system_cpu_usage")
|
||||
presystem: int | None = precpu.get("system_cpu_usage")
|
||||
# Первый снимок контейнера (только что создан) — precpu_stats пуст.
|
||||
if cpu_total is None or precpu_total is None or system is None or presystem is None:
|
||||
return None
|
||||
cpu_delta = cpu_total - precpu_total
|
||||
system_delta = system - presystem
|
||||
if system_delta <= 0 or cpu_delta < 0:
|
||||
return None
|
||||
percpu_usage = (cpu.get("cpu_usage") or {}).get("percpu_usage") or []
|
||||
online_cpus = cpu.get("online_cpus") or len(percpu_usage) or 1
|
||||
return float((cpu_delta / system_delta) * online_cpus * 100.0)
|
||||
|
||||
|
||||
def _apply_stats(name: str, stats: dict[str, Any]) -> None:
|
||||
cpu_percent = _cpu_percent(stats)
|
||||
if cpu_percent is not None:
|
||||
CPU_PERCENT.labels(name=name).set(cpu_percent)
|
||||
|
||||
memory = stats.get("memory_stats") or {}
|
||||
usage = memory.get("usage")
|
||||
limit = memory.get("limit")
|
||||
cache = (memory.get("stats") or {}).get("inactive_file", 0)
|
||||
if usage is not None:
|
||||
MEMORY_USAGE_BYTES.labels(name=name).set(max(usage - cache, 0))
|
||||
if limit is not None:
|
||||
MEMORY_LIMIT_BYTES.labels(name=name).set(limit)
|
||||
|
||||
networks: dict[str, Any] = stats.get("networks") or {}
|
||||
if networks:
|
||||
rx = sum(net.get("rx_bytes", 0) for net in networks.values())
|
||||
tx = sum(net.get("tx_bytes", 0) for net in networks.values())
|
||||
NETWORK_RX_BYTES.labels(name=name).set(rx)
|
||||
NETWORK_TX_BYTES.labels(name=name).set(tx)
|
||||
|
||||
|
||||
async def _fetch_and_apply(session: aiohttp.ClientSession, container: dict[str, Any]) -> None:
|
||||
name = _container_name(container)
|
||||
container_id = container["Id"]
|
||||
timeout = aiohttp.ClientTimeout(total=REQUEST_TIMEOUT_S)
|
||||
try:
|
||||
async with session.get(
|
||||
f"{DOCKER_API_BASE}/containers/{container_id}/stats",
|
||||
params={"stream": "false"},
|
||||
timeout=timeout,
|
||||
) as response:
|
||||
response.raise_for_status()
|
||||
stats = await response.json(content_type=None)
|
||||
except Exception:
|
||||
logger.warning("не удалось получить stats контейнера %s", name, exc_info=True)
|
||||
return
|
||||
_apply_stats(name, stats)
|
||||
|
||||
|
||||
async def _poll_once(session: aiohttp.ClientSession) -> None:
|
||||
timeout = aiohttp.ClientTimeout(total=REQUEST_TIMEOUT_S)
|
||||
async with session.get(f"{DOCKER_API_BASE}/containers/json", timeout=timeout) as response:
|
||||
response.raise_for_status()
|
||||
containers = await response.json(content_type=None)
|
||||
# Параллельно — см. докстринг модуля про латентность stats-запроса.
|
||||
await asyncio.gather(*(_fetch_and_apply(session, c) for c in containers))
|
||||
|
||||
|
||||
async def _poll_loop(state: PollState) -> None:
|
||||
async with aiohttp.ClientSession() as session:
|
||||
while True:
|
||||
start = time.monotonic()
|
||||
try:
|
||||
await _poll_once(session)
|
||||
state.last_poll_ok = True
|
||||
except Exception:
|
||||
logger.exception("опрос Docker API завершился ошибкой")
|
||||
state.last_poll_ok = False
|
||||
elapsed = time.monotonic() - start
|
||||
await asyncio.sleep(max(POLL_INTERVAL_S - elapsed, 1.0))
|
||||
|
||||
|
||||
# `aiohttp.web.Response(content_type=...)` не принимает `charset` внутри
|
||||
# строки типа (падает `ValueError`), а `CONTENT_TYPE_LATEST` из
|
||||
# `prometheus_client` — "text/plain; version=0.0.4; charset=utf-8" целиком;
|
||||
# разбираем один раз на media type (без `charset=...`) и сам charset.
|
||||
_METRICS_MEDIA_TYPE = "; ".join(
|
||||
part for part in CONTENT_TYPE_LATEST.split("; ") if not part.startswith("charset=")
|
||||
)
|
||||
|
||||
|
||||
async def metrics_handler(_request: web.Request) -> web.Response:
|
||||
return web.Response(body=generate_latest(), content_type=_METRICS_MEDIA_TYPE, charset="utf-8")
|
||||
|
||||
|
||||
def _make_health_handler(state: PollState) -> Any:
|
||||
async def health_handler(_request: web.Request) -> web.Response:
|
||||
if state.last_poll_ok:
|
||||
return web.Response(text="ok")
|
||||
return web.Response(text="опрос Docker API ещё не выполнялся успешно", status=503)
|
||||
|
||||
return health_handler
|
||||
|
||||
|
||||
def create_app() -> web.Application:
|
||||
app = web.Application()
|
||||
state = PollState()
|
||||
app.router.add_get("/metrics", metrics_handler)
|
||||
app.router.add_get("/health", _make_health_handler(state))
|
||||
|
||||
async def on_startup(_app: web.Application) -> None:
|
||||
app["poll_task"] = asyncio.create_task(_poll_loop(state))
|
||||
|
||||
async def on_cleanup(_app: web.Application) -> None:
|
||||
app["poll_task"].cancel()
|
||||
|
||||
app.on_startup.append(on_startup)
|
||||
app.on_cleanup.append(on_cleanup)
|
||||
return app
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
web.run_app(create_app(), host="0.0.0.0", port=LISTEN_PORT)
|
||||
26
deploy/monitoring/container-exporter/pyproject.toml
Normal file
26
deploy/monitoring/container-exporter/pyproject.toml
Normal file
@@ -0,0 +1,26 @@
|
||||
[project]
|
||||
name = "vidconf-container-exporter"
|
||||
version = "0.1.0"
|
||||
description = "Метрики Prometheus по контейнерам поверх Docker Engine API (профиль monitoring, deploy/docker-compose.yml)."
|
||||
requires-python = ">=3.12"
|
||||
dependencies = [
|
||||
"aiohttp>=3.10.0",
|
||||
"prometheus-client>=0.25.0",
|
||||
]
|
||||
|
||||
[dependency-groups]
|
||||
dev = [
|
||||
"mypy>=2.3.0",
|
||||
"ruff>=0.15.21",
|
||||
]
|
||||
|
||||
[tool.ruff]
|
||||
line-length = 100
|
||||
target-version = "py312"
|
||||
|
||||
[tool.ruff.lint]
|
||||
select = ["E", "F", "I", "UP", "B", "ASYNC"]
|
||||
|
||||
[tool.mypy]
|
||||
python_version = "3.12"
|
||||
strict = true
|
||||
747
deploy/monitoring/container-exporter/uv.lock
generated
Normal file
747
deploy/monitoring/container-exporter/uv.lock
generated
Normal file
@@ -0,0 +1,747 @@
|
||||
version = 1
|
||||
revision = 3
|
||||
requires-python = ">=3.12"
|
||||
resolution-markers = [
|
||||
"python_full_version >= '3.15'",
|
||||
"python_full_version < '3.15'",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "aiohappyeyeballs"
|
||||
version = "2.7.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/ce/f4/eec0465c2f67b2664688d0240b3212d5196fd89e741df67ddb81f8d35658/aiohappyeyeballs-2.7.1.tar.gz", hash = "sha256:065665c041c42a5938ed220bdcd7230f22527fbec085e1853d2402c8a3615d9d", size = 24757, upload-time = "2026-07-01T17:11:55.501Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/71/43/1947f06babed6b3f1d7f38b0c767f52df66bfb2bc10b468c4a7de9eceff2/aiohappyeyeballs-2.7.1-py3-none-any.whl", hash = "sha256:9243213661e29250eb41368e5daa826fc017156c3b8a11440826b2e3ed376472", size = 15038, upload-time = "2026-07-01T17:11:54.055Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "aiohttp"
|
||||
version = "3.14.3"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "aiohappyeyeballs" },
|
||||
{ name = "aiosignal" },
|
||||
{ name = "attrs" },
|
||||
{ name = "frozenlist" },
|
||||
{ name = "multidict" },
|
||||
{ name = "propcache" },
|
||||
{ name = "typing-extensions", marker = "python_full_version < '3.13'" },
|
||||
{ name = "yarl" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/58/d9/22ce5786ac0c1653ae8b6c23bded02c1686d11f0dbb45b31ce128e0df985/aiohttp-3.14.3.tar.gz", hash = "sha256:9491196535a88924a60afd5b5f434b5b203b6cc616250878dbdb223a8f7844bc", size = 7971213, upload-time = "2026-07-23T01:57:27.037Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/18/d4/eb96299230e20acf2efae207cb8d69051f1f68e357e5ea5e479bf6fb097a/aiohttp-3.14.3-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:39aded8c7f3b935b54aab1d8d73c70ec0ee2d3ec3b943e0e86611bc150ba47f5", size = 754690, upload-time = "2026-07-23T01:53:47.332Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/88/11/e7a70a209eb9a067c0d3212b518a0134e3484f5178c7533878b6b514d469/aiohttp-3.14.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:5bcb6ff3fdab1258a192679ff1a05d44f59626430aa05cd1a9d2447423599228", size = 509484, upload-time = "2026-07-23T01:53:51.159Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/30/07/4bbc222cc8dbe31d4c3e8a5baad2286e4d42026ac0c570027b89afce6344/aiohttp-3.14.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:617105e2c3018ee38d0c8ce5ee3c84f621a6d8b9f723202aacaff28449ca91ee", size = 511949, upload-time = "2026-07-23T01:53:55.083Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/54/b9/42e74c46b7b7c794b995bbc1f573fb48950c38b19d8600c62a6804ee2d67/aiohttp-3.14.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f631fe87a6f30df5fbe6d79640b25e4cffb38c31c7fb6f10871517b84b0f8c1a", size = 1765282, upload-time = "2026-07-23T01:53:59.662Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6b/ed/62bc4d74363ad346d518e0720363a949f63e2e23439a79eb5813d4d29bb3/aiohttp-3.14.3-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a94dbaae5ae27bd849c93570669bff91e0510f33a80805738e3de72a7be0447b", size = 1741511, upload-time = "2026-07-23T01:54:04.063Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d0/9f/181e8a8bc79e47d13c7fc4540bd7a3b729d9505609c61f392a8dd2fbfe55/aiohttp-3.14.3-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8f2f1c4c032c7cedd7d8da6f54c97b70266c6570c3108d3fdffee7188bb70529", size = 1810680, upload-time = "2026-07-23T01:54:09.882Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5c/9a/dec94d6ad694552fe3424e3f1928d7a606a5d9d9433a04e7ecdd9d38ae7f/aiohttp-3.14.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ea05e1f97ceea523942d9b2a7d7c0359d781d683d6b043f5943a602b14da4787", size = 1905646, upload-time = "2026-07-23T01:54:13.475Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/52/b7/7cd31f29d6055bd711ae6e669367fba6f5ae9de463910a793e30556a8db7/aiohttp-3.14.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:543906c127fb1d929b95076db19b83fa2d46751006ff1e23b093aa5ac4d8db42", size = 1792122, upload-time = "2026-07-23T01:54:15.752Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/66/73/10b1ef93afa61f4963c746257b70ced619cf31a4798671de5fdb2608501d/aiohttp-3.14.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0a5ff2dfbb9ce645fa5b8ef3e02c6c0b9cc3f6030ff863d0c51fffc50cb5541b", size = 1591127, upload-time = "2026-07-23T01:54:19.489Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/49/ed/3b203fa6de1b338c14acdc06bf6ca9b043b7944f005966958c2ced932cde/aiohttp-3.14.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:041badb8f84396357c4d3ad26de6afd7a32b112f43d3c63045c0c8278cfd2043", size = 1725210, upload-time = "2026-07-23T01:54:24.129Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/28/b7/1c2aab8c706436dcc28598452488ac9cd7c409da815237c28c27d58993e6/aiohttp-3.14.3-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:530125ee1163c4219af35dc3aa1206e541e7b31b6efc1a3f93b70a136f65d427", size = 1764848, upload-time = "2026-07-23T01:54:27.973Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/54/50/94c28f08b131c4bf10984ea2c7a536c9920608bb2d6e7f95642c30cc87b7/aiohttp-3.14.3-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:c8653fd547c93a61aadc612007790f5555cdd18946fa48cf45e26d8ea4ea473d", size = 1777102, upload-time = "2026-07-23T01:54:31.775Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/13/d4/e7d09ba7d345fb2d74440fd2fa033c5e079fac05552927705986f41a364f/aiohttp-3.14.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:89176250f686cb9853c0fb7ead90e639e915b84a6f43eedc2a4e7ec21f1037f0", size = 1580205, upload-time = "2026-07-23T01:54:34.518Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a3/84/072a91d68e1e1eb587985b54baab94221277f877e8ef274fc213a0ceae28/aiohttp-3.14.3-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:3a26434dafe408229ff3403458ca58de24fb51936504decac49ce6755f77e59d", size = 1797219, upload-time = "2026-07-23T01:54:36.995Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e0/eb/aad34e897e668424d6e995da5dff8a4a09af93363d3392488772957a63aa/aiohttp-3.14.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:d1558173930a5a8d3069cee5c92fc91c87c4dbcb099debbb3622053717145a19", size = 1768629, upload-time = "2026-07-23T01:54:40.103Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b6/2b/6bb88ddba0fecd9122aa3ebcad25996cf6c083a4a7040dbb3a4f97972af6/aiohttp-3.14.3-cp312-cp312-win32.whl", hash = "sha256:16100ad3ab8d649fdfbee87602d9d2dcdca9df0b9eda8a1b5fdc0d41f96da559", size = 451481, upload-time = "2026-07-23T01:54:42.547Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/76/9b/f2f8f108da17ecef2cc3efc424e8b7ad3782b1a8360f7b8eae8ced84f6ea/aiohttp-3.14.3-cp312-cp312-win_amd64.whl", hash = "sha256:33a2d7c28d33797a2e99923dffa63f83d908a19b6bf26cfe80fa790aa5e1a75a", size = 476845, upload-time = "2026-07-23T01:54:44.853Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3e/44/28dac80a8941b604f4da10ce21097614ca1bf905ce93dca28d8d7de9c1e7/aiohttp-3.14.3-cp312-cp312-win_arm64.whl", hash = "sha256:362a3fd481769cac1a824514bcd86fda51c65e8fe6e051099e008fddde6db17c", size = 448050, upload-time = "2026-07-23T01:54:47.087Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/57/be/5afd201cc0ab139029aadb75392efe85a293403d9dd3a3226161c21ce00c/aiohttp-3.14.3-cp313-cp313-android_21_arm64_v8a.whl", hash = "sha256:2e9878ae68e4a5f1c0abe4dd497dbc3d51946f5837b56759e2a02e78fa90ef86", size = 506269, upload-time = "2026-07-23T01:54:49.075Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/22/09/dec8189d62b45ade009f6792a2264b942a90cb88aeaf181239933cd72c3c/aiohttp-3.14.3-cp313-cp313-android_21_x86_64.whl", hash = "sha256:f3d2669fe7dec7fc359ecdb5984b29b50d85d5d00f8c1cb61de4f4a24ee42627", size = 515166, upload-time = "2026-07-23T01:54:51.894Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/28/24/2854869d29ed8a8b19d74f9ec6629515f7e04d02dd329d9d179201e58e47/aiohttp-3.14.3-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:cc7cb243a68167172f48c1fd43cee91ec4b1d40cefd190edd43369d1a6bc9c82", size = 486263, upload-time = "2026-07-23T01:54:54.223Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d4/dd/57187c8be2a35aea65eaee3bd2c3dcbbcf0204f5106c89637e3610380cd1/aiohttp-3.14.3-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:78253b573e6ffab5028924fc98bc281aae05445969982a10864bc360dea2016c", size = 492299, upload-time = "2026-07-23T01:54:56.236Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b9/11/06ae6ed8f0d414edf4068861e233d8fe23ee699bfd4b3ceb8663db948a62/aiohttp-3.14.3-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:7041d52c3a7fa20c9e8c182b534704abb19502c8bdcbde7ab23bfda6f642394f", size = 502235, upload-time = "2026-07-23T01:54:58.377Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7e/a3/559639c34a345d2cf7c52dff6838119f2eaf29eb508227b5b83f573af813/aiohttp-3.14.3-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ac74facc01463f138b0da5580329cfcc82818dea5656e83ddcd11268fc12ff80", size = 750883, upload-time = "2026-07-23T01:55:00.65Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/91/cd/41e131f13afd1e7b0172a9d9eda085ef90eb8439f41f0d279db81ed3ae60/aiohttp-3.14.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:d6218d92e450824e9b4881f44e8c09f1853b490f9a64130801024a4793b1b3b0", size = 508473, upload-time = "2026-07-23T01:55:02.945Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bc/6b/e7f13410d391c6e55b4c007a8de024355389d7d459e3d64c42b2d33617e5/aiohttp-3.14.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:11fb37ef075669eee52ab1928fbf6e1741fada40409fa309ebde9607a962aebf", size = 509190, upload-time = "2026-07-23T01:55:05.173Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/97/21/6464573e53d69672cc1eada3e5c5cb2d2efa82701e8305a0f2047a576967/aiohttp-3.14.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:55bdcc472aafe2de4a253045cc128007a64f1e0264fb675791e132ea5edaa3bd", size = 1761478, upload-time = "2026-07-23T01:55:07.383Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1a/81/d217043a4c17fbce360905e3b2bdd20139ebc9a2de836d035d179c4da006/aiohttp-3.14.3-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:c39846c3aad97a8530c89d7a3869a8f8e9e3762c6ac0504481e5c80948f7e807", size = 1735092, upload-time = "2026-07-23T01:55:09.803Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a1/66/e13a02d0eeb1a9a502402a977abb4e4abff9fe4051c26f80558c57a7c975/aiohttp-3.14.3-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5895ef58c4620afe02fa16044f023dc4dafec08158f9d08874a46a7dbc0341b8", size = 1800546, upload-time = "2026-07-23T01:55:12.012Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/26/5e/57d42fca1d18cb5acc1cad945d017fabc5d6ae71d8a08ad66be8dc3ee544/aiohttp-3.14.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:fa9467a8113aa69d3d7c55a70ef0b7c636010a40993f3df9d9d0d73b3eb7ef24", size = 1895250, upload-time = "2026-07-23T01:55:14.357Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ca/1c/7da8d08e74d56f00070822f9638ff3f1c563f8ad87d1efa996c87bfc8644/aiohttp-3.14.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d7d2deec16eeedf55f2c7cf75b521ea3856a5177e123844f8fd0f114ce252cb5", size = 1789289, upload-time = "2026-07-23T01:55:16.668Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cd/0f/cf16bcf56896981c1a0319f5d5db9337994b5165730c48a8fa07e9b34be6/aiohttp-3.14.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:dd54d0e8717de95939766febac482ac0474d8ac3b048115f9f2b1d23a16e7db4", size = 1586706, upload-time = "2026-07-23T01:55:18.913Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fe/6f/76eac12a7f2480e1e304f842efdb07db33256b0d9165b866b6ef0806c202/aiohttp-3.14.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:df82f3787c940c94986b34222d59c9e38843fba85139f36e85255a82ad5355a9", size = 1724652, upload-time = "2026-07-23T01:55:21.296Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/39/b6/19c8c592baeeb94b75f966547d40c02ac7590902306ec5863d5c027cf506/aiohttp-3.14.3-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:42a67efc36300d052fb4508a53e8b6901b9284b599ae63945c377569c5fcc1e1", size = 1756239, upload-time = "2026-07-23T01:55:23.705Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/dc/c9/4e9383150296f97f873b680c4de8fb2cd88608fb9f48c79edcb111611abc/aiohttp-3.14.3-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:7a75aa63cbf9b21cfaf60dc2657e19df2c2867d91707d653fee171ffeedd1371", size = 1769161, upload-time = "2026-07-23T01:55:26.082Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/aa/1e/147bdc6cc5de5f3ab011be8bf5d6e786633249f22c20bae06f85e45f5387/aiohttp-3.14.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:e92eb8acc45eb6a9f4935071a77edf5b85cc6f8dfad5cd99e97653c26593cdde", size = 1578759, upload-time = "2026-07-23T01:55:28.846Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fd/31/78388a9d6040ece2e11df62ea229a822cf5e52d238374b220ae9975b2623/aiohttp-3.14.3-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:b014a6ed7cf912e787149fdc529166d3ceabac23f26efeea3158c9aba2354e7e", size = 1792025, upload-time = "2026-07-23T01:55:31.457Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/03/51/a3d29fdf2c25d796746af8ad6fe56a45d6256c38b0a8a2ed752e1160b3a2/aiohttp-3.14.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:3d4f72af88ac2474bb5bca640030320e3d38a0163a1d7533500e87be458eef71", size = 1768477, upload-time = "2026-07-23T01:55:33.87Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/29/a6/442e18b5afeade534d877a2dc3c3e392aff8d49787890b0cf84790410267/aiohttp-3.14.3-cp313-cp313-win32.whl", hash = "sha256:5f08ec777f35ee70720233b8b9811d3bb5d728137f30ac91b7457709c3261ac0", size = 451069, upload-time = "2026-07-23T01:55:36.121Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9d/69/3d876ac02659f271cf7f6769f14a8e3de5b6e888ed8b5a7e998086a4cec8/aiohttp-3.14.3-cp313-cp313-win_amd64.whl", hash = "sha256:dff9461ec275f22135650d5ba4b4931a11f3958df7dfbb8db630000d4dee0883", size = 476518, upload-time = "2026-07-23T01:55:38.303Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b2/0e/50d6e6471cd31edce8b282bdec59375a3a69124d8a989a0b1313355cae52/aiohttp-3.14.3-cp313-cp313-win_arm64.whl", hash = "sha256:ddcac3c6b382e81f1dd0499199d4136b877beb4cb5ef770bbbfba56c4b8f55d2", size = 447676, upload-time = "2026-07-23T01:55:40.451Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c8/20/887fdcf832326571b370ffc347b3e70abe101096f3720126aac161b1d872/aiohttp-3.14.3-cp314-cp314-android_24_arm64_v8a.whl", hash = "sha256:49f7325beb0f85ef4aef5f48f490269575f83e6e2acad00a1d80b807eb027062", size = 509067, upload-time = "2026-07-23T01:55:42.618Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ad/a3/92cec936f78cc4bf0fa5554ebe593b73459d94e3c62303e1902a4cccb6f7/aiohttp-3.14.3-cp314-cp314-android_24_x86_64.whl", hash = "sha256:e3be98a7c30b8c25d573dafba7171d66dfb05ee6a9070fc46535464ff97700a6", size = 514774, upload-time = "2026-07-23T01:55:44.937Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/29/ba/2a0c38df3fc557620b6a5acd98364af050053b6285b4dc7ee74100c63c18/aiohttp-3.14.3-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:614c61d478b83953e261d02bb2df750f17227cd33ef8002945bf5aebbde21919", size = 488134, upload-time = "2026-07-23T01:55:47.135Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/48/d6/d51b7d4bf309af3693940d8ffd2b9ed0b682434ef85959b7c9c137f60cf8/aiohttp-3.14.3-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:1caa7b0d05f3e3a36f87788c59e970a7ee1cefcfcbb924a9f138c4a6551c9cb7", size = 494201, upload-time = "2026-07-23T01:55:49.451Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3f/5a/8f624384e5f1efabb5229b94157eb966b021e97bdb188c62860c2ae243c2/aiohttp-3.14.3-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:dfa68deb2a443bdaa3ea5297b0699c1464f08aef3812b486d1348eee61b07dc0", size = 502766, upload-time = "2026-07-23T01:55:51.656Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a6/26/4ff0164370deec18fb19254ee4ab10b7a73304ac0c860b13f5f84663759b/aiohttp-3.14.3-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:e72ee89e28d907a18f46959b4eb0bb06701cc7f8cf4366e00029e2ccfaaf5924", size = 756557, upload-time = "2026-07-23T01:55:53.964Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/97/a3/7056b86dc0d9ec709ea9777eae3b0161428f943372f8b98c01c11593b682/aiohttp-3.14.3-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:ad4c8b7488d745d2ca4838ebd8ae5ba9b56341d30b1da43640e4ce87f9f49646", size = 510168, upload-time = "2026-07-23T01:55:56.22Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/85/ed/0357a015892fd68058bf2d39d3fd1958e459b997a7db30aaa6aaa434ae96/aiohttp-3.14.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:db332af25642007330fca8be5c4d194caf2bea7a7fc84415aff3497af5dfee6b", size = 512957, upload-time = "2026-07-23T01:55:58.437Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/47/d1/8aba53f15ccb2238405f5e9d30e2a8ca44f93878c26e7165ade00d374b1c/aiohttp-3.14.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:25bd2708db6bdf6a6630dd37bdcdfcb47c4434d22ac69c64665b802910140b30", size = 1750149, upload-time = "2026-07-23T01:56:00.856Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/49/bd/40c3fee327529284375c6701cbb0fa4600cc2e8432af1378f897e2ef7d3a/aiohttp-3.14.3-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:cef89a58e628c4efcac3275c2d68083f82426dcdc89c1492a6f654f9f7ea6ab9", size = 1707685, upload-time = "2026-07-23T01:56:03.371Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2a/a3/ca0cc6724cca8114b05694abd916060758c79894c3aa5b012cdadc1bc28e/aiohttp-3.14.3-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c23ec8ee9d5ab2f5421f9c7fffce208435607af27fd46d4a44e031954352838f", size = 1803911, upload-time = "2026-07-23T01:56:05.817Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/95/b5/85b099c299c3ffd38ad9b3e43694c8a346934e4a30c88c4fd5a841234f77/aiohttp-3.14.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:e2667f0bbe7eb6c74eae5e9691441ad186e5845ca3cff63230fc09c4e7514f5d", size = 1876929, upload-time = "2026-07-23T01:56:08.413Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d5/b7/1da684a04175473fa4cddbf9a2f572e79514c3fd27a74597f43057d4f3da/aiohttp-3.14.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:18cb43369747b2ae007bd2655fb8e63a099c2ff1d207962943636dac989b3147", size = 1761112, upload-time = "2026-07-23T01:56:10.918Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d1/16/bc4b55e3e5cb175fd69c53c90d60d2f47797cb343da5106e23863dc4dba4/aiohttp-3.14.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d77640cc618c1d99fc4f8589c0f24a730adfa54eb1e57ef7bf0c8dfb78da898c", size = 1583500, upload-time = "2026-07-23T01:56:13.613Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2a/e8/13a9d957a1ee40837f46aa30f0f4c657e673ad86a2e6362a9f9be20d26d9/aiohttp-3.14.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:53e5179d8abb5710f8e83ba207c41c8d1261fcffd4616500e15ca2b7a33be10a", size = 1713940, upload-time = "2026-07-23T01:56:15.969Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/38/05/d33c680c1bcf1c7e130f9cbfc1fc02fe8bb0c4af2a94a53dd5fb56131e5c/aiohttp-3.14.3-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:cd817772b2fcf2b8c0905795318485f9ec16eae60b29feb7f4c77085311637f0", size = 1724413, upload-time = "2026-07-23T01:56:18.591Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/85/1d/af798d306f7a74b6a632dbcabcf62a4c91391b7582d2a8c6d7712e2cc54e/aiohttp-3.14.3-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:4e3ac92d90e92773b2362d506068e9a948192bd553e743c5b2429e28527c8661", size = 1770748, upload-time = "2026-07-23T01:56:21.074Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a8/92/ad720d472556a995049206867765e9410969684f86ee09423ff9969044c1/aiohttp-3.14.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:3f42e9b78301f11c8f861746175d8b9c1ccef713fcad9eab396e2f6db8ed4a22", size = 1577564, upload-time = "2026-07-23T01:56:23.475Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/60/ad/0ed7586cbef7a884e23a752fa2bb987a122e6a5dd50dab109258d0a95193/aiohttp-3.14.3-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:9d9edccfe496b476db5f398d97b865e9a6752bcf8aec4eef8390ce20fb64bb41", size = 1782080, upload-time = "2026-07-23T01:56:25.994Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/97/ea/dbaed0d73e8a69aad653b045dab451c67c2454bb731a37b45a86593e9422/aiohttp-3.14.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:1c5ec8fb1bcc31a8466f74aaf26c345d5c386fa4bd08a3f0eb9c7a4a3fe8b5bf", size = 1745813, upload-time = "2026-07-23T01:56:28.604Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/81/1b/6893d4bc57e434fc93a6c9217c637d967a0b651d989f6e3265179375754a/aiohttp-3.14.3-cp314-cp314-win32.whl", hash = "sha256:38901a84da3ce22249f6e860bf8f90d141bcab7da090cc398f8bb58c0e44b7da", size = 455872, upload-time = "2026-07-23T01:56:31.031Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f5/8b/c7baa1ba1eda4db6989baefe5de6d99834921b84ebd7918624febcb9f290/aiohttp-3.14.3-cp314-cp314-win_amd64.whl", hash = "sha256:8b3b60de05f3dcb6f6a00f818bb2ec781cee4de0645f59ccaf99b1d1823b6100", size = 481030, upload-time = "2026-07-23T01:56:33.365Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/22/8c/c29d067df825a2df88ca432db848aa2fe8199598359cc06c12b09320cac9/aiohttp-3.14.3-cp314-cp314-win_arm64.whl", hash = "sha256:1576145bdceeb92382d899751e12743a3a5b8e460a841e3e50543859e54864dc", size = 453669, upload-time = "2026-07-23T01:56:35.731Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6a/a4/9c033beb355d39b6147980597ec9645e4729243f686ee4dc73945de72030/aiohttp-3.14.3-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:8800c996b01c2772a783e3e46f3e1abd5823029adca0df54231960de9bfefa5b", size = 791403, upload-time = "2026-07-23T01:56:37.972Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/80/ca/87c32a0a7704583cfc49660bd817889bae5b830bf53b5dcb4e92145ac2da/aiohttp-3.14.3-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:ebe8e504f058fe91223351cecd2d9d6946c9d241bb0250d898ffbdf584cc72b0", size = 526413, upload-time = "2026-07-23T01:56:40.523Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9e/d8/8ec0e471248c500acdce2be3f46db8fb62b5eb60efef072529cc85ee1d26/aiohttp-3.14.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:30402d03a7c0ff52bce290b57e564e9079fd9d0cb545c8aba73f86a103162d2e", size = 532135, upload-time = "2026-07-23T01:56:42.876Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fe/45/f8919fd936e8b79fcd9bda7b6d8e62613462a713f4f17987fd7c34399142/aiohttp-3.14.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9fc7b5bfec6573f3ae844f457fdde5adeb713f8b8e4a81ad64fc207b49383716", size = 1922742, upload-time = "2026-07-23T01:56:45.528Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f6/ec/9ca76b28a27525b0cc53e20842e0228b022f301ce1f436b7d814b4aaf2df/aiohttp-3.14.3-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:8a5fd34f7f7410d1730d5c2ba873cacb2eed3fede366feb268a70ba22581ed8f", size = 1787371, upload-time = "2026-07-23T01:56:48.045Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b1/04/6acdbf17315f7b55f1937e3387acb89a3cddeb4995689553d064af8e92ab/aiohttp-3.14.3-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:270d3dace9ca2f10f0da5d8ebe519b7a310fc6112ed916e32df5866df0888553", size = 1912623, upload-time = "2026-07-23T01:56:50.605Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/86/e6/438b0c79ca6f45eb9fd9817dd4c01a91919a38c0de5ee9e05e2b4dc0ece7/aiohttp-3.14.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3ae5b3a59436d089b5395d910121a390feed4d00578eb95a0fd1a329fe963100", size = 2005515, upload-time = "2026-07-23T01:56:53.153Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bb/6b/62cbd6577758699525f5c712d1ddef57d9875fbab0ae8d5f5a202fd598f8/aiohttp-3.14.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2498f0fe69ead802f9675beca44a7c21c62fdaa4ec5145ea1c3ad6edbee29f85", size = 1879906, upload-time = "2026-07-23T01:56:55.818Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/00/95/18bcbf830a21dc3aae24d8f6b6feaf3db1d2090242d00a7868db2ffb0b67/aiohttp-3.14.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a0dc483c00da8b673abbb367eb6f8d8f4bcec30eb58529ea13cb42e7fd2dfa33", size = 1675849, upload-time = "2026-07-23T01:56:58.861Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a9/19/47f4968659c5e23606c3790c80fc624e691c153d036148449ee84d31b287/aiohttp-3.14.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c7d3a97c678d34fc5b59da671ee9cd630096ddc643e7b5a30d54a2a6f3574d3f", size = 1843496, upload-time = "2026-07-23T01:57:01.591Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/64/af/38c33c4dd82fddcb4e56c4653b6f1072a8edbc6b7fa15809f14932c41e2d/aiohttp-3.14.3-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:f8fb78a83c9e5f741ca3a68cfb455c1f5bb83b4e7249a3848b3cd78d0a8563b0", size = 1827746, upload-time = "2026-07-23T01:57:05.131Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a1/9d/0537cda4885ac8f5b7053d164dd06312f4c483a4edcb8ee5b8aaf2a989bf/aiohttp-3.14.3-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:74ab5b6a9fb13e873e5a90946588baecaf488745e1db1a4a5c433f971f035098", size = 1853810, upload-time = "2026-07-23T01:57:08.043Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/19/fe/26f9c5e6458385aa86497836b0dea6fb2f027827d63f37c7856cce9286ee/aiohttp-3.14.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:bd52f811e65f6fb634b1047159657c98f52b407f8efec907bcfc09da9a4c0a25", size = 1668895, upload-time = "2026-07-23T01:57:10.837Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ec/4c/618b1db9b9ba079b8875d2cdf78e7c4a3bf72903bd5850fee7dd9544600a/aiohttp-3.14.3-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:f0f177d1b195b9e06376cfd7d308d8a1b920909a609d03ac82a8c73bbb16d3b9", size = 1883833, upload-time = "2026-07-23T01:57:13.672Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/94/c6/bd959bd1e4771f9fd944e9e436224c48c77b018b73b519b5aad346335bcc/aiohttp-3.14.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:498c6c623134f8e09a3c4e60bcd607a0b4590dd7dbf08dd40851b27cbb520ccb", size = 1844251, upload-time = "2026-07-23T01:57:16.593Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5e/19/08d41839658bdd44a0ed2480f3891705ecb487ce28c0dde62c9040c997e0/aiohttp-3.14.3-cp314-cp314t-win32.whl", hash = "sha256:b304db572b4368edd8dda8a2274f73156fe15558fca4a917cb8a09fc47af5963", size = 474180, upload-time = "2026-07-23T01:57:19.306Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/99/5d/3cd6ef0a2b2851f7ab913b5b079334781bd50ff56a323e4454063377a080/aiohttp-3.14.3-cp314-cp314t-win_amd64.whl", hash = "sha256:b20032766aedf6261c7a566585a40867d092ac03a0d81592d5370ef9b054f99b", size = 500528, upload-time = "2026-07-23T01:57:21.762Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a4/37/cfd1ed540a4d318da025590d96b728e63713c09e9377950fc655dadeb856/aiohttp-3.14.3-cp314-cp314t-win_arm64.whl", hash = "sha256:2e1161602f45a54de2ce0905243a95f58cb42dcd378402f3697f5e0b21e9d2e7", size = 469280, upload-time = "2026-07-23T01:57:24.241Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "aiosignal"
|
||||
version = "1.4.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "frozenlist" },
|
||||
{ name = "typing-extensions", marker = "python_full_version < '3.13'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/61/62/06741b579156360248d1ec624842ad0edf697050bbaf7c3e46394e106ad1/aiosignal-1.4.0.tar.gz", hash = "sha256:f47eecd9468083c2029cc99945502cb7708b082c232f9aca65da147157b251c7", size = 25007, upload-time = "2025-07-03T22:54:43.528Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/fb/76/641ae371508676492379f16e2fa48f4e2c11741bd63c48be4b12a6b09cba/aiosignal-1.4.0-py3-none-any.whl", hash = "sha256:053243f8b92b990551949e63930a839ff0cf0b0ebbe0597b0f3fb19e1a0fe82e", size = 7490, upload-time = "2025-07-03T22:54:42.156Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ast-serialize"
|
||||
version = "0.6.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/58/ad/0d70a3a2d6e01968d985415259e8ec7ad3f777903f9b1c1f3c8c44642c60/ast_serialize-0.6.0.tar.gz", hash = "sha256:aadd3ffcf4858c9726bf3515f7b199c7eadbe504f96028e4a87172c0da65a8fe", size = 61489, upload-time = "2026-06-30T20:02:55.555Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/3f/12/3e5f575f156555547c250a8b0d1347517a3a20fc7f4492e9703a69d4f45e/ast_serialize-0.6.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:a7520b672827885bafeae7501f684d14d47d17e5f45256f9df547686cca52264", size = 1177640, upload-time = "2026-06-30T20:02:06.708Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a2/a4/921a9e27951627983b0f368859ea00f8330a551dc0bf4c2fdcb11855a98b/ast_serialize-0.6.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:a14191beec7e0c078d2fc1f6edc0aee88bcd4db9f18e1bc9f8052b559c22dddc", size = 1168111, upload-time = "2026-06-30T20:02:08.366Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/00/69/950cf404de7b8782cf95e5c1237e25e2aa46177b287f39f9eeddf481fd6f/ast_serialize-0.6.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:32ef62ec34cf6be20ad77d4799556638fbdf187f3ae10698dfb20ef9f2c89516", size = 1227656, upload-time = "2026-06-30T20:02:09.843Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4c/a8/46f8f6a6479d9d2273980957bb091a506c55f5b95d3c029ee58518a78407/ast_serialize-0.6.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:13b7769970a39983b0adf2f38917b1cd3b8946f76df045756c3d741bc689f089", size = 1227706, upload-time = "2026-06-30T20:02:11.367Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b7/b9/9ac415bda0a40e49eab8fea3b2741c19c98bb84d57d62c4cfc6230eb67be/ast_serialize-0.6.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6f7a408601bb3edaefb3bc67a4c01f5235e3253653b6a5729a2ee2382b35341c", size = 1431705, upload-time = "2026-06-30T20:02:12.737Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e5/06/8807115d441444879f7561b5eede5ac18fc80392f11826d61ccf31f503b1/ast_serialize-0.6.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8670bfa51208a2c0c8d138928e40e998fab158f9200d53bb80c088b5b8eda7b8", size = 1249533, upload-time = "2026-06-30T20:02:14.571Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3e/c0/c2ba82ef9618650357d9421a1fdb27ffec862a7f57e8e2de82a3ccd11e12/ast_serialize-0.6.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a4826809eb8597a8cd59fd924b6d7c285b8969a1e0007e2cb652cab62376270f", size = 1252619, upload-time = "2026-06-30T20:02:16.219Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0f/a7/fa31d52dd4102cede29fb9634e98d214129b2783b4f95528c6dc6a8f6587/ast_serialize-0.6.0-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:577a6c189068686869f5f1ddc38363f3ae1808a4753b577266f9202071a7bb66", size = 1242983, upload-time = "2026-06-30T20:02:17.813Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b1/20/ddf742b5ad3c4bafd3466f2265037cfd99bc1b9a5ee46a5d58c90d523242/ast_serialize-0.6.0-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:085de7f62dc9cc247eb01e965a362707d1d90b1d89a82c5bf78301a60a3c417b", size = 1296148, upload-time = "2026-06-30T20:02:19.146Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/24/cb/9f6f217cce8b3b632c5568b478d195a35e79dce4dbe309438cb89ba6ea4f/ast_serialize-0.6.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:9f8a8b78b13173de6a9ec22111d9be674874cd5bdccda04f14ae5ebc2bef403a", size = 1403826, upload-time = "2026-06-30T20:02:20.696Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2d/f8/9d16d4f0107a183924425cc0e7618d8bf76f96b45afa9ff19f924ed1ad57/ast_serialize-0.6.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:f2ff3baffc3a29c1f15bc9098aa0c09763410262d5e6cef42116f7356c184554", size = 1502943, upload-time = "2026-06-30T20:02:22.034Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/80/dd/bbc1c38756350dddf7e24acae1c9482ef42051c267417e019aecc1ed4075/ast_serialize-0.6.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:0067b25fce104eaae5b88383de9ab803faeb671831e14ca698b771b356e2600f", size = 1497632, upload-time = "2026-06-30T20:02:23.517Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/42/7e/9daffefcf5b97e6bb4c3e0b3c024c1aee9722f23d3cf7cd2ff80d6fb4a40/ast_serialize-0.6.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:c617417f9cbb0cb144f6283c3cbe0d2e0f01beaf9f608f662b21191058a626ec", size = 1448858, upload-time = "2026-06-30T20:02:24.889Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e5/1f/f9baaab81a677ea0af7d2458cac2f94ebcc85958f8a3c15ba9d9e5dab653/ast_serialize-0.6.0-cp314-cp314t-win32.whl", hash = "sha256:5337cb256dcea3df9288205213d1601581536526b8f4da44b6974f1180f3252a", size = 1052600, upload-time = "2026-06-30T20:02:26.263Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9e/1f/41b535866519512d8cf6669cb2cff7823b7672bb6279c0333b4ff89d7d9f/ast_serialize-0.6.0-cp314-cp314t-win_amd64.whl", hash = "sha256:2d947e45cafc4b09bd7528917fa84c517654a43de173c79785574b7b3068ac24", size = 1095570, upload-time = "2026-06-30T20:02:27.639Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/50/64/e472fe3e3a2d33d874b987e8518aedf24562919e3b6161a4fa1797e89c0f/ast_serialize-0.6.0-cp314-cp314t-win_arm64.whl", hash = "sha256:6e15ec740436e1a0d62de848641abe5f3a2f89a7f94907d534795ac91bbacf14", size = 1067267, upload-time = "2026-06-30T20:02:28.949Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/52/19/ac8348ae8711c9b5ae834634f635780cab62a0f5e6f988882e048b89c2ae/ast_serialize-0.6.0-cp39-abi3-macosx_10_12_x86_64.whl", hash = "sha256:093cb8bb91b720d8523580498d031791bb1bbaa048599c3d21085d380e11a596", size = 1185367, upload-time = "2026-06-30T20:02:30.427Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c1/f6/ec7ec652c51db77c2f61d8573338e13e4704303265ccc658cb4031d9f354/ast_serialize-0.6.0-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:e61580a69faf47e3689795367ed211f2a10fd741478cc0f36a0f128793360aad", size = 1178657, upload-time = "2026-06-30T20:02:31.964Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6f/02/613a7534a41d0122f37d1e0c64aa8ac78bfb831f8c92f6db057a311abb3c/ast_serialize-0.6.0-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:305802f2ce2a7c4e87835078ea85c58b586ddda8095b92fe2ead9364ae19c80a", size = 1238620, upload-time = "2026-06-30T20:02:33.664Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4d/21/087957bba486242afc52f49b2d9e21c9dad00289356cf9efe67084015a9d/ast_serialize-0.6.0-cp39-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c7b8b8f0c42f752ea00b2b7d7c090b3f80d9c1c5c75cadf16423790a0cc74081", size = 1236075, upload-time = "2026-06-30T20:02:34.936Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/82/04/78128bbb170071c2c72a210a181f1c00e11cc1cec60a8beef747b07f9201/ast_serialize-0.6.0-cp39-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:cd5b91b9e6f2356ace3a556963b0cd783b395fbbb0bb17b4defc283415466e77", size = 1441348, upload-time = "2026-06-30T20:02:36.245Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/64/64/62fb99d6faf199b4c3e5b08a07136e9a0d7664bb249c6de3670e5b63e9b6/ast_serialize-0.6.0-cp39-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4d6ef91590258ada18909b9caea344dac4de2013906b035473cd674a43f4b790", size = 1258580, upload-time = "2026-06-30T20:02:37.53Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ca/87/b4d6c38e0ccd5e85dc54cecdf933a152c60b28fe5d993a6d8a72fa6d5896/ast_serialize-0.6.0-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dcbed41e9386059fc0261d602445ede0976c2ecec2939688bcbcb9ed0b6f28b7", size = 1261693, upload-time = "2026-06-30T20:02:39.123Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0e/4b/3676ca2191f39bafb75f93f99b2f429ec464586158fece2165f3572805dc/ast_serialize-0.6.0-cp39-abi3-manylinux_2_31_riscv64.whl", hash = "sha256:cdc4e6f930b9090c2f92c9036ad12ffb8e6e44d4a5ba06f1458a05d60f203f7b", size = 1252517, upload-time = "2026-06-30T20:02:40.511Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f3/58/494ef8c4b4acb2f4a265ac934caf45f792a08fe27d6b853de35ad991941a/ast_serialize-0.6.0-cp39-abi3-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:897ac47b5637be41c0c07061c8a912fafa967ef1dc73fa115e4bfa70882a093b", size = 1304843, upload-time = "2026-06-30T20:02:41.961Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b1/f2/13736d920ab3d49bbee80ef1a277dd7b7aaf3b3545efd9d2a8114fe05525/ast_serialize-0.6.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:c4af9a1386166e40ed01464991806f89038a2d89782576c7774876fa77034e32", size = 1413698, upload-time = "2026-06-30T20:02:44.179Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a8/5a/e046f3899e2acba4677d7427b76431443a1aa1a0e583dfb05b55b69d55cf/ast_serialize-0.6.0-cp39-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:c901adbd750029b9ac4ad3d6aa56853e0ad4875119fbf52b7b8298afc223828b", size = 1512209, upload-time = "2026-06-30T20:02:45.584Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cc/c7/e42aaca7bb2d22a7c06d5a8c7930086c5a334e93d716e6fa5e6647a4515f/ast_serialize-0.6.0-cp39-abi3-musllinux_1_2_i686.whl", hash = "sha256:3ae22a366b752ab4496191525b78b097b5b72d531752e3c1dd7e383a8f2c8a1a", size = 1508464, upload-time = "2026-06-30T20:02:46.942Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/95/93/5524a3dc6c3f593de3228ed9cbef73afa047625b7000ec21b7f58e6eb4d4/ast_serialize-0.6.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:4ed29121da8b3fdc291002801a1de0f76248fa07dce89157a5f277842cf6126e", size = 1457164, upload-time = "2026-06-30T20:02:48.294Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4f/c0/36a6ffb4d653cf621427b4c4928671f53ad800c453474de2b82564a44ad9/ast_serialize-0.6.0-cp39-abi3-pyemscripten_2026_0_wasm32.whl", hash = "sha256:b1dac4e09d341c1300ba69cdcbe62867b32a8c75d90db9bf4d083bec3b039f0b", size = 863014, upload-time = "2026-06-30T20:02:49.742Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/09/c7/7d5ad8b49e1278e1c2a1e0274bd7850560b3f09313aa00c13bc8d5544792/ast_serialize-0.6.0-cp39-abi3-win32.whl", hash = "sha256:82c312a7844d2fdeb4d5c48bd3d215bf940dafd4704e1a9bcf252a99010a99b1", size = 1063165, upload-time = "2026-06-30T20:02:50.98Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/47/ae/6710c14ecb276031cf10249f6adf5a59e2d3fdb3b5183bd59f70524067ee/ast_serialize-0.6.0-cp39-abi3-win_amd64.whl", hash = "sha256:113b58346f9ceb664352032770caca817d4a3c86f611c6088e6ef65ddaa70f0e", size = 1101444, upload-time = "2026-06-30T20:02:52.554Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/66/40/c53deb2cd0c9b0fb636d24d9f40924cf2e65028e6b20b10cd5c1eeb2c730/ast_serialize-0.6.0-cp39-abi3-win_arm64.whl", hash = "sha256:ccd132fe8db56f61fe743b1f644d01b8d65b83248a8da506f3132bda86d6ed5e", size = 1072965, upload-time = "2026-06-30T20:02:54.097Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "attrs"
|
||||
version = "26.1.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/9a/8e/82a0fe20a541c03148528be8cac2408564a6c9a0cc7e9171802bc1d26985/attrs-26.1.0.tar.gz", hash = "sha256:d03ceb89cb322a8fd706d4fb91940737b6642aa36998fe130a9bc96c985eff32", size = 952055, upload-time = "2026-03-19T14:22:25.026Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/64/b4/17d4b0b2a2dc85a6df63d1157e028ed19f90d4cd97c36717afef2bc2f395/attrs-26.1.0-py3-none-any.whl", hash = "sha256:c647aa4a12dfbad9333ca4e71fe62ddc36f4e63b2d260a37a8b83d2f043ac309", size = 67548, upload-time = "2026-03-19T14:22:23.645Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "frozenlist"
|
||||
version = "1.8.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/2d/f5/c831fac6cc817d26fd54c7eaccd04ef7e0288806943f7cc5bbf69f3ac1f0/frozenlist-1.8.0.tar.gz", hash = "sha256:3ede829ed8d842f6cd48fc7081d7a41001a56f1f38603f9d49bf3020d59a31ad", size = 45875, upload-time = "2025-10-06T05:38:17.865Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/69/29/948b9aa87e75820a38650af445d2ef2b6b8a6fab1a23b6bb9e4ef0be2d59/frozenlist-1.8.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:78f7b9e5d6f2fdb88cdde9440dc147259b62b9d3b019924def9f6478be254ac1", size = 87782, upload-time = "2025-10-06T05:36:06.649Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/64/80/4f6e318ee2a7c0750ed724fa33a4bdf1eacdc5a39a7a24e818a773cd91af/frozenlist-1.8.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:229bf37d2e4acdaf808fd3f06e854a4a7a3661e871b10dc1f8f1896a3b05f18b", size = 50594, upload-time = "2025-10-06T05:36:07.69Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2b/94/5c8a2b50a496b11dd519f4a24cb5496cf125681dd99e94c604ccdea9419a/frozenlist-1.8.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f833670942247a14eafbb675458b4e61c82e002a148f49e68257b79296e865c4", size = 50448, upload-time = "2025-10-06T05:36:08.78Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6a/bd/d91c5e39f490a49df14320f4e8c80161cfcce09f1e2cde1edd16a551abb3/frozenlist-1.8.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:494a5952b1c597ba44e0e78113a7266e656b9794eec897b19ead706bd7074383", size = 242411, upload-time = "2025-10-06T05:36:09.801Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8f/83/f61505a05109ef3293dfb1ff594d13d64a2324ac3482be2cedc2be818256/frozenlist-1.8.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:96f423a119f4777a4a056b66ce11527366a8bb92f54e541ade21f2374433f6d4", size = 243014, upload-time = "2025-10-06T05:36:11.394Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d8/cb/cb6c7b0f7d4023ddda30cf56b8b17494eb3a79e3fda666bf735f63118b35/frozenlist-1.8.0-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3462dd9475af2025c31cc61be6652dfa25cbfb56cbbf52f4ccfe029f38decaf8", size = 234909, upload-time = "2025-10-06T05:36:12.598Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/31/c5/cd7a1f3b8b34af009fb17d4123c5a778b44ae2804e3ad6b86204255f9ec5/frozenlist-1.8.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c4c800524c9cd9bac5166cd6f55285957fcfc907db323e193f2afcd4d9abd69b", size = 250049, upload-time = "2025-10-06T05:36:14.065Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c0/01/2f95d3b416c584a1e7f0e1d6d31998c4a795f7544069ee2e0962a4b60740/frozenlist-1.8.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d6a5df73acd3399d893dafc71663ad22534b5aa4f94e8a2fabfe856c3c1b6a52", size = 256485, upload-time = "2025-10-06T05:36:15.39Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ce/03/024bf7720b3abaebcff6d0793d73c154237b85bdf67b7ed55e5e9596dc9a/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:405e8fe955c2280ce66428b3ca55e12b3c4e9c336fb2103a4937e891c69a4a29", size = 237619, upload-time = "2025-10-06T05:36:16.558Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/69/fa/f8abdfe7d76b731f5d8bd217827cf6764d4f1d9763407e42717b4bed50a0/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:908bd3f6439f2fef9e85031b59fd4f1297af54415fb60e4254a95f75b3cab3f3", size = 250320, upload-time = "2025-10-06T05:36:17.821Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f5/3c/b051329f718b463b22613e269ad72138cc256c540f78a6de89452803a47d/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:294e487f9ec720bd8ffcebc99d575f7eff3568a08a253d1ee1a0378754b74143", size = 246820, upload-time = "2025-10-06T05:36:19.046Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0f/ae/58282e8f98e444b3f4dd42448ff36fa38bef29e40d40f330b22e7108f565/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:74c51543498289c0c43656701be6b077f4b265868fa7f8a8859c197006efb608", size = 250518, upload-time = "2025-10-06T05:36:20.763Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8f/96/007e5944694d66123183845a106547a15944fbbb7154788cbf7272789536/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:776f352e8329135506a1d6bf16ac3f87bc25b28e765949282dcc627af36123aa", size = 239096, upload-time = "2025-10-06T05:36:22.129Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/66/bb/852b9d6db2fa40be96f29c0d1205c306288f0684df8fd26ca1951d461a56/frozenlist-1.8.0-cp312-cp312-win32.whl", hash = "sha256:433403ae80709741ce34038da08511d4a77062aa924baf411ef73d1146e74faf", size = 39985, upload-time = "2025-10-06T05:36:23.661Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b8/af/38e51a553dd66eb064cdf193841f16f077585d4d28394c2fa6235cb41765/frozenlist-1.8.0-cp312-cp312-win_amd64.whl", hash = "sha256:34187385b08f866104f0c0617404c8eb08165ab1272e884abc89c112e9c00746", size = 44591, upload-time = "2025-10-06T05:36:24.958Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a7/06/1dc65480ab147339fecc70797e9c2f69d9cea9cf38934ce08df070fdb9cb/frozenlist-1.8.0-cp312-cp312-win_arm64.whl", hash = "sha256:fe3c58d2f5db5fbd18c2987cba06d51b0529f52bc3a6cdc33d3f4eab725104bd", size = 40102, upload-time = "2025-10-06T05:36:26.333Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2d/40/0832c31a37d60f60ed79e9dfb5a92e1e2af4f40a16a29abcc7992af9edff/frozenlist-1.8.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:8d92f1a84bb12d9e56f818b3a746f3efba93c1b63c8387a73dde655e1e42282a", size = 85717, upload-time = "2025-10-06T05:36:27.341Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/30/ba/b0b3de23f40bc55a7057bd38434e25c34fa48e17f20ee273bbde5e0650f3/frozenlist-1.8.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:96153e77a591c8adc2ee805756c61f59fef4cf4073a9275ee86fe8cba41241f7", size = 49651, upload-time = "2025-10-06T05:36:28.855Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0c/ab/6e5080ee374f875296c4243c381bbdef97a9ac39c6e3ce1d5f7d42cb78d6/frozenlist-1.8.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f21f00a91358803399890ab167098c131ec2ddd5f8f5fd5fe9c9f2c6fcd91e40", size = 49417, upload-time = "2025-10-06T05:36:29.877Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d5/4e/e4691508f9477ce67da2015d8c00acd751e6287739123113a9fca6f1604e/frozenlist-1.8.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:fb30f9626572a76dfe4293c7194a09fb1fe93ba94c7d4f720dfae3b646b45027", size = 234391, upload-time = "2025-10-06T05:36:31.301Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/40/76/c202df58e3acdf12969a7895fd6f3bc016c642e6726aa63bd3025e0fc71c/frozenlist-1.8.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:eaa352d7047a31d87dafcacbabe89df0aa506abb5b1b85a2fb91bc3faa02d822", size = 233048, upload-time = "2025-10-06T05:36:32.531Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f9/c0/8746afb90f17b73ca5979c7a3958116e105ff796e718575175319b5bb4ce/frozenlist-1.8.0-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:03ae967b4e297f58f8c774c7eabcce57fe3c2434817d4385c50661845a058121", size = 226549, upload-time = "2025-10-06T05:36:33.706Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7e/eb/4c7eefc718ff72f9b6c4893291abaae5fbc0c82226a32dcd8ef4f7a5dbef/frozenlist-1.8.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f6292f1de555ffcc675941d65fffffb0a5bcd992905015f85d0592201793e0e5", size = 239833, upload-time = "2025-10-06T05:36:34.947Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c2/4e/e5c02187cf704224f8b21bee886f3d713ca379535f16893233b9d672ea71/frozenlist-1.8.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:29548f9b5b5e3460ce7378144c3010363d8035cea44bc0bf02d57f5a685e084e", size = 245363, upload-time = "2025-10-06T05:36:36.534Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1f/96/cb85ec608464472e82ad37a17f844889c36100eed57bea094518bf270692/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ec3cc8c5d4084591b4237c0a272cc4f50a5b03396a47d9caaf76f5d7b38a4f11", size = 229314, upload-time = "2025-10-06T05:36:38.582Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5d/6f/4ae69c550e4cee66b57887daeebe006fe985917c01d0fff9caab9883f6d0/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:517279f58009d0b1f2e7c1b130b377a349405da3f7621ed6bfae50b10adf20c1", size = 243365, upload-time = "2025-10-06T05:36:40.152Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7a/58/afd56de246cf11780a40a2c28dc7cbabbf06337cc8ddb1c780a2d97e88d8/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:db1e72ede2d0d7ccb213f218df6a078a9c09a7de257c2fe8fcef16d5925230b1", size = 237763, upload-time = "2025-10-06T05:36:41.355Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cb/36/cdfaf6ed42e2644740d4a10452d8e97fa1c062e2a8006e4b09f1b5fd7d63/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:b4dec9482a65c54a5044486847b8a66bf10c9cb4926d42927ec4e8fd5db7fed8", size = 240110, upload-time = "2025-10-06T05:36:42.716Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/03/a8/9ea226fbefad669f11b52e864c55f0bd57d3c8d7eb07e9f2e9a0b39502e1/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:21900c48ae04d13d416f0e1e0c4d81f7931f73a9dfa0b7a8746fb2fe7dd970ed", size = 233717, upload-time = "2025-10-06T05:36:44.251Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1e/0b/1b5531611e83ba7d13ccc9988967ea1b51186af64c42b7a7af465dcc9568/frozenlist-1.8.0-cp313-cp313-win32.whl", hash = "sha256:8b7b94a067d1c504ee0b16def57ad5738701e4ba10cec90529f13fa03c833496", size = 39628, upload-time = "2025-10-06T05:36:45.423Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d8/cf/174c91dbc9cc49bc7b7aab74d8b734e974d1faa8f191c74af9b7e80848e6/frozenlist-1.8.0-cp313-cp313-win_amd64.whl", hash = "sha256:878be833caa6a3821caf85eb39c5ba92d28e85df26d57afb06b35b2efd937231", size = 43882, upload-time = "2025-10-06T05:36:46.796Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c1/17/502cd212cbfa96eb1388614fe39a3fc9ab87dbbe042b66f97acb57474834/frozenlist-1.8.0-cp313-cp313-win_arm64.whl", hash = "sha256:44389d135b3ff43ba8cc89ff7f51f5a0bb6b63d829c8300f79a2fe4fe61bcc62", size = 39676, upload-time = "2025-10-06T05:36:47.8Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d2/5c/3bbfaa920dfab09e76946a5d2833a7cbdf7b9b4a91c714666ac4855b88b4/frozenlist-1.8.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:e25ac20a2ef37e91c1b39938b591457666a0fa835c7783c3a8f33ea42870db94", size = 89235, upload-time = "2025-10-06T05:36:48.78Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d2/d6/f03961ef72166cec1687e84e8925838442b615bd0b8854b54923ce5b7b8a/frozenlist-1.8.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:07cdca25a91a4386d2e76ad992916a85038a9b97561bf7a3fd12d5d9ce31870c", size = 50742, upload-time = "2025-10-06T05:36:49.837Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1e/bb/a6d12b7ba4c3337667d0e421f7181c82dda448ce4e7ad7ecd249a16fa806/frozenlist-1.8.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:4e0c11f2cc6717e0a741f84a527c52616140741cd812a50422f83dc31749fb52", size = 51725, upload-time = "2025-10-06T05:36:50.851Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bc/71/d1fed0ffe2c2ccd70b43714c6cab0f4188f09f8a67a7914a6b46ee30f274/frozenlist-1.8.0-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b3210649ee28062ea6099cfda39e147fa1bc039583c8ee4481cb7811e2448c51", size = 284533, upload-time = "2025-10-06T05:36:51.898Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c9/1f/fb1685a7b009d89f9bf78a42d94461bc06581f6e718c39344754a5d9bada/frozenlist-1.8.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:581ef5194c48035a7de2aefc72ac6539823bb71508189e5de01d60c9dcd5fa65", size = 292506, upload-time = "2025-10-06T05:36:53.101Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e6/3b/b991fe1612703f7e0d05c0cf734c1b77aaf7c7d321df4572e8d36e7048c8/frozenlist-1.8.0-cp313-cp313t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3ef2d026f16a2b1866e1d86fc4e1291e1ed8a387b2c333809419a2f8b3a77b82", size = 274161, upload-time = "2025-10-06T05:36:54.309Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ca/ec/c5c618767bcdf66e88945ec0157d7f6c4a1322f1473392319b7a2501ded7/frozenlist-1.8.0-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5500ef82073f599ac84d888e3a8c1f77ac831183244bfd7f11eaa0289fb30714", size = 294676, upload-time = "2025-10-06T05:36:55.566Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7c/ce/3934758637d8f8a88d11f0585d6495ef54b2044ed6ec84492a91fa3b27aa/frozenlist-1.8.0-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:50066c3997d0091c411a66e710f4e11752251e6d2d73d70d8d5d4c76442a199d", size = 300638, upload-time = "2025-10-06T05:36:56.758Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fc/4f/a7e4d0d467298f42de4b41cbc7ddaf19d3cfeabaf9ff97c20c6c7ee409f9/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:5c1c8e78426e59b3f8005e9b19f6ff46e5845895adbde20ece9218319eca6506", size = 283067, upload-time = "2025-10-06T05:36:57.965Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/dc/48/c7b163063d55a83772b268e6d1affb960771b0e203b632cfe09522d67ea5/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:eefdba20de0d938cec6a89bd4d70f346a03108a19b9df4248d3cf0d88f1b0f51", size = 292101, upload-time = "2025-10-06T05:36:59.237Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9f/d0/2366d3c4ecdc2fd391e0afa6e11500bfba0ea772764d631bbf82f0136c9d/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:cf253e0e1c3ceb4aaff6df637ce033ff6535fb8c70a764a8f46aafd3d6ab798e", size = 289901, upload-time = "2025-10-06T05:37:00.811Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b8/94/daff920e82c1b70e3618a2ac39fbc01ae3e2ff6124e80739ce5d71c9b920/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:032efa2674356903cd0261c4317a561a6850f3ac864a63fc1583147fb05a79b0", size = 289395, upload-time = "2025-10-06T05:37:02.115Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e3/20/bba307ab4235a09fdcd3cc5508dbabd17c4634a1af4b96e0f69bfe551ebd/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:6da155091429aeba16851ecb10a9104a108bcd32f6c1642867eadaee401c1c41", size = 283659, upload-time = "2025-10-06T05:37:03.711Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fd/00/04ca1c3a7a124b6de4f8a9a17cc2fcad138b4608e7a3fc5877804b8715d7/frozenlist-1.8.0-cp313-cp313t-win32.whl", hash = "sha256:0f96534f8bfebc1a394209427d0f8a63d343c9779cda6fc25e8e121b5fd8555b", size = 43492, upload-time = "2025-10-06T05:37:04.915Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/59/5e/c69f733a86a94ab10f68e496dc6b7e8bc078ebb415281d5698313e3af3a1/frozenlist-1.8.0-cp313-cp313t-win_amd64.whl", hash = "sha256:5d63a068f978fc69421fb0e6eb91a9603187527c86b7cd3f534a5b77a592b888", size = 48034, upload-time = "2025-10-06T05:37:06.343Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/16/6c/be9d79775d8abe79b05fa6d23da99ad6e7763a1d080fbae7290b286093fd/frozenlist-1.8.0-cp313-cp313t-win_arm64.whl", hash = "sha256:bf0a7e10b077bf5fb9380ad3ae8ce20ef919a6ad93b4552896419ac7e1d8e042", size = 41749, upload-time = "2025-10-06T05:37:07.431Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f1/c8/85da824b7e7b9b6e7f7705b2ecaf9591ba6f79c1177f324c2735e41d36a2/frozenlist-1.8.0-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:cee686f1f4cadeb2136007ddedd0aaf928ab95216e7691c63e50a8ec066336d0", size = 86127, upload-time = "2025-10-06T05:37:08.438Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8e/e8/a1185e236ec66c20afd72399522f142c3724c785789255202d27ae992818/frozenlist-1.8.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:119fb2a1bd47307e899c2fac7f28e85b9a543864df47aa7ec9d3c1b4545f096f", size = 49698, upload-time = "2025-10-06T05:37:09.48Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a1/93/72b1736d68f03fda5fdf0f2180fb6caaae3894f1b854d006ac61ecc727ee/frozenlist-1.8.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:4970ece02dbc8c3a92fcc5228e36a3e933a01a999f7094ff7c23fbd2beeaa67c", size = 49749, upload-time = "2025-10-06T05:37:10.569Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a7/b2/fabede9fafd976b991e9f1b9c8c873ed86f202889b864756f240ce6dd855/frozenlist-1.8.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:cba69cb73723c3f329622e34bdbf5ce1f80c21c290ff04256cff1cd3c2036ed2", size = 231298, upload-time = "2025-10-06T05:37:11.993Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3a/3b/d9b1e0b0eed36e70477ffb8360c49c85c8ca8ef9700a4e6711f39a6e8b45/frozenlist-1.8.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:778a11b15673f6f1df23d9586f83c4846c471a8af693a22e066508b77d201ec8", size = 232015, upload-time = "2025-10-06T05:37:13.194Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/dc/94/be719d2766c1138148564a3960fc2c06eb688da592bdc25adcf856101be7/frozenlist-1.8.0-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:0325024fe97f94c41c08872db482cf8ac4800d80e79222c6b0b7b162d5b13686", size = 225038, upload-time = "2025-10-06T05:37:14.577Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e4/09/6712b6c5465f083f52f50cf74167b92d4ea2f50e46a9eea0523d658454ae/frozenlist-1.8.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:97260ff46b207a82a7567b581ab4190bd4dfa09f4db8a8b49d1a958f6aa4940e", size = 240130, upload-time = "2025-10-06T05:37:15.781Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f8/d4/cd065cdcf21550b54f3ce6a22e143ac9e4836ca42a0de1022da8498eac89/frozenlist-1.8.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:54b2077180eb7f83dd52c40b2750d0a9f175e06a42e3213ce047219de902717a", size = 242845, upload-time = "2025-10-06T05:37:17.037Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/62/c3/f57a5c8c70cd1ead3d5d5f776f89d33110b1addae0ab010ad774d9a44fb9/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:2f05983daecab868a31e1da44462873306d3cbfd76d1f0b5b69c473d21dbb128", size = 229131, upload-time = "2025-10-06T05:37:18.221Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6c/52/232476fe9cb64f0742f3fde2b7d26c1dac18b6d62071c74d4ded55e0ef94/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:33f48f51a446114bc5d251fb2954ab0164d5be02ad3382abcbfe07e2531d650f", size = 240542, upload-time = "2025-10-06T05:37:19.771Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5f/85/07bf3f5d0fb5414aee5f47d33c6f5c77bfe49aac680bfece33d4fdf6a246/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:154e55ec0655291b5dd1b8731c637ecdb50975a2ae70c606d100750a540082f7", size = 237308, upload-time = "2025-10-06T05:37:20.969Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/11/99/ae3a33d5befd41ac0ca2cc7fd3aa707c9c324de2e89db0e0f45db9a64c26/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:4314debad13beb564b708b4a496020e5306c7333fa9a3ab90374169a20ffab30", size = 238210, upload-time = "2025-10-06T05:37:22.252Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b2/60/b1d2da22f4970e7a155f0adde9b1435712ece01b3cd45ba63702aea33938/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:073f8bf8becba60aa931eb3bc420b217bb7d5b8f4750e6f8b3be7f3da85d38b7", size = 231972, upload-time = "2025-10-06T05:37:23.5Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3f/ab/945b2f32de889993b9c9133216c068b7fcf257d8595a0ac420ac8677cab0/frozenlist-1.8.0-cp314-cp314-win32.whl", hash = "sha256:bac9c42ba2ac65ddc115d930c78d24ab8d4f465fd3fc473cdedfccadb9429806", size = 40536, upload-time = "2025-10-06T05:37:25.581Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/59/ad/9caa9b9c836d9ad6f067157a531ac48b7d36499f5036d4141ce78c230b1b/frozenlist-1.8.0-cp314-cp314-win_amd64.whl", hash = "sha256:3e0761f4d1a44f1d1a47996511752cf3dcec5bbdd9cc2b4fe595caf97754b7a0", size = 44330, upload-time = "2025-10-06T05:37:26.928Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/82/13/e6950121764f2676f43534c555249f57030150260aee9dcf7d64efda11dd/frozenlist-1.8.0-cp314-cp314-win_arm64.whl", hash = "sha256:d1eaff1d00c7751b7c6662e9c5ba6eb2c17a2306ba5e2a37f24ddf3cc953402b", size = 40627, upload-time = "2025-10-06T05:37:28.075Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c0/c7/43200656ecc4e02d3f8bc248df68256cd9572b3f0017f0a0c4e93440ae23/frozenlist-1.8.0-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:d3bb933317c52d7ea5004a1c442eef86f426886fba134ef8cf4226ea6ee1821d", size = 89238, upload-time = "2025-10-06T05:37:29.373Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d1/29/55c5f0689b9c0fb765055629f472c0de484dcaf0acee2f7707266ae3583c/frozenlist-1.8.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:8009897cdef112072f93a0efdce29cd819e717fd2f649ee3016efd3cd885a7ed", size = 50738, upload-time = "2025-10-06T05:37:30.792Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ba/7d/b7282a445956506fa11da8c2db7d276adcbf2b17d8bb8407a47685263f90/frozenlist-1.8.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:2c5dcbbc55383e5883246d11fd179782a9d07a986c40f49abe89ddf865913930", size = 51739, upload-time = "2025-10-06T05:37:32.127Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/62/1c/3d8622e60d0b767a5510d1d3cf21065b9db874696a51ea6d7a43180a259c/frozenlist-1.8.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:39ecbc32f1390387d2aa4f5a995e465e9e2f79ba3adcac92d68e3e0afae6657c", size = 284186, upload-time = "2025-10-06T05:37:33.21Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2d/14/aa36d5f85a89679a85a1d44cd7a6657e0b1c75f61e7cad987b203d2daca8/frozenlist-1.8.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:92db2bf818d5cc8d9c1f1fc56b897662e24ea5adb36ad1f1d82875bd64e03c24", size = 292196, upload-time = "2025-10-06T05:37:36.107Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/05/23/6bde59eb55abd407d34f77d39a5126fb7b4f109a3f611d3929f14b700c66/frozenlist-1.8.0-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:2dc43a022e555de94c3b68a4ef0b11c4f747d12c024a520c7101709a2144fb37", size = 273830, upload-time = "2025-10-06T05:37:37.663Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d2/3f/22cff331bfad7a8afa616289000ba793347fcd7bc275f3b28ecea2a27909/frozenlist-1.8.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:cb89a7f2de3602cfed448095bab3f178399646ab7c61454315089787df07733a", size = 294289, upload-time = "2025-10-06T05:37:39.261Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a4/89/5b057c799de4838b6c69aa82b79705f2027615e01be996d2486a69ca99c4/frozenlist-1.8.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:33139dc858c580ea50e7e60a1b0ea003efa1fd42e6ec7fdbad78fff65fad2fd2", size = 300318, upload-time = "2025-10-06T05:37:43.213Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/30/de/2c22ab3eb2a8af6d69dc799e48455813bab3690c760de58e1bf43b36da3e/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:168c0969a329b416119507ba30b9ea13688fafffac1b7822802537569a1cb0ef", size = 282814, upload-time = "2025-10-06T05:37:45.337Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/59/f7/970141a6a8dbd7f556d94977858cfb36fa9b66e0892c6dd780d2219d8cd8/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:28bd570e8e189d7f7b001966435f9dac6718324b5be2990ac496cf1ea9ddb7fe", size = 291762, upload-time = "2025-10-06T05:37:46.657Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c1/15/ca1adae83a719f82df9116d66f5bb28bb95557b3951903d39135620ef157/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:b2a095d45c5d46e5e79ba1e5b9cb787f541a8dee0433836cea4b96a2c439dcd8", size = 289470, upload-time = "2025-10-06T05:37:47.946Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ac/83/dca6dc53bf657d371fbc88ddeb21b79891e747189c5de990b9dfff2ccba1/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:eab8145831a0d56ec9c4139b6c3e594c7a83c2c8be25d5bcf2d86136a532287a", size = 289042, upload-time = "2025-10-06T05:37:49.499Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/96/52/abddd34ca99be142f354398700536c5bd315880ed0a213812bc491cff5e4/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:974b28cf63cc99dfb2188d8d222bc6843656188164848c4f679e63dae4b0708e", size = 283148, upload-time = "2025-10-06T05:37:50.745Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/af/d3/76bd4ed4317e7119c2b7f57c3f6934aba26d277acc6309f873341640e21f/frozenlist-1.8.0-cp314-cp314t-win32.whl", hash = "sha256:342c97bf697ac5480c0a7ec73cd700ecfa5a8a40ac923bd035484616efecc2df", size = 44676, upload-time = "2025-10-06T05:37:52.222Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/89/76/c615883b7b521ead2944bb3480398cbb07e12b7b4e4d073d3752eb721558/frozenlist-1.8.0-cp314-cp314t-win_amd64.whl", hash = "sha256:06be8f67f39c8b1dc671f5d83aaefd3358ae5cdcf8314552c57e7ed3e6475bdd", size = 49451, upload-time = "2025-10-06T05:37:53.425Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e0/a3/5982da14e113d07b325230f95060e2169f5311b1017ea8af2a29b374c289/frozenlist-1.8.0-cp314-cp314t-win_arm64.whl", hash = "sha256:102e6314ca4da683dca92e3b1355490fed5f313b768500084fbe6371fddfdb79", size = 42507, upload-time = "2025-10-06T05:37:54.513Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9a/9a/e35b4a917281c0b8419d4207f4334c8e8c5dbf4f3f5f9ada73958d937dcc/frozenlist-1.8.0-py3-none-any.whl", hash = "sha256:0c18a16eab41e82c295618a77502e17b195883241c563b00f0aa5106fc4eaa0d", size = 13409, upload-time = "2025-10-06T05:38:16.721Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "idna"
|
||||
version = "3.18"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/cd/63/9496c57188a2ee585e0f1db071d75089a11e98aa86eb99d9d7618fc1edce/idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848", size = 196711, upload-time = "2026-06-02T14:34:07.794Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/1e/5e/d4e9f1a599fb8e573b7b87160658329fbf28d19eac2718f51fc3def3aa5a/idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2", size = 65455, upload-time = "2026-06-02T14:34:06.319Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "librt"
|
||||
version = "0.13.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/dc/2f/3908645ddddab7120b46295e541ead308109fa48dbec7d67d7a778870d60/librt-0.13.0.tar.gz", hash = "sha256:1d2a610c14ac0d0750ee0a3ab8548e83155258387891caaca04def4bf7289781", size = 211402, upload-time = "2026-07-08T12:26:29.834Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/f0/f4/b2933ddae222dac338476abb872641169a5cfed2c2bb5444a5b07b32b0c3/librt-0.13.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:30536798f4504c0fad0885b1d371b0539abb081e4570c9d7c641cb51141b49f0", size = 150990, upload-time = "2026-07-08T12:25:02.42Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/90/ef/db98f744ca50e6efc9c95c70ee49b77aefac31f6a3fc7c83754a42d6a74f/librt-0.13.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:93d24ebb82aa4420b1409c389e7857bc35bd0b668007ac8172427d5c73cc8cc5", size = 155238, upload-time = "2026-07-08T12:25:03.681Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/03/e7/a197e7bc72baf2c61ce7fdc6906a5054dc05bd8da0819aa894e4857bf87e/librt-0.13.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cb8a1adce42d8b75485a5d56a9623a50bcab995b6079f1dac59fc44034dd93d9", size = 503073, upload-time = "2026-07-08T12:25:05.049Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f8/e7/7887712e27da7c1ab80fcabb1de6eb24243964f6557cae530d4b70706dbd/librt-0.13.0-cp312-cp312-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:0763ca2ab66058174f9dee426dc64f5e0a89c24a7df8d3fe3f1836c04e25de4b", size = 496528, upload-time = "2026-07-08T12:25:06.26Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/94/f0/f2283385bb6b950b26a1410f4ce51ec27231e0b3a4b925c46366d218b198/librt-0.13.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b222493da6e7b6199db9bd79502436cf5a27da3c1f7fa83c7e285444fc93fd03", size = 531786, upload-time = "2026-07-08T12:25:07.658Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/36/11/69ac3b54766ffba5fd7e5acebfb048d66dbe1f9f2d14516c2b3edc59cf87/librt-0.13.0-cp312-cp312-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:fadc63331f4388c3dc90090448f682a7e9feafc11481391c1e94f2f907a3976e", size = 524393, upload-time = "2026-07-08T12:25:09.121Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/61/5f/d72f95fd444a926a3c14b4e24979474116988dd57a45be242077c45d3c22/librt-0.13.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:70d9c62a4cffd9f23396cd5ef93fc5d11b31596b9b7d6306074abe3d5fcf09bd", size = 543026, upload-time = "2026-07-08T12:25:10.459Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c4/08/dcd9993ad192737a004ba263d549f8ea605b326b952e7d6205c7d4170b76/librt-0.13.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:66c0e7e6b02a155576df2c77ec933a70b72da726e248c494abf690923e624348", size = 546829, upload-time = "2026-07-08T12:25:11.716Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/96/d5/6d9bb2f54e4109a956b7128836529653eb9d740f784bc47ed10a02c1000e/librt-0.13.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:ac04bcd3328eb91d99dfedf6a60d9c1f15d3434e6f6daf922f0420f7d90b85c7", size = 535700, upload-time = "2026-07-08T12:25:13.144Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8c/f2/10946922503858a359492fa27f13e86228bde702116a740ac7b3cd185f24/librt-0.13.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:db327e7271e653c32040b85ae6188059c924b57d7e1e29f935523fa017cd4e82", size = 573566, upload-time = "2026-07-08T12:25:14.336Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/48/a8/94f00e3c99479a18088af3685ea016c42f3c7d5d1964d8dbb40c08d7f1aa/librt-0.13.0-cp312-cp312-win32.whl", hash = "sha256:860bd1d8ba48456ce08feaf8d343a8aaeb2fa086f2bcaa2a923fa3f7a3ff9aa3", size = 106099, upload-time = "2026-07-08T12:25:16.159Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c9/7b/2da9c74c1ed25a89cc4e1c8e007ea2eb4a0f1fafa3e70d757fe3242c5c5c/librt-0.13.0-cp312-cp312-win_amd64.whl", hash = "sha256:e54a315caf843c8d77e388cadc56ea9ded569935ee2d2347d7ea94992e5aa6fa", size = 126934, upload-time = "2026-07-08T12:25:17.275Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d0/65/aead61bbf3b5358593f9d4779d2a0e88eaf6ec191a6342dde36dd1df6371/librt-0.13.0-cp312-cp312-win_arm64.whl", hash = "sha256:c718e99a0992127af84385378460db624103b559ab260435abcfe77a4e4ed1c1", size = 112236, upload-time = "2026-07-08T12:25:18.425Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/67/3b/18e7b63255297a2bdc9c25c8d6d4ca8eca9f63aceb1252c0f7427ac7099e/librt-0.13.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:a468951af16155824e88bdd8326ebe5bdb371f3ec0ac04642994b98201d914f3", size = 151027, upload-time = "2026-07-08T12:25:19.638Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4d/68/e2248452c00d1a03b45fee1752cdc8f790a476efd2402b75181da88a9e61/librt-0.13.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:ae01d8512cc17079e53425635327dbf3f7ff57a42c00dec348bf79791c56444c", size = 155152, upload-time = "2026-07-08T12:25:20.851Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0e/16/52b1c99bf19057a062aac39c900cbb81499f6f75d6c537c14463d247ba78/librt-0.13.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:32c26893cd085c1efe83219e78d866da23fb20a066101b8f68210004361d224c", size = 502499, upload-time = "2026-07-08T12:25:22.055Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9f/54/b811151805c795f55e0dedee6ec687b75f9982a8105d240ea3910737a77b/librt-0.13.0-cp313-cp313-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:5929da1981a46bcf4b28b1b9499905f0ff58e2419da402a048234e9783acbc4b", size = 496108, upload-time = "2026-07-08T12:25:23.296Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8f/f8/094d6b2bd93f3fdaa54db54cc788c4a365333bddad65ab02e04da0b1d004/librt-0.13.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:94b85d664d777bab6c0d709416cb42938251fda9e221b79e3a2215d85df5f4f9", size = 531576, upload-time = "2026-07-08T12:25:24.648Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2e/40/541733d5755824f968f7ec39d78ffbd75d145964157ae5e69a09ec6d7326/librt-0.13.0-cp313-cp313-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:531b2df3e9fe96b1fcf73a6d165921e4656be5f58d631d384ebce344298368db", size = 524390, upload-time = "2026-07-08T12:25:25.898Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c6/b5/255673cfdbf5ba663339d36cd863c897289ab4337577e19f9405ce059f36/librt-0.13.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:109b84a9edf69ad89dc1f66358659e14a031baca95e3e5b0060bd903ede8efd6", size = 543053, upload-time = "2026-07-08T12:25:27.436Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9e/11/ab5005e9c9850710f21e354201bf090646349d3fabf5f951eaf70235729e/librt-0.13.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:1304368a3e7ffc3e9db986796cc5326fdb5943a3567ecc137cff318e4240c0e7", size = 546387, upload-time = "2026-07-08T12:25:28.65Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a2/04/a5d7ce1d1df1afd15ca283dcdf7530ac073e12d69ae8c40879dda96f7868/librt-0.13.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:e4f9b472e7d308d94b62c801982065661158c6ed02790d6c7ddb4337cea0f9c1", size = 535970, upload-time = "2026-07-08T12:25:30.171Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5a/76/927e267a6daa290174ac281b23c9804c8829b042ade9c6f24a065f540958/librt-0.13.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9f836c37478f167a81200d8c8b2c920a22224564bed2c23d7aeec760965c367a", size = 573582, upload-time = "2026-07-08T12:25:31.507Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/10/24/b6c5213efe39c19f9e13605644d0cf063b4ddaa33ac2e45b088e23a70e2e/librt-0.13.0-cp313-cp313-pyemscripten_2025_0_wasm32.whl", hash = "sha256:4000d961ff9598ac6ea603c6c836a5ed49bc205ade5fc378b998dfe1e2c36628", size = 82189, upload-time = "2026-07-08T12:25:32.675Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4c/00/d29736be177a906ac0b84a5b04b4fbfa22c776dc2f366de4172b0f968c08/librt-0.13.0-cp313-cp313-win32.whl", hash = "sha256:79e44cff71750d299d61a678e49995b0d5935a9cda238c2574daeca3ba536927", size = 106193, upload-time = "2026-07-08T12:25:33.692Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c8/ac/aff6fb45393cb8912f39dfb156ef6b2d1cadb207ff465fc8f66141054be8/librt-0.13.0-cp313-cp313-win_amd64.whl", hash = "sha256:54dab44a847d5ad1acd05c8a83fe518ae685516ecf4d3f7cc6e3df2a66767650", size = 126962, upload-time = "2026-07-08T12:25:34.769Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d9/3a/d68cb2b334d53fd30fac81d3a489ce4ba0d9506f4df43fcf676b68352b19/librt-0.13.0-cp313-cp313-win_arm64.whl", hash = "sha256:d4cb6fbfdf874340ab5e51450753c0f817b6958a3621125ee695bbc3de866566", size = 112127, upload-time = "2026-07-08T12:25:35.981Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7b/66/f49ae0d592bd45b6941e9a8bafcb6a87cddcd501ee7874707e767f01b585/librt-0.13.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:25218d94b1d2cbc0ba1d8a3f9dc9af578d9646e5ed16443a70cde1dfdcce6d71", size = 149818, upload-time = "2026-07-08T12:25:37.203Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3d/50/51c76d74014d04fb95b6506d286808984b78a2f7a41039094e6b2194ac48/librt-0.13.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:f26629539d4893c2957a16c41bb058e1e135c1f150f6a2e25ed047f64cf3f5c6", size = 154071, upload-time = "2026-07-08T12:25:39.399Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b8/fe/f19b0f5f82d5a1f2da736586bc840abd00ce07d6388136ae80b7333883fc/librt-0.13.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a4517d47b2b8af26975a406fba7d314de9696d864252e0257c6ea90238cfe27f", size = 494168, upload-time = "2026-07-08T12:25:40.641Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/94/bc/b8550c75775127fd31a5f20e8775997f7b527ad661fc8ddccd7497c064f7/librt-0.13.0-cp314-cp314-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:f19e181de5b3a1148bb3420b8c4b0b0ea0fce6950099724ad151d6cea5acc180", size = 491054, upload-time = "2026-07-08T12:25:41.905Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/30/14/4d0204867623df3f33f86efd3d3692ba5e01321443f4d6eab35a22697618/librt-0.13.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:22034924f5b42d5a56371cf271771bfeaabf235a7a8b6264bef2d20013f786c6", size = 523006, upload-time = "2026-07-08T12:25:43.327Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/19/0a/c45fc9a260934696bace1ac5df1e148ac92bd71767aee3bf7cd7a4534f4c/librt-0.13.0-cp314-cp314-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c7897db4e95e22468bdda33d8e012ceacd0182abf001e6389d763f0def6286b9", size = 515058, upload-time = "2026-07-08T12:25:44.541Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/13/0a/50c5ce45b326854ef8fa6ae4c36cf5142e5c55315eaf9e51d0ae73ac4da3/librt-0.13.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:1ce61b3746545029d4f5c17d6bd74b676254ad98433086c846ffb5e8fa73f007", size = 534025, upload-time = "2026-07-08T12:25:45.825Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/89/2d/08c413c8f93fc13b8103624fce38e5caa86cd08cbbc8465870ab287af54b/librt-0.13.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:46c330e82565962c761dbce7941be2cff7db674ee807455a8d0cadc5f9b759b0", size = 540557, upload-time = "2026-07-08T12:25:47.059Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b3/c1/93af71fb4a364952210051811dd4e40174e79656b050c89cacac18af3330/librt-0.13.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:375f5af8f99cbaa99dd293af986e3d57caabc9ba81a5d3f021603764854197a1", size = 523201, upload-time = "2026-07-08T12:25:48.392Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c1/6e/9766f07b676a4889d9f8bc2864e9ba5fff165653143ef4dda7df6aa34d16/librt-0.13.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:9320d34c3376ae204b2cd176e8d4883a013934e0aef822f1aed9c536490c275d", size = 565740, upload-time = "2026-07-08T12:25:49.678Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a2/1e/664e3472ce2b6e10e9b83f29d4a36eb982ff6b5a169ae7567bba3a4c4ff5/librt-0.13.0-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:9af313c66157a69dc69ea0059a66961692250e0dc95af9c385a48ffb770a0d16", size = 81611, upload-time = "2026-07-08T12:25:50.857Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2f/d4/8582a4d65e2234673685e07309d02c230b28a85724eb0acbf13f019b7f6e/librt-0.13.0-cp314-cp314-win32.whl", hash = "sha256:f2a7253458e34f33543551394ae4fe104b497ec2a65ac266074de64c1df82e37", size = 100106, upload-time = "2026-07-08T12:25:52.03Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/63/ce/0cb99efe6086b46cd985dc26672166fae312a239690e75871f7fafbd3fc5/librt-0.13.0-cp314-cp314-win_amd64.whl", hash = "sha256:a3dfe4edf10e8ed7e55b026a8bfc2c2a8704218b659cd4bffdf604fab966dc39", size = 121209, upload-time = "2026-07-08T12:25:53.166Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/26/85/4f3ccb083a3c9b0d42e223acdb3c3f507953324a59cdcab4826e8e2e3b89/librt-0.13.0-cp314-cp314-win_arm64.whl", hash = "sha256:68a5faee4bba381cb93b5961f684a514cf0053cb92308ff9c792c2fea0b174c6", size = 106404, upload-time = "2026-07-08T12:25:54.253Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b2/77/333191499538c8e8189de7a4cba8e6f49ee949fd6d6e6324b21fd1522466/librt-0.13.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:a38fb81d8376dfa2f8963b265fec07637802b0d01e2a127c19c66cb070fb24f5", size = 159231, upload-time = "2026-07-08T12:25:55.432Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7a/9e/2aa83758f22c278b837a1d8025898434ce2b8bff36678d5330ecaef56dff/librt-0.13.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:d4c8d9bd5abce34b2e75edb3bf37ab0f34e49b1f915a40ae8468eb7c85bc5b46", size = 161300, upload-time = "2026-07-08T12:25:56.585Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bb/c0/86791e936553ca763d6b3c2fb4d31d596cd00e14fa631c283a40ba01559a/librt-0.13.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:387e2f1d27e89bffe0d3f520f0da0662c973fd607ca16c1808f8a5085419485e", size = 582056, upload-time = "2026-07-08T12:25:58.144Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a8/d3/a9ec15984a185e000c4d2a16ba28bd623124ad4c38a10974c7ff78e3a893/librt-0.13.0-cp314-cp314t-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:4f6db193d2e5e0ed60359b9a5a682cd67205d0d3b1e459a867dd4b5c4e7eaa7a", size = 562758, upload-time = "2026-07-08T12:25:59.544Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3c/af/dbe36b78b19c06a55097f99305e4ea9458e2273e6ae16a3cbecaad7ee978/librt-0.13.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0d38604854e8d22faadf683ec6c02bb0f886e2ba56ef981a1c36ee275f21ea22", size = 602095, upload-time = "2026-07-08T12:26:00.991Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2a/a8/2966891b4dd2830f5203fbee92ac2c4947653a2390ba73dfa44244fad025/librt-0.13.0-cp314-cp314t-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:371f7ce73026815dafd51c50ce38416e91428b28c4b2ec97cd39271164b0045c", size = 593452, upload-time = "2026-07-08T12:26:02.352Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/61/f5/4df8bfc8405ecf8c0d525b4d69636f694bdd8620b313ec8b76e54a5926cc/librt-0.13.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:3aaedf52171bee90860704c560bc798fe83b76247df47568e0197e9b13c735a0", size = 623729, upload-time = "2026-07-08T12:26:04.294Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d6/13/9ac202dffc8db06f75d06c08c2f9f6ff054be67d21272dcc078fa1cc0c57/librt-0.13.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:96bad8725a4f196a798366c25ce075d1f7543a4ec045ffc13e6a7ec095cdab04", size = 617077, upload-time = "2026-07-08T12:26:05.845Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6e/f0/ebe38610716aee5cb28efd95089bb90192096179802779381e1c5dcf239c/librt-0.13.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:6bf6a559ffe4a93bbea6cf31ddf01a7fd9ba342ef51f27beb178e318b74acd61", size = 599561, upload-time = "2026-07-08T12:26:07.21Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4f/5c/c2e72e236fff7abc716d5b1753b8b8cd3ea85ac46fe17d2e7c51d4e1c723/librt-0.13.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:301067672387902c55f94b51d5022304b36c966ea9fe1f21caab99a9bef487c9", size = 645511, upload-time = "2026-07-08T12:26:08.562Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0c/99/6203ce619dee940d6bfbe099ec3fe4be00a68e9d60f70abf906cf124fe66/librt-0.13.0-cp314-cp314t-win32.whl", hash = "sha256:5fdcf34f86de8fb66d7dc7589f96ba91c4aa46671200d400e6fd6f109a483f18", size = 104357, upload-time = "2026-07-08T12:26:09.828Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/52/dd/843b6314087c41657c7036d7914d8f294bdf9b580aa8513ea0588c8e9a3d/librt-0.13.0-cp314-cp314t-win_amd64.whl", hash = "sha256:260c33e92263fa629b4f6d3c51967a1c2158fe6c33237aaa3ebeac586b085259", size = 126998, upload-time = "2026-07-08T12:26:10.975Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5f/5d/3dcec2884ba1b0806d1408612555c38dd5d68e90156b59f75f6e36435c3a/librt-0.13.0-cp314-cp314t-win_arm64.whl", hash = "sha256:2f281549a4c52ac7bb97997f14353f8bd0e53a34ca0dad1c905cfd0b4a58ae99", size = 110771, upload-time = "2026-07-08T12:26:12.303Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "multidict"
|
||||
version = "6.7.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/1a/c2/c2d94cbe6ac1753f3fc980da97b3d930efe1da3af3c9f5125354436c073d/multidict-6.7.1.tar.gz", hash = "sha256:ec6652a1bee61c53a3e5776b6049172c53b6aaba34f18c9ad04f82712bac623d", size = 102010, upload-time = "2026-01-26T02:46:45.979Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/8d/9c/f20e0e2cf80e4b2e4b1c365bf5fe104ee633c751a724246262db8f1a0b13/multidict-6.7.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:a90f75c956e32891a4eda3639ce6dd86e87105271f43d43442a3aedf3cddf172", size = 76893, upload-time = "2026-01-26T02:43:52.754Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fe/cf/18ef143a81610136d3da8193da9d80bfe1cb548a1e2d1c775f26b23d024a/multidict-6.7.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:3fccb473e87eaa1382689053e4a4618e7ba7b9b9b8d6adf2027ee474597128cd", size = 45456, upload-time = "2026-01-26T02:43:53.893Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a9/65/1caac9d4cd32e8433908683446eebc953e82d22b03d10d41a5f0fefe991b/multidict-6.7.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:b0fa96985700739c4c7853a43c0b3e169360d6855780021bfc6d0f1ce7c123e7", size = 43872, upload-time = "2026-01-26T02:43:55.041Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cf/3b/d6bd75dc4f3ff7c73766e04e705b00ed6dbbaccf670d9e05a12b006f5a21/multidict-6.7.1-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:cb2a55f408c3043e42b40cc8eecd575afa27b7e0b956dfb190de0f8499a57a53", size = 251018, upload-time = "2026-01-26T02:43:56.198Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fd/80/c959c5933adedb9ac15152e4067c702a808ea183a8b64cf8f31af8ad3155/multidict-6.7.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:eb0ce7b2a32d09892b3dd6cc44877a0d02a33241fafca5f25c8b6b62374f8b75", size = 258883, upload-time = "2026-01-26T02:43:57.499Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/86/85/7ed40adafea3d4f1c8b916e3b5cc3a8e07dfcdcb9cd72800f4ed3ca1b387/multidict-6.7.1-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:c3a32d23520ee37bf327d1e1a656fec76a2edd5c038bf43eddfa0572ec49c60b", size = 242413, upload-time = "2026-01-26T02:43:58.755Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d2/57/b8565ff533e48595503c785f8361ff9a4fde4d67de25c207cd0ba3befd03/multidict-6.7.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9c90fed18bffc0189ba814749fdcc102b536e83a9f738a9003e569acd540a733", size = 268404, upload-time = "2026-01-26T02:44:00.216Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e0/50/9810c5c29350f7258180dfdcb2e52783a0632862eb334c4896ac717cebcb/multidict-6.7.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:da62917e6076f512daccfbbde27f46fed1c98fee202f0559adec8ee0de67f71a", size = 269456, upload-time = "2026-01-26T02:44:02.202Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f3/8d/5e5be3ced1d12966fefb5c4ea3b2a5b480afcea36406559442c6e31d4a48/multidict-6.7.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bfde23ef6ed9db7eaee6c37dcec08524cb43903c60b285b172b6c094711b3961", size = 256322, upload-time = "2026-01-26T02:44:03.56Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/31/6e/d8a26d81ac166a5592782d208dd90dfdc0a7a218adaa52b45a672b46c122/multidict-6.7.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3758692429e4e32f1ba0df23219cd0b4fc0a52f476726fff9337d1a57676a582", size = 253955, upload-time = "2026-01-26T02:44:04.845Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/59/4c/7c672c8aad41534ba619bcd4ade7a0dc87ed6b8b5c06149b85d3dd03f0cd/multidict-6.7.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:398c1478926eca669f2fd6a5856b6de9c0acf23a2cb59a14c0ba5844fa38077e", size = 251254, upload-time = "2026-01-26T02:44:06.133Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7b/bd/84c24de512cbafbdbc39439f74e967f19570ce7924e3007174a29c348916/multidict-6.7.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:c102791b1c4f3ab36ce4101154549105a53dc828f016356b3e3bcae2e3a039d3", size = 252059, upload-time = "2026-01-26T02:44:07.518Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fa/ba/f5449385510825b73d01c2d4087bf6d2fccc20a2d42ac34df93191d3dd03/multidict-6.7.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:a088b62bd733e2ad12c50dad01b7d0166c30287c166e137433d3b410add807a6", size = 263588, upload-time = "2026-01-26T02:44:09.382Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d7/11/afc7c677f68f75c84a69fe37184f0f82fce13ce4b92f49f3db280b7e92b3/multidict-6.7.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:3d51ff4785d58d3f6c91bdbffcb5e1f7ddfda557727043aa20d20ec4f65e324a", size = 259642, upload-time = "2026-01-26T02:44:10.73Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2b/17/ebb9644da78c4ab36403739e0e6e0e30ebb135b9caf3440825001a0bddcb/multidict-6.7.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fc5907494fccf3e7d3f94f95c91d6336b092b5fc83811720fae5e2765890dfba", size = 251377, upload-time = "2026-01-26T02:44:12.042Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ca/a4/840f5b97339e27846c46307f2530a2805d9d537d8b8bd416af031cad7fa0/multidict-6.7.1-cp312-cp312-win32.whl", hash = "sha256:28ca5ce2fd9716631133d0e9a9b9a745ad7f60bac2bccafb56aa380fc0b6c511", size = 41887, upload-time = "2026-01-26T02:44:14.245Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/80/31/0b2517913687895f5904325c2069d6a3b78f66cc641a86a2baf75a05dcbb/multidict-6.7.1-cp312-cp312-win_amd64.whl", hash = "sha256:fcee94dfbd638784645b066074b338bc9cc155d4b4bffa4adce1615c5a426c19", size = 46053, upload-time = "2026-01-26T02:44:15.371Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0c/5b/aba28e4ee4006ae4c7df8d327d31025d760ffa992ea23812a601d226e682/multidict-6.7.1-cp312-cp312-win_arm64.whl", hash = "sha256:ba0a9fb644d0c1a2194cf7ffb043bd852cea63a57f66fbd33959f7dae18517bf", size = 43307, upload-time = "2026-01-26T02:44:16.852Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f2/22/929c141d6c0dba87d3e1d38fbdf1ba8baba86b7776469f2bc2d3227a1e67/multidict-6.7.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:2b41f5fed0ed563624f1c17630cb9941cf2309d4df00e494b551b5f3e3d67a23", size = 76174, upload-time = "2026-01-26T02:44:18.509Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c7/75/bc704ae15fee974f8fccd871305e254754167dce5f9e42d88a2def741a1d/multidict-6.7.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:84e61e3af5463c19b67ced91f6c634effb89ef8bfc5ca0267f954451ed4bb6a2", size = 45116, upload-time = "2026-01-26T02:44:19.745Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/79/76/55cd7186f498ed080a18440c9013011eb548f77ae1b297206d030eb1180a/multidict-6.7.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:935434b9853c7c112eee7ac891bc4cb86455aa631269ae35442cb316790c1445", size = 43524, upload-time = "2026-01-26T02:44:21.571Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e9/3c/414842ef8d5a1628d68edee29ba0e5bcf235dbfb3ccd3ea303a7fe8c72ff/multidict-6.7.1-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:432feb25a1cb67fe82a9680b4d65fb542e4635cb3166cd9c01560651ad60f177", size = 249368, upload-time = "2026-01-26T02:44:22.803Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f6/32/befed7f74c458b4a525e60519fe8d87eef72bb1e99924fa2b0f9d97a221e/multidict-6.7.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e82d14e3c948952a1a85503817e038cba5905a3352de76b9a465075d072fba23", size = 256952, upload-time = "2026-01-26T02:44:24.306Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/03/d6/c878a44ba877f366630c860fdf74bfb203c33778f12b6ac274936853c451/multidict-6.7.1-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:4cfb48c6ea66c83bcaaf7e4dfa7ec1b6bbcf751b7db85a328902796dfde4c060", size = 240317, upload-time = "2026-01-26T02:44:25.772Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/68/49/57421b4d7ad2e9e60e25922b08ceb37e077b90444bde6ead629095327a6f/multidict-6.7.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1d540e51b7e8e170174555edecddbd5538105443754539193e3e1061864d444d", size = 267132, upload-time = "2026-01-26T02:44:27.648Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b7/fe/ec0edd52ddbcea2a2e89e174f0206444a61440b40f39704e64dc807a70bd/multidict-6.7.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:273d23f4b40f3dce4d6c8a821c741a86dec62cded82e1175ba3d99be128147ed", size = 268140, upload-time = "2026-01-26T02:44:29.588Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b0/73/6e1b01cbeb458807aa0831742232dbdd1fa92bfa33f52a3f176b4ff3dc11/multidict-6.7.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9d624335fd4fa1c08a53f8b4be7676ebde19cd092b3895c421045ca87895b429", size = 254277, upload-time = "2026-01-26T02:44:30.902Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6a/b2/5fb8c124d7561a4974c342bc8c778b471ebbeb3cc17df696f034a7e9afe7/multidict-6.7.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:12fad252f8b267cc75b66e8fc51b3079604e8d43a75428ffe193cd9e2195dfd6", size = 252291, upload-time = "2026-01-26T02:44:32.31Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5a/96/51d4e4e06bcce92577fcd488e22600bd38e4fd59c20cb49434d054903bd2/multidict-6.7.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:03ede2a6ffbe8ef936b92cb4529f27f42be7f56afcdab5ab739cd5f27fb1cbf9", size = 250156, upload-time = "2026-01-26T02:44:33.734Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/db/6b/420e173eec5fba721a50e2a9f89eda89d9c98fded1124f8d5c675f7a0c0f/multidict-6.7.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:90efbcf47dbe33dcf643a1e400d67d59abeac5db07dc3f27d6bdeae497a2198c", size = 249742, upload-time = "2026-01-26T02:44:35.222Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/44/a3/ec5b5bd98f306bc2aa297b8c6f11a46714a56b1e6ef5ebda50a4f5d7c5fb/multidict-6.7.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:5c4b9bfc148f5a91be9244d6264c53035c8a0dcd2f51f1c3c6e30e30ebaa1c84", size = 262221, upload-time = "2026-01-26T02:44:36.604Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cd/f7/e8c0d0da0cd1e28d10e624604e1a36bcc3353aaebdfdc3a43c72bc683a12/multidict-6.7.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:401c5a650f3add2472d1d288c26deebc540f99e2fb83e9525007a74cd2116f1d", size = 258664, upload-time = "2026-01-26T02:44:38.008Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/52/da/151a44e8016dd33feed44f730bd856a66257c1ee7aed4f44b649fb7edeb3/multidict-6.7.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:97891f3b1b3ffbded884e2916cacf3c6fc87b66bb0dde46f7357404750559f33", size = 249490, upload-time = "2026-01-26T02:44:39.386Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/87/af/a3b86bf9630b732897f6fc3f4c4714b90aa4361983ccbdcd6c0339b21b0c/multidict-6.7.1-cp313-cp313-win32.whl", hash = "sha256:e1c5988359516095535c4301af38d8a8838534158f649c05dd1050222321bcb3", size = 41695, upload-time = "2026-01-26T02:44:41.318Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b2/35/e994121b0e90e46134673422dd564623f93304614f5d11886b1b3e06f503/multidict-6.7.1-cp313-cp313-win_amd64.whl", hash = "sha256:960c83bf01a95b12b08fd54324a4eb1d5b52c88932b5cba5d6e712bb3ed12eb5", size = 45884, upload-time = "2026-01-26T02:44:42.488Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ca/61/42d3e5dbf661242a69c97ea363f2d7b46c567da8eadef8890022be6e2ab0/multidict-6.7.1-cp313-cp313-win_arm64.whl", hash = "sha256:563fe25c678aaba333d5399408f5ec3c383ca5b663e7f774dd179a520b8144df", size = 43122, upload-time = "2026-01-26T02:44:43.664Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6d/b3/e6b21c6c4f314bb956016b0b3ef2162590a529b84cb831c257519e7fde44/multidict-6.7.1-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:c76c4bec1538375dad9d452d246ca5368ad6e1c9039dadcf007ae59c70619ea1", size = 83175, upload-time = "2026-01-26T02:44:44.894Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fb/76/23ecd2abfe0957b234f6c960f4ade497f55f2c16aeb684d4ecdbf1c95791/multidict-6.7.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:57b46b24b5d5ebcc978da4ec23a819a9402b4228b8a90d9c656422b4bdd8a963", size = 48460, upload-time = "2026-01-26T02:44:46.106Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c4/57/a0ed92b23f3a042c36bc4227b72b97eca803f5f1801c1ab77c8a212d455e/multidict-6.7.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:e954b24433c768ce78ab7929e84ccf3422e46deb45a4dc9f93438f8217fa2d34", size = 46930, upload-time = "2026-01-26T02:44:47.278Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b5/66/02ec7ace29162e447f6382c495dc95826bf931d3818799bbef11e8f7df1a/multidict-6.7.1-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:3bd231490fa7217cc832528e1cd8752a96f0125ddd2b5749390f7c3ec8721b65", size = 242582, upload-time = "2026-01-26T02:44:48.604Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/58/18/64f5a795e7677670e872673aca234162514696274597b3708b2c0d276cce/multidict-6.7.1-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:253282d70d67885a15c8a7716f3a73edf2d635793ceda8173b9ecc21f2fb8292", size = 250031, upload-time = "2026-01-26T02:44:50.544Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c8/ed/e192291dbbe51a8290c5686f482084d31bcd9d09af24f63358c3d42fd284/multidict-6.7.1-cp313-cp313t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:0b4c48648d7649c9335cf1927a8b87fa692de3dcb15faa676c6a6f1f1aabda43", size = 228596, upload-time = "2026-01-26T02:44:51.951Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1e/7e/3562a15a60cf747397e7f2180b0a11dc0c38d9175a650e75fa1b4d325e15/multidict-6.7.1-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:98bc624954ec4d2c7cb074b8eefc2b5d0ce7d482e410df446414355d158fe4ca", size = 257492, upload-time = "2026-01-26T02:44:53.902Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/24/02/7d0f9eae92b5249bb50ac1595b295f10e263dd0078ebb55115c31e0eaccd/multidict-6.7.1-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:1b99af4d9eec0b49927b4402bcbb58dea89d3e0db8806a4086117019939ad3dd", size = 255899, upload-time = "2026-01-26T02:44:55.316Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/00/e3/9b60ed9e23e64c73a5cde95269ef1330678e9c6e34dd4eb6b431b85b5a10/multidict-6.7.1-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6aac4f16b472d5b7dc6f66a0d49dd57b0e0902090be16594dc9ebfd3d17c47e7", size = 247970, upload-time = "2026-01-26T02:44:56.783Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3e/06/538e58a63ed5cfb0bd4517e346b91da32fde409d839720f664e9a4ae4f9d/multidict-6.7.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:21f830fe223215dffd51f538e78c172ed7c7f60c9b96a2bf05c4848ad49921c3", size = 245060, upload-time = "2026-01-26T02:44:58.195Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b2/2f/d743a3045a97c895d401e9bd29aaa09b94f5cbdf1bd561609e5a6c431c70/multidict-6.7.1-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:f5dd81c45b05518b9aa4da4aa74e1c93d715efa234fd3e8a179df611cc85e5f4", size = 235888, upload-time = "2026-01-26T02:44:59.57Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/38/83/5a325cac191ab28b63c52f14f1131f3b0a55ba3b9aa65a6d0bf2a9b921a0/multidict-6.7.1-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:eb304767bca2bb92fb9c5bd33cedc95baee5bb5f6c88e63706533a1c06ad08c8", size = 243554, upload-time = "2026-01-26T02:45:01.054Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/20/1f/9d2327086bd15da2725ef6aae624208e2ef828ed99892b17f60c344e57ed/multidict-6.7.1-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:c9035dde0f916702850ef66460bc4239d89d08df4d02023a5926e7446724212c", size = 252341, upload-time = "2026-01-26T02:45:02.484Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e8/2c/2a1aa0280cf579d0f6eed8ee5211c4f1730bd7e06c636ba2ee6aafda302e/multidict-6.7.1-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:af959b9beeb66c822380f222f0e0a1889331597e81f1ded7f374f3ecb0fd6c52", size = 246391, upload-time = "2026-01-26T02:45:03.862Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e5/03/7ca022ffc36c5a3f6e03b179a5ceb829be9da5783e6fe395f347c0794680/multidict-6.7.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:41f2952231456154ee479651491e94118229844dd7226541788be783be2b5108", size = 243422, upload-time = "2026-01-26T02:45:05.296Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/dc/1d/b31650eab6c5778aceed46ba735bd97f7c7d2f54b319fa916c0f96e7805b/multidict-6.7.1-cp313-cp313t-win32.whl", hash = "sha256:df9f19c28adcb40b6aae30bbaa1478c389efd50c28d541d76760199fc1037c32", size = 47770, upload-time = "2026-01-26T02:45:06.754Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ac/5b/2d2d1d522e51285bd61b1e20df8f47ae1a9d80839db0b24ea783b3832832/multidict-6.7.1-cp313-cp313t-win_amd64.whl", hash = "sha256:d54ecf9f301853f2c5e802da559604b3e95bb7a3b01a9c295c6ee591b9882de8", size = 53109, upload-time = "2026-01-26T02:45:08.044Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3d/a3/cc409ba012c83ca024a308516703cf339bdc4b696195644a7215a5164a24/multidict-6.7.1-cp313-cp313t-win_arm64.whl", hash = "sha256:5a37ca18e360377cfda1d62f5f382ff41f2b8c4ccb329ed974cc2e1643440118", size = 45573, upload-time = "2026-01-26T02:45:09.349Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/91/cc/db74228a8be41884a567e88a62fd589a913708fcf180d029898c17a9a371/multidict-6.7.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:8f333ec9c5eb1b7105e3b84b53141e66ca05a19a605368c55450b6ba208cb9ee", size = 75190, upload-time = "2026-01-26T02:45:10.651Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d5/22/492f2246bb5b534abd44804292e81eeaf835388901f0c574bac4eeec73c5/multidict-6.7.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:a407f13c188f804c759fc6a9f88286a565c242a76b27626594c133b82883b5c2", size = 44486, upload-time = "2026-01-26T02:45:11.938Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f1/4f/733c48f270565d78b4544f2baddc2fb2a245e5a8640254b12c36ac7ac68e/multidict-6.7.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:0e161ddf326db5577c3a4cc2d8648f81456e8a20d40415541587a71620d7a7d1", size = 43219, upload-time = "2026-01-26T02:45:14.346Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/24/bb/2c0c2287963f4259c85e8bcbba9182ced8d7fca65c780c38e99e61629d11/multidict-6.7.1-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:1e3a8bb24342a8201d178c3b4984c26ba81a577c80d4d525727427460a50c22d", size = 245132, upload-time = "2026-01-26T02:45:15.712Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a7/f9/44d4b3064c65079d2467888794dea218d1601898ac50222ab8a9a8094460/multidict-6.7.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:97231140a50f5d447d3164f994b86a0bed7cd016e2682f8650d6a9158e14fd31", size = 252420, upload-time = "2026-01-26T02:45:17.293Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8b/13/78f7275e73fa17b24c9a51b0bd9d73ba64bb32d0ed51b02a746eb876abe7/multidict-6.7.1-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:6b10359683bd8806a200fd2909e7c8ca3a7b24ec1d8132e483d58e791d881048", size = 233510, upload-time = "2026-01-26T02:45:19.356Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4b/25/8167187f62ae3cbd52da7893f58cb036b47ea3fb67138787c76800158982/multidict-6.7.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:283ddac99f7ac25a4acadbf004cb5ae34480bbeb063520f70ce397b281859362", size = 264094, upload-time = "2026-01-26T02:45:20.834Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a1/e7/69a3a83b7b030cf283fb06ce074a05a02322359783424d7edf0f15fe5022/multidict-6.7.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:538cec1e18c067d0e6103aa9a74f9e832904c957adc260e61cd9d8cf0c3b3d37", size = 260786, upload-time = "2026-01-26T02:45:22.818Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fe/3b/8ec5074bcfc450fe84273713b4b0a0dd47c0249358f5d82eb8104ffe2520/multidict-6.7.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7eee46ccb30ff48a1e35bb818cc90846c6be2b68240e42a78599166722cea709", size = 248483, upload-time = "2026-01-26T02:45:24.368Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/48/5a/d5a99e3acbca0e29c5d9cba8f92ceb15dce78bab963b308ae692981e3a5d/multidict-6.7.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:fa263a02f4f2dd2d11a7b1bb4362aa7cb1049f84a9235d31adf63f30143469a0", size = 248403, upload-time = "2026-01-26T02:45:25.982Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/35/48/e58cd31f6c7d5102f2a4bf89f96b9cf7e00b6c6f3d04ecc44417c00a5a3c/multidict-6.7.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:2e1425e2f99ec5bd36c15a01b690a1a2456209c5deed58f95469ffb46039ccbb", size = 240315, upload-time = "2026-01-26T02:45:27.487Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/94/33/1cd210229559cb90b6786c30676bb0c58249ff42f942765f88793b41fdce/multidict-6.7.1-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:497394b3239fc6f0e13a78a3e1b61296e72bf1c5f94b4c4eb80b265c37a131cd", size = 245528, upload-time = "2026-01-26T02:45:28.991Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/64/f2/6e1107d226278c876c783056b7db43d800bb64c6131cec9c8dfb6903698e/multidict-6.7.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:233b398c29d3f1b9676b4b6f75c518a06fcb2ea0b925119fb2c1bc35c05e1601", size = 258784, upload-time = "2026-01-26T02:45:30.503Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4d/c1/11f664f14d525e4a1b5327a82d4de61a1db604ab34c6603bb3c2cc63ad34/multidict-6.7.1-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:93b1818e4a6e0930454f0f2af7dfce69307ca03cdcfb3739bf4d91241967b6c1", size = 251980, upload-time = "2026-01-26T02:45:32.603Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e1/9f/75a9ac888121d0c5bbd4ecf4eead45668b1766f6baabfb3b7f66a410e231/multidict-6.7.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:f33dc2a3abe9249ea5d8360f969ec7f4142e7ac45ee7014d8f8d5acddf178b7b", size = 243602, upload-time = "2026-01-26T02:45:34.043Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9a/e7/50bf7b004cc8525d80dbbbedfdc7aed3e4c323810890be4413e589074032/multidict-6.7.1-cp314-cp314-win32.whl", hash = "sha256:3ab8b9d8b75aef9df299595d5388b14530839f6422333357af1339443cff777d", size = 40930, upload-time = "2026-01-26T02:45:36.278Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e0/bf/52f25716bbe93745595800f36fb17b73711f14da59ed0bb2eba141bc9f0f/multidict-6.7.1-cp314-cp314-win_amd64.whl", hash = "sha256:5e01429a929600e7dab7b166062d9bb54a5eed752384c7384c968c2afab8f50f", size = 45074, upload-time = "2026-01-26T02:45:37.546Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/97/ab/22803b03285fa3a525f48217963da3a65ae40f6a1b6f6cf2768879e208f9/multidict-6.7.1-cp314-cp314-win_arm64.whl", hash = "sha256:4885cb0e817aef5d00a2e8451d4665c1808378dc27c2705f1bf4ef8505c0d2e5", size = 42471, upload-time = "2026-01-26T02:45:38.889Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e0/6d/f9293baa6146ba9507e360ea0292b6422b016907c393e2f63fc40ab7b7b5/multidict-6.7.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:0458c978acd8e6ea53c81eefaddbbee9c6c5e591f41b3f5e8e194780fe026581", size = 82401, upload-time = "2026-01-26T02:45:40.254Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7a/68/53b5494738d83558d87c3c71a486504d8373421c3e0dbb6d0db48ad42ee0/multidict-6.7.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:c0abd12629b0af3cf590982c0b413b1e7395cd4ec026f30986818ab95bfaa94a", size = 48143, upload-time = "2026-01-26T02:45:41.635Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/37/e8/5284c53310dcdc99ce5d66563f6e5773531a9b9fe9ec7a615e9bc306b05f/multidict-6.7.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:14525a5f61d7d0c94b368a42cff4c9a4e7ba2d52e2672a7b23d84dc86fb02b0c", size = 46507, upload-time = "2026-01-26T02:45:42.99Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e4/fc/6800d0e5b3875568b4083ecf5f310dcf91d86d52573160834fb4bfcf5e4f/multidict-6.7.1-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:17307b22c217b4cf05033dabefe68255a534d637c6c9b0cc8382718f87be4262", size = 239358, upload-time = "2026-01-26T02:45:44.376Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/41/75/4ad0973179361cdf3a113905e6e088173198349131be2b390f9fa4da5fc6/multidict-6.7.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7a7e590ff876a3eaf1c02a4dfe0724b6e69a9e9de6d8f556816f29c496046e59", size = 246884, upload-time = "2026-01-26T02:45:47.167Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c3/9c/095bb28b5da139bd41fb9a5d5caff412584f377914bd8787c2aa98717130/multidict-6.7.1-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:5fa6a95dfee63893d80a34758cd0e0c118a30b8dcb46372bf75106c591b77889", size = 225878, upload-time = "2026-01-26T02:45:48.698Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/07/d0/c0a72000243756e8f5a277b6b514fa005f2c73d481b7d9e47cd4568aa2e4/multidict-6.7.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a0543217a6a017692aa6ae5cc39adb75e587af0f3a82288b1492eb73dd6cc2a4", size = 253542, upload-time = "2026-01-26T02:45:50.164Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c0/6b/f69da15289e384ecf2a68837ec8b5ad8c33e973aa18b266f50fe55f24b8c/multidict-6.7.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f99fe611c312b3c1c0ace793f92464d8cd263cc3b26b5721950d977b006b6c4d", size = 252403, upload-time = "2026-01-26T02:45:51.779Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a2/76/b9669547afa5a1a25cd93eaca91c0da1c095b06b6d2d8ec25b713588d3a1/multidict-6.7.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9004d8386d133b7e6135679424c91b0b854d2d164af6ea3f289f8f2761064609", size = 244889, upload-time = "2026-01-26T02:45:53.27Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7e/a9/a50d2669e506dad33cfc45b5d574a205587b7b8a5f426f2fbb2e90882588/multidict-6.7.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e628ef0e6859ffd8273c69412a2465c4be4a9517d07261b33334b5ec6f3c7489", size = 241982, upload-time = "2026-01-26T02:45:54.919Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c5/bb/1609558ad8b456b4827d3c5a5b775c93b87878fd3117ed3db3423dfbce1b/multidict-6.7.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:841189848ba629c3552035a6a7f5bf3b02eb304e9fea7492ca220a8eda6b0e5c", size = 232415, upload-time = "2026-01-26T02:45:56.981Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d8/59/6f61039d2aa9261871e03ab9dc058a550d240f25859b05b67fd70f80d4b3/multidict-6.7.1-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:ce1bbd7d780bb5a0da032e095c951f7014d6b0a205f8318308140f1a6aba159e", size = 240337, upload-time = "2026-01-26T02:45:58.698Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a1/29/fdc6a43c203890dc2ae9249971ecd0c41deaedfe00d25cb6564b2edd99eb/multidict-6.7.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:b26684587228afed0d50cf804cc71062cc9c1cdf55051c4c6345d372947b268c", size = 248788, upload-time = "2026-01-26T02:46:00.862Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a9/14/a153a06101323e4cf086ecee3faadba52ff71633d471f9685c42e3736163/multidict-6.7.1-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:9f9af11306994335398293f9958071019e3ab95e9a707dc1383a35613f6abcb9", size = 242842, upload-time = "2026-01-26T02:46:02.824Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/41/5f/604ae839e64a4a6efc80db94465348d3b328ee955e37acb24badbcd24d83/multidict-6.7.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:b4938326284c4f1224178a560987b6cf8b4d38458b113d9b8c1db1a836e640a2", size = 240237, upload-time = "2026-01-26T02:46:05.898Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5f/60/c3a5187bf66f6fb546ff4ab8fb5a077cbdd832d7b1908d4365c7f74a1917/multidict-6.7.1-cp314-cp314t-win32.whl", hash = "sha256:98655c737850c064a65e006a3df7c997cd3b220be4ec8fe26215760b9697d4d7", size = 48008, upload-time = "2026-01-26T02:46:07.468Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0c/f7/addf1087b860ac60e6f382240f64fb99f8bfb532bb06f7c542b83c29ca61/multidict-6.7.1-cp314-cp314t-win_amd64.whl", hash = "sha256:497bde6223c212ba11d462853cfa4f0ae6ef97465033e7dc9940cdb3ab5b48e5", size = 53542, upload-time = "2026-01-26T02:46:08.809Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4c/81/4629d0aa32302ef7b2ec65c75a728cc5ff4fa410c50096174c1632e70b3e/multidict-6.7.1-cp314-cp314t-win_arm64.whl", hash = "sha256:2bbd113e0d4af5db41d5ebfe9ccaff89de2120578164f86a5d17d5a576d1e5b2", size = 44719, upload-time = "2026-01-26T02:46:11.146Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/81/08/7036c080d7117f28a4af526d794aab6a84463126db031b007717c1a6676e/multidict-6.7.1-py3-none-any.whl", hash = "sha256:55d97cc6dae627efa6a6e548885712d4864b81110ac76fa4e534c03819fa4a56", size = 12319, upload-time = "2026-01-26T02:46:44.004Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "mypy"
|
||||
version = "2.3.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "ast-serialize" },
|
||||
{ name = "librt", marker = "platform_python_implementation != 'PyPy'" },
|
||||
{ name = "mypy-extensions" },
|
||||
{ name = "pathspec" },
|
||||
{ name = "typing-extensions" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/12/af/4e516a05d3ca2eb9283e9ec45b2c02225c1514dd6da49fd3c9eaa6639370/mypy-2.3.0.tar.gz", hash = "sha256:465965d41cd9a2726694e983e8ce7113259327bec798115d1e1dfa2a52fb666e", size = 3988104, upload-time = "2026-07-13T11:34:53.387Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/dc/94/0e7e592619e2133596a47cdd642534b0456545c218430bd3b9d8fefdd1b1/mypy-2.3.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:2d53fc67b9d28a43c6199077f49fea0f05839e36cf6158500331c9549225e5a5", size = 15026523, upload-time = "2026-07-13T11:34:49.206Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f6/d2/1e1731df090a857df2807177a4626863e5ac0f0256513c35780efe53986f/mypy-2.3.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fbc00cee7bdbb9291979ddc9d08034a29dfcda4932628c9bbc28c1edd589df0c", size = 14032189, upload-time = "2026-07-13T11:33:57.168Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/44/95/cab921f4a806e171f34113e6181dd23c55358ccf6a80741269ef594a410e/mypy-2.3.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:04e617030eca5221909c8b7d8d7fd1c637948199aa2100b2ad9813feb07e1491", size = 14198696, upload-time = "2026-07-13T11:32:12.767Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/66/80/e6d008bb19fe446e3662d85e0e2717bf9f2d611a2164fb29d6e067dbf46c/mypy-2.3.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:56c184d2c20ca6b6378d58d1960270a767f41f5e44acbbd27f05effef4f4e1d7", size = 15286904, upload-time = "2026-07-13T11:34:27.594Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/db/83/94397c9293608a364aa03e8084fb34ede4ae976a260384b9b52929308135/mypy-2.3.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:3961a4a34b05f7c74b0f05aa51fbfe99a2d1e126038df40318d15c8f558b7ef3", size = 15528342, upload-time = "2026-07-13T11:34:07.819Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cf/96/d8b37d819adec6cfccfb1fd3afc1735d94717ddeafb45536db9c6943e09b/mypy-2.3.0-cp312-cp312-win_amd64.whl", hash = "sha256:b1942b9314d4c784b8ea1dbab4972603290e5dd5630f06675f13aec97526bc4c", size = 11218346, upload-time = "2026-07-13T11:28:27.745Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2b/cd/cd9f725b19b19e5b530a154cf9bcf9e94279c5d55b3c34fb42b3aa48ea1b/mypy-2.3.0-cp312-cp312-win_arm64.whl", hash = "sha256:be51653d7669d7d7955d613b8d0bb57d5b652eaf71a873ddf65ac87254dd2595", size = 10204525, upload-time = "2026-07-13T11:31:02.552Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6e/ae/f7d056eb0294586a572d0d0d89580ec633c064db520f11d37d5a2fb833bd/mypy-2.3.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:91ad22a52ae2c7e621c2f67c94d5a17f66b3209a4cff5cf8a573579835c69e97", size = 14947298, upload-time = "2026-07-13T11:27:47.734Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/32/d5/db3e7af01e7844d21662c6ddc1f7825ec7cb4053f0391ac02faf3638396f/mypy-2.3.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:99ac767cc5d3b64c8d0ae226ead10c96694f94e4e7da1668642225dcd4e75aac", size = 13950768, upload-time = "2026-07-13T11:27:57.726Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d9/fb/43c031f0190513d1ec248ed037eceb742ddd2a4d74bbf406658a28173837/mypy-2.3.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:de6d2c484742a4d7b0ed6d07b143375624d3b899c5749c7b3c947f56261f48a6", size = 14151586, upload-time = "2026-07-13T11:29:18.615Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ec/c3/f8b2ffc60883084da91be51af58e88a7ffd4ff9795acb7d902ff88d31eb1/mypy-2.3.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7da939dd335cfd2ad788bdfd081c9f4e47634ab995e5a45eb15fd1e5bc052f8b", size = 15227411, upload-time = "2026-07-13T11:30:29.904Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/83/2e/16b917fc7adcf03f1aadddfc93aab804ffb234b1ab09c0ffd6d92a5d34a2/mypy-2.3.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:7247eb2824f996722a949530183394921ca71deb9680052a338cf53cff7925c2", size = 15478790, upload-time = "2026-07-13T11:33:14.686Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c0/88/aaa65a93c73d0cdae7e42f8adb302bf6885bb281302084f99d0290a35347/mypy-2.3.0-cp313-cp313-win_amd64.whl", hash = "sha256:75b0984bb3cbd76bb5c9291a8671f7ae66ca3b51c7584c358fc2e923259f0757", size = 11234919, upload-time = "2026-07-13T11:33:39.28Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/35/19/b40de63f1a80e63bc2d40f0679a6a8dbd34e95176c8122119bdf406aa552/mypy-2.3.0-cp313-cp313-win_arm64.whl", hash = "sha256:d78fcf900b59cb7e82cb7e3a235e31b462d9333d92285bd1e4952d355b8ffba1", size = 10201510, upload-time = "2026-07-13T11:31:52.619Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a4/58/fa0ae047da911f540284009b4f44b96fe09d83c076d7c103e9d645f46303/mypy-2.3.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:ea317b060ce83e26050f8f9e4d7d6bf44ed7597c8ff9990bccffbb9d1d8522db", size = 14941909, upload-time = "2026-07-13T11:32:34.332Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/15/14/2ba1d61452d7c2a7fe12741e8d374e52b183476b07aa7f9e2a0d02b0720a/mypy-2.3.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:094af99f92638aa92852326188b85a89e50f4a472f44827c03362228482f0762", size = 13967581, upload-time = "2026-07-13T11:30:00.587Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ed/5a/483fb9e5ffbbb1a28dccc7b0a13d141b17ac769b6c9f488c0a0c63698962/mypy-2.3.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:de121747278144fc9ae7caa2e978cf5df12aebc82933182f5b3b86081a30baef", size = 14168807, upload-time = "2026-07-13T11:28:48.6Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ae/77/70d7a10732063beb74ad713682cf871e88f5c5fa39bfc8beff8a524bf9cb/mypy-2.3.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:37fa4de896a84e2dc9200d91e614c22563b43d1a266789d4bbac7b22ebe6192b", size = 15200144, upload-time = "2026-07-13T11:31:25.283Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/56/72/766218ac783be4fdfcd699b90037b63017348a3e86fb2c1fbfb18302637d/mypy-2.3.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:f1b3a98dfd21058bc759bb3337d5d1f61d0fdf9f3cf9c00f4291790fb5427bff", size = 15460389, upload-time = "2026-07-13T11:29:29.077Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/38/4e/8a9db7411ecb8ec0cb1fd05dba432f28bafffcd38b4e887714a4a0506689/mypy-2.3.0-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:944c665d984157cb96a679dfb7a4a81dd1d36b24b9c284b699514e6e626b82d4", size = 7753664, upload-time = "2026-07-13T11:29:08.147Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/65/4c/c3f8bfd6ed0e5e38b5a244403b27f821d433443df5a15a278417c10a3a3c/mypy-2.3.0-cp314-cp314-win_amd64.whl", hash = "sha256:4359424140d985192c778c1ce2c114a10c1ca58a381ed79cfa70d37df94b299f", size = 11417237, upload-time = "2026-07-13T11:33:47.467Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3c/00/89a32eaf5ccf174bc4f90db0eaea5d70636c01b8d49f384bdab2e8834390/mypy-2.3.0-cp314-cp314-win_arm64.whl", hash = "sha256:3dd0bed92c4bdec57c42505b96416fb9e6a5aa7be84d2809bcd5f2ecec2860d7", size = 10389252, upload-time = "2026-07-13T11:31:43.81Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/31/56/104f93d69aa9f339b6b9d3b0a7faa699b8b466c942cf3ae86cc2a2ec0915/mypy-2.3.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:691fdc37132b1ae628d834f672e74de83462d9fb4aff621835767fb43a8dd373", size = 16385495, upload-time = "2026-07-13T11:29:49.818Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d2/03/f1d2123313f55efafdd27706960f43a771c62f1b68426c76043f3ab9ebf3/mypy-2.3.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:aec15d465d477558fd842757b487849007311cf3897849cdda0e3162ac0ac556", size = 15098155, upload-time = "2026-07-13T11:30:40.301Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e5/5d/d5f9200399b445e81726c4f23becee33f233aee81c72680b1ef3a258b641/mypy-2.3.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b352b7e49f5e6576009e8df730e1ff4f915cb565b851b396d2ffe2f5a6f5da88", size = 15514155, upload-time = "2026-07-13T11:34:38.569Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cd/ce/69977c555f08faa3190cfde44189b89dbd56861b1ab97aa18fc5f3a2e4a3/mypy-2.3.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1c6c6bf687b17f90dbfcad95b960d32eaa0154c00da45f03ab50bf8952e047fe", size = 16766351, upload-time = "2026-07-13T11:33:29.195Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bc/92/6648b6caa3ab9e00f9ac0c2a78307805f873dd48139b24a6f6f7c3667bbf/mypy-2.3.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:f4ed18f111bfe2d599bca7468e7f9251042c1c2118f762c8de2766a56d773c60", size = 17043490, upload-time = "2026-07-13T11:30:53.927Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7c/ab/0dc91d80f3f016634c68d451f294a97320fe903a9b6f90b9e57b3f7f1717/mypy-2.3.0-cp314-cp314t-win_amd64.whl", hash = "sha256:0b025a93cffb9781d231f232be07a17912f35f10a313c24f301c81e842870654", size = 12146869, upload-time = "2026-07-13T11:29:38.874Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/85/b5/4c964d02634ba81f4d1c84838e5c5b18ab06d13ed568960f5d6318495ccc/mypy-2.3.0-cp314-cp314t-win_arm64.whl", hash = "sha256:adebc76aab4f3495a88b41d48aa4aff0c03f2822501da76625afcca5975f19e5", size = 10965113, upload-time = "2026-07-13T11:28:07.056Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2c/fa/fdc54fe583ba3cafbcedfb70eeeaf03849f75b1827a07096c7bd996f582d/mypy-2.3.0-py3-none-any.whl", hash = "sha256:6b1cdb579446b60432432b2b2403a6201b4b475a004d7f488511c9ba177c9e88", size = 2753292, upload-time = "2026-07-13T11:33:18.48Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "mypy-extensions"
|
||||
version = "1.1.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/a2/6e/371856a3fb9d31ca8dac321cda606860fa4548858c0cc45d9d1d4ca2628b/mypy_extensions-1.1.0.tar.gz", hash = "sha256:52e68efc3284861e772bbcd66823fde5ae21fd2fdb51c62a211403730b916558", size = 6343, upload-time = "2025-04-22T14:54:24.164Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/79/7b/2c79738432f5c924bef5071f933bcc9efd0473bac3b4aa584a6f7c1c8df8/mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505", size = 4963, upload-time = "2025-04-22T14:54:22.983Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pathspec"
|
||||
version = "1.1.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/5a/82/42f767fc1c1143d6fd36efb827202a2d997a375e160a71eb2888a925aac1/pathspec-1.1.1.tar.gz", hash = "sha256:17db5ecd524104a120e173814c90367a96a98d07c45b2e10c2f3919fff91bf5a", size = 135180, upload-time = "2026-04-27T01:46:08.907Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/f1/d9/7fb5aa316bc299258e68c73ba3bddbc499654a07f151cba08f6153988714/pathspec-1.1.1-py3-none-any.whl", hash = "sha256:a00ce642f577bf7f473932318056212bc4f8bfdf53128c78bbd5af0b9b20b189", size = 57328, upload-time = "2026-04-27T01:46:07.06Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "prometheus-client"
|
||||
version = "0.26.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/52/73/f1334c29c2af4cd9dba6c7817e61b611bd0215e2eb5565c6064a4de18802/prometheus_client-0.26.0.tar.gz", hash = "sha256:04a91bcf94e2cf74a44a1a874d651a2e853ed354b6e822f3b7487751465d5c2b", size = 92910, upload-time = "2026-07-24T19:36:41.893Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/eb/a3/b69efbf4143b5b9859b977770bbbabcc2796b702fa69dc40271e45cd5a56/prometheus_client-0.26.0-py3-none-any.whl", hash = "sha256:fa93d06737aa02bacd05794768508bb97d2fbee28cb3bca04eaae92f0ca953d6", size = 64494, upload-time = "2026-07-24T19:36:40.854Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "propcache"
|
||||
version = "0.5.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/ec/44/c87281c333769159c50594f22610f77398a47ccbfbbf23074e744e86f87c/propcache-0.5.2.tar.gz", hash = "sha256:01c4fc7480cd0598bb4b57022df55b9ca296da7fc5a8760bd8451a7e63a7d427", size = 50208, upload-time = "2026-05-08T21:02:12.199Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/4a/cb/e27bc2b2737a0bb49962b275efa051e8f1c35a936df7d5139b6b658b7dc9/propcache-0.5.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:806719138ecd720339a12410fb9614ac9b2b2d3a5fdf8235d56981c36f4039ba", size = 95887, upload-time = "2026-05-08T21:00:11.277Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e6/13/b8ae04c59392f8d11c6cd9fb4011d1dc7c86b81225c770280300e259ffe1/propcache-0.5.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:db2b80ea58eab4f86b2beec3cc8b39e8ff9276ac20e96b7cce43c8ae84cd6b5a", size = 54654, upload-time = "2026-05-08T21:00:12.604Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2c/7d/49777a3e20b55863d4794384a38acd460c04157b0a00f8602b0d508b8431/propcache-0.5.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:e5cbfac9f61484f7e9f3597775500cd3ebe8274e9b050c38f9525c77c97520bf", size = 55190, upload-time = "2026-05-08T21:00:13.935Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/44/c7/085d0cd63062e84044e3f05797749c3f8e3938ff3aeb0eb2f69d43fafc91/propcache-0.5.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5dbc581d2814337da56222fab8dc5f161cd798a434e49bac27930aaef798e144", size = 59995, upload-time = "2026-05-08T21:00:15.526Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9c/42/32cf8e3009e92b2645cf1e944f701e8ea4e924dffde1ee26db860bcbf7e4/propcache-0.5.2-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:857187f381f88c8e2fa2fe56ab94879d011b883d5a2ee5a1b60a8cd2a06846d9", size = 63422, upload-time = "2026-05-08T21:00:16.824Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9e/1b/f112433f99fc979431b87a39ef169e3f8df070d99a72792c56d6937ac48b/propcache-0.5.2-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:178b4a2cdaac1818e2bf1c5a99b94383fa73ea5382e032a48dec07dc5668dc42", size = 64342, upload-time = "2026-05-08T21:00:18.362Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/14/15/5574111ae50dd6e879456888c0eadd4c5a869959775854e18e18a6b345f3/propcache-0.5.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6f328175a2cde1f0ff2c4ed8ce968b9dcfb55f3a7153f39e2957ed994da13476", size = 61639, upload-time = "2026-05-08T21:00:19.692Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cc/da/4d775080b1490c0ae604acda868bd71aabe3a89ed16f2aa4339eb8a283e7/propcache-0.5.2-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:5671d09a36b06d0fd4a3da0fccbcae360e9b1570924171a15e9e0997f0249fba", size = 61588, upload-time = "2026-05-08T21:00:21.155Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/04/ac/f076982cbe2195ee9cf32de5a1e46951d9fb399fc207f390562dd0fd8fb2/propcache-0.5.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:80168e2ebe4d3ec6599d10ad8f520304ae1cad9b6c5a95372aef1b66b7bfb53a", size = 60029, upload-time = "2026-05-08T21:00:22.713Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/70/60/189be62e0dd898dce3b331e1b8c7a543cd3a405ac0c81fe8ee8a9d5d77e1/propcache-0.5.2-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:45f11346f884bc47444f6e6647131055844134c3175b629f84952e2b5cd62b64", size = 56774, upload-time = "2026-05-08T21:00:24.001Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ea/9e/93377b9c7939c1ffae98f878dee955efadfd638078bc86dbc21f9d52f651/propcache-0.5.2-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:8e778ebd44ef4f66ed60a0416b06b489687db264a9c0b3620362f26489492913", size = 63532, upload-time = "2026-05-08T21:00:25.545Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/14/f9/590ef6cfb9b8028d516d287812ece32bb0bc5f11fbb9c8bf6b2e6313fec8/propcache-0.5.2-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:c0cb9ed24c8964e172768d455a38254c2dd8a552905729ce006cad3d3dda59b1", size = 61592, upload-time = "2026-05-08T21:00:27.186Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b4/5e/70958b3034c297a630bba2f17ca7abc2d5f39a803ad7e370ab79d1ecd022/propcache-0.5.2-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:1d1ad32d9d4355e2be65574fd0bfd3677e7066b009cd5b9b2dee8aa6a6393b33", size = 64788, upload-time = "2026-05-08T21:00:28.8Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/12/fd/77fe5936d8c3086ca9048f7f415f122ed82e53884a9ec193646b42deef06/propcache-0.5.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:c80f4ba3e8f00189165999a742ee526ebeccedf6c3f7beb0c7df821e9772435a", size = 62514, upload-time = "2026-05-08T21:00:30.098Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cf/74/66bd798b5b3be70aa1b391f5cc9d6a0a5532d7fd3b19ec0b213e72e6ad9d/propcache-0.5.2-cp312-cp312-win32.whl", hash = "sha256:8c7972d8f193740d9175f0998ab38717e6cd322d5935c5b0fef8c0d323fd9031", size = 39018, upload-time = "2026-05-08T21:00:31.622Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/61/7c/5c0d34aa3024694d6dcb9271cdbdd08c4e47c1c0ad95ec7e7bc74cdea145/propcache-0.5.2-cp312-cp312-win_amd64.whl", hash = "sha256:d9ee8826a7d47863a08ac44e1a5f611a462eefc3a194b492da242128bec75b42", size = 42322, upload-time = "2026-05-08T21:00:32.918Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4d/91/875812f1a3feb20ceba818ef39fbe4d92f1081e04ac815c822496d0d038b/propcache-0.5.2-cp312-cp312-win_arm64.whl", hash = "sha256:2800a4a8ead6b28cccd1ec54b59346f0def7922ee1c7598e8499c733cfbb7c84", size = 38172, upload-time = "2026-05-08T21:00:35.124Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c5/09/f049e45385503fe67db75a6b6186a7b9f0c3930366dc960522c312a825b1/propcache-0.5.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:099aaf4b4d1a02265b92a977edf00b5c4f63b3b17ac6de39b0d637c9cac0188a", size = 94457, upload-time = "2026-05-08T21:00:36.355Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6b/65/83d1d05655baf63113731bd5a1008435e14f8d1e5a06cbe4ec5b23ad7a31/propcache-0.5.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:68ce1c44c7a813a7f71ea04315a8c7b330b63db99d059a797a4651bb6f69f117", size = 53835, upload-time = "2026-05-08T21:00:38.072Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a9/12/a6ba6482bb5ea3260c000c9b20881c95fa11c6b30173715668259f844ed7/propcache-0.5.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:fc299c129490f55f254cd90be0deca4764e36e9a7c08b4aa588479a3bbed3098", size = 54545, upload-time = "2026-05-08T21:00:39.319Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a9/19/7fa086f5764c59ec8a8e157cd93aa8497acc00aba9dcdec56bfffb32602d/propcache-0.5.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a6ae2198be502c10f09b2516e7b5d019816924bc3183a43ce792a7bd6625e6f4", size = 59886, upload-time = "2026-05-08T21:00:40.621Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a1/e4/5d7663dc8235956c8f5281698a3af1d351d8820341ddd890f59d9a9127f2/propcache-0.5.2-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6041d31504dc1779d700e1edcfb08eea334b357620b06681a4eabb57a74e574e", size = 63261, upload-time = "2026-05-08T21:00:41.775Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4a/4a/15a03adee24d6350da4292caeac44c34c033d2afe5e87eb370f38854560f/propcache-0.5.2-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f7eabc04151c78a9f4d5bbb5f1faf571e4defeb4b585e0fe95b60ff2dbe4d3d7", size = 64184, upload-time = "2026-05-08T21:00:43.018Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8b/c6/979176efdaa3d239e36d503d5af63a0a773b36662ed8f52e5b6a6d9fd40e/propcache-0.5.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4db0ba63d693afd40d249bd93f842b5f144f8fcbb83de05660373bcf30517b1d", size = 61534, upload-time = "2026-05-08T21:00:44.507Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c8/22/63e8cd1bae4c2d2be6493b6b7d10566ddafad88137cfbc99964a1119853c/propcache-0.5.2-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1dbcf7675229b35d31abb6547d8ebc8c27a830ac3f9a794edff6254873ec7c0a", size = 61500, upload-time = "2026-05-08T21:00:45.796Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/60/5a/28e5d9acbac1cc9ccb67045e8c1b943aa8d79fdf39c93bd73cacd68008ea/propcache-0.5.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d310c013aad2c72f1c3f2f8dd3279d460a858c551f97aeb8c63e4693cca7b4d2", size = 59994, upload-time = "2026-05-08T21:00:47.093Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f3/40/db650677f554a95b9c01a7c9d93d629e93a15562f5deb4573c9ee136fed2/propcache-0.5.2-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:06187263ddad280d05b4d8a8b3bb7d164cbebd469236544a42e6d9b28ac6a4fa", size = 56884, upload-time = "2026-05-08T21:00:48.376Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/80/45/70b39b89516ff8b96bf732fa6fded8cef20f293cb1508690101c3c07ec51/propcache-0.5.2-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:3115559b8effafd63b142ea5ed53d63a16ea6469cbc63dce4ee194b42db5d853", size = 63464, upload-time = "2026-05-08T21:00:49.954Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f9/e2/fa59d3a89eac5534293124af4f1d0d0ada091ce4a0ab4610ce03fd2bdd8d/propcache-0.5.2-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:c60462af8e6dc30c35407c7237ea908d777b22862bbee27bc4699c0d8bcdc45a", size = 61588, upload-time = "2026-05-08T21:00:51.281Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0b/97/efb547a55c4bc7381cfb202d6a2239ac621045277bc1ea5dfd3a7f0516c0/propcache-0.5.2-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:40314bca9ac559716fe374094fc81c11dcc34b64fd6c585360f5775690505704", size = 64667, upload-time = "2026-05-08T21:00:52.602Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/92/56/f5c7d9b4b7595d5127da38974d791b2153f3d1eae6c674af3583ace92ad3/propcache-0.5.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:cfa21e036ce1e1db2be04ba3b85d2df1bb1702fa01932d984c5464c665228ff4", size = 62463, upload-time = "2026-05-08T21:00:54.303Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bd/3b/484a3a65fc9f9f60c41dcd17b428bace5389544e2c680994534a20755066/propcache-0.5.2-cp313-cp313-win32.whl", hash = "sha256:f156a3529f38063b6dbaf356e15602a7f95f8055b1295a438433a6386f10463d", size = 38621, upload-time = "2026-05-08T21:00:55.808Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1c/fd/3f0f10dba4dabad3bf53102be007abf55481067952bde0fdddff439e7c61/propcache-0.5.2-cp313-cp313-win_amd64.whl", hash = "sha256:dfed59d0a5aeb01e242e66ff0300bc4a265a7c05f612d30016f0b60b1017d757", size = 41649, upload-time = "2026-05-08T21:00:57.061Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/90/ec/6ce619cc32bb500a482f811f9cd509368b4e58e638d13f2c68f370d6b475/propcache-0.5.2-cp313-cp313-win_arm64.whl", hash = "sha256:ba338430e87ceb9c8f0cf754de38a9860560261e56c00376debd628698a7364f", size = 37636, upload-time = "2026-05-08T21:00:58.646Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1b/82/c1d268bbbf2ef981c5bf0fbbe746db617c66e3bcefe431a1aa8943fbe23a/propcache-0.5.2-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:a592f5f3da71c8691c788c13cb6734b6d17663d2e1cb8caddf0673d01ef8847d", size = 98872, upload-time = "2026-05-08T21:00:59.889Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f4/d4/52c871e73e864e6b34c0e2d58ac1ec5ccd149497ddc7ad2137ae98323a35/propcache-0.5.2-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:6a997d0489e9668a384fcfd5061b857aa5361de73191cac204d04b889cfbbafa", size = 56257, upload-time = "2026-05-08T21:01:01.195Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/67/f0/9b90ca2a210b3d09bcfcd96ecd0f55545c091535abce2a45de2775cfd357/propcache-0.5.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:10734b5484ea113152ee25a91dccedf81631791805d2c9ccb054958e51842c94", size = 56696, upload-time = "2026-05-08T21:01:02.941Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9d/0e/6e9d4ba07c8e56e21ddec1e75f12148142b21ca83a51871babce095334f4/propcache-0.5.2-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cafca7e56c12bb02ae16d283742bef25a61122e9dab2b5b3f2ccbe589ce32164", size = 62378, upload-time = "2026-05-08T21:01:04.475Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/65/19/c10badaa463dde8a27ce884f8ee2ec37e6035b7c9f5ff0c8f74f06f08dac/propcache-0.5.2-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f064f8d2b59177878b7615df1735cd8fe3462ed6be8c7b217d17a276489c2b7f", size = 65283, upload-time = "2026-05-08T21:01:05.959Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b0/b6/93bea99ca80e19cef6512a8580e5b7857bbe09422d9daa7fd4ef5723306c/propcache-0.5.2-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f78abfa8dfc32376fd1aacf597b2f2fbbe0ea751419aee718af5d4f82537ef8c", size = 66616, upload-time = "2026-05-08T21:01:07.228Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/83/e4/5c7462e50625f051f37fb38b8224f7639f667184bbd34424ec83819bb1b7/propcache-0.5.2-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f7467da8a9822bf1a55336f877340c5bcbd3c482afc43a99771169f74a26dedc", size = 63773, upload-time = "2026-05-08T21:01:08.514Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ca/b6/99238894047b13c823be25027e736626cd414a52a5e30d2c3347c2733529/propcache-0.5.2-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a6ddc6ac9e25de626c1f129c1b467d7ecd33ce2237d3fd0c4e429feef0a7ee1f", size = 63664, upload-time = "2026-05-08T21:01:09.874Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/85/1e/a3a1a63116a2b8edb415a8bb9a6f0c34bd03830b1e18e8ce2904e1dc1cf4/propcache-0.5.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:2f22cbbac9e26a8e864c0985ff1268d5d939d53d9d9411a9824279097e03a2cb", size = 62643, upload-time = "2026-05-08T21:01:11.132Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e4/03/893cf147de2fc6543c5eaa07ad833170e7e2a2385725bbebe8c0503723bb/propcache-0.5.2-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:fc76378c62a0f04d0cd82fbb1a2cd2d7e28fcb40d5873f28a6c44e388aaa2751", size = 59595, upload-time = "2026-05-08T21:01:12.387Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/86/3b/04c1a2e12c57766568ba75ba72b3bf2042818d4c1425fab6fc07155c7cff/propcache-0.5.2-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:acd2c8edba48e31e58a363b8cf4e5c7db3b04b3f9e371f601df30d9b0d244836", size = 65711, upload-time = "2026-05-08T21:01:13.676Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1c/34/80f8d0099f8d6bacc4de1624c85672681c8cd1149ca2da0e38fd120b817f/propcache-0.5.2-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:452b5065457eb9991ec5eb38ff41d6cd4c991c9ac7c531c4d5849ae473a9a13f", size = 64247, upload-time = "2026-05-08T21:01:14.936Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f3/1a/8b08f3a5f1037e9e370c55883ceeeee0f6dd0416fb2d2d67b8bfc91f2a79/propcache-0.5.2-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:3430bb2bfe1331885c427745a751e774ee679fd4344f80b97bf879815fe8fa55", size = 67102, upload-time = "2026-05-08T21:01:16.281Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/34/68/8bdb7bb7756d76e005490649d10e4a8369e610c74d619f71e1aedf889e9c/propcache-0.5.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:cef6cea3922890dd6c9654971001fa797b526c16ab5e1e46c05fd6f877be7568", size = 64964, upload-time = "2026-05-08T21:01:17.57Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0a/aa/50fb0b5d3968b61a510926ff8b8465f1d6e976b3ab74496d7a4b9fc42515/propcache-0.5.2-cp313-cp313t-win32.whl", hash = "sha256:72d61e16dd78228b58c5d47be830ff3da7e5f139abdf0aef9d86cde1c5cf2191", size = 42546, upload-time = "2026-05-08T21:01:18.946Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ae/4c/0ddbae64321bd4a95bcbfc19307238016b5b1fee645c84626c8d539e5b74/propcache-0.5.2-cp313-cp313t-win_amd64.whl", hash = "sha256:0958834041a0166d343b8d2cedcd8bcbaeb4fdbe0cf08320c5379f143c3be6e7", size = 46330, upload-time = "2026-05-08T21:01:20.162Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/00/d9/9cddc8efb78d8af264c5ec9f6d10b62f57c515feda8d321595f56010fb23/propcache-0.5.2-cp313-cp313t-win_arm64.whl", hash = "sha256:6de8bd93ddde9b992cf2b2e0d796d501a19026b5b9fd87356d7d0779531a8d96", size = 40521, upload-time = "2026-05-08T21:01:21.399Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e2/ea/23ee535d90ce8bcc465a3028eb3cc0ce3bd1005f4bb27710b30587de798d/propcache-0.5.2-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:46088abff4cba581dea21ae0467a480526cb25aa5f3c269e909f800328bc3999", size = 94662, upload-time = "2026-05-08T21:01:22.683Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b5/06/c5a52f419b5d8972f8d46a7577476090d8e3263ff589ce40b5ca4968d5be/propcache-0.5.2-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:fc88b26f08d634f7bc819a7852e5214f5802641ab8d9fd5326892292eee1993e", size = 53928, upload-time = "2026-05-08T21:01:23.986Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/63/b1/4260d67d6bd85e58a66b72d54ce15d5de789b6f3870cc6bedf8ff9667401/propcache-0.5.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:97797ebb098e670a2f92dd66f32897e30d7615b14e7f59711de23e30a9072539", size = 54650, upload-time = "2026-05-08T21:01:25.305Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/70/06/2f46c318e3307cd7a6a7481def374ce838c0fe20084b39dd54b0879d0e99/propcache-0.5.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ba57fffe4ac99c5d30076161b5866336d97600769bad35cc68f7774b15298a4e", size = 59912, upload-time = "2026-05-08T21:01:26.545Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4c/29/fe1aebec2ce57ab985a9c382bded1124431f85078113aa222c5d278430d4/propcache-0.5.2-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:583c19759d9eec1e5b69e2fbef36a7d9c326041be9746cb822d335c8cedc2979", size = 63300, upload-time = "2026-05-08T21:01:27.937Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b4/18/2334b26768b6c82be8c69e83671b767d5ef426aa09b0cba6c2ea47816774/propcache-0.5.2-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d0326e2e5e1f3163fa306c834e48e8d490e5fae607a097a40c0648109b47ba80", size = 64208, upload-time = "2026-05-08T21:01:29.484Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2b/76/7f1bfd6afff4c5e38e36a3c6d68eb5f4b7311ea80baf693db78d95b603c4/propcache-0.5.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e00820e192c8dbebcafb383ebbf99030895f09905e7a0eb2e0340a0bcc2bc825", size = 61633, upload-time = "2026-05-08T21:01:31.068Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c4/46/b3ff8aba2b4953a3e50de2cf72f1b5748b8eca93b15f3dc2c84339084c09/propcache-0.5.2-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c66afea89b1e43725731d2004732a046fe6fe955d51f952c3e95a7314a284a39", size = 61724, upload-time = "2026-05-08T21:01:32.374Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c5/01/814cfcafbcff954f94c01cf30e097ddc88a076b5440fbcf4570753437d40/propcache-0.5.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:d4dc37dec6c6cdad0b57881a5658fd14fbf53e333b1a86cf86559f190e1d9ec4", size = 60069, upload-time = "2026-05-08T21:01:33.67Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/da/68/5c6f7622d510cc666a300687e06fd060c1a43361c0c9b20d284f06d8096a/propcache-0.5.2-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:5570dbcc97571c15f68068e529c92715a12f8d54030e272d264b377e22bd17a5", size = 57099, upload-time = "2026-05-08T21:01:34.915Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/55/27/9cb0b4c679124085327957d42521c99dba04c88c90c3e55a6f0b633ebccc/propcache-0.5.2-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:f814362777a9f841adddb200ecdf8f5cb1e5a3c4b7a86378edbd6ccb26edd702", size = 63391, upload-time = "2026-05-08T21:01:36.231Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f0/9d/7258aaa5bdf60fc6f27591eef6fe52768cb0beda7140be477c8b12c9794a/propcache-0.5.2-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:196913dea116aeb5a2ba95af4ddcb7ea85559ae07d8eee8751688310d09168c3", size = 61626, upload-time = "2026-05-08T21:01:37.545Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8e/0d/41c602003e8a9b16fe1e7eadf62c7bfba9d5474370b24200bf48b315f45f/propcache-0.5.2-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:6e7b8719005dd1175be4ab1cd25e9b98659a5e0347331506ec6760d2773a7fb5", size = 64781, upload-time = "2026-05-08T21:01:38.83Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8b/f3/38e66b1856e9bd079deea015bc4a55f7767c0e4db2f7dcf69e7e680ba4ce/propcache-0.5.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:51f96d685ab16e88cab128cd37a52c5da540809c8b879fa047731bfcb4ad35a4", size = 62570, upload-time = "2026-05-08T21:01:40.415Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/95/ca/bbfe9b910ce57dde8bb4876b4520fc02a4e89497c10de26be936758a3aaa/propcache-0.5.2-cp314-cp314-win32.whl", hash = "sha256:cc6fc3cc62e8501d3ed62894425040d2728ecddb1ed072737a5c70bd537aa9f0", size = 39436, upload-time = "2026-05-08T21:01:41.654Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/61/d2/45c9defbaa1ea297035d9d4cce9e8f80daafbf19319c6007f157c6256ea9/propcache-0.5.2-cp314-cp314-win_amd64.whl", hash = "sha256:81e3a30b0bb60caa22033dd0f8a3618d1d67356212514f62c57db75cb0ef410c", size = 42373, upload-time = "2026-05-08T21:01:43.041Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/44/68/9ea5103f41d5217d7d6ec24db90018e23aebec070c3f9a6e54d12b841fd8/propcache-0.5.2-cp314-cp314-win_arm64.whl", hash = "sha256:0d2c9bf8528f135dbb805ce027567e09164f7efa51a2be07458a2c0420f292d0", size = 38554, upload-time = "2026-05-08T21:01:44.336Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8a/81/fadf555f42d3b762eea8a53950b0489fdc0aa9da5f8ed9e10ce0a4e01b48/propcache-0.5.2-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:4bc8ff1feffc6a61c7002ffe84634c41b822e104990ae009f44a0834430070bb", size = 99395, upload-time = "2026-05-08T21:01:45.883Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f5/c9/c61e134a686949cf7971af3a390148b1156f7be81c73bc0cd12c873e2d48/propcache-0.5.2-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:79aa3ff0a9b566633b642fa9caf7e21ed1c13d6feca718187873f199e1514078", size = 56653, upload-time = "2026-05-08T21:01:47.307Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cb/73/daf935ea7048ddd7ec8eec5345b4a40b619d2d178b3c0a0900796bc3c794/propcache-0.5.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1b31822f4474c4036bae62de9402710051d431a606d6a0f907fec79935a071aa", size = 56914, upload-time = "2026-05-08T21:01:48.573Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/79/9f/aba959b435ea18617edd7cf0a7ad0b9c574b8fc7e3d2cd55fb59cb255d33/propcache-0.5.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:13fef48778b5a2a756523fdb781326b028ca75e32858b04f2cdd19f394564917", size = 62567, upload-time = "2026-05-08T21:01:49.903Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6c/a1/859942de9a791ff42f6141736f5b37749b8f53e65edfa49638c67dd67e6a/propcache-0.5.2-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8b73ab70f1a3351fbc71f663b3e645af6dd0329100c353081cf69c37433fc6fe", size = 65542, upload-time = "2026-05-08T21:01:51.204Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b5/61/315bc0fd6c0fc7f80a528b8afd209e5fc4a875ea79571b91b8f50f442907/propcache-0.5.2-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5538d2c13d93e4698af7e092b57bc7298fd35d1d58e656ae18f23ee0d0378e03", size = 66845, upload-time = "2026-05-08T21:01:52.539Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/47/f7/9f8122e3132e8e354ac41975ef8f1099be7d5a16bc7ae562734e993665c0/propcache-0.5.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cd645f03898405cabe694fb8bc35241e3a9c332ec85627584fe3de201452b335", size = 63985, upload-time = "2026-05-08T21:01:53.847Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c8/54/c317819ec157cbf6f35df9df9657a6f82daf34d5faf15948b2f639c2192e/propcache-0.5.2-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a473b3440261e0c60706e732b2ed2f517857344fc21bf48fdfe211e2d98eb285", size = 63999, upload-time = "2026-05-08T21:01:55.179Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5a/56/387e3f7dfce0a9233df41fb888aa1c30222cb4bbbf09537c02dd9bd85fe2/propcache-0.5.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7afa37062e6650640e932e4cc9297d81f9f42d9944029cc386b8247dea4da837", size = 62779, upload-time = "2026-05-08T21:01:57.489Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a1/9c/596784cb5824ed61ee960d3f8655a3f0993e107c6e98ab6c818b7fb92ccb/propcache-0.5.2-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:8a90efd5777e996e42d568db9ac740b944d691e565cbfd31b2f7832f9184b2b8", size = 59796, upload-time = "2026-05-08T21:01:58.736Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c2/3d/1a6cfa1726a48542c1e8784a0761421476a5b68e09b7f36bf95eb954aaba/propcache-0.5.2-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:f19bb891234d72535764d703bfed1153cc34f4214d5bd7150aee1eec9e8f4366", size = 66023, upload-time = "2026-05-08T21:02:00.228Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e4/0e/05fd6990369477076e4e280bcb970de760fddf0161a46e988bc95f7940ec/propcache-0.5.2-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:32775082acd2d807ee3db715c7770d38767b817870acfa08c29e057f3c4d5b56", size = 64448, upload-time = "2026-05-08T21:02:01.888Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cd/86/5f8da315a4309c62c10c0b2516b17492d5d3bbe1bb862b96604db67e2a37/propcache-0.5.2-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:9282fb1a3bccd038da9f768b927b24a0c753e466c086b7c4f3c6982851eefb2d", size = 67329, upload-time = "2026-05-08T21:02:03.484Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/da/d3/3368efe79ab21f0cdf86ef49895811c9cc933131d4cde1f28a624e22e712/propcache-0.5.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:cc49723e2f60d6b32a0f0b08a3fd6d13203c07f1cd9566cfce0f12a917c967a2", size = 65172, upload-time = "2026-05-08T21:02:04.745Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d5/07/127e8b0bacfb325396196f9d976a22453049b89b9b2b08477cc3145faa44/propcache-0.5.2-cp314-cp314t-win32.whl", hash = "sha256:2d7aa89ebca5acc98cba9d1472d976e394782f587bad6661003602a619fd1821", size = 43813, upload-time = "2026-05-08T21:02:06.025Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/88/fb/46dad6c0ae49ed230ab1b16c890c2b6314e2403e6c412976f4a72d64a527/propcache-0.5.2-cp314-cp314t-win_amd64.whl", hash = "sha256:d447bb0b3054be5818458fbb171208b1d9ff11eba14e18ca18b90cbb45767370", size = 47764, upload-time = "2026-05-08T21:02:07.353Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e7/c4/a47d0a63aa309d10d59ede6e9d4cff03a344a79d1f0f4cd0cd74997b53e0/propcache-0.5.2-cp314-cp314t-win_arm64.whl", hash = "sha256:fe67a3d11cd9b4efabfa45c3d00ffba2b26811442a73a581a94b67c2b5faccf6", size = 41140, upload-time = "2026-05-08T21:02:09.065Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3a/ed/1cdcab6ba3d6ab7feca11fc14f0eeea80755bb53ef4e892079f31b10a25f/propcache-0.5.2-py3-none-any.whl", hash = "sha256:be1ddfcbb376e3de5d2e2db1d58d6d67463e6b4f9f040c000de8e300295465fe", size = 14036, upload-time = "2026-05-08T21:02:10.673Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ruff"
|
||||
version = "0.16.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/4d/94/1e5e4967626faf12fa56999cd6222dff6992ceb086ad7945756baf70c7a7/ruff-0.16.0.tar.gz", hash = "sha256:e460aafd5495ec89efaa6ced2e4a9a581116451e1c88b9d37ef497e0f8e93982", size = 4790557, upload-time = "2026-07-23T19:11:30.981Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/4b/81/1c8818fee7ce1a04cd7d1b3172e0a8f8e4f1dc4feb7fc390e16daa8af323/ruff-0.16.0-py3-none-linux_armv6l.whl", hash = "sha256:e5115729eb08c585e5121978ba5d5b60caeae394ce21b9fb5e6cd33a1c6c9b1e", size = 10754633, upload-time = "2026-07-23T19:10:46.415Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/23/df/beaf59c09d68db84304d555f188b276a77132a5d5b0b67a5c762aa143628/ruff-0.16.0-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:3c954b1d580bfa035b41654f7858cc7e71d5fc3ac5b723dd62bd9133830ed522", size = 10969164, upload-time = "2026-07-23T19:10:50.271Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/42/ce/741cd197496a1abbf51352710fd15ed995d2a2be87189c1da26a450d6e83/ruff-0.16.0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:e01c21d10eb1b29f47b7454e1f4056db9a3f0260c646aa88457c610291db9f81", size = 10488846, upload-time = "2026-07-23T19:10:52.639Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/52/2a/a2db8e88cade358f5cdcb05674a917751074109315d014eb6352d9a893f7/ruff-0.16.0-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6e364e5ed22ed8dc05082fd78e35308618260907ac2d3c1d637b2e682415b6c9", size = 10889729, upload-time = "2026-07-23T19:10:54.89Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/42/65/62a771694ebd63029dc953e27dbad40e1588bd4860ff9fe881018fddaa49/ruff-0.16.0-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d327b8fc113a1d4421a04f3839d3752057c8dd1ee320223a6f3f52d04ada462a", size = 10568275, upload-time = "2026-07-23T19:10:56.993Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3f/e2/ced249fe8af5f086c5c58cc21cc3356d50f32f7401c5df87050c999620a7/ruff-0.16.0-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a9b50c55e263103586b3dcf5f73d479eb8cb5fdb6098fec59a62891dab653717", size = 11385112, upload-time = "2026-07-23T19:10:59.615Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/87/0b/05154977a8fd69eeb6c103271f55403bfd8711f5c0f8ed07489d95a504e7/ruff-0.16.0-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0ff4a79ce3ec0172f3241943835de1c4cb4e2dcd07f0f8c2d02603dbbbee4b17", size = 12207008, upload-time = "2026-07-23T19:11:02.154Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fb/29/98225831a3a1eab0e02f4acc6ca6559a98611dcc68b6965ff4b7234627c1/ruff-0.16.0-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e95c448fca1fb2a18372a9440926c5a6ee789639bb975c72e7ae6d0b04218ab4", size = 11650842, upload-time = "2026-07-23T19:11:04.557Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/91/66/6bd3cf90500653d55dc0ffc8507aa8300bd49d0214b2e8cb4d3fef2943ba/ruff-0.16.0-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4f11a8d11010301d0a398a2fdef67691feca7294da6aef55e2150e8fa2cd520b", size = 11400718, upload-time = "2026-07-23T19:11:09.233Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8e/a2/a54eb4eae05d66364050a5d3b8a9c5ef88196531b3cbe7109d873f87f819/ruff-0.16.0-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:48044c678e9cb8698246c99b14aaccfa6601dea7379eb48a6f8f73f7a6d86cd0", size = 11426177, upload-time = "2026-07-23T19:11:11.994Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1a/be/16e3eea4b2a478a496919f5e36f17c4559e54620bd3bbac5d6affa068006/ruff-0.16.0-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:7aa0959bad8eb8bef50340154fc9b58678dae31fa4293afa38b44b6e552c0213", size = 10856126, upload-time = "2026-07-23T19:11:14.221Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a2/84/252eb8b868a16eec7257c14f504f77537e734b2d69c762e639e588e304a3/ruff-0.16.0-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:28ea2b7df8ebf7f9da6b7d47b230ab48f387c0a29be3b474c4d0740e197bb9af", size = 10571208, upload-time = "2026-07-23T19:11:16.378Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/21/09/817a482f542f7570cbb4554b26e896610c7114f539b1d9e2d2145bf6bef6/ruff-0.16.0-py3-none-musllinux_1_2_i686.whl", hash = "sha256:33a3dfac8c35f81498dea9181bccc2f4c4bc8f1521a1dd9406e77643e0f0fb09", size = 11063329, upload-time = "2026-07-23T19:11:19.173Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2e/23/9403c180ca1cb9b1f7335f5c3e5305c09d49ea5b345196682a36028bde4a/ruff-0.16.0-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:a5237a0bda500d30d81b8e07a6973a5cbc772864cbf746ae2f4e8a2e01c9f4ed", size = 11489751, upload-time = "2026-07-23T19:11:21.74Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b2/1d/1b2ef7bcde851c78d7f17f1cca13fd6dc695fc4b3d6197941e72cae5b132/ruff-0.16.0-py3-none-win32.whl", hash = "sha256:7fab76fa065c873f41ff744347c6e77bcc3dfec4bcc754dc26b63d23c0f7f5fb", size = 10785885, upload-time = "2026-07-23T19:11:23.947Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b2/a3/d5e4ef7a56be3f928ffb90b94c25ba7d3cb9c7fe0736aeaaedf361770712/ruff-0.16.0-py3-none-win_amd64.whl", hash = "sha256:429c117f022bf481fabd9d551e7a3952b24c65e6ef44337ea09d90bebef14472", size = 11923141, upload-time = "2026-07-23T19:11:26.409Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cb/9a/8415f2657cbe200f41a4531ccededf135505a92d4a012229121f885b26f9/ruff-0.16.0-py3-none-win_arm64.whl", hash = "sha256:14296fedcd2705c77ab8235439278bbb38f285cf7da5528b00b3e330c3d4872d", size = 11273407, upload-time = "2026-07-23T19:11:28.705Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "typing-extensions"
|
||||
version = "4.16.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/f6/cc/6253133b5bb138fc3306cebfbda2c520f545d36b5be2c7255cc528bb45d6/typing_extensions-4.16.0.tar.gz", hash = "sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5", size = 113555, upload-time = "2026-07-02T08:40:05.92Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8", size = 45571, upload-time = "2026-07-02T08:40:04.659Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "vidconf-container-exporter"
|
||||
version = "0.1.0"
|
||||
source = { virtual = "." }
|
||||
dependencies = [
|
||||
{ name = "aiohttp" },
|
||||
{ name = "prometheus-client" },
|
||||
]
|
||||
|
||||
[package.dev-dependencies]
|
||||
dev = [
|
||||
{ name = "mypy" },
|
||||
{ name = "ruff" },
|
||||
]
|
||||
|
||||
[package.metadata]
|
||||
requires-dist = [
|
||||
{ name = "aiohttp", specifier = ">=3.10.0" },
|
||||
{ name = "prometheus-client", specifier = ">=0.25.0" },
|
||||
]
|
||||
|
||||
[package.metadata.requires-dev]
|
||||
dev = [
|
||||
{ name = "mypy", specifier = ">=2.3.0" },
|
||||
{ name = "ruff", specifier = ">=0.15.21" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "yarl"
|
||||
version = "1.24.5"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "idna" },
|
||||
{ name = "multidict" },
|
||||
{ name = "propcache" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/31/33/ebe9e3d1f86c7a0b51094c0a146392045ca1631d2664889539dec8088a33/yarl-1.24.5.tar.gz", hash = "sha256:e81b83143bee16329c23db3c1b2d82b29892fcbcb849186d2f6e98a5abe9a57f", size = 228679, upload-time = "2026-07-20T02:07:45.435Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/1b/84/71d051c850b5af41d168c679d9eb67eb7c55283ac4ee131673edf134bc4e/yarl-1.24.5-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:d693396e5aea78db03decd60aec9ece16c9b40ba00a587f089615ff4e718a81d", size = 136035, upload-time = "2026-07-20T02:05:25.489Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/03/4d/8ad27f9a1b7e69313cca5d695b925b48efe51208d3490e0844bae97cabc0/yarl-1.24.5-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:3363fcc96e665878946ad7a106b9a13eac0541766a690ef287c0232ac768b6ec", size = 97642, upload-time = "2026-07-20T02:05:27.429Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ea/b4/05b4131c407006cd1e410e9c6539f16a0945724677e5364447313c15ea3e/yarl-1.24.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:9d399bdcfb4a0f659b9b3788bbc89babe63d9a6a65aacdf4d4e7065ff2e6316c", size = 97323, upload-time = "2026-07-20T02:05:29.441Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/20/16/e618c875c73e0e39611f20a581b3d5e8d59b8857bf001bee3263044c6deb/yarl-1.24.5-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:90333fd89b43c0d08ac85f3f1447593fc2c66de18c3d6378d7125ea118dc7a54", size = 107741, upload-time = "2026-07-20T02:05:31.367Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d9/9a/c4defeaf3ed33fcb346aacf9c6e971a8d4e2bde04a0310e79abb208e7965/yarl-1.24.5-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:665b0a2c463cc9423dd647e0bfd9f4ccc9b50f768c55304d5e9f80b177c1de12", size = 103570, upload-time = "2026-07-20T02:05:33.303Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5f/e7/0e0e0de5865ebd5914537ef486f36c727a59865c3ac0cf5ff1b32aececbf/yarl-1.24.5-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e006d3a974c4ee19512e5f058abedb6eef36a5e553c14812bdeba1758d812e6d", size = 115815, upload-time = "2026-07-20T02:05:35.292Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2b/27/ca56b700cb170aba25a3893b75355b213935657dc5714d2383354a270e62/yarl-1.24.5-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:e7d42c531243450ef0d4d9c172e7ed6ef052640f195629065041b5add4e058d1", size = 116025, upload-time = "2026-07-20T02:05:37.503Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d6/d0/d56c859b8222116f5d68459199f48359e0bf121b6f65a69bf329b3602ba0/yarl-1.24.5-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f08c7513ecef5aad65687bfdf6bc601ae9fccd04a42904501f8f7141abad9eb9", size = 109835, upload-time = "2026-07-20T02:05:39.506Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/70/a2/3a35557e4d1a79425040eba202ccaf08bdc8717680fc77e2498a1ad2e0a5/yarl-1.24.5-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:6c95b17fe34ed802f17e205112e6e10db92275c34fee290aa9bdc55a9c724027", size = 108884, upload-time = "2026-07-20T02:05:41.584Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e4/35/ef4c26356b7913c68983bac2d72a4212b3347af551cb8d250b99b5ed7b7f/yarl-1.24.5-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:56b149b22de33b23b0c6077ab9518c6dcb538ad462e1830e68d06591ccf6e38b", size = 107308, upload-time = "2026-07-20T02:05:43.697Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d5/91/ff0dc66c2ccf3e0153ab97ff61eabab4400e6a5264af427ab30cd69f1857/yarl-1.24.5-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:a8fe66b8f300da93798025a785a5b90b42f3810dc2b72283ff84a41aaaebc293", size = 103646, upload-time = "2026-07-20T02:05:45.895Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/74/f0/33b9271c7f881766359d58266fa0811d2e5210ed860e28da7dc6d7786344/yarl-1.24.5-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:377fe3732edbaf78ee74efdf2c9f49f6e99f20e7f9d2649fda3eb4badd77d76e", size = 115305, upload-time = "2026-07-20T02:05:47.832Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ef/65/fd79fb1868c4a80db8661091de525bf430f63c3bea1b20e8b6a84fc7d359/yarl-1.24.5-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:e8ffa78582120024f476a611d7befc123cee59e47e8309d470cf667d806e613b", size = 108404, upload-time = "2026-07-20T02:05:49.604Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ff/ba/dbabe6b262f17a816c70cfc09558dbf03ece3ec76684d02f911a3d3a189c/yarl-1.24.5-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:daba5e594f06114e37db186efd2dd916609071e59daca901a0a2e71f02b142ce", size = 115940, upload-time = "2026-07-20T02:05:51.741Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a5/43/fab2d1dad9d340a268cdde63756a123d069723efff6a372d123fa74a9517/yarl-1.24.5-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:65be18ec59496c13908f02a2472751d9ef840b4f3fb5726f129306bf6a2a7bba", size = 110006, upload-time = "2026-07-20T02:05:53.554Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c4/27/41eb51bbd1b8d89546b83897cfb0164f1e109304fd408dbb151b639eec0f/yarl-1.24.5-cp312-cp312-win_amd64.whl", hash = "sha256:a929d878fec099030c292803b31e5d5540a7b6a31e6a3cc76cb4685fc2a2f51b", size = 97618, upload-time = "2026-07-20T02:05:55.57Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3c/25/b2553764b3d65db711d8f45416351ec4f420847558eb669edcbcaadf5780/yarl-1.24.5-cp312-cp312-win_arm64.whl", hash = "sha256:7ce27823052e2013b597e0c738b13e7e36b8ccb9400df8959417b052ab0fd92c", size = 93018, upload-time = "2026-07-20T02:05:57.554Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e1/63/64ef361967cc983573149dc1515d531db5da8a4c92d22bb833d59e01b313/yarl-1.24.5-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:79af890482fc94648e8cde4c68620378f7fef60932710fa17a66abc039244da2", size = 135075, upload-time = "2026-07-20T02:05:59.671Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bb/89/55920fd853ce43e608adbc3962456f0d649d6bb15250dc2988321da0fe1c/yarl-1.24.5-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:46c2f213e23a04b93a392942d782eb9e413e6ef6bf7c8c53884e599a5c174dcb", size = 97225, upload-time = "2026-07-20T02:06:01.769Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/15/f0/7688d3f2cfff7590df2af38ec46d969f4281a4dddb08a9ad2eafbcdddf98/yarl-1.24.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:92ab3e11448f2ff7bf53c5a26eff0edc086898ec8b21fb154b85839ce1d88075", size = 96751, upload-time = "2026-07-20T02:06:03.676Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/05/1a/a851a0f94aaaf379dd4f901bfc80f634280bec51eb260b47363e2a4cd62e/yarl-1.24.5-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ebb0ec7f17803063d5aeb982f3b1bd2b2f4e4fae6751226cbd6ba1fcfe9e63ff", size = 107960, upload-time = "2026-07-20T02:06:05.699Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6c/a8/faea066c12f9c77ca0de90641f1655f9dd7b412477bf28c76d692f3aecff/yarl-1.24.5-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:82632daed195dcc8ea664e8556dc9bdbd671960fb3776bd92806ce05792c2448", size = 103500, upload-time = "2026-07-20T02:06:07.556Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fb/9c/1e67084c2a6e2f2db0e3be798328cb3be42c0119b621d25461479a224d21/yarl-1.24.5-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:53e549287ef628fecba270045c9701b0c564563a9b0577d24a4ec75b8ab8040f", size = 115780, upload-time = "2026-07-20T02:06:09.599Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/58/86/1f94664e147474337e3359f52012cf3d02f825f694317b178bfba1078c62/yarl-1.24.5-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:fcd3b77e2f17bbe4ca56ec7bcb07992647d19d0b9c05d84886dcd6f9eb810afd", size = 115308, upload-time = "2026-07-20T02:06:11.352Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0a/43/8e55ae7538ba5f28ccb3c845c6dd4549cf7016d5992e5326512519107cdd/yarl-1.24.5-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d46b86567dd4e248c6c159fcbcdcce01e0a5c8a7cd2334a0fff759d0fa075b16", size = 110574, upload-time = "2026-07-20T02:06:13.129Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ce/ba/a889ec8765cedcf2ac44dcb02d6a21e4861399b243b263c5f2dde27ee740/yarl-1.24.5-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7f72c74aa99359e27a2ee8d6613fefa28b5f76a983c083074dfc2aaa4ab46213", size = 109914, upload-time = "2026-07-20T02:06:15.243Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9c/c3/e45f821af67b791c2dbbe4a9f4137a1d33f8d386654a05a0c3f47bdfa25d/yarl-1.24.5-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:3f45789ce415a7ec0820dc4f82925f9b5f7732070be1dec1f5f23ec381435a24", size = 107712, upload-time = "2026-07-20T02:06:17.443Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/02/00/2ab0f42c9857fcb490bfaa6647b14540b53d241ab209f23220b958cc5832/yarl-1.24.5-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:6e73e7fe93f17a7b191f52ec9da9dd8c06a8fe735a1ecbd13b97d1c723bff385", size = 104251, upload-time = "2026-07-20T02:06:19.259Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7a/70/709d9a286e98af2c7fd8e4e6cada658b5c0e30d87dd7e2a63c2fb5767217/yarl-1.24.5-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:4a36f9becdd4c5c52a20c3e9484128b070b1dcfc8944c006f3a528295a359a9c", size = 115319, upload-time = "2026-07-20T02:06:21.207Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5c/6c/3eaa515142991fe84cfc483ff986492211f1978f90161ccefdbec919d09b/yarl-1.24.5-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:7bcbe0fcf850eae67b6b01749815a4f7161c560a844c769ad7b48fcd99f791c4", size = 109163, upload-time = "2026-07-20T02:06:23.006Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bb/64/711dafce66c323a3144d470547a71c5384c57623308ac8bb5e4b903ac148/yarl-1.24.5-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:24e861e9630e0daddcb9191fb187f60f034e17a4426f8101279f0c475cd74144", size = 115435, upload-time = "2026-07-20T02:06:24.923Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cf/f3/9b9d0e6d84bea851eb1ba99e4bdc755b86fd813e49ec86dfe42f26befdef/yarl-1.24.5-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9335a099ad87287c37fe5d1a982ff392fa5efe5d14b40a730b1ec1d6a41382b4", size = 110691, upload-time = "2026-07-20T02:06:26.973Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/86/e4/62a06b7e87c4246ac76b7c2da136f972eb4a3a1fc94abb07e7022d6fdb0a/yarl-1.24.5-cp313-cp313-win_amd64.whl", hash = "sha256:2dbe06fc16bc91502bca713704022182e5729861ae00277c3a23354b40929740", size = 97454, upload-time = "2026-07-20T02:06:29.163Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9e/c9/5fc8025b318ab10db413b61056bd0d95c557a70e8df4210c7511f866329c/yarl-1.24.5-cp313-cp313-win_arm64.whl", hash = "sha256:6b8536851f9f65e7f00c7a1d49ba7f2be0ffe2c11555367fc9f50d9f842410a1", size = 92813, upload-time = "2026-07-20T02:06:31.113Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a9/08/5f3085fef9564217074db9dd8573de1795bc82cde61a7ad10b6a7234a569/yarl-1.24.5-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:2729fcfc4f6a596fb0c50f32090400aa9367774ac296a00387e65098c0befa76", size = 135680, upload-time = "2026-07-20T02:06:33.273Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/98/35/ba9436e579bd48a8801f2021d842d9ab4994c26e4c7dd3a4c1f1bcb57a9e/yarl-1.24.5-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:ff330d3c30db4eb6b01d79e29d2d0b407a7ecad39cfd9ec993ece57396a2ec0d", size = 97395, upload-time = "2026-07-20T02:06:35.259Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/18/a9/a07f76f3c44e02b25cc743af5ef93eef27f7013eadca770451b6a6ccb5db/yarl-1.24.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:e42d75862735da90e7fc5a7b23db0c976f737113a54b3c9777a9b665e9cbff75", size = 97223, upload-time = "2026-07-20T02:06:37.216Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/77/f7/a9a1d6fa7dd9e388f95b30f6ad3ec4e285f6c8f61f44ce16070c3fcfe414/yarl-1.24.5-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a3732e66413163e72508da9eff9ce9d2846fde51fae45d3605393d3e6cd303e9", size = 108777, upload-time = "2026-07-20T02:06:39.292Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2f/44/e0b86c302471fabd6f02808ecf2ac52b8412b624787849d4bf2cdb466f6f/yarl-1.24.5-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:5b8ee53be440a0cffc991a27be3057e0530122548dbe7c0892df08822fce5ede", size = 103119, upload-time = "2026-07-20T02:06:41.456Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d1/16/9c16d180bf8faaf223225eb50e1245870ff1ae0e302a27153988e65c51fd/yarl-1.24.5-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:af3aefa655adb5869491fa907e652290386800ae99cc50095cba71e2c6aefdca", size = 116471, upload-time = "2026-07-20T02:06:43.696Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d2/8d/b219b9df28a02ce95cfbdd41d2f7caa5669d0ff979c1c9975697145e33c5/yarl-1.24.5-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2120b96872df4a117cde97d270bac96aea7cc52205d305cf4611df694a487027", size = 115974, upload-time = "2026-07-20T02:06:45.874Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9b/e8/f20557aca240d88e69850ad1ee91756821d094bb1310565c04d25c6682a2/yarl-1.24.5-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:66410eb6345d467151934b49bfa70fb32f5b35a6140baa40ad97d6436abea2e9", size = 110830, upload-time = "2026-07-20T02:06:47.852Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/db/18/199b85109a53eeca64ee19c9cca228287e8e4ab0cc1a09b28f530e65cce0/yarl-1.24.5-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4af7b7e1be0a69bee8210735fe6dcfc38879adfac6d62e789d53ba432d1ffa41", size = 110054, upload-time = "2026-07-20T02:06:49.84Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/aa/2f/ed28147f8cd7f48c49367c90713b30a555284b6105a6a56f3a05568da795/yarl-1.24.5-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:fa139875ff98ab97da323cfadfaff08900d1ad42f1b5087b0b812a55c5a06373", size = 108312, upload-time = "2026-07-20T02:06:51.835Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c5/c5/55e16ae0a5c227cea8df1c6871ba57d614a34243146c05729caf2a1bd9c5/yarl-1.24.5-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:0055afc45e864b92729ac7600e2d102c17bef060647e74bca75fa84d66b9ff36", size = 103662, upload-time = "2026-07-20T02:06:54.061Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8d/ea/dbd7c2caec459c9a426f18b02688ecbfb58620d0f6a3422d24769fbaf8ab/yarl-1.24.5-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:f0e466ed7511fe9d459a819edbc6c2585c0b6eabde9fa8a8947552468a7a6ef0", size = 116090, upload-time = "2026-07-20T02:06:56.015Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/06/84/39ce4ce3059e07fece5fbdbee8c4053406af9aca911ce9fa5f8548aab6af/yarl-1.24.5-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:f141474e85b7e54998ec5180530a7cda99ab29e282fa50e0756d89981a9b43c5", size = 109523, upload-time = "2026-07-20T02:06:57.926Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a9/8b/71ff44137b405c64a7788075669c24010019f57a7464b78c3a6cbee539d9/yarl-1.24.5-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:e2935f8c39e3b03e83519292d78f075189978f3f4adc15a78144c7c8e2a1cba5", size = 116084, upload-time = "2026-07-20T02:06:59.868Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/62/c0/423078fdd4042e1862c11f0ffd977a0ffa393783c12bee94685923bc189e/yarl-1.24.5-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:9d1216a7f6f77836617dba35687c5b78a4170afc3c3f18fc788f785ba26565c4", size = 111006, upload-time = "2026-07-20T02:07:01.907Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cf/52/6daa2ee9d95e5c98b8128f8df91eb692eb423ab274b8cf08db52152fad26/yarl-1.24.5-cp314-cp314-win_amd64.whl", hash = "sha256:5ba4f78df2bcc19f764a4b26a8a4f5049c110090ad5825993aacb052bf8003ad", size = 99215, upload-time = "2026-07-20T02:07:03.852Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ec/0e/464a847d7359e0da75dd9fc5c1d1aa35d0159ea31e5f8e66a3c1c29ff3d0/yarl-1.24.5-cp314-cp314-win_arm64.whl", hash = "sha256:9e4e16c73d717c5cf27626c524d0a2e261ad20e46932b2670f64ad5dde23e26f", size = 94566, upload-time = "2026-07-20T02:07:06.074Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e2/55/e03acc4446772660bc335e86e41ef31e4d0d838fd641531a11a5ee33b493/yarl-1.24.5-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:e1ae548a9d901adca07899a4147a7c826bbcc06239d3ce9a59f57886a28a4c88", size = 142533, upload-time = "2026-07-20T02:07:08.284Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ae/71/4acd3a1fc7cf14345cdb302665ecd2097f62c365b4f14ca17d4f37775cf9/yarl-1.24.5-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:ff405d91509d88e8d44129cd87b18d70acd1f0c1aeabd7bc3c46792b1fe2acba", size = 100776, upload-time = "2026-07-20T02:07:10.197Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ff/0b/cfb76b7fe99686db264bff829779a539d923e7564ffd7ef18da6c54c3774/yarl-1.24.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:47e98aab9d8d82ff682e7b0b5dded33bf138a32b817fcf7fa3b27b2d7c412928", size = 100913, upload-time = "2026-07-20T02:07:12.357Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8b/3f/7116e782992abbd4fb6948488aec72078895e929a23078290739e8396fce/yarl-1.24.5-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f0a658a6d3fafee5c6f63c58f3e785c8c43c93fbc02bf9f2b6663f8185e0971f", size = 106507, upload-time = "2026-07-20T02:07:14.173Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/33/90/d4d2d73ee78229cc889872eb8e085d8f5c6f51abdb178409fd9b23cf74fd/yarl-1.24.5-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:4377407001ca3c057773f44d8ddd6358fa5f691407c1ba92210bd3cf8d9e4c95", size = 99219, upload-time = "2026-07-20T02:07:16.019Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3e/fa/a6df1a9bccd644eec00abee0dff4277416222cec435330fd1f2858523ec1/yarl-1.24.5-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7c0494a31a1ac5461a226e7947a9c9b78c44e1dc7185164fa7e9651557a5d9bc", size = 111804, upload-time = "2026-07-20T02:07:18.141Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8a/9e/7b2a1f4bcc20e9447156dd2b1c4d01f70d9df0759025ee7d09a84ffae134/yarl-1.24.5-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a7cff474ab7cd149765bb784cf6d78b32e18e20473fb7bda860bce98ab58e9da", size = 110943, upload-time = "2026-07-20T02:07:20.06Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/08/ff/22c92affb0f9b623ca753d27d968b5625b868f12c6378d049d55ae247643/yarl-1.24.5-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cbb833ccacdb5519eff9b8b71ee618cc2801c878e77e288775d77c3a2ced858a", size = 108251, upload-time = "2026-07-20T02:07:22.217Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/45/44/5769b96298c1e195fb412997b6090af2a84105cf59c17613558a2d011d1f/yarl-1.24.5-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:82f75e05912e84b7a0fe57075d9c59de3cb352b928330f2eb69b2e1f54c3e1f0", size = 106025, upload-time = "2026-07-20T02:07:24.083Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4c/40/009e8e791fd9762c0e1567e69248acb4f49064597e1680874c16dd8bb798/yarl-1.24.5-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:16a2f5010280020e90f5330257e6944bc33e73593b136cc5a241e6c1dc292498", size = 106573, upload-time = "2026-07-20T02:07:26.248Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/20/c6/b7480578f8a0a80946f36ad6df547ecec704f9ba69d2de60f8aa6f1c1cbf/yarl-1.24.5-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:ffcd54362564dc1a30fb74d8b8a6e5a6b11ebd5e27266adc3b7427a21a6c9104", size = 100751, upload-time = "2026-07-20T02:07:28.098Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d4/27/4476f3360b91a48c5cf125e91f59a3bd35299d84a431a258d57f5977bb11/yarl-1.24.5-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:0465ec8cedc2349b97a6b595ace64084a50c6e839eca40aa0626f38b8350e331", size = 111643, upload-time = "2026-07-20T02:07:30.88Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4c/4b/5cdd3e5ee944e8af31e52f6cd3d3af5fd7b937e036ccbbba2c9ffebede95/yarl-1.24.5-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:4db9aecb141cb7a5447171b57aa1ed3a8fee06af40b992ffc31206c0b0121550", size = 106312, upload-time = "2026-07-20T02:07:33.06Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/18/86/f406b0c2a6f99575de2da671ef47aa06f89a5be83a27a46971c3b86cecdb/yarl-1.24.5-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:f540c013589084679a6c7fac07096b10159737918174f5dfc5e11bf5bca4dfe6", size = 110379, upload-time = "2026-07-20T02:07:35.155Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f0/6c/9f3adfbd3b30b4fa0f7ccb3a83eba2c1152d3fff554d535e640ba0f7ba2b/yarl-1.24.5-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:a61834fb15d81322d872eaafd333838ae7c9cea84067f232656f75965933d047", size = 108497, upload-time = "2026-07-20T02:07:37.35Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/dd/37/91eb2e5ca883a529c1b390348a74cd9fc0512171727f547ce70bfe02be5c/yarl-1.24.5-cp314-cp314t-win_amd64.whl", hash = "sha256:5c88e5815a49d289e599f3513aa7fde0bc2092ff188f99c940f007f90f53d104", size = 102450, upload-time = "2026-07-20T02:07:39.578Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bf/f4/ed5c402ac8fde4403ed3366c2716bfddc8a6677ebd59f3d62772cc7fe468/yarl-1.24.5-cp314-cp314t-win_arm64.whl", hash = "sha256:cf139c02f5f23ef6532040a30ff662c00a318c952334f211046b8e60b7f17688", size = 97222, upload-time = "2026-07-20T02:07:41.55Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/61/02/962c1cbfc401a30c1d034dc67ff395f64b52302c6d62de556c1fca99acc0/yarl-1.24.5-py3-none-any.whl", hash = "sha256:a33700d13d9b7d84fd10947b09ff69fb9a792e519c8cb9764a3ca70baa6c23a7", size = 58612, upload-time = "2026-07-20T02:07:43.461Z" },
|
||||
]
|
||||
173
deploy/monitoring/grafana/dashboards/db-pool.json
Normal file
173
deploy/monitoring/grafana/dashboards/db-pool.json
Normal file
@@ -0,0 +1,173 @@
|
||||
{
|
||||
"title": "БД и пулы соединений",
|
||||
"description": "Доступность БД (vidconf_db_up) и занятость основных пулов (SQLAlchemy/БД, Redis) — метрики читаются вне самих пулов, доступны и при их исчерпании (сессия 33, разбор инцидента 07.08 — .forcc/session-results/32-loadtest-07-08-debug.md). Пороги алертов см. deploy/monitoring/alerts.yml (группа vidconf-db).",
|
||||
"uid": "vidconf-db-pool",
|
||||
"editable": false,
|
||||
"timezone": "browser",
|
||||
"schemaVersion": 39,
|
||||
"version": 1,
|
||||
"time": { "from": "now-1h", "to": "now" },
|
||||
"refresh": "10s",
|
||||
"tags": ["vidconf", "db", "pool"],
|
||||
"panels": [
|
||||
{
|
||||
"id": 1,
|
||||
"title": "БД доступна",
|
||||
"description": "vidconf_db_up — отдельное соединение вне основного пула (core/db.py::check_db_up). Алерт DatabaseUnavailable, for: 30s.",
|
||||
"type": "stat",
|
||||
"gridPos": { "h": 4, "w": 6, "x": 0, "y": 0 },
|
||||
"datasource": { "type": "prometheus", "uid": "prometheus" },
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"mappings": [
|
||||
{ "type": "value", "options": { "0": { "text": "DOWN", "color": "red" }, "1": { "text": "UP", "color": "green" } } }
|
||||
],
|
||||
"thresholds": { "mode": "absolute", "steps": [{ "color": "red", "value": null }, { "color": "green", "value": 1 }] }
|
||||
},
|
||||
"overrides": []
|
||||
},
|
||||
"targets": [
|
||||
{ "datasource": { "type": "prometheus", "uid": "prometheus" }, "expr": "vidconf_db_up", "refId": "A" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"title": "Занятость пула БД сейчас, %",
|
||||
"description": "vidconf_db_pool_checked_out / (vidconf_db_pool_size + vidconf_db_pool_max_overflow) * 100. Порог алерта DbConnectionPoolNearExhaustion — 80% дольше минуты.",
|
||||
"type": "stat",
|
||||
"gridPos": { "h": 4, "w": 6, "x": 6, "y": 0 },
|
||||
"datasource": { "type": "prometheus", "uid": "prometheus" },
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"unit": "percent",
|
||||
"min": 0,
|
||||
"max": 100,
|
||||
"thresholds": { "mode": "absolute", "steps": [{ "color": "green", "value": null }, { "color": "orange", "value": 60 }, { "color": "red", "value": 80 }] }
|
||||
},
|
||||
"overrides": []
|
||||
},
|
||||
"targets": [
|
||||
{
|
||||
"datasource": { "type": "prometheus", "uid": "prometheus" },
|
||||
"expr": "(vidconf_db_pool_checked_out / (vidconf_db_pool_size + vidconf_db_pool_max_overflow)) * 100",
|
||||
"refId": "A"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 3,
|
||||
"title": "Занятость пула Redis сейчас, %",
|
||||
"description": "vidconf_redis_pool_in_use / vidconf_redis_pool_max_connections * 100. Порог алерта RedisConnectionPoolNearExhaustion — 80% дольше минуты.",
|
||||
"type": "stat",
|
||||
"gridPos": { "h": 4, "w": 6, "x": 12, "y": 0 },
|
||||
"datasource": { "type": "prometheus", "uid": "prometheus" },
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"unit": "percent",
|
||||
"min": 0,
|
||||
"max": 100,
|
||||
"thresholds": { "mode": "absolute", "steps": [{ "color": "green", "value": null }, { "color": "orange", "value": 60 }, { "color": "red", "value": 80 }] }
|
||||
},
|
||||
"overrides": []
|
||||
},
|
||||
"targets": [
|
||||
{
|
||||
"datasource": { "type": "prometheus", "uid": "prometheus" },
|
||||
"expr": "(vidconf_redis_pool_in_use / vidconf_redis_pool_max_connections) * 100",
|
||||
"refId": "A"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 4,
|
||||
"title": "Активных алертов группы vidconf-db",
|
||||
"description": "ALERTS{alertname=~\"DatabaseUnavailable|.*PoolNearExhaustion\", alertstate=\"firing\"} — снимок того, что прямо сейчас видит Alertmanager/страница Alerts.",
|
||||
"type": "stat",
|
||||
"gridPos": { "h": 4, "w": 6, "x": 18, "y": 0 },
|
||||
"datasource": { "type": "prometheus", "uid": "prometheus" },
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"thresholds": { "mode": "absolute", "steps": [{ "color": "green", "value": null }, { "color": "red", "value": 1 }] }
|
||||
},
|
||||
"overrides": []
|
||||
},
|
||||
"targets": [
|
||||
{
|
||||
"datasource": { "type": "prometheus", "uid": "prometheus" },
|
||||
"expr": "count(ALERTS{alertname=~\"DatabaseUnavailable|.*PoolNearExhaustion\", alertstate=\"firing\"}) OR on() vector(0)",
|
||||
"refId": "A"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 5,
|
||||
"title": "Занятость пула БД (соединений)",
|
||||
"description": "vidconf_db_pool_checked_out на фоне вместимости (size + max_overflow) — эта картина должна расти под нагрузочным тестом до срабатывания алерта. Ранний сигнал: в инциденте 07.08 занятость росла постепенно по мере входа участников в комнату, а не рывком от общей HTTP-нагрузки.",
|
||||
"type": "timeseries",
|
||||
"gridPos": { "h": 8, "w": 12, "x": 0, "y": 4 },
|
||||
"datasource": { "type": "prometheus", "uid": "prometheus" },
|
||||
"fieldConfig": {
|
||||
"defaults": { "custom": { "drawStyle": "line", "fillOpacity": 10 } },
|
||||
"overrides": []
|
||||
},
|
||||
"targets": [
|
||||
{ "datasource": { "type": "prometheus", "uid": "prometheus" }, "expr": "vidconf_db_pool_checked_out", "legendFormat": "занято", "refId": "A" },
|
||||
{ "datasource": { "type": "prometheus", "uid": "prometheus" }, "expr": "vidconf_db_pool_size + vidconf_db_pool_max_overflow", "legendFormat": "вместимость (size+overflow)", "refId": "B" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 6,
|
||||
"title": "Занятость пула Redis (соединений)",
|
||||
"description": "vidconf_redis_pool_in_use на фоне vidconf_redis_pool_max_connections. Второй потолок того же класса, что у БД (закрыт в 0.0.31, redis-py 8 сменил дефолт max_connections на 100).",
|
||||
"type": "timeseries",
|
||||
"gridPos": { "h": 8, "w": 12, "x": 12, "y": 4 },
|
||||
"datasource": { "type": "prometheus", "uid": "prometheus" },
|
||||
"fieldConfig": {
|
||||
"defaults": { "custom": { "drawStyle": "line", "fillOpacity": 10 } },
|
||||
"overrides": []
|
||||
},
|
||||
"targets": [
|
||||
{ "datasource": { "type": "prometheus", "uid": "prometheus" }, "expr": "vidconf_redis_pool_in_use", "legendFormat": "занято", "refId": "A" },
|
||||
{ "datasource": { "type": "prometheus", "uid": "prometheus" }, "expr": "vidconf_redis_pool_max_connections", "legendFormat": "вместимость", "refId": "B" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 7,
|
||||
"title": "БД доступна во времени",
|
||||
"description": "vidconf_db_up как временной ряд — удобно видеть провал целиком (начало/длительность отказа), не только текущее состояние.",
|
||||
"type": "timeseries",
|
||||
"gridPos": { "h": 6, "w": 12, "x": 0, "y": 12 },
|
||||
"datasource": { "type": "prometheus", "uid": "prometheus" },
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"min": 0,
|
||||
"max": 1,
|
||||
"custom": { "drawStyle": "line", "fillOpacity": 20, "lineInterpolation": "stepAfter" },
|
||||
"mappings": [
|
||||
{ "type": "value", "options": { "0": { "text": "DOWN" }, "1": { "text": "UP" } } }
|
||||
]
|
||||
},
|
||||
"overrides": []
|
||||
},
|
||||
"targets": [
|
||||
{ "datasource": { "type": "prometheus", "uid": "prometheus" }, "expr": "vidconf_db_up", "legendFormat": "db_up", "refId": "A" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 8,
|
||||
"title": "Сеансы failed / очереди Celery (для сверки с общей нагрузкой)",
|
||||
"description": "Тот же контекст, что на дашборде «Пайплайны пост-обработки» — здесь рядом с пулами, чтобы не переключаться между дашбордами при разборе инцидента.",
|
||||
"type": "timeseries",
|
||||
"gridPos": { "h": 6, "w": 12, "x": 12, "y": 12 },
|
||||
"datasource": { "type": "prometheus", "uid": "prometheus" },
|
||||
"fieldConfig": {
|
||||
"defaults": { "custom": { "drawStyle": "line", "fillOpacity": 5 } },
|
||||
"overrides": []
|
||||
},
|
||||
"targets": [
|
||||
{ "datasource": { "type": "prometheus", "uid": "prometheus" }, "expr": "vidconf_pipeline_sessions{status=\"failed\"}", "legendFormat": "сеансов failed", "refId": "A" },
|
||||
{ "datasource": { "type": "prometheus", "uid": "prometheus" }, "expr": "sum(vidconf_celery_queue_depth)", "legendFormat": "глубина очередей (сумма)", "refId": "B" }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
354
deploy/monitoring/grafana/dashboards/host.json
Normal file
354
deploy/monitoring/grafana/dashboards/host.json
Normal file
@@ -0,0 +1,354 @@
|
||||
{
|
||||
"title": "Хост и контейнеры",
|
||||
"description": "CPU/RAM/диск/сеть хоста (node-exporter), топ контейнеров по CPU/памяти (container-exporter поверх Docker Engine API — замена cAdvisor, несовместимого с containerd-снапшоттером сервера 1gb, см. .forcc/JOURNAL.md) и характеристики сервера — профиль compose monitoring. Пороги на панелях согласованы с deploy/monitoring/alerts.yml (группа vidconf-host).",
|
||||
"uid": "vidconf-host",
|
||||
"editable": false,
|
||||
"timezone": "browser",
|
||||
"schemaVersion": 39,
|
||||
"version": 1,
|
||||
"time": { "from": "now-6h", "to": "now" },
|
||||
"refresh": "30s",
|
||||
"tags": ["vidconf", "host"],
|
||||
"panels": [
|
||||
{
|
||||
"id": 1,
|
||||
"title": "Загрузка CPU",
|
||||
"description": "100 - idle, усреднено по всем ядрам. Порог алерта HostCpuHigh — 90% дольше 15 минут, см. alerts.yml.",
|
||||
"type": "timeseries",
|
||||
"gridPos": { "h": 8, "w": 12, "x": 0, "y": 0 },
|
||||
"datasource": { "type": "prometheus", "uid": "prometheus" },
|
||||
"fieldConfig": {
|
||||
"defaults": { "unit": "percent", "max": 100, "min": 0, "custom": { "drawStyle": "line", "fillOpacity": 10 } },
|
||||
"overrides": []
|
||||
},
|
||||
"targets": [
|
||||
{
|
||||
"datasource": { "type": "prometheus", "uid": "prometheus" },
|
||||
"expr": "100 - (avg(rate(node_cpu_seconds_total{mode=\"idle\"}[5m])) * 100)",
|
||||
"legendFormat": "CPU busy",
|
||||
"refId": "A"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"title": "Load average",
|
||||
"description": "node_load1/5/15 — на 4-ядерном сервере устойчивое значение выше ~4 означает очередь на CPU.",
|
||||
"type": "timeseries",
|
||||
"gridPos": { "h": 8, "w": 12, "x": 12, "y": 0 },
|
||||
"datasource": { "type": "prometheus", "uid": "prometheus" },
|
||||
"fieldConfig": {
|
||||
"defaults": { "custom": { "drawStyle": "line", "fillOpacity": 5 } },
|
||||
"overrides": []
|
||||
},
|
||||
"targets": [
|
||||
{ "datasource": { "type": "prometheus", "uid": "prometheus" }, "expr": "node_load1", "legendFormat": "1 мин", "refId": "A" },
|
||||
{ "datasource": { "type": "prometheus", "uid": "prometheus" }, "expr": "node_load5", "legendFormat": "5 мин", "refId": "B" },
|
||||
{ "datasource": { "type": "prometheus", "uid": "prometheus" }, "expr": "node_load15", "legendFormat": "15 мин", "refId": "C" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 3,
|
||||
"title": "Использование RAM",
|
||||
"description": "(1 - MemAvailable/MemTotal) — MemAvailable уже учитывает легко освобождаемый page cache/buffers. Порог алерта HostMemoryLow — доступно <10% дольше 10 минут, см. alerts.yml.",
|
||||
"type": "timeseries",
|
||||
"gridPos": { "h": 8, "w": 12, "x": 0, "y": 8 },
|
||||
"datasource": { "type": "prometheus", "uid": "prometheus" },
|
||||
"fieldConfig": {
|
||||
"defaults": { "unit": "percent", "max": 100, "min": 0, "custom": { "drawStyle": "line", "fillOpacity": 10 } },
|
||||
"overrides": []
|
||||
},
|
||||
"targets": [
|
||||
{
|
||||
"datasource": { "type": "prometheus", "uid": "prometheus" },
|
||||
"expr": "(1 - node_memory_MemAvailable_bytes / node_memory_MemTotal_bytes) * 100",
|
||||
"legendFormat": "RAM used",
|
||||
"refId": "A"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 4,
|
||||
"title": "Своп (использовано)",
|
||||
"description": "SwapTotal - SwapFree. На этом сервере swap не заведён под мониторинг отдельным алертом — рост от нуля сам по себе сигнал, что памяти уже не хватает (см. HostMemoryLow).",
|
||||
"type": "timeseries",
|
||||
"gridPos": { "h": 8, "w": 12, "x": 12, "y": 8 },
|
||||
"datasource": { "type": "prometheus", "uid": "prometheus" },
|
||||
"fieldConfig": {
|
||||
"defaults": { "unit": "decbytes", "custom": { "drawStyle": "line", "fillOpacity": 10 } },
|
||||
"overrides": []
|
||||
},
|
||||
"targets": [
|
||||
{
|
||||
"datasource": { "type": "prometheus", "uid": "prometheus" },
|
||||
"expr": "node_memory_SwapTotal_bytes - node_memory_SwapFree_bytes",
|
||||
"legendFormat": "swap used",
|
||||
"refId": "A"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 5,
|
||||
"title": "Свободно на диске (/)",
|
||||
"description": "node_filesystem_avail_bytes для корня. Порог алерта HostDiskLow — <10% (≈5 ГБ из 50 ГБ) дольше 15 минут.",
|
||||
"type": "stat",
|
||||
"gridPos": { "h": 4, "w": 6, "x": 0, "y": 16 },
|
||||
"datasource": { "type": "prometheus", "uid": "prometheus" },
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"unit": "decbytes",
|
||||
"thresholds": { "mode": "absolute", "steps": [{ "color": "red", "value": null }, { "color": "green", "value": 5368709120 }] }
|
||||
},
|
||||
"overrides": []
|
||||
},
|
||||
"targets": [
|
||||
{
|
||||
"datasource": { "type": "prometheus", "uid": "prometheus" },
|
||||
"expr": "node_filesystem_avail_bytes{mountpoint=\"/\",fstype!=\"tmpfs\"}",
|
||||
"refId": "A"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 6,
|
||||
"title": "RAM сейчас, %",
|
||||
"description": "Текущий снимок панели «Использование RAM». Порог совпадает с HostMemoryLow (used > 90%).",
|
||||
"type": "stat",
|
||||
"gridPos": { "h": 4, "w": 6, "x": 6, "y": 16 },
|
||||
"datasource": { "type": "prometheus", "uid": "prometheus" },
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"unit": "percent",
|
||||
"thresholds": { "mode": "absolute", "steps": [{ "color": "green", "value": null }, { "color": "red", "value": 90 }] }
|
||||
},
|
||||
"overrides": []
|
||||
},
|
||||
"targets": [
|
||||
{
|
||||
"datasource": { "type": "prometheus", "uid": "prometheus" },
|
||||
"expr": "(1 - node_memory_MemAvailable_bytes / node_memory_MemTotal_bytes) * 100",
|
||||
"refId": "A"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 7,
|
||||
"title": "Диск сейчас, %",
|
||||
"description": "Текущий снимок использования корневого раздела. Порог совпадает с HostDiskLow (used > 90%).",
|
||||
"type": "stat",
|
||||
"gridPos": { "h": 4, "w": 6, "x": 12, "y": 16 },
|
||||
"datasource": { "type": "prometheus", "uid": "prometheus" },
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"unit": "percent",
|
||||
"thresholds": { "mode": "absolute", "steps": [{ "color": "green", "value": null }, { "color": "red", "value": 90 }] }
|
||||
},
|
||||
"overrides": []
|
||||
},
|
||||
"targets": [
|
||||
{
|
||||
"datasource": { "type": "prometheus", "uid": "prometheus" },
|
||||
"expr": "(1 - node_filesystem_avail_bytes{mountpoint=\"/\",fstype!=\"tmpfs\"} / node_filesystem_size_bytes{mountpoint=\"/\",fstype!=\"tmpfs\"}) * 100",
|
||||
"refId": "A"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 8,
|
||||
"title": "CPU сейчас, %",
|
||||
"description": "Текущий снимок загрузки CPU. Порог совпадает с HostCpuHigh (>90%).",
|
||||
"type": "stat",
|
||||
"gridPos": { "h": 4, "w": 6, "x": 18, "y": 16 },
|
||||
"datasource": { "type": "prometheus", "uid": "prometheus" },
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"unit": "percent",
|
||||
"thresholds": { "mode": "absolute", "steps": [{ "color": "green", "value": null }, { "color": "red", "value": 90 }] }
|
||||
},
|
||||
"overrides": []
|
||||
},
|
||||
"targets": [
|
||||
{
|
||||
"datasource": { "type": "prometheus", "uid": "prometheus" },
|
||||
"expr": "100 - (avg(rate(node_cpu_seconds_total{mode=\"idle\"}[5m])) * 100)",
|
||||
"refId": "A"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 9,
|
||||
"title": "Сетевой трафик",
|
||||
"description": "rx/tx по физическим интерфейсам хоста (исключены lo/veth/docker/br — это внутренние интерфейсы контейнеров, а не реальный сетевой трафик сервера).",
|
||||
"type": "timeseries",
|
||||
"gridPos": { "h": 8, "w": 12, "x": 0, "y": 20 },
|
||||
"datasource": { "type": "prometheus", "uid": "prometheus" },
|
||||
"fieldConfig": {
|
||||
"defaults": { "unit": "Bps", "custom": { "drawStyle": "line", "fillOpacity": 5 } },
|
||||
"overrides": []
|
||||
},
|
||||
"targets": [
|
||||
{
|
||||
"datasource": { "type": "prometheus", "uid": "prometheus" },
|
||||
"expr": "rate(node_network_receive_bytes_total{device!~\"lo|veth.*|docker.*|br-.*\"}[5m])",
|
||||
"legendFormat": "rx {{device}}",
|
||||
"refId": "A"
|
||||
},
|
||||
{
|
||||
"datasource": { "type": "prometheus", "uid": "prometheus" },
|
||||
"expr": "rate(node_network_transmit_bytes_total{device!~\"lo|veth.*|docker.*|br-.*\"}[5m])",
|
||||
"legendFormat": "tx {{device}}",
|
||||
"refId": "B"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 10,
|
||||
"title": "Топ контейнеров по CPU",
|
||||
"description": "Источник — container-exporter (job container-exporter, свой сервис поверх Docker Engine API, см. deploy/monitoring/container-exporter/). Отвечает на вопрос «кто из контейнеров грузит CPU» при срабатывании HostCpuHigh. % от одного ядра — как `docker stats`, суммарно может быть больше 100 при нескольких ядрах.",
|
||||
"type": "timeseries",
|
||||
"gridPos": { "h": 8, "w": 12, "x": 12, "y": 20 },
|
||||
"datasource": { "type": "prometheus", "uid": "prometheus" },
|
||||
"fieldConfig": {
|
||||
"defaults": { "unit": "percent", "custom": { "drawStyle": "line", "fillOpacity": 10, "stacking": { "mode": "normal" } } },
|
||||
"overrides": []
|
||||
},
|
||||
"targets": [
|
||||
{
|
||||
"datasource": { "type": "prometheus", "uid": "prometheus" },
|
||||
"expr": "topk(5, vidconf_container_cpu_percent)",
|
||||
"legendFormat": "{{name}}",
|
||||
"refId": "A"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 11,
|
||||
"title": "Топ контейнеров по памяти",
|
||||
"description": "vidconf_container_memory_usage_bytes — usage без переиспользуемого page cache (working set), источник — container-exporter.",
|
||||
"type": "timeseries",
|
||||
"gridPos": { "h": 8, "w": 24, "x": 0, "y": 28 },
|
||||
"datasource": { "type": "prometheus", "uid": "prometheus" },
|
||||
"fieldConfig": {
|
||||
"defaults": { "unit": "decbytes", "custom": { "drawStyle": "line", "fillOpacity": 10, "stacking": { "mode": "normal" } } },
|
||||
"overrides": []
|
||||
},
|
||||
"targets": [
|
||||
{
|
||||
"datasource": { "type": "prometheus", "uid": "prometheus" },
|
||||
"expr": "topk(5, vidconf_container_memory_usage_bytes)",
|
||||
"legendFormat": "{{name}}",
|
||||
"refId": "A"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 12,
|
||||
"title": "Ядра CPU",
|
||||
"description": "Число ядер хоста (node-exporter, живое значение — работает на любой инсталляции, не только на 1gb).",
|
||||
"type": "stat",
|
||||
"gridPos": { "h": 4, "w": 6, "x": 0, "y": 36 },
|
||||
"datasource": { "type": "prometheus", "uid": "prometheus" },
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"unit": "none",
|
||||
"decimals": 0,
|
||||
"thresholds": { "mode": "absolute", "steps": [{ "color": "green", "value": null }] }
|
||||
},
|
||||
"overrides": []
|
||||
},
|
||||
"targets": [
|
||||
{
|
||||
"datasource": { "type": "prometheus", "uid": "prometheus" },
|
||||
"expr": "count(count by (cpu) (node_cpu_seconds_total{mode=\"idle\"}))",
|
||||
"refId": "A"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 13,
|
||||
"title": "Объём RAM",
|
||||
"description": "Полный объём памяти хоста (node-exporter, живое значение).",
|
||||
"type": "stat",
|
||||
"gridPos": { "h": 4, "w": 6, "x": 6, "y": 36 },
|
||||
"datasource": { "type": "prometheus", "uid": "prometheus" },
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"unit": "decbytes",
|
||||
"thresholds": { "mode": "absolute", "steps": [{ "color": "green", "value": null }] }
|
||||
},
|
||||
"overrides": []
|
||||
},
|
||||
"targets": [
|
||||
{
|
||||
"datasource": { "type": "prometheus", "uid": "prometheus" },
|
||||
"expr": "node_memory_MemTotal_bytes",
|
||||
"refId": "A"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 14,
|
||||
"title": "Диск (/)",
|
||||
"description": "Полный размер корневого раздела (node-exporter, живое значение) — не путать с панелью «Свободно на диске» выше (там остаток).",
|
||||
"type": "stat",
|
||||
"gridPos": { "h": 4, "w": 6, "x": 12, "y": 36 },
|
||||
"datasource": { "type": "prometheus", "uid": "prometheus" },
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"unit": "decbytes",
|
||||
"thresholds": { "mode": "absolute", "steps": [{ "color": "green", "value": null }] }
|
||||
},
|
||||
"overrides": []
|
||||
},
|
||||
"targets": [
|
||||
{
|
||||
"datasource": { "type": "prometheus", "uid": "prometheus" },
|
||||
"expr": "node_filesystem_size_bytes{mountpoint=\"/\",fstype!=\"tmpfs\"}",
|
||||
"refId": "A"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 15,
|
||||
"title": "GPU",
|
||||
"description": "Обнаруженная install.sh видеокарта (backend, gauge vidconf_host_info — HW_GPU_NAME из .env, ADR-004). «—» — видеокарты нет либо install.sh не запускался (dev-окружение). Таблица, а не stat-панель: у stat-панели в режиме отображения имени серии свой алгоритм автоподбора размера шрифта, который для однознаковых значений вроде «—» рисует нечитаемо мелкий текст.",
|
||||
"type": "table",
|
||||
"gridPos": { "h": 4, "w": 6, "x": 18, "y": 36 },
|
||||
"datasource": { "type": "prometheus", "uid": "prometheus" },
|
||||
"fieldConfig": {
|
||||
"defaults": { "custom": { "align": "center" } },
|
||||
"overrides": []
|
||||
},
|
||||
"options": {
|
||||
"showHeader": true,
|
||||
"cellHeight": "lg"
|
||||
},
|
||||
"transformations": [
|
||||
{
|
||||
"id": "organize",
|
||||
"options": {
|
||||
"excludeByName": {
|
||||
"Time": true,
|
||||
"Value": true,
|
||||
"__name__": true,
|
||||
"cpus": true,
|
||||
"ram_mb": true,
|
||||
"instance": true,
|
||||
"job": true
|
||||
},
|
||||
"renameByName": { "gpu_name": "Модель", "vram_mb": "VRAM, МБ" }
|
||||
}
|
||||
}
|
||||
],
|
||||
"targets": [
|
||||
{
|
||||
"datasource": { "type": "prometheus", "uid": "prometheus" },
|
||||
"expr": "vidconf_host_info",
|
||||
"format": "table",
|
||||
"instant": true,
|
||||
"refId": "A"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,9 +1,11 @@
|
||||
# Конфигурация Prometheus (devops) — сбор метрик backend,
|
||||
# PostgreSQL, Redis и локального LLM-сервера. Поднимается compose-профилем
|
||||
# `monitoring` (deploy/docker-compose.yml, сервис `prometheus`).
|
||||
# PostgreSQL, Redis, железа хоста, контейнеров и локального LLM-сервера.
|
||||
# Поднимается compose-профилем `monitoring` (deploy/docker-compose.yml,
|
||||
# сервис `prometheus`).
|
||||
#
|
||||
# Имена метрик backend (`vidconf_http_request_duration_seconds`,
|
||||
# `vidconf_pipeline_sessions`, `vidconf_celery_queue_depth`) — КОНТРАКТ с
|
||||
# `vidconf_pipeline_sessions`, `vidconf_celery_queue_depth`, `vidconf_db_up`,
|
||||
# `vidconf_db_pool_*`, `vidconf_redis_pool_*`) — КОНТРАКТ с
|
||||
# `backend/api/metrics.py`; правила в `alerts.yml` используют их буквально —
|
||||
# при переименовании метрик в backend поправить оба файла одновременно.
|
||||
global:
|
||||
@@ -33,6 +35,58 @@ scrape_configs:
|
||||
static_configs:
|
||||
- targets: ["redis-exporter:9121"]
|
||||
|
||||
# Хост целиком: CPU, память, диск, сеть, load average (профиль monitoring
|
||||
# — сервис node-exporter). Единственный источник, который покажет
|
||||
# нехватку памяти/CPU на сервере, если она не проявится как рост
|
||||
# латентности API (см. дашборд host.json).
|
||||
#
|
||||
# Адрес `host.docker.internal`, а не `node-exporter:9100`: с 2026-07-28
|
||||
# экспортер работает в host-сети и по имени сервиса в docker-сети не
|
||||
# резолвится. Причина перевода — сетевые метрики: `/proc/net` это симлинк
|
||||
# на `self/net`, поэтому в bridge-сети экспортер отдавал трафик
|
||||
# собственного `eth0` вместо хостового `enp3s0`. Имя резолвится через
|
||||
# `extra_hosts: host-gateway` у сервиса prometheus (deploy/docker-compose.yml).
|
||||
- job_name: node
|
||||
static_configs:
|
||||
- targets: ["host.docker.internal:9100"]
|
||||
|
||||
# Метрики по каждому контейнеру (CPU/память/сеть отдельно у backend,
|
||||
# worker, postgres и т.д. — профиль monitoring, сервис
|
||||
# `container-exporter`). Отвечает на вопрос «какой из контейнеров ест
|
||||
# ресурсы», в отличие от job `node` (только хост целиком). Замена
|
||||
# cAdvisor (несовместим с containerd-снапшоттером сервера `1gb`, см.
|
||||
# .forcc/JOURNAL.md) — свой экспортер поверх Docker Engine API,
|
||||
# метрики `vidconf_container_*`, см. deploy/monitoring/container-exporter/.
|
||||
- job_name: container-exporter
|
||||
static_configs:
|
||||
- targets: ["container-exporter:9419"]
|
||||
|
||||
# LiveKit SFU (профиль `media`). Порт объявлен в самом LiveKit —
|
||||
# `deploy/livekit/livekit.yaml.template`, секция `prometheus: port: 6789`;
|
||||
# наружу он не публикуется, скрейп идёт по имени сервиса внутри docker-сети.
|
||||
#
|
||||
# Почему это важно отдельно от `container-exporter`: тот показывает CPU,
|
||||
# память и суммарный трафик контейнера, но ничего не знает о том, ЧТО внутри
|
||||
# этого трафика. Разбор нагрузочного теста 28.07.2026 пришлось делать по
|
||||
# логам именно потому, что job'а здесь не было (см. .forcc/LOAD-FINDINGS.md).
|
||||
#
|
||||
# Ключевое, что отсюда появляется:
|
||||
# livekit_track_subscribed_total / livekit_track_published_total —
|
||||
# подписки против публикаций, то есть прямой эффект adaptiveStream;
|
||||
# livekit_participant_total, livekit_room_total — нагрузка в участниках;
|
||||
# livekit_quality_score, livekit_packet_loss_percent, livekit_rtt_ms,
|
||||
# livekit_jitter_us, livekit_nack_total, livekit_pli_total — качество
|
||||
# связи у клиентов, а не догадки по событиям congestion в логах;
|
||||
# livekit_webhook_queue_length, livekit_webhook_dispatch_total — очередь
|
||||
# доставки вебхуков в backend (на тесте она росла до 56 секунд).
|
||||
#
|
||||
# Профиль `media` входит в дефолтный набор COMPOSE_PROFILES (см. .env.example),
|
||||
# поэтому job включён, а не закомментирован, как `llm`. На инсталляции без
|
||||
# профиля `media` таргет не резолвится — закомментируйте секцию.
|
||||
- job_name: livekit
|
||||
static_configs:
|
||||
- targets: ["livekit:6789"]
|
||||
|
||||
# Локальный LLM-сервер (llama.cpp, LLAMA_ARG_ENDPOINT_METRICS=1). Адрес
|
||||
# `llm:8080` разрешается ОДНИМ из двух compose-сервисов в зависимости от
|
||||
# выбранного при установке пресета — `llm` (CPU, профиль `llm`, уровни
|
||||
|
||||
@@ -170,12 +170,33 @@ server {
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
# --- Медиа (аватары): раздача напрямую из volume, в обход backend. ---
|
||||
# --- Медиа (аватары, картинки фона): раздача напрямую из volume, в обход backend. ---
|
||||
location /media/ {
|
||||
alias /media/;
|
||||
autoindex off;
|
||||
}
|
||||
|
||||
# --- Рантайм MediaPipe для замены фона видео (сессия 35) ---
|
||||
# Ассеты сегментации (wasm-рантайм ~9 МБ + модель ~250 КБ) отдаются СО
|
||||
# СВОЕГО домена, а не с внешних CDN: VidConf ставят в закрытых контурах,
|
||||
# где jsdelivr и storage.googleapis.com недоступны, и там фича молча не
|
||||
# работала бы (см. frontend/src/lib/virtualBackground.ts).
|
||||
#
|
||||
# gzip именно здесь, а не на весь сервер: без сжатия wasm едет все 9 МБ,
|
||||
# со сжатием — около 3. Файлы неизменны в пределах сборки образа и
|
||||
# скачиваются один раз на браузер, поэтому кэшируются надолго.
|
||||
location /mediapipe/ {
|
||||
root /usr/share/nginx/html;
|
||||
gzip on;
|
||||
# Модель (.tflite) в список не входит намеренно: внутри она уже
|
||||
# сжатый архив, gzip дал бы 0% выигрыша за полную цену по CPU.
|
||||
gzip_types application/wasm application/javascript;
|
||||
gzip_min_length 1024;
|
||||
gzip_proxied any;
|
||||
expires 30d;
|
||||
add_header Cache-Control "public, max-age=2592000";
|
||||
}
|
||||
|
||||
# --- Frontend SPA (React + Vite): статика вкомпилирована в образ nginx
|
||||
# (frontend/Dockerfile) в /usr/share/nginx/html. `try_files` с
|
||||
# history-fallback на /index.html нужен для клиентского роутинга
|
||||
|
||||
@@ -9,27 +9,57 @@
|
||||
# запускайте ПЕРЕД `docker compose up` (и после каждого изменения .env,
|
||||
# влияющего на эти конфиги) — из корня репозитория:
|
||||
# ./deploy/render-templates.sh
|
||||
#
|
||||
# Источник значений можно подменить переменной ENV_FILE:
|
||||
# ENV_FILE=.env.local ./deploy/render-templates.sh
|
||||
# Это нужно для локальных стендов: корневой `.env` рабочего чекаута обычно
|
||||
# держит боевые значения (LIVEKIT_NODE_IP/TURN_EXTERNAL_IP смотрят на прод), и
|
||||
# рендерить конфиги из него для локального запуска нельзя. Без этой переменной
|
||||
# путь был жёстко зашит, и локальные сессии повторяли логику скрипта вручную
|
||||
# через envsubst — расхождение с реальным рендером ждало своего часа.
|
||||
# По умолчанию — как раньше, `<корень репозитория>/.env`, поэтому install.sh
|
||||
# и сервер ничего не замечают (свою переменную ENV_FILE install.sh не
|
||||
# экспортирует, так что она сюда не протекает).
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
ENV_FILE="$SCRIPT_DIR/../.env"
|
||||
# Относительный путь разрешаем от текущей директории вызова (`ENV_FILE=.env.local`
|
||||
# из корня репозитория), абсолютный — как есть.
|
||||
ENV_FILE="${ENV_FILE:-$SCRIPT_DIR/../.env}"
|
||||
|
||||
if [ ! -f "$ENV_FILE" ]; then
|
||||
echo "render-templates.sh: не найден $ENV_FILE — сначала запустите ./install.sh или скопируйте .env.example в .env" >&2
|
||||
echo "render-templates.sh: другой файл значений можно задать так: ENV_FILE=.env.local $0" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "[render] источник значений: $ENV_FILE"
|
||||
|
||||
# Читаем только нужные ключи через grep/cut (НЕ `source .env`) — .env содержит
|
||||
# значения вроде `SMTP_FROM=VidConf <no-reply@vidconf.example>`, где `<` —
|
||||
# валидный литерал для docker-compose/pydantic, но невалидный bash-синтаксис
|
||||
# (интерпретируется как редирект) при попытке `source` файла целиком.
|
||||
#
|
||||
# `|| true` в конце обязателен: под `set -e -o pipefail` (см. выше) сборка
|
||||
# `"$(env_var VAR)"` для ключа, которого в файле нет ВООБЩЕ (не просто
|
||||
# пустое значение, а отсутствующая строка) иначе завершает весь скрипт
|
||||
# ошибкой grep ДО того, как сработает дружелюбная проверка `:?` ниже —
|
||||
# найдено на TURN_TLS_HOST (новый необязательный ключ, есть не во всех
|
||||
# существующих .env).
|
||||
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_REALM="$(env_var TURN_REALM)"
|
||||
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_NODE_IP="$(env_var LIVEKIT_NODE_IP)"
|
||||
LIVEKIT_USE_EXTERNAL_IP="$(env_var LIVEKIT_USE_EXTERNAL_IP)"
|
||||
@@ -43,14 +73,31 @@ REDIS_PASSWORD="$(env_var REDIS_PASSWORD)"
|
||||
: "${LIVEKIT_USE_EXTERNAL_IP:?LIVEKIT_USE_EXTERNAL_IP не задан в .env (true/false)}"
|
||||
: "${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}' \
|
||||
< "$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 пуст)")"
|
||||
|
||||
envsubst '${LIVEKIT_USE_EXTERNAL_IP} ${LIVEKIT_NODE_IP} ${LIVEKIT_API_KEY} ${REDIS_PASSWORD}' \
|
||||
# TURN_EXTERNAL_IP и TURN_STATIC_AUTH_SECRET нужны и здесь: с 0.0.14 LiveKit
|
||||
# анонсирует клиентам внешний coturn (секция `rtc.turn_servers`), и секрет
|
||||
# обязан совпадать с `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} ${TURN_TLS_HOST}' \
|
||||
< "$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 готов"
|
||||
|
||||
envsubst '${REDIS_PASSWORD}' \
|
||||
|
||||
33
design/backgrounds/README.md
Normal file
33
design/backgrounds/README.md
Normal file
@@ -0,0 +1,33 @@
|
||||
# Дефолтные фоны замены фона видео
|
||||
|
||||
Три сцены, которые предлагаются участнику «из коробки» при включённом модуле
|
||||
«Замена фона» (`instance_settings.virtual_background`):
|
||||
|
||||
| файл | сцена |
|
||||
|---|---|
|
||||
| `office.svg` | офис: окно с городом, картина, лампа, стеллаж, растение |
|
||||
| `beach.svg` | пляж: море, песок, пальмы по краям кадра |
|
||||
| `space-station.svg` | космическая станция: панели, иллюминатор с Землёй |
|
||||
|
||||
## Откуда взялись картинки
|
||||
|
||||
**Это собственные векторные рисунки VidConf, а не фотографии из интернета.**
|
||||
Так решено сознательно (решение оператора от 09.08.2026): чужая фотография
|
||||
тянет за собой чужую лицензию, а продукт самоуправляемый и расходится по
|
||||
инсталляциям — проверять права на каждую копию некому. У нарисованной сцены
|
||||
правовых вопросов нет вовсе.
|
||||
|
||||
Сцены намеренно расфокусированы (`feGaussianBlur`) и оставляют спокойным центр
|
||||
кадра: там находится человек, а резкая графика за спиной сильнее всего выдаёт
|
||||
подмену фона.
|
||||
|
||||
## Как пересобрать
|
||||
|
||||
```bash
|
||||
python3 design/backgrounds/render.py
|
||||
```
|
||||
|
||||
Скрипт растеризует каждый `*.svg` в 1280×720 через headless Chrome и кладёт
|
||||
результат в `frontend/public/backgrounds/*.webp` (именно эти файлы и
|
||||
раздаются фронтендом). Нужны установленный Google Chrome и Pillow.
|
||||
Правьте SVG — WebP пересобирается из них, вручную растровые файлы не трогаем.
|
||||
106
design/backgrounds/beach.svg
Normal file
106
design/backgrounds/beach.svg
Normal file
@@ -0,0 +1,106 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="1280" height="720" viewBox="0 0 1280 720">
|
||||
<title>Пляж с пальмами — дефолтный фон замены фона видео</title>
|
||||
<desc>Собственная векторная сцена VidConf. Море, песок и пальмы по краям кадра;
|
||||
центр оставлен спокойным — там находится человек.</desc>
|
||||
<defs>
|
||||
<linearGradient id="sky" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0" stop-color="#7fc4e8"/>
|
||||
<stop offset="0.55" stop-color="#bfe4f2"/>
|
||||
<stop offset="1" stop-color="#f2e7c9"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="sea" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0" stop-color="#2a8fb5"/>
|
||||
<stop offset="0.5" stop-color="#3aa8c4"/>
|
||||
<stop offset="1" stop-color="#63c8cf"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="sand" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0" stop-color="#f0dfb8"/>
|
||||
<stop offset="1" stop-color="#d8bf90"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="trunk" x1="0" y1="0" x2="1" y2="0">
|
||||
<stop offset="0" stop-color="#8c6239"/>
|
||||
<stop offset="1" stop-color="#5f4126"/>
|
||||
</linearGradient>
|
||||
<radialGradient id="sun" cx="0.5" cy="0.5" r="0.5">
|
||||
<stop offset="0" stop-color="#fffbe8" stop-opacity="0.95"/>
|
||||
<stop offset="1" stop-color="#fff3c4" stop-opacity="0"/>
|
||||
</radialGradient>
|
||||
<radialGradient id="beachVignette" cx="0.5" cy="0.45" r="0.8">
|
||||
<stop offset="0.55" stop-color="#000000" stop-opacity="0"/>
|
||||
<stop offset="1" stop-color="#1f4a5c" stop-opacity="0.26"/>
|
||||
</radialGradient>
|
||||
<filter id="seaSoft" x="-5%" y="-5%" width="110%" height="110%">
|
||||
<feGaussianBlur stdDeviation="4"/>
|
||||
</filter>
|
||||
<filter id="palmSoft" x="-8%" y="-8%" width="116%" height="116%">
|
||||
<feGaussianBlur stdDeviation="7"/>
|
||||
</filter>
|
||||
</defs>
|
||||
|
||||
<rect width="1280" height="720" fill="url(#sky)"/>
|
||||
<ellipse cx="900" cy="150" rx="300" ry="230" fill="url(#sun)"/>
|
||||
|
||||
<g filter="url(#seaSoft)">
|
||||
<!-- Облака -->
|
||||
<ellipse cx="270" cy="128" rx="130" ry="34" fill="#ffffff" opacity="0.72"/>
|
||||
<ellipse cx="350" cy="112" rx="88" ry="28" fill="#ffffff" opacity="0.6"/>
|
||||
<ellipse cx="1010" cy="96" rx="112" ry="28" fill="#ffffff" opacity="0.5"/>
|
||||
|
||||
<!-- Море -->
|
||||
<rect x="0" y="352" width="1280" height="176" fill="url(#sea)"/>
|
||||
<!-- Блики на воде -->
|
||||
<g fill="#ffffff" opacity="0.4">
|
||||
<rect x="120" y="392" width="150" height="6" rx="3"/>
|
||||
<rect x="360" y="420" width="210" height="6" rx="3"/>
|
||||
<rect x="700" y="400" width="170" height="6" rx="3"/>
|
||||
<rect x="980" y="440" width="190" height="6" rx="3"/>
|
||||
<rect x="240" y="466" width="130" height="5" rx="2.5"/>
|
||||
<rect x="820" y="474" width="150" height="5" rx="2.5"/>
|
||||
</g>
|
||||
<!-- Пена у берега -->
|
||||
<path d="M0 512c180 26 360-12 540 8s360 34 740-8v36H0z" fill="#ffffff" opacity="0.75"/>
|
||||
</g>
|
||||
|
||||
<!-- Песок -->
|
||||
<rect x="0" y="530" width="1280" height="190" fill="url(#sand)"/>
|
||||
<g fill="#c9ac7c" opacity="0.45">
|
||||
<ellipse cx="300" cy="640" rx="180" ry="18"/>
|
||||
<ellipse cx="960" cy="682" rx="220" ry="20"/>
|
||||
</g>
|
||||
|
||||
<!-- Пальмы по краям кадра -->
|
||||
<g filter="url(#palmSoft)">
|
||||
<g>
|
||||
<path d="M168 704c-6-190 6-320 40-420l26 8c-30 100-40 226-34 412z" fill="url(#trunk)"/>
|
||||
<g fill="#2f7a4f">
|
||||
<path d="M234 292c-84-30-150-16-196 40 66-16 128-14 186 12z"/>
|
||||
<path d="M234 292c-58-72-124-96-196-76 62 22 116 58 164 102z"/>
|
||||
<path d="M240 288c50-72 116-98 190-82-62 26-114 62-158 108z"/>
|
||||
<path d="M242 296c86-24 154-4 198 56-66-22-128-24-186 4z"/>
|
||||
<path d="M238 282c14-78-8-140-70-176 26 66 38 128 40 184z"/>
|
||||
</g>
|
||||
<g fill="#245f3e">
|
||||
<path d="M236 300c-46 16-88 46-124 90 48-32 94-52 138-60z"/>
|
||||
<path d="M240 300c50 14 94 46 130 92-48-34-96-56-142-64z"/>
|
||||
</g>
|
||||
<circle cx="228" cy="306" r="12" fill="#7a5a30"/>
|
||||
<circle cx="252" cy="312" r="11" fill="#7a5a30"/>
|
||||
</g>
|
||||
|
||||
<g>
|
||||
<path d="M1136 720c10-166 2-278-26-360l-24 8c26 84 34 190 26 352z" fill="url(#trunk)"/>
|
||||
<g fill="#2f7a4f">
|
||||
<path d="M1084 352c72-30 134-20 178 30-60-16-118-14-172 8z"/>
|
||||
<path d="M1084 352c50-66 110-90 176-72-56 20-106 54-150 96z"/>
|
||||
<path d="M1080 348c-46-64-104-88-170-74 56 24 102 58 142 100z"/>
|
||||
<path d="M1078 356c-78-22-140-4-180 50 60-20 116-22 168 4z"/>
|
||||
</g>
|
||||
<g fill="#245f3e">
|
||||
<path d="M1082 360c42 14 80 42 112 82-42-30-84-48-124-56z"/>
|
||||
</g>
|
||||
<circle cx="1090" cy="364" r="11" fill="#7a5a30"/>
|
||||
</g>
|
||||
</g>
|
||||
|
||||
<rect width="1280" height="720" fill="url(#beachVignette)"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 4.7 KiB |
130
design/backgrounds/office.svg
Normal file
130
design/backgrounds/office.svg
Normal file
@@ -0,0 +1,130 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="1280" height="720" viewBox="0 0 1280 720">
|
||||
<title>Офис — дефолтный фон замены фона видео</title>
|
||||
<desc>Собственная векторная сцена VidConf. Светлый переговорный угол: окно с
|
||||
тёплым светом, стеллаж с книгами и растениями. Центр кадра намеренно спокойный —
|
||||
там будет человек, детали вынесены к краям.</desc>
|
||||
<defs>
|
||||
<linearGradient id="wall" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0" stop-color="#f3ede4"/>
|
||||
<stop offset="0.62" stop-color="#e7ded1"/>
|
||||
<stop offset="1" stop-color="#d9cebe"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="floor" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0" stop-color="#c9b49a"/>
|
||||
<stop offset="1" stop-color="#a98e70"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="daylight" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0" stop-color="#fdfaf2"/>
|
||||
<stop offset="1" stop-color="#e8eef1"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="wood" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0" stop-color="#b58a5f"/>
|
||||
<stop offset="1" stop-color="#8d6540"/>
|
||||
</linearGradient>
|
||||
<radialGradient id="glow" cx="0.5" cy="0.5" r="0.5">
|
||||
<stop offset="0" stop-color="#fff6de" stop-opacity="0.85"/>
|
||||
<stop offset="1" stop-color="#fff6de" stop-opacity="0"/>
|
||||
</radialGradient>
|
||||
<radialGradient id="vignette" cx="0.5" cy="0.45" r="0.78">
|
||||
<stop offset="0.55" stop-color="#000000" stop-opacity="0"/>
|
||||
<stop offset="1" stop-color="#3b2d1e" stop-opacity="0.3"/>
|
||||
</radialGradient>
|
||||
<!-- Мягкое расфокусирование: фон за спиной человека в кадре камеры никогда
|
||||
не бывает резким, и резкая графика выдаёт подмену сильнее всего. -->
|
||||
<filter id="soft" x="-6%" y="-6%" width="112%" height="112%">
|
||||
<feGaussianBlur stdDeviation="5"/>
|
||||
</filter>
|
||||
<filter id="softer" x="-8%" y="-8%" width="116%" height="116%">
|
||||
<feGaussianBlur stdDeviation="10"/>
|
||||
</filter>
|
||||
</defs>
|
||||
|
||||
<rect width="1280" height="720" fill="url(#wall)"/>
|
||||
|
||||
<g filter="url(#soft)">
|
||||
<!-- Окно слева -->
|
||||
<rect x="52" y="86" width="330" height="392" rx="10" fill="#cbb9a3"/>
|
||||
<rect x="66" y="100" width="302" height="364" rx="6" fill="url(#daylight)"/>
|
||||
<rect x="212" y="100" width="10" height="364" fill="#cbb9a3"/>
|
||||
<rect x="66" y="276" width="302" height="10" fill="#cbb9a3"/>
|
||||
<!-- Городская дымка за стеклом -->
|
||||
<rect x="66" y="352" width="302" height="112" fill="#dfe6e6" opacity="0.85"/>
|
||||
<rect x="96" y="300" width="46" height="164" fill="#d3dcde" opacity="0.7"/>
|
||||
<rect x="158" y="330" width="34" height="134" fill="#cfd8db" opacity="0.6"/>
|
||||
<rect x="248" y="316" width="52" height="148" fill="#d3dcde" opacity="0.65"/>
|
||||
<rect x="314" y="344" width="30" height="120" fill="#cfd8db" opacity="0.55"/>
|
||||
|
||||
<!-- Стеллаж справа -->
|
||||
<rect x="876" y="120" width="352" height="392" rx="8" fill="url(#wood)"/>
|
||||
<rect x="892" y="136" width="320" height="112" fill="#e9dcc9" opacity="0.55"/>
|
||||
<rect x="892" y="264" width="320" height="112" fill="#e9dcc9" opacity="0.5"/>
|
||||
<rect x="892" y="392" width="320" height="104" fill="#e9dcc9" opacity="0.45"/>
|
||||
<!-- Книги -->
|
||||
<g>
|
||||
<rect x="906" y="156" width="18" height="92" fill="#8a5a4a"/>
|
||||
<rect x="928" y="168" width="14" height="80" fill="#6c7f6a"/>
|
||||
<rect x="946" y="150" width="20" height="98" fill="#c08a4a"/>
|
||||
<rect x="970" y="172" width="16" height="76" fill="#4f6478"/>
|
||||
<rect x="990" y="160" width="12" height="88" fill="#9d5f5f"/>
|
||||
<rect x="1010" y="176" width="22" height="72" fill="#7a6a55"/>
|
||||
|
||||
<rect x="906" y="292" width="16" height="84" fill="#5f7382"/>
|
||||
<rect x="926" y="284" width="20" height="92" fill="#a86f4e"/>
|
||||
<rect x="950" y="300" width="14" height="76" fill="#7f8f76"/>
|
||||
<rect x="968" y="288" width="18" height="88" fill="#8d5a63"/>
|
||||
</g>
|
||||
<!-- Растение на полке -->
|
||||
<g>
|
||||
<path d="M1140 392c-26-14-40-42-34-72 26 6 44 30 44 60z" fill="#4f7a52"/>
|
||||
<path d="M1150 392c26-16 38-46 30-76-26 8-42 34-40 64z" fill="#5f8f5f"/>
|
||||
<rect x="1128" y="386" width="40" height="30" rx="5" fill="#b98a63"/>
|
||||
</g>
|
||||
|
||||
<!-- Картина на стене между окном и стеллажом -->
|
||||
<g>
|
||||
<rect x="486" y="150" width="196" height="146" rx="6" fill="#c2a882"/>
|
||||
<rect x="498" y="162" width="172" height="122" fill="#eae3d3"/>
|
||||
<path d="M498 284l52-58 40 34 44-52 36 76z" fill="#9fb5a2"/>
|
||||
<circle cx="622" cy="196" r="16" fill="#e8c579"/>
|
||||
</g>
|
||||
|
||||
<!-- Настольная лампа и край стола справа от картины -->
|
||||
<g>
|
||||
<rect x="700" y="452" width="220" height="14" rx="4" fill="#c8a878"/>
|
||||
<rect x="712" y="466" width="12" height="46" fill="#b2915f"/>
|
||||
<rect x="896" y="466" width="12" height="46" fill="#b2915f"/>
|
||||
<rect x="792" y="392" width="8" height="60" fill="#6f7b82"/>
|
||||
<path d="M760 392l36-52 36 52z" fill="#7f8d95"/>
|
||||
<ellipse cx="796" cy="424" rx="54" ry="18" fill="#ffe9b8" opacity="0.5"/>
|
||||
</g>
|
||||
</g>
|
||||
|
||||
<!-- Пол -->
|
||||
<rect x="0" y="512" width="1280" height="208" fill="url(#floor)"/>
|
||||
<rect x="0" y="506" width="1280" height="14" fill="#e9e0d3" opacity="0.7"/>
|
||||
|
||||
<!-- Крупное растение в левом углу — передний план, размыто сильнее -->
|
||||
<g filter="url(#softer)" opacity="0.95">
|
||||
<g fill="#3f6b45">
|
||||
<ellipse cx="88" cy="508" rx="52" ry="34" transform="rotate(-24 88 508)"/>
|
||||
<ellipse cx="206" cy="556" rx="56" ry="36" transform="rotate(18 206 556)"/>
|
||||
<ellipse cx="70" cy="596" rx="48" ry="32" transform="rotate(-8 70 596)"/>
|
||||
</g>
|
||||
<g fill="#4c7f52">
|
||||
<ellipse cx="158" cy="486" rx="50" ry="32" transform="rotate(6 158 486)"/>
|
||||
<ellipse cx="248" cy="626" rx="46" ry="30" transform="rotate(32 248 626)"/>
|
||||
<ellipse cx="124" cy="574" rx="54" ry="34" transform="rotate(-14 124 574)"/>
|
||||
</g>
|
||||
<g stroke="#35603c" stroke-width="7" fill="none" stroke-linecap="round">
|
||||
<path d="M120 668c-16-56-24-108-32-160"/>
|
||||
<path d="M132 668c14-52 34-96 62-136"/>
|
||||
<path d="M126 668c2-44 20-76 46-96"/>
|
||||
</g>
|
||||
<rect x="52" y="656" width="150" height="86" rx="14" fill="#9d7550"/>
|
||||
<rect x="52" y="656" width="150" height="16" rx="8" fill="#b08a63"/>
|
||||
</g>
|
||||
|
||||
<!-- Свет из окна и общая виньетка -->
|
||||
<ellipse cx="300" cy="250" rx="460" ry="330" fill="url(#glow)"/>
|
||||
<rect width="1280" height="720" fill="url(#vignette)"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 6.7 KiB |
73
design/backgrounds/render.py
Normal file
73
design/backgrounds/render.py
Normal file
@@ -0,0 +1,73 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Запекает векторные сцены дефолтных фонов (`*.svg`) в WebP для фронтенда.
|
||||
|
||||
Исходники сцен — собственные рисунки VidConf (см. README рядом), поэтому
|
||||
лежат в репозитории вместе с результатом: чужих фотографий с их лицензиями в
|
||||
продукте нет.
|
||||
|
||||
Запуск (нужен установленный Google Chrome и Pillow):
|
||||
|
||||
python3 design/backgrounds/render.py
|
||||
|
||||
Результат — `frontend/public/backgrounds/*.webp`, 1280×720. Chrome нужен
|
||||
только для растеризации SVG (rsvg/ImageMagick на машине может не быть),
|
||||
Pillow — для перевода PNG в WebP: браузерный скриншот WebP не отдаёт.
|
||||
"""
|
||||
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
from PIL import Image
|
||||
|
||||
CHROME = "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome"
|
||||
WIDTH, HEIGHT = 1280, 720
|
||||
# 82 — визуально неотличимо от исходника на плавных градиентах сцены, а вес
|
||||
# втрое меньше, чем при 95. Фон грузится по сети при каждом первом показе.
|
||||
WEBP_QUALITY = 82
|
||||
|
||||
SOURCE_DIR = Path(__file__).parent
|
||||
TARGET_DIR = SOURCE_DIR.parents[1] / "frontend" / "public" / "backgrounds"
|
||||
|
||||
|
||||
def render(svg_path: Path, out_path: Path, workdir: Path) -> None:
|
||||
"""Растеризовать одну сцену и сохранить её в WebP."""
|
||||
png_path = workdir / f"{svg_path.stem}.png"
|
||||
subprocess.run(
|
||||
[
|
||||
CHROME,
|
||||
"--headless",
|
||||
"--disable-gpu",
|
||||
"--hide-scrollbars",
|
||||
"--default-background-color=00000000",
|
||||
f"--screenshot={png_path}",
|
||||
f"--window-size={WIDTH},{HEIGHT}",
|
||||
svg_path.resolve().as_uri(),
|
||||
],
|
||||
check=True,
|
||||
capture_output=True,
|
||||
)
|
||||
with Image.open(png_path) as image:
|
||||
image.convert("RGB").save(out_path, "WEBP", quality=WEBP_QUALITY, method=6)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
if not Path(CHROME).exists():
|
||||
print(f"не найден Chrome: {CHROME}", file=sys.stderr)
|
||||
return 1
|
||||
TARGET_DIR.mkdir(parents=True, exist_ok=True)
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
workdir = Path(tmp)
|
||||
for svg_path in sorted(SOURCE_DIR.glob("*.svg")):
|
||||
out_path = TARGET_DIR / f"{svg_path.stem}.webp"
|
||||
render(svg_path, out_path, workdir)
|
||||
print(
|
||||
f"{svg_path.name} → {out_path.relative_to(SOURCE_DIR.parents[1])}"
|
||||
f" ({out_path.stat().st_size // 1024} КБ)"
|
||||
)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
150
design/backgrounds/space-station.svg
Normal file
150
design/backgrounds/space-station.svg
Normal file
@@ -0,0 +1,150 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="1280" height="720" viewBox="0 0 1280 720">
|
||||
<title>Космическая станция — дефолтный фон замены фона видео</title>
|
||||
<desc>Собственная векторная сцена VidConf. Интерьер модуля станции: панели,
|
||||
подсветка, иллюминатор с Землёй. Центр кадра спокойный — там человек.</desc>
|
||||
<defs>
|
||||
<linearGradient id="hull" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0" stop-color="#2b3446"/>
|
||||
<stop offset="0.5" stop-color="#222a3a"/>
|
||||
<stop offset="1" stop-color="#161c28"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="panel" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0" stop-color="#39445a"/>
|
||||
<stop offset="1" stop-color="#28304180"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="strip" x1="0" y1="0" x2="1" y2="0">
|
||||
<stop offset="0" stop-color="#4fd7ff" stop-opacity="0.1"/>
|
||||
<stop offset="0.5" stop-color="#7fe8ff" stop-opacity="0.9"/>
|
||||
<stop offset="1" stop-color="#4fd7ff" stop-opacity="0.1"/>
|
||||
</linearGradient>
|
||||
<radialGradient id="earth" cx="0.36" cy="0.32" r="0.78">
|
||||
<stop offset="0" stop-color="#7fd1f5"/>
|
||||
<stop offset="0.55" stop-color="#2f7fc4"/>
|
||||
<stop offset="1" stop-color="#0c2c56"/>
|
||||
</radialGradient>
|
||||
<radialGradient id="atmo" cx="0.5" cy="0.5" r="0.5">
|
||||
<stop offset="0.78" stop-color="#8fd8ff" stop-opacity="0"/>
|
||||
<stop offset="0.9" stop-color="#8fd8ff" stop-opacity="0.5"/>
|
||||
<stop offset="1" stop-color="#8fd8ff" stop-opacity="0"/>
|
||||
</radialGradient>
|
||||
<radialGradient id="portGlow" cx="0.5" cy="0.5" r="0.5">
|
||||
<stop offset="0" stop-color="#8fd8ff" stop-opacity="0.4"/>
|
||||
<stop offset="1" stop-color="#8fd8ff" stop-opacity="0"/>
|
||||
</radialGradient>
|
||||
<radialGradient id="spaceVignette" cx="0.5" cy="0.45" r="0.78">
|
||||
<stop offset="0.5" stop-color="#000000" stop-opacity="0"/>
|
||||
<stop offset="1" stop-color="#05080f" stop-opacity="0.65"/>
|
||||
</radialGradient>
|
||||
<filter id="stationSoft" x="-6%" y="-6%" width="112%" height="112%">
|
||||
<feGaussianBlur stdDeviation="4"/>
|
||||
</filter>
|
||||
<filter id="stationSofter" x="-8%" y="-8%" width="116%" height="116%">
|
||||
<feGaussianBlur stdDeviation="9"/>
|
||||
</filter>
|
||||
<!-- Всё, что «за стеклом», обрезается по стеклу: без этого Земля вылезала
|
||||
за переплёт иллюминатора и сцена читалась как наклейка на стене. -->
|
||||
<clipPath id="portholeGlass">
|
||||
<circle cx="640" cy="318" r="192"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
|
||||
<rect width="1280" height="720" fill="url(#hull)"/>
|
||||
|
||||
<g filter="url(#stationSoft)">
|
||||
<!-- Рёбра модуля -->
|
||||
<g fill="#39445a" opacity="0.55">
|
||||
<rect x="0" y="0" width="1280" height="52"/>
|
||||
<rect x="0" y="668" width="1280" height="52"/>
|
||||
<rect x="150" y="52" width="26" height="616"/>
|
||||
<rect x="1104" y="52" width="26" height="616"/>
|
||||
</g>
|
||||
|
||||
<!-- Приборные панели слева -->
|
||||
<g>
|
||||
<rect x="46" y="150" width="220" height="300" rx="14" fill="url(#panel)"/>
|
||||
<rect x="66" y="172" width="180" height="86" rx="6" fill="#101826"/>
|
||||
<g fill="#5ee0a8" opacity="0.85">
|
||||
<rect x="78" y="230" width="120" height="4" rx="2"/>
|
||||
<rect x="78" y="216" width="82" height="4" rx="2"/>
|
||||
<rect x="78" y="202" width="146" height="4" rx="2"/>
|
||||
</g>
|
||||
<g>
|
||||
<circle cx="92" cy="292" r="11" fill="#ff8a5c"/>
|
||||
<circle cx="126" cy="292" r="11" fill="#ffd166"/>
|
||||
<circle cx="160" cy="292" r="11" fill="#5ee0a8"/>
|
||||
</g>
|
||||
<g fill="#4a5670">
|
||||
<rect x="66" y="322" width="180" height="16" rx="8"/>
|
||||
<rect x="66" y="352" width="180" height="16" rx="8"/>
|
||||
<rect x="66" y="382" width="180" height="16" rx="8"/>
|
||||
</g>
|
||||
<rect x="66" y="322" width="104" height="16" rx="8" fill="#7fe8ff" opacity="0.8"/>
|
||||
<rect x="66" y="352" width="62" height="16" rx="8" fill="#7fe8ff" opacity="0.6"/>
|
||||
</g>
|
||||
|
||||
<!-- Стеллаж оборудования справа -->
|
||||
<g>
|
||||
<rect x="1014" y="128" width="230" height="344" rx="14" fill="url(#panel)"/>
|
||||
<g fill="#101826">
|
||||
<rect x="1034" y="150" width="190" height="70" rx="6"/>
|
||||
<rect x="1034" y="238" width="190" height="70" rx="6"/>
|
||||
<rect x="1034" y="326" width="190" height="70" rx="6"/>
|
||||
</g>
|
||||
<g fill="#7fe8ff" opacity="0.6">
|
||||
<rect x="1050" y="176" width="86" height="5" rx="2.5"/>
|
||||
<rect x="1050" y="264" width="126" height="5" rx="2.5"/>
|
||||
<rect x="1050" y="352" width="64" height="5" rx="2.5"/>
|
||||
</g>
|
||||
<circle cx="1206" cy="424" r="14" fill="#ff8a5c" opacity="0.8"/>
|
||||
</g>
|
||||
|
||||
<!-- Иллюминатор -->
|
||||
<g>
|
||||
<circle cx="640" cy="318" r="212" fill="#39445a"/>
|
||||
<circle cx="640" cy="318" r="192" fill="#0b1120"/>
|
||||
<g clip-path="url(#portholeGlass)">
|
||||
<!-- Звёзды -->
|
||||
<g fill="#ffffff">
|
||||
<circle cx="530" cy="200" r="2.5" opacity="0.9"/>
|
||||
<circle cx="596" cy="168" r="1.8" opacity="0.7"/>
|
||||
<circle cx="712" cy="188" r="2.2" opacity="0.8"/>
|
||||
<circle cx="768" cy="252" r="1.6" opacity="0.6"/>
|
||||
<circle cx="500" cy="300" r="1.8" opacity="0.65"/>
|
||||
<circle cx="742" cy="356" r="2" opacity="0.7"/>
|
||||
<circle cx="556" cy="424" r="1.7" opacity="0.6"/>
|
||||
<circle cx="676" cy="456" r="2.3" opacity="0.75"/>
|
||||
</g>
|
||||
<!-- Земля -->
|
||||
<circle cx="596" cy="386" r="150" fill="url(#earth)"/>
|
||||
<g fill="#3f8f5f" opacity="0.75">
|
||||
<path d="M520 330c40-16 74-10 96 14-34 8-64 12-96 6z"/>
|
||||
<path d="M596 430c46-8 84 6 108 40-42 4-80-8-108-30z"/>
|
||||
<path d="M498 404c26 6 46 22 58 46-28-4-50-18-66-38z"/>
|
||||
</g>
|
||||
<g fill="#ffffff" opacity="0.35">
|
||||
<ellipse cx="560" cy="352" rx="70" ry="16"/>
|
||||
<ellipse cx="656" cy="416" rx="86" ry="18"/>
|
||||
</g>
|
||||
<circle cx="596" cy="386" r="150" fill="url(#atmo)"/>
|
||||
</g>
|
||||
<!-- Переплёт иллюминатора -->
|
||||
<circle cx="640" cy="318" r="192" fill="none" stroke="#4a5670" stroke-width="14"/>
|
||||
<circle cx="640" cy="318" r="206" fill="none" stroke="#2b3446" stroke-width="16"/>
|
||||
<g fill="#4a5670">
|
||||
<rect x="622" y="98" width="36" height="34" rx="8"/>
|
||||
<rect x="622" y="504" width="36" height="34" rx="8"/>
|
||||
<rect x="420" y="300" width="34" height="36" rx="8"/>
|
||||
<rect x="826" y="300" width="34" height="36" rx="8"/>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
|
||||
<!-- Свечение из иллюминатора и световые полосы модуля -->
|
||||
<ellipse cx="640" cy="318" rx="420" ry="330" fill="url(#portGlow)"/>
|
||||
<g filter="url(#stationSofter)">
|
||||
<rect x="176" y="60" width="928" height="10" rx="5" fill="url(#strip)"/>
|
||||
<rect x="176" y="650" width="928" height="10" rx="5" fill="url(#strip)"/>
|
||||
</g>
|
||||
|
||||
<rect width="1280" height="720" fill="url(#spaceVignette)"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 7.0 KiB |
@@ -393,7 +393,9 @@ Email сразу считается подтверждённым (письмо
|
||||
"registration_team_choice": false,
|
||||
"registration_email_domain_enabled": false,
|
||||
"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` — эталонный домен email (нормализован: без ведущего `@`, в нижнем регистре); `null`, пока верификация не настроена
|
||||
- `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, то есть без ограничения); участники сверх лимита уходят на следующую страницу пагинации
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -81,10 +81,26 @@ ufw allow 22/tcp # SSH — сузьте до вашей сети, если
|
||||
ufw allow 80/tcp # HTTP (редирект на HTTPS + ACME-challenge)
|
||||
ufw allow 443/tcp # HTTPS
|
||||
ufw allow 7881/tcp # LiveKit RTC TCP fallback (профиль media)
|
||||
ufw allow 54000:54100/udp # LiveKit WebRTC media (ICE), см. docker-compose.yml
|
||||
# TURN (coturn) — только если включаете раздел 8:
|
||||
ufw allow 54000/udp # LiveKit WebRTC media (ICE, мультиплекс), см. docker-compose.yml
|
||||
# TURN (coturn) — только если включаете раздел 8. Нужны ОБА пункта:
|
||||
# сигнальные порты И диапазон relay-аллокаций (min-port/max-port из
|
||||
# deploy/coturn/turnserver.conf). Без второго TURN отвечает на запросы, но
|
||||
# сам релей не работает — клиент получает кандидата и не может им
|
||||
# воспользоваться, а в логах coturn при этом тишина.
|
||||
# ufw allow 3478/tcp
|
||||
# ufw allow 3478/udp
|
||||
# ufw allow 49160:49200/udp
|
||||
|
||||
# Мониторинг (профиль `monitoring`): node-exporter работает в host-сети —
|
||||
# иначе он отдаёт сетевые метрики собственного контейнера вместо метрик
|
||||
# сервера (`/proc/net` — симлинк на `self/net`, bind-mount `/proc` этого не
|
||||
# обходит; см. комментарий у сервиса в deploy/docker-compose.yml). Порт
|
||||
# слушается на хосте, поэтому Prometheus в docker-сети упирается в
|
||||
# политику ufw по умолчанию. Правило разрешает скрейп ТОЛЬКО из внутренних
|
||||
# docker-подсетей — снаружи 9100 остаётся закрыт (172.16.0.0/12 не
|
||||
# маршрутизируется в интернете):
|
||||
ufw allow from 172.16.0.0/12 to any port 9100 proto tcp comment 'node-exporter: скрейп Prometheus из docker-сети'
|
||||
|
||||
ufw enable
|
||||
```
|
||||
|
||||
@@ -175,6 +191,14 @@ chmod 600 .env # секреты внутри — только root может
|
||||
собирает образы, поднимает `postgres`/`redis`, применяет миграции Alembic +
|
||||
seed, поднимает остальной стек (`up -d --wait`).
|
||||
|
||||
По умолчанию `render-templates.sh` читает корневой `.env`. Другой файл
|
||||
значений задаётся переменной окружения — это нужно, когда рядом с рабочим
|
||||
`.env` (боевые адреса) поднимается локальный стенд:
|
||||
|
||||
```bash
|
||||
ENV_FILE=.env.local ./deploy/render-templates.sh
|
||||
```
|
||||
|
||||
**Не запускайте `docker compose` вручную без `--env-file .env`** — без него
|
||||
compose не подхватывает корневой `.env` (файл на уровень выше
|
||||
`deploy/docker-compose.yml`) и подставляет небезопасные дефолты из самого
|
||||
@@ -221,7 +245,22 @@ docker compose -f deploy/docker-compose.yml --env-file .env restart nginx
|
||||
```bash
|
||||
cat > /etc/letsencrypt/renewal-hooks/deploy/reload-nginx.sh << 'EOF'
|
||||
#!/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
|
||||
|
||||
# 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
|
||||
chmod +x /etc/letsencrypt/renewal-hooks/deploy/reload-nginx.sh
|
||||
|
||||
@@ -260,8 +299,9 @@ docker compose -f deploy/docker-compose.yml --env-file .env --profile monitoring
|
||||
# 1. Все сервисы healthy
|
||||
docker compose -f deploy/docker-compose.yml --env-file .env ps
|
||||
|
||||
# 2. Наружу открыто только ожидаемое (80/443/7881 + udp 54000-54100,
|
||||
# плюс 3478 tcp+udp, если включили TURN — раздел 8)
|
||||
# 2. Наружу открыто только ожидаемое (80/443/7881 + udp 54000,
|
||||
# плюс 3478 tcp+udp и 49160:49200/udp, если включили TURN, плюс 5349/tcp,
|
||||
# если включили TURN over TLS — раздел 8)
|
||||
ss -ltnp
|
||||
|
||||
# 3. Redis требует пароль (НЕ должен пускать без него)
|
||||
@@ -302,36 +342,147 @@ Protocols`. Проверьте **гостевой вход** (`/j/<slug>` в п
|
||||
**Статус: НЕ обязателен.** Реальное кросс-сетевое тестирование (участники в
|
||||
разных сетях/на разных устройствах) прошло успешно **без** раздачи TURN
|
||||
клиентам — комбинации `LIVEKIT_USE_EXTERNAL_IP=false` + реальный
|
||||
`LIVEKIT_NODE_IP` + проброшенный UDP-диапазон `54000-54100` (шаг 1,
|
||||
`LIVEKIT_NODE_IP` + проброшенный UDP-порт `54000` (шаг 1,
|
||||
firewall) хватает для подавляющего большинства сетей. Включайте этот
|
||||
раздел только если у вас есть конкретные пользователи за CGNAT или
|
||||
жёстким корпоративным firewall, которые не могут установить медиа-соединение
|
||||
(характерный симптом — конференция подключается по signaling, `connection
|
||||
state: connected`, но собеседник не видит видео/не слышит звук).
|
||||
|
||||
По умолчанию `deploy/livekit/livekit.yaml.template` содержит
|
||||
`turn.enabled: false`, и `rtc.turn_servers` не задан — standalone coturn
|
||||
поднимается (профиль `media`), но LiveKit не раздаёт его клиентам как
|
||||
ICE-фолбэк.
|
||||
**С версии 0.0.14 LiveKit анонсирует coturn клиентам** — секция
|
||||
`rtc.turn_servers` в `deploy/livekit/livekit.yaml.template` (UDP и TCP на
|
||||
3478, credentials по механизму TURN REST API из общего
|
||||
`TURN_STATIC_AUTH_SECRET`). Встроенный TURN LiveKit при этом остаётся
|
||||
выключенным (`turn.enabled: false`), чтобы не поднимать два TURN-сервера.
|
||||
|
||||
Включение (правки шаблона `deploy/livekit/livekit.yaml.template` +
|
||||
редеплой; **код-фикс не входит в это руководство без запроса** — обсудите
|
||||
с командой перед изменением):
|
||||
⚠️ **Чем это было до 0.0.14, если вы обновляетесь со старой версии.** coturn
|
||||
поднимался и был healthy, но клиенты о нём не знали: в конфиге LiveKit
|
||||
внешний TURN объявлен не был, а фронтенд `iceServers` не задаёт. За всё
|
||||
время работы в логах coturn не было ни одного ALLOCATE — то есть relay не
|
||||
использовался никогда, и участники из сетей с жёстким NAT просто теряли
|
||||
соединение (`PEER_CONNECTION_DISCONNECTED`).
|
||||
|
||||
1. Открыть 443 для TURN/TLS (наиболее надёжный фолбэк — TURN через тот же
|
||||
порт, что и остальной HTTPS-трафик, редко блокируется firewall'ами):
|
||||
потребует отдельного TLS-сертификата для coturn (`cert-file`/`pkey-file`
|
||||
в `deploy/coturn/turnserver.conf.template`) — можно переиспользовать тот
|
||||
же Let's Encrypt сертификат, что и nginx (тот же `/etc/letsencrypt`, уже
|
||||
смонтированный в nginx — coturn сейчас его не монтирует, потребуется
|
||||
доп. volume).
|
||||
2. В `livekit.yaml.template` включить `turn.enabled: true` и/или явно
|
||||
прописать `rtc.turn_servers` со статическими credentials
|
||||
(`TURN_STATIC_AUTH_SECRET` уже есть в `.env`).
|
||||
3. `ufw allow 3478/tcp` + `ufw allow 3478/udp` (шаг 1, закомментированные
|
||||
строки) — сейчас coturn поднят, но порт не проверялся как обязательный.
|
||||
4. Передеплой (`docker compose ... up -d --force-recreate livekit coturn`)
|
||||
и повторный кросс-сетевой тест именно с проблемной сетью.
|
||||
Что нужно проверить на своей инсталляции:
|
||||
|
||||
1. **Порты в ufw — оба пункта** (см. шаг 1): `3478/tcp` + `3478/udp` для
|
||||
сигнализации и `49160:49200/udp` для relay-аллокаций. Диапазон должен
|
||||
совпадать с `min-port`/`max-port` в
|
||||
`deploy/coturn/turnserver.conf.template`. Без него TURN отвечает на
|
||||
запросы, но релей не работает — самый неприятный вариант, потому что в
|
||||
логах coturn при этом тишина.
|
||||
2. **`TURN_EXTERNAL_IP` в `.env`** — реальный внешний IP или домен сервера.
|
||||
Именно это значение уезжает клиентам как адрес TURN-сервера, поэтому
|
||||
`127.0.0.1` из dev-дефолта сделает анонс бесполезным.
|
||||
3. После правок — `./deploy/render-templates.sh` (перерендерит конфиги из
|
||||
шаблонов), затем `docker compose ... up -d --force-recreate livekit`.
|
||||
⚠️ Перезапуск LiveKit **разрывает все активные конференции** — выбирайте
|
||||
окно.
|
||||
4. Проверка, что релей заработал: провести звонок из проблемной сети и
|
||||
убедиться, что в логах появились аллокации:
|
||||
`docker logs vidconf-coturn-1 --since 10m 2>&1 | grep -ci allocate`.
|
||||
Ноль при живом звонке из-за NAT означает, что до coturn не дошли —
|
||||
смотрите ufw и `TURN_EXTERNAL_IP`.
|
||||
⚠️ **Эта проверка работает только с `verbose` в
|
||||
`deploy/coturn/turnserver.conf.template`** (включён по умолчанию). Без
|
||||
этого флага coturn пишет в лог только служебные строки старта
|
||||
(листенеры, `Total auth threads`) и НИКОГДА не логирует
|
||||
ALLOCATE/CreatePermission/Refresh — `grep -ci allocate` даёт `0` даже
|
||||
когда relay реально обслуживает звонок. Это не гипотеза: на релизе
|
||||
0.0.22 именно так и обнаружили — рабочий relay-звонок (LiveKit
|
||||
`connectionType: turn`, реальные relay-кандидаты в `49160-49200`) при
|
||||
дефолтном `simple-log` без `verbose` дал `grep -ci allocate` = `0`,
|
||||
подтверждение пришлось брать из логов LiveKit. Если ваш конфиг старее
|
||||
и `verbose` в нём нет — добавьте флаг, перерендерите
|
||||
(`./deploy/render-templates.sh`) и пересоздайте `coturn`, прежде чем
|
||||
доверять этой проверке.
|
||||
|
||||
### TURN over TLS (порт 5349)
|
||||
|
||||
Самый надёжный фолбэк: в жёстких корпоративных сетях наружу часто разрешён
|
||||
только `443/tcp`, и TLS-соединение на нестандартный порт (5349) выглядит для
|
||||
firewall как обычный HTTPS. UDP/TCP на 3478 такие сети режут целиком.
|
||||
|
||||
**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 продолжает работать для остальных клиентов
|
||||
(с `verbose`, см. предупреждение в п.4 выше, каждая аллокация видна
|
||||
отдельной строкой `ALLOCATE processed, success` — можно отличить
|
||||
TLS-сессию от обычной по времени и по тому, что порт входящего
|
||||
соединения — 5349).
|
||||
|
||||
---
|
||||
|
||||
@@ -389,7 +540,7 @@ docker exec vidconf-postgres-1 pg_dump -U vidconf vidconf | gzip > db-$(date +%F
|
||||
```bash
|
||||
gunzip -c db-2026-07-25.sql.gz | docker exec -i vidconf-postgres-1 psql -U vidconf vidconf
|
||||
# Догнать миграции, если бэкап снят на более старой версии кода:
|
||||
docker compose -f deploy/docker-compose.yml --env-file .env run --rm backend uv run alembic upgrade head
|
||||
docker compose -f deploy/docker-compose.yml --env-file .env run --rm backend uv run --no-sync alembic upgrade head
|
||||
docker compose -f deploy/docker-compose.yml --env-file .env restart backend worker
|
||||
```
|
||||
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
# Нагрузочное тестирование SFU (LiveKit): методика и ёмкость
|
||||
|
||||
> Цифры здесь — с dev-Mac (см. предупреждение ниже), для реальных прод-замеров
|
||||
> и готовой таблицы «профиль нагрузки → железо» см.
|
||||
> [hardware-sizing.md](hardware-sizing.md).
|
||||
|
||||
Оценивает,
|
||||
сколько одновременных издателей аудио+видео и подписчиков выдерживает
|
||||
LiveKit SFU в текущей конфигурации compose (`deploy/livekit/livekit.yaml`),
|
||||
@@ -179,12 +183,15 @@ dev-стека.
|
||||
профилей битрейта заводить не требуется; при необходимости ограничить
|
||||
верхнюю границу — `videoEncoding`/`simulcastLayers` на фронтенде
|
||||
(клиентский SDK, вне скоупа devops-части).
|
||||
4. **UDP-диапазон 54000-54100 (101 порт)** не был узким местом ни на одной
|
||||
ступени (максимум 60 участников в тесте) — при планировании прод-узла с
|
||||
ожидаемым бОльшим числом одновременных участников across все комнаты
|
||||
узла держать `port_range_end - port_range_start` заметно больше пикового
|
||||
числа участников на узле (LiveKit резервирует пару портов на участника
|
||||
на медиа-транспорт).
|
||||
4. **UDP-диапазон 54000-54100 (101 порт)**, на котором проводился этот тест,
|
||||
не был узким местом ни на одной ступени (максимум 60 участников). Тогда
|
||||
же с ним была цена: под каждый порт диапазона Docker держал отдельный
|
||||
процесс `docker-proxy` — 101 порт-101 процесс на медиапути, весь трафик
|
||||
шёл лишним userland-хопом. С переходом на `rtc.udp_port` (один порт,
|
||||
мультиплексирование по ICE ufrag внутри LiveKit) рекомендация «держать
|
||||
диапазон шире пикового числа участников» больше не актуальна — портов
|
||||
для планирования ёмкости не остаётся вовсе, LiveKit разводит участников
|
||||
поверх одного сокета сам.
|
||||
5. **STUN/TURN-находка (см. «Методика») —** рекомендуется отдельной задачей
|
||||
зарегистрировать `deploy/coturn/` в `rtc.turn_servers` LiveKit и на
|
||||
проде, а не только для теста — иначе клиенты в вырожденном случае
|
||||
|
||||
@@ -61,4 +61,6 @@ VidConf использует **5 пресетов инсталлятора** (н
|
||||
- **[LLM Setup](llm-setup.md)** — ручная установка/скачивание моделей
|
||||
- **[Deploy: Мониторинг](monitoring.md)** — Prometheus/Grafana, алерты
|
||||
- **[Deploy: Масштабирование](scaling.md)** — горизонтальное масштабирование
|
||||
- **[Deploy: Ёмкость](capacity.md)** — калькулятор нагрузки и IOPS
|
||||
- **[Deploy: Ёмкость](capacity.md)** — калькулятор нагрузки и IOPS (синтетический тест)
|
||||
- **[Deploy: Профиль нагрузки → железо](hardware-sizing.md)** — сайзинг под саму
|
||||
видео-нагрузку (участники/камеры/полоса), на реальных замерах с прода
|
||||
|
||||
243
docs/deploy/hardware-sizing.md
Normal file
243
docs/deploy/hardware-sizing.md
Normal file
@@ -0,0 +1,243 @@
|
||||
# Профиль нагрузки → рекомендуемое железо (медиа)
|
||||
|
||||
Отвечает на вопрос «сколько CPU, RAM и полосы нужно под ожидаемую видео-нагрузку»
|
||||
для тех, кто разворачивает VidConf у себя. Разговор именно про **медиа**
|
||||
(конференции, LiveKit SFU) — сайзинг под AI (транскрибация/суммаризация) описан
|
||||
отдельно: [install.md](install.md), [hardware-profiles.md](hardware-profiles.md),
|
||||
[ADR-004](../architecture/adr/004-ai-tier-matrix.md). Базовый пресет инсталлятора
|
||||
без AI (`4 vCPU / 8 ГБ RAM / 40 ГБ диска`) рассчитан именно на медиа-нагрузку —
|
||||
этот документ объясняет, какую конференцию такое железо реально держит и когда
|
||||
его уже мало.
|
||||
|
||||
От синтетического нагрузочного теста [capacity.md](capacity.md) (SFU на dev-Mac,
|
||||
формула по CPU) этот документ отличается источником цифр: здесь — **измерения на
|
||||
боевом сервере с реальными людьми**, не эмуляция.
|
||||
|
||||
⚠️ Все числа ниже — **ориентировочные**, при указанных допущениях. Реальная
|
||||
нагрузка зависит от поведения людей: сколько включат камеру, будет ли демонстрация
|
||||
экрана, как долго говорят несколько человек одновременно. Используйте таблицу как
|
||||
отправную точку для выбора железа, не как гарантию.
|
||||
|
||||
## Точка привязки к реальности
|
||||
|
||||
Единственные цифры ниже, которые не расчёт, а прямое измерение на боевом сервере
|
||||
(`4 CPU, 8 ГБ RAM, Ubuntu 24.04`, тот же узел, где сейчас работает `vidconf.ru`):
|
||||
|
||||
| Дата | Версия | Участников | Камер | Подписок на видео | Исходящий трафик LiveKit | CPU LiveKit |
|
||||
|---|---|---|---|---|---|---|
|
||||
| 28.07.2026 | 0.0.11, **до** `adaptiveStream` | 19 | 17 | ~306 | 140–169 Мбит/с устойчиво (пик 240) | 1.62 ядра из 4 |
|
||||
| 31.07.2026 | 0.0.13, **после** `adaptiveStream`+`dynacast` | 30 | 27 | 783 | **70.6 Мбит/с** | 1.83 ядра из 4 |
|
||||
|
||||
Второе измерение и есть якорь для формулы ниже — оно снято на конфигурации,
|
||||
максимально близкой к дефолтной (пагинация сетки участников, `adaptiveStream`,
|
||||
`dynacast`), без ручной настройки под тест.
|
||||
|
||||
**Почему не пиковые 240 Мбит/с.** Пик — кратковременный всплеск, а не режим, в
|
||||
котором сервер работал устойчиво; 1.62 ядра CPU намерены именно под устойчивые
|
||||
140–169 Мбит/с. Если посчитать коэффициент «Мбит/с на ядро» по пиковому числу,
|
||||
получится оптимистичнее примерно в полтора раза, чем в реальности — расчёт по
|
||||
такому коэффициенту недооценит нужное железо.
|
||||
|
||||
**Что показывает разница двух строк.** Во второй нагрузка выше (участников ×1.6,
|
||||
подписок ×2.6), а трафик почти вдвое **ниже**, при том что CPU почти не
|
||||
изменился. Это эффект `adaptiveStream`: клиент подписывается на трек, но получает
|
||||
битрейт под фактический размер плитки на экране, а невидимые (не помещающиеся на
|
||||
текущую страницу сетки) треки почти не занимают полосы. Всё, что дальше в этом
|
||||
документе, посчитано **для конфигурации с `adaptiveStream`** (релиз ≥0.0.13, в
|
||||
проекте включён с этой версии по умолчанию) — без него числа нужно умножать в
|
||||
разы, см. следующий раздел.
|
||||
|
||||
## Почему нельзя считать «все видят всех»
|
||||
|
||||
SFU (LiveKit) пересылает пакеты, а не микширует их. Наивная формула трафика —
|
||||
`N × (N−1) × битрейт` (каждый участник получает поток от каждого) — при 80
|
||||
участниках даёт единицы **гигабит в секунду**: недостижимо на одном сервере и не
|
||||
имеет отношения к тому, что видит пользователь на экране.
|
||||
|
||||
Реальная модель другая: клиент подписан не на всех, а на **видимые плитки**, и
|
||||
получает под каждую подписку битрейт по фактическому размеру плитки — благодаря
|
||||
пагинации сетки (с релиза 0.0.11) и `adaptiveStream`/`dynacast` (с 0.0.13).
|
||||
Отсюда рабочая формула:
|
||||
|
||||
```
|
||||
подписок на видео = камер × (участников − 1)
|
||||
```
|
||||
|
||||
Она подтверждена обоими измерениями выше **точно**: 17 × 18 = 306, 27 × 29 = 783.
|
||||
Это формула для типичного «видят всех камер» размещения (сетка без ручного
|
||||
скрытия участников) — при включённом лимите плиток (см. ниже) число подписок не
|
||||
растёт дальше лимита, даже если камер больше.
|
||||
|
||||
**Насколько наивная формула хуже.** Гипотетически, если бы все 75 участников
|
||||
большого собрания (профиль ниже) были источником видео и каждый получал полный
|
||||
поток от каждого — `75 × 74 × 1.5 Мбит/с` (типичный битрейт публикации без
|
||||
адаптации) — это **9.48 Гбит/с**: недостижимо ни на одном разумном сервере.
|
||||
Модель выше на сопоставимом масштабе (профиль «Большое собрание», 15 камер из
|
||||
75) даёт около 182 Мбит/с — **в 50 с лишним раз меньше**. Разница — не оптимизация
|
||||
в мелочах, а другая по порядку величины задача, и именно поэтому SFU вообще
|
||||
годится для конференций на десятки участников.
|
||||
|
||||
Аудио в этой формуле — единицы процентов трафика: микрофоны обычно включены у
|
||||
2–5 человек одновременно, замьюченный трек полосу не занимает, DTX/RED включены
|
||||
по умолчанию. Дальше считаем аудио отдельным слагаемым, не путая с видео.
|
||||
|
||||
## Формула
|
||||
|
||||
```
|
||||
1. подписок = камер × (участников − 1)
|
||||
(если включён лимит плиток в настройках инстанса — camер заменить на min(камер, лимит))
|
||||
|
||||
2. видео = подписок × битрейт_на_подписку
|
||||
|
||||
битрейт_на_подписку зависит от того, помещаются ли все плитки на одну страницу:
|
||||
- крупная плитка / говорящий в фокусе (мало плиток на экране) → 450 кбит/с
|
||||
- мелкая плитка сетки, все участники на одной странице → 150 кбит/с
|
||||
- сетка с пагинацией (участников больше лимита плиток) → 100 кбит/с
|
||||
(часть подписок физически не на экране — почти не потребляет полосы)
|
||||
|
||||
3. аудио = активных_микрофонов × участников × 40 кбит/с
|
||||
|
||||
4. исходящая полоса сервера = видео + аудио ← главный параметр, см. ниже
|
||||
|
||||
5. ядер CPU (LiveKit) ≈ max(2, ⌈исходящая_полоса_Мбит/с ÷ 90⌉)
|
||||
+ 1–2 ядра на остальной стек (backend, БД, Redis, coturn, nginx, ОС)
|
||||
|
||||
6. RAM ≈ 4 ГБ база (без AI-профилей) — на этом масштабе RAM не была узким
|
||||
местом ни на одном реальном или синтетическом тесте, планировать по CPU и полосе
|
||||
```
|
||||
|
||||
**Откуда коэффициенты.**
|
||||
|
||||
- 450 / 150 кбит/с — измерение одного трека клиентом в крупной и мелкой плитке
|
||||
(релиз 0.0.13), округлено вверх от 453 и 147 для запаса.
|
||||
- 100 кбит/с — обратный расчёт по якорному замеру 31.07.2026:
|
||||
70.6 Мбит/с ÷ 783 подписки ≈ 90 кбит/с в среднем, округлено вверх. Число ниже,
|
||||
чем «мелкая плитка» (147), потому что в комнате на 30 участников часть из 783
|
||||
подписок физически не помещалась на текущую страницу сетки — `adaptiveStream`
|
||||
почти обнулил их битрейт, а среднее по всем подпискам это отражает.
|
||||
- 90 Мбит/с на ядро — из 28.07.2026: 140–169 Мбит/с устойчиво ÷ 1.62 ядра =
|
||||
86–104 Мбит/с/ядро, округлено вниз (консервативно, в пользу большего числа
|
||||
ядер).
|
||||
- Минимум 2 ядра и запас 1–2 ядра на остальной стек — эмпирический пол: на
|
||||
обоих реальных замерах LiveKit не опускался ниже 1.6 ядра независимо от
|
||||
трафика, а весь остальной стек (backend, Postgres, Redis, coturn, nginx) на
|
||||
боевом сервере устойчиво укладывается в разницу между занятым LiveKit и 4
|
||||
доступными ядрами.
|
||||
|
||||
⚠️ **Формула по полосе — не единственная граница.** Между двумя замерами
|
||||
подписок стало в 2.6 раза больше, трафик упал вдвое, а CPU почти не изменился
|
||||
(1.62 → 1.83 ядра) — то есть процессор тратится в первую очередь на
|
||||
обработку пакетов/подписок, а не на байты. На сценариях с очень большим числом
|
||||
мелких подписок (много участников, лимит плиток не выставлен) реальный CPU
|
||||
может обогнать то, что предсказывает формула по полосе быстрее, чем ожидается
|
||||
— держите эмпирический пол (2 ядра LiveKit минимум под любую активную
|
||||
конференцию) и не полагайтесь только на деление на 90.
|
||||
|
||||
## Профили нагрузки
|
||||
|
||||
Все профили — при `adaptiveStream`+`dynacast` (по умолчанию с 0.0.13) и без
|
||||
AI-профилей (`transcribe`/`llm`). Допущения по камерам/микрофонам — решение,
|
||||
не измерение; подставьте свои, если знаете реальный сценарий.
|
||||
|
||||
| Профиль | Сценарий | Камер | Подписок | Полоса (видео+аудио) | CPU (LiveKit) | RAM | Узкое место |
|
||||
|---|---|---|---|---|---|---|---|
|
||||
| Малая команда | 10 параллельных созвонов по 5 чел., 60% с камерой, 2 микрофона в каждом | 3×10 | 12×10=120 | ~58 Мбит/с | 2 ядра | 4 ГБ | нет — запас большой |
|
||||
| Совещание | 1 конференция × 25 чел., 70% с камерой, 4 микрофона | 18 | 432 | ~69 Мбит/с | 2 ядра | 4 ГБ | полоса — близко к нашему якорю (70.6 Мбит/с на 30 чел.) |
|
||||
| Большое собрание | 1 конференция × 75 чел., 20% с камерой (камер меньше лимита плиток), 5 микрофонов | 15 | 1110 | ~182 Мбит/с | 3 ядра | 4–6 ГБ | полоса и её цена у хостера |
|
||||
| Смешанная нагрузка | «Малая команда» + «Совещание» одновременно на одном сервере | — | — | ~127 Мбит/с | 2 ядра | 4 ГБ | суммируется линейно |
|
||||
|
||||
Малая команда и совещание укладываются в базовый пресет инсталлятора без AI
|
||||
(`4 vCPU / 8 ГБ`) с большим запасом — это ровно тот масштаб, что подтверждён
|
||||
якорным замером (30 чел./27 камер на этом же железе, CPU занят на 46%). Большое
|
||||
собрание уже требует железа **больше** базового пресета — 3 ядра под сам
|
||||
LiveKit плюс 1–2 под остальной стек означают, что 4 vCPU становятся тесными.
|
||||
|
||||
### Когда профиль недостижим — и как его спасти
|
||||
|
||||
«Большое собрание, все 75 человек с камерой» без ограничений: подписок
|
||||
75 × 74 = 5550, полоса по коэффициенту пагинации (100 кбит/с) — уже **~555
|
||||
Мбит/с** только видео, плюс аудио. Такой канал недостижим на типичном железе
|
||||
и его аренде — это не вопрос выбора сервера мощнее, это упирается в канал
|
||||
и его стоимость у хостера.
|
||||
|
||||
Спасает **лимит плиток на экране** (настройка инстанса «Максимум плиток на
|
||||
странице», 25/16/9/4, с релиза 0.0.21): он ограничивает число подписок сверху
|
||||
независимо от числа камер — `min(камер, лимит) × (участников − 1)`. При лимите
|
||||
16 и том же собрании: 16 × 74 = 1184 подписки × 150 кбит/с (все 16 видны
|
||||
одновременно, без пагинации) ≈ **178 Мбит/с** только видео — втрое меньше, чем
|
||||
без лимита, но всё ещё требует железа заметно больше базового пресета (3+ ядра
|
||||
LiveKit по формуле). Лимит делает профиль реалистичным, не дешёвым. Тот же
|
||||
эффект даёт «Потолок качества публикации» (720p/360p/180p, тот же релиз) —
|
||||
режет битрейт публикации у источника, а не только у подписчика.
|
||||
|
||||
Если и это не помогает — речь уже не про один сервер, а про горизонтальное
|
||||
масштабирование LiveKit-кластера, вне рамок этого документа.
|
||||
|
||||
## Входящая полоса и канал клиента — отдельная история
|
||||
|
||||
Таблица выше — **исходящая** полоса сервера (каждому подписчику отдельная
|
||||
копия), она и есть главный параметр: растёт с числом участников и подписок.
|
||||
|
||||
**Входящая** полоса (от клиентов к серверу) на порядок меньше: она равна сумме
|
||||
битрейтов публикуемых потоков — `камер × ~0.3–1.5 Мбит/с` (зависит от «Потолка
|
||||
качества публикации», 0.0.21) — и **не** умножается на число зрителей. Для
|
||||
собрания на 75 человек с 15 камерами это 4.5–22.5 Мбит/с входящих — заметно
|
||||
меньше 182 Мбит/с исходящих, узким местом почти никогда не становится.
|
||||
|
||||
Отдельно — канал **самого клиента**, не сервера. Офис, откуда заходит половина
|
||||
участников совещания, может упереться в свой исходящий/входящий канал раньше,
|
||||
чем сервер упрётся в свой. Серверный сайзинг эту часть не решает — это забота
|
||||
сетевой инфраструктуры на стороне участников.
|
||||
|
||||
## Оговорка про канал и его стоимость
|
||||
|
||||
На реалистичных профилях (кроме экстремальных, см. выше) узким местом
|
||||
оказывается почти всегда не CPU — оба реальных замера показали комфортный
|
||||
запас (1.6–1.83 ядра из 4) — а **исходящая полоса и её стоимость у хостера**.
|
||||
Большинство тарифов VPS считают трафик либо лимитом с доплатой за перебор,
|
||||
либо по 95-му перцентилю канала; при планировании крупных конференций
|
||||
сверяйтесь с тарифом хостера на трафик/канал, а не только с числом ядер и
|
||||
объёмом RAM.
|
||||
|
||||
## Как посчитать под свой сценарий
|
||||
|
||||
1. Оцените участников (N), долю с камерой, число одновременно активных
|
||||
микрофонов.
|
||||
2. `подписок = камер × (N − 1)`; если планируете включить лимит плиток —
|
||||
`min(камер, лимит) × (N − 1)`.
|
||||
3. Выберите битрейт на подписку по разделу «Формула» (450 / 150 / 100 кбит/с)
|
||||
в зависимости от того, помещаются ли все камеры на одну страницу.
|
||||
4. `видео = подписок × битрейт`, `аудио = микрофонов × N × 40 кбит/с`,
|
||||
`полоса = видео + аудио`.
|
||||
5. `ядер CPU ≈ max(2, ⌈полоса ÷ 90⌉) + 1–2` на остальной стек.
|
||||
6. RAM — 4 ГБ база, не растёт заметно с этим масштабом участников (растёт с
|
||||
выбранным уровнем AI, см. [ADR-004](../architecture/adr/004-ai-tier-matrix.md),
|
||||
если он используется).
|
||||
7. Сверьте полосу с тарифом хостера на трафик/канал — часто это упрётся раньше
|
||||
железа.
|
||||
|
||||
## Что может измениться
|
||||
|
||||
⚠️ Коэффициент «90 Мбит/с на ядро» и оба якорных замера сняты **до** перевода
|
||||
LiveKit с диапазона UDP-портов на `rtc.udp_port` (задача «Сеть LiveKit», релиз
|
||||
0.0.20, задеплоено 02.08.2026) — до этой правки медиатрафик на хосте шёл через
|
||||
процессы `docker-proxy` (userland-прокси Docker). Сама оптимизация меняет путь
|
||||
пакетов на хосте, а не логику LiveKit, поэтому полоса из таблиц, скорее всего,
|
||||
не изменится, а запас по CPU/сети хоста на практике может оказаться больше
|
||||
указанного здесь. Новый нагрузочный тест с реальными участниками после этой
|
||||
оптимизации пока не проводился — числа в этом документе консервативны и не
|
||||
переоценивают требуемое железо, но при появлении нового замера на текущей
|
||||
сети коэффициенты стоит пересчитать.
|
||||
|
||||
## Смотрите также
|
||||
|
||||
- [capacity.md](capacity.md) — синтетический нагрузочный тест SFU (`lk load-test`
|
||||
на dev-Mac) и формула по CPU для верхней оценки ёмкости узла; используйте вместе
|
||||
с этим документом, если нужна методика для собственного повторного теста.
|
||||
- [install.md](install.md), [hardware-profiles.md](hardware-profiles.md),
|
||||
[ADR-004](../architecture/adr/004-ai-tier-matrix.md) — сайзинг под AI
|
||||
(транскрибация/суммаризация), отдельно от медиа.
|
||||
- [monitoring.md](monitoring.md) — как снять реальные цифры со своего сервера
|
||||
(CPU/RAM/сеть по контейнерам, Grafana).
|
||||
- [DEPLOYMENT.md](DEPLOYMENT.md) — TURN, порты, что открыть в файрволе под
|
||||
медиа-трафик.
|
||||
@@ -63,7 +63,7 @@ CPU-only — отдельного GPU-варианта профилей для
|
||||
|
||||
Поведение:
|
||||
- **Дефолт (Enter):** применяет матрицу выбранного пресета к настройкам БД
|
||||
(defs: `docker compose exec -T backend uv run python -m scripts.apply_preset_settings --force`)
|
||||
(defs: `docker compose exec -T backend uv run --no-sync python -m scripts.apply_preset_settings --force`)
|
||||
- **Отказ (`n`):** сохраняет ручные правки админа; переменные `BOOTSTRAP_*` в `.env`
|
||||
обновляются, но скрипт применения настроек НЕ запускается
|
||||
- **Флаг `--yes`:** автоматически применяет пресет без вопроса (для CI/CD)
|
||||
@@ -111,7 +111,7 @@ Grafana/Prometheus не поднимаются автоматически — с
|
||||
собирает фронтенд-SPA и вкомпилирует статику, `frontend/Dockerfile`).
|
||||
4. Поднимает `postgres`/`redis` (`up -d --wait`) и применяет **до старта
|
||||
backend** миграции и seed одноразовыми контейнерами:
|
||||
`docker compose run --rm backend uv run alembic upgrade head` +
|
||||
`docker compose run --rm backend uv run --no-sync alembic upgrade head` +
|
||||
`... python -m scripts.seed`. Порядок критичен: `backend.lifespan`
|
||||
бутстрапит `instance_settings` при каждом старте приложения, поэтому на
|
||||
чистой БД таблицы обязаны существовать до первого запуска backend — иначе
|
||||
@@ -122,9 +122,8 @@ Grafana/Prometheus не поднимаются автоматически — с
|
||||
настройки инстанса не перетираются).
|
||||
5. Поднимает остальной стек: `docker compose <--profile ...> up -d --wait`
|
||||
(backend, worker, nginx с фронтом + сервисы активных профилей). Команда
|
||||
идемпотентна и обёрнута в ретрай (до 3 попыток): первый старт backend/worker
|
||||
включает `uv run` (синхронизация окружения + компиляция байткода), и на
|
||||
слабой/загруженной машине healthcheck может не успеть за отведённые
|
||||
идемпотентна и обёрнута в ретрай (до 3 попыток): на слабой/загруженной
|
||||
машине healthcheck может не успеть за отведённые
|
||||
retries — повтор лишь дожидается уже стартующих контейнеров.
|
||||
6. Печатает сводку: URL фронтенда/бэкенда, учётные данные администратора,
|
||||
команда для `--profile monitoring`.
|
||||
|
||||
@@ -10,11 +10,68 @@
|
||||
| `prometheus` | `prom/prometheus:v3.13.1` | `9090` | сбор и хранение метрик, оценка правил алертинга |
|
||||
| `postgres-exporter` | `quay.io/prometheuscommunity/postgres-exporter:v0.20.1` | — (внутренний) | метрики PostgreSQL |
|
||||
| `redis-exporter` | `oliver006/redis_exporter:v1.87.0-alpine` | — (внутренний) | метрики Redis |
|
||||
| `grafana` | `grafana/grafana:13.1.0` | `3001` (внутри контейнера `3000`) | дашборд «Пайплайны пост-обработки» |
|
||||
| `node-exporter` | `prom/node-exporter:v1.8.2` | — (внутренний) | метрики хоста: CPU, память, диск, сеть, load average |
|
||||
| `docker-socket-proxy` | `tecnativa/docker-socket-proxy:v0.5.0` | — (внутренний) | read-only прокси к Docker Engine API для `container-exporter` |
|
||||
| `container-exporter` | сборка из `deploy/monitoring/container-exporter/` | — (внутренний) | метрики CPU/память/сеть в разбивке по контейнерам |
|
||||
| `grafana` | `grafana/grafana:13.1.0` | `3001` (внутри контейнера `3000`) | дашборды «Пайплайны пост-обработки» и «Хост и контейнеры» |
|
||||
|
||||
`node-exporter`/`docker-socket-proxy`/`container-exporter` не публикуют
|
||||
порт на хост вообще (не только 127.0.0.1) — Prometheus и сам экспортер
|
||||
ходят друг к другу по имени сервиса во внутренней сети compose,
|
||||
публикация на хост для этого не нужна.
|
||||
|
||||
### 1.1. Метрики по контейнерам — свой экспортер вместо cAdvisor
|
||||
|
||||
**cAdvisor не используется**: Docker Engine на сервере `1gb` использует
|
||||
containerd-снапшоттер (`docker info` → `driver-type:
|
||||
io.containerd.snapshotter.v1`), а не классический overlay2-драйвер, и
|
||||
cAdvisor (проверено на v0.49.2 и v0.52.1, с `--docker_only` и без, через
|
||||
`--containerd`/`--containerd-namespace=moby` и без) не может определить
|
||||
read-write layer контейнеров — падает с «failed to identify the
|
||||
read-write layer ID», метрики есть только по корневому cgroup. Открытая
|
||||
проблема совместимости cAdvisor с containerd image store, флагами не
|
||||
чинится — см. `.forcc/JOURNAL.md`.
|
||||
|
||||
Вместо этого — свой минимальный экспортер (`container-exporter`,
|
||||
`deploy/monitoring/container-exporter/exporter.py`, Python/aiohttp/
|
||||
`prometheus_client`) поверх Docker Engine API: `GET /containers/json` +
|
||||
`GET /containers/{id}/stats?stream=false`. Эти эндпойнты не зависят от
|
||||
storage-driver'а — работают и с containerd-снапшоттером, и с overlay2.
|
||||
Опрос контейнеров идёт параллельно в фоновой задаче (свой интервал,
|
||||
не завязан на scrape Prometheus) — `stats?stream=false` не мгновенен
|
||||
(Docker считает CPU-дельту по двум внутренним замерам с разницей ~1с),
|
||||
линейный опрос дюжины контейнеров легко вышел бы за scrape-интервал.
|
||||
Метрики (лейбл `name` — имя контейнера):
|
||||
|
||||
- `vidconf_container_cpu_percent` — % от одного ядра, как `docker stats`.
|
||||
- `vidconf_container_memory_usage_bytes` / `_limit_bytes` — working set
|
||||
(без переиспользуемого page cache) и лимит памяти.
|
||||
- `vidconf_container_network_rx_bytes` / `_tx_bytes` — суммарно по
|
||||
интерфейсам, счётчик со старта контейнера.
|
||||
|
||||
**Безопасность: доступ к `/var/run/docker.sock` равносилен root на
|
||||
хосте.** Флаг `:ro` при монтировании сокета не спасает — он ограничивает
|
||||
права на файл-ноду, а не на протокол: через сокет можно поднять
|
||||
привилегированный контейнер и получить хост. Поэтому сокет НЕ
|
||||
монтируется напрямую в `container-exporter` — между ними стоит
|
||||
`docker-socket-proxy` (`tecnativa/docker-socket-proxy`), которому
|
||||
разрешены только `GET`-запросы по контейнерам (`CONTAINERS=1`, `POST=0`
|
||||
— дефолт образа, выписан явно). Даже полная компрометация
|
||||
`container-exporter` (например, через уязвимость в его зависимостях) не
|
||||
даёт управлять Docker — прокси физически не пропустит `POST`. Ни один
|
||||
из двух сервисов не публикует портов наружу.
|
||||
|
||||
Если на другом сервере используется классический overlay2 и хочется
|
||||
именно cAdvisor — можно добавить его по тому же образцу, что и
|
||||
`node-exporter` (свой job в `prometheus.yml`, без публикации портов),
|
||||
но тогда придётся решать ту же проблему монтирования сокета отдельно
|
||||
(cAdvisor тоже требует доступ к Docker API).
|
||||
|
||||
Файлы: `deploy/monitoring/prometheus.yml`, `deploy/monitoring/alerts.yml`,
|
||||
`deploy/monitoring/grafana/provisioning/` (datasource + провайдер
|
||||
дашбордов), `deploy/monitoring/grafana/dashboards/pipelines.json`.
|
||||
дашбордов), `deploy/monitoring/grafana/dashboards/pipelines.json`,
|
||||
`deploy/monitoring/grafana/dashboards/host.json`,
|
||||
`deploy/monitoring/container-exporter/` (код экспортера).
|
||||
|
||||
## 2. Запуск
|
||||
|
||||
@@ -37,9 +94,9 @@ ssh -L 9090:127.0.0.1:9090 -L 3001:127.0.0.1:3001 <user>@<host>
|
||||
```
|
||||
и открывайте `http://localhost:9090` / `http://localhost:3001` у себя.
|
||||
|
||||
Дашборд «Пайплайны пост-обработки» (папка VidConf в Grafana) появляется
|
||||
сразу — источник данных и дашборд провижинятся из файлов, без ручной
|
||||
настройки.
|
||||
Дашборды «Пайплайны пост-обработки» и «Хост и контейнеры» (папка VidConf
|
||||
в Grafana) появляются сразу — источник данных и дашборды провижинятся из
|
||||
файлов, без ручной настройки.
|
||||
|
||||
## 3. Метрики backend
|
||||
|
||||
@@ -56,6 +113,13 @@ ssh -L 9090:127.0.0.1:9090 -L 3001:127.0.0.1:3001 <user>@<host>
|
||||
- `vidconf_celery_queue_depth` (gauge, `queue`) — глубина очередей Celery
|
||||
(`transcription`/`summarize`/`notify`/`celery`, redis `LLEN`), карта
|
||||
очередей — `docs/deploy/scaling.md`.
|
||||
- `vidconf_host_info` (gauge, всегда `1`, лейблы `cpus`/`ram_mb`/`gpu_name`/
|
||||
`vram_mb`) — info-метрика обнаруженного `install.sh` железа (`HW_*` в
|
||||
`.env`, ADR-004). Источник плашки с характеристиками сервера в дашборде
|
||||
«Хост и контейнеры» — живые CPU/RAM/диск там же берутся из
|
||||
node-exporter, а GPU node-exporter не знает вообще, поэтому только эта
|
||||
метрика. `«—»` в лейбле — `install.sh` не запускался (dev) либо
|
||||
видеокарта не обнаружена.
|
||||
|
||||
Job `llm` в `prometheus.yml` скрейпит `llm:8080/metrics`
|
||||
(`LLAMA_ARG_ENDPOINT_METRICS=1`) — этот адрес резолвится ЛИБО сервисом
|
||||
@@ -78,6 +142,13 @@ Job `llm` в `prometheus.yml` скрейпит `llm:8080/metrics`
|
||||
| `PipelineFailed` | рост числа сеансов в статусе `failed` за 15 минут | critical |
|
||||
| `QueueGrowing` | глубина очереди растёт 15 минут подряд и превышает 10 задач | warning |
|
||||
| `LlmDown` | `up{job="llm"} == 0` дольше 2 минут | critical (закомментирован по умолчанию) |
|
||||
| `HostMemoryLow` | свободно <10% RAM (≈800 МБ из 8 ГБ) дольше 10 минут | warning |
|
||||
| `HostDiskLow` | свободно <10% диска (≈5 ГБ из 50 ГБ) дольше 15 минут | warning |
|
||||
| `HostCpuHigh` | загрузка CPU >90% дольше 15 минут подряд | warning |
|
||||
|
||||
Пороги трёх алертов по железу подобраны под конкретный сервер `1gb`
|
||||
(8 ГБ RAM, 4 CPU, 50 ГБ диска) — при смене сервера пересчитать
|
||||
(`deploy/monitoring/alerts.yml`, группа `vidconf-host`).
|
||||
|
||||
`LlmDown` актуален только на инсталляциях с профилем `llm`/`llm-gpu`
|
||||
(пресеты 3–5) — по умолчанию (профили `media,monitoring`, без AI) правило
|
||||
|
||||
@@ -63,7 +63,7 @@ services:
|
||||
dockerfile: Dockerfile
|
||||
restart: unless-stopped
|
||||
# Без -B: beat уже запущен на базовом worker (см. правило выше).
|
||||
command: ["uv", "run", "celery", "-A", "workers.celery_app", "worker",
|
||||
command: ["uv", "run", "--no-sync", "celery", "-A", "workers.celery_app", "worker",
|
||||
"-Q", "summarize", "--hostname=worker-summarize-%h@%h", "--loglevel=info"]
|
||||
env_file:
|
||||
- ../.env
|
||||
|
||||
37
frontend/package-lock.json
generated
37
frontend/package-lock.json
generated
@@ -20,6 +20,7 @@
|
||||
"@fullcalendar/timegrid": "^6.1.21",
|
||||
"@livekit/components-react": "^2.9.23",
|
||||
"@livekit/components-styles": "^1.2.0",
|
||||
"@livekit/track-processors": "^0.7.2",
|
||||
"@tailwindcss/vite": "^4.3.2",
|
||||
"@tanstack/react-query": "^5.101.2",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
@@ -1220,6 +1221,25 @@
|
||||
"@bufbuild/protobuf": "^1.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@livekit/track-processors": {
|
||||
"version": "0.7.2",
|
||||
"resolved": "https://registry.npmjs.org/@livekit/track-processors/-/track-processors-0.7.2.tgz",
|
||||
"integrity": "sha512-lzARBKTbBwqycdR/SwTu6//N0l20BzfDd7grxCXl07676SwRApNtZAK1GJjL1m3dCM3KBqH1aVxjMpNcbOw5uQ==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@mediapipe/tasks-vision": "0.10.14"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/dom-mediacapture-transform": "^0.1.9",
|
||||
"livekit-client": "^1.12.0 || ^2.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@mediapipe/tasks-vision": {
|
||||
"version": "0.10.14",
|
||||
"resolved": "https://registry.npmjs.org/@mediapipe/tasks-vision/-/tasks-vision-0.10.14.tgz",
|
||||
"integrity": "sha512-vOifgZhkndgybdvoRITzRkIueWWSiCKuEUXXK6Q4FaJsFvRJuwgg++vqFUMlL0Uox62U5aEXFhHxlhV7Ja5e3Q==",
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/@modelcontextprotocol/sdk": {
|
||||
"version": "1.29.0",
|
||||
"resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.29.0.tgz",
|
||||
@@ -1921,6 +1941,23 @@
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/@types/dom-mediacapture-transform": {
|
||||
"version": "0.1.12",
|
||||
"resolved": "https://registry.npmjs.org/@types/dom-mediacapture-transform/-/dom-mediacapture-transform-0.1.12.tgz",
|
||||
"integrity": "sha512-d7/QsLRwF864A5mgIM/YrfiglHoYn7zgCcAoJgW404r+2DwnNr7EBbLnCWpmOMgH8y0te73L1AV6H1bmauaWFw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@types/dom-webcodecs": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/dom-webcodecs": {
|
||||
"version": "0.1.18",
|
||||
"resolved": "https://registry.npmjs.org/@types/dom-webcodecs/-/dom-webcodecs-0.1.18.tgz",
|
||||
"integrity": "sha512-vAvE8C9DGWR+tkb19xyjk1TSUlJ7RUzzp4a9Anu7mwBT+fpyePWK1UxmH14tMO5zHmrnrRIMg5NutnnDztLxgg==",
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/@types/esrecurse": {
|
||||
"version": "4.3.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/esrecurse/-/esrecurse-4.3.1.tgz",
|
||||
|
||||
@@ -22,6 +22,7 @@
|
||||
"@fullcalendar/timegrid": "^6.1.21",
|
||||
"@livekit/components-react": "^2.9.23",
|
||||
"@livekit/components-styles": "^1.2.0",
|
||||
"@livekit/track-processors": "^0.7.2",
|
||||
"@tailwindcss/vite": "^4.3.2",
|
||||
"@tanstack/react-query": "^5.101.2",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
|
||||
BIN
frontend/public/backgrounds/beach.webp
Normal file
BIN
frontend/public/backgrounds/beach.webp
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 13 KiB |
BIN
frontend/public/backgrounds/office.webp
Normal file
BIN
frontend/public/backgrounds/office.webp
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 9.2 KiB |
BIN
frontend/public/backgrounds/space-station.webp
Normal file
BIN
frontend/public/backgrounds/space-station.webp
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 11 KiB |
16
frontend/public/mediapipe/NOTICE.txt
Normal file
16
frontend/public/mediapipe/NOTICE.txt
Normal file
@@ -0,0 +1,16 @@
|
||||
selfie_segmenter.tflite
|
||||
=======================
|
||||
|
||||
Модель сегментации силуэта человека MediaPipe Selfie Segmenter (float16),
|
||||
используется заменой фона видео (см. frontend/src/lib/virtualBackground.ts).
|
||||
|
||||
Источник: https://storage.googleapis.com/mediapipe-models/image_segmenter/selfie_segmenter/float16/latest/selfie_segmenter.tflite
|
||||
Документация: https://ai.google.dev/edge/mediapipe/solutions/vision/image_segmenter
|
||||
Правообладатель: Google LLC
|
||||
Лицензия: Apache License 2.0 (https://www.apache.org/licenses/LICENSE-2.0)
|
||||
|
||||
Файл лежит в репозитории намеренно: npm-пакетом модель не поставляется, а
|
||||
VidConf — самоуправляемый продукт и обязан работать в контуре без доступа к
|
||||
внешним CDN. Wasm-рантайм MediaPipe (тоже Apache 2.0) в репозиторий не
|
||||
коммитится — он копируется в public/mediapipe/wasm/ из node_modules при
|
||||
сборке, см. плагин `mediapipeWasm` в frontend/vite.config.ts.
|
||||
BIN
frontend/public/mediapipe/selfie_segmenter.tflite
Normal file
BIN
frontend/public/mediapipe/selfie_segmenter.tflite
Normal file
Binary file not shown.
@@ -2,6 +2,7 @@ import { Navigate, Route, Routes } from 'react-router-dom'
|
||||
import { LoginPage } from '@/pages/LoginPage'
|
||||
import { RegisterPage } from '@/pages/RegisterPage'
|
||||
import { VerifyEmailPage } from '@/pages/VerifyEmailPage'
|
||||
import { ConsentPolicyPage } from '@/pages/ConsentPolicyPage'
|
||||
import { LobbyPage } from '@/pages/LobbyPage'
|
||||
import { JoinPage } from '@/pages/JoinPage'
|
||||
import { RoomPage } from '@/pages/RoomPage'
|
||||
@@ -19,6 +20,10 @@ function App() {
|
||||
<Route path="/login" element={<LoginPage />} />
|
||||
<Route path="/register" element={<RegisterPage />} />
|
||||
<Route path="/verify-email" element={<VerifyEmailPage />} />
|
||||
{/* Публичная страница регламента обработки ПДн — читается до регистрации,
|
||||
когда пользователя ещё нет; ссылка на неё — рядом с галочкой согласия
|
||||
на RegisterPage. */}
|
||||
<Route path="/legal/personal-data-consent" element={<ConsentPolicyPage />} />
|
||||
<Route
|
||||
path="/lobby"
|
||||
element={
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
* конверте пагинации `items`/`total`.
|
||||
*/
|
||||
import { apiRequest } from '@/api/client'
|
||||
import type { ConferenceRecurrence, ConferenceStatus, SummaryRecipientsMode } from '@/api/conferences'
|
||||
import type { ConferenceRecurrence, ConferenceStatus, PublishQualityCap, SummaryRecipientsMode } from '@/api/conferences'
|
||||
|
||||
/** Уровень качества AI-обработки (транскрибация + суммаризация). */
|
||||
export type AiLevel = 'min' | 'medium' | 'max'
|
||||
@@ -19,6 +19,8 @@ export interface AiLevelStatus {
|
||||
/** Эффективные настройки инстанса. */
|
||||
export interface SettingsOut {
|
||||
chat_enabled: boolean
|
||||
/** Включён ли модуль «поднятие руки» — кнопка «Рука» и очередь целиком. */
|
||||
hand_queue_enabled: boolean
|
||||
/** Единый переключатель модуля AI (транскрибация + суммаризация). */
|
||||
transcription_enabled: boolean
|
||||
/** Есть ли хотя бы один Celery-воркер, обслуживающий очередь транскрибации. */
|
||||
@@ -39,11 +41,28 @@ export interface SettingsOut {
|
||||
contact_email_enabled: boolean
|
||||
/** Контактный адрес — `null`, если не задан/выключен. */
|
||||
contact_email: string | null
|
||||
/** Потолок качества исходящего видео публикующего — см. `PublishQualityCap`. */
|
||||
publish_quality_cap: PublishQualityCap
|
||||
/** Максимум одновременно видимых плиток сцены (`StageGrid`). */
|
||||
stage_max_tiles: number
|
||||
/** Обязательна ли галочка согласия на обработку персональных данных при регистрации. */
|
||||
consent_required: boolean
|
||||
/** Текст регламента (редактируемый шаблон, дефолт — типовой образец без юридической силы). */
|
||||
consent_policy_text: string
|
||||
/** Номер редакции текста — растёт при каждом изменении `consent_policy_text`. */
|
||||
consent_policy_version: number
|
||||
/** Проверка устройств на входе (сессия 33) — запрос доступа к камере/микрофону
|
||||
* и превью камеры на странице логина и в карточке «Как вас зовут?». */
|
||||
device_check_enabled: boolean
|
||||
/** Замена фона видео на картинку (сессия 35) — кнопка «Фон» в комнате,
|
||||
* выбор фона в превью на входе и раздел «Свои фоны» в профиле. */
|
||||
virtual_background_enabled: boolean
|
||||
}
|
||||
|
||||
/** Тело частичного обновления настроек инстанса — все поля опциональны. */
|
||||
export interface SettingsUpdateIn {
|
||||
chat_enabled?: boolean
|
||||
hand_queue_enabled?: boolean
|
||||
transcription_enabled?: boolean
|
||||
/** Недоступный уровень (см. `ai_levels`) — backend отвечает 400. */
|
||||
ai_level?: AiLevel
|
||||
@@ -56,6 +75,13 @@ export interface SettingsUpdateIn {
|
||||
/** Включение без email или невалидный email — backend отвечает 400. */
|
||||
contact_email_enabled?: boolean
|
||||
contact_email?: string | null
|
||||
publish_quality_cap?: PublishQualityCap
|
||||
stage_max_tiles?: number
|
||||
/** Включение с пустым текстом регламента — backend отвечает 400. */
|
||||
consent_required?: boolean
|
||||
consent_policy_text?: string
|
||||
device_check_enabled?: boolean
|
||||
virtual_background_enabled?: boolean
|
||||
}
|
||||
|
||||
/** Тело запроса тестовой отправки письма (`POST /admin/settings/test-email`). */
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user