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