## Контекст

Реализуем вторую половину «единого окна» — пользовательское удаление загрузки.
Точки подключения (из разведки кода):

- Методы-действия воркера берут `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`.
