84 Commits

Author SHA1 Message Date
65fbcf952c release: версия 0.0.27
Some checks failed
CI / backend (push) Has been cancelled
CI / frontend (push) Has been cancelled
2026-08-04 02:52:49 +03:00
b44652d6a6 fix(room): разрыв связи выбрасывал участника в лобби вместо возврата в конференцию
Телефон с погасшим экраном (и просто свёрнутый браузер) выпадал из
конференции: Chrome срезает фоновой вкладке ресурсы, ICE перестаёт
отвечать, и LiveKit закрывает участника через 5 с после потери
соединения. Замерено на проде: 37 с после блокировки экрана, 23 с
после сворачивания браузера. Восстановить сессию после этого нельзя
(сервер отвечает "could not restart participant") — нужен полный
повторный вход, и livekit-client его пытается сделать сам, но его
бюджет повторов в фоновой вкладке успевает сгореть. Тогда приходило
событие Disconnected, и страница уводила пользователя в лобби.

Теперь непреднамеренный разрыв не уводит со страницы, а сбрасывает
joinState — дальше работает уже написанный путь авто-перезахода:
резолв конференции, свежий токен, вход заново. Намеренный выход
отличается по флагу от кнопки "Выйти", а не по коду причины: причина
CLIENT_INITIATED приходит и от кнопки, и от самого livekit-client,
который при заморозке вкладки (событие freeze) вызывает disconnect()
сам — и эта его подписка не отключается опцией disconnectOnPageLeave.

Разрывы, после которых возвращаться нельзя (выгнал организатор,
конференция закрыта, вход той же личностью с другого устройства),
уводят в лобби как раньше. От бесконечного цикла "вошёл — сразу
выбросило" защищает лимит в 5 перезаходов подряд; соединение,
прожившее дольше 30 с, счётчик обнуляет.
2026-08-04 02:52:22 +03:00
d11b808e97 release: версия 0.0.26
Some checks failed
CI / backend (push) Has been cancelled
CI / frontend (push) Has been cancelled
2026-08-04 00:01:22 +03:00
ed6f9fff44 feat(room): полноэкранный режим на мобильном с выезжающим тулбаром
Вход — кнопка «Экран» в тулбаре, теперь видна и на мобильном (по аналогии
с десктопом, а не спрятана в шторку настроек). В полноэкранном режиме
топбар и тулбар выходят из потока и лежат оверлеем поверх сцены: тап/клик
по сцене вне элементов управления показывает их, сами прячутся через
несколько секунд бездействия; на десктопе дополнительно — наведение мыши
в нижнюю полосу экрана. Работает одинаково на мобильном и десктопе.

Кнопка настроек устройств в тулбаре на мобильном подписана «Настройки»
вместо «Устройства». Раскладка кнопок мобильного тулбара, когда они не
помещаются в один ряд (7+, обычный случай с «Экраном» и «Очередью» у
организатора), стала равномерной сеткой на 4 колонки вместо переноса
«как получится» через flex-wrap.
2026-08-04 00:00:45 +03:00
06455f2401 fix(room): шторка настроек не закрывалась свайпом вниз
Обработчики висели только на ручке `.room-sheet-handle` (40×4px) —
попасть в неё пальцем практически невозможно, и палец почти всегда
приземлялся на панель, где обработчиков не было вовсе. Свайп теперь
закрывает шторку при жесте по любому месту панели, но только когда
содержимое проскроллено в самый верх — иначе палец должен листать
список устройств, как в любом стандартном bottom sheet.
2026-08-03 23:59:26 +03:00
3847476798 chore(deploy): render-templates.sh умеет читать другой файл значений
Some checks failed
CI / backend (push) Has been cancelled
CI / frontend (push) Has been cancelled
Путь к файлу со значениями был жёстко зашит как `<корень>/.env`. На
машине разработчика корневой `.env` держит боевые адреса
(LIVEKIT_NODE_IP/TURN_EXTERNAL_IP смотрят на прод), поэтому рендерить из
него конфиги для локального стенда нельзя, а подменить нечем — локальные
сессии дважды повторяли логику скрипта вручную через envsubst, что
означало расхождение с реальным рендером при первой же правке шаблонов.

Теперь источник значений задаётся переменной ENV_FILE:

    ENV_FILE=.env.local ./deploy/render-templates.sh

Поведение по умолчанию не меняется — тот же корневой `.env`. install.sh
свою переменную ENV_FILE не экспортирует, так что она сюда не протекает;
вызов из install.sh и рендер на сервере работают как раньше. Скрипт
дополнительно печатает, из какого файла взяты значения, и подсказывает
про ENV_FILE, если файл не найден.
2026-08-03 18:45:20 +03:00
a895782250 release: версия 0.0.25
Some checks failed
CI / backend (push) Has been cancelled
CI / frontend (push) Has been cancelled
2026-08-03 18:24:13 +03:00
fa8270c156 fix(room): мини-окно игнорировало закрепление, а демонстрация слетала от реплики
Some checks failed
CI / backend (push) Has been cancelled
CI / frontend (push) Has been cancelled
Мини-плеер намеренно вёл себя иначе, чем основное окно: без удержания
демонстрации экрана (holdScreenShare), без приоритета говорящего с
включённой камерой, без антидребезга говорящего и с собственным чистым
useState для закрепления. На практике это читалось как поломка —
закрепление, сделанное в основном окне, в мини-окне не действовало, а
демонстрация экрана пропадала, стоило кому-то сказать слово.

Теперь pickStageFocus получает одинаковые правила в обоих вариантах
сцены. Единственное сознательное отличие — localKey («показать себя»
последним фолбэком), он остаётся только у мини-плеера: это защита от
дефекта 0.0.11, когда мини-окно открывалось на самом пользователе.

Закрепление переезжает между окнами тем же мостиком через RoomPage,
что и фокус (initialPinnedKey/onPinnedKeyChange). Отдельный общий
источник правды не нужен: экземпляр сцены в каждый момент ровно один —
пока открыт Document PiP, основное окно показывает заглушку.

Заодно в снятии закрепления «участник вышел из комнаты» добавлена
охрана tracksKnown. На первом рендере нового экземпляра сцены useTracks
отдаёт пустой массив, и пустой набор читался как «все вышли»: приехавшее
через initialPinnedKey закрепление обнулялось прямо при монтировании,
то есть мини-плеер терял его каждый раз.

Надпись на булавке — «Закрепить» вместо «Закрепить в основном окне»:
закрепление больше не ограничено основным окном.
2026-08-03 18:21:43 +03:00
a9e24f6692 fix(room): в Chrome пропадал звук после возврата из мини-окна
RoomAudioRenderer жил внутри RoomStage и рендерился дважды — в ветке
variant="pip" и в основной. При открытии Document PiP сцена
размонтируется в основном окне и монтируется в PiP-окне, поэтому
скрытые <audio> с чужими аудиотреками физически переезжали в ДРУГОЙ
документ, а при возврате — обратно. После такого переезда Chrome
теряет аудиовыход у remote-трека: пакеты продолжают приходить
(packetsReceived растёт), но totalSamplesDuration и totalAudioEnergy
замирают, и трек молчит даже в свежесозданном <audio> со свежим
MediaStream. Тот же цикл detach/attach в пределах одного документа
безвреден — дело именно в переезде между документами. В Safari бага не
было: там Document PiP не используется (video-фолбэк), сцена остаётся
в основном окне.

Рендерер вынесен в RoomPage — один экземпляр, всегда в основном
документе. В PiP-окне аудиоэлементов теперь нет вовсе, цикл «открыл
мини-окно → вернул» их не касается.
2026-08-03 18:19:19 +03:00
8b63c24332 release: версия 0.0.24
Some checks failed
CI / backend (push) Has been cancelled
CI / frontend (push) Has been cancelled
2026-08-03 01:58:32 +03:00
cd5399f88a fix(auth): заголовок вылезал за брендовую панель на широком окне
Some checks failed
CI / backend (push) Has been cancelled
CI / frontend (push) Has been cancelled
Кегль «Ваша инфраструктура» уменьшался только в @media по ширине ОКНА —
не спасало, когда окно широкое, а панель (42% от него, `.layout` flex
42/58) сама по себе узкая: слово не помещалось, overflow:hidden обрезал
его вместо переноса. Перешли на container query (`container-type:
inline-size` на .brand-panel + `cqw` на .brand-headline) — тот же приём,
что у аватара участника в комнате (container-type:size + cqmin,
room.css). Кегль теперь считается от реальной ширины панели, а не окна,
в любой раскладке.

Тем же механизмом клипались плашки .brand-stats («AI-саммари») — добавлен
flex-wrap в базовое правило вместо только-мобильного.
2026-08-03 01:56:42 +03:00
2be799b19d fix(room): эмодзи-поповер в чате — 5-я колонка вылезала за рамку
Настоящая причина (подтверждена вживую через getBoundingClientRect):
`.chat-input-row button` (стиль круглой кнопки «Отправить», width/height
42px) красил размер и кнопкам эмодзи внутри поповера — они тоже лежат в
.chat-input-row. Сетка на 5 колонок по 42px требовала ~238px при
фиксированной ширине попапа 220px, лишнее уезжало вправо за рамку.
Явные width/height: auto на .chat-emoji-option перебивают унаследованный
размер нужной специфичностью, дальше квадрат считает aspect-ratio от
реальной ширины колонки (~36px). Заодно — max-width на попап и
overflow:hidden/white-space:nowrap на кнопки как общая страховка от
переполнения на очень узких экранах.
2026-08-03 01:56:27 +03:00
6b9d0b833e fix(room): чат вылезает за панель в Firefox — min-width/min-height:0
`.chat-messages` (flex-колонка) без явного min-height:0 не сжимается до
flex:1 в Firefox и растёт по контенту списка сообщений. Textarea в
.chat-input-row без min-width:0 упирается в автоматическую минимальную
ширину, которую Firefox считает от атрибута cols (умолчание 20 символов) —
жёстче, чем Chrome/Safari.
2026-08-03 01:56:03 +03:00
f4e8f91839 release: версия 0.0.23
Some checks failed
CI / backend (push) Has been cancelled
CI / frontend (push) Has been cancelled
2026-08-02 22:29:34 +03:00
fb50c5d8ea docs(deploy): таблица «профиль нагрузки → железо» на реальных замерах с прода
Some checks failed
CI / backend (push) Has been cancelled
CI / frontend (push) Has been cancelled
Формула CPU/RAM/полосы для медиа-нагрузки (не AI): подписки = камер ×
(участников − 1), подтверждено точно двумя боевыми замерами (28.07 и
31.07.2026). Коэффициенты полосы на подписку и CPU на ядро — из тех же
замеров, с явными допущениями и предупреждением не путать пиковый трафик
с устойчивым. Профили — малая команда/совещание/большое собрание/
смешанная нагрузка, включая пример недостижимого профиля и как его
спасают лимит плиток и потолок качества публикации (0.0.21).

Перекрёстные ссылки из README, hardware-profiles.md, capacity.md.
2026-08-02 22:28:55 +03:00
826a0a391a fix(coturn): verbose-логирование — иначе ALLOCATE/CreatePermission не видны в docker logs
Some checks failed
CI / backend (push) Has been cancelled
CI / frontend (push) Has been cancelled
Дефолтный simple-log в coturn не пишет построчно ALLOCATE/CreatePermission/
Refresh даже при реально работающем relay (найдено на релизе 0.0.22:
рабочий звонок через TURN, но grep -ci allocate по логам coturn = 0,
подтверждение пришлось брать из логов LiveKit). Добавлен флаг verbose
(умеренный режим, не Verbose/-V — тот слишком шумный). DEPLOYMENT.md
предупреждает, что проверка через grep -ci allocate надёжна только с
этим флагом.
2026-08-02 22:11:32 +03:00
4f5f336cd6 release: версия 0.0.22
Some checks failed
CI / backend (push) Has been cancelled
CI / frontend (push) Has been cancelled
2026-08-02 20:27:36 +03:00
0441f4f72b docs(deploy): описать включение TURN over TLS, обновление сертификата, порты
Some checks failed
CI / backend (push) Has been cancelled
CI / frontend (push) Has been cancelled
Раздел 8: как включить TURN_TLS_HOST, почему 443 недоступен coturn без
SNI-мультиплексора (порт уже занят nginx через Docker port-publish),
пошаговая проверка (openssl s_client, аллокации в логах). Раздел 5:
deploy-hook certbot теперь перекопирует сертификат в coturn-certs-init и
перезапускает coturn при продлении — без этого TLS-TURN тихо остановится
через ~60 дней со старым сертификатом.
2026-08-02 20:27:01 +03:00
9bc8d6174d feat(coturn): TURN over TLS на 5349 — сертификаты, монтирование, анонс клиентам
coturn (nobody:nogroup, без root-фазы в entrypoint) не может сам прочитать
приватный ключ Let's Encrypt — coturn-certs-init (по образцу
recordings-init/llm-models-init) копирует fullchain/privkey в отдельный
volume под правами 644, не трогая права на ключ на хосте.

TURN_TLS_HOST в .env — единственный переключатель фичи: пусто (dev-дефолт)
вырезает TLS-блоки из turnserver.conf и rtc.turn_servers целиком (маркеры
BEGIN/END-TLS-* в *.template, render-templates.sh), непустое значение
включает оба сразу — TLS без анонса LiveKit клиентам не имеет смысла
(история 0.0.14: coturn работал healthy, но клиенты о нём не знали, и не
было ни одной аллокации). Значение обязано быть доменом сертификата, а не
IP — иначе браузер не пройдёт TLS-валидацию по имени хоста для turns:.

TLS-запись в rtc.turn_servers стоит последней в списке (фолбэк дороже
прямого UDP/TCP).
2026-08-02 20:26:56 +03:00
45c997380f release: версия 0.0.21
Some checks failed
CI / backend (push) Has been cancelled
CI / frontend (push) Has been cancelled
2026-08-02 19:57:51 +03:00
173d384f06 feat(admin): рычаги нагрузки медиа — потолок качества публикации и лимит плиток
Some checks failed
CI / backend (push) Has been cancelled
CI / frontend (push) Has been cancelled
instance_settings.media_limits (publish_quality_cap: off/720p/360p/180p,
stage_max_tiles: 4/9/16/25) — новая вкладка «Нагрузка» в админке, дефолты
(off/25) сохраняют текущее поведение существующих инсталляций.

Настройка отдаётся не только GET /admin/settings, но и в join-ответе
(JoinOut) — участнику нужно иметь её на руках ДО публикации трека, а
/admin/settings доступен только администратору.

Потолок качества применяется через RoomOptions.publishDefaults
(videoEncoding + videoSimulcastLayers на пресетах VideoPresets LiveKit) —
режет битрейт верхнего слоя симулкаста, реальное разрешение WebRTC
подстраивает сам. Лимит плиток — фильтрация STAGE_GRID_LAYOUTS по
columns*rows в StageGrid, лишние участники уходят на страницу пагинации
вместо подписки.

Значение приезжает в joinState вместе с токеном ДО первого рендера
LiveKitRoom (RoomPage не рендерит его, пока joinState не заполнен целиком),
поэтому смена настройки не переподключает уже вошедшего участника —
roomOptions пересчитывается по стабильной ссылке на joinState, которая
после подключения не меняется.

Проверено вживую на локальном стенде (docker compose --profile media):
сохранение/персист настроек, join отдаёт актуальные значения, уже
подключённый участник не разрывается при смене настройки в другом окне.
2026-08-02 19:53:23 +03:00
7b2427535a release: версия 0.0.20
Some checks failed
CI / backend (push) Has been cancelled
CI / frontend (push) Has been cancelled
2026-08-02 12:34:04 +03:00
8e45038251 fix(livekit): один UDP-порт вместо диапазона на 101 порт
Диапазон 54000-54100/udp заставлял Docker поднимать по отдельному
docker-proxy на каждый порт — весь медиатрафик шёл лишним userland-хопом.
rtc.udp_port переключает LiveKit на мультиплексирование ICE-сессий через
один порт; TURN и остальные связи (redis/nginx/webhook/prometheus) не
задеты. Проверено локально lk load-test — 0% потерь пакетов, ICE во всех
сессиях выбирает новый порт.
2026-08-02 12:33:53 +03:00
286c01d93b release: версия 0.0.19
Some checks failed
CI / frontend (push) Has been cancelled
CI / backend (push) Has been cancelled
2026-08-02 05:03:05 +03:00
e8af2fce10 fix(room): адаптивный тулбар — плавное сжатие кнопок вместо оверфлоу
Some checks failed
CI / backend (push) Has been cancelled
CI / frontend (push) Has been cancelled
Задачи B1/B2 добавили в тулбар «Рука» и «Очередь» (до 11 кнопок вместо
исходных 8) — на промежуточных ширинах (~600–1100px) кнопки вылезали за
края тулбара: жёсткого мобильного брейкпоинта (≤600px, скрывает три
кнопки) не хватало, а до него сжатия не было вовсе.

Иконка/отступы/зазоры/шрифт кнопок теперь плавно уменьшаются на диапазоне
1200px → 600px через `clamp()` с явной линейной интерполяцией (обычный
`clamp(min, Nvw, max)` не подошёл — подобранный N упирался в потолок уже на
довольно широких экранах, сжатие получалось резким скачком заметно раньше
нужной ширины, а не плавным). Нижние границы совпадают с тем, что жёстко
выставляет мобильный медиа-запрос на 600px — переход в него визуально
бесшовный.

Побочный эффект сжатия: подпись «Мини-окно» на промежуточных ширинах
переносилась на 2 строки и делала эту кнопку выше соседних. Ниже 1200px
показываем короткое «Мини» вместо полной подписи — кнопка остаётся
однострочной; aria-label по-прежнему несёт полный смысл для скринридеров.
2026-08-02 05:00:22 +03:00
d7ae462ed5 fix(room): очередь поднятых рук — компактный поповер вместо панели во весь экран
HandQueuePanel рендерился как боковая панель на весь экран на мобильном
(.chat-panel, унаследовано от чата) — для короткого списка поднятых рук это
избыточно, история переписки тут ни при чём. Переделано в HandQueueMenu —
тот же самодостаточный поповер над кнопкой, что и «Вид» (StageViewMenu):
своё состояние открытия, закрытие по клику вне/Escape, .tb-menu вместо
боковой панели. Кнопка «Очередь» больше не получает open-состояние
пропсами сверху — сама решает, открыта ли, и сама проверяет
useIsOrganizer().

Список внутри растёт вместе с очередью (компактно при 1–2 поднятых руках),
но не бесконечно — после ~10 строк упирается в max-height и скроллится
дальше.
2026-08-02 04:58:56 +03:00
826b7639b1 fix(chat): специфичность CSS эмодзи-поповера + сетка 6×5
Кнопка эмодзи и все кнопки внутри поповера лежат в DOM внутри
.chat-input-row (форма отправки) — той же формы, что и круглая зелёная
кнопка «Отправить» (.chat-input-row button, специфичность 0,1,1). Голого
класса .chat-emoji-trigger/.chat-emoji-option (0,1,0) для победы над этим
правилом не хватало: все кнопки поповера красились в зелёный независимо от
порядка объявления в файле — оттуда и «поплывший» вид после добавления
коня, дело было не в самом коне. Каждый селектор уточнён родительским
классом (.chat-emoji-wrap/.chat-emoji-popover), чтобы обойти гонку
специфичности.

Заодно сетка приведена к ровным 5 колонкам × 6 строкам — добавлены
🦾 🚀 🦞 💯 🤷‍♂️, теперь 30 эмодзи без неполной последней строки.
2026-08-02 04:57:39 +03:00
daaa480f03 release: версия 0.0.18
Some checks failed
CI / backend (push) Has been cancelled
CI / frontend (push) Has been cancelled
2026-08-01 23:42:53 +03:00
c60047c594 fix(conferences): rate limit блокировал вход всей конференции сразу
Два бага в одном месте, оба вскрылись на нагрузочном тесте 31.07.2026.

1. Ключ лимита строился по `request.client.host`. Backend стоит за nginx,
   поэтому это адрес КОНТЕЙНЕРА NGINX, одинаковый для всех пользователей.
   Проверено на проде: в Redis лежал единственный ключ
   `rate_limit:resolve:172.18.0.13`. То есть лимит «10 запросов в минуту»
   действовал на весь инстанс разом, а не на клиента.

2. Считались все запросы подряд, включая успешные. Одиннадцатый человек,
   открывший ссылку на конференцию в течение минуты, получал 429 — и видел
   «Не удалось найти конференцию» для существующей и активной конференции.
   Люди попадали внутрь с пятой-десятой попытки, попадая в новое окно.

Что изменилось:
- адрес клиента берётся из `X-Real-IP` (nginx его уже передаёт). Именно
  `X-Real-IP`, а не первый элемент `X-Forwarded-For`: последний заполняется
  через `$proxy_add_x_forwarded_for`, то есть дописывается к присланному
  клиентом, и лимит обходился бы одним заголовком;
- жёсткий счётчик (10/мин, как было) теперь считает только ПРОМАХИ:
  конференция не найдена или пароль неверен. Именно так выглядит перебор
  номера, от которого лимит и защищает по ADR-001, п.4;
- на общий поток с адреса оставлен мягкий потолок 300/мин — против тупого
  флуда. Офис за общим NAT это один адрес, поэтому потолок заведомо выше
  правдоподобного числа участников одной конференции.

Тесты: успешные резолвы и гостевые входы не упираются в лимит (50 и 30
подряд); перебор номера, несуществующий идентификатор и подбор пароля
по-прежнему упираются; лимит одного клиента не задевает другого.
2026-08-01 23:42:36 +03:00
f39c21e7e1 release: версия 0.0.17
Some checks failed
CI / backend (push) Has been cancelled
CI / frontend (push) Has been cancelled
2026-08-01 23:20:31 +03:00
84b7f807f7 fix(auth): проверка пароля больше не блокирует весь backend
На нагрузочном тесте 31.07.2026 около 70 человек заходили одновременно.
Вход развалился: p95 `/api/v1/auth/token` — 7.28 с, p95 `guest-join` —
7.06 с, в БД 33 соединения `idle in transaction` при ОДНОМ активном
запросе. Люди попадали внутрь с пятой-десятой попытки, часть не попала
вовсе. Медиа при этом работало штатно: 30 участников с 27 камерами в
следующем окне прошли без единого лага.

Причина — argon2 считался синхронно внутри async-обработчика. Замер на
боевом сервере: 95–155 мс на одну проверку, и всё это время event loop
процесса стоит целиком. Транзакция БД к тому моменту уже открыта
(`get_by_email` сделал SELECT), поэтому соединение висело без работы, пул
из 40 выбирался, и отказы получали совершенно посторонние ручки — включая
вход в конференцию, где никакого пароля не проверялось.

Что изменилось:
- `hash_password`/`verify_password` стали асинхронными и считаются в пуле
  потоков (`asyncio.to_thread`). argon2-cffi освобождает GIL, поэтому
  проверки идут по-настоящему параллельно;
- параметры argon2id заменены с дефолтов библиотеки (t=3, m=64 МБ, p=4) на
  рекомендацию OWASP (t=2, m=19 МБ, p=1): 95 мс → 42 мс. Отдельно важен
  `parallelism`: при p=4 одна проверка пароля занимала все четыре ядра
  сервера — те же, на которых работает LiveKit;
- добавлен `needs_rehash`: существующие хэши проверяются как прежде
  (параметры зашиты в саму строку) и лениво перевыпускаются при первом
  успешном входе.

Расчёт по замерам: пачка из 70 логинов — 6.7–10.9 с блокировки против
~0.36 с без неё.

Тесты: event loop продолжает тикать во время проверки; 8 параллельных
проверок укладываются заметно быстрее восьми последовательных; хэш со
старыми параметрами принимается и перевыпускается при входе.
2026-08-01 23:19:52 +03:00
e5c596f2bf release: версия 0.0.16
Some checks failed
CI / backend (push) Has been cancelled
CI / frontend (push) Has been cancelled
2026-08-01 22:08:34 +03:00
4c60e092e5 feat(room): принудительный мьют участника организатором
Some checks failed
CI / backend (push) Has been cancelled
CI / frontend (push) Has been cancelled
Новый эндпоинт POST /conferences/{id}/mute-participant: права проверяются
ЗАНОВО по владельцу конференции в БД (ConferenceService.mute_participant),
не по метаданным LiveKit-токена вызывающего — те лишь подсказка для UI и
потенциально подделываемы клиентом. Обычный участник получает 403, чужая/
несуществующая конференция — 404, участник не в комнате LiveKit — отдельный
404 (participant_not_in_room).

Само выключение — серверный вызов api.LiveKitAPI (services/room_control.py,
тот же паттерн, что services/egress.py): backend аутентифицируется
СОБСТВЕННЫМИ api_key/api_secret, а не токеном организатора, поэтому
дополнительный LiveKit-грант в токене организатора не нужен — мьютит сервер
от своего имени. Если трек данного source не опубликован (с 0.0.15 участники
заходят с выключенными микрофоном/камерой) — не ошибка, а no-op: искомое
состояние уже достигнуто, ответ muted:false.

Уведомление участника — тот же общий канал комнаты, что и очередь рук
(hand_queue_channel): рассылается всем, получатель сам сверяет identity
(ForcedMuteWatcher, рендерится внутри LiveKitRoom). Само выключение трека
участник видит сразу через штатный useTrackToggle (LiveKit сам присылает
TrackMuted), тост только поясняет причину — иначе не отличить от глюка.
Включить себя обратно можно сразу тем же тулбаром, сервер это не блокирует.

Кнопки — на чужой плитке камеры, видны только организатору по наведению
(на тач-устройствах — всегда, как и булавка закрепления).

Тесты: владелец мьютит успешно и публикует broadcast, уже-выключенный трек —
muted:false без broadcast, администратор мьютит чужую конференцию, обычный
участник получает 403 без обращения к LiveKit, конференция не найдена и
участник не в комнате — соответствующие 404.
2026-08-01 22:07:06 +03:00
8e5eda88a2 feat(room): поднятие руки и очередь для организатора
Транспорт — существующий аутентифицированный WS чата (api/chat.py), а не
отдельный эндпоинт: сервер уже держит это соединение на каждого участника
(обоснование — докстринг chat_websocket и useChat.ts). Состояние очереди —
Redis (services/hand_queue.py), не Postgres: это эфемерное состояние звонка,
а не история, и два процесса uvicorn делают наивную память одного процесса
недостаточной. HSETNX даёт идемпотентное «поднять» (повторный клик не
переставляет в конец очереди), снапшот шлётся всем участникам при любом
изменении — организатор, зашедший позже, сразу видит актуальную картину.

Опустить чужую руку может организатор (решение оператора) — проверка через
conference.owner_id, не через identity клиента. Участник, вышедший из
комнаты LiveKit (webhook participant_left), теряет место в очереди
автоматически; переподключение WS чата место не сбрасывает (Redis не привязан
к жизни соединения). room_finished чистит очередь целиком — она не должна
пережить завершение звонка.

Побочный эффект транспортного решения: поднять руку нельзя, если чат выключен
настройкой инстанса (WS вообще не открывается) — принятый компромисс ради
переиспользования уже готового канала.

UI: кнопка «Рука» в тулбаре (у всех, бейдж — общий счётчик), бейдж на плитке
говорящего (видно всем), панель «Очередь» организатору (HandQueuePanel).
Кнопка «Рука» и панель «Очередь» намеренно НЕ прячутся в мобильную шторку
настроек, в отличие от «Вида», — поднятие руки посреди разговора требует
кнопки под рукой, а не в два клика вглубь настроек.

Этим же коммитом (файлы разделяемые с задачей B2, RoomParticipantTile.tsx/
useChat.ts/RoomStage.tsx/RoomPage.tsx/room.css) — проброс conferenceId и
каркас forced_mute-обработки, без которых кнопки принудительного мьюта не
скомпилировались бы; сама реализация мьюта — следующим коммитом.
2026-08-01 22:06:43 +03:00
42bfb88a22 feat(room): роль организатора в метаданных LiveKit-токена
Предварительная работа для очереди рук и принудительного мьюта (B1/B2):
build_join кладёт is_organizer:true в метаданные токена организатора
(создатель мгновенной конференции и владелец при обычном входе). Метаданные
токена — только подсказка для UI (см. предупреждение в докстринге
build_join), любое серверное действие организатора обязано перепроверяться
по conference.owner_id в БД — так и сделано в mute_participant (B2).

На фронте — общий парсер метаданных участника (lib/participantMetadata.ts,
переиспользован в RoomParticipantTile вместо локальной копии) и хук
useIsOrganizer (читает подсказку для локального участника через
useLocalParticipant — вызывается только внутри LiveKitRoom).
2026-08-01 22:06:10 +03:00
07dc1aaeef feat(chat): выбор эмодзи скачущего коня
Добавлен 🐎 в набор EMOJI_OPTIONS по просьбе оператора.
2026-08-01 22:04:44 +03:00
0162cc8a6d release: версия 0.0.15
Some checks failed
CI / backend (push) Has been cancelled
CI / frontend (push) Has been cancelled
2026-07-30 00:27:51 +03:00
5d39e0f076 feat(room): кнопка микрофона в мини-окне (PiP)
Some checks failed
CI / backend (push) Has been cancelled
CI / frontend (push) Has been cancelled
Document PiP не показывает тулбар комнаты — во время звонка в мини-окне
нельзя было замьютить себя, не разворачивая основное окно. Кнопка лежит
поверх единственной плитки мини-плеера и использует тот же useTrackToggle,
что и тулбар: React-контекст LiveKitRoom не разрывается порталом в
pipWindow.document.body, поэтому состояние читается и меняется одинаково
что в основном окне, что в мини-плеере.
2026-07-30 00:25:19 +03:00
43cc1ae3e8 feat(room): закрепление участника доступно из любого режима показа
Булавка закрепления раньше рендерилась только в «Стандарте» — в «Плитках»
и «Живых плитках» её не было вовсе, хотя крупной плитки для показа
закреплённого там и так нет. Теперь кнопка есть на любой плитке в любом
режиме, а сам клик закрепления (не открепления) переключает вид на
«Стандарт» — иначе закреплённого негде показать крупно.

Открепление обратно на предыдущий вид не переключает — это разовое
действие, а не устойчивый выбор пользователя, дёргать раскладку туда-обратно
не нужно. Переключение при закреплении не сохраняется в localStorage
(в отличие от явного выбора режима в тулбаре/настройках) по той же причине.

Заодно закрывает задачу «закрепление на мобильном»: раньше кнопки не было
в плиточных режимах даже там, а на тач-устройствах она и так всегда видна
(`@media (hover: none)`), отдельной мобильной доработки не потребовалось.
2026-07-30 00:25:02 +03:00
82204553e2 feat(chat): выбор эмодзи в панели чата
Кнопка со смайликом слева от поля ввода открывает поповер с небольшим
собственным набором популярных эмодзи (без библиотеки-пикера) — выбор
вставляется в позицию курсора. Поповер открывается вверх от кнопки, поэтому
на мобильном никогда не перекрывает textarea; закрытие — по клику вне и
по Escape (переиспользован useModalDismiss).
2026-07-30 00:23:53 +03:00
3290eb5d07 feat(room): показывать название конференции вместо технического room_name
RoomTopbar рендерил `JoinOut.room_name` — техническое имя комнаты LiveKit
вида `hC-Dmos9KEM`, которое туда попало по ошибке (перепутано с
человекочитаемым title конференции). Теперь топбар получает `title`
(из резолва номера/ссылки или из карточки конференции в лобби) и падает
на «Конференция без названия», если организатор его не задал. Номер и
slug остаются на своих местах — в инвайт-чипе и в адресе, где они нужны
функционально.
2026-07-30 00:23:18 +03:00
f627d827af feat(room): выключенные микрофон и камера при входе в конференцию
Раньше LiveKitRoom публиковал оба трека сразу при подключении (audio/video
без значения — булев true). Теперь участник заходит с выключенными
микрофоном и камерой и включает их сам уже в комнате; кнопки тулбара
(useTrackToggle) отражают фактическое состояние и работают штатно.
2026-07-30 00:22:44 +03:00
5e6c4f8bfd chore(monitoring): глубина логов и срок хранения метрик под наблюдение неделями
Some checks failed
CI / backend (push) Has been cancelled
CI / frontend (push) Has been cancelled
Готовим стенд к сбору статистики по реальным конференциям вместо разового
нагрузочного теста. В прежней конфигурации данные не дожили бы до разбора.

Логи: 10 МБ × 3 → 50 МБ × 5 на контейнер. При полусотне участников логи
LiveKit перезаписывались за часы, а по ним восстанавливается то, чего нет
в метриках: сколько камер работало одновременно, кого и почему отключило,
как шли события congestion. Именно так был уточнён профиль теста 28.07
(оказалось 17 камер, а не 8).

Prometheus: retention 15 суток (дефолт) → 30. Первые два флага в `command`
дублируют дефолт образа намеренно — `command` перекрывает CMD целиком, без
них Prometheus не найдёт конфиг.

Расход: 250 МБ логов на контейнер и рост TSDB со 100 МБ; на сервере
свободно 15 ГБ.
2026-07-29 13:32:18 +03:00
270926cc96 release: версия 0.0.14
Some checks failed
CI / backend (push) Has been cancelled
CI / frontend (push) Has been cancelled
2026-07-29 00:17:19 +03:00
e018837a1d fix(livekit): анонсировать клиентам внешний TURN — relay не работал совсем
coturn поднимался, был healthy и слушал 3478 — но клиенты о нём никогда не
узнавали: встроенный TURN выключен (`turn.enabled: false`), внешний в
конфигурации не объявлен, фронтенд `iceServers` не задаёт. За всё время
работы сервера в логах coturn нет ни одной аллокации.

Следствие: у участников из сетей, где прямое UDP-соединение не проходит,
не было relay-фолбэка вообще — только прямой UDP и TCP 7881. На
нагрузочном тесте 28.07 все разрывы `PEER_CONNECTION_DISCONNECTED`
пришлись на внешних участников и ни одного — на офисных.

Добавлена секция `rtc.turn_servers` (UDP и TCP на 3478). Эти серверы
только анонсируются клиенту в списке ICE — сам SFU через них не ходит
(см. iceServersForParticipant в LiveKit). Credentials генерируются по
механизму TURN REST API из общего `TURN_STATIC_AUTH_SECRET`, поэтому
`render-templates.sh` теперь подставляет его и `TURN_EXTERNAL_IP` также в
конфигурацию LiveKit.

TLS (5349/443) намеренно не анонсируется: сертификаты в coturn не
смонтированы, а неработающий `turns:` заставил бы клиента ждать таймаута
перед переходом к рабочему кандидату. Что нужно для его включения —
описано в разделе 8 руководства.

Там же исправлено умолчание в правилах ufw: помимо 3478 нужен диапазон
relay-аллокаций `49160:49200/udp`. Без него TURN отвечает на запросы, но
релей не работает, причём в логах coturn при этом тишина.
2026-07-29 00:17:19 +03:00
705f160912 release: версия 0.0.13
Some checks failed
CI / backend (push) Has been cancelled
CI / frontend (push) Has been cancelled
2026-07-28 23:26:41 +03:00
7a5e9d2d8a perf(deploy): не пересобирать окружение uv в рантайме контейнеров
Some checks failed
CI / backend (push) Has been cancelled
CI / frontend (push) Has been cancelled
Образ собран с `uv sync --frozen --no-dev`, но `uv run` перед каждым запуском
заново синхронизирует venv и подтягивает dev-группу. В логах старта
vidconf-backend-1 и vidconf-worker-1 на проде это видно как «Downloading ruff /
mypy / pygments» и «Installed 12 packages». Хуже всего healthcheck'и: они
выполняют ту же синхронизацию каждые 15 секунд всю жизнь контейнера.

Проверено на локально собранном образе, одна и та же команда:

  uv run             — качает 12 пакетов, venv 456 → 574 МБ
  uv run --no-sync   — не качает ничего, venv остаётся 456 МБ

Флаг добавлен во все вызовы в прод-путях: CMD образа, command/entrypoint/
healthcheck всех сервисов compose, миграции и seed в install.sh, те же команды
в docs/deploy. Локальная разработка (dev-setup.md, backend/README.md, CI) не
затронута — там dev-зависимости нужны. Заодно убрано устаревшее объяснение
ретрая `up -d --wait`: первый старт больше не синхронизирует окружение.

