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

116 lines
9.6 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
## 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** остальные загрузки пачки удаляются