- Раскладка docs/ приведена к канону 2: заведены passport/architecture/ database/security/review и research; docs/specs, drafts, backlog, review/ и BRIEF.md разобраны и удалены, беклог переехал в docs/tasks (34 задачи, 6 целей, слаги на английский). - Нарративы specs удалены как дубли openspec-спек после поимённой сверки; остаток заведён задачами (редактор маппинга ревью, крайние случаи именования), отказ от сущности title промоутнут в ADR. - Проектные копии агентов и скиллов ревью/пайплайна удалены в пользу плагинов av-dev-pm и av-dev-pipeline; в task gate добавлен шаг canon вместо er-schema.
46 KiB
state-reconciliation Specification
Purpose
Сверка записанного состояния загрузки с фактом на файловой системе и в
qBittorrent. Capability описывает периодическую и принудительную проверку
присутствия источника (раздача в qBittorrent) и цели (разложенные
хардлинки), вывод состояний рассинхрона (target_missing/orphaned/
deleted) из матрицы «источник × цель», их переходы и самовосстановление,
дебаунс пропажи источника, владение целевым путём (один путь — один владелец)
и инвариант безопасного Undo (не снимать последнюю копию). Уведомления о
рассинхроне — в notifications.
Requirements
Requirement: Периодическая сверка состояния с реальностью
worker SHALL периодически (на тике поллинга) сверять задачи, для которых
ожидаются разложенные файлы, с фактом на файловой системе и в qBittorrent, и
выводить состояние задачи из двух независимых признаков: присутствия
источника (раздача, совпавшая с любым из известных хешей загрузки в
download_infohash, в выдаче qBittorrent) и присутствия цели (см.
требование о владении целевым путём: существуют все ссылки последнего батча
со статусом раскладки, всё ещё принадлежащие этой загрузке).
Сверке по матрице «источник × цель» SHALL подвергаться состояния done,
target_missing, orphaned. Состояние deleted сверка трогать SHALL NOT —
оно терминально. Активные (downloading/recognizing/review/deferred/
linking) и пользовательски-терминальные (reverted/cancelled) состояния
сверка по матрице трогать SHALL NOT.
Восстановимые failed/stuck (с error_code magnet_timeout или
stalled — задержки, вызванные нашей нетерпеливостью, а не реальной ошибкой)
сверка SHALL рассматривать отдельно — на предмет оживления источника (см.
требование о восстановлении зависшей загрузки), не по матрице «источник ×
цель». Прочие failed (например qbit_error) сверка трогать SHALL NOT.
Состояние SHALL переписываться только при его изменении (без записи и логов, когда выведенное состояние совпадает с текущим).
Scenario: Источник и цель на месте — состояние не меняется
- WHEN для задачи в
doneраздача присутствует в qBittorrent и все её разложенные хардлинки существуют - THEN задача остаётся в
done - AND запись состояния и лог перехода не выполняются
Scenario: Частичная пропажа цели считается отсутствием
- WHEN часть разложенных хардлинков задачи удалена, а источник на месте
- THEN цель считается отсутствующей и задача переходит в
target_missing
Scenario: Задача в deleted сверкой не переоценивается
- WHEN задача находится в
deleted - THEN сверка её не рассматривает и состояние не меняет, даже если по её бывшему пути появился файл другой загрузки
Scenario: Провал по ошибке qBittorrent восстановлению не подлежит
- WHEN задача в
failedсerror_codeqbit_error - THEN сверка её не рассматривает и состояние не меняет
Requirement: Восстановление зависшей загрузки при оживлении источника
Система SHALL возвращать в активный поток задачу, упавшую из-за нашей
нетерпеливости (failed/magnet_timeout или stuck/stalled), если её
источник в qBittorrent жив и продвинулся: переход выводится из текущего
состояния торрента так же, как при штатной сверке загрузки
(uploading/stalledUP/… → completed; downloading/metaDL/… →
downloading). Восстановление SHALL опираться на фактическое состояние
торрента в qBittorrent, а не на время с момента создания записи.
После возврата в любое нетерминальное состояние (downloading или
completed) повторный приём того же infohash SHALL снова дедуплицироваться
на эту задачу: активность задачи выводится только из её state, отдельный
восстанавливаемый ключ идемпотентности отсутствует. Если за время простоя в
failed/stuck тем же infohash (любым из хешей задачи) уже завладела
другая активная задача (новый приём, пока эта лежала упавшей), система
SHALL NOT воскрешать упавшую задачу и SHALL оставить её в failed/stuck,
сохраняя инвариант «не более одной активной задачи на infohash».
magnet_timeout/stalled SHALL быть редким страховочным исходом, а не
рабочим механизмом. Две страховочные меры при этом РАЗНЫЕ: magnet_timeout
SHALL мериться по возрасту торрента (время от добавления в qBittorrent,
added_on, с фолбэком на created_at задачи), а stuck_after — по
длительности простоя (время от last_activity qBittorrent — момента
последнего движения данных), а НЕ по возрасту. Пока торрент в
metaDL/forcedMetaDL или иным образом прогрессирует в пределах
страховочного таймаута, задача в failed/stuck из-за него оказаться
SHALL NOT; в частности, торрент со свежим last_activity в stuck система
пометить SHALL NOT, даже если его общий возраст превышает stuck_after (см.
требование о терпеливости к долгим метаданным и меры таймаутов в
openspec/specs/download-tracking/spec.md).
Scenario: Метаданные пришли после magnet_timeout
- GIVEN задача в
failedсerror_codemagnet_timeout, а её торрент в qBittorrent уже получил метаданные и качается (downloading) - WHEN срабатывает фоновая сверка
- THEN задача возвращается в
downloading - AND повторный приём того же infohash снова дедуплицируется на неё
Scenario: Торрент уже завершился, пока задача была в failed
- GIVEN задача в
failedсerror_codemagnet_timeout, а её торрент в qBittorrent уже готов к раскладке (uploading/stalledUP) - WHEN срабатывает фоновая сверка
- THEN задача переходит в
completedи продолжает обычный поток (распознавание/раскладка)
Scenario: Источник так и не ожил — состояние не меняется
- GIVEN задача в
failedсerror_codemagnet_timeout, а её торрент всё ещё висит вmetaDLбез метаданных (или отсутствует в qBittorrent) - WHEN срабатывает фоновая сверка
- THEN задача остаётся в
failed
Scenario: infohash уже занят другой активной задачей
- GIVEN задача #1 в
failed/magnet_timeout, а тем же infohash уже владеет другая активная задача #2 (приём повторили, пока #1 лежала упавшей) - WHEN источник ожил (торрент получил метаданные или готов) и сверка пытается воскресить #1
- THEN #1 остаётся в
failed(восстановление не выполняется) - AND активной по этому infohash остаётся #2
Scenario: Долго качавшийся торрент на миг зашёл в stalledDL
- GIVEN торрент качался часами и двигал данные только что (свежий
last_activity), но на текущем тике qBittorrent показывает егоstalledDL - WHEN
workerпроверяет таймаут зависания - THEN задача остаётся в
downloading(простой меньшеstuck_after), несмотря на большой возраст торрента - AND ложного
stuckсо «stalled for <возраст>» и уведомления о падении не возникает
Requirement: Ручной повтор зависшей/упавшей загрузки из транспортов
Система SHALL предоставлять пользователю команду повторной попытки (retry)
для задач в failed/stuck из веб-UI и Telegram (не только через REST API).
Retry SHALL переводить задачу обратно в downloading, не вызывая её
немедленного повторного падения по таймауту: базис отсчёта таймаутов SHALL
сбрасываться.
Сброс базиса система SHALL выполнять сохранением времени retry в поле задачи
(retried_at, RFC 3339 UTC), которое приподнимает пол ОБОИХ страховочных мер
(magnet_timeout по возрасту и stuck_after по простою): отсчёт ведётся от
max(базис, retried_at). retried_at SHALL храниться в задаче (не в памяти
процесса), чтобы сброс базиса пережил интервал поллинга и рестарт процесса.
Благодаря этому даже живой, но давно добавленный либо давно простаивающий
торрент после retry SHALL получать свежее окно и на ближайшем тике сверки
падать снова SHALL NOT.
Если источник задачи уже жив и ЗДОРОВ в qBittorrent, retry SHALL перецепляться
к существующему торренту, а не добавлять источник повторно вслепую. Если же
живой торрент в состоянии ошибки qBittorrent (error/missingFiles), retry
SHALL отклоняться с понятным пользователю сообщением — починить раздачу
(recheck/восстановить файлы) в qBittorrent и повторить. Повторная отдача
источника такой торрент не чинит (qBittorrent отверг бы дубль), а простой
возврат в downloading тут же снова упал бы qbit_error по сверке (+ дебаунс
уведомления) — retry выглядел бы сломанным. При отказе состояние задачи
(failed/stuck) система менять SHALL NOT и повторный Add выполнять SHALL NOT.
Повторный Add при retry система SHALL выполнять, только когда раздачи в
qBittorrent нет, — по типу источника (source_type), как и добавление
пойманной загрузки (см. download-tracking «Добавление пойманной загрузки в
qBittorrent»): magnet/url — ссылкой; torrent — сохранёнными байтами .torrent
файлом. Для torrent-источника retry БЕЗ живой раздачи система SHALL добавлять
раздачу байтами и SHALL NOT активировать задачу в downloading, не добавив её
(иначе задача повиснет как «нет в qBittorrent»).
Scenario: Retry упавшей magnet-загрузки из веб-UI
- GIVEN задача в
failed, её торрент жив и здоров в qBittorrent - WHEN пользователь нажимает retry в веб-UI
- THEN задача возвращается в
downloadingбез повторногоAdd - AND не падает снова на ближайшем тике сверки по таймауту
Scenario: Retry живого, но давно простаивающего торрента не падает снова
- GIVEN задача в
stuck/stalled, её торрент жив в qBittorrent, но добавлен давно и данные не двигались дольшеstuck_after - WHEN пользователь нажимает retry
- THEN задача возвращается в
downloadingбез повторногоAdd - AND на ближайшем тике сверки НЕ падает снова в
stuck(базис сброшен черезretried_at)
Scenario: Retry доступен в Telegram
- WHEN для задачи в
failed/stuckпользователь вызывает retry в Telegram-боте - THEN задача возвращается в
downloading
Scenario: Retry без живого источника добавляет источник заново
- GIVEN задача в
failed, раздачи в qBittorrent нет - WHEN пользователь инициирует retry
- THEN источник добавляется в qBittorrent заново — magnet/url ссылкой, torrent сохранёнными байтами файлом
- AND задача переходит в
downloading
Scenario: Retry сломанного живого торрента отклоняется
- GIVEN задача в
failed, её торрент присутствует в qBittorrent, но в состоянии ошибки (error/missingFiles) - WHEN пользователь инициирует retry
- THEN retry отклоняется с сообщением починить раздачу (
recheck) в qBittorrent - AND состояние задачи не меняется (остаётся
failed), повторныйAddне выполняется
Scenario: Retry torrent-загрузки без живого источника
- GIVEN задача с
source_type = torrentвfailed, раздачи в qBittorrent нет, байты.torrentсохранены - WHEN пользователь инициирует retry
- THEN сохранённые байты добавляются в qBittorrent файлом
- AND задача переходит в
downloading(не остаётся без раздачи)
Requirement: Принудительная проверка источника/цели перед действием
Команда workflow, требующая наличия источника или цели, SHALL синхронно
проверять их присутствие непосредственно перед выполнением действия и SHALL
NOT полагаться только на фоновую сверку worker (она отстаёт на интервал
поллинга и дебаунс). Проверка перед действием выполняется как единичная
немедленная проба без дебаунса: дебаунс применяется только к фоновому
авто-маркированию.
Под это требование подпадают как минимум: повторная привязка/распознавание
(relink, «Распознать заново», «Уточнить») и раскладка (Apply) — требуют
источника; Undo — требует источника (чтобы не снять последнюю
копию).
Если предусловие не выполнено, система SHALL NOT выполнять действие, SHALL привести состояние задачи в соответствие с реальностью (вывести состояние из матрицы «источник × цель», как при сверке) и SHALL сообщить причину пользователю.
Scenario: Relink проверяет источник перед запуском
- WHEN пользователь даёт команду, требующую источника (например «Привязать заново»)
- THEN система синхронно проверяет наличие раздачи в qBittorrent перед запуском распознавания
- AND если источника нет — распознавание не запускается, задача
приводится к
orphanedлибоdeleted(по наличию цели), причина сообщается
Scenario: Проверка не ждёт фоновую сверку
- WHEN источник уже удалён, а фоновая сверка ещё не отметила это (в БД
состояние, например,
done) - THEN команда, требующая источника, немедленно обнаруживает его
отсутствие собственной проверкой и отказывает, не дожидаясь
worker
Requirement: Состояние target_missing и доступность повторной привязки
Когда у задачи источник присутствует, а цель отсутствует, система SHALL
переводить её в состояние target_missing и SHALL NOT предпринимать
автоматических действий (не запускать повторное распознавание/раскладку
самостоятельно).
Из target_missing система SHALL предоставлять пользовательскую команду
повторной привязки (relink) с переходом target_missing → recognizing, как
из reverted/cancelled; повторная привязка идёт через review с ручным
подтверждением.
Scenario: Цель удалена, источник на месте
- WHEN сверка обнаруживает, что разложенных хардлинков задачи
doneбольше нет, но раздача в qBittorrent присутствует - THEN задача переходит в
target_missing - AND система не запускает распознавание или раскладку автоматически
Scenario: Пользователь инициирует повторную привязку
- WHEN для задачи в
target_missingпользователь даёт команду «Привязать заново» - THEN задача переходит в
recognizingи далее проходит черезreviewс ручным подтверждением
Requirement: Состояние orphaned при пропаже источника
Система SHALL переводить задачу в состояние orphaned, когда источник
отсутствует (с учётом дебаунса), а цель присутствует, отражая, что
библиотечный хардлинк остался единственной копией данных.
Scenario: Источник удалён, цель на месте
- WHEN сверка устойчиво (после дебаунса) не находит раздачу задачи в qBittorrent, а её разложенные хардлинки существуют
- THEN задача переходит в
orphaned
Requirement: Состояние deleted при пропаже источника и цели
Когда отсутствуют и источник (с учётом дебаунса), и цель, система SHALL
переводить задачу в состояние deleted. deleted терминально: действий над
задачей больше нет, и сверка её больше не переоценивает (источник к
терминальной задаче не возвращается из-за идемпотентности, а цель отбирается
переходом владения путём к другой загрузке).
Scenario: Источник и цель удалены
- WHEN сверка устойчиво не находит раздачу в qBittorrent и разложенных хардлинков задачи на ФС больше нет
- THEN задача переходит в
deleted
Scenario: deleted не воскресает при переиспользовании пути
- GIVEN задача A в
deleted - WHEN другая задача раскладывается по бывшему пути A
- THEN задача A остаётся в
deleted(не переходит вorphaned)
Requirement: Владение целевым путём — один путь, один владелец
Целевой путь раскладки (file_link.dst_path) SHALL принадлежать не более
чем одной загрузке одновременно. При успешной раскладке загрузки на путь,
который ранее заняла другая загрузка, владение SHALL переходить к новой
загрузке: ссылки прежней загрузки на тот же dst_path система SHALL
помечать вышедшими из обращения (статус, не относящийся к разложенной цели),
после чего они перестают считаться целью прежней загрузки при сверке.
Присутствие цели при сверке SHALL определяться по владению, а не по факту существования пути: цель загрузки считается присутствующей, только если существующие на ФС файлы по её путям — это ссылки, всё ещё принадлежащие этой загрузке (не вышедшие из обращения). Файл, лежащий по тому же пути, но созданный другой загрузкой, целью первой загрузки считаться SHALL NOT.
Переход владения возможен лишь когда путь к моменту раскладки свободен (прежний файл уже удалён): занятый реальным файлом путь по-прежнему даёт коллизию и уходит в review (новая раскладка не перезаписывает чужой файл).
Scenario: Повторная закачка забирает освободившийся путь
- GIVEN загрузка A разложена по пути P, но её файл по P удалён вручную
- WHEN загрузка B успешно раскладывается по тому же пути P
- THEN ссылки A на P помечаются вышедшими из обращения
- AND при сверке цель A по пути P считается отсутствующей
Scenario: Чужой файл по пути не считается своей целью
- GIVEN по пути P лежит файл, созданный загрузкой B
- WHEN сверка проверяет присутствие цели загрузки A, чьи ссылки на P вышли из обращения
- THEN цель A считается отсутствующей, несмотря на существование файла по P
Scenario: Занятый путь даёт коллизию, а не переход владения
- GIVEN файл загрузки A по пути P всё ещё существует
- WHEN загрузка B пытается разложиться по тому же пути P
- THEN возникает коллизия и B уходит в review
- AND владение путём P за A не отбирается
Requirement: Дебаунс пропажи источника
Система SHALL дебаунсить только отсутствие источника, чтобы временная
недоступность qBittorrent (рестарт демона, сбой API) не вызывала ложных
пометок: источник считается удалённым лишь после N подряд тиков сверки без
него, где N = [worker].source_missing_threshold. Любое обнаружение раздачи
SHALL сбрасывать счётчик пропусков.
Отсутствие цели дебаунсу подвергаться SHALL NOT (локальная проверка ФС надёжна).
Scenario: Кратковременная пропажа источника не помечается
- WHEN раздача отсутствует в qBittorrent меньше
source_missing_thresholdтиков подряд - THEN источник трактуется как присутствующий и состояние задачи не меняется
Scenario: Возврат источника сбрасывает счётчик
- WHEN раздача снова обнаружена в qBittorrent
- THEN счётчик пропусков источника сбрасывается в ноль
Requirement: Самовосстановление состояния при возврате реальности
Система SHALL возвращать задачу в согласованное состояние, когда реальность
восстановилась (состояние выводится из текущей матрицы «источник × цель»):
при возврате источника и/или цели задача SHALL переходить из
orphaned/target_missing обратно (в т.ч. в done, когда присутствуют
оба). Из терминального deleted самовосстановления SHALL NOT быть.
Scenario: Источник вернулся
- WHEN для задачи в
orphanedраздача снова появилась в qBittorrent, а цель по-прежнему на месте - THEN задача возвращается в
done
Requirement: Безопасный Undo не снимает последнюю копию
Undo (снятие созданных хардлинков) SHALL отказываться удалять целевую
ссылку, если она является последней копией данных: целевой файл существует и
его счётчик ссылок nlink <= 1, либо исходный файл (src_path) не
существует. В этом случае система SHALL NOT выполнять unlink такого файла и
SHALL явно сообщать причину отказа; задача в reverted при отказе переходить
SHALL NOT.
Отсутствующую целевую ссылку (файла уже нет) Undo SHALL пропускать как
успешно снятую (идемпотентность). Команда Undo для задачи в orphaned
SHALL отклоняться сразу с пояснением, что источник удалён.
Scenario: Отказ снять единственную копию
- WHEN при
Undoцелевой хардлинк существует, но егоnlink <= 1(или исходный файл отсутствует) - THEN система не удаляет файл и сообщает, что это последняя копия
- AND задача остаётся в текущем состоянии (не
reverted)
Scenario: Undo снимает лишний хардлинк при живом источнике
- WHEN при
Undoцелевой хардлинк существует, исходный файл на месте иnlink > 1 - THEN система снимает целевой хардлинк, оставляя исходный файл нетронутым
Requirement: Восстановление задачи, застрявшей в linking
Система SHALL на каждом тике поллинга и при старте выявлять задачи в состоянии
linking и возвращать их в review с причиной «прерванная раскладка» (код
interrupted), откуда человек повторит применение (повтор идемпотентен).
linking — нетерминальное активное состояние, и у него, как у каждого
нетерминального состояния, ДОЛЖЕН быть владелец, продвигающий задачу; иначе
краш процесса между переходом в linking и финальным переходом оставил бы
задачу без владельца — её не листит ни один штатный шаг (ни поллинг активных,
ни распознавание, ни матрица сверки, ни восстановление failed/stuck).
Выявление SHALL выполняться под той же блокировкой переходов, что и раскладка:
активная раскладка удерживает блокировку весь свой срок и завершает переход из
linking до её отпускания, поэтому любая linking-задача, наблюдаемая под
блокировкой, по построению устарела (осталась после краха) — восстановление НЕ
SHALL задевать раскладку в полёте.
Scenario: Осиротевший linking возвращается в review
- GIVEN задача осталась в
linkingпосле краха между claim и финальным переходом - WHEN выполняется тик поллинга (или старт сервиса)
- THEN задача переходит в
reviewс причиной «прерванная раскладка» (кодinterrupted) - AND её можно повторно применить из ревью
Scenario: Прочие состояния sweep не задевает
- GIVEN задачи в состояниях
doneиreview - WHEN выполняется тик поллинга
- THEN восстановление
linkingих состояние не меняет
Requirement: Полное удаление загрузки пользователем
Система SHALL предоставлять пользователю команду «Удалить» (delete),
доступную из состояний done, orphaned и target_missing во всех транспортах
(веб-UI и Telegram, опц. REST). Команда SHALL снимать обе стороны загрузки —
целевые библиотечные хардлинки И раздачу с файлами в qBittorrent — и переводить
задачу в терминальное deleted. Из прочих состояний команда доступна SHALL NOT.
Снятие цели SHALL идти по механике снятия ссылок последнего батча (как в Undo:
superseded пропускаются как забранные другой загрузкой), но отдельным путём с
выключенным гардом последней копии — не переиспользуя guarded-Undo: в отличие
от Undo, delete SHALL снимать целевую ссылку, даже если она — последняя копия
данных (nlink <= 1). Это осознанный выход за
инвариант «источник неприкосновенен», поэтому delete SHALL требовать явного
подтверждения пользователя перед выполнением и SHALL NOT срабатывать по
одиночному клику/тапу. Снятие цели SHALL затрагивать только собственные ссылки
загрузки строго под paths.movies/series; файлы источника под
paths.downloads система сама трогать SHALL NOT — их удаляет qBittorrent по
вызову API с deleteFiles=true.
В отличие от прочих команд, требующих источника, delete синхронный source-preflight
выполнять SHALL NOT и под требование «Принудительная проверка источника/цели перед
действием» не подпадает: цель delete — снять источник, поэтому его отсутствие
трактуется как уже снятая сторона, а не как повод привести состояние сверкой и
отказать. Удаление SHALL быть идемпотентным к отсутствующей стороне: в orphaned
(нет источника) отсутствие раздачи в qBittorrent ошибкой считаться SHALL NOT; в
target_missing (нет цели) пустой список живых ссылок обрабатывается как «нечего
снимать». Если qBittorrent вернул ошибку при удалении присутствующей раздачи,
система в deleted переходить SHALL NOT (не заявляем освобождение места, которого
не произошло), SHALL сообщить пользователю причину отказа (это не ErrConflict,
а ошибка внешнего сервиса — транслируется как таковая), и повторный delete
идемпотентно дожимает удаление, опираясь на оставшийся done либо приведённый
сверкой к реальности target_missing (кратковременное рассогласование до тика
сверки ожидаемо).
Инициатора перехода в deleted система SHALL отличать от фоновой сверки:
пользовательское удаление SHALL помечаться error_code = "user_delete" (сверка
кладёт "reconcile"), человекочитаемую причину — в error_msg и лог перехода.
Новый статус для этого система вводить SHALL NOT — переиспользуется существующее
терминальное deleted (сверка его не переоценивает, см. требование о deleted).
Scenario: Удаление из done снимает обе стороны и освобождает место
- GIVEN задача в
done: раздача присутствует в qBittorrent, её библиотечные хардлинки существуют - WHEN пользователь подтверждает «Удалить»
- THEN библиотечные ссылки последнего батча снимаются
- AND раздача с файлами удаляется из qBittorrent (
deleteFiles=true) - AND задача переходит в
deletedсerror_code = "user_delete"
Scenario: Удаление из orphaned снимает последнюю копию осознанно
- GIVEN задача в
orphaned: источник пропал, библиотечный хардлинк остался единственной копией данных (nlink <= 1) - WHEN пользователь подтверждает «Удалить»
- THEN библиотечная ссылка снимается несмотря на то, что она последняя копия
(гард последней копии выключен, в отличие от
Undo) - AND отсутствие раздачи в qBittorrent ошибкой не считается
- AND задача переходит в
deleted
Scenario: Удаление из target_missing сносит остаточную раздачу
- GIVEN задача в
target_missing: источник присутствует, цель уже удалена вручную - WHEN пользователь подтверждает «Удалить»
- THEN снятие цели идемпотентно (живых ссылок нет)
- AND раздача с файлами удаляется из qBittorrent
- AND задача переходит в
deleted
Scenario: Удаление требует подтверждения
- GIVEN задача в
done - WHEN пользователь инициирует «Удалить», но не подтверждает действие
- THEN ни ссылки, ни раздача не удаляются, состояние остаётся
done
Scenario: Удаление недоступно из прочих состояний
- GIVEN задача в
review(или ином состоянии внеdone/orphaned/target_missing) - WHEN приходит команда «Удалить»
- THEN команда отклоняется с конфликтом, состояние не меняется
Scenario: Ошибка qBittorrent не метит deleted ложно
- GIVEN задача в
done, раздача присутствует, но qBittorrent вернул ошибку на удаление - WHEN пользователь подтверждает «Удалить»
- THEN задача в
deletedне переходит (место не освобождено) - AND пользователю сообщается причина отказа (ошибка qBittorrent, не тихий успех)
- AND повторный delete идемпотентно дожимает удаление
Requirement: Ручное закрытие загрузки (стоп-кран)
Система SHALL предоставлять пользователю команду «Закрыть» (dismiss),
доступную из любого состояния, кроме deleted, во всех транспортах (веб-UI и
Telegram, опц. REST). Команда SHALL переводить загрузку в терминальное
cancelled, убирая её из активного списка/внимания, и SHALL служить
универсальным стоп-краном для любой зависшей или спорной загрузки (в т.ч. лишнего
дубля-близнеца в target_missing, чьи файлы уже разложены другой загрузкой). Для
загрузки в deleted команда доступна SHALL NOT (состояние строго терминально); в
cancelled команда SHALL быть идемпотентным no-op.
Команда SHALL только менять статус и SHALL NOT производить никаких действий с
файлами или раздачей: система SHALL NOT вызывать qBittorrent (раздача не
снимается, продолжает раздаваться) и SHALL NOT удалять либо создавать хардлинки
под paths.movies/series — в т.ч. из done/orphaned существующие
библиотечные ссылки сознательно остаются на месте. Синхронный source-preflight
«Закрыть» выполнять SHALL NOT (источник в действии не участвует).
Переход SHALL помечаться error_code = "user_dismiss" (человекочитаемая причина —
в error_msg и логе перехода), отличающим стоп-кран от отклонения на ревью и от
удаления. Новый статус для этого система вводить SHALL NOT — переиспользуется
существующее терминальное cancelled (сверка его не переоценивает). Из
cancelled пользователю остаётся доступной перепривязка (relink), если он
передумает.
В интерфейсе команда SHALL размещаться в отдельной «danger zone» (напр. внизу страницы загрузки), обособленно от штатных действий.
Scenario: Закрытие записи без цели не трогает раздачу
- GIVEN загрузка в
target_missing: источник присутствует в qBittorrent, целевых хардлинков нет (напр. её файлы разложены другой загрузкой) - WHEN пользователь даёт команду «Закрыть»
- THEN запись переходит в
cancelledсerror_code = "user_dismiss" - AND раздача с файлами в qBittorrent не удаляется
- AND запись пропадает из активного списка
Scenario: Закрытие done оставляет библиотечные файлы на месте
- GIVEN загрузка в
doneс существующими библиотечными хардлинками - WHEN пользователь даёт команду «Закрыть»
- THEN запись переходит в
cancelledсerror_code = "user_dismiss" - AND библиотечные хардлинки не удаляются
- AND раздача в qBittorrent не снимается
Scenario: Закрытие зависшей загрузки
- GIVEN загрузка в
stuck(илиfailed/deferred) - WHEN пользователь даёт команду «Закрыть»
- THEN запись переходит в
cancelled - AND источник в qBittorrent не трогается
Scenario: «Закрыть» недоступна для deleted
- GIVEN загрузка в
deleted - WHEN пользователь пытается вызвать «Закрыть»
- THEN команда недоступна, состояние остаётся
deleted
Scenario: Закрытую запись можно привязать заново
- GIVEN запись, закрытая командой «Закрыть» в
cancelled - WHEN пользователь даёт команду «Привязать заново»
- THEN запись уходит на перераспознавание с ручным подтверждением (как relink
из
cancelled)