Принимаем .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>
17 KiB
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_codemagnet_timeout
Requirement: Ошибка qBittorrent переводит в failed
Состояния error/missingFiles система SHALL трактовать как настоящий провал и
переводить загрузку в failed (error_code qbit_error) — в отличие от
таймаутов-предохранителей, такой провал сверкой не воскрешается.
Scenario: qBit сообщает об ошибке
- GIVEN раздача в состоянии
missingFiles - WHEN идёт тик поллинга
- THEN загрузка переходит в
failedсerror_codeqbit_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как ссылку (urlsAPI/torrents/add); подсказку отображаемого имени брать из полей самой ссылки. - Для
torrent— загружать сохранённые байты.torrent(привязанные к загрузке при приёме) и передавать их файлом (torrentsAPI/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_codeqbit_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) проходит