# 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` (иначе переход отклоняется — например, при параллельной отмене). Вывод имени (LLM) занимает секунды и идёт вне блокировки, поэтому загрузку могут отменить (`catched → cancelled`) в это окно. Чтобы отменённая задача не оставила неуправляемый торрент в qBittorrent, worker SHALL применять комбинированную защиту. Порядок шагов относительно блокировки переходов: `[под блокировкой]` re-read состояния → `[вне блокировки]` свежий листинг присутствия → `[вне блокировки]` `add` → `[под блокировкой]` запись перехода и (при неуспехе) re-read состояния для решения об уборке → `[вне блокировки]` удаление. Сетевые вызовы (листинг, `add`, удаление) под блокировкой держаться SHALL NOT. - **Re-read состояния перед `add`.** Непосредственно перед `qbt.Add` (после вывода имени) worker SHALL под блокировкой переходов перечитать запись и, если она уже НЕ в `catched` (отменена), НЕ вызывать `add` и загрузку в этот тик пропустить. Это сужает окно гонки до промежутка между re-read и записью перехода. - **Подтверждение отсутствия торрента перед `add`.** Непосредственно перед `add` worker SHALL свежим листингом раздач qBittorrent подтвердить, что раздачи ни с одним из infohash загрузки ещё НЕТ. Если этот листинг **не удался** (сетевой сбой), worker `add` выполнять SHALL NOT и загрузку в этот тик пропустить (повтор на следующем): без подтверждённого отсутствия признак «своё/чужое» неизвестен, и последующее удаление-с-данными было бы небезопасным. Если торрент уже присутствует (внешний клиент/пользователь добавил тот же infohash в окно гонки), worker `add` выполнять SHALL NOT и загрузку в этот тик пропустить — на следующем тике её усыновит ветка «уже присутствует». Подтверждённое отсутствие непосредственно-перед-`add` SHALL служить признаком того, что торрент, оказавшийся под этим infohash сразу после `add`, создан именно этим `add` (наш артефакт), а не пред-существовал. - **Уборка добавленного торрента при отмене в окне после `add`.** Если `add` прошёл успешно, а последующая запись перехода `PromoteCatched` не применилась, worker SHALL принимать решение об уборке по **свежему re-read состояния под блокировкой**, а не по факту ошибки промоушена: неуспех промоушена бывает и из-за отмены (`state` уже не `catched`), и из-за транзиентного сбоя хранилища (`state` всё ещё `catched`, задача жива). Только при подтверждённом `state != catched` worker SHALL удалить только что добавленный торрент из qBittorrent **вместе с его данными** (`deleteFiles = true`) по infohash загрузки. Если повторное чтение показало `state == catched` (транзиентный сбой) либо само не удалось, worker торрент удалять SHALL NOT — переход доводится на следующем тике усыновлением присутствующей (нашей) раздачи. Удаление SHALL идти через API qBittorrent (`torrents/delete`), не прямыми fs-операциями. Это легитимная уборка **собственного** артефакта, а не пользовательских данных: инвариант «источник неприкосновенен» защищает существующие раздачи/файлы пользователя под `paths.downloads`, а здесь удаляется торрент, который сам worker добавил секундами ранее — уже после намерения отмены. Состояние отменённой задачи (`cancelled`) уборка трогать SHALL NOT; неуспех удаления SHALL логироваться (торрент временно остаётся, повторная авто-уборка не требуется). - **Негативный инвариант (удаляем только своё).** Удаление-с-данными допустимо ТОЛЬКО для торрента, который worker создал именно этим `add`. Торрент, который присутствовал в qBittorrent ДО нашего `add` (пользователь уже раздавал тот же infohash / внешний торрент с тем же хешем), удалять с данными worker SHALL NOT — иначе снёс бы чужие данные в нарушение инварианта. Гарантию обеспечивает подтверждение отсутствия перед `add`: путь уборки достижим только тогда, когда отсутствие infohash было подтверждено непосредственно перед `add`; при обнаруженном присутствии (или недоступном листинге) `add` не выполняется вовсе. Записи об этом пути (торрент оставлен после отмены → удаляем; факт удаления) worker SHALL логировать на уровне `WARN` с корреляцией по `download_id`/`infohash` и без секретов; неуспех удаления — на `ERROR`. #### 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: Свежий листинг перед add недоступен — повтор - **GIVEN** загрузка в `catched`, торрента в снимке тика нет, имя выведено - **WHEN** свежий листинг присутствия непосредственно перед `add` не удался (сетевой сбой) - **THEN** worker `add` НЕ вызывает (отсутствие infohash не подтверждено) - **AND** загрузка остаётся в `catched`, попытка повторяется на следующем тике #### Scenario: Отмена до add — источник не добавляется - **GIVEN** загрузка в `catched`, worker выводит отображаемое имя вне блокировки - **WHEN** параллельно приходит команда отмены (`catched → cancelled`) во время вывода имени, а затем worker перечитывает состояние перед `add` - **THEN** re-read видит, что загрузка уже не в `catched`, и `qbt.Add` НЕ вызывается - **AND** источник в qBittorrent не добавляется, задача остаётся `cancelled` #### Scenario: Отмена в окне после add — добавленный торрент удаляется с данными - **GIVEN** загрузка в `catched`, отсутствие её infohash в qBittorrent подтверждено перед `add`, и `add` прошёл успешно - **WHEN** отмена (`catched → cancelled`) приходит в окне между `add` и записью перехода, из-за чего запись перехода не применяется, а re-read состояния под блокировкой показывает `state != catched` - **THEN** worker удаляет только что добавленный торрент из qBittorrent вместе с его данными (`deleteFiles = true`) по infohash загрузки - **AND** пишет `WARN` о том, что торрент оставлен после отмены и удалён - **AND** состояние задачи остаётся `cancelled` #### Scenario: Сбой записи перехода без отмены — торрент не удаляется - **GIVEN** загрузка в `catched`, `add` прошёл успешно, но запись перехода `PromoteCatched` вернула ошибку из-за транзиентного сбоя хранилища - **WHEN** re-read состояния под блокировкой показывает, что загрузка всё ещё в `catched` (отмены не было) - **THEN** worker торрент из qBittorrent НЕ удаляет (это наш живой торрент) - **AND** переход доводится на следующем тике усыновлением присутствующей раздачи #### Scenario: Пред-существующий торрент не удаляется с данными - **GIVEN** загрузка в `catched`, чей infohash уже присутствует в qBittorrent к моменту проверки перед `add` (внешний торрент/раздача пользователя с тем же хешем) - **WHEN** worker обрабатывает тик и параллельно приходит отмена - **THEN** worker `add` НЕ выполняет и торрент с данными НЕ удаляет (чужие данные неприкосновенны) - **AND** загрузка пропускается в этот тик (усыновление присутствующей раздачи — на следующем тике, если задача ещё активна) ### 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`