Files
jellybit/openspec/changes/archive/2026-07-10-download-delete/design.md
T
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

8.4 KiB
Raw Blame History

Контекст

Реализуем вторую половину «единого окна» — пользовательское удаление загрузки. Точки подключения (из разведки кода):

  • Методы-действия воркера берут 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 уже снимает хардлинки последнего батча: LatestBatchIDListFileLinksByBatch → фильтр isLaidOut (пропускаем superseded) → layouter.Undo(links)DeleteFileLinksByBatchtransition(...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. Снять цель (наши библиотечные ссылки): как в UndoLatestBatchID, 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.