Быстрый приём: сохранение в 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 @@
## ADDED Requirements
### Requirement: Добавление пойманной загрузки в qBittorrent
Worker SHALL периодически (в поллинг-цикле, под единой блокировкой переходов)
подхватывать загрузки в состоянии `catched` и для каждой: вывести отображаемое
имя из контекста (см. `ingest` «Отображаемое имя торрента из контекста»),
добавить источник в qBittorrent (категория `qbittorrent.category`, savepath,
`rename`) и перевести загрузку `catched → downloading`. Отдельного состояния
между `catched` и `downloading` быть SHALL NOT — успешный `add` сразу переводит
в `downloading` (которое и означает «в qBit, возможно `metaDL`»).
Неуспешный `add` (qBittorrent недоступен и т.п.) SHALL оставлять загрузку в
`catched` для повторной попытки на следующем тике; переход в терминальное
состояние по единичному сбою происходить SHALL NOT (ретраи — естественными
тиками поллинга).
Медленные вызовы (вывод имени через LLM, `qbt.Add`) SHALL выполняться **вне**
блокировки сериализации переходов, чтобы не задерживать команды транспортов и
поллинг. Под блокировкой сериализуется только **запись перехода** `catched →
downloading` (см. «Переходы состояний сериализуются воркером»), с
ре-валидацией, что загрузка всё ещё в `catched` (иначе переход отклоняется —
например, при параллельной отмене).
#### Scenario: Пойманная загрузка добавляется в qBittorrent
- **GIVEN** загрузка в состоянии `catched`
- **WHEN** worker обрабатывает тик
- **THEN** выводится отображаемое имя, источник добавляется в qBittorrent с
нашей категорией и `rename`
- **AND** загрузка переходит в `downloading`
#### Scenario: Временный сбой добавления — повтор
- **GIVEN** загрузка в `catched`, qBittorrent временно недоступен
- **WHEN** worker пытается добавить источник и `add` не удался
- **THEN** загрузка остаётся в `catched`
- **AND** на следующем тике попытка добавления повторяется
#### Scenario: Отмена во время добавления
- **GIVEN** загрузка в `catched`, worker выводит имя и добавляет её вне
блокировки
- **WHEN** параллельно приходит команда отмены (`catched → cancelled`), а затем
worker берёт блокировку для записи перехода
- **THEN** ре-валидация видит, что загрузка уже не в `catched`, и переход в
`downloading` не применяется
### Requirement: Предохранитель зависшего catched
Система SHALL переводить загрузку, задержавшуюся в `catched` дольше
`catch_timeout` (конфигурируемый предохранитель, дефолт консервативный), в
`failed` (`error_code` `qbit_add`) и уведомлять автора. Возраст SHALL считать
от времени попадания в `catched` (создания загрузки). Предохранитель —
редкий страховочный механизм на случай устойчивой недоступности qBittorrent, а
не штатный путь.
#### Scenario: catched висит дольше таймаута
- **GIVEN** загрузка в `catched` дольше `catch_timeout`
- **WHEN** идёт тик поллинга
- **THEN** загрузка переходит в `failed` с `error_code` `qbit_add`
- **AND** автор загрузки уведомляется
### Requirement: catched не считается пропажей раздачи
Система SHALL исключать состояние `catched` из проверок «раздача не найдена в
qBittorrent» — как в поллинге активных загрузок, так и в сверке рассинхрона
(`state-reconciliation`). У пойманной загрузки раздачи в qBittorrent ещё нет по
дизайну, поэтому её отсутствие система SHALL NOT трактовать как рассинхрон,
`orphaned` или пропажу источника.
#### Scenario: Отсутствие раздачи у catched — не рассинхрон
- **GIVEN** загрузка в `catched` (раздачи в qBittorrent ещё нет)
- **WHEN** идёт тик поллинга и сверки
- **THEN** загрузка не считается пропавшей/рассинхронизированной и остаётся в
`catched` (до добавления воркером или срабатывания `catch_timeout`)
@@ -0,0 +1,81 @@
## MODIFIED Requirements
### Requirement: Приём источника и заведение загрузки
Приём SHALL быть единым **быстрым** use-case, общим для всех транспортов (HTTP,
Telegram, CLI): по источнику (Ф1 — magnet) и текстовому контексту система SHALL
синхронно извлечь инфохэши, синтезировать контекст из полей ссылки (без сети),
дедуплицировать по активной задаче и при отсутствии дубля завести загрузку
(`download` в состоянии **`catched`** + записи `download_infohash`), после чего
**сразу вернуть ответ** транспорту. Заведение загрузки и запись её хешей SHALL
выполняться атомарно (см. «Атомарность возврата загрузки в активное
состояние»).
Синхронный путь приёма SHALL NOT обращаться к qBittorrent и SHALL NOT выводить
отображаемое имя (потенциально медленный LLM): и добавление источника в
qBittorrent, и вывод имени выполняются отдельным асинхронным шагом машины
состояний (worker) — см. `download-tracking` «Добавление пойманной загрузки в
qBittorrent».
`catched` — нетерминальное активное состояние: оно участвует в инварианте «не
более одной активной загрузки на infohash» наравне с прочими активными.
#### Scenario: Быстрый приём magnet
- **GIVEN** валидная magnet-ссылка и контекст
- **WHEN** вызывается приём
- **THEN** создаётся `download` в состоянии `catched` с записями
`download_infohash`
- **AND** ответ транспорту отдан без обращения к qBittorrent и без вывода имени
#### Scenario: Дубль по активной задаче на быстром пути
- **GIVEN** уже есть активная (в т.ч. `catched`) загрузка с тем же infohash
- **WHEN** вызывается приём
- **THEN** новая загрузка не создаётся, возвращается существующая
### Requirement: Отображаемое имя торрента из контекста
На шаге добавления пойманной загрузки в qBittorrent (worker) система SHALL
выводить из контекста загрузки человекочитаемое отображаемое имя и передавать
его в qBittorrent (параметр `rename` API `/torrents/add`), чтобы задача в списке
qBit не показывалась безликим `dn` magnet-ссылки. Это же имя система SHALL
**сохранять у загрузки** (`download.display_name`) для последующего показа
заголовком в веб-UI.
Имя SHALL быть коротким читаемым ярлыком (название, опционально режиссёр и
год; для сериала — номер сезона, если он определён), а не куском сырого
контекста. Имя SHALL очищаться от управляющих символов и переводов строк и
SHALL обрезаться по ограничению длины.
Вывод имени SHALL выполняться на шаге добавления, непосредственно перед вызовом
`add` (параметр `rename` действует только в момент добавления), а НЕ в
синхронном пути ответа приёма. В состоянии `catched` (до добавления)
`download.display_name` ещё пуст — веб-UI берёт заголовок из фолбека.
Отображаемое имя SHALL влиять только на отображение (в qBittorrent и как
заголовок в веб-UI) и SHALL NOT влиять на пути файлов на диске, распознавание
или раскладку — реальные пути система по-прежнему читает из qBit API.
#### Scenario: Имя из контекста передаётся в qBittorrent
- **WHEN** на шаге добавления получен непустой контекст, из которого удалось
вывести имя
- **THEN** система передаёт это имя в qBittorrent в параметре `rename`
- **AND** имя — короткий читаемый ярлык вида «название (режиссёр, год)»,
где режиссёр и год опциональны
#### Scenario: Имя сохраняется у загрузки
- **WHEN** на шаге добавления выведено непустое отображаемое имя
- **THEN** система сохраняет его в `download.display_name` (обновлением записи
загрузки)
- **AND** веб-UI использует его заголовком карточки и страницы загрузки
#### Scenario: Контекст пуст или имя не получено
- **WHEN** контекста нет либо ни один способ вывода не дал непустого имени
- **THEN** система добавляет загрузку без параметра `rename`
- **AND** qBittorrent оставляет собственное имя (из `dn`/торрента)
- **AND** `download.display_name` остаётся пустым, а веб-UI берёт заголовок из
фолбека (распознанное название или усечённый источник)
@@ -0,0 +1,42 @@
## ADDED Requirements
### Requirement: Отображение промежуточного состояния catched
Веб-UI SHALL отображать состояние `catched` как штатную промежуточную фазу
(«поймано, добавляется в qBittorrent»): бейдж статуса загрузки SHALL иметь
понятную человекочитаемую подпись для `catched` (а не сырое `catched`), а
загрузка в `catched` SHALL относиться к **активной** группе списка.
Пока отображаемое имя ещё не выведено (в `catched` `download.display_name`
пуст), заголовок загрузки SHALL деградировать по существующему фолбеку
(распознанное название или усечённый источник) — см. «Заголовок загрузки из
имени раздачи». Секция раздачи/живого прогресса для `catched` SHALL корректно
отсутствовать (раздачи в qBittorrent ещё нет), не создавая ошибок отображения.
Карточка/страница загрузки в `catched` SHALL самообновляться самозавершающимся
htmx-поллингом (см. конвенцию веб-UI): по переходе загрузки в `downloading`
интерфейс SHALL отражать это без перезагрузки страницы (подхватить бейдж,
выведенное имя и появившийся живой прогресс), а поллинг фазы `catched` SHALL
завершаться, как только загрузка её покинула.
#### Scenario: Бейдж и группа для catched
- **WHEN** загрузка находится в состоянии `catched`
- **THEN** её бейдж статуса имеет человекочитаемую подпись для `catched`
- **AND** загрузка попадает в активную группу списка
#### Scenario: Заголовок catched без имени
- **GIVEN** загрузка в `catched` с пустым `download.display_name`
- **WHEN** рендерится карточка/страница загрузки
- **THEN** заголовок берётся из фолбека (распознанное название или усечённый
источник), без ошибок отображения
- **AND** секция раздачи/живого прогресса не показывается (раздачи ещё нет)
#### Scenario: Самообновление при переходе в downloading
- **GIVEN** открытая карточка загрузки в `catched`
- **WHEN** worker перевёл загрузку в `downloading`
- **THEN** интерфейс без перезагрузки показывает состояние `downloading`
(бейдж, имя, живой прогресс)
- **AND** поллинг фазы `catched` завершается