Версия uv в образе — 0.11.33, `--no-sync` поддерживается.
2026-07-28 23:25:56 +03:00
eb4e5ea83f perf(frontend): включить adaptiveStream и dynacast
Оба флага в LiveKit по умолчанию выключены: каждый клиент был подписан на
полное качество всех чужих треков независимо от размера плитки, а каждый
паблишер слал все слои симулкаста, даже если их никто не смотрит. На тесте
28.07 (19 участников, ~8 камер) это дало устойчивые 140–169 Мбит/с исходящего
трафика при пике 240, 662 события `remote bwe: channel congestion detected` и
146 переходов аллокатора STABLE → DEFICIENT — то есть видимый участникам лаг.

Замер на локальном стенде (9 участников, паблишеры 720p, одинаковый состав
комнаты, приращение bytesReceived по getStats клиента):

  без флагов   5 потоков 1280x720 @33 fps  — 11 110 кбит/с
  с флагами    4 потока   320x150 @17 fps  —    778 кбит/с

Заодно начинает экономить уже написанный код, который до сих пор не давал
выигрыша: «скрыть остальных» не рендерит карусель (TX контейнера LiveKit
0.76 → 0.03 Мбит/с), пагинация StageGrid рендерит только текущую страницу,
а pauseVideoInBackground (дефолт true) работает лишь при adaptiveStream.

Демонстрация экрана, режимы показа и листание страниц проверены — регрессий нет.
2026-07-28 23:25:43 +03:00
b528785249 docs(deploy): правило ufw для скрейпа node-exporter из docker-сети
Some checks failed
CI / backend (push) Has been cancelled
CI / frontend (push) Has been cancelled
node-exporter переведён в host-сеть (иначе отдавал сетевые метрики
контейнера вместо серверных), и порт 9100 теперь слушается на хосте —
а значит Prometheus из docker-сети упирается в политику ufw по умолчанию.
Без этого правила таргет `node` остаётся down с `context deadline exceeded`.

Правило узкое: только из внутренних docker-подсетей (172.16.0.0/12 не
маршрутизируется в интернете) и только на 9100. Снаружи порт закрыт.

Инсталлятор firewall не настраивает — это ручной шаг документации,
поэтому строка добавлена именно сюда, иначе на новой инсталляции
мониторинг хоста молча останется без сетевых метрик.
2026-07-28 19:25:33 +03:00
a53ba7c827 fix(monitoring): node-exporter отдавал сетевые метрики контейнера вместо хоста
Some checks failed
CI / backend (push) Has been cancelled
CI / frontend (push) Has been cancelled
`node_network_*` показывал трафик собственного `eth0` экспортера (56 МБ)
вместо хостового `enp3s0` (39.8 ГБ). При разборе нагрузочного теста 28.07
сетевых метрик хоста не оказалось вовсе — весь анализ трафика пришлось
вести по метрикам контейнеров.

Причина не в конфигурации экспортера, а в устройстве procfs: bind-mount
`/proc` хоста достаточен для CPU, памяти и диска, но `/proc/net` — это
симлинк на `self/net`, который резолвится в сетевом namespace читающего
процесса. Никакое монтирование это не обходит, нужен host network
namespace. Прежний комментарий в compose утверждал обратное — исправлен.

Порт 9100 теперь слушается на хосте, наружу не торчит: ufw пропускает
только 22/80/443/3478/7881/51820 и UDP-диапазон LiveKit. Prometheus
обращается к экспортеру через `host.docker.internal` (`extra_hosts:
host-gateway`), потому что по имени сервиса в docker-сети он больше не
резолвится.

Дашборд `host.json` правок не требует: сетевые панели фильтруют
интерфейсы по исключению (`device!~"lo|veth.*|docker.*|br-.*"`), под
которое `enp3s0` не подпадает. Алерты на имя instance не завязаны.
2026-07-28 19:22:36 +03:00
71f150d1b6 feat(monitoring): собирать метрики LiveKit в Prometheus
Some checks failed
CI / backend (push) Has been cancelled
CI / frontend (push) Has been cancelled
В `livekit.yaml` порт метрик (6789) объявлен с самого начала, но job'а в
Prometheus не было — метрики SFU просто не собирались. Из-за этого разбор
нагрузочного теста 28.07.2026 пришлось вести по логам: `container-exporter`
показывает CPU, память и суммарный трафик контейнера, но не знает, что
внутри этого трафика.

Теперь доступны, в частности:
- `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` — очередь доставки вебхуков в backend.

Job включён, а не закомментирован, как `llm`: профиль `media` входит в
дефолтный набор COMPOSE_PROFILES. Конфиг проверен `promtool check config`.
2026-07-28 19:15:38 +03:00
7549b53ec9 release: версия 0.0.12
Some checks failed
CI / backend (push) Has been cancelled
CI / frontend (push) Has been cancelled
2026-07-28 19:00:09 +03:00
6d65b620fe perf(backend): явный пул соединений БД и несколько воркеров uvicorn
Пул создавался с дефолтом SQLAlchemy (5 + 10) и под нагрузкой выгребался
за секунды. Теперь параметры заданы явно и вынесены в настройки:
DB_POOL_SIZE=10, DB_MAX_OVERFLOW=10, DB_POOL_TIMEOUT=10. Таймаут снижен с
дефолтных 30 секунд намеренно — пусть запрос падает быстро и показывает
проблему, а не висит полминуты.

Backend запускался одним процессом uvicorn: любой блокирующий вызов
останавливал и параллельные запросы, и WS-чат всех участников. Добавлен
UVICORN_WORKERS с дефолтом 2 — не по числу ядер, потому что на
четырёхъядерном сервере ядра делятся с LiveKit, а медиа важнее API.

Бюджет соединений считается на весь инстанс: каждый воркер держит свой
пул, поэтому UVICORN_WORKERS × (DB_POOL_SIZE + DB_MAX_OVERFLOW) должно
оставаться заметно ниже max_connections у Postgres.

Многопроцессность безопасна: бутстрап настроек в lifespan идемпотентен
(INSERT ... ON CONFLICT DO NOTHING), а WS-чат разносит сообщения через
Redis pub/sub и состояния в памяти процесса не держит.
2026-07-28 19:00:05 +03:00
32949ebc66 fix(webhook): запуск egress не блокирует транзакцию track_published
Обработчик `track_published` вызывал `start_track_egress` внутри своей
транзакции. На инстансе без профиля `transcribe` egress-сервиса нет, и
LiveKit ждал ответа воркера через Redis до собственного таймаута psrpc —
20–25 секунд на каждый микрофонный трек. Всё это время webhook удерживал
соединение с БД и открытую транзакцию.

На нагрузочном тесте с 19 участниками (28.07.2026) это дало 226 ошибок
`QueuePool limit of size 5 overflow 10 reached` и 37 ответов 500 на путях
входа в конференцию, а со стороны LiveKit — 33 дропнутых webhook при
очереди доставки до 56 секунд.

Что изменилось:
- запуск ушёл в фоновую задачу `run_track_egress` со своей сессией БД;
  обработчик только планирует её и отвечает 200 сразу;
- добавлен ранний выход по `transcriber.enabled` — симметрично guard'у,
  который уже был в `room_finished`;
- запуск ограничен таймаутом `egress_start_timeout_s` (по умолчанию 3 с).

Идемпотентность сохранена: проверка «трек уже пишется» осталась в
обработчике, а `AudioTrackRepository.create` — это INSERT ... ON CONFLICT
DO NOTHING.

Попутно: `test_room_finished_enqueues_pipeline` падал в зависимости от
того, что осталось в локальной БД, — теперь выставляет `transcriber`
явно, как и остальные тесты этой группы.
2026-07-28 18:59:53 +03:00
b8220f682d release: версия 0.0.11
Some checks failed
CI / backend (push) Has been cancelled
CI / frontend (push) Has been cancelled
2026-07-28 04:26:02 +03:00
29a3e78836 fix(room): мини-окно открывается на плитке из основного окна
В Chrome мини-плеер показывал самого пользователя вместо того, что он видел
крупно. Сцена в мини-окне — отдельный экземпляр RoomStage (портал в PiP-окно),
и состояние фокуса он начинал с нуля: демонстрации нет, никто прямо сейчас не
говорит — pickStageFocus доходил до последнего фолбэка localKey, то есть до
«показать себя». В Safari бага не было видно: там Document PiP не
используется, а video-фолбэк берёт <video> прямо из фокус-плитки основного
окна.

Ключ фокуса теперь передаётся наружу (onFocusKeyChange), живёт в состоянии
RoomPage и достаётся следующему экземпляру сцены (initialFocusKey). Работает
в обе стороны — возврат из мини-плеера тоже не сбрасывает фокус. Правила
выбора фокуса (pickStageFocus) не тронуты.
2026-07-28 04:25:10 +03:00
3fb3a5d42c feat(room): выбор режима показа участников и скрытие остальных
Раскладка сцены больше не выбирается автоматически — пользователь выбирает
один из трёх режимов:
  - «Стандарт» — как раньше: крупная плитка плюс карусель остальных сбоку,
    кого показать крупно, по-прежнему решает pickStageFocus;
  - «Плитки» — все участники равными плитками, без выделенного крупного;
  - «Живые плитки» — сетка только из тех, у кого включена камера, остальные
    в карусели сбоку; если камеру не включил никто, сетка была бы пустой —
    режим вырождается в «Плитки».

Демонстрация экрана перебивает выбранный режим: пока в комнате есть активный
шэр, сцена ведёт себя как «Стандарт» (смысл плиточных режимов — равноправие
участников, а демонстрация неравноправна по определению). Режим при этом
живёт в состоянии RoomPage, поэтому по завершении шэра вид сам возвращается
к выбранному.

Скрытие остальных убирает карусель, основная область занимает сцену целиком.
Скрыть можно из меню «Вид», из шторки настроек на мобильном и кнопкой прямо
над колонкой миниатюр; вернуть — кнопкой «Показать остальных (N)» на сцене,
которая видна всё время, пока кто-то скрыт. В режиме «Плитки» скрывать
нечего, переключатель там заблокирован с пояснением.

Переключатель на широком экране — кнопка «Вид» с поповером; на мобильном её
нет (тулбар там и так ужат до пяти кнопок), те же настройки идут секцией в
шторке. Видимость решается условным рендерингом в React, а не новым
CSS-правилом поверх медиазапроса — в room.css за это уже была битва
специфичности.

Режим сохраняется между заходами в комнату (localStorage, своим модулем —
LocalUserChoices у LiveKit фиксированной структуры, поля под раскладку там
нет). Скрытие не сохраняется: разовое действие по ходу разговора, войти в
новую конференцию без половины участников — сюрприз, а не удобство.

Сетка собрана своим StageGrid поверх тех же публичных хуков, что использует
GridLayout: библиотечный компонент не пробрасывает gridLayouts, а её набор
раскладок требует 560px уже для 2x2 — на телефоне это две плитки на страницу.
Свой набор даёт 2x2 от 360px и портретную 2x3. Индикатор страниц тоже свой:
PaginationControl/PaginationIndicator из пакета не экспортируются.
2026-07-28 04:24:49 +03:00
975763a3a6 fix(room): круглая иконка участника вместо эллипса
Размер аватара при выключенной камере считался как width/height в процентах
от РАЗНЫХ сторон плитки, а круглым `.avatar` делает только border-radius —
на неквадратной плитке (то есть почти всегда) получался эллипс. Плюс
max-*: 96px не давал иконке вырасти на большой плитке фокуса.

Размер теперь берётся от узкой стороны плитки через container-запросы:
контейнером объявлен .lk-participant-placeholder (он position: absolute с
inset: 0, то есть повторяет плитку и уже имеет обе размерности), аватар —
width в cqmin с aspect-ratio: 1. Крупная плитка — 50cqmin, миниатюры
карусели — во всю узкую сторону минус 5px с каждого края.

Контейнер именно на placeholder-обёртке, а не на самой плитке: size
containment на плитке отрезал бы её содержимое (видео, метаданные) от
влияния на авторазмер.
2026-07-28 04:23:10 +03:00
c5b241ae02 release: версия 0.0.10
Some checks failed
CI / backend (push) Has been cancelled
CI / frontend (push) Has been cancelled
2026-07-28 01:43:24 +03:00
356bc57a2a docs(monitoring): описать контейнерный экспортер и модель безопасности
Some checks failed
CI / backend (push) Has been cancelled
CI / frontend (push) Has been cancelled
Новый раздел про container-exporter/docker-socket-proxy: зачем свой
экспортер вместо cAdvisor, какие метрики отдаёт, почему доступ к
докер-сокету через прокси безопасен. Дополнена таблица метрик backend
(vidconf_host_info).
2026-07-28 01:40:04 +03:00
afd4daee65 feat(backend): метрика vidconf_host_info для плашки характеристик сервера
Info-метрика (значение всегда 1, лейблы cpus/ram_mb/gpu_name/vram_mb) —
источник GPU для новой панели «Характеристики сервера» в дашборде «Хост
и контейнеры» (живые CPU/RAM/диск там же берутся из node-exporter,
GPU node-exporter не знает). Данные — уже читаемые Settings.hw_* из .env,
которые install.sh пишет по ADR-004. «—» вместо None/пустой строки —
однозначный прочерк на панели вместо пустого текста.
2026-07-28 01:39:58 +03:00
ace5bf7a0d feat(monitoring): экспортер метрик по контейнерам поверх Docker Engine API
Разбивка по контейнерам (CPU/память/сеть) — то, что не смог дать
cAdvisor из-за несовместимости с containerd-снапшоттером сервера 1gb
(убран в 7f5c888). Свой минимальный сервис container-exporter (Python/
aiohttp/prometheus_client) опрашивает Docker Engine API параллельно в
фоновой задаче, не завязываясь на scrape-интервал Prometheus.

