Быстрый приём: сохранение в catched, добавление в qBittorrent — шаг worker'а

Приём (Ingest) стал быстрым: синхронно только парс magnet, синтез контекста из
полей ссылки, атомарный дедуп и запись загрузки в новое состояние `catched` —
ответ клиенту сразу. Медленный вывод имени (LLM) и добавление в qBittorrent
вынесены в асинхронный шаг машины состояний, который двигает worker.

- store: состояние `catched` (нетерминальное, активная группа); атомарный
  переход PromoteCatched (catched → downloading + display_name) с гардом
  state='catched' (ре-валидация после сетевых вызовов вне блокировки)
- ingest: убраны namer/qbt из пути приёма; пишем `catched`, отвечаем сразу
- worker.processCatched: вне w.mu выводит имя и qbt.Add, под w.mu — короткий
  переход; сбой add оставляет catched (ретрай тиком); предохранитель
  catch_timeout → failed(qbit_add)+notify; catched исключён из проверок пропажи
- config: worker.catch_timeout (дефолт 10m)
- веб-UI: бейдж catched, активная группа, самозавершающийся htmx-поллинг
  карточки/страницы до перехода в downloading; Telegram-текст без сырого catched
- OpenSpec: дельты ingest/download-tracking/web-ui влиты в спеки, change
  заархивирован

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
av
2026-07-07 21:29:28 +03:00
co-authored by Claude Opus 4.8
parent f0ce6b4bc8
commit 0d263270cb
30 changed files with 1198 additions and 348 deletions
@@ -0,0 +1,78 @@
## Why
Сейчас приём (`Ingest`) синхронно делает всё: парсит источник, выводит
отображаемое имя (потенциально **медленный вызов LLM** в `namer.DeriveName`) и
добавляет источник в qBittorrent — и только потом отвечает клиенту. Долгий LLM
и внешний запрос к qBit задерживают ответ HTTP API / веб-UI / Telegram и
расширяют окно «строка в БД есть, в qBittorrent ещё нет».
Идея: сделать приём **быстрым** — синхронно только валидировать и сохранить
загрузку (новое состояние `catched`), сразу вернув ответ; вывод имени и
добавление в qBittorrent вынести в отдельный **асинхронный шаг машины
состояний**, который двигает worker.
## What Changes
- Новое состояние **`catched`** — загрузка поймана и персистентно сохранена
(быстрый путь). Нетерминальное, активное (участвует в инварианте «≤1 активная
загрузка на infohash»).
- **Приём (`ingest`) — быстрый**: парс magnet, извлечение инфохэшей, синтез
контекста из полей ссылки (дёшево, без сети), атомарный дедуп и запись
загрузки в `catched`. Ответ клиенту сразу. Синхронного вывода имени и
добавления в qBittorrent в приёме больше нет.
- **Асинхронный шаг (`download-tracking`, worker)**: на каждом тике worker
подхватывает `catched`-загрузки, выводит отображаемое имя из контекста (LLM +
фолбек), добавляет источник в qBittorrent (категория/savepath/rename) и
переводит `catched → downloading` (отдельного `added-to-qbittorrent` нет —
`downloading` и так значит «в qBit, возможно metaDL»).
- **Обработка сбоев вне запроса клиента**: неуспешный `add` оставляет загрузку
в `catched` (worker перетыкивает на следующем тике); предохранитель
`catch_timeout` уводит долго-зависший `catched` в `failed`
(`error_code` `qbit_add`) с уведомлением автора.
- `catched` **исключён** из проверок «раздача не найдена» (у него раздачи нет
по дизайну) — ни поллинг, ни сверка не считают его рассинхроном/orphaned.
- **Веб-UI** показывает промежуточное состояние `catched` (бейдж/фаза
жизненного цикла), заголовок деградирует, пока имя не выведено (фолбек уже
есть). `catched` попадает в активную группу списка.
## Capabilities
### New Capabilities
_Нет._ Изменение переиспользует существующие capabilities.
### Modified Capabilities
- `ingest`: приём становится быстрым — сохранение в `catched` и мгновенный
ответ; синхронный вывод имени и добавление в qBittorrent из приёма убраны
(переезжают в асинхронный шаг). Требования по выводу имени переформулированы:
выполняются на шаге добавления, а не в пути ответа клиента.
- `download-tracking`: добавляется шаг «добавление пойманной загрузки в
qBittorrent» (вывод имени + `add` + переход `catched → downloading`),
предохранитель `catch_timeout`, исключение `catched` из проверок пропажи
раздачи.
- `web-ui`: человекочитаемый бейдж для `catched`; активная группа включает
`catched`; заголовок при пустом имени — по фолбеку; карточка `catched`
самообновляется htmx-поллингом до перехода в `downloading`.
_Без спек-правок:_ `state-reconciliation` и `live-status` не меняются.
Исключение `catched` из проверок рассинхрона нормативно закреплено требованием
`download-tracking` «catched не считается пропажей раздачи» (само поведение уже
верно — `catched` не входит в `desyncStates` и дебаунс пропажи источника его не
трогает). Иллюстративное перечисление активных состояний в тексте
`state-reconciliation` остаётся на кросс-ссылке и будет выверено при следующем
касании этой спеки. `live-status`: у `catched` нет qBit-телеметрии по дизайну —
живой прогресс для него корректно отсутствует.
## Impact
- Код: `internal/store` (состояние `StateCatched`, группа active), `internal/
ingest` (быстрый путь: убрать namer/qbt-add, писать `catched`), `internal/
worker` (новый шаг обработки `catched`: namer + qbt-add + переход, таймаут),
`internal/httpapi`+`internal/tgbot` (без правок API — ответ и так по Result),
веб-UI шаблоны (бейдж/фаза `catched`).
- Конфиг: новый `catch_timeout` (предохранитель), дефолт консервативный.
- Данные: у активной загрузки теперь есть фаза без infohash-раздачи; схема БД
не меняется (используем существующие `state`/`error_code`/`error_msg`).
- Совместимость: существующие загрузки не затронуты; переход одноразовый на
уровне логики приёма.