Files
jellybit/openspec/specs/download-tracking/spec.md
T
av 69853a96c9 docs: разобран урожай судей после подъёма канона
- перечень команд пользователя убран из architecture.md в спеки review и
  state-reconciliation, где ему дом: обзор успел разойтись с ними в обе стороны
- README перестал дублировать деплой и статус — теперь ссылается на дом
- статус «заведена ли задача под пробел» сведён в один регистр открытых вопросов
- шаги tasks.py и openspec.py названы в перечне «что красит безусловно»
- из спеки download-tracking сняты числа умолчаний: их дом — database.md
2026-08-09 19:24:59 +03:00

41 KiB
Raw Blame History

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) опрашивать 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 (редкий предохранитель) в 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 перечитывать под блокировкой переходов после тик-снимка (тик-снимок — снимок списка catched, снятый на тике поллинга) — перед подготовкой запроса на добавление, а не полагаясь на снимок списка catched, который к моменту обработки загрузки уже устарел (блокировка между снятием списка и обработкой отпускается). Иначе апгрейд пойманной magnet-задачи до .torrent (см. ingest), легший между снятием списка и обработкой, был бы пропущен, и worker добавил бы magnet из устаревшего снимка, хотя в БД уже torrent.

Если подготовка запроса не удалась (сохранённые байты .torrent недоступны), worker add выполнять SHALL NOT и SHALL оставлять загрузку в catched для повтора на следующем тике; отсечка — catch_timeout.

Между этим re-read и самим add остаётся окно: вывод отображаемого имени идёт секунды вне блокировки (см. ниже), а запрос на добавление собран до него и по свежей записи не пересобирается. Апгрейд magnet → .torrent, легший ровно в это окно, worker не подхватывает и добавляет magnet-ссылку. Это известное ограничение с названной ценой, а не гарантия: сегодня запрос собран до вывода имени и по свежей записи не пересобирается. Закрывать ли окно кодом и каким способом (пересборкой запроса или сравнением одного source_type под замком с пропуском тика при расхождении) — открытый вопрос за границами этого требования.

Ущерб окна ограничен, но не бесплатен, и спека называет его точно. На публичном трекере (или при живом DHT) magnet доберёт метаданные и задача пойдёт штатно — ущерба нет вовсе. На закрытом трекере, ради которого .torrent и подавался, magnet без метаданных зависает в metaDL, и предохранитель magnet_timeout уводит задачу в failed (см. «Таймауты-предохранители downloading»). Сама собой задача из этого состояния не восстанавливается, и Retry её тоже не чинит — почему, см. state-reconciliation «Ручной повтор зависшей/упавшей загрузки из транспортов». Восстановление сегодня требует ручного шага: убрать зависшую magnet-раздачу из qBittorrent, после чего Retry добавит источник заново по актуальному source_type — сохранёнными байтами, которые апгрейд уже записал. Без этого шага повторный приём .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 и записью перехода. Этот re-read проверяет только состояние и запрос на добавление не пересобирает — за source_type отвечает более ранний 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: Апгрейд magnet → .torrent до подготовки запроса — добавляется файлом

  • GIVEN загрузка попала в снимок списка catched с source_type = magnet
  • WHEN апгрейд до .torrent применяется после снятия снимка, но до re-read записи под блокировкой, и worker обрабатывает эту загрузку
  • THEN worker берёт source_type = torrent из перечитанной записи и добавляет раздачу сохранёнными байтами файлом (torrents)
  • AND magnet-ссылка из устаревшего снимка не используется

Scenario: Апгрейд magnet → .torrent в окне вывода имени — добавляется magnet

  • GIVEN загрузка в catched с source_type = magnet, запрос на добавление подготовлен, worker выводит отображаемое имя вне блокировки
  • WHEN апгрейд до .torrent применяется именно в это окно
  • THEN worker добавляет magnet-ссылку из подготовленного запроса — апгрейд в этом окне не подхватывается (известное ограничение)
  • AND на закрытом трекере раздача зависает в metaDL и по magnet_timeout задача уходит в failed
  • AND восстановление требует ручного шага: убрать зависшую раздачу из qBittorrent, после чего Retry добавляет источник заново по актуальному source_type — сохранёнными байтами

Scenario: Байты .torrent недоступны — повтор

  • GIVEN загрузка в catched с source_type = torrent, торрента в qBittorrent нет, но сохранённые байты .torrent прочитать не удалось
  • WHEN worker готовит запрос на добавление
  • THEN worker add НЕ вызывает
  • AND загрузка остаётся в catched, попытка повторяется на следующем тике (отсечка — catch_timeout)

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