Единое окно: полное пользовательское удаление загрузки (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,81 @@
|
||||
## 1. qbt — удаление раздачи
|
||||
|
||||
- [x] 1.1 В `internal/qbt/qbt.go` добавить `Delete(ctx, hashes []string, deleteFiles bool) error`:
|
||||
`POST /api/v2/torrents/delete`, форма `hashes=<h1>|<h2>|…` + `deleteFiles`.
|
||||
Логирование `ExtCall{Operation:"torrents/delete"}` как у `Add`; 2xx/пустой
|
||||
ответ — успех, отсутствие хеша ошибкой не считать.
|
||||
- [x] 1.2 Тест `internal/qbt/qbt_test.go`: форма запроса delete (hashes через `|`,
|
||||
`deleteFiles=true`), обработка ответа.
|
||||
|
||||
## 2. layout — снятие ссылок без гарда последней копии
|
||||
|
||||
- [x] 2.1 Добавить в `Layouter` и реализацию `internal/layout/layout.go` путь
|
||||
снятия целевых ссылок **без** `ErrLastCopy` (напр. `Remove(ctx, links)`):
|
||||
безусловный `unlink` только своих ссылок строго под `paths.movies`/`series`;
|
||||
санитизация/проверка «под библиотекой» сохраняется, `paths.downloads` не
|
||||
трогаем.
|
||||
|
||||
## 3. worker — команда Delete
|
||||
|
||||
- [x] 3.1 Расширить интерфейс `QBittorrent` воркера методом `Delete(...)`
|
||||
(`internal/worker/worker.go`); проброс реализации `qbt.Client`.
|
||||
- [x] 3.2 В `internal/worker/review.go` добавить `Delete(ctx, id string) error`:
|
||||
под `w.mu`; `GetDownload`; предусловие `d.State ∈ {done, orphaned,
|
||||
target_missing}`, иначе `ErrConflict`. Порядок: (а) снять цель как в `Undo`
|
||||
(`LatestBatchID`/`ListFileLinksByBatch`/фильтр `isLaidOut`, `superseded`
|
||||
пропускаем) через новый `layouter.Remove`, затем `DeleteFileLinksByBatch`; (б)
|
||||
`qbt.Delete(d.Infohashes, deleteFiles=true)` (хеши уже загружены с задачей,
|
||||
отдельный store-геттер не нужен) — ошибку присутствующей раздачи пробросить
|
||||
(не метить `deleted`), отсутствие раздачи ошибкой не считать; (в)
|
||||
`transition(ctx, d, StateDeleted, "user_delete", <причина>)`. Delete
|
||||
source-preflight (`ensureSourceReady`/`reconcileToReality`) НЕ вызывает —
|
||||
толерантен к отсутствию источника.
|
||||
- [x] 3.3 Логировать удаление как осознанное пользовательское снятие источника
|
||||
(`slog`, без секретов): id, состояние-источник, снятые ссылки, `code=user_delete`.
|
||||
|
||||
## 4. Транспорт HTTP/веб + подтверждение
|
||||
|
||||
- [x] 4.1 Роут `POST /ui/downloads/{id}/delete` → хендлер (по образцу
|
||||
`handleUndo`), финализация через `surfaceAction`; трансляция `ErrConflict` →
|
||||
409/фрагмент как у прочих действий, а ошибка qBittorrent (не `ErrConflict`) —
|
||||
внятный отказ пользователю (не тихий успех), не 200-без-эффекта.
|
||||
- [x] 4.2 Флаг `Deletable` (состояние ∈ {done, orphaned, target_missing}) в
|
||||
расчётах кнопок (`httpapi.go:648`, `download.go:108`).
|
||||
- [x] 4.3 Danger-секция в самом низу `web/templates/partials/download_main.html`:
|
||||
кнопка «Удалить» → confirm (htmx-своп фрагмента «Точно удалить? …
|
||||
[Да, удалить] [Отмена]») → `POST …/delete`. Деградация без htmx
|
||||
(`<details>`/отдельный шаг), ошибка на htmx-пути = 200 + фрагмент.
|
||||
- [ ] 4.4 (Опц., НЕ в этом change) JSON-роут `POST /api/downloads/{id}/delete`.
|
||||
Осознанно отложено: REST в спеке помечен опциональным, а деструктивный endpoint
|
||||
без естественного шага подтверждения заводим только по реальной нужде.
|
||||
|
||||
## 5. Транспорт Telegram + подтверждение
|
||||
|
||||
- [x] 5.1 В `internal/tgbot` добавить действие `delete`: callback `delete:<id>`
|
||||
показывает confirm-keyboard (`delete_confirm:<id>` / «Отмена»); удаление — на
|
||||
подтверждающем callback (`Reviewer.Delete`). Кнопка «Удалить» — в карточке
|
||||
задачи для состояний done/orphaned/target_missing.
|
||||
- [x] 5.2 Ветка ошибок как у прочих действий (`ErrConflict` → понятный отказ;
|
||||
ошибка qBittorrent → отдельное сообщение об отказе, не тихий успех), после
|
||||
успеха — `answer` + `refreshCard`.
|
||||
|
||||
## 6. Тесты воркера
|
||||
|
||||
- [x] 6.1 Delete из `done`: сняты ссылки + вызван `qbt.Delete(deleteFiles=true)`
|
||||
+ переход `deleted`/`user_delete`.
|
||||
- [x] 6.2 Delete из `orphaned`: последняя копия снята (гард выключен), отсутствие
|
||||
раздачи не ошибка, переход `deleted`.
|
||||
- [x] 6.3 Delete из `target_missing`: снятие цели идемпотентно (нет живых ссылок),
|
||||
раздача снесена, переход `deleted`.
|
||||
- [x] 6.4 Delete из `review`/иного — отказ `ErrConflict`, состояние не меняется.
|
||||
- [x] 6.5 Ошибка `qbt.Delete` при присутствующей раздаче — задача остаётся, в
|
||||
`deleted` не уходит; повторный delete идемпотентен.
|
||||
|
||||
## 7. Ревью и сверка
|
||||
|
||||
- [x] 7.1 `task test` и `task lint` зелёные.
|
||||
- [x] 7.2 Ревью кода (второй чекпоинт) перед archive.
|
||||
- [x] 7.3 `openspec validate download-delete --strict` зелёный.
|
||||
- [x] 7.4 Синк нарратива `docs/specs/workflow.md`: явные пользовательские рёбра
|
||||
`done/orphaned/target_missing → deleted` (delete) и различие Undo vs delete;
|
||||
удалить `docs/backlog/udalenie-edinoe-okno.md` и строку из индекса беклога.
|
||||
Reference in New Issue
Block a user