Вторая половина «единого окна»: команда «Удалить» снимает наши библиотечные хардлинки (гард последней копии осознанно выключен, в отличие от 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>
8.4 KiB
Контекст
Реализуем вторую половину «единого окна» — пользовательское удаление загрузки. Точки подключения (из разведки кода):
- Методы-действия воркера берут
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):
Lock;GetDownload; предусловиеd.State ∈ {done, orphaned, target_missing}, иначеErrConflict(какUndoдля не-done). Из прочих состояний команда недоступна.- Снять цель (наши библиотечные ссылки): как в
Undo—LatestBatchID,ListFileLinksByBatch, отфильтроватьisLaidOut(пропуститьsuperseded— путь забрала другая загрузка, её хардлинк не трогаем), снять их без гарда последней копии, затемDeleteFileLinksByBatch. Вtarget_missing/после ручного удаления ссылок список пуст — снятие идемпотентно (нечего снимать). - Снять источник (раздачу):
qbt.Delete(hashes=все известные infohash задачи, deleteFiles=true). Идемпотентно: если раздачи нет (orphaned), qBittorrent просто не находит хеш — не ошибка. Ошибку сети/API от qBittorrent пробрасываем (не метимdeleted, если источник реально не смогли снять — иначе соврём про освобождённое место); удаление ссылок при этом уже выполнено — повторный delete идемпотентен и дожмёт источник. 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.