Единое окно: полное пользовательское удаление загрузки (delete)

Вторая половина «единого окна»: команда «Удалить» снимает наши библиотечные
хардлинки (гард последней копии осознанно выключен, в отличие от Undo) и сносит
раздачу с файлами из qBittorrent (deleteFiles=true) → терминальный deleted.
Доступна из done/orphaned/target_missing, идемпотентна к отсутствующей стороне;
инициатор различается через error_code=user_delete. Подтверждение обязательно:
веб — danger-секция внизу страницы (hx-confirm + details), Telegram — двухшаговый
inline-confirm. qbt.Delete + layout.Remove (unlink без ErrLastCopy, только свои
ссылки под movies/series). Граф переходов не менялся — рёбра уже были.

OpenSpec: state-reconciliation +1 требование; синк workflow.md; беклог закрыт.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
av
2026-07-10 12:17:51 +03:00
co-authored by Claude Opus 4.8
parent e68f96df9e
commit 3f1a928000
25 changed files with 893 additions and 76 deletions
@@ -0,0 +1,74 @@
## Why
«Единое окно» реализовано наполовину: jellybit распознаёт **ручное** удаление и
метит рассинхрон (`state-reconciliation``target_missing`/`orphaned`/`deleted`,
безопасный `Undo` с гардом последней копии, preflight). Не хватает второй
половины — **удалять загрузку из самого jellybit**, не идя руками в
qBittorrent/Jellyfin.
Основной сценарий одного окна — «досмотрел → освободить место»: убрать и раздачу,
и файлы в библиотеке разом. Из-за хардлинков иначе место не вернуть — файл в
`downloads/` (qBittorrent) и наша ссылка в библиотеке указывают на **один инод**,
диск освобождается только когда исчезает последняя ссылка. Значит удаление обязано
снять **обе** стороны. Это осознанный выход за инвариант «источник неприкосновенен»,
поэтому — с подтверждением и отдельным следом в логе/причине перехода.
`Undo` и `delete` — разные операции, и это надо зафиксировать:
- **Undo** — «перераспознать»: снимает только наши ссылки, раздачу в qBittorrent
сохраняет, гард последней копии **включён**. Уже готов.
- **delete** — «убрать окончательно, освободить место»: снимает наши ссылки И
сносит раздачу+файлы из qBittorrent, гард **выключен**, состояние терминальное
`deleted`.
## What Changes
- Ввести пользовательскую команду **«Удалить»** в `worker`, доступную из
`done`, `orphaned`, `target_missing` (подчистить остатки за собой из любого
состояния «уже разложено / рассинхрон»). Действие: снять наши библиотечные
хардлинки последнего батча (переиспользуем механику `Undo`, `superseded`
пропускаем) с **выключенным** гардом последней копии **+** удалить раздачу с
файлами из qBittorrent. Идемпотентно к отсутствующей стороне (в `orphaned` нет
источника, в `target_missing` нет цели).
- Переиспользовать существующее терминальное состояние `deleted`**без** нового
статуса; рёбра `done/orphaned/target_missing → deleted` уже есть в
`allowedTransitions`. Инициатора-пользователя отличать от сверки через
`error_code = "user_delete"` (сверка кладёт `"reconcile"`), человекочитаемую
причину — в `error_msg` и лог перехода.
- Добавить в `qbt` метод удаления раздачи (`POST /api/v2/torrents/delete`,
`deleteFiles=true`) и объявить его в интерфейсе `QBittorrent` воркера.
- Добавить в `layout` путь снятия ссылок **без** гарда последней копии (delete
сознательно снимает последнюю копию, в отличие от `Undo`).
- Обязательное **подтверждение** во всех транспортах: веб-UI — danger-секция с
кнопкой в самом низу страницы загрузки; Telegram — двухшаговый inline-confirm.
## Capabilities
### New Capabilities
<!-- нет новых capability -->
### Modified Capabilities
- `state-reconciliation`: добавляется пользовательская команда «Удалить»
(снять источник и цель, гард последней копии выключен) с переходом
`done`/`orphaned`/`target_missing` → терминальный `deleted`, отличимая от
reconcile-`deleted` инициатором, с обязательным подтверждением.
## Impact
- Код воркера: `internal/worker/review.go` (новый метод `Delete`; путь снятия
ссылок с выключенным гардом), `internal/worker/worker.go` (расширить интерфейс
`QBittorrent` методом удаления; проброс в конструктор при необходимости).
- Инфраструктура: `internal/qbt/qbt.go` (метод `Delete(hashes, deleteFiles)`
`/api/v2/torrents/delete`), `internal/layout/layout.go` (снятие ссылок без
`ErrLastCopy`).
- Транспорты: `internal/httpapi` (роут `POST /ui/downloads/{id}/delete` + confirm,
флаг `Deletable` по состоянию; опц. JSON-роут), `web/templates/partials`
(danger-секция внизу страницы загрузки), `internal/tgbot` (действие `delete` +
двухшаговый confirm-keyboard).
- Данные: **осознанное** снятие последней копии данных при удалении из
`orphaned` — только по явному подтверждению; инвариант «источник
неприкосновенен» обходится намеренно и логируется как пользовательское
удаление. Схема БД не меняется (переиспользуем `error_code`/`error_msg`).
- Тесты: `internal/worker/review_test.go` (delete из каждого из трёх состояний,
идемпотентность к отсутствующей стороне, отказ из прочих состояний),
`internal/qbt/qbt_test.go` (форма запроса delete), транспортные тесты confirm.