Единое окно: полное пользовательское удаление загрузки (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:
@@ -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`.
|
||||
Reference in New Issue
Block a user