Приём (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>
172 lines
12 KiB
Markdown
172 lines
12 KiB
Markdown
# download-tracking Specification
|
||
|
||
## Purpose
|
||
Отслеживание скачивания и прямой путь машины состояний загрузки: поллинг
|
||
qBittorrent и сопоставление его состояний (downloading → completed; готовность
|
||
только когда файлы на месте), таймауты-предохранители (`magnet_timeout`/
|
||
`stuck_after`), ошибка qBit → failed, усыновление раздач по категории/тегу и
|
||
переходы под per-download блокировкой. Сверка уже разложенного с реальностью —
|
||
в `state-reconciliation`.
|
||
## Requirements
|
||
### Requirement: Поллинг qBittorrent и сопоставление состояний
|
||
|
||
Worker SHALL периодически (`worker.poll_interval`, дефолт 5 с) опрашивать
|
||
qBittorrent и сопоставлять его состояния раздачи с состоянием загрузки в БД.
|
||
Готовые к раскладке состояния (`uploading`/`stalledUP`/`pausedUP`/`stoppedUP`/
|
||
`queuedUP`/`forcedUP`, с учётом различий имён между qBit v4 и v5) SHALL переводить
|
||
загрузку в `completed`. Ещё качающиеся состояния (`downloading`/`stalledDL`/
|
||
`metaDL`/…) SHALL оставлять её в `downloading`.
|
||
|
||
#### Scenario: Раздача завершилась
|
||
|
||
- **GIVEN** загрузка в `downloading`
|
||
- **WHEN** qBittorrent сообщает состояние `stalledUP` и файлы на месте
|
||
- **THEN** загрузка переходит в `completed`
|
||
|
||
### Requirement: Готовность только когда файлы на месте
|
||
|
||
Переходные состояния qBittorrent система SHALL трактовать как «ждём»
|
||
(`moving`/`checkingUP`/`checkingResumeData`/`allocating`): оставаться в
|
||
`downloading` и НЕ объявлять готовность, даже если выставлены флаги `UP`, пока
|
||
qBit не завершит перенос/проверку. Финальные пути файлов система SHALL брать из
|
||
API после завершения переноса.
|
||
|
||
#### Scenario: Ждём завершения переноса
|
||
|
||
- **GIVEN** загрузка, у которой qBittorrent в состоянии `moving`
|
||
- **WHEN** идёт тик поллинга
|
||
- **THEN** загрузка остаётся в `downloading`, готовность не объявляется
|
||
|
||
### Requirement: Таймауты-предохранители downloading
|
||
|
||
Система SHALL переводить `metaDL`/`forcedMetaDL` дольше `magnet_timeout` (дефолт
|
||
`24h`, редкий предохранитель) в `failed` (`error_code` `magnet_timeout`), а
|
||
`stalledDL` дольше `stuck_after` — в `stuck` (`error_code`
|
||
`stalled`). Возраст система SHALL считать от времени добавления в qBittorrent
|
||
(`added_on`), а не от создания задачи, чтобы базис переживал retry и усыновление.
|
||
Долгий `metaDL` система НЕ SHALL убивать агрессивно (медленные трекеры — норма).
|
||
|
||
#### Scenario: Завис на метаданных дольше таймаута
|
||
|
||
- **GIVEN** раздача в `metaDL` дольше `magnet_timeout` от `added_on`
|
||
- **WHEN** идёт тик поллинга
|
||
- **THEN** загрузка переходит в `failed` с `error_code` `magnet_timeout`
|
||
|
||
### Requirement: Ошибка qBittorrent переводит в failed
|
||
|
||
Состояния `error`/`missingFiles` система SHALL трактовать как настоящий провал и
|
||
переводить загрузку в `failed` (`error_code` `qbit_error`) — в отличие от
|
||
таймаутов-предохранителей, такой провал сверкой не воскрешается.
|
||
|
||
#### Scenario: qBit сообщает об ошибке
|
||
|
||
- **GIVEN** раздача в состоянии `missingFiles`
|
||
- **WHEN** идёт тик поллинга
|
||
- **THEN** загрузка переходит в `failed` с `error_code` `qbit_error`
|
||
|
||
### Requirement: Усыновление раздач по категории или тегу
|
||
|
||
Worker SHALL периодически сверять раздачи qBittorrent с БД и **усыновлять** те, у
|
||
которых наша категория (`qbittorrent.category`) ИЛИ тег (`qbittorrent.tag`), а
|
||
записи в БД ещё нет, заводя для них загрузку в состоянии `downloading`. Категория
|
||
ставится на добавляемые нами раздачи (push); тег позволяет подхватить уже
|
||
существующую раздачу (pull), не трогая её категорию и файлы.
|
||
|
||
#### Scenario: Подхват существующей раздачи по тегу
|
||
|
||
- **GIVEN** в qBittorrent есть раздача с тегом `qbittorrent.tag`, которой нет в БД
|
||
- **WHEN** worker сверяет qBittorrent с БД
|
||
- **THEN** для раздачи заводится загрузка в состоянии `downloading`
|
||
|
||
### Requirement: Переходы состояний сериализуются воркером
|
||
|
||
Все переходы состояний загрузки SHALL сериализоваться worker'ом под единой
|
||
блокировкой (поллинг-цикл и команды всех транспортов проходят через неё), чтобы
|
||
два источника перехода не гонялись за одно состояние. Состояние SHALL быть
|
||
персистентным в SQLite; активность загрузки SHALL выводиться только из `state`,
|
||
без отдельного флага.
|
||
|
||
#### Scenario: Команды сериализуются
|
||
|
||
- **GIVEN** две одновременные команды к одной загрузке из разных транспортов
|
||
- **WHEN** они обрабатываются
|
||
- **THEN** переходы применяются последовательно под блокировкой, без гонки
|
||
|
||
### 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`)
|
||
|