## Контекст Реализуем вторую половину «единого окна» — пользовательское удаление загрузки. Точки подключения (из разведки кода): - Методы-действия воркера берут `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=

|

|…` (все известные хеши задачи, 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-конвенция): секция — обычная форма с подтверждающей кнопкой на отдельном шаге/`
`, ошибка на htmx-пути = 200 + фрагмент. - **Telegram:** двухшаговый inline — `delete:` показывает confirm-keyboard (`delete_confirm:` / «Отмена»), само удаление — на подтверждающем 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`.