Единое окно: полное пользовательское удаление загрузки (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`.