Единое окно: полное пользовательское удаление загрузки (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,99 @@
## Контекст
Реализуем вторую половину «единого окна» — пользовательское удаление загрузки.
Точки подключения (из разведки кода):
- Методы-действия воркера берут `w.mu.Lock()`, читают `GetDownload`, проверяют
предусловие по `d.State`, делают переход. Образцы: `Undo`
(`internal/worker/review.go:509`), `Cancel`/`Retry`
(`internal/worker/worker.go:772`/`:792`), `Defer` (`review.go:489`).
- `Undo` уже снимает хардлинки последнего батча: `LatestBatchID`
`ListFileLinksByBatch` → фильтр `isLaidOut` (пропускаем `superseded`) →
`layouter.Undo(links)``DeleteFileLinksByBatch``transition(...Reverted)`.
Гард последней копии — в `layout.Undo` (`internal/layout/layout.go:393`,
отказ `ErrLastCopy` при `nlink<=1`).
- Инициатор перехода различается по `error_code`: сверка кладёт `"reconcile"`
(`reconcile.go:290`), человекочитаемую причину — в `error_msg`; пользовательские
действия сейчас передают `"",""`.
- Рёбра `done→deleted`, `orphaned→deleted`, `target_missing→deleted` уже есть в
`allowedTransitions` (`internal/store/download.go:94,100,101`); `deleted`
терминально (`StateDeleted: nil`). **Граф не трогаем.**
- Метода удаления в `qbt` нет — добавляем (`Add`/`Torrents`/`Files` — образцы
вызова WebUI API с `ExtCall`-логированием).
## Решения
### 1. Delete как единая операция «снять обе стороны»
`Worker.Delete(ctx, id)`:
1. `Lock`; `GetDownload`; предусловие `d.State ∈ {done, orphaned, target_missing}`,
иначе `ErrConflict` (как `Undo` для не-`done`). Из прочих состояний команда
недоступна.
2. **Снять цель** (наши библиотечные ссылки): как в `Undo``LatestBatchID`,
`ListFileLinksByBatch`, отфильтровать `isLaidOut` (пропустить `superseded`
путь забрала другая загрузка, её хардлинк не трогаем), снять их **без** гарда
последней копии, затем `DeleteFileLinksByBatch`. В `target_missing`/после
ручного удаления ссылок список пуст — снятие идемпотентно (нечего снимать).
3. **Снять источник** (раздачу): `qbt.Delete(hashes=все известные infohash задачи,
deleteFiles=true)`. Идемпотентно: если раздачи нет (`orphaned`), qBittorrent
просто не находит хеш — не ошибка. Ошибку сети/API от qBittorrent
пробрасываем (не метим `deleted`, если источник реально не смогли снять —
иначе соврём про освобождённое место); удаление ссылок при этом уже
выполнено — повторный delete идемпотентен и дожмёт источник.
4. `transition(ctx, d, StateDeleted, "user_delete", <причина>)` — терминально.
Лог перехода несёт `code=user_delete`, отличая от reconcile-`deleted`.
Порядок «сначала цель, потом источник»: если оборвёмся между шагами (в т.ч. при
ошибке `qbt.Delete`), останемся с живым источником и снятой целью. Записанное
состояние ещё `done`, а реальность — «источник есть, цель снята», т.е. по матрице
это `target_missing` (не `orphaned`!); ближайший тик сверки приведёт запись к
`target_missing`. Кратковременное рассогласование `done`↔реальность до тика
сверки ожидаемо и безопасно: повторный delete идемпотентно дожмёт, опираясь либо
на оставшийся `done`, либо на приведённый сверкой `target_missing` (delete
доступен из обоих). Это безопаснее обратного порядка — снести источник, оставив
«последнюю копию» в библиотеке при неснятой цели.
### 2. Гард последней копии — выключаем осознанно
`Undo` отказывается снимать ссылку при `nlink<=1` (последняя копия). Delete —
ровно обратное намерение: освободить место, сняв последнюю копию. Нужен путь
снятия ссылок в `layout` **без** `ErrLastCopy`. Вариант: добавить в `Layouter`
метод (напр. `Remove(ctx, links)`), делающий `unlink` целевых ссылок безусловно,
но по-прежнему **только своих** ссылок под `paths.movies`/`series` (инвариант
«трогаем лишь свои ссылки, не `paths.downloads`» сохраняется). Санитизация/
проверка «строго под библиотекой» остаётся. Источник (файлы в `downloads/`) мы
не трогаем сами — их сносит **qBittorrent** по нашему API-вызову `deleteFiles=true`.
### 3. qbt.Delete
`POST /api/v2/torrents/delete`, форма `hashes=<h1>|<h2>|…` (все известные хеши
задачи, v1/v2 — qBittorrent матчит присутствующий), `deleteFiles=true`.
Логирование — `ExtCall{Operation:"torrents/delete"}`, как у `Add`. Пустой/2xx
ответ — успех; отсутствие хеша ошибкой не считается (идемпотентность).
### 4. Подтверждение (обязательно, во всех транспортах)
Терминальное необратимое действие с обходом инварианта — только по явному
подтверждению (в проекте подтверждений опасных действий ещё нет, вводим впервые).
- **Веб-UI:** danger-секция в самом низу `download_main.html` (виз. отделена).
Кнопка «Удалить» раскрывает confirm (htmx-своп фрагмента: «Точно удалить?
Раздача и файлы будут снесены — [Да, удалить] [Отмена]»); фактическое удаление
— `POST /ui/downloads/{id}/delete`. Деградация без htmx (web-ui-конвенция):
секция — обычная форма с подтверждающей кнопкой на отдельном шаге/`<details>`,
ошибка на htmx-пути = 200 + фрагмент.
- **Telegram:** двухшаговый inline — `delete:<id>` показывает confirm-keyboard
(`delete_confirm:<id>` / «Отмена»), само удаление — на подтверждающем callback.
Флаг `Deletable` (состояние ∈ {done, orphaned, target_missing}) считается там же,
где `Undoable`/`Retriable` (`httpapi.go:648`, `download.go:108`).
## Что осознанно НЕ делаем
- **Мультивыбор «удалить выбранное»** — из scope вынесено (задача помечала как
опциональное). Остаётся тонкой обёрткой над `Delete` на будущее, если понадобится.
- **Новый статус** — не заводим, `deleted` переиспользуется.
- **Миграция БД / новое поле инициатора** — не нужны, различаем через `error_code`.
- **Дельта `review`** — исходная задача упоминала `review`, но delete не команда
экрана ревью (недоступна из `review`); правим только `state-reconciliation`.
@@ -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.
@@ -0,0 +1,92 @@
## ADDED Requirements
### 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 идемпотентно дожимает удаление
@@ -0,0 +1,81 @@
## 1. qbt — удаление раздачи
- [x] 1.1 В `internal/qbt/qbt.go` добавить `Delete(ctx, hashes []string, deleteFiles bool) error`:
`POST /api/v2/torrents/delete`, форма `hashes=<h1>|<h2>|…` + `deleteFiles`.
Логирование `ExtCall{Operation:"torrents/delete"}` как у `Add`; 2xx/пустой
ответ — успех, отсутствие хеша ошибкой не считать.
- [x] 1.2 Тест `internal/qbt/qbt_test.go`: форма запроса delete (hashes через `|`,
`deleteFiles=true`), обработка ответа.
## 2. layout — снятие ссылок без гарда последней копии
- [x] 2.1 Добавить в `Layouter` и реализацию `internal/layout/layout.go` путь
снятия целевых ссылок **без** `ErrLastCopy` (напр. `Remove(ctx, links)`):
безусловный `unlink` только своих ссылок строго под `paths.movies`/`series`;
санитизация/проверка «под библиотекой» сохраняется, `paths.downloads` не
трогаем.
## 3. worker — команда Delete
- [x] 3.1 Расширить интерфейс `QBittorrent` воркера методом `Delete(...)`
(`internal/worker/worker.go`); проброс реализации `qbt.Client`.
- [x] 3.2 В `internal/worker/review.go` добавить `Delete(ctx, id string) error`:
под `w.mu`; `GetDownload`; предусловие `d.State ∈ {done, orphaned,
target_missing}`, иначе `ErrConflict`. Порядок: (а) снять цель как в `Undo`
(`LatestBatchID`/`ListFileLinksByBatch`/фильтр `isLaidOut`, `superseded`
пропускаем) через новый `layouter.Remove`, затем `DeleteFileLinksByBatch`; (б)
`qbt.Delete(d.Infohashes, deleteFiles=true)` (хеши уже загружены с задачей,
отдельный store-геттер не нужен) — ошибку присутствующей раздачи пробросить
(не метить `deleted`), отсутствие раздачи ошибкой не считать; (в)
`transition(ctx, d, StateDeleted, "user_delete", <причина>)`. Delete
source-preflight (`ensureSourceReady`/`reconcileToReality`) НЕ вызывает —
толерантен к отсутствию источника.
- [x] 3.3 Логировать удаление как осознанное пользовательское снятие источника
(`slog`, без секретов): id, состояние-источник, снятые ссылки, `code=user_delete`.
## 4. Транспорт HTTP/веб + подтверждение
- [x] 4.1 Роут `POST /ui/downloads/{id}/delete` → хендлер (по образцу
`handleUndo`), финализация через `surfaceAction`; трансляция `ErrConflict`
409/фрагмент как у прочих действий, а ошибка qBittorrent (не `ErrConflict`) —
внятный отказ пользователю (не тихий успех), не 200-без-эффекта.
- [x] 4.2 Флаг `Deletable` (состояние ∈ {done, orphaned, target_missing}) в
расчётах кнопок (`httpapi.go:648`, `download.go:108`).
- [x] 4.3 Danger-секция в самом низу `web/templates/partials/download_main.html`:
кнопка «Удалить» → confirm (htmx-своп фрагмента «Точно удалить? …
[Да, удалить] [Отмена]») → `POST …/delete`. Деградация без htmx
(`<details>`/отдельный шаг), ошибка на htmx-пути = 200 + фрагмент.
- [ ] 4.4 (Опц., НЕ в этом change) JSON-роут `POST /api/downloads/{id}/delete`.
Осознанно отложено: REST в спеке помечен опциональным, а деструктивный endpoint
без естественного шага подтверждения заводим только по реальной нужде.
## 5. Транспорт Telegram + подтверждение
- [x] 5.1 В `internal/tgbot` добавить действие `delete`: callback `delete:<id>`
показывает confirm-keyboard (`delete_confirm:<id>` / «Отмена»); удаление — на
подтверждающем callback (`Reviewer.Delete`). Кнопка «Удалить» — в карточке
задачи для состояний done/orphaned/target_missing.
- [x] 5.2 Ветка ошибок как у прочих действий (`ErrConflict` → понятный отказ;
ошибка qBittorrent → отдельное сообщение об отказе, не тихий успех), после
успеха — `answer` + `refreshCard`.
## 6. Тесты воркера
- [x] 6.1 Delete из `done`: сняты ссылки + вызван `qbt.Delete(deleteFiles=true)`
+ переход `deleted`/`user_delete`.
- [x] 6.2 Delete из `orphaned`: последняя копия снята (гард выключен), отсутствие
раздачи не ошибка, переход `deleted`.
- [x] 6.3 Delete из `target_missing`: снятие цели идемпотентно (нет живых ссылок),
раздача снесена, переход `deleted`.
- [x] 6.4 Delete из `review`/иного — отказ `ErrConflict`, состояние не меняется.
- [x] 6.5 Ошибка `qbt.Delete` при присутствующей раздаче — задача остаётся, в
`deleted` не уходит; повторный delete идемпотентен.
## 7. Ревью и сверка
- [x] 7.1 `task test` и `task lint` зелёные.
- [x] 7.2 Ревью кода (второй чекпоинт) перед archive.
- [x] 7.3 `openspec validate download-delete --strict` зелёный.
- [x] 7.4 Синк нарратива `docs/specs/workflow.md`: явные пользовательские рёбра
`done/orphaned/target_missing → deleted` (delete) и различие Undo vs delete;
удалить `docs/backlog/udalenie-edinoe-okno.md` и строку из индекса беклога.