processCatched перед Add проверяет присутствие торрента в qBittorrent (один листинг на тик): если раздача уже есть — усыновляем (promote catched→downloading без повторного Add и без LLM-namer, имя из раздачи), иначе добавляем как раньше. Это убирает бесконечный цикл дубль-Add → 409 → ретрай и лишние вызовы LLM. Инвариант приёма «одна активная на infohash» делает различие «наш/чужой» ненужным. source_type перечитывается под замком (сужение гонки апгрейда F6); при недоступности qBittorrent тик пропускается без вызова LLM. Дедуп на приёме (дубль на уже активную задачу) теперь отражается явным ответом бота «дубль уже активной #id — добавление отменено». Спека download-tracking обновлена (OpenSpec change заархивирован); закрыта задача беклога review-f2-promote-without-add. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
346 lines
26 KiB
Markdown
346 lines
26 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` и для каждой (кроме случая уже
|
||
присутствующего в qBittorrent торрента, см. ниже): вывести отображаемое имя из
|
||
контекста (см. `ingest` «Отображаемое имя торрента из контекста»), добавить
|
||
источник в qBittorrent (категория `qbittorrent.category`, savepath, `rename`) и
|
||
перевести загрузку `catched → downloading`. Отдельного состояния между `catched`
|
||
и `downloading` быть SHALL NOT — успешный `add` сразу переводит в `downloading`
|
||
(которое и означает «в qBit, возможно `metaDL`»).
|
||
|
||
Перед добавлением worker SHALL проверять, **присутствует ли торрент загрузки уже
|
||
в qBittorrent** (по любому из её infohash), опираясь на листинг раздач того же
|
||
тика. Если торрент уже присутствует, worker SHALL **усыновить** его: перевести
|
||
загрузку `catched → downloading` **без повторного `add`** и без вывода имени
|
||
через LLM (`display_name` берётся из имени присутствующей раздачи). Повторный
|
||
`add` здесь не нужен и вреден — qBittorrent отверг бы дубль (напр. `409
|
||
Conflict`), и загрузка зациклилась бы на ретраях. Усыновлённая раздача дальше
|
||
идёт обычным путём отслеживания и раскладки. Проверка присутствия SHALL
|
||
выполняться **до вывода отображаемого имени**, чтобы не тратить LLM-вызов на
|
||
загрузку, которую добавлять не требуется.
|
||
|
||
Инвариант приёма («одна активная загрузка на infohash», см. `ingest`) гарантирует,
|
||
что до этого шага доходит лишь загрузка, для которой в jellybit НЕТ другой
|
||
активной задачи; поэтому присутствие торрента в qBittorrent worker трактует как
|
||
«усыновить и разложить», а не как конфликт с чужой задачей.
|
||
|
||
Если листинг раздач qBittorrent недоступен (сетевой сбой), worker пойманную
|
||
загрузку в этот тик трогать SHALL NOT (ни `add`, ни namer) и повторить на
|
||
следующем; устойчивая недоступность отсекается предохранителем `catch_timeout`
|
||
(см. «Предохранитель зависшего catched»).
|
||
|
||
Добавление в qBittorrent worker SHALL выполнять **по типу источника**
|
||
(`source_type`):
|
||
|
||
- Для `magnet`/`url` — передавать `source_ref` как ссылку (`urls` API
|
||
`/torrents/add`); подсказку отображаемого имени брать из полей самой ссылки.
|
||
- Для `torrent` — загружать сохранённые байты `.torrent` (привязанные к
|
||
загрузке при приёме) и передавать их **файлом** (`torrents` API
|
||
`/torrents/add`), НЕ как ссылку; подсказку отображаемого имени брать из
|
||
метаданных торрента (имя раздачи). Добавление байтами SHALL сохранять полные
|
||
метаданные (qBittorrent стартует без докачки), поэтому воскрешать раздачу по
|
||
magnet-хешу вместо файла система SHALL NOT.
|
||
|
||
`source_type` для выбора способа добавления worker SHALL перечитывать **под
|
||
блокировкой переходов** непосредственно перед добавлением (а не полагаться на
|
||
снимок, снятый ранее вне блокировки): иначе при точном оверлапе тика с апгрейдом
|
||
пойманной magnet-задачи до `.torrent` (см. `ingest`) воркер добавил бы magnet из
|
||
устаревшего снимка, хотя БД уже `torrent`.
|
||
|
||
Неуспешный `add` (qBittorrent временно отверг/недоступен) SHALL оставлять
|
||
загрузку в `catched` для повторной попытки на следующем тике; переход в
|
||
терминальное состояние по единичному сбою происходить SHALL NOT (ретраи —
|
||
естественными тиками поллинга, отсечка — `catch_timeout`).
|
||
|
||
Медленные вызовы (вывод имени через LLM, `qbt.Add`) SHALL выполняться **вне**
|
||
блокировки сериализации переходов, чтобы не задерживать команды транспортов и
|
||
поллинг. Под блокировкой сериализуется только **запись перехода** `catched →
|
||
downloading` (см. «Переходы состояний сериализуются воркером»), с ре-валидацией,
|
||
что загрузка всё ещё в `catched` (иначе переход отклоняется — например, при
|
||
параллельной отмене).
|
||
|
||
#### Scenario: Пойманная magnet-загрузка добавляется в qBittorrent
|
||
|
||
- **GIVEN** загрузка в состоянии `catched` с `source_type = magnet`, торрента
|
||
ещё нет в qBittorrent
|
||
- **WHEN** worker обрабатывает тик
|
||
- **THEN** выводится отображаемое имя, ссылка добавляется в qBittorrent с
|
||
нашей категорией и `rename`
|
||
- **AND** загрузка переходит в `downloading`
|
||
|
||
#### Scenario: Пойманная .torrent-загрузка добавляется файлом
|
||
|
||
- **GIVEN** загрузка в состоянии `catched` с `source_type = torrent` и
|
||
сохранёнными байтами файла, торрента ещё нет в qBittorrent
|
||
- **WHEN** worker обрабатывает тик
|
||
- **THEN** сохранённые байты добавляются в qBittorrent файлом (`torrents`), с
|
||
нашей категорией и `rename`, без обращения к magnet-хешу
|
||
- **AND** загрузка переходит в `downloading`
|
||
|
||
#### Scenario: Торрент уже присутствует в qBittorrent — усыновление без add
|
||
|
||
- **GIVEN** загрузка в состоянии `catched`, торрент которой уже присутствует в
|
||
qBittorrent (добавлен ранее вручную/другим клиентом либо `add` прошёл на
|
||
прошлом тике, а запись перехода не удалась)
|
||
- **WHEN** worker обрабатывает тик
|
||
- **THEN** worker НЕ вызывает `qbt.Add` и НЕ выводит отображаемое имя через LLM
|
||
- **AND** `display_name` записывается из имени присутствующей раздачи
|
||
- **AND** загрузка переходит в `downloading` и идёт обычным путём к раскладке
|
||
|
||
#### Scenario: qBittorrent недоступен при проверке присутствия — повтор
|
||
|
||
- **GIVEN** загрузка в `catched`, листинг раздач qBittorrent не удался
|
||
- **WHEN** worker обрабатывает тик
|
||
- **THEN** worker НЕ вызывает namer и НЕ добавляет источник
|
||
- **AND** загрузка остаётся в `catched` и попытка повторяется на следующем тике
|
||
|
||
#### Scenario: Временный сбой добавления — повтор
|
||
|
||
- **GIVEN** загрузка в `catched`, торрента в qBittorrent нет, но `add` не удался
|
||
- **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`)
|
||
|
||
### Requirement: Легальность переходов задаётся декларативным графом
|
||
|
||
Множество легальных переходов машины состояний загрузки SHALL быть объявлено
|
||
декларативно в едином месте (`internal/store`) как отображение `from →
|
||
{разрешённые to}`, покрывающее все переходы, которые worker выполняет по всем
|
||
capability (прямой путь, `state-reconciliation`, `review`). Этот граф SHALL быть
|
||
единственным источником истины о легальности рёбер.
|
||
|
||
Запись состояния (`setState`, общая основа `SetDownloadState` и
|
||
`ActivateIfNoOtherActive`) SHALL применять переход, только если он либо объявлен
|
||
ребром графа, либо является идемпотентным самопереходом (`from == to`, переустановка
|
||
того же состояния — например, повторная запись ошибки). Переход, не удовлетворяющий
|
||
ни одному из условий, запись SHALL отклонять (0 строк UPDATE → ошибка), НЕ применяя
|
||
его.
|
||
|
||
Гейт графа SHALL быть **ортогонален** остальным гардам записи и НЕ SHALL их ослаблять:
|
||
существующий запрет молча оживить терминальную задачу (переход из терминального
|
||
состояния разрешён только через `ActivateIfNoOtherActive` с проверкой владения
|
||
хешами) и инвариант «не более одной активной загрузки на infohash» сохраняются. Как
|
||
следствие, ребро из терминального состояния (напр. `failed → downloading` при retry)
|
||
SHALL проходить только revive-путём (`ActivateIfNoOtherActive`) и SHALL отклоняться
|
||
обычным `SetDownloadState`.
|
||
|
||
Граф SHALL быть надмножеством всех переходов, которые worker уже выполняет: введение
|
||
гейта НЕ SHALL менять поведение существующих легальных переходов.
|
||
|
||
#### Scenario: Объявленный переход применяется
|
||
|
||
- **GIVEN** загрузка в состоянии `downloading`
|
||
- **WHEN** worker записывает переход `downloading → completed` (объявленное ребро)
|
||
- **THEN** состояние становится `completed`
|
||
|
||
#### Scenario: Необъявленный переход отклоняется
|
||
|
||
- **GIVEN** загрузка в состоянии `review`
|
||
- **WHEN** делается попытка записать переход `review → done` (ребра в графе нет)
|
||
- **THEN** запись отклоняется с ошибкой, состояние остаётся `review`
|
||
|
||
#### Scenario: Идемпотентная переустановка состояния разрешена
|
||
|
||
- **GIVEN** загрузка в состоянии `deferred`
|
||
- **WHEN** записывается переход `deferred → deferred` (самопереход)
|
||
- **THEN** запись проходит, состояние остаётся `deferred`
|
||
|
||
#### Scenario: Ребро из терминального состояния только через revive
|
||
|
||
- **GIVEN** загрузка в терминальном состоянии `failed`
|
||
- **WHEN** переход `failed → downloading` делается обычным `SetDownloadState`
|
||
- **THEN** запись отклоняется (терминальную задачу нельзя оживить мимо гарда владения)
|
||
- **AND** тот же переход через `ActivateIfNoOtherActive` (при свободном infohash)
|
||
проходит
|
||
|
||
### Requirement: Пропажа источника у активной загрузки
|
||
|
||
Поллинг активных загрузок (`downloading`) SHALL обнаруживать пропажу источника:
|
||
если раздача, совпадающая с любым из известных хешей загрузки, отсутствует в
|
||
выдаче qBittorrent, система SHALL применять **тот же дебаунс пропажи источника**,
|
||
что и сверка рассинхрона (счётчик `source_miss_count`, порог
|
||
`[worker].source_missing_threshold`; см. `state-reconciliation` «Дебаунс пропажи
|
||
источника»). Любое обнаружение раздачи SHALL сбрасывать счётчик.
|
||
|
||
После `N` подряд идущих тиков без раздачи (`N =
|
||
[worker].source_missing_threshold`) система SHALL переводить загрузку
|
||
`downloading → failed` с `error_code` `source_gone` и уведомлять автора. До
|
||
достижения порога загрузка SHALL оставаться в `downloading` (транзиентная
|
||
недоступность qBittorrent, например рестарт демона, не должна ронять задачу).
|
||
|
||
`source_gone` система SHALL трактовать как отдельную причину, отличную от
|
||
`qbit_error` (реальная ошибка qBittorrent) и от `magnet_timeout`/`stalled` (наша
|
||
нетерпеливость). Восстановлению сверкой (`reconcileRecovery`) `source_gone`
|
||
подлежать SHALL NOT — удаление источника из qBittorrent намеренно, молча
|
||
воскрешать задачу нельзя. Задача SHALL оставаться штатно восстановимой вручную
|
||
(`Retry` заново отдаёт сохранённый источник в qBittorrent).
|
||
|
||
Состояние `catched` этим правилом затрагиваться SHALL NOT: у пойманной загрузки
|
||
раздачи в qBittorrent ещё нет по дизайну (см. «catched не считается пропажей
|
||
раздачи»), а цикл активных загрузок листает только `downloading`.
|
||
|
||
#### Scenario: Источник пропал у активной загрузки дольше порога
|
||
|
||
- **GIVEN** загрузка в `downloading`, чья раздача удалена из qBittorrent
|
||
- **WHEN** раздача отсутствует `source_missing_threshold` подряд идущих тиков
|
||
- **THEN** загрузка переходит в `failed` с `error_code` `source_gone`
|
||
- **AND** автор загрузки уведомляется
|
||
|
||
#### Scenario: Кратковременная пропажа источника не роняет задачу
|
||
|
||
- **GIVEN** загрузка в `downloading`
|
||
- **WHEN** раздача отсутствует в qBittorrent меньше `source_missing_threshold`
|
||
тиков подряд
|
||
- **THEN** загрузка остаётся в `downloading`
|
||
|
||
#### Scenario: Возврат раздачи сбрасывает счётчик
|
||
|
||
- **GIVEN** загрузка в `downloading` с накопленными промахами источника (меньше
|
||
порога)
|
||
- **WHEN** раздача снова обнаружена в qBittorrent
|
||
- **THEN** счётчик промахов сбрасывается в ноль и загрузка ведётся обычной
|
||
сверкой состояния
|
||
|
||
#### Scenario: source_gone не воскрешается сверкой
|
||
|
||
- **GIVEN** загрузка в `failed` с `error_code` `source_gone`
|
||
- **WHEN** её раздача снова появляется в qBittorrent и продвигается
|
||
- **THEN** сверка восстановления её не трогает — задача остаётся в `failed` до
|
||
ручного `Retry`
|
||
|