Files
jellybit/openspec/specs/download-tracking/spec.md
T
avandClaude Opus 4.8 14d615a7c2 Приём: добавление загрузки по .torrent-файлу
Принимаем .torrent как загруженные байты — через файл-пикер в веб-форме и
Telegram-документ, наряду с magnet. Файл несёт полные метаданные: работает
там, где magnet не резолвится (закрытые трекеры, без DHT), и даёт максимум
контекста для распознавания без сети.

- internal/torrent: парсер поверх anacrolix/torrent/metainfo — инфохэш(и)
  (v1 SHA1 исходных байтов info; v2 BEP52 при наличии) + Context() из имени,
  дерева файлов, размера, трекеров. Извлечение файлов панико-безопасно
  (недоверенный вход).
- Персистентность байтов: таблица-спутник download_torrent (миграция 0009);
  пишется в транзакции создания загрузки, только на ветке создания (не при
  дедупе). Байты живут весь срок строки — нужны для повторного добавления
  при retry.
- ingest: Request.TorrentData/TorrentName, диспетч парсера; source_ref —
  человекочитаемый референс (имя раздачи/файла), не адрес добавления.
- worker: общий sourceAddParts ветвит по source_type в ОБОИХ add-путях —
  processCatched и Retry (torrent добавляется файлом, не magnet-хешем).
- Транспорты: multipart-форма с файл-пикером (деградация без JS) и приём
  Telegram-документа (скачивание с редактированием токена из ошибок — секрет
  не в логи; обработка до ветки pending/текста).

Разработка по OpenSpec (SDD): change torrent-file-ingest, два чекпоинта ревью
(дизайн до кода, код до архива) сабагентами; дельты влиты в спеки, change
архивирован. Ручная проверка на живом qBittorrent (7.3) — за деплоем.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-08 10:52:31 +03:00

245 lines
17 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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`»).
Добавление в qBittorrent worker SHALL выполнять **по типу источника**
(`source_type`):
- Для `magnet`/`url` — передавать `source_ref` как ссылку (`urls` API
`/torrents/add`); подсказку отображаемого имени брать из полей самой ссылки.
- Для `torrent` — загружать сохранённые байты `.torrent` (привязанные к
загрузке при приёме) и передавать их **файлом** (`torrents` API
`/torrents/add`), НЕ как ссылку; подсказку отображаемого имени брать из
метаданных торрента (имя раздачи). Добавление байтами SHALL сохранять полные
метаданные (qBittorrent стартует без докачки), поэтому воскрешать раздачу по
magnet-хешу вместо файла система SHALL NOT.
Неуспешный `add` (qBittorrent недоступен и т.п.) SHALL оставлять загрузку в
`catched` для повторной попытки на следующем тике; переход в терминальное
состояние по единичному сбою происходить SHALL NOT (ретраи — естественными
тиками поллинга).
Медленные вызовы (вывод имени через LLM, `qbt.Add`) SHALL выполняться **вне**
блокировки сериализации переходов, чтобы не задерживать команды транспортов и
поллинг. Под блокировкой сериализуется только **запись перехода** `catched →
downloading` (см. «Переходы состояний сериализуются воркером»), с
ре-валидацией, что загрузка всё ещё в `catched` (иначе переход отклоняется —
например, при параллельной отмене).
#### Scenario: Пойманная magnet-загрузка добавляется в qBittorrent
- **GIVEN** загрузка в состоянии `catched` с `source_type = magnet`
- **WHEN** worker обрабатывает тик
- **THEN** выводится отображаемое имя, ссылка добавляется в qBittorrent с
нашей категорией и `rename`
- **AND** загрузка переходит в `downloading`
#### Scenario: Пойманная .torrent-загрузка добавляется файлом
- **GIVEN** загрузка в состоянии `catched` с `source_type = torrent` и
сохранёнными байтами файла
- **WHEN** worker обрабатывает тик
- **THEN** сохранённые байты добавляются в qBittorrent файлом (`torrents`), с
нашей категорией и `rename`, без обращения к magnet-хешу
- **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`)
### 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)
проходит