Files
avandClaude Opus 4.8 3f1a928000 Единое окно: полное пользовательское удаление загрузки (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>
2026-07-10 12:17:51 +03:00

100 lines
8.4 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
## Контекст
Реализуем вторую половину «единого окна» — пользовательское удаление загрузки.
Точки подключения (из разведки кода):
- Методы-действия воркера берут `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`.