Доступ к докер-сокету изолирован через docker-socket-proxy: экспортеру
разрешены только GET /containers/json и /containers/*/stats, любые
изменяющие запросы блокируются на уровне прокси (POST=0) — полная
компрометация экспортера не даёт управлять Docker. Ни один из двух
сервисов не публикует портов наружу.

В host.json возвращены панели «Топ контейнеров по CPU/памяти» на новых
метриках vidconf_container_* (в 0.0.9 их убрали вместе с cAdvisor).
2026-07-28 01:39:42 +03:00
7f5c88869a fix(monitoring): убрать cAdvisor — несовместим с containerd-снапшоттером
Some checks failed
CI / backend (push) Has been cancelled
CI / frontend (push) Has been cancelled
На сервере 1gb Docker Engine использует containerd-снапшоттер
(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, job в prometheus.yml, панели «топ контейнеров» в
host.json (Prometheus иначе резолвит cadvisor:8080 в никуда — Grafana
показывала бы «No data» вечно). node-exporter метрики хоста (CPU/RAM/
диск/сеть) при этом покрывает полностью, без изменений.

Правка конфигурации мониторинга, без изменения пользовательского
поведения — версия не бампается.
2026-07-28 00:42:25 +03:00
54fd1a26d7 release: версия 0.0.9
Some checks failed
CI / backend (push) Has been cancelled
CI / frontend (push) Has been cancelled
2026-07-28 00:26:40 +03:00
456cc58b25 docs(monitoring): описать node-exporter/cAdvisor и алерты по железу
Some checks failed
CI / backend (push) Has been cancelled
CI / frontend (push) Has been cancelled
Документация мониторинга описывала только старые компоненты (prometheus,
postgres/redis-exporter, дашборд пайплайнов) — актуализирована под
node-exporter/cAdvisor, дашборд host.json и три новых алерта.
2026-07-28 00:22:08 +03:00
dccc369d0b fix(admin): дефолтные подвкладки конференций и пользователей
Some checks failed
CI / backend (push) Has been cancelled
CI / frontend (push) Has been cancelled
Конференции открывались на «Все» — неинформативная сборная вкладка вместо
актуальных «Запланированные». Заодно перенесена вкладка «Все» в конец
списка фильтров (после «Завершённые»), чтобы порядок шёл от актуального
к общему.

Пользователи открывались на «Все» вместо «Активные» — админ по умолчанию
видел вперемешку с заблокированными.
2026-07-28 00:21:06 +03:00
77ee26014d feat(monitoring): метрики CPU/RAM/диска хоста и контейнеров
Дашборд «Пайплайны пост-обработки» покрывал только прикладную логику —
нехватка памяти/CPU на сервере была видна только косвенно, по латентности API.

Добавлены node-exporter (метрики хоста) и cAdvisor (метрики по контейнерам,
профиль monitoring) — оба без публикации портов наружу, Prometheus ходит
к ним по внутренней сети compose. Новый дашборд host.json («Хост и
контейнеры») и три алерта (HostMemoryLow/HostDiskLow/HostCpuHigh) с
порогами под сервер 1gb (8 ГБ RAM, 4 CPU, 50 ГБ диска).
2026-07-28 00:21:01 +03:00
413789ba22 release: версия 0.0.8
Some checks failed
CI / backend (push) Has been cancelled
CI / frontend (push) Has been cancelled
2026-07-27 21:10:08 +03:00
c4721fcf99 feat(admin): поиск пользователей по названию команды
AdminUserRepository.list_paginated ищет теперь и по названию команды
(LEFT JOIN teams, как и раньше) — пользователи без команды по-прежнему
не пропадают из общей выдачи, просто не совпадают с этой частью поиска.
Плейсхолдер поля поиска в админке обновлён под новое поведение.
2026-07-27 21:04:26 +03:00
09215da22a feat(admin): вкладки фильтра по статусу в списке пользователей
Задача 3 переопределена оператором: вместо окончательного удаления
пользователя (упёрлось в CHECK-constraint'ы participant/chat_messages,
требующие миграции схемы — решили отложить) добавлены вкладки
«Активные»/«Заблокированные»/«Все» перед полем поиска в админке —
список фильтруется по `is_blocked` на бэкенде (GET /admin/users?status=).
2026-07-27 20:59:29 +03:00
25ffd9e678 feat(auth): несколько эталонных mail-доменов для верификации регистрации
Some checks failed
CI / backend (push) Has been cancelled
CI / frontend (push) Has been cancelled
Настройка «Эталон mail-домена» теперь хранит список доменов вместо
одного — email при регистрации принимается, если совпадает с любым из
них. Старое значение в БД ({"domain": str|None}) читается прозрачно
(обратная совместимость без Alembic-миграции) и переписывается в новую
форму ({"domains": [...]}) при первом же сохранении настроек. В админке
добавление/удаление доменов — списком чипов; на экране регистрации
подсказка о несовпадении домена перечисляет все эталонные варианты.
2026-07-27 20:40:23 +03:00
e55a6aebe7 fix(admin): не блокировать сохранение настроек недоступным уровнем AI
Some checks failed
CI / backend (push) Has been cancelled
CI / frontend (push) Has been cancelled
Валидация ai_level срабатывала на любой PUT /admin/settings, включая
случаи, когда фронт отправлял уже сохранённый (не изменённый) уровень —
на слабом железе это намертво блокировало правку любых других настроек.
Бэкенд теперь сравнивает patch.ai_level с текущим значением и проверяет
доступность только при фактической смене уровня; фронт дополнительно
отправляет в PUT только реально изменённые поля.
2026-07-27 20:25:31 +03:00
4b92f89efe release: версия 0.0.7
Some checks failed
CI / backend (push) Has been cancelled
CI / frontend (push) Has been cancelled
2026-07-27 17:42:03 +03:00
b11feac529 feat(room): компактная шторка настроек устройств на мобильном
Some checks failed
CI / backend (push) Has been cancelled
CI / frontend (push) Has been cancelled
На ширине ≤600px (брейкпоинт комнаты) DeviceSettingsDialog рендерит те же
поля не модалкой по центру, а шторкой снизу (`room-sheet-overlay`/
`room-sheet-panel`) — по образцу решения оператора: так быстрее и привычнее
с телефона. Десктоп не меняется.

Закрытие — клик вне панели (было и раньше), Escape и возврат фокуса через
существующий useModalDismiss, и свайп вниз за ручку (JS считает смещение
пальца и либо закрывает панель, либо возвращает её на место пружиной).
2026-07-27 17:32:42 +03:00
e07217657f feat(room): выбор устройства аудиовывода в настройках
Третий селект в DeviceSettingsDialog (динамики/наушники/bluetooth) рядом
с уже существующими микрофоном и камерой — на useMediaDeviceSelect(kind:
'audiooutput'). Выбор персистится отдельно от usePersistentUserChoices
(LocalUserChoices LiveKit про аудиовыход не знает) и применяется заново
при следующем подключении через RoomOptions.audioOutput.

Поддержка определяется по наличию setSinkId в рантайме, не по User-Agent:
там, где браузер не умеет переключать вывод (iOS Safari), список скрыт,
вместо него — подсказка, что маршрутизация звука на стороне системы.
2026-07-27 17:31:35 +03:00
35a1455645 refactor(auth): убрать строку «self-hosted» из брендовой панели
Some checks failed
CI / backend (push) Has been cancelled
CI / frontend (push) Has been cancelled
Копирайт целиком переехал в общий футер (решение оператора), в панели
строка больше не нужна. У `.brand-panel` осталось два потомка вместо трёх,
поэтому `.brand-copy` получил `margin-block: auto` — иначе
`justify-content: space-between` прижал бы текст к низу панели.
2026-07-27 01:53:27 +03:00
cfb3a941ab release: версия 0.0.6
Some checks failed
CI / backend (push) Has been cancelled
CI / frontend (push) Has been cancelled
2026-07-27 01:34:03 +03:00
f3556d7d89 fix(calendar): убрать «г.» из подписей дней в сетке
Some checks failed
CI / backend (push) Has been cancelled
CI / frontend (push) Has been cancelled
В 0.0.4 «г.» убрали из подписи периода, но дату дня FullCalendar
подставляет ещё и в служебную подпись ячейки (`aria-label`, у navLinks —
`title`): видна как всплывающая подсказка и в accessibility-дереве.
Формат этой подписи библиотека наружу не отдаёт — `buildNavLinkAttrs`
форматирует дату жёстко зашитым `{year,month:'long',day:'numeric'}`, а
локаль `ru` всегда даёт «20 июля 2026 г.», — поэтому чистим уже
отрендеренный атрибут.

Чистка идёт через MutationObserver, а не через `dayCellDidMount`/
`dayHeaderDidMount`: при переключении недельного вида на месячный
FullCalendar переиспользует смонтированные ячейки, `didMount` для них
повторно не зовётся, и «г.» возвращалась в строке переиспользованных ячеек
(поймано на живой проверке).

Проверено в обоих видах и при навигации вперёд/назад/«Сегодня»: подписей с
«г.» в документе не остаётся.
2026-07-27 01:32:02 +03:00
b6f57384b0 feat(ui): общий футер на всех страницах, кроме комнаты
`AppFooter` — версия инстанса из публичного `/api/health` (не требует
авторизации, поэтому работает и на экранах входа; поля `version` может не
быть — тогда версия не показывается) плюс копирайт с ТЕКУЩИМ годом и
ссылкой на профиль автора.

Подключён во все раскладки: `page-shell` (календарь, админка, профиль, мои
конференции), `AuthLayout` (вход, регистрация, подтверждение email),
`join-shell` и `lobby-shell`. В комнате конференции футера нет — там
полноэкранное видео.

Прежний футер админки (`.admin-footer`) удалён вместе со стилем — дубля не
осталось. В брендовой панели экранов auth строка «© 2026 VidConf ·
self-hosted» ужата до «self-hosted»: год там был захардкожен, а два
копирайта на одном экране — дубль.
2026-07-27 01:31:52 +03:00
5daff3b7af feat(room): закрепление участника и слежение основного окна за говорящим
Правила выбора фокуса сцены (`pickStageFocus`) дополнены двумя входами:

- `pinnedKey` — участник, закреплённый булавкой на плитке. Держит фокус
  вопреки говорящим, но уступает любой активной демонстрации экрана; как
  только демонстрация закончилась, фокус возвращается именно на
  закреплённого (правило стоит выше удержания предыдущего фокуса).
  Состояние — в `RoomStage`, повторное нажатие снимает, выход закреплённого
  из комнаты тоже. Закреплённая плитка помечена рамкой и подсвеченной
  булавкой; на тач-устройствах булавка видна без наведения.
- `holdScreenShare` — живая демонстрация в фокусе не уступает заговорившему
  (основное окно). PiP не затронут: там по-прежнему всегда виден говорящий.

Основное окно теперь следует за говорящим (`followSpeaker`) поверх
`useSpeakingParticipants()` вместо дребезжащего `participant.isSpeaking`, с
удержанием состава в 1.2 с (`useSteadySpeakers`) — короткие реплики фокус не
уводят. Среди одновременно говорящих предпочитается тот, у кого включена
камера (`cameraKeysWithVideo`).

`stageTrackKey` переехал в `stageFocus.ts` — ключ плитки нужен и сцене, и
самой плитке (в карусели/гриде она рендерится шаблоном, без пропсов).
2026-07-27 01:31:42 +03:00
28b87a8c95 release: версия 0.0.5
Some checks failed
CI / backend (push) Has been cancelled
CI / frontend (push) Has been cancelled
2026-07-27 00:56:37 +03:00
b35209a775 feat(admin): контактный адрес и тестовое письмо в настройках админки
Some checks failed
CI / backend (push) Has been cancelled
CI / frontend (push) Has been cancelled
Поле включения/адреса в карточке «Контактный адрес» и кнопка «Отправить
тестовое письмо» с выводом результата — рядом, по образцу соседних
настроек AdminSettingsTab.
2026-07-27 00:35:59 +03:00
84b06f78f1 test(email): контактный адрес, Reply-To и тестовая отправка письма
Покрытие валидации/сохранения contact_email, простановки Reply-To в
письмах регистрации/приглашений/саммари при включённом и выключенном
контактном адресе, и эндпоинта тестовой отправки (успех, дефолтный
получатель, сбой транспорта без утечки логина/пароля).
2026-07-27 00:35:53 +03:00
74de51dbd8 feat(email): контактный адрес инстанса, Reply-To в письмах и тестовая отправка
Новая настройка instance_settings.contact_email (включён/адрес, с
валидацией формата) — подставляется в заголовок Reply-To писем
подтверждения регистрации, приглашений и саммари. Админ-эндпоинт
POST /admin/settings/test-email отправляет проверочное письмо синхронно
и возвращает внятный результат (успех либо текст ошибки транспорта),
не раскрывая логин/пароль SMTP.
2026-07-27 00:35:47 +03:00
111 changed files with 9477 additions and 573 deletions

View File

@@ -59,6 +59,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 +91,20 @@ 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
# --- Email (рассылка саммари + .ics-приглашения) ---
# `console` — дефолт для dev (письмо только логируется, ссылка подтверждения
# email берётся из логов); `smtp` — реальная отправка через aiosmtplib.
@@ -98,7 +122,7 @@ SMTP_TIMEOUT_S=30
# --- Версия инстанса (релиз v0.0.1) ---
# install.sh копирует значение из корневого файла VERSION при каждой
# установке/обновлении — руками менять не нужно.
VIDCONF_VERSION=0.0.4
VIDCONF_VERSION=0.0.27
# --- Профили compose. Дефолт ниже (`media,monitoring`) — только для ручного
# `docker compose up` БЕЗ install.sh: медиа (LiveKit+coturn) + мониторинг,

View File

@@ -3,6 +3,576 @@
Формат основан на [Keep a Changelog](https://keepachangelog.com/ru/1.1.0/),
проект придерживается [семантического версионирования](https://semver.org/lang/ru/).
## [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 кнопок вместо
восьми) — теперь плавно уменьшаются на диапазоне 1200600px вместо
жёсткого скачка на мобильный вид.
- Подпись «Мини-окно» на промежуточных ширинах переносилась на 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-сервиса в деплое нет)
этот вызов висел 2025 секунд, всё это время удерживая соединение с БД. На
нагрузочном тесте с 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-доменов для регистрации, фильтр и поиск по команде в
списке пользователей, исправлен баг сохранения настроек.
### Исправлено
- Сохранение любой настройки инстанса блокировалось ошибкой «уровень AI
недоступен», даже если AI-модуль (транскрибация/суммаризация) выключен и
пользователь уровень не трогал — фронт отправлял в `PUT /admin/settings`
весь набор полей целиком, включая уже сохранённый `ai_level`. Теперь
доступность уровня проверяется только при его фактической смене; фронт
отправляет в запросе лишь реально изменённые поля.
### Добавлено
- «Эталон mail-домена» в настройках инстанса поддерживает несколько
доменов вместо одного — регистрация проходит с email с любого домена из
списка. Старое значение из БД (один домен) читается без миграции и
переписывается в новый формат при первом же сохранении.
- В списке пользователей админки — вкладки «Активные»/«Заблокированные»/
«Все» перед полем поиска.
- Поиск по пользователям учитывает и название команды — пользователи без
команды по-прежнему остаются в общей выдаче.
## [0.0.7] — 2026-07-27
Настройки устройств: выбор аудиовывода и компактная шторка на мобильном.
### Добавлено
- Выбор устройства аудиовывода (динамики/наушники/Bluetooth-гарнитура) в
«Настройках устройств» рядом с микрофоном и камерой. Выбор переживает
перезаход в комнату. Список показывается только там, где браузер умеет
им управлять (Android Chrome и другие Chromium на десктопе/мобильном);
в iOS Safari поддержки нет вовсе — вместо списка короткая подсказка, что
переключение звука там на стороне системы.
- На мобильной ширине «Настройки устройств» открываются компактной шторкой
снизу вместо модалки по центру экрана — закрывается свайпом вниз, тапом
вне панели или Escape. На десктопе поведение не изменилось.
### Примечания по эксплуатации
- Управлять маршрутизацией звука (принудительно включить громкую связь или
перекинуть звук на Bluetooth) из браузера нельзя в принципе — это делает
ОС, веб-страница может только выбрать устройство вывода там, где браузер
это позволяет (`setSinkId`).
## [0.0.6] — 2026-07-27
Управление фокусом основного окна конференции: участника можно закрепить, а
без закрепления окно само следует за говорящим. Плюс общий футер на всех
страницах сервиса.
### Добавлено
- Закрепление участника в основном окне — кнопка-булавка на плитке.
Закреплённый участник держит фокус, даже когда говорят другие; повторное
нажатие снимает закрепление, как и выход закреплённого из комнаты.
Закреплённая плитка помечена рамкой и подсвеченной булавкой; на
тач-устройствах булавка видна без наведения.
- Общий футер на всех страницах, кроме комнаты конференции: версия инстанса
и копирайт со ссылкой на автора.
### Изменения
- Основное окно конференции следует за активным говорящим, а не удерживает
выбранного один раз участника. Фокус не переключается на короткие реплики
(удержание 1.2 с) и не уводится с идущей демонстрации экрана. Среди
нескольких одновременно говорящих крупно показывается тот, у кого включена
камера. Мини-плеер (Document PiP) работает как раньше.
- Демонстрация экрана по-прежнему главнее всего: она перебивает и
закрепление, а после её завершения фокус возвращается на закреплённого
участника.
- Футер админки заменён общим компонентом; в брендовой панели экранов входа
строка копирайта с захардкоженным годом убрана — год теперь берётся
текущий.
### Исправлено
- Календарь: «г.» после года убрана и из подписей дней в самой сетке (видны
как всплывающая подсказка и в accessibility-дереве) — в недельном и
месячном виде. В 0.0.4 это было исправлено только для подписи периода.
## [0.0.5] — 2026-07-27
Контактный адрес инстанса: настраиваемый адрес для ответов на письма
(уходят от `no-reply@`) и кнопка проверки почтовой конфигурации из
админки.
### Добавлено
- Настройка «Контактный адрес» в админке (вкладка «Настройки») — email
подставляется в заголовок `Reply-To` писем подтверждения регистрации,
приглашений на конференции и саммари; при выключенной настройке
`Reply-To` не проставляется, поведение как раньше.
- Кнопка «Отправить тестовое письмо» — синхронная проверка почтовой
конфигурации без регистрации фиктивного пользователя. Получатель по
умолчанию — email текущего администратора, можно указать другой.
Результат виден сразу: успех либо текст ошибки транспорта (сервер
недоступен/получатель отклонён), без логина и пароля SMTP в ответе.
## [0.0.4] — 2026-07-26
Календарь конференций: перестроен в раскладку «календарь во всю ширину плюс
@@ -112,6 +682,12 @@
Первоначальная версия 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
[0.0.5]: https://github.com/MaxRonzhin/vidconf_rel/releases/tag/v0.0.5
[0.0.4]: https://github.com/MaxRonzhin/vidconf_rel/releases/tag/v0.0.4
[0.0.3]: https://github.com/MaxRonzhin/vidconf_rel/releases/tag/v0.0.3
[0.0.2]: https://github.com/MaxRonzhin/vidconf_rel/releases/tag/v0.0.2

View File

@@ -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 профили (низкоуровневый контроль)

View File

@@ -1 +1 @@
0.0.4
0.0.27

View File

@@ -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}"]

View File

@@ -13,7 +13,7 @@
import uuid
from pathlib import Path
from typing import Annotated
from typing import Annotated, Literal
import anyio
from fastapi import APIRouter, Depends, File, HTTPException, Query, UploadFile, status
@@ -45,6 +45,8 @@ from schemas.admin import (
TeamListOut,
TeamOut,
TeamUpdateIn,
TestEmailIn,
TestEmailOut,
)
from schemas.conferences import ConferenceUpdateIn
from services.ai_levels import detect_ai_levels
@@ -56,9 +58,11 @@ from services.conferences import (
InvalidConferenceStateError,
NotConferenceOwnerError,
)
from services.email import EmailSendError, create_email_backend
from services.instance_settings import (
InstanceSettingsService,
InvalidAiLevelError,
InvalidContactEmailError,
InvalidEmailDomainError,
InvalidTimezoneError,
SettingsUpdateIn,
@@ -170,12 +174,16 @@ async def send_conference_invitations(
async def list_users(
admin: Annotated[User, Depends(require_admin)],
session: Annotated[AsyncSession, Depends(get_session)],
status: Annotated[Literal["active", "blocked"] | None, Query()] = None,
q: Annotated[str | None, Query()] = None,
limit: Annotated[int, Query(gt=0, le=MAX_LIMIT)] = DEFAULT_LIMIT,
offset: Annotated[int, Query(ge=0)] = 0,
) -> AdminUserListOut:
"""Список всех пользователей инстанса с текстовым поиском по email/имени."""
rows, total = await AdminUserRepository(session).list_paginated(q=q, limit=limit, offset=offset)
"""Список пользователей инстанса — фильтр по статусу (`active`/`blocked`, без
параметра — все) и текстовый поиск по email/имени/названию команды."""
rows, total = await AdminUserRepository(session).list_paginated(
status=status, q=q, limit=limit, offset=offset
)
media_root = _media_root()
items = [
_to_admin_user_out(user, team_name=team_name, media_root=media_root)
@@ -209,7 +217,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
@@ -391,12 +399,59 @@ async def update_settings(
service = InstanceSettingsService(session)
try:
cfg = await service.update(data)
except (InvalidAiLevelError, InvalidTimezoneError, InvalidEmailDomainError) as exc:
except (
InvalidAiLevelError,
InvalidTimezoneError,
InvalidEmailDomainError,
InvalidContactEmailError,
) 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)
return _to_settings_out(cfg, transcription_queue_served=queue_served)
@router.post("/settings/test-email", response_model=TestEmailOut)
async def send_test_email(
data: TestEmailIn,
admin: Annotated[User, Depends(require_admin)],
session: Annotated[AsyncSession, Depends(get_session)],
) -> TestEmailOut:
"""Отправить тестовое письмо синхронно — проверка почтовой конфигурации без
регистрации фиктивного пользователя.
Получатель по умолчанию — email текущего администратора. Отправка идёт
напрямую из эндпоинта (не через Celery), чтобы результат был виден сразу.
Секреты SMTP (логин/пароль) в ответе не участвуют — только хост/порт,
и то лишь при `EMAIL_BACKEND=smtp` (см. `.env.example`).
"""
app_settings = get_app_settings()
recipient = data.to or admin.email
cfg = await InstanceSettingsService(session).get()
reply_to = cfg.contact_email if cfg.contact_email_enabled else None
is_smtp = app_settings.email_backend == "smtp"
smtp_host = app_settings.smtp_host if is_smtp else None
smtp_port = app_settings.smtp_port if is_smtp else None
backend = create_email_backend(app_settings)
try:
await backend.send(
to=recipient,
subject="Тестовое письмо VidConf",
body="Это тестовое письмо для проверки почтовой конфигурации инстанса VidConf.",
reply_to=reply_to,
)
except EmailSendError as exc:
return TestEmailOut(
success=False, message=str(exc), smtp_host=smtp_host, smtp_port=smtp_port
)
return TestEmailOut(
success=True,
message=f"Письмо успешно отправлено на {recipient}",
smtp_host=smtp_host,
smtp_port=smtp_port,
)
def _to_settings_out(cfg: InstanceConfig, *, transcription_queue_served: bool) -> SettingsOut:
"""Собрать `SettingsOut` из эффективной конфигурации + доступность уровней AI."""
return SettingsOut(
@@ -409,7 +464,11 @@ def _to_settings_out(cfg: InstanceConfig, *, transcription_queue_served: bool) -
display_timezone=cfg.display_timezone,
registration_team_choice=cfg.registration_team_choice,
registration_email_domain_enabled=cfg.registration_email_domain_enabled,
registration_email_domain=cfg.registration_email_domain,
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,
)

View File

@@ -53,17 +53,17 @@ async def registration_options(
Список команд отдаётся только при включённой настройке инстанса
`registration_team_choice` — иначе пустой массив (справочник команд не
раскрывается, пока выбор выключен). `email_domain` — эталонный домен при
включённой настройке `registration_email_domain`, иначе `None`.
раскрывается, пока выбор выключен). `email_domains` — эталонные домены при
включённой настройке `registration_email_domain`, иначе пустой список.
"""
cfg = await InstanceSettingsService(session).get()
teams: list[RegistrationTeamOptionOut] = []
if cfg.registration_team_choice:
items, _ = await TeamRepository(session).list_all()
teams = [RegistrationTeamOptionOut(id=team.id, name=team.name) for team in items]
email_domain = cfg.registration_email_domain if cfg.registration_email_domain_enabled else None
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_domain=email_domain
team_choice_enabled=cfg.registration_team_choice, teams=teams, email_domains=email_domains
)

View File

@@ -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)
@@ -57,21 +74,26 @@ async def chat_websocket(
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)
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_pubsub_to_websocket(websocket, pubsub, channel, seen_ids))
tg.create_task(_pump_websocket_to_service(websocket, service, conference, identity))
except* WebSocketDisconnect:
# Штатное закрытие соединения клиентом — не ошибка.
@@ -91,7 +113,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 +133,71 @@ 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
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"))
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")
)
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
) -> None:
"""Читать текстовые сообщения клиента, валидировать и сохранять+публиковать их."""
"""Читать сообщения клиента (текст чата / поднять-опустить руку), валидировать и обработать."""
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
await service.persist_and_publish(conference, identity=identity, text=envelope.text)
if isinstance(envelope, ChatMessageIn):
await service.persist_and_publish(conference, identity=identity, text=envelope.text)
elif isinstance(envelope, RaiseHandIn):
await hand_queue.raise_hand(
conference.id, identity=_identity_key(identity), name=identity.author_name
)
await hand_queue.publish_snapshot(conference.id)
else:
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:

View File

@@ -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)

View File

@@ -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"}

View File

@@ -1,15 +1,17 @@
"""Метрики 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` источник
и вовсе синхронный — настройки уже в памяти процесса).
"""
import time
@@ -20,6 +22,7 @@ from prometheus_client import CONTENT_TYPE_LATEST, Gauge, Histogram, generate_la
from sqlalchemy.ext.asyncio import AsyncSession
from starlette.routing import Match
from core.config import get_settings
from core.db import get_session
from core.redis import redis_client
from models.session import PIPELINE_STATUSES
@@ -110,6 +113,42 @@ async def _refresh_celery_queue_depth_gauge() -> None:
CELERY_QUEUE_DEPTH.labels(queue=queue).set(depth)
# --- 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:
"""Отдать метрики Prometheus в формате text exposition.
@@ -122,4 +161,5 @@ async def metrics_endpoint(session: AsyncSession = Depends(get_session)) -> Resp
"""
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)

View File

@@ -101,11 +101,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()

View File

@@ -20,6 +20,24 @@ 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
# --- Версия инстанса (релиз v0.0.1) ---
# install.sh копирует значение из файла `VERSION` (корень репозитория) в
# `.env` при каждой установке/обновлении — здесь только чтение готового
@@ -59,6 +77,13 @@ class Settings(BaseSettings):
# (см. `deploy/docker-compose.yml`); в тестах переопределяется на `tmp_path`.
recordings_dir: str = "/recordings"
# Таймаут запуска Track Egress. Когда egress-сервиса в деплое нет (профиль
# `transcribe` не поднят), LiveKit ждёт ответа воркера через Redis до
# собственного таймаута psrpc — на тесте 28.07.2026 это давало по 2025
# секунд на каждый вызов. Ждать столько бессмысленно: если egress жив, он
# отвечает за доли секунды.
egress_start_timeout_s: float = 3.0
# --- Email (SMTP-бэкенд) ---
# `console` — дефолт для dev (письмо только логируется); `smtp` — реальная
# отправка через aiosmtplib. Секреты SMTP — только в `.env` (инвариант №6),

View File

@@ -13,7 +13,16 @@ 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)

View File

@@ -62,6 +62,28 @@ SummaryRecipientsMode = Literal["all", "owner"]
"""Режим рассылки саммари по умолчанию: всем участникам либо только
владельцу конференции (переопределяется на уровне `conferences.summary_recipients`)."""
PublishQualityCap = Literal["off", "180p", "360p", "720p"]
"""Потолок качества исходящего видео участника (задача «рычаги качества
медиа»). `off` — без ограничения (дефолт, поведение как до появления
настройки). Остальные значения режут `publishDefaults.videoEncoding` и
`videoSimulcastLayers` на клиенте (`frontend/src/lib/publishQualityCap.ts`) —
именно битрейт верхнего слоя симулкаста, а не жёсткое разрешение захвата
камеры; фактическое разрешение WebRTC подстраивает под битрейт сам."""
StageMaxTiles = Literal[4, 9, 16, 25]
"""Потолок числа одновременно видимых плиток на сцене (`StageGrid`) —
режет набор доступных раскладок сетки, что бросает лишних участников на
следующую страницу пагинации вместо подписки на их треки. `25` — дефолт,
совпадает с текущим максимумом сетки (5×5), то есть без ограничения."""
class MediaLimitsConfig(BaseModel):
"""Рычаги нагрузки медиа для администратора инстанса (не логика состояния
конференции — статичные потолки, применяются на клиенте при входе)."""
publish_quality_cap: PublishQualityCap = "off"
stage_max_tiles: StageMaxTiles = 25
class InstanceConfig(BaseModel):
"""Эффективная конфигурация инстанса (значения `instance_settings` поверх дефолтов
@@ -77,8 +99,19 @@ class InstanceConfig(BaseModel):
# — см. `services/instance_settings.py`.
registration_team_choice: bool = False
# Верификация регистрирующихся по домену email: при включении
# `POST /auth/register` принимает только
# email с доменом `registration_email_domain` — см.
# `POST /auth/register` принимает только email с доменом из
# `registration_email_domains` (любым из списка) — см.
# `services/instance_settings.py`.
registration_email_domain_enabled: bool = False
registration_email_domain: str | None = None
registration_email_domains: list[str] = Field(default_factory=list)
# Контактный адрес инстанса — подставляется в `Reply-To` исходящих писем
# (сами письма уходят от `no-reply@`, отвечать на них некуда без этого
# адреса) — см. `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)

View File

@@ -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,

View File

@@ -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: синхронный вызов останавливал весь процесс на 95155 мс, и при
массовом входе (около 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()

View File

@@ -2,7 +2,7 @@
import uuid
from sqlalchemy import func, or_, select
from sqlalchemy import ColumnElement, func, or_, select
from sqlalchemy.ext.asyncio import AsyncSession
from models.conference import Conference
@@ -56,25 +56,37 @@ class AdminConferenceRepository:
class AdminUserRepository:
"""Постраничный список пользователей с текстовым поиском по email/имени."""
"""Постраничный список пользователей с фильтром по статусу и текстовым поиском."""
def __init__(self, session: AsyncSession) -> None:
self._session = session
async def list_paginated(
self, *, q: str | None, limit: int, offset: int
self, *, status: str | None, q: str | None, limit: int, offset: int
) -> tuple[list[tuple[User, str | None]], int]:
"""Вернуть страницу пользователей (+ имя команды) и общее число совпадений.
`LEFT JOIN` на `teams` — имя команды нужно карточке профиля/таблице
админки, у пользователя без команды — `None`.
`status` — `"active"`/`"blocked"` (фильтр по `is_blocked`), `None` —
без фильтра (все пользователи). `q` ищет по email/имени пользователя
И по названию команды (`LEFT JOIN` на `teams` — нужен и для этого
поиска, и для колонки «Команда» в таблице админки; `LEFT`, а не
`INNER`, — пользователи без команды не должны пропадать из общей
выдачи, только не совпадать с поиском по названию команды).
"""
filters = []
filters: list[ColumnElement[bool]] = []
if status == "active":
filters.append(User.is_blocked.is_(False))
elif status == "blocked":
filters.append(User.is_blocked.is_(True))
if q:
like = f"%{q}%"
filters.append(or_(User.email.ilike(like), User.name_user.ilike(like)))
filters.append(
or_(User.email.ilike(like), User.name_user.ilike(like), Team.name.ilike(like))
)
count_stmt = select(func.count()).select_from(User)
count_stmt = (
select(func.count()).select_from(User).outerjoin(Team, User.team_id == Team.id)
)
items_stmt = (
select(User, Team.name)
.outerjoin(Team, User.team_id == Team.id)

View File

@@ -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
@@ -155,4 +155,33 @@ class SettingsOut(BaseModel):
display_timezone: str
registration_team_choice: bool
registration_email_domain_enabled: bool
registration_email_domain: str | None = None
registration_email_domains: list[str] = Field(default_factory=list)
contact_email_enabled: bool
contact_email: str | None = None
# Рычаги нагрузки медиа (`InstanceConfig.media_limits`) — потолок
# качества публикации и максимум плиток сцены, см. `core/plugins/config.py`.
publish_quality_cap: PublishQualityCap
stage_max_tiles: StageMaxTiles
class TestEmailIn(BaseModel):
"""Тело запроса тестовой отправки (`POST /admin/settings/test-email`).
`to` не задан — получатель по умолчанию: email текущего администратора
(см. `api/admin.py::send_test_email`).
"""
to: EmailStr | None = None
class TestEmailOut(BaseModel):
"""Результат тестовой отправки — понятный админу успех/ошибка транспорта.
`smtp_host`/`smtp_port` — только хост и порт (без логина/пароля, они не
покидают `.env`), заполняются лишь при `EMAIL_BACKEND=smtp`.
"""
success: bool
message: str
smtp_host: str | None = None
smtp_port: int | None = None

View File

@@ -103,11 +103,11 @@ class RegistrationOptionsOut(BaseModel):
`teams` отдаётся только при `team_choice_enabled=True` — иначе пустой
список (справочник команд не раскрывается, пока выбор выключен).
`email_domain` — эталонный домен при включённой верификации регистрации
`email_domains` — эталонные домены при включённой верификации регистрации
по домену email (настройка инстанса `registration_email_domain`), иначе
`None`.
пустой список.
"""
team_choice_enabled: bool
teams: list[RegistrationTeamOptionOut]
email_domain: str | None = None
email_domains: list[str] = Field(default_factory=list)

View File

@@ -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,11 @@ class JoinOut(BaseModel):
# Тоггл инстанса `chat.enabled` на момент входа — клиент решает,
# показывать ли UI чата, не дожидаясь ошибки WS-подключения.
chat_enabled: bool
# Рычаги нагрузки медиа (`instance_settings.media_limits`) — отдаются
# прямо в join-ответе, а не только в админке: участнику нужно иметь их
# на руках ДО публикации своего трека (см. `services/conference_access.py`).
publish_quality_cap: PublishQualityCap
stage_max_tiles: StageMaxTiles
class ConferenceOut(BaseModel):
@@ -210,3 +216,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

View 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

View File

@@ -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
@@ -106,9 +107,9 @@ class AuthService:
существует — иначе `InvalidTeamSelectionError` (публичный
эндпоинт, деталей не раскрываем). Если включена верификация домена
email (`registration_email_domain_enabled`), домен `email` (часть
после `@`, без учёта регистра) должен совпадать с эталонным —
иначе `InvalidEmailDomainError`. Обе проверки — до создания
пользователя.
после `@`, без учёта регистра) должен совпадать с одним из
эталонных доменов (`registration_email_domains`) — иначе
`InvalidEmailDomainError`. Обе проверки — до создания пользователя.
"""
existing = await self._users.get_by_email(email)
if existing is not None:
@@ -118,7 +119,7 @@ class AuthService:
if cfg.registration_email_domain_enabled:
email_domain = email.rsplit("@", 1)[-1].lower()
if email_domain != cfg.registration_email_domain:
if email_domain not in cfg.registration_email_domains:
raise InvalidEmailDomainError(email)
if team_id is not None:
@@ -131,10 +132,11 @@ class AuthService:
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,
)
await self._issue_verification_email(user)
reply_to = cfg.contact_email if cfg.contact_email_enabled else None
await self._issue_verification_email(user, reply_to=reply_to)
await self._session.commit()
return user
@@ -160,10 +162,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:
@@ -216,7 +229,7 @@ class AuthService:
await self._redis.set(f"{REFRESH_KEY_PREFIX}{jti}", str(user_id), ex=ttl_seconds)
return TokenPair(access_token=access_token, refresh_token=refresh_token)
async def _issue_verification_email(self, user: User) -> None:
async def _issue_verification_email(self, user: User, *, reply_to: str | None = None) -> None:
token = secrets.token_urlsafe(32) # 256 бит случайности
expires_at = datetime.now(UTC) + timedelta(
hours=self._settings.email_verification_ttl_hours
@@ -231,6 +244,7 @@ class AuthService:
to=user.email,
subject="Подтверждение регистрации VidConf",
body=f"Для подтверждения email перейдите по ссылке: {link}",
reply_to=reply_to,
)

View File

@@ -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,35 @@ def build_join(
identity: str,
name: str,
chat_enabled: bool,
publish_quality_cap: PublishQualityCap,
stage_max_tiles: StageMaxTiles,
avatar_url: str | None = None,
is_organizer: bool = False,
) -> JoinOut:
"""Построить ответ join: LiveKit access-токен для входа в комнату конференции.
Имя LiveKit-комнаты всегда равно `conference.slug` (ADR-001, п.4).
`chat_enabled` — снятый вызывающей стороной тоггл `instance_settings`:
читается здесь параметром, а не заново из БД, чтобы не плодить
отдельный запрос настроек на каждый join. `avatar_url` прокидывается
в метаданные токена как JSON
`{"avatar_url": ...}`; `None` (гость либо пользователь без аватара) —
метаданные не выставляются вовсе.
`chat_enabled`/`publish_quality_cap`/`stage_max_tiles` — снятые вызывающей
стороной значения `instance_settings`: читаются здесь параметрами, а не
заново из БД, чтобы не плодить отдельный запрос настроек на каждый join.
`avatar_url`/`is_organizer`
прокидываются в метаданные токена как JSON `{"avatar_url": ..., "is_organizer": true}`
— поля добавляются, только если заданы (гость без аватара и не-организатор
получают токен вовсе без метаданных, как и раньше).
⚠️ `is_organizer` в метаданных — только подсказка для UI клиента (показать/
скрыть кнопки организатора). Метаданным токена доверять для АВТОРИЗАЦИИ
нельзя — участник технически может их подделать на своей стороне. Любое
серверное действие организатора (например, принудительный мьют) обязано
заново проверяться по `conference.owner_id` в БД, а не по этому полю.
"""
settings = get_settings()
metadata = 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 +91,6 @@ def build_join(
room_name=conference.slug,
conference_id=conference.id,
chat_enabled=chat_enabled,
publish_quality_cap=publish_quality_cap,
stage_max_tiles=stage_max_tiles,
)

View File

@@ -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,16 @@ class ConferenceService:
join = None
if is_instant:
chat_enabled = (await InstanceSettingsService(self._session).get()).chat.enabled
cfg = await InstanceSettingsService(self._session).get()
join = build_join(
conference,
identity=str(owner_id),
name=owner_name,
chat_enabled=chat_enabled,
chat_enabled=cfg.chat.enabled,
publish_quality_cap=cfg.media_limits.publish_quality_cap,
stage_max_tiles=cfg.media_limits.stage_max_tiles,
avatar_url=resolve_avatar_url(self._media_root, owner_avatar_path),
is_organizer=True,
)
else:
# Плановая (разовая) либо закреплённая с повторением/датой — есть
@@ -239,20 +244,23 @@ 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,
publish_quality_cap=cfg.media_limits.publish_quality_cap,
stage_max_tiles=cfg.media_limits.stage_max_tiles,
avatar_url=resolve_avatar_url(self._media_root, user.avatar_path),
is_organizer=conference.owner_id is not None and conference.owner_id == user.id,
)
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 +269,46 @@ class ConferenceService:
await self._session.flush()
await self._session.commit()
chat_enabled = (await InstanceSettingsService(self._session).get()).chat.enabled
cfg = await InstanceSettingsService(self._session).get()
return build_join(
conference,
identity=f"guest:{guest.id}",
name=data.display_name,
chat_enabled=chat_enabled,
chat_enabled=cfg.chat.enabled,
publish_quality_cap=cfg.media_limits.publish_quality_cap,
stage_max_tiles=cfg.media_limits.stage_max_tiles,
)
async def mute_participant(
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 +336,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`) — сбросить/установить
# переопределение; отсутствие поля в запросе значение не трогает.

View File

@@ -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,13 +47,17 @@ async def start_track_egress(room_name: str, track_sid: str, filepath: str) -> E
api_secret=settings.livekit_api_secret,
)
try:
info = await lkapi.egress.start_track_egress(
api.TrackEgressRequest(
room_name=room_name,
track_id=track_sid,
file=api.DirectFileOutput(filepath=filepath),
# Без таймаута вызов висит до собственного таймаута psrpc LiveKit
# (2025 с, когда egress-воркера в деплое нет). `TimeoutError`
# ловит вызывающая сторона наравне с прочими ошибками запуска.
async with asyncio.timeout(settings.egress_start_timeout_s):
info = await lkapi.egress.start_track_egress(
api.TrackEgressRequest(
room_name=room_name,
track_id=track_sid,
file=api.DirectFileOutput(filepath=filepath),
)
)
)
finally:
await lkapi.aclose()
@@ -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` занимало
соединение на 2025 секунд, и на нагрузочном тесте 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,
)

View File

@@ -56,8 +56,14 @@ class EmailBackend(Protocol):
body: str,
html_body: str | None = None,
attachments: Sequence[EmailAttachment] = (),
reply_to: str | None = None,
) -> None:
"""Отправить письмо получателю `to` (plaintext body обязателен, HTML — альтернатива)."""
"""Отправить письмо получателю `to` (plaintext body обязателен, HTML — альтернатива).
`reply_to` — необязательный контактный адрес инстанса (см.
`services/instance_settings.py::InstanceConfig.contact_email`), проставляется
заголовком `Reply-To`, если задан.
"""
...
@@ -72,13 +78,15 @@ class ConsoleEmailBackend:
body: str,
html_body: str | None = None,
attachments: Sequence[EmailAttachment] = (),
reply_to: str | None = None,
) -> None:
"""Залогировать письмо (вложения — только имена файлов, без содержимого)."""
attachment_names = ", ".join(a.filename for a in attachments) or "нет"
logger.info(
"EMAIL to=%s subject=%s attachments=[%s]\n%s",
"EMAIL to=%s subject=%s reply_to=%s attachments=[%s]\n%s",
to,
subject,
reply_to or "нет",
attachment_names,
body,
)
@@ -116,6 +124,7 @@ class SmtpEmailBackend:
body: str,
html_body: str | None = None,
attachments: Sequence[EmailAttachment] = (),
reply_to: str | None = None,
) -> None:
"""Отправить письмо; ошибки транспорта транслируются в `EmailSendError`."""
message = _build_message(
@@ -125,6 +134,7 @@ class SmtpEmailBackend:
body=body,
html_body=html_body,
attachments=attachments,
reply_to=reply_to,
)
try:
await aiosmtplib.send(
@@ -165,12 +175,15 @@ def _build_message(
body: str,
html_body: str | None,
attachments: Sequence[EmailAttachment],
reply_to: str | None = None,
) -> EmailMessage:
"""Собрать `EmailMessage`: plaintext (+ HTML-альтернатива) + вложения."""
message = EmailMessage()
message["From"] = sender
message["To"] = to
message["Subject"] = subject
if reply_to:
message["Reply-To"] = reply_to
message.set_content(body)
if html_body is not None:
message.add_alternative(html_body, subtype="html")

View 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())

View File

@@ -2,8 +2,9 @@
Ключи зеркалят секции конфигурации (`transcriber`, `summarizer`, `chat`,
`ai_level`, `summary_recipients`, `display_timezone`,
`registration_team_choice`, `registration_email_domain`) — новая настройка
не требует миграции, только новая строка. Бутстрап (`ensure_bootstrapped`)
`registration_team_choice`, `registration_email_domain`, `contact_email`,
`media_limits`) —
новая настройка не требует миграции, только новая строка. Бутстрап (`ensure_bootstrapped`)
импортирует дефолты `config/plugins.yaml` через `INSERT ... ON CONFLICT DO
NOTHING` в lifespan backend — однократно и идемпотентно: повторный вызов
(например, при рестарте backend) не перетирает уже сделанные администратором
@@ -17,7 +18,8 @@ from pathlib import Path
from typing import Any
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
from pydantic import BaseModel
from pydantic import BaseModel, EmailStr, TypeAdapter
from pydantic import ValidationError as PydanticValidationError
from sqlalchemy import select
from sqlalchemy.dialects.postgresql import insert as pg_insert
from sqlalchemy.ext.asyncio import AsyncSession
@@ -27,7 +29,10 @@ from core.plugins.config import (
AiLevel,
ChatConfig,
InstanceConfig,
MediaLimitsConfig,
PluginsConfig,
PublishQualityCap,
StageMaxTiles,
SummarizerConfig,
SummaryRecipientsMode,
TranscriberConfig,
@@ -45,6 +50,8 @@ _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"
BOOTSTRAP_MANAGED_KEYS: tuple[str, ...] = (
_KEY_CHAT,
@@ -61,7 +68,14 @@ _DEFAULT_AI_LEVEL_VALUE = {"level": "min"}
_DEFAULT_SUMMARY_RECIPIENTS_VALUE = {"mode": "all"}
_DEFAULT_DISPLAY_TIMEZONE_VALUE = {"tz": "Europe/Moscow"}
_DEFAULT_REGISTRATION_TEAM_CHOICE_VALUE = {"enabled": False}
_DEFAULT_REGISTRATION_EMAIL_DOMAIN_VALUE: dict[str, Any] = {"enabled": False, "domain": None}
_DEFAULT_REGISTRATION_EMAIL_DOMAIN_VALUE: dict[str, Any] = {"enabled": False, "domains": []}
"""Формат значения ключа `registration_email_domain` в БД. До версии с
несколькими доменами хранилась форма `{"enabled": bool, "domain": str|None}`
(один домен) — читающий код (`_extract_email_domains`) понимает обе формы
для обратной совместимости с уже развёрнутыми инстансами; при первом же
`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}
# Простой паттерн доменного имени: минимум один символ, минимум одна точка,
# метки из латинских букв/цифр/дефисов (без ведущего/конечного дефиса),
@@ -70,6 +84,11 @@ _EMAIL_DOMAIN_PATTERN = re.compile(
r"^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)+$"
)
# Валидация формата контактного email — переиспользует тот же валидатор,
# что и `EmailStr` в pydantic-схемах (`schemas/admin.py` и др.), без
# отдельного регэкспа под адрес целиком.
_CONTACT_EMAIL_ADAPTER: TypeAdapter[str] = TypeAdapter(EmailStr)
class SettingsUpdateIn(BaseModel):
"""Частичное обновление настроек инстанса — все поля опциональны (PUT-патч).
@@ -87,7 +106,11 @@ class SettingsUpdateIn(BaseModel):
display_timezone: str | None = None
registration_team_choice: bool | None = None
registration_email_domain_enabled: bool | None = None
registration_email_domain: str | None = None
registration_email_domains: list[str] | None = None
contact_email_enabled: bool | None = None
contact_email: str | None = None
publish_quality_cap: PublishQualityCap | None = None
stage_max_tiles: StageMaxTiles | None = None
class BootstrapOverrides(BaseModel):
@@ -136,6 +159,8 @@ def build_bootstrap_defaults(
_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),
}
if overrides is None:
return defaults
@@ -172,6 +197,15 @@ class InvalidEmailDomainError(ValueError):
"""
class InvalidContactEmailError(ValueError):
"""Некорректная настройка контактного адреса инстанса.
Поднимается при попытке включить контактный адрес без email
(`enabled=true` и пустой/отсутствующий email) либо при email, не
проходящем валидацию формата (`EmailStr`) — см. `_normalize_contact_email`.
"""
class InstanceSettingsService:
"""CRUD-доступ к настройкам инстанса поверх таблицы `instance_settings`."""
@@ -217,7 +251,20 @@ class InstanceSettingsService:
rows = await self._load_rows()
cfg = _build_config(rows)
if patch.ai_level is not None:
if patch.ai_level is not None and patch.ai_level != cfg.ai_level:
# Валидация только при фактической смене уровня (сравнение с уже
# сохранённым cfg.ai_level) — иначе фронт, отправляющий текущий
# ai_level вместе с любой другой правкой (см. `AdminSettingsTab`),
# блокировал бы сохранение несвязанных настроек на слабом железе,
# где текущий (давно и легитимно сохранённый) уровень недоступен
# по факту заново переоценённых требований (RAM/модели).
#
# Не ослабляем проверку и при отключённых transcriber.enabled/
# summarizer.enabled (когда уровень AI сейчас ни на что не
# влияет): если проверять по факту переключения — это осознанное
# намерение администратора сменить уровень, и молчаливое
# сохранение недоступного значения подставит администратора при
# последующем включении AI неработающей конфигурацией.
statuses = {status.level: status for status in detect_ai_levels(cfg)}
if not statuses[patch.ai_level].available:
raise InvalidAiLevelError(
@@ -247,26 +294,50 @@ class InstanceSettingsService:
if (
patch.registration_email_domain_enabled is not None
or patch.registration_email_domain is not None
or patch.registration_email_domains is not None
):
enabled = (
patch.registration_email_domain_enabled
if patch.registration_email_domain_enabled is not None
else cfg.registration_email_domain_enabled
)
raw_domain = (
patch.registration_email_domain
if patch.registration_email_domain is not None
else cfg.registration_email_domain
raw_domains = (
patch.registration_email_domains
if patch.registration_email_domains is not None
else cfg.registration_email_domains
)
domain = _normalize_email_domain(raw_domain) if raw_domain else None
if enabled and domain is None:
domains = _normalize_email_domains(raw_domains)
if enabled and not domains:
raise InvalidEmailDomainError(
"нельзя включить верификацию домена email без указания домена"
"нельзя включить верификацию домена email без указания хотя бы одного домена"
)
cfg.registration_email_domain_enabled = enabled
cfg.registration_email_domain = domain
await self._set(_KEY_REGISTRATION_EMAIL_DOMAIN, {"enabled": enabled, "domain": domain})
cfg.registration_email_domains = domains
await self._set(
_KEY_REGISTRATION_EMAIL_DOMAIN, {"enabled": enabled, "domains": domains}
)
if patch.contact_email_enabled is not None or patch.contact_email is not None:
contact_enabled = (
patch.contact_email_enabled
if patch.contact_email_enabled is not None
else cfg.contact_email_enabled
)
raw_contact_email = (
patch.contact_email if patch.contact_email is not None else cfg.contact_email
)
contact_email = (
_normalize_contact_email(raw_contact_email) if raw_contact_email else None
)
if contact_enabled and contact_email is None:
raise InvalidContactEmailError(
"нельзя включить контактный адрес без указания email"
)
cfg.contact_email_enabled = contact_enabled
cfg.contact_email = contact_email
await self._set(
_KEY_CONTACT_EMAIL, {"enabled": contact_enabled, "email": contact_email}
)
if patch.transcription_enabled is not None:
cfg.transcriber = cfg.transcriber.model_copy(
@@ -278,6 +349,20 @@ class InstanceSettingsService:
await self._set(_KEY_TRANSCRIBER, cfg.transcriber.model_dump(mode="json"))
await self._set(_KEY_SUMMARIZER, cfg.summarizer.model_dump(mode="json"))
if patch.publish_quality_cap is not None or patch.stage_max_tiles is not None:
cap = (
patch.publish_quality_cap
if patch.publish_quality_cap is not None
else cfg.media_limits.publish_quality_cap
)
max_tiles = (
patch.stage_max_tiles
if patch.stage_max_tiles is not None
else cfg.media_limits.stage_max_tiles
)
cfg.media_limits = MediaLimitsConfig(publish_quality_cap=cap, stage_max_tiles=max_tiles)
await self._set(_KEY_MEDIA_LIMITS, cfg.media_limits.model_dump(mode="json"))
await self._session.commit()
return cfg
@@ -360,6 +445,42 @@ def _normalize_email_domain(domain: str) -> str:
return normalized
def _normalize_email_domains(domains: list[str]) -> list[str]:
"""Нормализовать список доменов: strip/lower/убрать «@» на каждом (см.
`_normalize_email_domain`), отбросить пустые строки, убрать дубликаты
(с сохранением порядка первого вхождения)."""
normalized: list[str] = []
for raw in domains:
if not raw.strip():
continue
domain = _normalize_email_domain(raw)
if domain not in normalized:
normalized.append(domain)
return normalized
def _extract_email_domains(value: dict[str, Any]) -> list[str]:
"""Достать список доменов из значения ключа `registration_email_domain`,
понимая и текущую форму (`domains: [...]`), и форму до многодоменной
поддержки (`domain: str | None`, один домен) — на проде уже записано
именно старое значение, миграция БД для этого не нужна: следующий же
`update()` перепишет строку в новую форму (см. docstring `update`)."""
if "domains" in value:
return list(value["domains"])
legacy_domain = value.get("domain")
return [legacy_domain] if legacy_domain else []
def _normalize_contact_email(email: str) -> str:
"""Нормализовать контактный email (strip, lower) и провалидировать формат."""
normalized = email.strip().lower()
try:
_CONTACT_EMAIL_ADAPTER.validate_python(normalized)
except PydanticValidationError as exc:
raise InvalidContactEmailError(f"некорректный email: {email!r}") from exc
return normalized
def _build_config(rows: dict[str, Any]) -> InstanceConfig:
"""Собрать `InstanceConfig` из строк `instance_settings` с фолбэком на дефолты моделей.
@@ -384,7 +505,14 @@ def _build_config(rows: dict[str, Any]) -> InstanceConfig:
registration_email_domain_enabled=rows.get(
_KEY_REGISTRATION_EMAIL_DOMAIN, _DEFAULT_REGISTRATION_EMAIL_DOMAIN_VALUE
).get("enabled", False),
registration_email_domain=rows.get(
_KEY_REGISTRATION_EMAIL_DOMAIN, _DEFAULT_REGISTRATION_EMAIL_DOMAIN_VALUE
).get("domain"),
registration_email_domains=_extract_email_domains(
rows.get(_KEY_REGISTRATION_EMAIL_DOMAIN, _DEFAULT_REGISTRATION_EMAIL_DOMAIN_VALUE)
),
contact_email_enabled=rows.get(_KEY_CONTACT_EMAIL, _DEFAULT_CONTACT_EMAIL_VALUE).get(
"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)
),
)

View 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()

View File

@@ -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-контейнера в деплое нет) каждый микрофон превращался в
# заведомо безнадёжный сетевой вызов длиной в 2025 секунд.
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).

View File

@@ -24,6 +24,7 @@ from models.team import Team
from models.user import User
from services import ai_levels
from services.conference_ids import generate_number, generate_slug
from services.email import EmailSendError
FUTURE = datetime.now(UTC) + timedelta(days=3)
@@ -32,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,
)
@@ -85,6 +86,7 @@ async def test_all_admin_endpoints_forbidden_for_non_admin(
("PATCH", f"/api/v1/admin/users/{other_user.id}", {"role": "admin"}),
("GET", "/api/v1/admin/settings", None),
("PUT", "/api/v1/admin/settings", {}),
("POST", "/api/v1/admin/settings/test-email", {}),
]
for method, path, body in requests:
response = await client.request(method, path, json=body, headers=headers)
@@ -379,6 +381,67 @@ async def test_list_users_returns_all(client: httpx.AsyncClient, db_session: Asy
assert all("email_verified" in item for item in items)
async def test_list_users_filters_by_status(
client: httpx.AsyncClient, db_session: AsyncSession
) -> None:
"""`status=active`/`blocked` фильтрует по `is_blocked`; без параметра — все."""
admin = await _make_user(db_session, role="admin")
active_user = await _make_user(db_session)
blocked_user = await _make_user(db_session)
blocked_user.is_blocked = True
await db_session.commit()
active_response = await client.get(
"/api/v1/admin/users", params={"status": "active"}, headers=_auth_headers(admin)
)
assert active_response.status_code == 200, active_response.text
active_ids = [item["id"] for item in active_response.json()["items"]]
assert str(active_user.id) in active_ids
assert str(blocked_user.id) not in active_ids
blocked_response = await client.get(
"/api/v1/admin/users", params={"status": "blocked"}, headers=_auth_headers(admin)
)
assert blocked_response.status_code == 200, blocked_response.text
blocked_ids = [item["id"] for item in blocked_response.json()["items"]]
assert str(blocked_user.id) in blocked_ids
assert str(active_user.id) not in blocked_ids
all_response = await client.get("/api/v1/admin/users", headers=_auth_headers(admin))
all_ids = [item["id"] for item in all_response.json()["items"]]
assert str(active_user.id) in all_ids
assert str(blocked_user.id) in all_ids
async def test_list_users_search_matches_team_name(
client: httpx.AsyncClient, db_session: AsyncSession
) -> None:
"""`q` находит пользователей по (части) названия их команды; пользователи
без команды при этом не пропадают из общей (без поиска) выдачи."""
admin = await _make_user(db_session, role="admin")
suffix = uuid.uuid4().hex[:8]
team = Team(name=f"Rocket-{suffix} Squad")
db_session.add(team)
await db_session.flush()
teamed_user = await _make_user(db_session)
teamed_user.team_id = team.id
teamless_user = await _make_user(db_session)
await db_session.commit()
response = await client.get(
"/api/v1/admin/users", params={"q": f"Rocket-{suffix}"}, headers=_auth_headers(admin)
)
assert response.status_code == 200, response.text
ids = [item["id"] for item in response.json()["items"]]
assert str(teamed_user.id) in ids
assert str(teamless_user.id) not in ids
assert str(admin.id) not in ids
all_response = await client.get("/api/v1/admin/users", headers=_auth_headers(admin))
all_ids = [item["id"] for item in all_response.json()["items"]]
assert str(teamless_user.id) in all_ids
async def test_patch_user_role_and_block(
client: httpx.AsyncClient, db_session: AsyncSession
) -> None:
@@ -606,3 +669,215 @@ async def test_put_settings_unavailable_ai_level_returns_400(
headers=_auth_headers(admin),
)
assert response.status_code == 400
async def test_put_settings_contact_email_enable_and_persist(
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={"contact_email_enabled": True, "contact_email": "Contact@VidConf.RU"},
headers=_auth_headers(admin),
)
assert response.status_code == 200, response.text
body = response.json()
assert body["contact_email_enabled"] is True
assert body["contact_email"] == "contact@vidconf.ru"
reloaded = await client.get("/api/v1/admin/settings", headers=_auth_headers(admin))
assert reloaded.json()["contact_email"] == "contact@vidconf.ru"
async def test_put_settings_contact_email_invalid_returns_400(
client: httpx.AsyncClient, db_session: AsyncSession
) -> None:
admin = await _make_user(db_session, role="admin")
await db_session.commit()
response = await client.put(
"/api/v1/admin/settings",
json={"contact_email_enabled": True, "contact_email": "not an email"},
headers=_auth_headers(admin),
)
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
# --- Тестовое письмо ----------------------------------------------------------------
async def test_send_test_email_defaults_to_admin_email(
client: httpx.AsyncClient, db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
) -> None:
"""Без явного `to` тестовое письмо уходит на email текущего администратора."""
admin = await _make_user(db_session, role="admin")
await db_session.commit()
sent: dict[str, object] = {}
class _FakeBackend:
async def send(self, **kwargs: object) -> None:
sent.update(kwargs)
monkeypatch.setattr(admin_module, "create_email_backend", lambda settings: _FakeBackend())
response = await client.post(
"/api/v1/admin/settings/test-email", json={}, headers=_auth_headers(admin)
)
assert response.status_code == 200, response.text
body = response.json()
assert body["success"] is True
assert sent["to"] == admin.email
async def test_send_test_email_to_explicit_recipient(
client: httpx.AsyncClient, db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
) -> None:
admin = await _make_user(db_session, role="admin")
await db_session.commit()
sent: dict[str, object] = {}
class _FakeBackend:
async def send(self, **kwargs: object) -> None:
sent.update(kwargs)
monkeypatch.setattr(admin_module, "create_email_backend", lambda settings: _FakeBackend())
response = await client.post(
"/api/v1/admin/settings/test-email",
json={"to": "other@example.com"},
headers=_auth_headers(admin),
)
assert response.status_code == 200, response.text
assert sent["to"] == "other@example.com"
async def test_send_test_email_uses_contact_email_as_reply_to(
client: httpx.AsyncClient, db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
) -> None:
admin = await _make_user(db_session, role="admin")
await db_session.commit()
await client.put(
"/api/v1/admin/settings",
json={"contact_email_enabled": True, "contact_email": "contact@vidconf.example"},
headers=_auth_headers(admin),
)
sent: dict[str, object] = {}
class _FakeBackend:
async def send(self, **kwargs: object) -> None:
sent.update(kwargs)
monkeypatch.setattr(admin_module, "create_email_backend", lambda settings: _FakeBackend())
response = await client.post(
"/api/v1/admin/settings/test-email", json={}, headers=_auth_headers(admin)
)
assert response.status_code == 200, response.text
assert sent["reply_to"] == "contact@vidconf.example"
async def test_send_test_email_reports_transport_failure_without_leaking_secrets(
client: httpx.AsyncClient, db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
) -> None:
"""Сбой транспорта — понятный текст ошибки; хост/порт видны, логин/пароль — нет."""
monkeypatch.setenv("EMAIL_BACKEND", "smtp")
monkeypatch.setenv("SMTP_HOST", "smtp.example.com")
monkeypatch.setenv("SMTP_PORT", "2525")
monkeypatch.setenv("SMTP_USERNAME", "secret-user")
monkeypatch.setenv("SMTP_PASSWORD", "super-secret-password")
get_settings.cache_clear()
class _FailingBackend:
async def send(self, **kwargs: object) -> None:
raise EmailSendError("временный сбой SMTP: сервер недоступен", retryable=True)
monkeypatch.setattr(admin_module, "create_email_backend", lambda settings: _FailingBackend())
try:
admin = await _make_user(db_session, role="admin")
await db_session.commit()
response = await client.post(
"/api/v1/admin/settings/test-email", json={}, headers=_auth_headers(admin)
)
assert response.status_code == 200, response.text
body = response.json()
assert body["success"] is False
assert "сбой SMTP" in body["message"]
assert body["smtp_host"] == "smtp.example.com"
assert body["smtp_port"] == 2525
assert "secret-user" not in response.text
assert "super-secret-password" not in response.text
finally:
get_settings.cache_clear()

View File

@@ -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,
)

View File

@@ -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
@@ -48,6 +50,7 @@ class _CapturingEmailBackend:
def __init__(self) -> None:
self.sent: list[tuple[str, str, str]] = []
self.reply_to: list[str | None] = []
async def send(
self,
@@ -57,8 +60,10 @@ class _CapturingEmailBackend:
body: str,
html_body: str | None = None,
attachments: Sequence[EmailAttachment] = (),
reply_to: str | None = None,
) -> None:
self.sent.append((to, subject, body))
self.reply_to.append(reply_to)
def _extract_verification_token(email_body: str) -> str:
@@ -235,7 +240,7 @@ async def test_registration_options_disabled_by_default(
body = response.json()
assert body["team_choice_enabled"] is False
assert body["teams"] == []
assert body["email_domain"] is None
assert body["email_domains"] == []
async def test_registration_options_enabled_returns_teams_sorted_by_name(
@@ -340,29 +345,30 @@ async def test_register_with_unknown_team_id_returns_400(
# --- Верификация регистрирующихся по домену email -------------------------------------
async def test_registration_options_returns_email_domain_when_enabled(
async def test_registration_options_returns_email_domains_when_enabled(
client: httpx.AsyncClient, db_session: AsyncSession
) -> None:
"""При включённой верификации домена `registration-options` отдаёт эталонный домен."""
"""При включённой верификации домена `registration-options` отдаёт все эталонные домены."""
await InstanceSettingsService(db_session).update(
SettingsUpdateIn(
registration_email_domain_enabled=True, registration_email_domain="example.com"
registration_email_domain_enabled=True,
registration_email_domains=["example.com", "corp.example"],
)
)
await db_session.commit()
response = await client.get("/api/v1/auth/registration-options")
assert response.status_code == 200, response.text
assert response.json()["email_domain"] == "example.com"
assert response.json()["email_domains"] == ["example.com", "corp.example"]
async def test_registration_options_email_domain_null_when_disabled(
async def test_registration_options_email_domains_empty_when_disabled(
client: httpx.AsyncClient, db_session: AsyncSession
) -> None:
"""Выключенная верификация домена — `email_domain` всегда `null`, даже если домен сохранён."""
"""Выключенная верификация домена — `email_domains` всегда пуст, даже если домены сохранены."""
await InstanceSettingsService(db_session).update(
SettingsUpdateIn(
registration_email_domain_enabled=True, registration_email_domain="example.com"
registration_email_domain_enabled=True, registration_email_domains=["example.com"]
)
)
await InstanceSettingsService(db_session).update(
@@ -372,7 +378,7 @@ async def test_registration_options_email_domain_null_when_disabled(
response = await client.get("/api/v1/auth/registration-options")
assert response.status_code == 200, response.text
assert response.json()["email_domain"] is None
assert response.json()["email_domains"] == []
async def test_register_with_foreign_domain_when_verification_enabled_returns_400(
@@ -380,7 +386,8 @@ async def test_register_with_foreign_domain_when_verification_enabled_returns_40
) -> None:
await InstanceSettingsService(db_session).update(
SettingsUpdateIn(
registration_email_domain_enabled=True, registration_email_domain="example.com"
registration_email_domain_enabled=True,
registration_email_domains=["example.com", "corp.example"],
)
)
await db_session.commit()
@@ -402,7 +409,7 @@ async def test_register_with_matching_domain_case_insensitive_succeeds(
) -> None:
await InstanceSettingsService(db_session).update(
SettingsUpdateIn(
registration_email_domain_enabled=True, registration_email_domain="example.com"
registration_email_domain_enabled=True, registration_email_domains=["example.com"]
)
)
await db_session.commit()
@@ -418,6 +425,29 @@ async def test_register_with_matching_domain_case_insensitive_succeeds(
assert response.status_code == 201, response.text
async def test_register_with_second_domain_in_list_succeeds(
client: httpx.AsyncClient, db_session: AsyncSession, email_backend: _CapturingEmailBackend
) -> None:
"""Email подходит, если совпадает с ЛЮБЫМ доменом из списка — не только с первым."""
await InstanceSettingsService(db_session).update(
SettingsUpdateIn(
registration_email_domain_enabled=True,
registration_email_domains=["example.com", "corp.example"],
)
)
await db_session.commit()
response = await client.post(
"/api/v1/auth/register",
json={
"email": "dave@corp.example",
"name_user": "Dave",
"password": "supersecret1",
},
)
assert response.status_code == 201, response.text
async def test_register_any_domain_allowed_when_verification_disabled(
client: httpx.AsyncClient, email_backend: _CapturingEmailBackend
) -> None:
@@ -432,3 +462,66 @@ async def test_register_any_domain_allowed_when_verification_disabled(
},
)
assert response.status_code == 201, response.text
async def test_register_sets_reply_to_when_contact_email_enabled(
client: httpx.AsyncClient, db_session: AsyncSession, email_backend: _CapturingEmailBackend
) -> None:
"""Контактный адрес инстанса включён — письмо подтверждения регистрации несёт `Reply-To`."""
await InstanceSettingsService(db_session).update(
SettingsUpdateIn(contact_email_enabled=True, contact_email="contact@vidconf.example")
)
await db_session.commit()
response = await client.post(
"/api/v1/auth/register",
json={"email": "dave@example.com", "name_user": "Dave", "password": "supersecret1"},
)
assert response.status_code == 201, response.text
assert email_backend.reply_to[-1] == "contact@vidconf.example"
async def test_register_no_reply_to_when_contact_email_disabled(
client: httpx.AsyncClient, email_backend: _CapturingEmailBackend
) -> None:
"""Контактный адрес выключен (дефолт) — `Reply-To` не проставляется."""
response = await client.post(
"/api/v1/auth/register",
json={"email": "erin@example.com", "name_user": "Erin", "password": "supersecret1"},
)
assert response.status_code == 201, response.text
assert email_backend.reply_to[-1] 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

View File

@@ -44,7 +44,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 +85,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
# --- Основной сценарий: обмен сообщениями + история -------------------------

View File

@@ -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)

View File

@@ -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)

View File

@@ -33,7 +33,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 +67,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 +113,10 @@ async def test_create_instant_conference_returns_active_with_join(
assert body["join"]["conference_id"] == body["id"]
assert body["join"]["room_name"] == body["slug"]
assert body["join"]["token"]
# Рычаги нагрузки медиа — дефолты без ограничения (существующие
# инсталляции не должны получить внезапно ухудшенное качество).
assert body["join"]["publish_quality_cap"] == "off"
assert body["join"]["stage_max_tiles"] == 25
async def test_create_instant_conference_join_metadata_contains_owner_avatar_url(
@@ -573,17 +577,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, "лимит одного клиента не должен задевать другого"
# --- Вход зарегистрированным пользователем ---------------------------------------
@@ -708,6 +770,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 +845,33 @@ async def test_guest_join_creates_guest_access_and_returns_join(
assert guests[0].email == "alice-guest@example.com"
async def test_guest_join_reflects_admin_configured_media_limits(
client: httpx.AsyncClient, db_session: AsyncSession
) -> None:
"""Настройки, сохранённые администратором в `PUT /admin/settings`, доезжают
до гостя в join-ответе ДО входа в комнату — публичный путь доставки
(см. `services/conference_access.py::build_join`), гость админку не видит."""
admin = await _make_user(db_session, role="admin")
conference = await _make_conference(db_session)
await db_session.commit()
settings_response = await client.put(
"/api/v1/admin/settings",
json={"publish_quality_cap": "180p", "stage_max_tiles": 4},
headers=_auth_headers(admin),
)
assert settings_response.status_code == 200, settings_response.text
response = await client.post(
f"/api/v1/conferences/{conference.id}/guest-join",
json={"display_name": "Guest Bob"},
)
assert response.status_code == 200, response.text
body = response.json()
assert body["publish_quality_cap"] == "180p"
assert body["stage_max_tiles"] == 4
async def test_guest_join_without_email_is_allowed(
client: httpx.AsyncClient, db_session: AsyncSession
) -> None:
@@ -803,21 +924,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

View File

@@ -0,0 +1,272 @@
"""Тесты очереди поднятых рук поверх WS комнаты (`WS /api/v1/conferences/{id}/chat`, задача B1).
Протокол и аутентификация — общие с чатом (`api/chat.py`), поэтому структура
тестов и хелперы намеренно зеркалят `tests/test_chat_ws.py`.
"""
import uuid
from collections.abc import Callable
from typing import Any
from sqlalchemy.ext.asyncio import AsyncSession
from core.security import 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.livekit_tokens import create_room_access_token
from tests.conftest import ASGIWebSocketSession
WSFactory = Callable[[str], ASGIWebSocketSession]
# --- Хелперы (см. 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)]

View File

@@ -48,6 +48,7 @@ from services.instance_settings import (
BootstrapOverrides,
InstanceSettingsService,
InvalidAiLevelError,
InvalidContactEmailError,
InvalidEmailDomainError,
InvalidTimezoneError,
SettingsUpdateIn,
@@ -69,6 +70,8 @@ _MANAGED_KEYS = (
"display_timezone",
"registration_team_choice",
"registration_email_domain",
"contact_email",
"media_limits",
)
@@ -123,6 +126,8 @@ async def test_ensure_bootstrapped_imports_yaml_defaults(
"display_timezone",
"registration_team_choice",
"registration_email_domain",
"contact_email",
"media_limits",
}
cfg = await service.get()
assert cfg.transcriber.provider == "faster_whisper_cpu"
@@ -131,7 +136,14 @@ async def test_ensure_bootstrapped_imports_yaml_defaults(
assert cfg.display_timezone == "Europe/Moscow"
assert cfg.registration_team_choice is False
assert cfg.registration_email_domain_enabled is False
assert cfg.registration_email_domain is None
assert cfg.registration_email_domains == []
assert cfg.contact_email_enabled is False
assert cfg.contact_email is None
# Дефолты сохраняют текущее (до появления настройки) поведение —
# без ограничения качества и с максимумом сетки, равным фактическому
# потолку `StageGrid` (5×5).
assert cfg.media_limits.publish_quality_cap == "off"
assert cfg.media_limits.stage_max_tiles == 25
async def test_ensure_bootstrapped_is_idempotent_and_keeps_admin_edits(
@@ -321,7 +333,7 @@ async def test_registration_team_choice_toggle(
async def test_registration_email_domain_enable_without_domain_rejected(
db_session: AsyncSession, clean_instance_settings: None
) -> None:
"""Включение верификации без домена (ни в патче, ни ранее сохранённого) → 400."""
"""Включение верификации без доменов (ни в патче, ни ранее сохранённых) → 400."""
service = InstanceSettingsService(db_session)
await service.ensure_bootstrapped(PLUGINS_YAML)
@@ -330,7 +342,24 @@ async def test_registration_email_domain_enable_without_domain_rejected(
cfg = await service.get()
assert cfg.registration_email_domain_enabled is False
assert cfg.registration_email_domain is None
assert cfg.registration_email_domains == []
async def test_registration_email_domain_enable_with_empty_list_rejected(
db_session: AsyncSession, clean_instance_settings: None
) -> None:
"""Пустой список доменов при включении верификации — та же ошибка, что и
отсутствие поля (не молчаливое отключение проверки)."""
service = InstanceSettingsService(db_session)
await service.ensure_bootstrapped(PLUGINS_YAML)
with pytest.raises(InvalidEmailDomainError):
await service.update(
SettingsUpdateIn(registration_email_domain_enabled=True, registration_email_domains=[])
)
cfg = await service.get()
assert cfg.registration_email_domain_enabled is False
async def test_registration_email_domain_rejects_invalid_pattern(
@@ -340,48 +369,168 @@ async def test_registration_email_domain_rejects_invalid_pattern(
await service.ensure_bootstrapped(PLUGINS_YAML)
with pytest.raises(InvalidEmailDomainError):
await service.update(SettingsUpdateIn(registration_email_domain="not a domain"))
await service.update(SettingsUpdateIn(registration_email_domains=["not a domain"]))
cfg = await service.get()
assert cfg.registration_email_domain is None
assert cfg.registration_email_domains == []
async def test_registration_email_domain_normalizes_input(
db_session: AsyncSession, clean_instance_settings: None
) -> None:
"""`@Corp.RU ` нормализуется в `corp.ru` (strip, убрать «@», lower)."""
"""`@Corp.RU ` нормализуется в `corp.ru` (strip, убрать «@», lower); пустые строки
отбрасываются, дубликаты схлопываются."""
service = InstanceSettingsService(db_session)
await service.ensure_bootstrapped(PLUGINS_YAML)
cfg = await service.update(
SettingsUpdateIn(
registration_email_domain_enabled=True, registration_email_domain="@Corp.RU "
registration_email_domain_enabled=True,
registration_email_domains=["@Corp.RU ", "", "corp.ru", "Acme.IO"],
)
)
assert cfg.registration_email_domain_enabled is True
assert cfg.registration_email_domain == "corp.ru"
assert cfg.registration_email_domains == ["corp.ru", "acme.io"]
reloaded = await service.get()
assert reloaded.registration_email_domain == "corp.ru"
assert reloaded.registration_email_domains == ["corp.ru", "acme.io"]
async def test_registration_email_domain_can_be_disabled_keeping_stored_domain(
async def test_registration_email_domain_can_be_disabled_keeping_stored_domains(
db_session: AsyncSession, clean_instance_settings: None
) -> None:
"""Выключение верификации без передачи домена не требует домена и не роняет валидацию."""
"""Выключение верификации без передачи доменов не требует их и не роняет валидацию."""
service = InstanceSettingsService(db_session)
await service.ensure_bootstrapped(PLUGINS_YAML)
await service.update(
SettingsUpdateIn(
registration_email_domain_enabled=True, registration_email_domain="acme.io"
registration_email_domain_enabled=True, registration_email_domains=["acme.io"]
)
)
cfg = await service.update(SettingsUpdateIn(registration_email_domain_enabled=False))
assert cfg.registration_email_domain_enabled is False
assert cfg.registration_email_domain == "acme.io"
assert cfg.registration_email_domains == ["acme.io"]
async def test_registration_email_domain_reads_legacy_single_domain_shape(
db_session: AsyncSession, clean_instance_settings: None
) -> None:
"""До многодоменной поддержки в БД хранилась форма `{"domain": str|None}` (один
домен, без миграции на проде) — чтение должно разворачивать её в список из
одного элемента (см. `_extract_email_domains`)."""
service = InstanceSettingsService(db_session)
await service.ensure_bootstrapped(PLUGINS_YAML)
stmt = (
pg_insert(InstanceSetting)
.values(
key="registration_email_domain",
value={"enabled": True, "domain": "legacy.example"},
)
.on_conflict_do_update(
index_elements=["key"],
set_={"value": {"enabled": True, "domain": "legacy.example"}},
)
)
await db_session.execute(stmt)
await db_session.commit()
cfg = await service.get()
assert cfg.registration_email_domain_enabled is True
assert cfg.registration_email_domains == ["legacy.example"]
async def test_registration_email_domain_rewrites_legacy_shape_on_update(
db_session: AsyncSession, clean_instance_settings: None
) -> None:
"""Первое же сохранение после чтения старой формы переписывает строку в
новую (`domains: [...]`), а не оставляет legacy `domain` рядом."""
service = InstanceSettingsService(db_session)
await service.ensure_bootstrapped(PLUGINS_YAML)
stmt = (
pg_insert(InstanceSetting)
.values(
key="registration_email_domain",
value={"enabled": True, "domain": "legacy.example"},
)
.on_conflict_do_update(
index_elements=["key"],
set_={"value": {"enabled": True, "domain": "legacy.example"}},
)
)
await db_session.execute(stmt)
await db_session.commit()
await service.update(SettingsUpdateIn(registration_email_domains=["new.example"]))
row = await db_session.get(InstanceSetting, "registration_email_domain")
assert row is not None
assert row.value == {"enabled": True, "domains": ["new.example"]}
async def test_contact_email_enable_without_email_rejected(
db_session: AsyncSession, clean_instance_settings: None
) -> None:
"""Включение контактного адреса без email (ни в патче, ни ранее сохранённого) → 400."""
service = InstanceSettingsService(db_session)
await service.ensure_bootstrapped(PLUGINS_YAML)
with pytest.raises(InvalidContactEmailError):
await service.update(SettingsUpdateIn(contact_email_enabled=True))
cfg = await service.get()
assert cfg.contact_email_enabled is False
assert cfg.contact_email is None
async def test_contact_email_rejects_invalid_format(
db_session: AsyncSession, clean_instance_settings: None
) -> None:
service = InstanceSettingsService(db_session)
await service.ensure_bootstrapped(PLUGINS_YAML)
with pytest.raises(InvalidContactEmailError):
await service.update(SettingsUpdateIn(contact_email="not an email"))
cfg = await service.get()
assert cfg.contact_email is None
async def test_contact_email_normalizes_input(
db_session: AsyncSession, clean_instance_settings: None
) -> None:
"""` Contact@VidConf.RU ` нормализуется в `contact@vidconf.ru` (strip, lower)."""
service = InstanceSettingsService(db_session)
await service.ensure_bootstrapped(PLUGINS_YAML)
cfg = await service.update(
SettingsUpdateIn(contact_email_enabled=True, contact_email=" Contact@VidConf.RU ")
)
assert cfg.contact_email_enabled is True
assert cfg.contact_email == "contact@vidconf.ru"
reloaded = await service.get()
assert reloaded.contact_email == "contact@vidconf.ru"
async def test_contact_email_can_be_disabled_keeping_stored_email(
db_session: AsyncSession, clean_instance_settings: None
) -> None:
"""Выключение контактного адреса без передачи email не требует его и не роняет валидацию."""
service = InstanceSettingsService(db_session)
await service.ensure_bootstrapped(PLUGINS_YAML)
await service.update(
SettingsUpdateIn(contact_email_enabled=True, contact_email="contact@acme.io")
)
cfg = await service.update(SettingsUpdateIn(contact_email_enabled=False))
assert cfg.contact_email_enabled is False
assert cfg.contact_email == "contact@acme.io"
async def test_transcription_enabled_flag_toggles_both_transcriber_and_summarizer(
@@ -396,6 +545,28 @@ async def test_transcription_enabled_flag_toggles_both_transcriber_and_summarize
assert cfg.summarizer.enabled is False
async def test_media_limits_partial_update_keeps_untouched_field(
db_session: AsyncSession, clean_instance_settings: None
) -> None:
"""Патч только одного поля `media_limits` не сбрасывает соседнее — оба поля
живут в одной строке JSON, `update()` обязан подставлять текущее значение
несменённого поля, а не дефолт модели."""
service = InstanceSettingsService(db_session)
await service.ensure_bootstrapped(PLUGINS_YAML)
cfg = await service.update(SettingsUpdateIn(publish_quality_cap="360p"))
assert cfg.media_limits.publish_quality_cap == "360p"
assert cfg.media_limits.stage_max_tiles == 25 # дефолт не тронут
cfg = await service.update(SettingsUpdateIn(stage_max_tiles=9))
assert cfg.media_limits.stage_max_tiles == 9
assert cfg.media_limits.publish_quality_cap == "360p" # предыдущая правка сохранилась
reloaded = await service.get()
assert reloaded.media_limits.publish_quality_cap == "360p"
assert reloaded.media_limits.stage_max_tiles == 9
async def test_update_rejects_unavailable_ai_level(
db_session: AsyncSession, clean_instance_settings: None
) -> None:
@@ -411,6 +582,52 @@ async def test_update_rejects_unavailable_ai_level(
assert cfg.ai_level == "min"
async def test_update_unrelated_setting_passes_when_current_ai_level_unavailable(
db_session: AsyncSession, clean_instance_settings: None
) -> None:
"""Баг: фронт всегда отправляет текущий `ai_level` вместе с любой другой
правкой (см. `AdminSettingsTab.handleSave`) — если уже сохранённый
уровень стал недоступен (слабое железо/модель не докачана), это не
должно блокировать сохранение несвязанной настройки. Валидация нужна
только при фактической смене уровня (см. docstring `update`)."""
service = InstanceSettingsService(db_session)
await service.ensure_bootstrapped(PLUGINS_YAML)
stmt = (
pg_insert(InstanceSetting)
.values(key="ai_level", value={"level": "medium"})
.on_conflict_do_update(index_elements=["key"], set_={"value": {"level": "medium"}})
)
await db_session.execute(stmt)
await db_session.commit()
assert not detect_ai_levels(await service.get())[1].available # "medium" недоступен
# ai_level в патче совпадает с уже сохранённым — не должно проверяться.
cfg = await service.update(
SettingsUpdateIn(ai_level="medium", registration_team_choice=True)
)
assert cfg.ai_level == "medium"
assert cfg.registration_team_choice is True
async def test_update_still_rejects_switching_to_unavailable_ai_level(
db_session: AsyncSession, clean_instance_settings: None
) -> None:
"""Фактическая попытка ПЕРЕКЛЮЧИТЬ уровень на недоступный по-прежнему
отклоняется — даже если AI (транскрибация/суммаризация) выключен: иначе
администратор молча сохранит нерабочую конфигурацию, которая проявится
только при последующем включении AI."""
service = InstanceSettingsService(db_session)
await service.ensure_bootstrapped(PLUGINS_YAML)
await service.update(SettingsUpdateIn(transcription_enabled=False))
with pytest.raises(InvalidAiLevelError):
await service.update(SettingsUpdateIn(ai_level="medium"))
cfg = await service.get()
assert cfg.ai_level == "min"
async def test_update_rejects_invalid_timezone(
db_session: AsyncSession, clean_instance_settings: None
) -> None:

View File

@@ -19,6 +19,7 @@ from celery.exceptions import MaxRetriesExceededError
from sqlalchemy import text
from core.db import engine
from core.plugins.config import ChatConfig, InstanceConfig, SummarizerConfig, TranscriberConfig
from services.conference_ids import generate_number, generate_slug
from services.email import EmailAttachment, EmailSendError
from workers.tasks import invitations as invitations_module
@@ -65,10 +66,13 @@ class _FakeEmailBackend:
body: str,
html_body: str | None = None,
attachments: tuple[EmailAttachment, ...] = (),
reply_to: str | None = None,
) -> None:
if to in self._fail_for:
raise EmailSendError(f"сбой отправки {to}", retryable=self._retryable)
self.sent.append({"to": to, "subject": subject, "attachments": attachments})
self.sent.append(
{"to": to, "subject": subject, "attachments": attachments, "reply_to": reply_to}
)
class _Fixture:
@@ -364,6 +368,48 @@ async def test_pinned_conference_sends_to_owner_and_past_participants_with_email
assert delivered == sent_to
def _cfg(
*, contact_email_enabled: bool = False, contact_email: str | None = None
) -> InstanceConfig:
return InstanceConfig(
transcriber=TranscriberConfig(),
summarizer=SummarizerConfig(),
chat=ChatConfig(),
contact_email_enabled=contact_email_enabled,
contact_email=contact_email,
)
async def test_invitations_set_reply_to_when_contact_email_enabled(
fx: _Fixture, monkeypatch: pytest.MonkeyPatch
) -> None:
"""Включённый контактный адрес инстанса — приглашение несёт `Reply-To`."""
mock_backend = _FakeEmailBackend()
monkeypatch.setattr(invitations_module, "create_email_backend", lambda settings: mock_backend)
await send_invitations_async(
_FakeTask(),
fx.conference_id,
plugins_config=_cfg(contact_email_enabled=True, contact_email="contact@vidconf.example"),
)
assert mock_backend.sent
assert all(call["reply_to"] == "contact@vidconf.example" for call in mock_backend.sent)
async def test_invitations_no_reply_to_when_contact_email_disabled(
fx: _Fixture, monkeypatch: pytest.MonkeyPatch
) -> None:
"""Контактный адрес выключен (дефолт) — `Reply-To` не проставляется."""
mock_backend = _FakeEmailBackend()
monkeypatch.setattr(invitations_module, "create_email_backend", lambda settings: mock_backend)
await send_invitations_async(_FakeTask(), fx.conference_id, plugins_config=_cfg())
assert mock_backend.sent
assert all(call["reply_to"] is None for call in mock_backend.sent)
async def test_explicit_emails_override_default_recipients(
fx: _Fixture, monkeypatch: pytest.MonkeyPatch
) -> None:

View File

@@ -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 —
2025 секунд внутри открытой транзакции вебхука. На нагрузочном тесте
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-транспорт дожидается фоновых
задач), а сама суть: пока открыта транзакция вебхука, сетевого вызова не
происходит — обработчик только планирует задачу. Именно это разгружает пул
соединений: до правки вызов жил внутри транзакции и держал соединение
2025 секунд, когда 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 это было 2025 секунд на каждый микрофонный
трек. Живой 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()

View File

@@ -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,31 @@ 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
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:

View 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

View File

@@ -60,6 +60,7 @@ class _FakeEmailBackend:
def __init__(self, *, fail_for: set[str] | None = None, retryable: bool = True) -> None:
self.sent: list[str] = []
self.reply_to: list[str | None] = []
self._fail_for = fail_for or set()
self._retryable = retryable
@@ -71,10 +72,12 @@ class _FakeEmailBackend:
body: str,
html_body: str | None = None,
attachments: Any = (),
reply_to: str | None = None,
) -> None:
if to in self._fail_for:
raise EmailSendError(f"сбой отправки для {to}", retryable=self._retryable)
self.sent.append(to)
self.reply_to.append(reply_to)
class _Fixture:
@@ -221,12 +224,19 @@ async def fx() -> AsyncGenerator[_Fixture, None]:
await conn.commit()
def _cfg(*, summary_recipients: str = "all") -> InstanceConfig:
def _cfg(
*,
summary_recipients: str = "all",
contact_email_enabled: bool = False,
contact_email: str | None = None,
) -> InstanceConfig:
return InstanceConfig(
transcriber=TranscriberConfig(),
summarizer=SummarizerConfig(),
chat=ChatConfig(),
summary_recipients=cast("Any", summary_recipients),
contact_email_enabled=contact_email_enabled,
contact_email=contact_email,
)
@@ -291,6 +301,40 @@ async def test_notify_session_mode_owner_sends_only_to_owner(
assert await _fetch_session_status(fx.session_id) == "notified"
async def test_notify_session_sets_reply_to_when_contact_email_enabled(
fx: _Fixture, monkeypatch: pytest.MonkeyPatch
) -> None:
"""Включённый контактный адрес инстанса — саммари-письмо несёт `Reply-To`."""
backend = _FakeEmailBackend()
monkeypatch.setattr(notify_module, "create_email_backend", lambda settings: backend)
await notify_session_async(
_FakeTask(),
fx.session_id,
plugins_config=_cfg(
summary_recipients="owner",
contact_email_enabled=True,
contact_email="contact@vidconf.example",
),
)
assert backend.reply_to == ["contact@vidconf.example"]
async def test_notify_session_no_reply_to_when_contact_email_disabled(
fx: _Fixture, monkeypatch: pytest.MonkeyPatch
) -> None:
"""Контактный адрес выключен (дефолт) — `Reply-To` не проставляется."""
backend = _FakeEmailBackend()
monkeypatch.setattr(notify_module, "create_email_backend", lambda settings: backend)
await notify_session_async(
_FakeTask(), fx.session_id, plugins_config=_cfg(summary_recipients="owner")
)
assert backend.reply_to == [None]
async def test_notify_session_conference_override_wins_over_instance_default(
fx: _Fixture, monkeypatch: pytest.MonkeyPatch
) -> None:

View File

@@ -0,0 +1,128 @@
"""Проверка пароля не должна блокировать event loop (регресс после теста 31.07.2026).
Синхронный `verify_password` останавливал весь процесс backend на 95155 мс.
При массовом входе (около 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 обязан прокрутить заметное число тиков. Порог намеренно щадящий:
# при блокировке тиков будет 01, а не «мало».
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

View File

@@ -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,
)

View File

@@ -7,6 +7,8 @@
"""
import logging
from email.message import EmailMessage
from typing import cast
import aiosmtplib
import pytest
@@ -90,6 +92,33 @@ def test_build_message_includes_html_alternative_and_ics_attachment() -> None:
assert attachments[0].get_payload(decode=True) == b"BEGIN:VCALENDAR\r\nEND:VCALENDAR\r\n"
def test_build_message_sets_reply_to_when_given() -> None:
message = _build_message(
sender="VidConf <no-reply@vidconf.example>",
to="user@example.com",
subject="Тема",
body="Тело",
html_body=None,
attachments=(),
reply_to="contact@vidconf.example",
)
assert message["Reply-To"] == "contact@vidconf.example"
def test_build_message_omits_reply_to_when_not_given() -> None:
message = _build_message(
sender="VidConf <no-reply@vidconf.example>",
to="user@example.com",
subject="Тема",
body="Тело",
html_body=None,
attachments=(),
)
assert message["Reply-To"] is None
def _backend() -> SmtpEmailBackend:
return SmtpEmailBackend(
hostname="smtp.example.com",
@@ -168,3 +197,23 @@ async def test_smtp_backend_send_success_calls_aiosmtplib(monkeypatch: pytest.Mo
"use_tls": False,
"timeout": 30,
}
async def test_smtp_backend_send_passes_reply_to_into_message(
monkeypatch: pytest.MonkeyPatch,
) -> None:
captured: dict[str, object] = {}
async def _fake_send(message: object, **kwargs: object) -> tuple[dict[str, object], str]:
captured["message"] = message
return {}, "OK"
monkeypatch.setattr(aiosmtplib, "send", _fake_send)
backend = _backend()
await backend.send(
to="user@example.com", subject="Тема", body="Тело", reply_to="contact@vidconf.example"
)
message = cast(EmailMessage, captured["message"])
assert message["Reply-To"] == "contact@vidconf.example"

View File

@@ -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)

View File

@@ -24,7 +24,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 +98,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 +380,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)

View File

@@ -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
# автоматически). Для локальной разработки (без внешних участников)

View File

@@ -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:
@@ -82,7 +89,12 @@ services:
MEDIA_ROOT: ${MEDIA_ROOT:-/app/media}
# Версия инстанса (релиз v0.0.1) — install.sh копирует значение
# из файла VERSION (корень репозитория) в .env; отдаётся в GET /api/health.
VIDCONF_VERSION: ${VIDCONF_VERSION:-0.0.4}
VIDCONF_VERSION: ${VIDCONF_VERSION:-0.0.27}
# Число процессов 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,7 +132,13 @@ 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
@@ -147,7 +165,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 +199,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,7 +234,7 @@ 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:
@@ -247,7 +265,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,7 +290,7 @@ 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:
@@ -305,7 +323,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 +407,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 +432,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 +487,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 +721,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 +788,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 +934,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`) — переживают пересоздание.

View File

@@ -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

View File

@@ -60,3 +60,59 @@ groups:
# суммаризация встанет (задачи будут копиться в очереди summarize,
# см. также алерт QueueGrowing). Проверить
# `docker compose ps llm` / `llm-gpu` и `docker compose logs llm`.
# Железо хоста (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/ресурсов под нагрузку.

View 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"]

View 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)

View 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

View 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" },
]

View 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"
}
]
}
]
}

View File

@@ -1,6 +1,7 @@
# Конфигурация 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`) — КОНТРАКТ с
@@ -33,6 +34,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`, уровни

View File

@@ -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}' \

View File

@@ -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, то есть без ограничения); участники сверх лимита уходят на следующую страницу пагинации
---

View File

@@ -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
```

View File

@@ -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 и на
проде, а не только для теста — иначе клиенты в вырожденном случае

View File

@@ -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)** — сайзинг под саму
видео-нагрузку (участники/камеры/полоса), на реальных замерах с прода

View 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 | 140169 Мбит/с устойчиво (пик 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 намерены именно под устойчивые
140169 Мбит/с. Если посчитать коэффициент «Мбит/с на ядро» по пиковому числу,
получится оптимистичнее примерно в полтора раза, чем в реальности — расчёт по
такому коэффициенту недооценит нужное железо.
**Что показывает разница двух строк.** Во второй нагрузка выше (участников ×1.6,
подписок ×2.6), а трафик почти вдвое **ниже**, при том что CPU почти не
изменился. Это эффект `adaptiveStream`: клиент подписывается на трек, но получает
битрейт под фактический размер плитки на экране, а невидимые (не помещающиеся на
текущую страницу сетки) треки почти не занимают полосы. Всё, что дальше в этом
документе, посчитано **для конфигурации с `adaptiveStream`** (релиз ≥0.0.13, в
проекте включён с этой версии по умолчанию) — без него числа нужно умножать в
разы, см. следующий раздел.
## Почему нельзя считать «все видят всех»
SFU (LiveKit) пересылает пакеты, а не микширует их. Наивная формула трафика —
`N × (N1) × битрейт` (каждый участник получает поток от каждого) — при 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 вообще
годится для конференций на десятки участников.
Аудио в этой формуле — единицы процентов трафика: микрофоны обычно включены у
25 человек одновременно, замьюченный трек полосу не занимает, DTX/RED включены
по умолчанию. Дальше считаем аудио отдельным слагаемым, не путая с видео.
## Формула
```
1. подписок = камер × (участников 1)
(если включён лимит плиток в настройках инстанса — camер заменить на min(камер, лимит))
2. видео = подписок × битрейт_на_подписку
битрейт_на_подписку зависит от того, помещаются ли все плитки на одну страницу:
- крупная плитка / говорящий в фокусе (мало плиток на экране) → 450 кбит/с
- мелкая плитка сетки, все участники на одной странице → 150 кбит/с
- сетка с пагинацией (участников больше лимита плиток) → 100 кбит/с
(часть подписок физически не на экране — почти не потребляет полосы)
3. аудио = активных_микрофонов × участников × 40 кбит/с
4. исходящая полоса сервера = видео + аудио ← главный параметр, см. ниже
5. ядер CPU (LiveKit) ≈ max(2, ⌈исходящая_полоса_Мбит/с ÷ 90⌉)
+ 12 ядра на остальной стек (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: 140169 Мбит/с устойчиво ÷ 1.62 ядра =
86104 Мбит/с/ядро, округлено вниз (консервативно, в пользу большего числа
ядер).
- Минимум 2 ядра и запас 12 ядра на остальной стек — эмпирический пол: на
обоих реальных замерах 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 ядра | 46 ГБ | полоса и её цена у хостера |
| Смешанная нагрузка | «Малая команда» + «Совещание» одновременно на одном сервере | — | — | ~127 Мбит/с | 2 ядра | 4 ГБ | суммируется линейно |
Малая команда и совещание укладываются в базовый пресет инсталлятора без AI
(`4 vCPU / 8 ГБ`) с большим запасом — это ровно тот масштаб, что подтверждён
якорным замером (30 чел./27 камер на этом же железе, CPU занят на 46%). Большое
собрание уже требует железа **больше** базового пресета — 3 ядра под сам
LiveKit плюс 12 под остальной стек означают, что 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.31.5 Мбит/с` (зависит от «Потолка
качества публикации», 0.0.21) — и **не** умножается на число зрителей. Для
собрания на 75 человек с 15 камерами это 4.522.5 Мбит/с входящих — заметно
меньше 182 Мбит/с исходящих, узким местом почти никогда не становится.
Отдельно — канал **самого клиента**, не сервера. Офис, откуда заходит половина
участников совещания, может упереться в свой исходящий/входящий канал раньше,
чем сервер упрётся в свой. Серверный сайзинг эту часть не решает — это забота
сетевой инфраструктуры на стороне участников.
## Оговорка про канал и его стоимость
На реалистичных профилях (кроме экстремальных, см. выше) узким местом
оказывается почти всегда не CPU — оба реальных замера показали комфортный
запас (1.61.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⌉) + 12` на остальной стек.
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, порты, что открыть в файрволе под
медиа-трафик.

View File

@@ -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`.

View File

@@ -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`
(пресеты 35) — по умолчанию (профили `media,monitoring`, без AI) правило

View File

@@ -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

View File

@@ -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'
@@ -32,8 +32,17 @@ export interface SettingsOut {
registration_team_choice: boolean
/** Включена ли верификация регистрации по домену корпоративной почты. */
registration_email_domain_enabled: boolean
/** Эталонный домен для верификации (напр. `company.ru`) — `null`, если верификация выключена. */
registration_email_domain: string | null
/** Эталонные домены для верификации (напр. `['company.ru']`) — email подходит, если
* совпадает с любым из списка; пуст, если верификация выключена. */
registration_email_domains: string[]
/** Включён ли контактный адрес инстанса (подставляется в `Reply-To` исходящих писем). */
contact_email_enabled: boolean
/** Контактный адрес — `null`, если не задан/выключен. */
contact_email: string | null
/** Потолок качества исходящего видео публикующего — см. `PublishQualityCap`. */
publish_quality_cap: PublishQualityCap
/** Максимум одновременно видимых плиток сцены (`StageGrid`). */
stage_max_tiles: number
}
/** Тело частичного обновления настроек инстанса — все поля опциональны. */
@@ -45,9 +54,29 @@ export interface SettingsUpdateIn {
summary_recipients?: SummaryRecipientsMode
display_timezone?: string
registration_team_choice?: boolean
/** Включение без домена или невалидный домен — backend отвечает 400. */
/** Включение с пустым списком или невалидным доменом — backend отвечает 400. */
registration_email_domain_enabled?: boolean
registration_email_domain?: string | null
registration_email_domains?: string[]
/** Включение без email или невалидный email — backend отвечает 400. */
contact_email_enabled?: boolean
contact_email?: string | null
publish_quality_cap?: PublishQualityCap
stage_max_tiles?: number
}
/** Тело запроса тестовой отправки письма (`POST /admin/settings/test-email`). */
export interface TestEmailIn {
/** Не задан — backend отправит на email текущего администратора. */
to?: string | null
}
/** Результат тестовой отправки — успех или текст ошибки транспорта. */
export interface TestEmailOut {
success: boolean
message: string
/** Хост/порт SMTP — только при `EMAIL_BACKEND=smtp`, без логина/пароля. */
smtp_host: string | null
smtp_port: number | null
}
/** Конференция в ответе админ-списка — `ConferenceOut` + сведения о владельце. */
@@ -114,6 +143,8 @@ export type AdminUserDetailOut = AdminUserOut
/** Параметры выборки списка пользователей. */
export interface AdminUserQuery {
/** Фильтр по блокировке — без параметра отдаются все пользователи. */
status?: 'active' | 'blocked'
q?: string
limit?: number
offset?: number
@@ -198,7 +229,7 @@ export async function sendConferenceInvitations(id: string, emails?: string[]):
/** Список пользователей для админки — с поиском и пагинацией. */
export async function listAdminUsers(query: AdminUserQuery = {}): Promise<PagedResult<AdminUserOut>> {
const qs = toQueryString({ q: query.q, limit: query.limit, offset: query.offset })
const qs = toQueryString({ status: query.status, q: query.q, limit: query.limit, offset: query.offset })
return apiRequest<PagedResult<AdminUserOut>>(`/admin/users${qs}`)
}
@@ -238,6 +269,14 @@ export async function updateAdminSettings(payload: SettingsUpdateIn): Promise<Se
return apiRequest<SettingsOut>('/admin/settings', { method: 'PUT', body: payload })
}
/**
* Отправить тестовое письмо синхронно — проверка почтовой конфигурации.
* Результат приходит в теле ответа (`success`/`message`), не через HTTP-статус ошибки.
*/
export async function sendTestEmail(payload: TestEmailIn = {}): Promise<TestEmailOut> {
return apiRequest<TestEmailOut>('/admin/settings/test-email', { method: 'POST', body: payload })
}
/** Список команд для админки — с пагинацией. */
export async function listAdminTeams(query: AdminTeamQuery = {}): Promise<PagedResult<TeamOut>> {
const qs = toQueryString({ limit: query.limit, offset: query.offset })

View File

@@ -21,8 +21,8 @@ export interface RegistrationTeamOption {
export interface RegistrationOptions {
team_choice_enabled: boolean
teams: RegistrationTeamOption[]
/** Эталонный домен почты при включённой верификации, иначе `null`. */
email_domain: string | null
/** Эталонные домены почты при включённой верификации (email подходит под любой), иначе пуст. */
email_domains: string[]
}
export interface CurrentUser {

View File

@@ -19,6 +19,15 @@ export type RecurrenceType = 'weekly' | 'biweekly' | 'monthly' | 'every_n_days'
*/
export type SummaryRecipientsMode = 'all' | 'owner'
/**
* Потолок качества исходящего видео участника (`instance_settings.media_limits`,
* см. `SettingsOut`/`SettingsUpdateIn` в `src/api/admin.ts`). `off` — без
* ограничения. Применяется на клиенте через `publishDefaults`
* (`lib/publishQualityCap.ts`) — режет битрейт верхнего слоя симулкаста, а не
* жёсткое разрешение захвата камеры.
*/
export type PublishQualityCap = 'off' | '180p' | '360p' | '720p'
/**
* Правило повторения закреплённой конференции — форма 1:1 с pydantic-моделью
* `backend/services/recurrence.py::RecurrenceRule` (истина о форме — там).
@@ -52,6 +61,10 @@ export interface ConferenceJoinData {
conference_id: string
/** Включён ли чат для этой конференции — при `false` панель/кнопка чата не рендерятся. */
chat_enabled: boolean
/** Потолок качества публикации видео на момент входа — см. `PublishQualityCap`. */
publish_quality_cap: PublishQualityCap
/** Максимум одновременно видимых плиток сцены (`StageGrid`) на момент входа. */
stage_max_tiles: number
}
/**
@@ -145,6 +158,14 @@ export interface ConferenceGuestJoinPayload {
password?: string
}
/** Источник трека, который организатор может принудительно выключить (задача B2). */
export type MuteSource = 'microphone' | 'camera'
/** Ответ на принудительный мьют — `false`, если трек и так не был опубликован (нечего было мьютить). */
export interface MuteParticipantResult {
muted: boolean
}
/** Тело частичного обновления конференции — те же поля, что и при создании, все опциональны. */
export type ConferenceUpdatePayload = Partial<ConferenceCreatePayload>
@@ -202,6 +223,23 @@ export async function guestJoinConference(
})
}
/**
* Принудительно выключить микрофон/камеру участника (задача B2) — только
* владелец конференции/администратор, иначе 403 (`not_owner`). 404
* (`participant_not_in_room`) — участника с таким `identity` сейчас нет в
* комнате LiveKit.
*/
export async function muteParticipant(
conferenceId: string,
identity: string,
source: MuteSource,
): Promise<MuteParticipantResult> {
return apiRequest<MuteParticipantResult>(`/conferences/${conferenceId}/mute-participant`, {
method: 'POST',
body: { identity, source },
})
}
/** Список «моих» конференций — закреплённые (повторяющиеся) и предстоящие разовые владельца. */
export async function getMyConferences(): Promise<ConferenceOut[]> {
return apiRequest<ConferenceOut[]>('/conferences/my')

View File

@@ -18,10 +18,10 @@ import { formatRecurrenceSummary } from '@/lib/recurrenceFormat'
const PAGE_SIZE = 10
const STATUS_FILTERS: { value: ConferenceStatus | 'all'; label: string }[] = [
{ value: 'all', label: 'Все' },
{ value: 'scheduled', label: 'Запланированные' },
{ value: 'active', label: 'Активные' },
{ value: 'ended', label: 'Завершённые' },
{ value: 'all', label: 'Все' },
]
function StatusBadge({ conference }: { conference: AdminConferenceOut }) {
@@ -287,7 +287,7 @@ function InviteModal({ conference, onClose }: { conference: AdminConferenceOut;
* блок «table-card», адаптированный под сущность «конференция»).
*/
export function AdminConferencesTab() {
const [statusFilter, setStatusFilter] = useState<ConferenceStatus | 'all'>('all')
const [statusFilter, setStatusFilter] = useState<ConferenceStatus | 'all'>('scheduled')
const [searchInput, setSearchInput] = useState('')
const [search, setSearch] = useState('')
const [offset, setOffset] = useState(0)

View File

@@ -1,9 +1,18 @@
import { useState } from 'react'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { AlertTriangle } from 'lucide-react'
import { getAdminSettings, updateAdminSettings, type AiLevel, type SettingsOut, type SettingsUpdateIn } from '@/api/admin'
import type { SummaryRecipientsMode } from '@/api/conferences'
import { AlertTriangle, X } from 'lucide-react'
import {
getAdminSettings,
sendTestEmail,
updateAdminSettings,
type AiLevel,
type SettingsOut,
type SettingsUpdateIn,
type TestEmailOut,
} from '@/api/admin'
import type { PublishQualityCap, SummaryRecipientsMode } from '@/api/conferences'
import { ApiError, errorDetail } from '@/api/client'
import { useAuth } from '@/auth/useAuth'
import { Select } from '@/components/ui/Select'
import { useToast } from '@/components/ui/ToastProvider'
@@ -12,6 +21,20 @@ const SUMMARY_RECIPIENTS_OPTIONS = [
{ value: 'owner', label: 'Только организатору' },
]
const PUBLISH_QUALITY_CAP_OPTIONS = [
{ value: 'off', label: 'Без ограничения' },
{ value: '720p', label: 'Не выше 720p' },
{ value: '360p', label: 'Не выше 360p' },
{ value: '180p', label: 'Не выше 180p' },
]
const STAGE_MAX_TILES_OPTIONS = [
{ value: '25', label: '25 (5×5, без ограничения)' },
{ value: '16', label: '16 (4×4)' },
{ value: '9', label: '9 (3×3)' },
{ value: '4', label: '4 (2×2)' },
]
const AI_LEVEL_LABEL: Record<AiLevel, string> = {
min: 'Минимальный (CPU, faster-whisper small + Qwen2.5-3B)',
medium: 'Средний',
@@ -42,6 +65,7 @@ export function AdminSettingsTab() {
function AdminSettingsForm({ data }: { data: SettingsOut }) {
const queryClient = useQueryClient()
const toast = useToast()
const { user } = useAuth()
const [chatEnabled, setChatEnabled] = useState(data.chat_enabled)
const [aiEnabled, setAiEnabled] = useState(data.transcription_enabled)
@@ -50,7 +74,14 @@ function AdminSettingsForm({ data }: { data: SettingsOut }) {
const [timezone, setTimezone] = useState(data.display_timezone)
const [teamChoiceEnabled, setTeamChoiceEnabled] = useState(data.registration_team_choice)
const [domainVerificationEnabled, setDomainVerificationEnabled] = useState(data.registration_email_domain_enabled)
const [emailDomain, setEmailDomain] = useState(data.registration_email_domain ?? '')
const [emailDomains, setEmailDomains] = useState(data.registration_email_domains)
const [newDomainInput, setNewDomainInput] = useState('')
const [contactEmailEnabled, setContactEmailEnabled] = useState(data.contact_email_enabled)
const [contactEmail, setContactEmail] = useState(data.contact_email ?? '')
const [publishQualityCap, setPublishQualityCap] = useState<PublishQualityCap>(data.publish_quality_cap)
const [stageMaxTiles, setStageMaxTiles] = useState(data.stage_max_tiles)
const [testEmailTo, setTestEmailTo] = useState('')
const [testEmailResult, setTestEmailResult] = useState<TestEmailOut | null>(null)
const mutation = useMutation({
mutationFn: (payload: SettingsUpdateIn) => updateAdminSettings(payload),
@@ -67,17 +98,63 @@ function AdminSettingsForm({ data }: { data: SettingsOut }) {
},
})
const testEmailMutation = useMutation({
mutationFn: () => sendTestEmail(testEmailTo.trim() ? { to: testEmailTo.trim() } : {}),
onSuccess: (result) => setTestEmailResult(result),
onError: (err: unknown) => {
setTestEmailResult({
success: false,
message: (err instanceof ApiError && errorDetail(err)) || 'Не удалось отправить тестовое письмо',
smtp_host: null,
smtp_port: null,
})
},
})
function addDomain() {
const domain = newDomainInput.trim().toLowerCase().replace(/^@/, '')
if (!domain || emailDomains.includes(domain)) {
setNewDomainInput('')
return
}
setEmailDomains([...emailDomains, domain])
setNewDomainInput('')
}
function removeDomain(domain: string) {
setEmailDomains(emailDomains.filter((d) => d !== domain))
}
function handleSave() {
mutation.mutate({
chat_enabled: chatEnabled,
transcription_enabled: aiEnabled,
ai_level: aiLevel,
summary_recipients: recipients,
display_timezone: timezone.trim(),
registration_team_choice: teamChoiceEnabled,
registration_email_domain_enabled: domainVerificationEnabled,
registration_email_domain: emailDomain.trim() || null,
})
// Отправляем только реально изменённые поля (`SettingsUpdateIn` — набор
// опциональных полей именно для этого): иначе, например, ai_level
// уходил бы в PUT нетронутым при каждом сохранении и на слабом железе
// валился бы в 400, блокируя правку вообще любой другой настройки.
const payload: SettingsUpdateIn = {}
if (chatEnabled !== data.chat_enabled) payload.chat_enabled = chatEnabled
if (aiEnabled !== data.transcription_enabled) payload.transcription_enabled = aiEnabled
if (aiLevel !== data.ai_level) payload.ai_level = aiLevel
if (recipients !== data.summary_recipients) payload.summary_recipients = recipients
if (timezone.trim() !== data.display_timezone) payload.display_timezone = timezone.trim()
if (teamChoiceEnabled !== data.registration_team_choice) {
payload.registration_team_choice = teamChoiceEnabled
}
if (domainVerificationEnabled !== data.registration_email_domain_enabled) {
payload.registration_email_domain_enabled = domainVerificationEnabled
}
if (JSON.stringify(emailDomains) !== JSON.stringify(data.registration_email_domains)) {
payload.registration_email_domains = emailDomains
}
if (contactEmailEnabled !== data.contact_email_enabled) {
payload.contact_email_enabled = contactEmailEnabled
}
const trimmedContactEmail = contactEmail.trim() || null
if (trimmedContactEmail !== (data.contact_email ?? null)) {
payload.contact_email = trimmedContactEmail
}
if (publishQualityCap !== data.publish_quality_cap) payload.publish_quality_cap = publishQualityCap
if (stageMaxTiles !== data.stage_max_tiles) payload.stage_max_tiles = stageMaxTiles
mutation.mutate(payload)
}
return (
@@ -200,19 +277,53 @@ function AdminSettingsForm({ data }: { data: SettingsOut }) {
<section className="settings-card">
<h2>Эталон mail-домена</h2>
<p className="desc">Домен, с которым сверяется email при регистрации, если включена верификация в «Модулях».</p>
<p className="desc">Домены, с любым из которых сверяется email при регистрации, если включена верификация в «Модулях».</p>
<div className="settings-card-body settings-card-body--center">
<div className="settings-card-body">
{emailDomains.length > 0 && (
<div className="participants-chips">
{emailDomains.map((domain) => (
<span className="participant-chip" key={domain}>
{domain}
<button
type="button"
className="participant-chip-remove"
aria-label={`Убрать домен: ${domain}`}
onClick={() => removeDomain(domain)}
disabled={!domainVerificationEnabled}
>
<X style={{ width: 12, height: 12 }} aria-hidden="true" />
</button>
</span>
))}
</div>
)}
<div className="field" style={{ marginBottom: 0 }}>
<label htmlFor="settings-email-domain">Домен корпоративной почты</label>
<input
id="settings-email-domain"
type="text"
placeholder="company.ru"
value={emailDomain}
disabled={!domainVerificationEnabled}
onChange={(e) => setEmailDomain(e.target.value)}
/>
<div style={{ display: 'flex', gap: 'var(--space-2)' }}>
<input
id="settings-email-domain"
type="text"
placeholder="company.ru"
value={newDomainInput}
disabled={!domainVerificationEnabled}
onChange={(e) => setNewDomainInput(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault()
addDomain()
}
}}
/>
<button
type="button"
className="btn btn-secondary"
disabled={!domainVerificationEnabled || !newDomainInput.trim()}
onClick={addDomain}
>
Добавить
</button>
</div>
</div>
</div>
</section>
@@ -268,6 +379,125 @@ function AdminSettingsForm({ data }: { data: SettingsOut }) {
</div>
</section>
<section className="settings-card">
<h2>Нагрузка</h2>
<p className="desc">
Рычаги для инстансов на слабом канале/железе режут исходящий трафик и нагрузку на
устройство участника. Дефолты сохраняют прежнее поведение (без ограничений).
</p>
<div className="settings-card-body settings-card-body--spread">
<div className="field" style={{ marginBottom: 0 }}>
<label id="settings-quality-cap-label" htmlFor="settings-quality-cap">
Потолок качества публикации видео
</label>
<Select
id="settings-quality-cap"
aria-labelledby="settings-quality-cap-label"
value={publishQualityCap}
onChange={(v) => setPublishQualityCap(v as PublishQualityCap)}
options={PUBLISH_QUALITY_CAP_OPTIONS}
/>
<p className="field-hint">
Ограничивает битрейт исходящего видео публикующего снижает нагрузку на его канал и
устройство, независимо от размера плитки у смотрящих (adaptiveStream режет с их
стороны отдельно)
</p>
</div>
<div className="field" style={{ marginBottom: 0 }}>
<label id="settings-max-tiles-label" htmlFor="settings-max-tiles">
Максимум плиток на экране
</label>
<Select
id="settings-max-tiles"
aria-labelledby="settings-max-tiles-label"
value={String(stageMaxTiles)}
onChange={(v) => setStageMaxTiles(Number(v))}
options={STAGE_MAX_TILES_OPTIONS}
/>
<p className="field-hint">
Сверх лимита участники уходят на следующую страницу сетки вместо подписки меньше
одновременных видеопотоков на канал и экран участника
</p>
</div>
</div>
</section>
<section className="settings-card">
<h2>Контактный адрес</h2>
<p className="desc">
Адрес для ответов на письма от инстанса (уходят от no-reply этот адрес подставляется
в заголовок «Reply-To»).
</p>
<div className="settings-card-body">
<div className="toggle-row" style={{ borderTop: 'none', paddingTop: 0 }}>
<div className="toggle-copy">
<strong>Контактный адрес включён</strong>
<span>Подставляется в Reply-To подтверждения регистрации, приглашений и саммари</span>
</div>
<label className="switch">
<input
type="checkbox"
checked={contactEmailEnabled}
onChange={(e) => setContactEmailEnabled(e.target.checked)}
/>
<span className="slider" />
</label>
</div>
<div className="field" style={{ marginBottom: 0, marginTop: 'var(--space-4)' }}>
<label htmlFor="settings-contact-email">Email</label>
<input
id="settings-contact-email"
type="email"
placeholder="contact@vidconf.ru"
value={contactEmail}
disabled={!contactEmailEnabled}
onChange={(e) => setContactEmail(e.target.value)}
/>
</div>
</div>
</section>
<section className="settings-card">
<h2>Тестовое письмо</h2>
<p className="desc">Отправить проверочное письмо синхронно, чтобы сразу увидеть результат почтовой конфигурации.</p>
<div className="settings-card-body">
<div className="field">
<label htmlFor="settings-test-email-to">Получатель</label>
<input
id="settings-test-email-to"
type="email"
placeholder={user?.email ?? 'you@example.com'}
value={testEmailTo}
onChange={(e) => setTestEmailTo(e.target.value)}
/>
</div>
<button
type="button"
className="btn btn-secondary"
disabled={testEmailMutation.isPending}
onClick={() => testEmailMutation.mutate()}
>
{testEmailMutation.isPending ? 'Отправляем…' : 'Отправить тестовое письмо'}
</button>
{testEmailResult && (
<p
className="field-hint"
style={{ color: testEmailResult.success ? 'var(--color-success)' : 'var(--color-danger)' }}
>
{testEmailResult.message}
{testEmailResult.smtp_host && ` (${testEmailResult.smtp_host}:${testEmailResult.smtp_port})`}
</p>
)}
</div>
</section>
<div className="settings-actions">
<button type="button" className="btn btn-primary" disabled={mutation.isPending} onClick={handleSave}>
{mutation.isPending ? 'Сохраняем…' : 'Сохранить настройки'}

View File

@@ -13,6 +13,12 @@ const PAGE_SIZE = 10
/** Верхний предел выборки команд для селекта — без отдельной пагинации в этом контексте. */
const TEAMS_LIMIT = 200
const STATUS_FILTERS: { value: 'active' | 'blocked' | 'all'; label: string }[] = [
{ value: 'active', label: 'Активные' },
{ value: 'blocked', label: 'Заблокированные' },
{ value: 'all', label: 'Все' },
]
/**
* Вкладка «Пользователи» админки — список с поиском, пагинацией, сменой
* роли, блокировкой и командой (design/mockups/admin.html,
@@ -28,6 +34,7 @@ const TEAMS_LIMIT = 200
*/
export function AdminUsersTab() {
const { user: currentUser } = useAuth()
const [statusFilter, setStatusFilter] = useState<'active' | 'blocked' | 'all'>('active')
const [searchInput, setSearchInput] = useState('')
const [search, setSearch] = useState('')
const [offset, setOffset] = useState(0)
@@ -45,8 +52,14 @@ export function AdminUsersTab() {
}, [searchInput])
const { data, isLoading } = useQuery({
queryKey: ['admin', 'users', search, offset],
queryFn: () => listAdminUsers({ q: search || undefined, limit: PAGE_SIZE, offset }),
queryKey: ['admin', 'users', statusFilter, search, offset],
queryFn: () =>
listAdminUsers({
status: statusFilter === 'all' ? undefined : statusFilter,
q: search || undefined,
limit: PAGE_SIZE,
offset,
}),
})
const { data: teamsData } = useQuery({
@@ -89,13 +102,29 @@ export function AdminUsersTab() {
return (
<>
<div className="section-tabs-inline">
{STATUS_FILTERS.map((f) => (
<button
key={f.value}
type="button"
className={`chip${statusFilter === f.value ? ' is-active' : ''}`}
onClick={() => {
setStatusFilter(f.value)
setOffset(0)
}}
>
{f.label}
</button>
))}
</div>
<div className="toolbar-row">
<div className="toolbar-left">
<div className="search-wrap">
<Search className="icon" style={{ width: 16, height: 16 }} aria-hidden="true" />
<input
type="text"
placeholder="Поиск по имени или email…"
placeholder="Поиск по имени, email или команде…"
value={searchInput}
onChange={(e) => setSearchInput(e.target.value)}
/>

View File

@@ -1,4 +1,5 @@
import type { ReactNode } from 'react'
import { AppFooter } from '@/components/layout/AppFooter'
import { LogoMark } from '@/components/ui/LogoMark'
import { ThemeToggle } from '@/components/ui/ThemeToggle'
import '@/styles/auth.css'
@@ -40,12 +41,16 @@ export function AuthLayout({ children }: { children: ReactNode }) {
</div>
</div>
</div>
<p className="hint-msg" style={{ position: 'relative', zIndex: 1 }}>
© 2026 VidConf · self-hosted
</p>
</aside>
<main className="form-panel">{children}</main>
{/* Копирайт с годом и версией инстанса — общий `AppFooter` (он же на всех
остальных страницах). Строку «© 2026 VidConf · self-hosted» из
брендовой панели при этом убрали совсем (решение оператора): год в ней
был захардкожен, а копирайт теперь живёт в футере. */}
<main className="form-panel">
{children}
<AppFooter />
</main>
</div>
)
}

View File

@@ -98,6 +98,57 @@ function renderEventContent(arg: EventContentArg) {
/** «г.» — сноска Intl.DateTimeFormat('ru-RU', ...) после года при формате year+month(+day); в подписи периода не нужна. */
const YEAR_SUFFIX_RE = /\s*г\.(?=\s|$)/g
/**
* Подписи дней в самой сетке, из которых надо убрать «г.» (см.
* `useStripYearSuffix`): месячный вид — номер дня в ячейке, недельный —
* подпись колонки дня.
*/
const DAY_LABEL_SELECTOR = '.fc-daygrid-day-number, .fc-col-header-cell-cushion'
/** Убирает «г.» из `aria-label`/`title` элемента; ничего не пишет, если менять нечего. */
function stripYearSuffixAttrs(el: Element) {
for (const attr of ['aria-label', 'title']) {
const value = el.getAttribute(attr)
if (!value) continue
const cleaned = value.replace(YEAR_SUFFIX_RE, '')
if (cleaned !== value) el.setAttribute(attr, cleaned)
}
}
/**
* Убирает «г.» из служебных подписей дней внутри сетки FullCalendar.
*
* Кроме подписи периода (её формат мы задаём сами, см. `buildTitleFormatter`),
* дату дня FullCalendar подставляет ещё и в подпись ячейки — она видна в
* accessibility-дереве и как всплывающая подсказка. Формат этой подписи
* библиотека наружу НЕ отдаёт: `buildNavLinkAttrs` (@fullcalendar/core)
* форматирует дату жёстко зашитым `{year,month:'long',day:'numeric'}` и кладёт
* результат в `aria-label` (в `title` — когда включены navLinks), а с локалью
* `ru` Intl всегда даёт «20 июля 2026 г.». Настройкой это не убирается,
* поэтому правим уже отрендеренный атрибут.
*
* Почему MutationObserver, а не хуки `dayCellDidMount`/`dayHeaderDidMount`:
* при переключении недельного вида на месячный FullCalendar ПЕРЕИСПОЛЬЗУЕТ
* уже смонтированные ячейки — `didMount` для них повторно не зовётся, а
* подпись внутри перерисовывается заново, и «г.» возвращается (поймали на
* живой проверке: после переключения вида ровно строка переиспользованных
* ячеек оставалась с «г.»). Наблюдатель же ловит любую перерисовку.
* Зацикливания нет: `stripYearSuffixAttrs` пишет атрибут, только если он
* реально меняется, поэтому вызванная нашей же правкой итерация ничего не
* пишет и цепочка обрывается.
*/
function useStripYearSuffix(rootRef: React.RefObject<HTMLElement | null>) {
useEffect(() => {
const root = rootRef.current
if (!root) return
const clean = () => root.querySelectorAll(DAY_LABEL_SELECTOR).forEach(stripYearSuffixAttrs)
clean()
const observer = new MutationObserver(clean)
observer.observe(root, { subtree: true, childList: true, attributeFilter: ['aria-label', 'title'] })
return () => observer.disconnect()
}, [rootRef])
}
/**
* Форма аргумента, который FullCalendar передаёт функции-`titleFormat`
* (`VerboseFormattingArg` из @fullcalendar/core/internal-common — не
@@ -152,6 +203,9 @@ export function ConferenceCalendar({
}: ConferenceCalendarProps) {
const calendarRef = useRef<FullCalendar | null>(null)
const titleRef = useRef<HTMLSpanElement | null>(null)
const gridRef = useRef<HTMLDivElement | null>(null)
useStripYearSuffix(gridRef)
// См. комментарий у `latestOnEditClick` в начале файла — синхронизируем
// переменную вместо того, чтобы замыкать пропс напрямую в renderEventContent.
@@ -269,7 +323,7 @@ export function ConferenceCalendar({
</div>
</div>
<div className="vc-calendar" aria-busy={isLoading}>
<div className="vc-calendar" aria-busy={isLoading} ref={gridRef}>
<FullCalendar
ref={calendarRef}
plugins={CALENDAR_PLUGINS}

View File

@@ -0,0 +1,34 @@
import { useQuery } from '@tanstack/react-query'
import { getHealth } from '@/api/health'
import '@/styles/footer.css'
/**
* Общий футер приложения: версия инстанса и копирайт со ссылкой на автора.
*
* Ставится на всех страницах, КРОМЕ комнаты конференции (`RoomPage`) — там
* полноэкранное видео и тулбар, футеру места нет. Раскладок в приложении
* несколько (`page-shell`, `AuthLayout`, `join-shell`, `lobby-shell`), поэтому
* компонент рендерит только собственный `<footer>`, а привязку к конкретной
* раскладке делает CSS (см. `styles/footer.css`).
*
* Версия — из публичного `GET /api/health` (авторизации не требует, годится и
* для экранов входа). Поле `version` опционально: если backend его не отдал,
* версию просто не показываем (см. `HealthOut.version`). Ключ запроса тот же
* `['health']`, что и у админки, — react-query отдаст общий закэшированный
* результат, лишнего запроса не будет.
*
* Год — текущий, из `Date` (не захардкожен): страница живёт в браузере
* пользователя и не должна устаревать 1 января.
*/
export function AppFooter() {
const { data: health } = useQuery({ queryKey: ['health'], queryFn: getHealth, retry: false })
return (
<footer className="app-footer">
VidConf{health?.version ? ` · v${health.version}` : ''} · © {new Date().getFullYear()} · by{' '}
<a href="https://github.com/MaxRonzhin" target="_blank" rel="noopener">
MaxRonzhin
</a>
</footer>
)
}

View File

@@ -1,7 +1,21 @@
import { useEffect, useRef, useState, type ChangeEvent, type KeyboardEvent } from 'react'
import { AlertCircle, Send, X } from 'lucide-react'
import { AlertCircle, Send, SmilePlus, X } from 'lucide-react'
import type { ChatConnectionStatus, ChatMessageOut } from '@/hooks/useChat'
import { formatLocalTime } from '@/lib/localTime'
import { useModalDismiss } from '@/hooks/useModalDismiss'
/**
* Небольшой собственный набор популярных эмодзи — вместо библиотеки-пикера на
* сотни килобайт ради десятка кнопок в поповере. Ровно 30 штук (6×5 —
* `.chat-emoji-popover` в room.css рассчитан на эту сетку без остатка;
* добавляя/убирая эмодзи, держи кратность 5).
*/
const EMOJI_OPTIONS = [
'😀', '😂', '😊', '😉', '😍', '🤔', '😅', '😢',
'😮', '😎', '🙌', '👍', '👎', '👏', '🙏', '❤️',
'🔥', '🎉', '✅', '❌', '⚠️', '💡', '👀', '🤝',
'🐎', '🦾', '🚀', '🦞', '💯', '🤷‍♂️',
]
interface ChatPanelProps {
messages: ChatMessageOut[]
@@ -38,12 +52,21 @@ export function ChatPanel({ messages, status, statusMessage, onSend, onClose }:
}
}, [messages])
// Авторасширение textarea до 4 строк, дальше — внутренний скролл. Эффектом,
// а не только в обработчике ввода, — так пересчёт срабатывает и после
// программной вставки эмодзи, и после очистки поля отправкой.
useEffect(() => {
const el = textareaRef.current
if (!el) return
el.style.height = 'auto'
el.style.height = `${Math.min(el.scrollHeight, 96)}px`
}, [draft])
function handleSend() {
const text = draft.trim()
if (!text || status !== 'open') return
onSend(text)
setDraft('')
if (textareaRef.current) textareaRef.current.style.height = 'auto'
}
function handleKeyDown(event: KeyboardEvent<HTMLTextAreaElement>) {
@@ -55,10 +78,27 @@ export function ChatPanel({ messages, status, statusMessage, onSend, onClose }:
function handleDraftChange(event: ChangeEvent<HTMLTextAreaElement>) {
setDraft(event.target.value)
// Авторасширение textarea до 4 строк, дальше — внутренний скролл.
const el = event.target
el.style.height = 'auto'
el.style.height = `${Math.min(el.scrollHeight, 96)}px`
}
// Вставка эмодзи в позицию курсора, а не в конец поля, — ожидаемое поведение
// при редактировании текста в середине.
function insertEmoji(emoji: string) {
const el = textareaRef.current
if (!el) {
setDraft((current) => current + emoji)
return
}
const start = el.selectionStart ?? draft.length
const end = el.selectionEnd ?? draft.length
setDraft(draft.slice(0, start) + emoji + draft.slice(end))
// value обновится реальным React-рендером только на следующий тик —
// курсор возвращаем после него, иначе setSelectionRange встанет на
// ещё старую (более короткую) строку.
requestAnimationFrame(() => {
const pos = start + emoji.length
el.focus()
el.setSelectionRange(pos, pos)
})
}
return (
@@ -101,6 +141,7 @@ export function ChatPanel({ messages, status, statusMessage, onSend, onClose }:
handleSend()
}}
>
<EmojiPickerButton onSelect={insertEmoji} disabled={status !== 'open'} />
<textarea
ref={textareaRef}
rows={1}
@@ -118,3 +159,77 @@ export function ChatPanel({ messages, status, statusMessage, onSend, onClose }:
</aside>
)
}
interface EmojiPickerButtonProps {
onSelect: (emoji: string) => void
disabled: boolean
}
/**
* Кнопка с поповером выбора эмодзи слева от поля ввода. Поповер открывается
* ВВЕРХ от кнопки (как `.tb-menu` тулбара) — на мобильном чат-панель занята
* полем ввода снизу, а место над тулбаром свободно, так поповер никогда не
* перекрывает textarea.
*/
function EmojiPickerButton({ onSelect, disabled }: EmojiPickerButtonProps) {
const [open, setOpen] = useState(false)
const wrapRef = useRef<HTMLDivElement>(null)
// Закрытие по клику вне — тем же паттерном, что меню «Вид» (StageViewMenu):
// слушатель на document, пока поповер открыт.
useEffect(() => {
if (!open) return
function handlePointerDown(event: MouseEvent) {
if (wrapRef.current && !wrapRef.current.contains(event.target as Node)) {
setOpen(false)
}
}
document.addEventListener('mousedown', handlePointerDown)
return () => document.removeEventListener('mousedown', handlePointerDown)
}, [open])
return (
<div className="chat-emoji-wrap" ref={wrapRef}>
<button
type="button"
className={`chat-emoji-trigger${open ? ' is-open' : ''}`}
aria-label="Вставить эмодзи"
aria-expanded={open}
aria-haspopup="dialog"
disabled={disabled}
onClick={() => setOpen((v) => !v)}
>
<SmilePlus className="lucide" aria-hidden="true" />
</button>
{open && (
<EmojiPopover
onSelect={(emoji) => {
onSelect(emoji)
setOpen(false)
}}
onClose={() => setOpen(false)}
/>
)}
</div>
)
}
interface EmojiPopoverProps {
onSelect: (emoji: string) => void
onClose: () => void
}
/** Отдельный компонент — чтобы `useModalDismiss` (Escape + возврат фокуса) жил ровно то время, пока поповер открыт. */
function EmojiPopover({ onSelect, onClose }: EmojiPopoverProps) {
useModalDismiss(onClose)
return (
<div className="chat-emoji-popover" role="dialog" aria-label="Выбор эмодзи">
{EMOJI_OPTIONS.map((emoji) => (
<button key={emoji} type="button" className="chat-emoji-option" onClick={() => onSelect(emoji)}>
{emoji}
</button>
))}
</div>
)
}

View File

@@ -1,34 +1,80 @@
import { useRef, useState } from 'react'
import { X } from 'lucide-react'
import { useMediaDeviceSelect, usePersistentUserChoices } from '@livekit/components-react'
import { useToast } from '@/components/ui/ToastProvider'
import { useIsCompactViewport } from '@/hooks/useIsCompactViewport'
import { useModalDismiss } from '@/hooks/useModalDismiss'
import { StageViewOptions, type StageViewProps } from '@/components/room/StageViewOptions'
import { isAudioOutputSelectable, saveAudioOutputDeviceId } from '@/lib/audioOutputDevice'
interface DeviceSettingsDialogProps {
interface DeviceSettingsDialogProps extends StageViewProps {
onClose: () => void
}
/** Свайп панели вниз дальше этого порога (px) закрывает шторку, меньше — она возвращается на место. */
const SHEET_DISMISS_THRESHOLD_PX = 80
/** Человекочитаемая подпись пункта списка устройств — `label` пуст, пока нет разрешения на медиа. */
function deviceLabel(device: MediaDeviceInfo, index: number, fallback: string): string {
return device.label || `${fallback} ${index + 1}`
}
/**
* Диалог «Настройки устройств» — два селекта
* на хуках `@livekit/components-react`: список устройств и переключение —
* целиком в `useMediaDeviceSelect` (сама подписана на
* `RoomEvent.MediaDevicesChanged`), персист выбора — в `usePersistentUserChoices`
* (localStorage, читается заново при следующем входе в комнату — см.
* `RoomPage.tsx`, `options` пропс `LiveKitRoom`).
* Панель «Настройки устройств» — три селекта на хуках `@livekit/components-react`:
* список устройств и переключение целиком в `useMediaDeviceSelect` (сама
* подписана на `RoomEvent.MediaDevicesChanged`), персист выбора микрофона/камеры —
* в `usePersistentUserChoices` (localStorage, читается заново при следующем
* входе в комнату — см. `RoomPage.tsx`, `options` пропс `LiveKitRoom`).
* Аудиовыход персистится отдельно (`lib/audioOutputDevice.ts`) — `LocalUserChoices`
* LiveKit про него не знает, а `RoomOptions.audioOutput` читает сохранённый
* `deviceId` при следующем подключении.
*
* На мобильной ширине (`useIsCompactViewport`) рендерится шторкой снизу вместо
* модалки — по клику вне, Escape (`useModalDismiss`) и свайпу вниз по ЛЮБОМУ
* месту панели (не только за ручку-волосок `.room-sheet-handle` — та её не
* пережила бы: 40×4px, попасть пальцем почти нереально). Свайп срабатывает,
* только если содержимое панели проскроллено в самый верх (`scrollTop === 0`
* на touchstart, см. `dragEligibleRef`) — иначе палец должен листать список
* устройств, а не закрывать окно; решение фиксируется на весь жест, а не
* пересчитывается на каждый touchmove, как в любом стандартном bottom sheet.
* Десктоп не меняется.
*
* Там же, и только там, первой секцией идёт «Вид» (режим показа участников и
* скрытие остальных): на мобильном тулбар ужат до пяти кнопок, отдельной
* кнопки «Вид» там нет — см. `RoomToolbar`. Поэтому и заголовок панели на
* мобильном шире по смыслу («Настройки», а не «Настройки устройств»).
*
* ДОЛЖЕН рендериться внутри `<LiveKitRoom>`: `useMediaDeviceSelect` без явно
* переданного `room` берёт активную комнату из `RoomContext` — вне контекста
* он создал бы отдельный, ни с чем не связанный `Room()` и переключал бы
* устройство «в никуда».
*/
export function DeviceSettingsDialog({ onClose }: DeviceSettingsDialogProps) {
export function DeviceSettingsDialog({
onClose,
layoutMode,
onLayoutModeChange,
hideOthers,
onHideOthersChange,
}: DeviceSettingsDialogProps) {
const toast = useToast()
const { saveAudioInputDeviceId, saveVideoInputDeviceId } = usePersistentUserChoices()
const mic = useMediaDeviceSelect({ kind: 'audioinput' })
const camera = useMediaDeviceSelect({ kind: 'videoinput' })
const speaker = useMediaDeviceSelect({ kind: 'audiooutput' })
const speakerSelectable = isAudioOutputSelectable()
const isCompact = useIsCompactViewport()
useModalDismiss(onClose)
const dragStartYRef = useRef<number | null>(null)
// Жест начат, когда контент панели был проскроллен в самый верх — свайп по
// панели, у которой ещё есть что скроллить, должен листать содержимое, а не
// закрывать шторку. Решается ОДИН раз в touchstart и держится весь жест
// (даже если внутри него направление сменится) — так же, как в любом
// стандартном bottom sheet.
const dragEligibleRef = useRef(false)
const [dragOffset, setDragOffset] = useState(0)
const [isDragging, setIsDragging] = useState(false)
async function handleMicChange(deviceId: string) {
try {
@@ -49,22 +95,82 @@ export function DeviceSettingsDialog({ onClose }: DeviceSettingsDialogProps) {
}
}
async function handleSpeakerChange(deviceId: string) {
try {
await speaker.setActiveMediaDevice(deviceId)
saveAudioOutputDeviceId(deviceId)
} catch {
toast.show('Не удалось переключить вывод звука — устройство занято или отключено', 'error')
}
}
function handlePanelTouchStart(e: React.TouchEvent<HTMLDivElement>) {
dragEligibleRef.current = e.currentTarget.scrollTop === 0
if (!dragEligibleRef.current) return
dragStartYRef.current = e.touches[0].clientY
setIsDragging(true)
}
function handlePanelTouchMove(e: React.TouchEvent<HTMLDivElement>) {
if (!dragEligibleRef.current || dragStartYRef.current === null) return
const delta = e.touches[0].clientY - dragStartYRef.current
if (delta > 0) setDragOffset(delta)
}
function handlePanelTouchEnd() {
if (!dragEligibleRef.current) return
if (dragOffset > SHEET_DISMISS_THRESHOLD_PX) {
onClose()
return
}
dragStartYRef.current = null
setIsDragging(false)
setDragOffset(0)
}
return (
<div
className="room-modal-overlay"
className={isCompact ? 'room-sheet-overlay' : 'room-modal-overlay'}
role="dialog"
aria-modal="true"
aria-labelledby="device-settings-title"
onClick={onClose}
>
<div className="room-modal-panel" onClick={(e) => e.stopPropagation()}>
<div
className={isCompact ? 'room-sheet-panel' : 'room-modal-panel'}
onClick={(e) => e.stopPropagation()}
onTouchStart={isCompact ? handlePanelTouchStart : undefined}
onTouchMove={isCompact ? handlePanelTouchMove : undefined}
onTouchEnd={isCompact ? handlePanelTouchEnd : undefined}
style={
isCompact && dragOffset
? { transform: `translateY(${dragOffset}px)`, transition: isDragging ? 'none' : undefined }
: undefined
}
>
{isCompact && <div className="room-sheet-handle" />}
<div className="room-modal-head">
<h2 id="device-settings-title">Настройки устройств</h2>
<h2 id="device-settings-title">{isCompact ? 'Настройки' : 'Настройки устройств'}</h2>
<button type="button" className="room-modal-close" aria-label="Закрыть" onClick={onClose}>
<X className="lucide" style={{ width: 16, height: 16 }} aria-hidden="true" />
</button>
</div>
{/* Обёртка НЕ `.room-field`: там `label { display: block }` со
специфичностью выше, чем у `.stage-view-mode` — пункты режимов
рассыпались бы в столбик (кружок отдельно, текст под ним). */}
{isCompact && (
<div className="stage-view-section">
<StageViewOptions
layoutMode={layoutMode}
onLayoutModeChange={onLayoutModeChange}
hideOthers={hideOthers}
onHideOthersChange={onHideOthersChange}
/>
</div>
)}
<div className="room-field">
<label htmlFor="device-settings-mic">Микрофон</label>
<select
@@ -104,6 +210,34 @@ export function DeviceSettingsDialog({ onClose }: DeviceSettingsDialogProps) {
))}
</select>
</div>
{speakerSelectable ? (
<div className="room-field">
<label htmlFor="device-settings-speaker">Динамики</label>
<select
id="device-settings-speaker"
value={speaker.activeDeviceId}
onChange={(e) => void handleSpeakerChange(e.target.value)}
>
<option value="" disabled>
Определяется
</option>
{speaker.devices.map((device, index) => (
<option key={device.deviceId} value={device.deviceId}>
{deviceLabel(device, index, 'Динамик')}
</option>
))}
</select>
</div>
) : (
<div className="room-field">
<span className="room-field-label">Динамики</span>
<p className="room-field-hint">
Вывод звука (динамик, наушники, Bluetooth) переключается средствами системы этот
браузер не позволяет управлять им со страницы.
</p>
</div>
)}
</div>
</div>
)

View File

@@ -0,0 +1,40 @@
import { useEffect } from 'react'
import { useLocalParticipant } from '@livekit/components-react'
import { useToast } from '@/components/ui/ToastProvider'
import type { ForcedMuteEvent } from '@/hooks/useChat'
/**
* Уведомляет ЛОКАЛЬНОГО участника тостом, когда организатор принудительно
* выключил его микрофон/камеру (задача B2). Рендерится безусловно внутри
* `<LiveKitRoom>` — `useLocalParticipant` недоступен снаружи (`RoomPage`
* сам вне контекста LiveKit, см. докстринг `useIsOrganizer`).
*
* Само выключение трека организатор делает СЕРВЕРНЫМ вызовом LiveKit API
* (`services/room_control.py`) — тулбарные кнопки (useTrackToggle) сами
* отразят новое состояние по родному событию LiveKit `TrackMuted`, этот
* компонент только поясняет ПОЧЕМУ: без тоста человек не отличил бы
* действие организатора от случайного глюка. Участник может включить себя
* обратно сразу тем же тулбаром — сервер это не блокирует (см. докстринг B2
* в CHANGELOG/коммите).
*/
export function ForcedMuteWatcher({ event }: { event: ForcedMuteEvent | null }) {
const { localParticipant } = useLocalParticipant()
const toast = useToast()
useEffect(() => {
if (!event || event.identity !== localParticipant.identity) return
toast.show(
event.source === 'microphone'
? 'Организатор выключил ваш микрофон'
: 'Организатор выключил вашу камеру',
'info',
)
// `event` (включая `nonce`) — единственная зависимость, которая должна
// повторно показывать тост; `localParticipant`/`toast` стабильны в
// рамках подключения и намеренно не входят в список, чтобы их
// пересоздание (если когда-нибудь случится) не дублировало уведомление.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [event])
return null
}

View File

@@ -0,0 +1,99 @@
import { useEffect, useRef, useState } from 'react'
import { Hand, ListOrdered } from 'lucide-react'
import { useIsOrganizer } from '@/hooks/useIsOrganizer'
import type { HandQueueEntry } from '@/hooks/useChat'
interface HandQueueMenuProps {
queue: HandQueueEntry[]
onLower: (identity: string) => void
}
/**
* Кнопка «Очередь» в тулбаре с поповером над ней — видна только организатору
* (задача B1). Тот же самодостаточный паттерн, что и `StageViewMenu` («Вид»):
* собственное состояние открытия, закрытие по клику вне/Escape, поповер
* `.tb-menu` над кнопкой — а не боковая панель на весь экран (как чат):
* очередь рук — короткий список, а не история переписки, разворачивать её
* во весь экран незачем и на мобильном.
*
* Размер поповера подстраивается под число записей — `.hand-queue-list`
* растёт вместе со списком и не даёт пустого места при 12 поднятых руках,
* но не бесконечно: после ~10 строк список упирается в `max-height` и дальше
* скроллится (см. room.css) — иначе организатор на энергичной встрече
* получил бы поповер выше экрана.
*/
export function HandQueueMenu({ queue, onLower }: HandQueueMenuProps) {
const isOrganizer = useIsOrganizer()
const [open, setOpen] = useState(false)
const wrapRef = useRef<HTMLDivElement>(null)
useEffect(() => {
if (!open) return
function handlePointerDown(event: MouseEvent) {
if (wrapRef.current && !wrapRef.current.contains(event.target as Node)) {
setOpen(false)
}
}
function handleKeydown(event: KeyboardEvent) {
if (event.key === 'Escape') setOpen(false)
}
document.addEventListener('mousedown', handlePointerDown)
document.addEventListener('keydown', handleKeydown)
return () => {
document.removeEventListener('mousedown', handlePointerDown)
document.removeEventListener('keydown', handleKeydown)
}
}, [open])
if (!isOrganizer) return null
return (
<div className="tb-menu-wrap" ref={wrapRef}>
<button
type="button"
className={`tb-btn${open ? ' is-panel-open' : ''}`}
aria-pressed={open}
aria-expanded={open}
aria-haspopup="dialog"
aria-label={open ? 'Свернуть очередь поднятых рук' : 'Открыть очередь поднятых рук'}
onClick={() => setOpen((v) => !v)}
>
<span className="icon-shell">
<ListOrdered className="lucide" aria-hidden="true" />
{queue.length > 0 && (
<span className="badge-count">{queue.length > 9 ? '9+' : queue.length}</span>
)}
</span>
<span className="label">Очередь</span>
</button>
{open && (
<div className="tb-menu hand-queue-menu" role="dialog" aria-label="Очередь поднятых рук">
{queue.length === 0 ? (
<p className="chat-empty">Пока никто не поднял руку</p>
) : (
<ol className="hand-queue-list">
{queue.map((entry, index) => (
<li className="hand-queue-item" key={entry.identity}>
<span className="hand-queue-position">{index + 1}</span>
<span className="hand-queue-name">
<Hand className="lucide" aria-hidden="true" />
{entry.name}
</span>
<button
type="button"
className="hand-queue-lower"
onClick={() => onLower(entry.identity)}
>
Опустить
</button>
</li>
))}
</ol>
)}
</div>
)}
</div>
)
}

View File

@@ -1,4 +1,4 @@
import { ScreenShare } from 'lucide-react'
import { Hand, Mic, Pin, PinOff, ScreenShare, Video } from 'lucide-react'
import { Track } from 'livekit-client'
import {
AudioTrack,
@@ -19,21 +19,80 @@ import {
type TrackReferenceOrPlaceholder,
} from '@livekit/components-react'
import { Avatar } from '@/components/ui/Avatar'
import { stageTrackKey } from '@/components/room/stageFocus'
import { parseParticipantMetadata } from '@/lib/participantMetadata'
import { useIsOrganizer } from '@/hooks/useIsOrganizer'
import { useToast } from '@/components/ui/ToastProvider'
import { muteParticipant } from '@/api/conferences'
/** Метаданные участника из LiveKit access-токена (см. `AccessToken.with_metadata` на backend) — JSON `{"avatar_url": "..."}`; у гостей отсутствуют. */
interface ParticipantMetadata {
avatar_url?: string | null
/** Достаёт URL аватара из метаданных участника — `null`, если поля нет (гость/без аватара). */
function parseAvatarUrl(metadata: string | undefined): string | null {
const parsed = parseParticipantMetadata(metadata)
return typeof parsed.avatar_url === 'string' && parsed.avatar_url ? parsed.avatar_url : null
}
/** Разбирает `participant.metadata` в URL аватара — `null`, если поля нет, метаданные пусты или невалидны (гость). */
function parseAvatarUrl(metadata: string | undefined): string | null {
if (!metadata) return null
try {
const parsed = JSON.parse(metadata) as ParticipantMetadata
return typeof parsed.avatar_url === 'string' && parsed.avatar_url ? parsed.avatar_url : null
} catch {
return null
/**
* Кнопки принудительного мьюта организатором (задача B2) — микрофон/камера
* ЧУЖОГО участника. Видны только организатору (`useIsOrganizer`, подсказка
* UI — сервер перепроверяет права по владельцу конференции в БД) и только на
* чужой плитке камеры (на своей — обычный тулбарный toggle, мьютить себя
* через «принудительное» действие не нужно).
*
* Не проверяют текущее состояние мьюта заранее (усложнило бы плитку ради
* малополезной оптимизации): клик по уже выключенному треку — не ошибка, а
* no-op на backend (`muted: false` в ответе, см. `services/room_control.py`).
*/
function OrganizerMuteControls({
conferenceId,
identity,
displayName,
}: {
conferenceId: string
identity: string
displayName: string
}) {
const toast = useToast()
async function handleMute(source: 'microphone' | 'camera') {
try {
const result = await muteParticipant(conferenceId, identity, source)
if (!result.muted) {
toast.show(
source === 'microphone' ? 'Микрофон и так выключен' : 'Камера и так выключена',
'info',
)
}
} catch {
toast.show('Не удалось выключить трек участника', 'error')
}
}
return (
<div className="room-organizer-controls">
<button
type="button"
title={`Выключить микрофон: ${displayName}`}
aria-label={`Выключить микрофон: ${displayName}`}
onClick={(e) => {
e.stopPropagation()
void handleMute('microphone')
}}
>
<Mic aria-hidden="true" />
</button>
<button
type="button"
title={`Выключить камеру: ${displayName}`}
aria-label={`Выключить камеру: ${displayName}`}
onClick={(e) => {
e.stopPropagation()
void handleMute('camera')
}}
>
<Video aria-hidden="true" />
</button>
</div>
)
}
/**
@@ -43,8 +102,15 @@ function parseAvatarUrl(metadata: string | undefined): string | null {
* разметке (см. `node_modules/@livekit/components-react/src/components/participant/ParticipantTile.tsx`,
* версия 2.9.23 — источник этой копии).
*/
function TileBody({ onStopSharing }: { onStopSharing?: () => void }) {
function TileBody({
onStopSharing,
pinnedKey,
onTogglePin,
raisedHandIdentities,
conferenceId,
}: TileControlsProps) {
const trackReference = useEnsureTrackRef()
const isOrganizer = useIsOrganizer()
const isEncrypted = useIsEncrypted(trackReference.participant)
const autoManageSubscription = useFeatureContext()?.autoSubscription
// useParticipantInfo — реактивные name/metadata участника (переподписка на
@@ -60,6 +126,26 @@ function TileBody({ onStopSharing }: { onStopSharing?: () => void }) {
const showSharingChip = Boolean(
onStopSharing && trackReference.source === Track.Source.ScreenShare && trackReference.participant.isLocal,
)
// Кнопка закрепления — только там, где сцена её даёт (основное окно передаёт
// `onTogglePin`; в мини-плеере своего тулбара нет и плитка одна, поэтому
// булавки там нет — само закрепление, сделанное в основном окне, с 0.0.25
// действует и в мини-плеере, см. `initialPinnedKey` в `RoomStage`).
// Ключ плитки берём из её собственного трека: в карусели/гриде плитки
// рендерятся шаблоном без пропсов, снаружи «какая это плитка» не передать.
const tileKey = stageTrackKey(trackReference)
const isPinned = pinnedKey === tileKey
// Бейдж поднятой руки (задача B1) — только на плитке КАМЕРЫ участника, не
// на плитке его демонстрации экрана (рука — про человека, не про экран).
const isHandRaised =
trackReference.source === Track.Source.Camera &&
Boolean(raisedHandIdentities?.has(trackReference.participant.identity))
// Кнопки принудительного мьюта (задача B2) — организатору, только на
// чужой плитке камеры (см. докстринг `OrganizerMuteControls`).
const showOrganizerMuteControls =
isOrganizer &&
Boolean(conferenceId) &&
trackReference.source === Track.Source.Camera &&
!trackReference.participant.isLocal
return (
<>
@@ -81,6 +167,11 @@ function TileBody({ onStopSharing }: { onStopSharing?: () => void }) {
<div className="lk-participant-placeholder">
<Avatar name={displayName} avatarUrl={avatarUrl} className="room-tile-avatar" />
</div>
{isHandRaised && (
<div className="room-hand-badge" title={`${displayName}: поднята рука`}>
<Hand className="lucide" aria-hidden="true" />
</div>
)}
<div className="lk-participant-metadata">
<div className="lk-participant-metadata-item">
{trackReference.source === Track.Source.Camera ? (
@@ -106,6 +197,23 @@ function TileBody({ onStopSharing }: { onStopSharing?: () => void }) {
<ConnectionQualityIndicator className="lk-participant-metadata-item" />
</div>
<FocusToggle trackRef={trackReference} />
{onTogglePin && (
<button
type="button"
className={`room-pin-toggle${isPinned ? ' is-pinned' : ''}`}
aria-pressed={isPinned}
title={isPinned ? 'Открепить' : 'Закрепить'}
aria-label={isPinned ? `Открепить: ${displayName}` : `Закрепить: ${displayName}`}
onClick={(e) => {
// Иначе клик долетит до самой плитки (`onParticipantClick`
// у `ParticipantTile`) — булавка не должна означать «клик по плитке».
e.stopPropagation()
onTogglePin(tileKey)
}}
>
{isPinned ? <PinOff aria-hidden="true" /> : <Pin aria-hidden="true" />}
</button>
)}
{showSharingChip && (
<div className="stage-sharing-chip">
<ScreenShare className="lucide" aria-hidden="true" />
@@ -115,14 +223,19 @@ function TileBody({ onStopSharing }: { onStopSharing?: () => void }) {
</button>
</div>
)}
{showOrganizerMuteControls && conferenceId && (
<OrganizerMuteControls
conferenceId={conferenceId}
identity={trackReference.participant.identity}
displayName={displayName}
/>
)}
</>
)
}
interface RoomParticipantTileProps {
trackRef?: TrackReferenceOrPlaceholder
disableSpeakingIndicator?: boolean
onParticipantClick?: (event: ParticipantClickEvent) => void
/** Управляющие элементы поверх плитки — общие для обёртки и её `TileBody`. */
interface TileControlsProps {
/**
* Остановить демонстрацию экрана — если передано, при рендере СВОЕЙ активной
* демонстрации (Track.Source.ScreenShare + `participant.isLocal`) поверх
@@ -131,6 +244,34 @@ interface RoomParticipantTileProps {
* в этом приложении не появляется (см. `RoomStage.tsx`).
*/
onStopSharing?: () => void
/**
* Ключ закреплённой сейчас плитки (`identity:source`, см. `stageTrackKey`) —
* плитка сравнивает его со своим и подсвечивает булавку/рамку.
*/
pinnedKey?: string | null
/**
* Закрепить/открепить эту плитку в основном окне. Передаёт свой ключ
* (плитки в карусели/гриде рендерятся шаблоном, снаружи их не различить).
* Не передан — кнопки-булавки на плитке нет (мини-плеер: плитка одна).
*/
onTogglePin?: (key: string) => void
/**
* Identity участников с поднятой рукой прямо сейчас (задача B1, из
* `useChat().handQueue`) — плитка сама решает, её ли это identity. Не
* передан — бейдж нигде не рендерится (мини-плеер).
*/
raisedHandIdentities?: Set<string>
/**
* Id конференции (не slug/номер) — нужен для вызова эндпоинта мьюта
* (задача B2). Не передан — кнопок мьюта на плитке нет (мини-плеер).
*/
conferenceId?: string
}
interface RoomParticipantTileProps extends TileControlsProps {
trackRef?: TrackReferenceOrPlaceholder
disableSpeakingIndicator?: boolean
onParticipantClick?: (event: ParticipantClickEvent) => void
}
/**
@@ -142,15 +283,22 @@ interface RoomParticipantTileProps {
* собственного токена), и для удалённых.
*
* Пин-логика оригинала (`handleSubscribe`/сброс пина при отписке от трека)
* сознательно опущена — приложение пока нигде не создаёт `LayoutContext`
* (пиннинг плиток не реализован), поэтому в оригинале эта ветка и так была
* мёртвым кодом без провайдера контекста.
* сознательно опущена — приложение нигде не создаёт `LayoutContext`, поэтому
* в оригинале эта ветка и так была мёртвым кодом без провайдера контекста (по
* той же причине ничего не рисует и штатный `FocusToggle` ниже). Своё
* закрепление участника (задача 3.1) сделано мимо `LayoutContext`: состояние —
* в `RoomStage`, кнопка — `.room-pin-toggle` здесь, выбор фокуса —
* `pickStageFocus`.
*/
export function RoomParticipantTile({
trackRef,
disableSpeakingIndicator,
onParticipantClick,
onStopSharing,
pinnedKey,
onTogglePin,
raisedHandIdentities,
conferenceId,
}: RoomParticipantTileProps) {
return (
<ParticipantTile
@@ -158,7 +306,13 @@ export function RoomParticipantTile({
disableSpeakingIndicator={disableSpeakingIndicator}
onParticipantClick={onParticipantClick}
>
<TileBody onStopSharing={onStopSharing} />
<TileBody
onStopSharing={onStopSharing}
pinnedKey={pinnedKey}
onTogglePin={onTogglePin}
raisedHandIdentities={raisedHandIdentities}
conferenceId={conferenceId}
/>
</ParticipantTile>
)
}

View File

@@ -1,18 +1,21 @@
import { useState } from 'react'
import { Track } from 'livekit-client'
import { useEffect, useState, type ReactNode } from 'react'
import { EyeOff, Mic, MicOff, Users } from 'lucide-react'
import { Track, type Participant } from 'livekit-client'
import {
CarouselLayout,
FocusLayoutContainer,
GridLayout,
RoomAudioRenderer,
isTrackReference,
useRoomContext,
useSpeakingParticipants,
useTrackToggle,
useTracks,
type TrackReferenceOrPlaceholder,
} from '@livekit/components-react'
import { RoomParticipantTile } from '@/components/room/RoomParticipantTile'
import { pickStageFocus } from '@/components/room/stageFocus'
import { StageGrid } from '@/components/room/StageGrid'
import { useIsCompactViewport } from '@/hooks/useIsCompactViewport'
import { pickStageFocus, stageTrackKey } from '@/components/room/stageFocus'
import type { StageLayoutMode } from '@/lib/stageLayoutMode'
/**
* Стабильная (модульная, не пересоздаётся на каждый рендер) ссылка на
@@ -39,15 +42,135 @@ const STAGE_TRACK_SOURCES = [
{ source: Track.Source.ScreenShare, withPlaceholder: false },
]
/** Ключ трека для `pickStageFocus` — см. обоснование в `stageFocus.ts`. */
function stageTrackKey(t: TrackReferenceOrPlaceholder): string {
return `${t.participant.identity}:${t.source}`
/**
* Удержание фокуса при смене говорящего, мс. Действует в ОБОИХ вариантах
* сцены — и в основном окне, и в мини-плеере (до 0.0.25 в PiP удержания не
* было вовсе, фокус там переключался мгновенно).
*
* Сцена следует за спикером (`followSpeaker`, задача 3.2), и без
* удержания короткие реплики («ага», «угу») уводили бы большую плитку на
* секунду и возвращали обратно. Источник говорящих (`useSpeakingParticipants`
* поверх `RoomEvent.ActiveSpeakersChanged`) сам по себе не дребезжит, но
* реплику длиной в полсекунды он честно отдаёт как смену состава.
*
* Значение подобрано от периода самого события: LiveKit пересчитывает активных
* спикеров примерно раз в 0.5 с, то есть короткая реплика — это 12 обновления.
* 1.2 с ≈ 23 обновления: блик до 1.2 с не проходит вовсе (значение успевает
* вернуться обратно, таймер перезапускается), а осмысленная фраза переключает
* фокус с задержкой, которая на глаз читается как плавность, а не как тормоз.
* Меньше (~0.6 с) — короткие «ага» всё ещё пролезают, больше (~2 с) — заметно
* запаздывает переход на нового докладчика.
*
* В мини-плеере удержание тем более уместно: там плитка ОДНА, и мгновенное
* переключение читается не как «камера следует за разговором», а как мигание
* всего окна целиком.
*/
const SPEAKER_HOLD_MS = 1200
/**
* Возвращает состав говорящих, «успокоенный» удержанием: новое значение
* применяется, только если оно продержалось `holdMs` без изменений. Короткая
* реплика меняет состав и возвращает его обратно раньше таймера — тогда
* применять уже нечего (cleanup эффекта гасит таймер, а новое значение
* сравнивается по ссылке с текущим).
*
* `holdMs <= 0` — удержания нет, значение отдаётся как есть. Сейчас этим
* режимом никто не пользуется (обе сцены удерживают состав), но параметр
* оставлен: он и делает функцию пригодной для повторного использования.
*/
function useSteadySpeakers(speakers: Participant[], holdMs: number): Participant[] {
const [steady, setSteady] = useState(speakers)
useEffect(() => {
if (holdMs <= 0 || speakers === steady) return
const timer = setTimeout(() => setSteady(speakers), holdMs)
return () => clearTimeout(timer)
}, [speakers, steady, holdMs])
return holdMs <= 0 ? speakers : steady
}
/**
* Есть ли у трека ЖИВОЕ видео: это настоящий трек (не плейсхолдер выключенной
* камеры) и он не в мьюте. По этому признаку режим «Живые плитки» делит
* участников на сетку и карусель, а `pickStageFocus` отдаёт предпочтение
* говорящему с картинкой.
*/
function hasLiveVideo(track: TrackReferenceOrPlaceholder): boolean {
return isTrackReference(track) && !track.publication.isMuted
}
/**
* Кнопка микрофона поверх единственной плитки мини-плеера (задача A5): в
* Document PiP своего тулбара нет вовсе, а звонок продолжается — без неё
* замьютить себя можно, только развернув основное окно. `useTrackToggle` —
* тот же хук, что и в `RoomToolbar`, читает/меняет состояние ЧЕРЕЗ
* `RoomContext`, а не через DOM, поэтому работает одинаково что в основном
* окне, что в портале, рендерящем эту сцену внутрь `pipWindow.document.body`
* (React-контекст порталом не разрывается, в отличие от DOM-дерева).
*/
function PipMicToggle() {
const mic = useTrackToggle({ source: Track.Source.Microphone })
return (
<button
type="button"
{...mic.buttonProps}
className={`room-pip-mic-toggle${mic.enabled ? '' : ' is-off'}`}
aria-label={mic.enabled ? 'Выключить микрофон' : 'Включить микрофон'}
>
{mic.enabled ? <Mic className="lucide" aria-hidden="true" /> : <MicOff className="lucide" aria-hidden="true" />}
</button>
)
}
/**
* Основная сцена конференции: превью остальных участников + крупная плитка
* активного спикера (FocusLayoutContainer + CarouselLayout при нескольких
* участниках, GridLayout при одном/двух).
* участниках, равномерная сетка при одном/двух).
*
* РЕЖИМЫ ПОКАЗА (`layoutMode`, выбор пользователя, см. `lib/stageLayoutMode.ts`):
* - `standard` — как было до 0.0.11: крупная плитка (кого показать, решает
* `pickStageFocus`) + карусель остальных сбоку;
* - `tiles` — все участники равными плитками (`StageGrid`), без фокуса;
* - `live-tiles` — сетка только из участников с включённой камерой,
* остальные (плейсхолдер/мьют, см. `hasLiveVideo`) — в карусели сбоку.
* Если камеру не включил никто, сетка была бы пустой, поэтому в этом
* случае показываем всех — режим вырождается в `tiles`.
*
* ДЕМОНСТРАЦИЯ ЭКРАНА перебивает выбранный режим: пока в комнате есть хоть
* одна активная демонстрация, сцена ведёт себя как `standard` (демонстрация
* крупно, все камеры в карусели). Это сознательно: смысл плиточных режимов —
* равноправие участников, а демонстрация по определению неравноправна, ради
* неё её и включают. Режим при этом не меняется — он живёт в состоянии
* `RoomPage`, поэтому по завершении демонстрации сцена сама возвращается к
* выбранному пользователем виду.
*
* СКРЫТИЕ ОСТАЛЬНЫХ (`hideOthers`): карусель не рендерится вовсе, основная
* область занимает всю сцену. Скрыть можно тремя способами — меню «Вид»,
* шторка настроек на мобильном и кнопка «Скрыть» прямо над колонкой миниатюр
* (`onHideOthers`, только широкий экран). Вернуть — кнопкой «Показать
* остальных» на сцене (`onShowOthers`): она видна всегда, пока кто-то скрыт,
* чтобы участники не «потерялись» без понятного способа их вернуть. В режиме
* `tiles` скрывать нечего (карусели нет), переключатель там заблокирован —
* см. `StageViewOptions`.
*
* ФОКУС И ЗАКРЕПЛЕНИЕ ПЕРЕЖИВАЮТ ПЕРЕЕЗД В МИНИ-ПЛЕЕР. Сцена в мини-плеере —
* ОТДЕЛЬНЫЙ экземпляр этого компонента (портал в PiP-окно), и своё состояние
* фокуса он начинал с нуля: демонстрации нет, никто прямо сейчас не говорит — и
* `pickStageFocus` доходил до последнего фолбэка `localKey`, то есть мини-окно
* открывалось на самом пользователе вместо того, что он видел крупно. В Safari
* бага не было видно: там Document PiP не используется, а video-фолбэк
* (`useRoomPiP`) берёт `<video>` прямо из фокус-плитки основного окна. Лечится
* передачей ключа наружу и обратно: `onFocusKeyChange` → состояние в
* `RoomPage` → `initialFocusKey` следующего экземпляра. Работает в обе стороны
* — возврат из мини-плеера тоже не сбрасывает фокус.
*
* Ровно тем же мостиком с 0.0.25 ездит и ЗАКРЕПЛЕНИЕ (`initialPinnedKey` /
* `onPinnedKeyChange`): раньше `pinnedKey` был чисто локальным `useState`, и
* закрепление, сделанное в основном окне, в мини-плеер не попадало вовсе.
* Отдельный «общий» источник правды здесь не нужен: экземпляр сцены в каждый
* момент ровно один (пока открыт Document PiP, основное окно показывает
* заглушку — см. `RoomPage`), поэтому состояние достаточно передать по эстафете.
*
* Раскладка — вертикальная колонка миниатюр слева от основной сцены (не
* горизонтальная лента, см. design/mockups/room.html после правки: узкая
@@ -75,23 +198,84 @@ function stageTrackKey(t: TrackReferenceOrPlaceholder): string {
*
* Проп `variant="pip"` — для рендера
* ВНУТРИ мини-плеера (Document PiP, портал в `RoomPage.tsx`). В этом режиме
* показываем ТОЛЬКО одну крупную плитку активного окна — без карусели/грида
* — и фокус ЖИВО следует за активным спикером (см. `followSpeaker` у
* `pickStageFocus`), а не удерживается, как в основном окне. Основной рендер
* (`variant="full"`, дефолт) не меняется вовсе.
* показываем ТОЛЬКО одну крупную плитку активного окна — без карусели/грида;
* режимы показа и скрытие остальных на мини-плеер не влияют вовсе.
*
* ВЫБОР ФОКУСА ОДИНАКОВ В ОБОИХ ВАРИАНТАХ (с 0.0.25). До этого мини-плеер был
* намеренно «упрощён»: без удержания говорящего, без удержания демонстрации
* экрана (`holdScreenShare`), без приоритета говорящего с включённой камерой и
* без закрепления. На практике это читалось как поломка: в мини-окне
* демонстрация экрана слетала от любой чужой реплики, а закрепление,
* сделанное в основном окне, не действовало. Теперь `pickStageFocus`
* получает одни и те же правила независимо от варианта — разным остаётся
* ровно одно: `localKey` (см. ниже) и то, что PiP рисует одну плитку вместо
* раскладки.
*
* Единственное сознательное отличие — `localKey`: у мини-плеера есть
* последний фолбэк «показать себя», у основного окна его нет (там фолбэк —
* первый трек по порядку, поведение не менялось). Строка из того же сюжета,
* что и `initialFocusKey`: без неё свежеоткрытое мини-окно на пустой комнате
* выбирало произвольного участника.
*/
export function RoomStage({ variant = 'full' }: { variant?: 'full' | 'pip' }) {
export function RoomStage({
variant = 'full',
layoutMode = 'standard',
hideOthers = false,
onShowOthers,
onHideOthers,
initialFocusKey = null,
onFocusKeyChange,
initialPinnedKey = null,
onPinnedKeyChange,
onPinFocus,
raisedHandIdentities,
conferenceId,
stageMaxTiles,
}: {
variant?: 'full' | 'pip'
/** Выбранный пользователем режим показа; игнорируется при `variant="pip"`. */
layoutMode?: StageLayoutMode
/** Скрыть карусель остальных участников; игнорируется при `variant="pip"`. */
hideOthers?: boolean
/** Вернуть скрытых участников — кнопка на сцене (см. докстринг выше). */
onShowOthers?: () => void
/** Скрыть остальных — кнопка над каруселью (только широкий экран). */
onHideOthers?: () => void
/** Чем инициализировать фокус при монтировании — см. докстринг про мини-плеер. */
initialFocusKey?: string | null
/** Сообщать наружу текущий фокус, чтобы его пережил переезд сцены в мини-плеер и обратно. */
onFocusKeyChange?: (key: string | null) => void
/** Чем инициализировать закрепление при монтировании — тот же мостик через `RoomPage`, что и у фокуса. */
initialPinnedKey?: string | null
/** Сообщать наружу закрепление, чтобы оно пережило переезд сцены в мини-плеер и обратно. */
onPinnedKeyChange?: (key: string | null) => void
/**
* Участника только что закрепили (не открепили) в режиме без крупной
* плитки — сцена сама переключиться не может (режим живёт в `RoomPage`),
* поэтому просит переключить на `standard`, где закреплённого есть куда
* показать крупно. Открепление НЕ вызывает колбэк — вид назад не
* переключаем (см. докстринг `RoomPage`, обоснование решения в коммите).
*/
onPinFocus?: () => void
/** Identity участников с поднятой рукой (задача B1) — бейдж на плитке; игнорируется при `variant="pip"`. */
raisedHandIdentities?: Set<string>
/** Id конференции (задача B2) — кнопки принудительного мьюта на чужих плитках; игнорируется при `variant="pip"`. */
conferenceId?: string
/** Потолок админки на число плиток `StageGrid` (`instance_settings.media_limits`); игнорируется при `variant="pip"`. */
stageMaxTiles?: number
}) {
const room = useRoomContext()
const isCompact = useIsCompactViewport()
const tracks = useTracks(STAGE_TRACK_SOURCES, {
onlySubscribed: false,
})
// Только для PiP (см. followSpeaker ниже) — активные спикеры уже
// отсортированы SDK по громкости (`Room.activeSpeakers`, обновляются по
// `RoomEvent.ActiveSpeakersChanged`, событие шлётся лишь при РЕАЛЬНОЙ смене
// состава/порядка говорящих — не дребезжит на каждый чих, в отличие от
// сырого `participant.isSpeaking`). Хук вызывается безусловно (Rules of
// Hooks) — для `variant="full"` его результат просто не используется.
const speakingParticipants = useSpeakingParticipants()
// Активные спикеры уже отсортированы SDK по громкости
// (`Room.activeSpeakers`, обновляются по `RoomEvent.ActiveSpeakersChanged`,
// событие шлётся лишь при РЕАЛЬНОЙ смене состава/порядка говорящих — не
// дребезжит на каждый чих, в отличие от сырого `participant.isSpeaking`).
// Поверх этого сцена ещё и удерживает состав (см. `useSteadySpeakers` и
// `SPEAKER_HOLD_MS`) — в обоих вариантах одинаково.
const speakingParticipants = useSteadySpeakers(useSpeakingParticipants(), SPEAKER_HOLD_MS)
const cameraTracks = tracks.filter((t) => t.source === Track.Source.Camera)
const screenShareTracks = tracks.filter((t) => isTrackReference(t) && t.source === Track.Source.ScreenShare)
@@ -121,47 +305,76 @@ export function RoomStage({ variant = 'full' }: { variant?: 'full' | 'pip' }) {
// новый массив только когда реально что-то изменилось (см. комментарий у
// `STAGE_TRACK_SOURCES` про стабильность ссылки).
//
// Для PiP (`variant="pip"`) пересчёт триггерится ЕЩЁ и сменой
// `speakingParticipants` (тоже сравнение по ссылке — хук отдаёт новый
// массив только при реальном изменении состава/порядка говорящих), и
// передаётся `followSpeaker: true` — фокус живо переключается на нового
// спикера, а не удерживает прежний (см. правило 2 в `pickStageFocus`). Для
// основного окна (`variant="full"`) `speakingChanged` всегда `false` —
// поведение байт-в-байт то же, что было до этой правки.
// Пересчёт триггерится ещё и сменой `speakingParticipants` (тоже сравнение
// по ссылке — хук отдаёт новый массив только при реальном изменении состава/
// порядка говорящих), и сменой закрепления (`pinnedKey`) — оба входа
// `pickStageFocus` меняют результат без изменения самих треков.
const [prevTracks, setPrevTracks] = useState(tracks)
const [prevSpeakingParticipants, setPrevSpeakingParticipants] = useState(speakingParticipants)
const [focusKey, setFocusKey] = useState<string | null>(null)
// Стартовое значение — фокус, доставшийся от предыдущего экземпляра сцены
// (см. `initialFocusKey` и докстринг про мини-плеер). Дальше живёт своей
// жизнью: `pickStageFocus` пересчитывает его на каждое значимое изменение.
const [focusKey, setFocusKey] = useState<string | null>(initialFocusKey)
// Закрепление живёт в состоянии сцены (задача 3.1): ключ `identity:source`
// плитки, которую пользователь закрепил булавкой; `null` — закрепления нет.
// Стартовое значение приходит от предыдущего экземпляра сцены (тот же
// мостик через `RoomPage`, что и у фокуса), поэтому закрепление, сделанное
// в основном окне, действует и в мини-плеере.
const [pinnedKey, setPinnedKey] = useState<string | null>(initialPinnedKey)
const [prevPinnedKey, setPrevPinnedKey] = useState<string | null>(null)
const cameraKeys = cameraTracks.map(stageTrackKey)
const screenShareKeys = screenShareTracks.map(stageTrackKey)
// Закреплённый участник вышел из комнаты (его ключа нет ни среди камер — а
// камера есть у КАЖДОГО участника хотя бы плейсхолдером, — ни среди
// демонстраций) — закрепление снимаем, чтобы сцена не осталась в подвешенном
// состоянии и булавка не «висела» на исчезнувшем ключе.
//
// `tracksKnown` — обязательная охрана, а не перестраховка: на ПЕРВОМ рендере
// нового экземпляра сцены `useTracks` отдаёт ПУСТОЙ массив (реальный состав
// приезжает следующим рендером, из подписки на события комнаты). Без этой
// проверки пустой набор читается как «все вышли», и закрепление, приехавшее
// через `initialPinnedKey`, обнулялось сразу при монтировании — то есть
// мини-плеер терял его каждый раз (найдено живой отладкой при 0.0.25).
// Пустых наборов при живой комнате не бывает: камера есть у каждого
// участника хотя бы плейсхолдером.
const tracksKnown = cameraKeys.length > 0 || screenShareKeys.length > 0
const pinnedAlive = pinnedKey !== null && (cameraKeys.includes(pinnedKey) || screenShareKeys.includes(pinnedKey))
const tracksChanged = tracks !== prevTracks
const speakingChanged = variant === 'pip' && speakingParticipants !== prevSpeakingParticipants
const speakingChanged = speakingParticipants !== prevSpeakingParticipants
const pinnedChanged = pinnedKey !== prevPinnedKey
if (tracksChanged || speakingChanged) {
if (pinnedKey !== null && tracksKnown && !pinnedAlive) {
setPinnedKey(null)
}
if (tracksChanged || speakingChanged || pinnedChanged) {
const prevKeys = prevTracks.map(stageTrackKey)
if (tracksChanged) setPrevTracks(tracks)
if (speakingChanged) setPrevSpeakingParticipants(speakingParticipants)
// Источник «говорящих» — РАЗНЫЙ для основного окна и PiP, намеренно:
// здесь строго тот же расчёт, что был в основном окне ДО этой правки
// (`participant.isSpeaking`, без сортировки — фолбэк только на первый
// рендер, дребезг неважен, см. JSDoc правила 3/4 в stageFocus.ts), а для
// PiP — упорядоченный по громкости `speakingParticipants` (нужен именно
// порядок, чтобы взять самого громкого, и именно throttled-источник SDK,
// чтобы followSpeaker не дёргался на каждый чих).
const speakingCameraKeys =
variant === 'pip'
? speakingParticipants
.map((p) => cameraTracks.find((t) => t.participant.identity === p.identity))
.filter((t): t is TrackReferenceOrPlaceholder => Boolean(t))
.map(stageTrackKey)
: cameraTracks.filter((t) => t.participant.isSpeaking).map(stageTrackKey)
if (pinnedChanged) setPrevPinnedKey(pinnedKey)
// Говорящие — упорядоченные по громкости камера-ключи: нужен именно
// порядок (взять самого громкого) и именно throttled-источник SDK
// (`useSpeakingParticipants`, в основном окне ещё и с удержанием), чтобы
// followSpeaker не дёргался на каждый чих. Сырой `participant.isSpeaking`,
// на котором основное окно жило до 0.0.6, дребезжит и для слежения за
// спикером не годится.
const speakingCameraKeys = speakingParticipants
.map((p) => cameraTracks.find((t) => t.participant.identity === p.identity))
.filter((t): t is TrackReferenceOrPlaceholder => Boolean(t))
.map(stageTrackKey)
const result = pickStageFocus({
cameraKeys: cameraTracks.map(stageTrackKey),
screenShareKeys: screenShareTracks.map(stageTrackKey),
cameraKeys,
screenShareKeys,
speakingCameraKeys,
cameraKeysWithVideo: cameraTracks.filter(hasLiveVideo).map(stageTrackKey),
prevKeys,
prevFocusKey: focusKey,
followSpeaker: variant === 'pip',
// Только для PiP — в основном окне фолбэк на «первый трек» не менялся
// (см. JSDoc про speakingChanged выше: поведение full-варианта не трогаем).
pinnedKey: pinnedAlive ? pinnedKey : null,
followSpeaker: true,
holdScreenShare: true,
// Только для PiP — в основном окне фолбэк на «первый трек» не менялся.
localKey: variant === 'pip' ? `${room.localParticipant.identity}:${Track.Source.Camera}` : null,
})
if (result.focusKey !== focusKey) {
@@ -169,6 +382,19 @@ export function RoomStage({ variant = 'full' }: { variant?: 'full' | 'pip' }) {
}
}
// Отдаём фокус наружу (в `RoomPage`), чтобы он пережил размонтирование этой
// сцены и достался следующей — см. `initialFocusKey`. Именно эффект, а не
// вызов в теле рендера: setState ЧУЖОГО компонента во время рендера React
// запрещает.
useEffect(() => {
onFocusKeyChange?.(focusKey)
}, [focusKey, onFocusKeyChange])
// То же самое для закрепления — см. `initialPinnedKey`.
useEffect(() => {
onPinnedKeyChange?.(pinnedKey)
}, [pinnedKey, onPinnedKeyChange])
const focusTrack = tracks.find((t) => stageTrackKey(t) === focusKey) ?? screenShareTracks[0] ?? cameraTracks[0]
const focusTrackKey = focusTrack ? stageTrackKey(focusTrack) : null
// При активной демонстрации карусель — ВСЕ камеры (включая демонстратора) И
@@ -192,37 +418,130 @@ export function RoomStage({ variant = 'full' }: { variant?: 'full' | 'pip' }) {
void room.localParticipant.setScreenShareEnabled(false)
}
/**
* Закрепить/открепить плитку: повторное нажатие на уже закреплённой снимает
* закрепление. Ключ приходит из самой плитки (она знает свой трек из
* контекста — в карусели/гриде плитки рендерятся шаблоном, без пропсов).
*
* Закрепление доступно из ЛЮБОГО режима показа (задача 3.1 → 3.3): в
* плиточных режимах крупной плитки нет, поэтому само закрепление (не
* открепление) просит `RoomPage` переключить вид на `standard` —
* закреплённого иначе негде показать крупно.
*/
function handleTogglePin(key: string) {
const willPin = pinnedKey !== key
setPinnedKey(willPin ? key : null)
if (willPin) onPinFocus?.()
}
// Мини-плеер показывает ТОЛЬКО активное окно — без карусели/
// грида, одна плитка на весь контейнер (см. `.room-single-tile`,
// `styles/room.css`). `focusTrack` уже вычислен выше тем же `pickStageFocus`
// (с `followSpeaker: true` для этого варианта) — переиспользуем как есть.
// и по тем же правилам, что и в основном окне (закрепление, удержание
// демонстрации, антидребезг говорящего) — переиспользуем как есть.
// Булавки на плитке здесь нет намеренно: своего тулбара у мини-окна нет,
// закрепление делается в основном окне и приезжает сюда через
// `initialPinnedKey`.
if (variant === 'pip') {
return (
<section className="stage room-single-tile">
{focusTrack && <RoomParticipantTile trackRef={focusTrack} onStopSharing={handleStopSharing} />}
<RoomAudioRenderer />
<PipMicToggle />
</section>
)
}
// Демонстрация экрана перебивает выбранный режим (обоснование — в докстринге).
const effectiveMode: StageLayoutMode = hasScreenShare ? 'standard' : layoutMode
const liveCameraTracks = cameraTracks.filter(hasLiveVideo)
// Кого показываем сбоку в карусели. В «плитках» — никого (все равноправны),
// в «живых плитках» — тех, у кого камера выключена (а если камеры нет ни у
// кого, карусель пуста: все ушли в сетку), в «стандарте» — всех, кроме
// плитки в фокусе.
const sideTracks: TrackReferenceOrPlaceholder[] =
effectiveMode === 'tiles'
? []
: effectiveMode === 'live-tiles'
? liveCameraTracks.length > 0
? cameraTracks.filter((t) => !hasLiveVideo(t))
: []
: carouselTracks
const showCarousel = !hideOthers && sideTracks.length > 0
// Булавка закрепления есть на КАЖДОЙ плитке во всех режимах (задача 3.3):
// сама кнопка вызывает переключение на `standard`, где закреплённый и
// попадёт в фокус (см. `handleTogglePin`/`onPinFocus`). Бейдж поднятой
// руки (задача B1) и кнопки принудительного мьюта (задача B2) едут тем же
// спредом — тоже нужны на КАЖДОЙ плитке.
const tileProps = { pinnedKey, onTogglePin: handleTogglePin, raisedHandIdentities, conferenceId }
function renderMain(): ReactNode {
if (effectiveMode === 'tiles') {
return (
<StageGrid tracks={tracks} maxTiles={stageMaxTiles}>
<RoomParticipantTile {...tileProps} />
</StageGrid>
)
}
if (effectiveMode === 'live-tiles') {
return (
<StageGrid tracks={liveCameraTracks.length > 0 ? liveCameraTracks : cameraTracks} maxTiles={stageMaxTiles}>
<RoomParticipantTile {...tileProps} />
</StageGrid>
)
}
// «Стандарт» с единственным участником (карусель пуста и скрывать нечего) —
// прежнее поведение: равномерная сетка на всю сцену, а не фокус-плитка.
if (sideTracks.length === 0 && !hideOthers) {
return (
<StageGrid tracks={tracks} maxTiles={stageMaxTiles}>
<RoomParticipantTile {...tileProps} />
</StageGrid>
)
}
// FocusLayout оригинала — лёгкая обёртка ровно над ParticipantTile
// (см. её исходник), поэтому вместо неё используем свою обёртку
// напрямую с тем же trackRef (аватар в фокус-плитке).
return (
focusTrack && <RoomParticipantTile trackRef={focusTrack} onStopSharing={handleStopSharing} {...tileProps} />
)
}
return (
<section className="stage">
{!hasScreenShare && (!focusTrack || carouselTracks.length === 0) ? (
<GridLayout tracks={tracks} className="stage-tiles">
<RoomParticipantTile />
</GridLayout>
) : (
{showCarousel ? (
<FocusLayoutContainer className="stage-tiles">
<CarouselLayout tracks={carouselTracks}>
<RoomParticipantTile />
</CarouselLayout>
{/* FocusLayout оригинала — лёгкая обёртка ровно над ParticipantTile
(см. её исходник), поэтому вместо неё используем свою обёртку
напрямую с тем же trackRef (аватар в фокус-плитке). */}
{focusTrack && <RoomParticipantTile trackRef={focusTrack} onStopSharing={handleStopSharing} />}
{/* Обёртка колонки миниатюр: сама карусель не может быть прямым
ребёнком грида, потому что над ней стоит кнопка «Скрыть». Из-за
этого мобильный `.lk-carousel{order:1}` библиотеки перестаёт
действовать — порядок переносим на `.stage-side` (см. room.css). */}
<div className="stage-side">
{/* Быстрый способ убрать карусель, не открывая меню «Вид». Только
на широком экране: на мобильном колонка превращается в узкую
полосу снизу, где кнопке не место, — там переключатель живёт в
шторке настроек. Вернуть участников можно кнопкой на сцене. */}
{!isCompact && onHideOthers && (
<button type="button" className="stage-side-hide" onClick={onHideOthers}>
<EyeOff className="lucide" aria-hidden="true" />
<span>Скрыть</span>
</button>
)}
<CarouselLayout tracks={sideTracks}>
<RoomParticipantTile {...tileProps} />
</CarouselLayout>
</div>
{renderMain()}
</FocusLayoutContainer>
) : (
// Карусели нет — основная область занимает сцену целиком.
<div className="stage-tiles stage-main-only">{renderMain()}</div>
)}
{hideOthers && sideTracks.length > 0 && (
<button type="button" className="stage-show-others" onClick={onShowOthers}>
<Users className="lucide" aria-hidden="true" />
<span>Показать остальных ({sideTracks.length})</span>
</button>
)}
<RoomAudioRenderer />
</section>
)
}

View File

@@ -1,4 +1,5 @@
import {
Hand,
LogOut,
Maximize,
MessageSquare,
@@ -13,8 +14,13 @@ import {
VideoOff,
} from 'lucide-react'
import { Track, type ScreenShareCaptureOptions } from 'livekit-client'
import { DisconnectButton, useTrackToggle } from '@livekit/components-react'
import { DisconnectButton, useLocalParticipant, useTrackToggle } from '@livekit/components-react'
import { useToast } from '@/components/ui/ToastProvider'
import { useIsCompactViewport } from '@/hooks/useIsCompactViewport'
import { useIsOrganizer } from '@/hooks/useIsOrganizer'
import type { HandQueueEntry } from '@/hooks/useChat'
import { StageViewMenu, type StageViewProps } from '@/components/room/StageViewOptions'
import { HandQueueMenu } from '@/components/room/HandQueueMenu'
/**
* Опции захвата демонстрации экрана: `audio: true` — звук
@@ -36,7 +42,7 @@ const SCREEN_SHARE_CAPTURE_OPTIONS: ScreenShareCaptureOptions = {
systemAudio: 'include',
}
interface RoomToolbarProps {
interface RoomToolbarProps extends StageViewProps {
/** Показывать ли кнопку чата — `JoinOut.chat_enabled` И чат не помечен недоступным (close-код 4404). */
chatVisible: boolean
chatOpen: boolean
@@ -53,13 +59,49 @@ interface RoomToolbarProps {
pipSupported: boolean
pipActive: boolean
onTogglePiP: () => void
/**
* Очередь поднятых рук целиком (задача B1, `useChat().handQueue`) — сама
* решает, поднята ли СВОЯ рука (сравнивая с `localParticipant.identity`
* через `useLocalParticipant`), и показывает бейдж общего счётчика.
*/
handQueue: HandQueueEntry[]
onRaiseHand: () => void
onLowerHand: () => void
/** Опустить ЧУЖУЮ руку по identity — только организатору (панель очереди, `HandQueueMenu`). */
onLowerHandById: (identity: string) => void
/** Тулбар в оверлее полноэкранного режима — см. докстринг `RoomTopbar.overlayVisible`, тот же механизм. */
overlayVisible?: boolean
/**
* Пользователь нажал «Выйти» — вызывается ПЕРЕД тем, как `DisconnectButton`
* отключит комнату (обработчики в `mergeProps` вызываются цепочкой). Нужен
* `RoomPage`, чтобы отличить намеренный выход от разрыва: причина
* `CLIENT_INITIATED` приходит и от кнопки, и от livekit-client при заморозке
* вкладки — см. докстринг `handleDisconnected`.
*/
onLeave?: () => void
}
/**
* Нижний тулбар комнаты: микрофон/камера/демонстрация экрана/
* настройки устройств/полноэкранный режим/мини-плеер/чат/выход — собственные
* кнопки на хуках LiveKit (useTrackToggle/DisconnectButton) и панели чата,
* стилизованные по design/mockups/room.html.
* Нижний тулбар комнаты: микрофон/камера/демонстрация экрана/рука/очередь
* рук/вид сцены/настройки устройств/полноэкранный режим/мини-плеер/чат/выход —
* собственные кнопки на хуках LiveKit (useTrackToggle/DisconnectButton) и
* панели чата, стилизованные по design/mockups/room.html.
*
* Кнопка «Вид» (режимы показа и скрытие остальных) рендерится ТОЛЬКО на
* широком экране — условным рендерингом, а не скрытием через CSS: демонстрация
* и мини-плеер скрыты на узком экране через CSS (см. `styles/room.css`), а те
* же настройки показа сцены доступны секцией «Вид» в шторке настроек
* (`DeviceSettingsDialog`). «Рука» — сознательное исключение из этой
* экономии: поднять руку посреди разговора — действие со временем жизни в
* секунды, прятать его в шторку настроек означало бы делать его практически
* недоступным с телефона. «Очередь» показывается только организатору —
* встречается редко, но по той же причине оставлена в тулбаре, а не в
* шторке: организатору с телефона тоже нужно видеть очередь сразу.
* Полноэкранный режим на мобильном ОСТАЁТСЯ в тулбаре (не спрятан в шторку,
* как демонстрация/мини-плеер) — по решению оператора вход в него должен
* быть по аналогии с десктопом; если из-за этого кнопки не помещаются в один
* ряд, `mobileButtonCount`/`.tb-wrap-grid` ниже раскладывают их равномерной
* сеткой, а не как получится через `flex-wrap`.
*/
export function RoomToolbar({
chatVisible,
@@ -73,8 +115,38 @@ export function RoomToolbar({
pipSupported,
pipActive,
onTogglePiP,
handQueue,
onRaiseHand,
onLowerHand,
onLowerHandById,
layoutMode,
onLayoutModeChange,
hideOthers,
onHideOthersChange,
overlayVisible,
onLeave,
}: RoomToolbarProps) {
const toast = useToast()
const isCompact = useIsCompactViewport()
const isOrganizer = useIsOrganizer()
const { localParticipant } = useLocalParticipant()
const handRaised = handQueue.some((entry) => entry.identity === localParticipant.identity)
// Сколько кнопок реально видно на мобильном (демонстрация/мини-окно там
// скрыты через CSS всегда, «Вид» не рендерится вовсе — см. докстринг) —
// считаем в JS, а не через CSS-селекторы вида `:nth-child`: у «Очереди»
// (только у организатора) в DOM есть своя обёртка `.tb-menu-wrap`, из-за
// которой позиция остальных кнопок «плывёт», и подсчёт по структуре DOM
// был бы хрупким. От порога зависит `.tb-wrap-grid` в room.css — ниже он
// переключает перенос на 2 строки с «как поместится» (flex-wrap) на
// равномерную сетку 4 колонки (7 → 4+3, 8 → 4+4).
const mobileButtonCount =
3 /* микрофон, камера, рука */ +
(isOrganizer ? 1 : 0) /* очередь */ +
1 /* настройки */ +
(fullscreenSupported ? 1 : 0) +
(chatVisible ? 1 : 0) +
1 /* выйти */
const mic = useTrackToggle({ source: Track.Source.Microphone })
const camera = useTrackToggle({ source: Track.Source.Camera })
const screenShare = useTrackToggle({
@@ -91,7 +163,9 @@ export function RoomToolbar({
})
return (
<footer className="room-toolbar">
<footer
className={`room-toolbar${overlayVisible ? ' is-visible' : ''}${mobileButtonCount >= 7 ? ' tb-wrap-grid' : ''}`}
>
<button
type="button"
{...mic.buttonProps}
@@ -140,6 +214,33 @@ export function RoomToolbar({
<span className="label">Демонстрация</span>
</button>
<button
type="button"
className={`tb-btn${handRaised ? ' is-hand-raised' : ''}`}
aria-pressed={handRaised}
aria-label={handRaised ? 'Опустить руку' : 'Поднять руку'}
onClick={() => (handRaised ? onLowerHand() : onRaiseHand())}
>
<span className="icon-shell">
<Hand className="lucide" aria-hidden="true" />
{handQueue.length > 0 && (
<span className="badge-count">{handQueue.length > 9 ? '9+' : handQueue.length}</span>
)}
</span>
<span className="label">Рука</span>
</button>
<HandQueueMenu queue={handQueue} onLower={onLowerHandById} />
{!isCompact && (
<StageViewMenu
layoutMode={layoutMode}
onLayoutModeChange={onLayoutModeChange}
hideOthers={hideOthers}
onHideOthersChange={onHideOthersChange}
/>
)}
<button
type="button"
className="tb-btn"
@@ -149,7 +250,10 @@ export function RoomToolbar({
<span className="icon-shell">
<Settings className="lucide" aria-hidden="true" />
</span>
<span className="label">Устройства</span>
{/* На мобильном подпись шире по смыслу («Настройки»): там же в шторке
секция «Вид», а отдельной кнопки под неё в тулбаре нет. На десктопе
панель — только настройки устройств, подпись это отражает. */}
<span className="label">{isCompact ? 'Настройки' : 'Устройства'}</span>
</button>
{fullscreenSupported && (
@@ -182,7 +286,14 @@ export function RoomToolbar({
<span className="icon-shell">
<PictureInPicture2 className="lucide" aria-hidden="true" />
</span>
<span className="label">Мини-окно</span>
{/* На узком экране («Мини-окно» иначе переносится на 2 строки и
кнопка становится выше соседних, см. .label-full/.label-short
в room.css) — только «Мини». Текст, не структура: aria-label
выше уже несёт полный смысл независимо от видимой подписи. */}
<span className="label">
<span className="label-full">Мини-окно</span>
<span className="label-short">Мини</span>
</span>
</button>
)}
@@ -204,7 +315,7 @@ export function RoomToolbar({
</button>
)}
<DisconnectButton className="tb-btn danger" aria-label="Выйти из конференции">
<DisconnectButton className="tb-btn danger" aria-label="Выйти из конференции" onClick={onLeave}>
<span className="icon-shell">
<LogOut className="lucide" aria-hidden="true" />
</span>

View File

@@ -4,10 +4,23 @@ import { useState } from 'react'
import { pluralizeParticipants } from '@/lib/pluralize'
interface RoomTopbarProps {
roomName: string
/**
* Название конференции (`ConferenceOut.title`/`ResolveOut.title`) —
* `null`/не задано, если организатор его не указал. НЕ путать с
* `JoinOut.room_name` (техническое имя комнаты LiveKit вида `hC-Dmos9KEM`,
* которое сюда раньше и попадало по ошибке, см. задачу A6) — тот нужен
* только серверу и в UI показывать нечего.
*/
title: string | null
/** Slug/номер конференции из адреса — для инвайт-чипа (копирование ссылки). */
slug?: string
number?: string
/**
* Полноэкранный режим (`RoomPage.tsx`) прячет топбар в оверлей и показывает
* его только по этому флагу — вне полноэкранного режима не влияет ни на что
* (CSS-правило само по себе действует лишь под `.room-fullscreen-overlay`).
*/
overlayVisible?: boolean
}
/**
@@ -19,7 +32,7 @@ interface RoomTopbarProps {
* тёмных токенов темы `room` (см. `--color-room-tile*`), без новых
* цветов и форм.
*/
export function RoomTopbar({ roomName, slug, number }: RoomTopbarProps) {
export function RoomTopbar({ title, slug, number, overlayVisible }: RoomTopbarProps) {
const participants = useParticipants()
const [copied, setCopied] = useState(false)
@@ -37,9 +50,9 @@ export function RoomTopbar({ roomName, slug, number }: RoomTopbarProps) {
}
return (
<header className="room-topbar">
<header className={`room-topbar${overlayVisible ? ' is-visible' : ''}`}>
<div className="room-title-block">
<h1>{roomName}</h1>
<h1>{title ?? 'Конференция без названия'}</h1>
<p>
<span className="rec-dot" aria-hidden="true" />
Конференция активна · <Users className="lucide" style={{ width: 14, height: 14 }} aria-hidden="true" />{' '}

View File

@@ -0,0 +1,128 @@
import { useMemo, useRef, type ReactNode, type RefObject } from 'react'
import { ChevronLeft, ChevronRight } from 'lucide-react'
import {
TrackLoop,
useGridLayout,
usePagination,
useSwipe,
type GridLayoutDefinition,
type TrackReferenceOrPlaceholder,
} from '@livekit/components-react'
/**
* Раскладки равномерной сетки — расширенный набор поверх библиотечного
* `GRID_LAYOUTS` (@livekit/components-core, `helper/grid-layouts.ts`).
*
* Зачем свой набор. Библиотечный требует `minWidth: 560` уже для сетки 2×2,
* поэтому на телефоне (~390px) максимальной раскладкой оказывается `1×2` — две
* плитки на страницу, и десяток участников превращается в пять страниц
* листания. Здесь добавлены два портретных варианта под узкий экран (2×2 и
* 2×3): плитка шириной ~190px с именем и аватаром читается нормально, а
* страниц становится втрое меньше.
*
* Как это читает `selectGridLayout` (см. её исходник): раскладки сортируются
* по `maxTiles` (при равенстве — по `minWidth`), берётся первая, вмещающая всех
* участников, при этом раскладка пропускается, если ДАЛЬШЕ есть другая с тем же
* `maxTiles` и подходящей ориентацией контейнера. Отсюда пары «портрет/
* ландшафт» на одно и то же число плиток: 1×2 / 2×1 и 2×3 / 3×2 — на широком
* экране выигрывает горизонтальный вариант, на узком вертикальный. Если
* контейнер не дотягивает до `minWidth`/`minHeight`, функция рекурсивно
* спускается к меньшей раскладке, а лишние участники уходят на следующую
* страницу (`usePagination`).
*/
const STAGE_GRID_LAYOUTS: GridLayoutDefinition[] = [
{ columns: 1, rows: 1 },
{ columns: 1, rows: 2, orientation: 'portrait' },
{ columns: 2, rows: 1, orientation: 'landscape' },
// 4 плитки помещаются и на телефоне: 390px / 2 ≈ 190px на плитку.
{ columns: 2, rows: 2, minWidth: 360 },
{ columns: 2, rows: 3, minWidth: 360, minHeight: 480, orientation: 'portrait' },
{ columns: 3, rows: 2, minWidth: 700, orientation: 'landscape' },
{ columns: 3, rows: 3, minWidth: 700 },
{ columns: 4, rows: 4, minWidth: 960 },
{ columns: 5, rows: 5, minWidth: 1100 },
]
interface StageGridProps {
tracks: TrackReferenceOrPlaceholder[]
/** Шаблон плитки — рендерится для каждого трека страницы (как у `GridLayout`, через `TrackLoop`). */
children: ReactNode
/**
* Потолок админки на число одновременно видимых плиток (`instance_settings.media_limits.stage_max_tiles`,
* см. `RoomPage`). Не задан — все раскладки `STAGE_GRID_LAYOUTS` доступны как
* раньше (текущий максимум сетки — 25, 5×5). Реализовано отсечением раскладок
* КРУПНЕЕ потолка из набора, который видит `useGridLayout`: она сама выбирает
* бОльшую свободную раскладку, укладывающую всех участников без пагинации,
* поэтому урезанный набор просто не даёт ей раздуть сетку сверх лимита —
* лишние участники уходят на следующую страницу пагинации (`usePagination`),
* то есть перестают быть подписанными треками, а не просто визуально мельче.
*/
maxTiles?: number
}
/**
* Равномерная сетка плиток с пагинацией — собственная сборка вместо
* библиотечного `GridLayout`.
*
* Причина ровно одна: `GridLayout` не пробрасывает `gridLayouts` в
* `useGridLayout`, то есть набор раскладок у неё жёстко зашит (см.
* `STAGE_GRID_LAYOUTS` выше). Всё остальное — те же публичные хуки, что
* использует оригинал (`useGridLayout` + `usePagination` + `useSwipe` +
* `TrackLoop`) и тот же класс `.lk-grid-layout`, поэтому и стили библиотеки, и
* `useVisualStableUpdate` внутри пагинации работают как раньше. Отличается
* только элемент управления страницами: библиотечные `PaginationIndicator`/
* `PaginationControl` из пакета не экспортируются, вместо них — свой
* `.stage-grid-pages` (кнопки со стрелками + счётчик, доступен и мышью, и с
* клавиатуры; на тач-экране страницы листаются ещё и свайпом).
*/
export function StageGrid({ tracks, children, maxTiles }: StageGridProps) {
const gridEl = useRef<HTMLDivElement | null>(null)
// Хуки библиотеки объявлены с `RefObject<HTMLDivElement>` (типы React 18, где
// `current` был readonly и тип вёл себя ковариантно). В типах React 19
// `current` мутабельный, поэтому `RefObject<HTMLDivElement | null>` в такой
// параметр уже не присваивается — приведение безопасно: оба хука только
// читают `.current` (ResizeObserver и слушатели touch-событий).
const gridRef = gridEl as RefObject<HTMLDivElement>
const gridLayouts = useMemo(
() => (maxTiles == null ? STAGE_GRID_LAYOUTS : STAGE_GRID_LAYOUTS.filter((l) => l.columns * l.rows <= maxTiles)),
[maxTiles],
)
const { layout } = useGridLayout(gridRef, tracks.length, { gridLayouts })
const pagination = usePagination(layout.maxTiles, tracks)
useSwipe(gridRef, {
onLeftSwipe: pagination.nextPage,
onRightSwipe: pagination.prevPage,
})
const hasPages = pagination.totalPageCount > 1
return (
<div ref={gridEl} className="lk-grid-layout stage-grid" data-lk-pagination={hasPages}>
<TrackLoop tracks={pagination.tracks}>{children}</TrackLoop>
{hasPages && (
<div className="stage-grid-pages">
<button
type="button"
aria-label="Предыдущая страница участников"
disabled={pagination.currentPage <= 1}
onClick={pagination.prevPage}
>
<ChevronLeft className="lucide" aria-hidden="true" />
</button>
<span aria-live="polite">
{pagination.currentPage} / {pagination.totalPageCount}
</span>
<button
type="button"
aria-label="Следующая страница участников"
disabled={pagination.currentPage >= pagination.totalPageCount}
onClick={pagination.nextPage}
>
<ChevronRight className="lucide" aria-hidden="true" />
</button>
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,130 @@
import { useEffect, useId, useRef, useState } from 'react'
import { LayoutGrid } from 'lucide-react'
import { STAGE_LAYOUT_MODE_OPTIONS, type StageLayoutMode } from '@/lib/stageLayoutMode'
export interface StageViewProps {
layoutMode: StageLayoutMode
onLayoutModeChange: (mode: StageLayoutMode) => void
/** Карусель остальных участников скрыта — на сцене только основная область. */
hideOthers: boolean
onHideOthersChange: (hide: boolean) => void
}
/**
* Блок выбора вида сцены: режим показа (три варианта) + скрытие остальных
* участников. Один и тот же блок рендерится в двух местах — в поповере кнопки
* «Вид» на десктопе (`StageViewMenu` ниже) и секцией внутри мобильной шторки
* настроек (`DeviceSettingsDialog`), поэтому вынесен отдельно.
*
* «Скрыть остальных» в режиме «Плитки» заблокировано, а не спрятано: все
* плитки там равноправны, скрывать нечего, но исчезающий на ровном месте
* переключатель читался бы как баг — вместо этого он выключен с пояснением.
*/
export function StageViewOptions({
layoutMode,
onLayoutModeChange,
hideOthers,
onHideOthersChange,
}: StageViewProps) {
// Радиогруппе нужно имя, уникальное на документ: блок может оказаться на
// странице дважды (десктопный поповер и шторка живут в разных ветках
// рендера, но полагаться на это не стоит).
const groupName = useId()
const hideOthersDisabled = layoutMode === 'tiles'
return (
<div className="stage-view-options">
<fieldset className="stage-view-modes">
<legend>Режим показа</legend>
{STAGE_LAYOUT_MODE_OPTIONS.map((option) => (
<label
key={option.mode}
className={`stage-view-mode${layoutMode === option.mode ? ' is-active' : ''}`}
>
<input
type="radio"
name={groupName}
value={option.mode}
checked={layoutMode === option.mode}
onChange={() => onLayoutModeChange(option.mode)}
/>
<span className="stage-view-mode-text">
<span className="stage-view-mode-title">{option.title}</span>
<span className="stage-view-mode-hint">{option.hint}</span>
</span>
</label>
))}
</fieldset>
<label className={`stage-view-toggle${hideOthersDisabled ? ' is-disabled' : ''}`}>
<input
type="checkbox"
checked={hideOthers}
disabled={hideOthersDisabled}
onChange={(e) => onHideOthersChange(e.target.checked)}
/>
<span>Скрыть остальных участников</span>
</label>
{hideOthersDisabled && (
<p className="stage-view-note">В режиме «Плитки» все участники равноправны скрывать нечего.</p>
)}
</div>
)
}
/**
* Кнопка «Вид» в тулбаре с поповером над ней — десктопный вход в
* `StageViewOptions`. На мобильном не рендерится вовсе (см. `RoomToolbar`):
* там тулбар и так ужат до пяти кнопок, а те же настройки доступны секцией
* «Вид» в шторке настроек.
*
* Закрытие по клику вне и Escape — тем же паттерном, что меню пользователя в
* `ShellTopbar` (слушатели на document, пока меню открыто).
*/
export function StageViewMenu(props: StageViewProps) {
const [open, setOpen] = useState(false)
const wrapRef = useRef<HTMLDivElement>(null)
useEffect(() => {
if (!open) return
function handlePointerDown(event: MouseEvent) {
if (wrapRef.current && !wrapRef.current.contains(event.target as Node)) {
setOpen(false)
}
}
function handleKeydown(event: KeyboardEvent) {
if (event.key === 'Escape') setOpen(false)
}
document.addEventListener('mousedown', handlePointerDown)
document.addEventListener('keydown', handleKeydown)
return () => {
document.removeEventListener('mousedown', handlePointerDown)
document.removeEventListener('keydown', handleKeydown)
}
}, [open])
return (
<div className="tb-menu-wrap" ref={wrapRef}>
<button
type="button"
className={`tb-btn${open ? ' is-panel-open' : ''}`}
aria-label="Вид сцены"
aria-expanded={open}
aria-haspopup="dialog"
onClick={() => setOpen((v) => !v)}
>
<span className="icon-shell">
<LayoutGrid className="lucide" aria-hidden="true" />
</span>
<span className="label">Вид</span>
</button>
{open && (
<div className="tb-menu" role="dialog" aria-label="Вид сцены">
<StageViewOptions {...props} />
</div>
)}
</div>
)
}

View File

@@ -1,9 +1,11 @@
/**
* Чистая функция выбора «сцены в фокусе» (демонстрация экрана).
*
* Никаких зависимостей от React/DOM/LiveKit SDK — на вход только примитивы,
* на выход тоже примитивы; можно покрыть unit-тестом при появлении раннера
* (vitest в проект сознательно не вводим, тестируем вручную).
* Никаких зависимостей от React/DOM/LiveKit SDK в РАНТАЙМЕ — на вход только
* примитивы, на выход тоже примитивы; можно покрыть unit-тестом при появлении
* раннера (vitest в проект сознательно не вводим, тестируем вручную).
* Единственный импорт из SDK — `import type` у `stageTrackKey` (тип стирается
* при компиляции, рантайм-зависимости не добавляет).
*
* Ключ трека — НЕ sid публикации и НЕ голая identity участника, а составной
* `${identity}:${source}` (см. `RoomStage.tsx`, функция `stageTrackKey`):
@@ -20,9 +22,21 @@
* однозначность (camera и screen_share одного участника — разные ключи).
*/
import type { TrackReferenceOrPlaceholder } from '@livekit/components-react'
/** Вид источника трека на сцене. */
export type StageFocusKind = 'camera' | 'screen_share'
/**
* Ключ трека для `pickStageFocus` — см. обоснование схемы в начале файла.
* Живёт здесь (а не в `RoomStage.tsx`), потому что нужен обеим сторонам:
* сцене — чтобы считать фокус, плитке (`RoomParticipantTile`) — чтобы понять,
* закреплена ли именно она, и каким ключом сообщить о нажатии на «закрепить».
*/
export function stageTrackKey(t: TrackReferenceOrPlaceholder): string {
return `${t.participant.identity}:${t.source}`
}
export interface PickStageFocusInput {
/** Ключи текущих камера-треков (один на участника — трек либо его плейсхолдер). */
cameraKeys: readonly string[]
@@ -35,19 +49,56 @@ export interface PickStageFocusInput {
* `followSpeaker`, для живого переключения фокуса.
*/
speakingCameraKeys: readonly string[]
/**
* Подмножество `cameraKeys` с ЖИВЫМ видео (камера включена и не в мьюте) —
* среди нескольких одновременно говорящих такой участник выигрывает у
* говорящего с выключенной камерой: показывать крупно аватар-заглушку, когда
* рядом говорит человек с картинкой, бессмысленно. Не указан — приоритета
* нет, берётся первый (самый громкий) говорящий, как было раньше.
*/
cameraKeysWithVideo?: readonly string[]
/** Объединённый набор ключей (camera+screenshare) с ПРЕДЫДУЩЕГО рендера — определяет, какие screenshare-ключи «новые». */
prevKeys: readonly string[]
/** Ключ, что был в фокусе на предыдущем рендере; `null` — фокус ещё не выбирался. */
prevFocusKey: string | null
/**
* Режим мини-плеера (PiP): фокус должен ЖИВО следовать за
* активным спикером (переключаться сразу, а не удерживать текущий), в
* отличие от основного окна сцены — там держим фокус, даже если заговорил
* кто-то другой (см. правило 2 ниже и обоснование в `RoomStage.tsx` про
* дребезг `isSpeaking` у фейковых медиапотоков). По умолчанию `false` —
* поведение основного окна не меняется.
* Ключ трека, ЗАКРЕПЛЁННОГО пользователем (кнопка-булавка на плитке
* основного окна; состояние живёт в `RoomStage.tsx` и переезжает в
* мини-плеер через `RoomPage`, см. там `initialPinnedKey`); `null` —
* закрепления нет.
* Закрепление держит фокус вопреки говорящим, но уступает ЛЮБОЙ активной
* демонстрации экрана (формулировка оператора: «перебивается только чьей-либо
* демонстрацией экрана») — а когда демонстрация закончилась, фокус
* возвращается именно на закреплённого, а не на того, кто был до неё:
* правило закрепления стоит ВЫШЕ удержания предыдущего фокуса.
* Ключ закреплённого участника, покинувшего комнату, игнорируется (его нет
* ни в `cameraKeys`, ни в `screenShareKeys`) — снимает закрепление вызывающая
* сторона.
*/
pinnedKey?: string | null
/**
* Фокус должен ЖИВО следовать за активным спикером (переключаться сразу, а
* не удерживать текущий). С 0.0.6 включено и для мини-плеера (PiP), и для
* основного окна — решение оператора (этап 3, задача 3.2). Защита от
* дребезга — на стороне вызывающего: источник «говорящих» — throttled
* `useSpeakingParticipants()` поверх `RoomEvent.ActiveSpeakersChanged` плюс
* удержание в ~1.2 с (см. `useSteadySpeakers` в `RoomStage.tsx`, с 0.0.25 —
* в обеих сценах), не сырой дребезжащий `participant.isSpeaking`.
* По умолчанию `false` — фокус удерживается (см. правило 5).
*/
followSpeaker?: boolean
/**
* Живая демонстрация экрана в фокусе НЕ уступает заговорившему участнику
* (правило 3). Демонстрация — это содержательный центр разговора, и уводить
* её из фокуса на каждую реплику нельзя.
*
* С 0.0.25 включено в ОБЕИХ сценах. До этого мини-плеер (PiP) намеренно
* ходил с `false` — «одна плитка, всегда показываем говорящего»; на практике
* это выглядело как поломка: демонстрация в мини-окне пропадала, стоило
* кому-то сказать слово. По умолчанию всё ещё `false` — это поведение
* функции без явного запроса удержания.
*/
holdScreenShare?: boolean
/**
* Ключ локального участника (та же схема `identity:source`) — предпоследний
* фолбэк, ПЕРЕД чисто первым элементом набора: если фокуса ещё не было и
@@ -64,6 +115,20 @@ export interface PickStageFocusResult {
kind: StageFocusKind | null
}
/**
* Выбирает говорящего, которого стоит показать крупно: среди живых говорящих
* (упорядоченных по громкости) сначала ищем того, у кого включена камера, и
* только если такого нет — берём самого громкого как есть.
*/
function pickSpeakerKey(
speakingCameraKeys: readonly string[],
cameraKeys: readonly string[],
cameraKeysWithVideo: readonly string[],
): string | null {
const liveSpeakers = speakingCameraKeys.filter((key) => cameraKeys.includes(key))
return liveSpeakers.find((key) => cameraKeysWithVideo.includes(key)) ?? liveSpeakers[0] ?? null
}
/**
* Выбирает, какой трек показать крупно (в `FocusLayoutContainer`).
*
@@ -72,24 +137,36 @@ export interface PickStageFocusResult {
* фокус безусловно переходит на него (последний из новых, если появилось
* сразу несколько), даже если до этого в фокусе была камера или другая
* демонстрация. Так же ведут себя типовые UI конференций (Google Meet).
* 2. `followSpeaker` (только PiP): если сейчас есть говорящий — фокус СРАЗУ
* переходит на него, даже если текущий фокус ещё жив. В основном окне
* (`followSpeaker: false`) этот шаг пропускается — см. правило 3.
* 3. Иначе, если текущий фокус жив (остался среди camera/screenshare-ключей) —
* 2. Закрепление (`pinnedKey`): закреплённый участник
* забирает фокус у говорящих и у удержания предыдущего фокуса, но уступает
* ЛЮБОЙ активной демонстрации экрана. Поэтому правило и стоит выше
* удержания (правило 5): как только демонстрация закончилась и
* `screenShareKeys` опустел, фокус возвращается на закреплённого, а не
* остаётся на том, кто был в фокусе до демонстрации.
* 3. `holdScreenShare`: демонстрация, уже стоящая в фокусе, не уступает
* заговорившему — иначе окно уводило бы шэр на каждую реплику.
* 4. `followSpeaker`: если сейчас есть говорящий — фокус СРАЗУ переходит на
* него, даже если текущий фокус ещё жив; среди одновременно говорящих
* предпочитаем того, у кого включена камера (`cameraKeysWithVideo`).
* При `followSpeaker: false` этот шаг пропускается — см. правило 5.
* 5. Иначе, если текущий фокус жив (остался среди camera/screenshare-ключей) —
* держим его: НЕ дёргаем фокус на каждый ре-рендер (изменение состава
* участников, дребезг isSpeaking и т.п.). Это и есть «стабильный фолбэк»
* для PiP, когда никто не говорит — держим предыдущего активного.
* 4. Иначе (фокуса не было или он пропал) — приоритет активной демонстрации
* над камерой; среди камер — активный спикер, иначе `localKey` (если
* указан и жив), иначе первая по порядку.
* участников, дребезг isSpeaking и т.п.). Это и есть «стабильный фолбэк»,
* когда никто не говорит — держим предыдущего активного.
* 6. Иначе (фокуса не было или он пропал) — приоритет активной демонстрации
* над камерой; среди камер — активный спикер (снова с приоритетом камеры),
* иначе `localKey` (если указан и жив), иначе первая по порядку.
*/
export function pickStageFocus({
cameraKeys,
screenShareKeys,
speakingCameraKeys,
cameraKeysWithVideo = [],
prevKeys,
prevFocusKey,
pinnedKey = null,
followSpeaker = false,
holdScreenShare = false,
localKey = null,
}: PickStageFocusInput): PickStageFocusResult {
if (cameraKeys.length === 0 && screenShareKeys.length === 0) {
@@ -102,8 +179,25 @@ export function pickStageFocus({
return { focusKey: newScreenShareKeys[newScreenShareKeys.length - 1], kind: 'screen_share' }
}
if (pinnedKey) {
// Закреплена сама демонстрация — она и есть «активная демонстрация»,
// уступать нечему (UI позволяет закрепить любую плитку, включая шэр).
if (screenShareKeys.includes(pinnedKey)) {
return { focusKey: pinnedKey, kind: 'screen_share' }
}
// Закреплена камера: пока в комнате идёт чья-то демонстрация, она
// перебивает закрепление (правило оператора) — идём дальше по списку.
if (cameraKeys.includes(pinnedKey) && screenShareKeys.length === 0) {
return { focusKey: pinnedKey, kind: 'camera' }
}
}
if (holdScreenShare && prevFocusKey && screenShareKeys.includes(prevFocusKey)) {
return { focusKey: prevFocusKey, kind: 'screen_share' }
}
if (followSpeaker) {
const liveSpeaker = speakingCameraKeys.find((key) => cameraKeys.includes(key))
const liveSpeaker = pickSpeakerKey(speakingCameraKeys, cameraKeys, cameraKeysWithVideo)
if (liveSpeaker) {
return { focusKey: liveSpeaker, kind: 'camera' }
}
@@ -120,7 +214,7 @@ export function pickStageFocus({
return { focusKey: screenShareKeys[screenShareKeys.length - 1], kind: 'screen_share' }
}
const speaking = speakingCameraKeys.find((key) => cameraKeys.includes(key))
const speaking = pickSpeakerKey(speakingCameraKeys, cameraKeys, cameraKeysWithVideo)
if (speaking) {
return { focusKey: speaking, kind: 'camera' }
}

View File

@@ -24,10 +24,44 @@ export interface ChatMessageOut {
*/
export type ChatConnectionStatus = 'connecting' | 'open' | 'closed' | 'error'
/**
* Один участник в очереди поднятых рук (задача B1) — 1:1 с pydantic-схемой
* `HandQueueEntryOut` backend. `identity` — тот же формат, что и
* `Participant.identity` в LiveKit (`str(user_id)` либо `guest:{id}`),
* пригоден для прямого сравнения с `localParticipant.identity`/
* `participant.identity` на сцене.
*/
export interface HandQueueEntry {
identity: string
name: string
raised_at: string
}
/** Источник трека, принудительно выключенного организатором (задача B2). */
export type ForcedMuteSource = 'microphone' | 'camera'
/**
* Одно событие принудительного мьюта (задача B2) — рассылается ВСЕМ
* участникам конференции (канал общий, адресной доставки нет), поэтому
* несёт `identity` затронутого: получатель сам решает, про него ли это
* (см. `ForcedMuteWatcher` — сравнивает с `localParticipant.identity`).
* `nonce` — счётчик хука, растёт на каждое полученное событие: тот же
* `source`/`identity` два раза подряд (например, повторный клик
* организатора на уже выключенный трек) должен переоткрыть тост, а не
* молча схлопнуться в один и тот же объект по `useEffect`-сравнению.
*/
export interface ForcedMuteEvent {
identity: string
source: ForcedMuteSource
nonce: number
}
type IncomingFrame =
| { type: 'history'; messages: ChatMessageOut[] }
| { type: 'message'; message: ChatMessageOut }
| { type: 'error'; code: string }
| { type: 'hand_queue'; queue: HandQueueEntry[] }
| { type: 'forced_mute'; identity: string; source: ForcedMuteSource }
interface UseChatOptions {
/** id конференции — пока не известен (страница ещё не подключилась к LiveKit), WS не открываем. */
@@ -47,6 +81,23 @@ interface UseChatResult {
unavailable: boolean
/** Отправить сообщение (1..2000 символов после strip, пустое/слишком длинное — игнорируется). */
sendMessage: (text: string) => void
/**
* Очередь поднятых рук, упорядоченная по времени поднятия — сервер
* присылает полный снапшот при любом изменении (см. `schemas/room_events.py`
* backend), поэтому клиенту не нужно вести собственное состояние очереди.
* Пуста, пока WS не открыт/не пришёл первый снапшот.
*/
handQueue: HandQueueEntry[]
/** Поднять СВОЮ руку — повторный вызов на уже поднятой руке безвреден (идемпотентно на сервере). */
raiseHand: () => void
/**
* Опустить руку — свою (без аргумента) либо чужую по `identity` (только
* организатору, иначе сервер отклонит `{type:"error",code:"forbidden"}`,
* см. `statusMessage`).
*/
lowerHand: (identity?: string) => void
/** Последнее событие принудительного мьюта (задача B2) — `null` до первого. */
lastForcedMute: ForcedMuteEvent | null
}
/** Close-коды сервера — см. зафиксированный протокол WS. */
@@ -61,16 +112,30 @@ function buildChatWsUrl(conferenceId: string): string {
}
/**
* WS-клиент чата комнаты конференции. Реализует зафиксированный протокол:
* connect → `{type:"auth"}` → `{type:"history"}` → далее входящие
* `{type:"message"}`/`{type:"error"}`.
* WS-клиент комнаты конференции (несмотря на имя — не только чат, задача
* B1). Реализует зафиксированный протокол: connect → `{type:"auth"}` →
* `{type:"history"}` → `{type:"hand_queue"}` → далее входящие
* `{type:"message"}`/`{type:"hand_queue"}`/`{type:"error"}`.
*
* Optimistic-append собственных сообщений НЕ делается: сервер всегда
* Очередь поднятых рук (`raiseHand`/`lowerHand`/`handQueue`) едет по тому же
* соединению, что и чат, — переиспользование уже открытого аутентифицированного
* WS дешевле отдельного эндпоинта (см. `backend/api/chat.py`). Следствие:
* поднять руку нельзя, если чат выключен настройкой инстанса (`enabled=false`,
* соединение вообще не открывается) — принятый компромисс, обоснование в
* коммите задачи B1.
*
* Optimistic-append собственных сообщений чата НЕ делается: сервер всегда
* присылает наше же сообщение обратно echo-фреймом `message` — если
* добавлять его на клиенте сразу при отправке, оно задублируется в списке.
* Очередь рук устроена иначе: сервер шлёт ПОЛНЫЙ снапшот на каждое
* изменение, поэтому `raiseHand`/`lowerHand` ничего не трогают в состоянии
* сами — ждут снапшот.
*/
export function useChat({ conferenceId, token, enabled }: UseChatOptions): UseChatResult {
const [messages, setMessages] = useState<ChatMessageOut[]>([])
const [handQueue, setHandQueue] = useState<HandQueueEntry[]>([])
const [lastForcedMute, setLastForcedMute] = useState<ForcedMuteEvent | null>(null)
const forcedMuteNonceRef = useRef(0)
// `wsStatus` меняется ТОЛЬКО из колбэков реального WS-соединения (см. ниже) —
// никогда синхронно в теле эффекта, иначе react-hooks/set-state-in-effect
// (эффект без активной подписки, только синхронизирующий производное
@@ -106,6 +171,7 @@ export function useChat({ conferenceId, token, enabled }: UseChatOptions): UseCh
ws.onopen = () => {
if (stale) return
setMessages([])
setHandQueue([])
setStatusMessage(null)
setUnavailable(false)
setWsStatus('open')
@@ -124,9 +190,19 @@ export function useChat({ conferenceId, token, enabled }: UseChatOptions): UseCh
setMessages(frame.messages)
} else if (frame.type === 'message') {
setMessages((prev) => [...prev, frame.message])
} else if (frame.type === 'hand_queue') {
setHandQueue(frame.queue)
} else if (frame.type === 'forced_mute') {
forcedMuteNonceRef.current += 1
setLastForcedMute({
identity: frame.identity,
source: frame.source,
nonce: forcedMuteNonceRef.current,
})
} else if (frame.type === 'error') {
// Ошибка отдельной операции (например, отклонённое сообщение) — соединение
// не рвётся, просто короткое пояснение пользователю.
// Ошибка отдельной операции (например, отклонённое сообщение или
// запрет опустить чужую руку не-организатору, code:"forbidden") —
// соединение не рвётся, просто короткое пояснение пользователю.
setStatusMessage(`Ошибка чата: ${frame.code}`)
}
}
@@ -168,10 +244,32 @@ export function useChat({ conferenceId, token, enabled }: UseChatOptions): UseCh
ws.send(JSON.stringify({ type: 'message', text: trimmed }))
}, [])
const raiseHand = useCallback(() => {
const ws = wsRef.current
if (!ws || ws.readyState !== WebSocket.OPEN) return
ws.send(JSON.stringify({ type: 'raise_hand' }))
}, [])
const lowerHand = useCallback((identity?: string) => {
const ws = wsRef.current
if (!ws || ws.readyState !== WebSocket.OPEN) return
ws.send(JSON.stringify({ type: 'lower_hand', identity: identity ?? null }))
}, [])
// Наружу — производный статус: пока подключаться нечем (выключено/нет
// conferenceId/token), всегда `closed`, даже если внутренний `wsStatus`
// ещё хранит значение от предыдущего подключения.
const status: ChatConnectionStatus = canConnect ? wsStatus : 'closed'
return { messages, status, statusMessage, unavailable, sendMessage }
return {
messages,
status,
statusMessage,
unavailable,
sendMessage,
handQueue,
raiseHand,
lowerHand,
lastForcedMute,
}
}

View File

@@ -0,0 +1,27 @@
import { useEffect, useState } from 'react'
/** Совпадает с мобильным брейкпоинтом комнаты (`room.css`, `max-width: 600px`). */
const COMPACT_VIEWPORT_QUERY = '(max-width: 600px)'
/**
* Живое отслеживание мобильной ширины — та же схема, что системная тема в
* `useTheme.ts` (matchMedia + change-листенер).
*
* Нужен там, где мобильный вариант — это ДРУГАЯ разметка, а не другие стили:
* шторка вместо модалки (`DeviceSettingsDialog`) и условный рендеринг кнопок
* тулбара (`RoomToolbar`). Прятать элементы новым CSS-правилом поверх
* медиазапроса в `room.css` намеренно не стали — там уже была битва
* специфичности (см. комментарий в конце `room.css`).
*/
export function useIsCompactViewport(): boolean {
const [isCompact, setIsCompact] = useState(() => window.matchMedia(COMPACT_VIEWPORT_QUERY).matches)
useEffect(() => {
const media = window.matchMedia(COMPACT_VIEWPORT_QUERY)
const handleChange = (event: MediaQueryListEvent) => setIsCompact(event.matches)
media.addEventListener('change', handleChange)
return () => media.removeEventListener('change', handleChange)
}, [])
return isCompact
}

View File

@@ -0,0 +1,13 @@
import { useLocalParticipant } from '@livekit/components-react'
import { parseParticipantMetadata } from '@/lib/participantMetadata'
/**
* Организатор ли ТЕКУЩИЙ (локальный) участник комнаты — читает подсказку
* `is_organizer` из метаданных собственного LiveKit-токена (см.
* `lib/participantMetadata.ts`). Только для UI (показать/скрыть кнопки
* организатора) — серверные действия перепроверяют права по БД сами.
*/
export function useIsOrganizer(): boolean {
const { localParticipant } = useLocalParticipant()
return Boolean(parseParticipantMetadata(localParticipant.metadata).is_organizer)
}

View File

@@ -0,0 +1,33 @@
const STORAGE_KEY = 'vidconf-audio-output-device'
/**
* Поддержка выбора устройства аудиовывода — определяем по наличию
* `setSinkId` в рантайме, НЕ по User-Agent: в iOS Safari метода нет вообще
* (маршрутизация звука там целиком на стороне ОС), в Android Chrome есть.
*/
export function isAudioOutputSelectable(): boolean {
return typeof HTMLMediaElement !== 'undefined' && 'setSinkId' in HTMLMediaElement.prototype
}
/**
* Персист выбранного устройства вывода — отдельно от `usePersistentUserChoices`
* LiveKit: их `LocalUserChoices` знает только про вход (микрофон/камера),
* поля для аудиовыхода там нет.
*/
export function loadAudioOutputDeviceId(): string {
try {
return localStorage.getItem(STORAGE_KEY) ?? ''
} catch {
// localStorage недоступен (приватный режим/политики браузера) — без
// сохранённого выбора, устройство по умолчанию.
return ''
}
}
export function saveAudioOutputDeviceId(deviceId: string): void {
try {
localStorage.setItem(STORAGE_KEY, deviceId)
} catch {
// Сохранение недоступно — выбор продержится до конца сессии в комнате.
}
}

View File

@@ -0,0 +1,26 @@
/**
* Метаданные участника из LiveKit access-токена (см. `AccessToken.with_metadata`
* на backend, `services/conference_access.py::build_join`) — JSON
* `{"avatar_url"?: string, "is_organizer"?: true}`. У гостей и участников без
* аватара/прав организатора соответствующие поля отсутствуют.
*/
export interface ParticipantMetadata {
avatar_url?: string | null
/**
* Подсказка для UI — организатор ли участник. НЕ источник авторизации:
* метаданные читает и потенциально может подделать сам клиент. Любое
* серверное действие организатора (например, принудительный мьют)
* перепроверяется backend'ом по владельцу конференции в БД.
*/
is_organizer?: boolean
}
/** Разобрать `participant.metadata` — пустой объект, если поля нет, метаданные пусты или невалидны. */
export function parseParticipantMetadata(metadata: string | undefined): ParticipantMetadata {
if (!metadata) return {}
try {
return JSON.parse(metadata) as ParticipantMetadata
} catch {
return {}
}
}

View File

@@ -0,0 +1,36 @@
import { VideoPresets, type TrackPublishDefaults } from 'livekit-client'
import type { PublishQualityCap } from '@/api/conferences'
/**
* Потолок качества публикации → `TrackPublishDefaults` для `RoomOptions.publishDefaults`
* (см. `RoomPage.tsx`, `roomOptions`).
*
* Ограничивается только `videoEncoding` (битрейт/framerate верхнего слоя
* симулкаста) и набор дополнительных слоёв `videoSimulcastLayers` — НЕ
* фактическое разрешение захвата камеры (`videoCaptureDefaults`, трогать
* его не входит в задачу). WebRTC сам подстраивает реальное разрешение
* кодирования под урезанный битрейт (`degradationPreference`), поэтому
* проверять эффект нужно по фактическому битрейту исходящего видео, а не по
* заявленному разрешению потока.
*
* Слои каждого потолка — все пресеты LiveKit НИЖЕ и РАВНО потолку (без
* дефолтного «h180, h360», который иначе подставился бы сам при пустом
* `videoSimulcastLayers` и мог бы превысить потолок 180p).
*/
const PUBLISH_DEFAULTS_BY_CAP: Record<Exclude<PublishQualityCap, 'off'>, TrackPublishDefaults> = {
'180p': { videoEncoding: VideoPresets.h180.encoding, videoSimulcastLayers: [] },
'360p': { videoEncoding: VideoPresets.h360.encoding, videoSimulcastLayers: [VideoPresets.h180] },
'720p': {
videoEncoding: VideoPresets.h720.encoding,
videoSimulcastLayers: [VideoPresets.h180, VideoPresets.h360],
},
}
/**
* `off` — `undefined`: `publishDefaults` не задаётся вовсе, поведение
* библиотеки не отличается от состояния до появления настройки (см.
* критерий готовности «дефолты сохраняют текущее поведение»).
*/
export function buildPublishDefaults(cap: PublishQualityCap): TrackPublishDefaults | undefined {
return cap === 'off' ? undefined : PUBLISH_DEFAULTS_BY_CAP[cap]
}

View File

@@ -0,0 +1,55 @@
const STORAGE_KEY = 'vidconf-stage-layout-mode'
/**
* Режим показа участников на сцене конференции — выбирается пользователем
* явно (до 0.0.11 раскладка выбиралась автоматически по наличию демонстрации
* экрана и числу участников).
*
* - `standard` — один крупный участник (фокус) + остальные в карусели сбоку;
* кто именно в фокусе, по-прежнему решает `pickStageFocus`.
* - `tiles` — все участники равными плитками, без выделенного крупного.
* - `live-tiles` — равномерная сетка ТОЛЬКО из участников с включённой
* камерой, остальные (камера выключена) — в карусели сбоку.
*/
export type StageLayoutMode = 'standard' | 'tiles' | 'live-tiles'
/** Порядок и подписи режимов в переключателе (единый источник для тулбара и мобильной шторки). */
export const STAGE_LAYOUT_MODE_OPTIONS: readonly {
mode: StageLayoutMode
title: string
hint: string
}[] = [
{ mode: 'standard', title: 'Стандарт', hint: 'Крупно — активный участник, остальные сбоку' },
{ mode: 'tiles', title: 'Плитки', hint: 'Все участники равными плитками' },
{ mode: 'live-tiles', title: 'Живые плитки', hint: 'Плитками — только с камерой, остальные сбоку' },
]
function isStageLayoutMode(value: string | null): value is StageLayoutMode {
return value === 'standard' || value === 'tiles' || value === 'live-tiles'
}
/**
* Сохранённый режим показа — читается при входе в комнату.
*
* Персист сделан своим модулем (как `lib/audioOutputDevice.ts`), а не через
* `usePersistentUserChoices` LiveKit: их `LocalUserChoices` — фиксированная
* структура про устройства и имя, поля для нашей раскладки там нет и добавить
* его нельзя. Схема хранения та же: одна строка в localStorage, любые сбои
* доступа к нему гасятся (приватный режим/политики браузера).
*/
export function loadStageLayoutMode(): StageLayoutMode {
try {
const stored = localStorage.getItem(STORAGE_KEY)
return isStageLayoutMode(stored) ? stored : 'standard'
} catch {
return 'standard'
}
}
export function saveStageLayoutMode(mode: StageLayoutMode): void {
try {
localStorage.setItem(STORAGE_KEY, mode)
} catch {
// Сохранение недоступно — выбор продержится до конца сессии в комнате.
}
}

View File

@@ -1,11 +1,10 @@
import { useState } from 'react'
import { useQuery } from '@tanstack/react-query'
import { ShellTopbar } from '@/components/layout/ShellTopbar'
import { AppFooter } from '@/components/layout/AppFooter'
import { AdminConferencesTab } from '@/components/admin/AdminConferencesTab'
import { AdminUsersTab } from '@/components/admin/AdminUsersTab'
import { AdminTeamsTab } from '@/components/admin/AdminTeamsTab'
import { AdminSettingsTab } from '@/components/admin/AdminSettingsTab'
import { getHealth } from '@/api/health'
import '@/styles/lobby.css'
import '@/styles/calendar.css'
import '@/styles/admin.css'
@@ -30,10 +29,6 @@ const TABS: { value: AdminTab; label: string }[] = [
*/
export function AdminPage() {
const [tab, setTab] = useState<AdminTab>('conferences')
// Версия инстанса для футера — поле `version` в `/api/health`;
// если его нет в ответе, футер версию просто не показывает
// (см. `HealthOut.version`).
const { data: health } = useQuery({ queryKey: ['health'], queryFn: getHealth, retry: false })
return (
<div className="admin-page page-shell">
@@ -63,11 +58,8 @@ export function AdminPage() {
{tab === 'users' && <AdminUsersTab />}
{tab === 'teams' && <AdminTeamsTab />}
{tab === 'settings' && <AdminSettingsTab />}
<footer className="admin-footer">
VidConf{health?.version ? ` · v${health.version}` : ''}
</footer>
</main>
<AppFooter />
</div>
)
}

View File

@@ -2,6 +2,7 @@ import { useCallback, useMemo, useState } from 'react'
import { useQuery } from '@tanstack/react-query'
import { Plus } from 'lucide-react'
import { ShellTopbar } from '@/components/layout/ShellTopbar'
import { AppFooter } from '@/components/layout/AppFooter'
import { ConferenceCalendar, type CalendarViewMode } from '@/components/calendar/ConferenceCalendar'
import { ConferenceFormCard } from '@/components/calendar/ConferenceFormCard'
import { ConferenceOccurrenceDialog } from '@/components/calendar/ConferenceOccurrenceDialog'
@@ -102,6 +103,7 @@ export function CalendarPage() {
/>
</div>
</main>
<AppFooter />
{isFormOpen && (
<ConferenceFormModal

View File

@@ -9,6 +9,7 @@ import {
} from '@/api/conferences'
import { ApiError, errorDetail } from '@/api/client'
import { useAuth } from '@/auth/useAuth'
import { AppFooter } from '@/components/layout/AppFooter'
import { LogoMark } from '@/components/ui/LogoMark'
import { ThemeToggle } from '@/components/ui/ThemeToggle'
import { parseJoinQuery } from '@/lib/parseJoinQuery'
@@ -142,9 +143,11 @@ export function JoinPage() {
state: {
livekitUrl: data.livekit_url,
token: data.token,
roomName: data.room_name,
title: resolved.title,
conferenceId: data.conference_id,
chatEnabled: data.chat_enabled,
publishQualityCap: data.publish_quality_cap,
stageMaxTiles: data.stage_max_tiles,
},
})
} catch (err) {
@@ -409,6 +412,7 @@ export function JoinPage() {
</section>
)}
</main>
<AppFooter />
</div>
)
}

View File

@@ -2,6 +2,7 @@ import { useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { Calendar, Link2, User, Video } from 'lucide-react'
import { ShellTopbar } from '@/components/layout/ShellTopbar'
import { AppFooter } from '@/components/layout/AppFooter'
import { LogoMedallion } from '@/components/ui/LogoMark'
import { createConference } from '@/api/conferences'
import { useToast } from '@/components/ui/ToastProvider'
@@ -39,9 +40,11 @@ export function LobbyPage() {
state: {
livekitUrl: conference.join.livekit_url,
token: conference.join.token,
roomName: conference.join.room_name,
title: conference.title,
conferenceId: conference.join.conference_id,
chatEnabled: conference.join.chat_enabled,
publishQualityCap: conference.join.publish_quality_cap,
stageMaxTiles: conference.join.stage_max_tiles,
number: conference.number,
slug: conference.slug,
},
@@ -113,6 +116,7 @@ export function LobbyPage() {
</div>
</div>
</main>
<AppFooter />
</div>
)
}

Some files were not shown because too many files have changed in this diff Show More