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>
26 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 и для каждой (кроме случая уже
присутствующего в 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как ссылку (urlsAPI/torrents/add); подсказку отображаемого имени брать из полей самой ссылки. - Для
torrent— загружать сохранённые байты.torrent(привязанные к загрузке при приёме) и передавать их файлом (torrentsAPI/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_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) проходит
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_codesource_gone - AND автор загрузки уведомляется
Scenario: Кратковременная пропажа источника не роняет задачу
- GIVEN загрузка в
downloading - WHEN раздача отсутствует в qBittorrent меньше
source_missing_thresholdтиков подряд - THEN загрузка остаётся в
downloading
Scenario: Возврат раздачи сбрасывает счётчик
- GIVEN загрузка в
downloadingс накопленными промахами источника (меньше порога) - WHEN раздача снова обнаружена в qBittorrent
- THEN счётчик промахов сбрасывается в ноль и загрузка ведётся обычной сверкой состояния
Scenario: source_gone не воскрешается сверкой
- GIVEN загрузка в
failedсerror_codesource_gone - WHEN её раздача снова появляется в qBittorrent и продвигается
- THEN сверка восстановления её не трогает — задача остаётся в
failedдо ручногоRetry