Files
jellybit/openspec/changes/archive/2026-08-10-bulk-delete-page/specs/state-reconciliation/spec.md
T
av 288be8ec34 web-ui: добавлена страница группового удаления загрузок
- выбор → поимённое подтверждение → отчёт: пачка до 20 загрузок, гарды входа
  на обеих границах, потолок времени и остановка после трёх подряд отказов
  внешнего сервиса
- допуск полного удаления сведён в единую точку store.State.CanDelete() —
  worker, страница загрузки и Telegram больше не держат своих перечней
2026-08-10 17:43:36 +03:00

9.6 KiB
Raw Blame History

MODIFIED Requirements

Requirement: Полное удаление загрузки пользователем

Система SHALL предоставлять пользователю команду «Удалить» (delete), доступную из состояний done, orphaned и target_missing во всех транспортах (веб-UI и Telegram, опц. REST). Команда SHALL снимать обе стороны загрузки — целевые библиотечные хардлинки И раздачу с файлами в qBittorrent — и переводить задачу в терминальное deleted. Из прочих состояний команда доступна SHALL NOT.

Перечень состояний, из которых удаление допустимо, SHALL иметь единственный дом — предикат состояния в модели данных. Транспорты, решающие, показывать действие или нет, SHALL опираться на него, а не на собственный список. Проверку допуска в ядре это SHALL NOT отменять: транспорт решает, что показать, ядро — что допустить, и допуск SHALL держаться без транспорта.

Снятие цели 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.

Подтверждение SHALL быть допустимо одно на пачку загрузок, когда транспорт даёт групповое удаление: ослаблением требования это не является, если подтверждение называет каждую загрузку пачки поимённо. Условия допустимости групповой путь смягчать SHALL NOT — каждая загрузка пачки проходит те же проверки и тот же отказ по конфликту, что и при поштучном удалении, а отказ на одной остальных отменять SHALL NOT.

В отличие от прочих команд, требующих источника, 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 идемпотентно дожимает удаление

Scenario: Одно подтверждение на пачку не смягчает допуска

  • GIVEN транспорт даёт групповое удаление, и человек подтвердил пачку, где каждая загрузка названа поимённо
  • WHEN одна из загрузок пачки находится в состоянии, из которого удаление недоступно
  • THEN по ней приходит тот же отказ по конфликту, что и при поштучном удалении
  • AND остальные загрузки пачки удаляются