Единое окно: полное пользовательское удаление загрузки (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:
av
2026-07-10 12:17:51 +03:00
co-authored by Claude Opus 4.8
parent e68f96df9e
commit 3f1a928000
25 changed files with 893 additions and 76 deletions
-1
View File
@@ -18,7 +18,6 @@ Tududi (проект `jellybit`) больше **не** держит беклог
- [Проблема второго сезона (сходимость папки сериала)](vtoroy-sezon-shodimost-papki.md) — Второй/третий сезон должен ложиться в ТУ ЖЕ папку сериала, а не заводить рядом почти…
- [Раздачи с докачиванием (merge при повторном добавлении)](merge-dokachivanie.md) — Свежий сериал раздают по мере выхода: торрент с 5 из 10 эпизодов позже перезаливают…
- [Полное удаление загрузки из jellybit («единое окно», path 2)](udalenie-edinoe-okno.md) — Действие «Удалить»: снять хардлинки + снести раздачу+файлы из qBittorrent → освободить место (отдельно от undo)
- [Ретеншн и очистка БД](retention-ochistka-bd.md) — Терминальные задачи (done/cancelled/failed/reverted), их попытки recognition с сырыми…
- [Eval-харнес распознавания (корпус кейсов + метрика точности)](eval-harness-raspoznavaniya.md) — Распознавание — ядро продукта, но смена модели или правка промпта сейчас вслепую…
-52
View File
@@ -1,52 +0,0 @@
# Полное удаление загрузки из jellybit («единое окно», path 2)
**Приоритет:** высокий
Безопасная половина «единого окна» уже в проде: распознавание ручного удаления и
пометка рассинхрона (state-reconciliation — `target_missing`/`orphaned`/`deleted`,
undo с nlink-гардом, preflight). Осталась вторая половина — **удалять из самого
jellybit**, не идя руками в qBittorrent/Jellyfin.
Основной сценарий одного окна — «досмотрел → освободить место»: удалить и раздачу,
и файлы в библиотеке разом. Из-за хардлинков иначе место и не вернуть — файл в
`downloads/` (qBittorrent) и наша ссылка в библиотеке указывают на **один инод**,
диск освобождается только когда исчезает последняя ссылка. Значит delete обязан
снять **обе** стороны.
## Действие «Удалить» (одна загрузка)
- Снять живые хардлинки загрузки (переиспользуем undo-механику: `superseded`
пропускаем) **+** удалить раздачу с файлами из qBittorrent (новый метод в `qbt`,
`deleteTorrents` с `deleteFiles=true`).
- **Осознанно обходим** предохранитель последней копии (undo при «источник удалён,
цель — последняя копия» отказывает — `worker/review.go:499`; здесь мы наоборот
хотим снять последнюю копию).
- Обязательное **подтверждение** — это выход за инвариант «источник
неприкосновенен», не по случайному клику. Логировать как осознанное удаление
источника.
## undo vs delete (зафиксировать различие)
- **undo** — «перераспознать»: снимает только наши ссылки, раздачу в qBittorrent
сохраняет, данные бережёт (гард включён). Уже готов.
- **delete** — «убрать окончательно, освободить место»: снимает наши ссылки И
сносит раздачу+файлы, гард выключен, состояние терминальное.
## Терминальное состояние
Переиспользуем существующий `deleted` (исход тот же: и источник, и цель сняты) —
**без** нового статуса. Инициатора (`user` против reconciliation) пишем в причину/
лог перехода. В граф переходов добавить явное пользовательское ребро
`done → deleted` (сейчас `deleted` выводит только сверка с реальностью).
## Опционально (если понадобится)
Мультивыбор в списке + «удалить выбранное» — тонкая обёртка над тем же действием
для редкого случая «снести сериал целиком». Вычисляемую группу-«тайтл» и экран
состава **не** делаем — оверинжиниринг ради редкого сценария.
Оформить как OpenSpec-change (дельта `state-reconciliation`/`review` + метод `qbt`
+ ребро графа + action в UI/боте).
Связано: drafts/logical-title-model.md §5.3/§6.4, ADR-2026-06-13-hardlinks,
openspec/specs/state-reconciliation, specs/workflow.md, пакеты qbt, worker, layout.
+17 -3
View File
@@ -51,10 +51,11 @@ stateDiagram-v2
done --> target_missing: сверка — цель удалена
done --> orphaned: сверка — источник пропал
done --> deleted: Удалить (delete)
target_missing --> recognizing: Привязать заново
target_missing --> orphaned: источник тоже пропал
target_missing --> deleted: источник тоже пропал
orphaned --> deleted: цель тоже удалена
target_missing --> deleted: Удалить / источник тоже пропал (сверка)
orphaned --> deleted: Удалить / цель тоже удалена (сверка)
target_missing --> done: healing (цель вернулась)
orphaned --> done: healing (источник вернулся)
@@ -96,7 +97,8 @@ stateDiagram-v2
[architecture.md](architecture.md) → «Раскладка файлов».
- **done** — при входе неблокирующе дёргаем пересканирование Jellyfin
(опц., см. [architecture.md](architecture.md) → «Пересканирование
Jellyfin»); доступен **Undo**`reverted` (убрать созданные ссылки).
Jellyfin»); доступен **Undo**`reverted` (убрать созданные ссылки) и
**Удалить**`deleted` (полное удаление, см. ниже).
- **stuck / failed / cancelled** — не качается дольше таймаута; ошибка
(ретраибельна); «Отклонить».
- **reverted / cancelled → recognizing** — «Привязать заново»: после
@@ -120,6 +122,18 @@ stateDiagram-v2
- **deleted** — нет ни источника, ни цели; **терминально**: сверка его
больше не переоценивает (см. ниже).
**Undo vs Удалить (delete).** Это разные пользовательские операции. **Undo**
(из `done`) — «перераспознать»: снимает только наши библиотечные ссылки, раздачу
в qBittorrent бережёт, гард последней копии включён (не сотрёт единственный
файл) → `reverted`. **Удалить** (из `done`, `orphaned`, `target_missing`) —
«убрать окончательно, освободить место»: снимает наши ссылки **и** сносит раздачу
с файлами из qBittorrent, гард последней копии осознанно выключен (обход
инварианта «источник неприкосновенен» — только по подтверждению) →
терминальный `deleted`. Идемпотентно к отсутствующей стороне, так что подчищает
остатки из любого из трёх состояний. Инициатор в `deleted` различается по
`error_code`: пользовательское удаление — `user_delete`, вывод сверкой —
`reconcile`. Полные требования — `openspec/specs/state-reconciliation/`.
Сверка трогает только `done`/`target_missing`/`orphaned` — терминальный
`deleted`, активные и пользовательски-терминальные (`reverted`/`cancelled`/
`failed`/`stuck`) состояния не задевает. Реальность «лечится» сама: при
+2
View File
@@ -25,6 +25,7 @@ func (s stubCommander) Retry(context.Context, string) error { return s.retryErr
type actionReviewer struct {
stubReviewer
undoErr error
deleteErr error
relinkErr error
rerecognizeErr error
refineErr error
@@ -32,6 +33,7 @@ type actionReviewer struct {
}
func (a actionReviewer) Undo(context.Context, string) error { return a.undoErr }
func (a actionReviewer) Delete(context.Context, string) error { return a.deleteErr }
func (a actionReviewer) Relink(context.Context, string) error { return a.relinkErr }
func (a actionReviewer) Rerecognize(context.Context, string) error { return a.rerecognizeErr }
func (a actionReviewer) Refine(_ context.Context, _ string, hint string) error {
+3
View File
@@ -53,6 +53,7 @@ type downloadDetailView struct {
Undoable bool
Relinkable bool
Retriable bool
Deletable bool // полное удаление доступно (done/orphaned/target_missing)
}
// detailTitle — заголовок страницы просмотра: имя раздачи (display_name) →
@@ -111,6 +112,8 @@ func (s *server) buildDownloadView(id string, rd *worker.ReviewData) downloadDet
Relinkable: d.State == store.StateReverted || d.State == store.StateCancelled ||
d.State == store.StateTargetMissing,
Retriable: d.State == store.StateFailed || d.State == store.StateStuck,
Deletable: d.State == store.StateDone || d.State == store.StateOrphaned ||
d.State == store.StateTargetMissing,
}
// Дата добавления рядом с шапкой (source_added_at → фолбэк created_at,
// как в порядке и карточках списка); неразбираемое время просто опускаем.
+1
View File
@@ -137,6 +137,7 @@ func NewRouter(d Deps) (http.Handler, error) {
r.Post("/ui/downloads/{id}/defer", s.handleDefer)
r.Post("/ui/downloads/{id}/undo", s.handleUndo)
r.Post("/ui/downloads/{id}/relink", s.handleRelink)
r.Post("/ui/downloads/{id}/delete", s.handleDelete)
// REST API.
r.Route("/api", func(r chi.Router) {
+5
View File
@@ -454,6 +454,7 @@ type fakeReviewer struct {
applied []string
deferred []string
undone []string
deleted []string
relinked []string
rerecognized []string
cleared []string
@@ -491,6 +492,10 @@ func (f *fakeReviewer) Undo(_ context.Context, id string) error {
f.undone = append(f.undone, id)
return nil
}
func (f *fakeReviewer) Delete(_ context.Context, id string) error {
f.deleted = append(f.deleted, id)
return nil
}
func (f *fakeReviewer) Relink(_ context.Context, id string) error {
f.relinked = append(f.relinked, id)
return nil
+1
View File
@@ -49,6 +49,7 @@ func (stubReviewer) SetType(context.Context, string, string) error
func (stubReviewer) IgnoreFile(context.Context, string, string) error { return nil }
func (stubReviewer) Defer(context.Context, string) error { return nil }
func (stubReviewer) Undo(context.Context, string) error { return nil }
func (stubReviewer) Delete(context.Context, string) error { return nil }
func (stubReviewer) Relink(context.Context, string) error { return nil }
func (stubReviewer) Rerecognize(context.Context, string) error { return nil }
func (stubReviewer) ChooseCandidate(context.Context, string, string) error { return nil }
+14
View File
@@ -21,6 +21,7 @@ type Reviewer interface {
IgnoreFile(ctx context.Context, id string, src string) error
Defer(ctx context.Context, id string) error
Undo(ctx context.Context, id string) error
Delete(ctx context.Context, id string) error
Relink(ctx context.Context, id string) error
Rerecognize(ctx context.Context, id string) error
ChooseCandidate(ctx context.Context, id, candidateID string) error
@@ -332,6 +333,19 @@ func (s *server) handleUndo(w http.ResponseWriter, r *http.Request) {
s.surfaceAction(w, r, id, s.deps.Reviewer.Undo(r.Context(), id))
}
// handleDelete — полное удаление загрузки (снять хардлинки + снести раздачу с
// файлами из qBittorrent → deleted). Осознанное необратимое действие: транспорт
// подтверждает его перед POST (hx-confirm + отдельная danger-секция). Ошибку
// qBittorrent surfaceAction покажет как отказ (не тихий успех).
func (s *server) handleDelete(w http.ResponseWriter, r *http.Request) {
id, err := pathID(r)
if err != nil {
redirectErr(w, r, "некорректный id")
return
}
s.surfaceAction(w, r, id, s.deps.Reviewer.Delete(r.Context(), id))
}
// handleRelink повторно привязывает откатанную задачу: перезапускает
// распознавание, задача пройдёт recognizing → review для подтверждения.
func (s *server) handleRelink(w http.ResponseWriter, r *http.Request) {
+28
View File
@@ -434,6 +434,34 @@ func (l *Layouter) Undo(ctx context.Context, links []Link) (int, error) {
return removed, nil
}
// Remove снимает целевые ссылки БЕЗ гарда последней копии — в отличие от Undo,
// который отказывается стирать единственную копию данных. Служит осознанному
// удалению загрузки («освободить место»): снимает последнюю библиотечную ссылку,
// даже если источник уже пропал. Как и Undo, трогает только пути строго под
// библиотеками (источник недосягаем) и идемпотентен к отсутствующей цели.
// Возвращает число удалённых ссылок.
func (l *Layouter) Remove(ctx context.Context, links []Link) (int, error) {
log := logctx.FromOr(ctx, l.log)
removed := 0
for _, ln := range links {
root, err := undoRoot(l, ln.Dst)
if err != nil {
return removed, err
}
if err := os.Remove(ln.Dst); err != nil {
if errors.Is(err, fs.ErrNotExist) {
continue // цели уже нет — снимать нечего (идемпотентно)
}
log.Error("layout remove failed", "dst", ln.Dst, "error", err)
return removed, fmt.Errorf("layout: remove %q: %w", ln.Dst, err)
}
removed++
log.Debug("layout link removed (delete)", "dst", ln.Dst)
pruneEmptyDirs(filepath.Dir(ln.Dst), root)
}
return removed, nil
}
// undoRoot возвращает корень библиотеки, под которым лежит dst, либо ошибку,
// если путь не под movies/series (откат трогает только библиотеку).
func undoRoot(l *Layouter, dst string) (string, error) {
+50
View File
@@ -256,6 +256,56 @@ func (c *Client) Add(ctx context.Context, ar AddRequest) error {
return nil
}
// Delete удаляет раздачу(и) из qBittorrent. deleteFiles=true сносит и файлы на
// диске (освобождая место — библиотечный хардлинк jellybit к тому же иноду
// снимается отдельно). Идемпотентно: неизвестный хеш qBittorrent просто
// игнорирует, ошибкой это не считаем. jellybit сам файлы под downloads не
// трогает — их удаляет qBittorrent по этому вызову.
func (c *Client) Delete(ctx context.Context, hashes []string, deleteFiles bool) error {
nonEmpty := hashes[:0:0]
for _, h := range hashes {
if h = strings.TrimSpace(h); h != "" {
nonEmpty = append(nonEmpty, h)
}
}
if len(nonEmpty) == 0 {
return fmt.Errorf("qbittorrent delete: no hashes")
}
form := url.Values{
"hashes": {strings.Join(nonEmpty, "|")},
"deleteFiles": {strconv.FormatBool(deleteFiles)},
}
body := form.Encode()
log := logctx.FromOr(ctx, c.log)
call := logging.ExtCall{Service: logging.ServiceQBittorrent, Operation: "torrents/delete", Start: time.Now()}
resp, err := c.do(ctx, func() (*http.Request, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodPost,
c.endpoint("/api/v2/torrents/delete"), strings.NewReader(body))
if err != nil {
return nil, err
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.Header.Set("Referer", c.base.String())
return req, nil
})
if err != nil {
call.Failure(log, err)
return fmt.Errorf("qbittorrent delete: %w", err)
}
defer func() { _ = resp.Body.Close() }()
call.Status = resp.StatusCode
if resp.StatusCode != http.StatusOK {
respBody, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<10))
err := fmt.Errorf("qbittorrent delete: status %d body %q",
resp.StatusCode, strings.TrimSpace(string(respBody)))
call.Failure(log, err)
return err
}
call.Success(log, "hashes", len(nonEmpty), "delete_files", deleteFiles)
return nil
}
// Torrents возвращает задачи указанной категории (пустая — все).
func (c *Client) Torrents(ctx context.Context, category string) ([]Torrent, error) {
log := logctx.FromOr(ctx, c.log)
+32
View File
@@ -114,6 +114,38 @@ func TestAddSendsRename(t *testing.T) {
}
}
func TestDeleteSendsHashesAndDeleteFiles(t *testing.T) {
var gotHashes, gotDeleteFiles string
mux := http.NewServeMux()
mux.HandleFunc("/api/v2/torrents/delete", func(w http.ResponseWriter, r *http.Request) {
_ = r.ParseForm()
gotHashes = r.PostForm.Get("hashes")
gotDeleteFiles = r.PostForm.Get("deleteFiles")
_, _ = w.Write([]byte("Ok."))
})
srv := httptest.NewServer(mux)
t.Cleanup(srv.Close)
c := newClient(t, srv.URL)
err := c.Delete(context.Background(), []string{"aaa", "", "bbb"}, true)
if err != nil {
t.Fatalf("Delete: %v", err)
}
if gotHashes != "aaa|bbb" { // пустые хеши отфильтрованы, склейка через |
t.Errorf("hashes = %q, want %q", gotHashes, "aaa|bbb")
}
if gotDeleteFiles != "true" {
t.Errorf("deleteFiles = %q, want true", gotDeleteFiles)
}
}
func TestDeleteNoHashesIsError(t *testing.T) {
c := newClient(t, "http://unused")
if err := c.Delete(context.Background(), []string{"", " "}, true); err == nil {
t.Fatal("Delete with no hashes must error before any request")
}
}
func TestAddPerformsLazyLogin(t *testing.T) {
srv := fakeQBittorrent(t, "[]")
c := newClient(t, srv.URL)
+26
View File
@@ -45,6 +45,7 @@ type Reviewer interface {
Defer(ctx context.Context, id string) error
Cancel(ctx context.Context, id string) error
Retry(ctx context.Context, id string) error
Delete(ctx context.Context, id string) error
}
// Config — параметры бота.
@@ -305,6 +306,20 @@ func (b *Bot) handleCallback(ctx context.Context, cq *tgbotapi.CallbackQuery) {
case "retry":
err = b.reviewer.Retry(ctx, id)
note = "Повторяю…"
case "delete":
// Первый шаг: показываем подтверждение (необратимое действие — снос
// раздачи+файлов и хардлинков). Домен не трогаем, меняем клавиатуру.
b.answer(cq.ID, "")
b.editMarkup(chatID, msgID, b.deleteConfirmKeyboard(id))
return
case "delete_cancel":
// Отмена подтверждения — возвращаем обычную карточку.
b.answer(cq.ID, "Отменено")
b.refreshCard(ctx, chatID, msgID, id)
return
case "delete_confirm":
err = b.reviewer.Delete(ctx, id)
note = "Удаляю…"
case "type":
err = b.reviewer.SetType(ctx, id, val)
note = "Меняю тип…"
@@ -397,6 +412,17 @@ func (b *Bot) answer(callbackID, text string) {
}
}
// editMarkup подменяет только клавиатуру сообщения на месте (текст не трогаем).
// Используется для шага подтверждения удаления.
func (b *Bot) editMarkup(chatID int64, msgID int, kb *tgbotapi.InlineKeyboardMarkup) {
if kb == nil {
return
}
if _, err := b.api.Send(tgbotapi.NewEditMessageReplyMarkup(chatID, msgID, *kb)); err != nil {
b.log.Warn("telegram edit markup failed", "chat_id", chatID, "error", err)
}
}
func (b *Bot) setPending(chatID int64, id string) {
b.mu.Lock()
b.pending[chatID] = id
+5
View File
@@ -69,6 +69,7 @@ type fakeReviewer struct {
deferred []string
canceled []string
retried []string
deleted []string
}
func (f *fakeReviewer) ReviewData(context.Context, string) (*worker.ReviewData, error) {
@@ -104,6 +105,10 @@ func (f *fakeReviewer) Retry(_ context.Context, id string) error {
f.retried = append(f.retried, id)
return nil
}
func (f *fakeReviewer) Delete(_ context.Context, id string) error {
f.deleted = append(f.deleted, id)
return nil
}
// tid — валидный lowercase-ULID (callback-data валидируется как ULID).
const tid = "01arz3ndektsv4rrffq69g5fav"
+30 -3
View File
@@ -24,15 +24,19 @@ func (b *Bot) renderCard(rd *worker.ReviewData) (string, *tgbotapi.InlineKeyboar
case store.StateLinking:
return "⏳ Раскладываю #" + id + "…", nil
case store.StateDone:
return b.renderDone(rd), b.webOnly(id)
return b.renderDone(rd), b.deletableKeyboard(id)
default:
text := fmt.Sprintf("Задача #%s — %s.", id, state)
if msg := rd.Download.ErrorMsg.String; msg != "" {
text += "\n" + msg
}
// failed/stuck — даём кнопку повтора; остальное только «в вебе».
if state == store.StateFailed || state == store.StateStuck {
switch state {
case store.StateFailed, store.StateStuck:
// failed/stuck — даём кнопку повтора.
return text, b.retryKeyboard(id)
case store.StateOrphaned, store.StateTargetMissing:
// Рассинхрон — можно подчистить остатки полным удалением.
return text, b.deletableKeyboard(id)
}
return text, b.webOnly(id)
}
@@ -164,6 +168,29 @@ func (b *Bot) retryKeyboard(id string) *tgbotapi.InlineKeyboardMarkup {
return &kb
}
// deletableKeyboard — клавиатура состояний, откуда доступно полное удаление
// (done/orphaned/target_missing): ссылка в веб (опц.) + «Удалить». Само удаление
// двухшаговое — кнопка ведёт на подтверждение (deleteConfirmKeyboard).
func (b *Bot) deletableKeyboard(id string) *tgbotapi.InlineKeyboardMarkup {
var row []tgbotapi.InlineKeyboardButton
if url := b.reviewURL(id); url != "" {
row = append(row, tgbotapi.NewInlineKeyboardButtonURL("🌐 В вебе", url))
}
row = append(row, tgbotapi.NewInlineKeyboardButtonData("🗑 Удалить", "delete:"+id))
kb := tgbotapi.NewInlineKeyboardMarkup(tgbotapi.NewInlineKeyboardRow(row...))
return &kb
}
// deleteConfirmKeyboard — шаг подтверждения удаления: необратимое снятие
// раздачи+файлов и хардлинков. Явное «Да» отделено от отмены.
func (b *Bot) deleteConfirmKeyboard(id string) *tgbotapi.InlineKeyboardMarkup {
kb := tgbotapi.NewInlineKeyboardMarkup(tgbotapi.NewInlineKeyboardRow(
tgbotapi.NewInlineKeyboardButtonData("❗ Да, удалить", "delete_confirm:"+id),
tgbotapi.NewInlineKeyboardButtonData("Отмена", "delete_cancel:"+id),
))
return &kb
}
func (b *Bot) webOnly(id string) *tgbotapi.InlineKeyboardMarkup {
url := b.reviewURL(id)
if url == "" {
+84 -11
View File
@@ -537,17 +537,7 @@ func (w *Worker) Undo(ctx context.Context, id string) error {
if err != nil {
return fmt.Errorf("undo: %w", err)
}
// Снимаем только реально разложенные нами ссылки. superseded-строки —
// путь забрала другая загрузка (см. state-reconciliation, владение
// путём); файл по нему теперь её хардлинк, трогать его нельзя.
links := make([]layout.Link, 0, len(rows))
for _, r := range rows {
if !isLaidOut(r.Status) {
continue
}
links = append(links, layout.Link{Src: r.SrcPath, Dst: r.DstPath, Kind: layout.Kind(r.Kind)})
}
n, err := w.layouter.Undo(ctx, links)
n, err := w.layouter.Undo(ctx, laidOutLinks(rows))
if err != nil {
return fmt.Errorf("undo: %w", err)
}
@@ -559,6 +549,89 @@ func (w *Worker) Undo(ctx context.Context, id string) error {
return nil
}
// laidOutLinks отбирает реально разложенные нами ссылки батча и маппит их в
// layout.Link. superseded-строки пропускаем: путь забрала другая загрузка (см.
// state-reconciliation, владение путём), файл по нему — её хардлинк, трогать
// нельзя. Общий для Undo (снимает с гардом) и Delete (снимает без гарда).
func laidOutLinks(rows []store.FileLink) []layout.Link {
links := make([]layout.Link, 0, len(rows))
for _, r := range rows {
if !isLaidOut(r.Status) {
continue
}
links = append(links, layout.Link{Src: r.SrcPath, Dst: r.DstPath, Kind: layout.Kind(r.Kind)})
}
return links
}
// Delete — пользовательское удаление загрузки: снять наши библиотечные ссылки
// (гард последней копии ВЫКЛЮЧЕН, в отличие от Undo) И снести раздачу с файлами
// из qBittorrent, переведя задачу в терминальный deleted. Осознанный выход за
// инвариант «источник неприкосновенен» — вызывается только после подтверждения
// в транспорте. Доступно из done/orphaned/target_missing; идемпотентно к
// отсутствующей стороне. Source-preflight НЕ делает (цель — снять источник,
// его отсутствие трактуем как уже снятую сторону).
func (w *Worker) Delete(ctx context.Context, id string) error {
w.mu.Lock()
defer w.mu.Unlock()
if w.layouter == nil {
return fmt.Errorf("delete: layouter not configured")
}
d, err := w.store.GetDownload(ctx, id)
if err != nil {
return fmt.Errorf("delete: %w", err)
}
switch d.State {
case store.StateDone, store.StateOrphaned, store.StateTargetMissing:
default:
return fmt.Errorf("delete: download %s is in state %s (expected done/orphaned/target_missing): %w", id, d.State, ErrConflict)
}
ctx = w.scoped(ctx, capFileLayout, id, d.PrimaryInfohash())
// (а) Снять цель — наши библиотечные ссылки последнего батча БЕЗ гарда
// последней копии. superseded пропускаем: путь забрала другая загрузка (см.
// state-reconciliation, владение путём), её хардлинк трогать нельзя. В
// target_missing / после ручного удаления живых ссылок нет — снятие
// идемпотентно.
batch, err := w.store.LatestBatchID(ctx, id)
if err != nil {
return fmt.Errorf("delete: %w", err)
}
removed := 0
if batch != "" {
rows, err := w.store.ListFileLinksByBatch(ctx, batch)
if err != nil {
return fmt.Errorf("delete: %w", err)
}
removed, err = w.layouter.Remove(ctx, laidOutLinks(rows))
if err != nil {
return fmt.Errorf("delete: %w", err)
}
if err := w.store.DeleteFileLinksByBatch(ctx, batch); err != nil {
return fmt.Errorf("delete: %w", err)
}
}
// (б) Снять источник — раздачу с файлами из qBittorrent. Идемпотентно:
// отсутствие раздачи (orphaned) не ошибка — qBit не найдёт хеш и ответит OK.
// Реальную ошибку API пробрасываем и в deleted НЕ уводим: не заявляем
// освобождённое место, которого не произошло (цель уже снята → сверка
// приведёт запись к target_missing; повторный delete идемпотентно дожмёт).
if hashes := d.HashList(); len(hashes) > 0 {
if err := w.qbt.Delete(ctx, hashes, true); err != nil {
return fmt.Errorf("delete: qbittorrent: %w", err)
}
}
// (в) Терминальный deleted с пользовательским маркером инициатора
// (отличает от reconcile-deleted, который кладёт "reconcile").
w.transition(ctx, *d, store.StateDeleted, "user_delete", "удалено пользователем")
logctx.From(ctx).Info("download deleted by user",
"from", d.State, "removed_links", removed, "code", "user_delete")
return nil
}
// requireReviewable проверяет, что задача в review/deferred. Вызывается под mu.
func (w *Worker) requireReviewable(ctx context.Context, id string, op string) (*store.Download, error) {
d, err := w.store.GetDownload(ctx, id)
+121 -1
View File
@@ -872,6 +872,7 @@ func TestDefer(t *testing.T) {
type applyFixture struct {
w *Worker
st *memStore
qb *fakeQbt
downloads string
movies string
series string
@@ -911,7 +912,7 @@ func newApplyFixture(t *testing.T, plan recognize.Plan) applyFixture {
qb := &fakeQbt{torrents: []qbt.Torrent{{Hash: ihTest, SavePath: downloads, Category: "jellybit", State: "uploading"}}}
w := testWorkerWith(st, qb, &fakeRecognizer{}, lay)
return applyFixture{w: w, st: st, downloads: downloads, movies: movies, series: series}
return applyFixture{w: w, st: st, qb: qb, downloads: downloads, movies: movies, series: series}
}
func TestApply_LinksAndDone(t *testing.T) {
@@ -1068,6 +1069,125 @@ func TestUndo_RevertsLinks(t *testing.T) {
}
}
func TestDelete_FromDone_RemovesBothSides(t *testing.T) {
f := newApplyFixture(t, seriesResult().Plan)
if err := f.w.Apply(context.Background(), "1"); err != nil {
t.Fatalf("Apply: %v", err)
}
dst := filepath.Join(f.series, "Show (2006)", "Season 02", "Show (2006) S02E01.mkv")
if err := f.w.Delete(context.Background(), "1"); err != nil {
t.Fatalf("Delete: %v", err)
}
d := f.st.downloads["1"]
if d.State != store.StateDeleted {
t.Errorf("state = %q, want deleted", d.State)
}
if d.ErrorCode.String != "user_delete" {
t.Errorf("error_code = %q, want user_delete", d.ErrorCode.String)
}
if _, err := os.Stat(dst); !os.IsNotExist(err) {
t.Errorf("library link must be removed: %v", err)
}
if len(f.st.links) != 0 {
t.Errorf("file_links must be deleted, got %d", len(f.st.links))
}
if len(f.qb.deleted) != 1 || len(f.qb.deleted[0]) == 0 || f.qb.deleted[0][0] != ihTest {
t.Errorf("qbt.Delete calls = %v, want [[%s]]", f.qb.deleted, ihTest)
}
}
func TestDelete_FromOrphaned_RemovesLastCopy(t *testing.T) {
f := newApplyFixture(t, seriesResult().Plan)
if err := f.w.Apply(context.Background(), "1"); err != nil {
t.Fatalf("Apply: %v", err)
}
dst := filepath.Join(f.series, "Show (2006)", "Season 02", "Show (2006) S02E01.mkv")
// Источник пропал → библиотечная ссылка стала последней копией (nlink==1):
// Undo бы отказал, Delete снимает осознанно.
for _, src := range []string{"Show/e1.mkv", "Show/e2.mkv"} {
_ = os.Remove(filepath.Join(f.downloads, src))
}
f.qb.torrents = nil // раздачи в qBittorrent больше нет
f.st.downloads["1"].State = store.StateOrphaned
if err := f.w.Delete(context.Background(), "1"); err != nil {
t.Fatalf("Delete: %v", err)
}
if f.st.downloads["1"].State != store.StateDeleted {
t.Errorf("state = %q, want deleted", f.st.downloads["1"].State)
}
if _, err := os.Stat(dst); !os.IsNotExist(err) {
t.Errorf("last-copy link must be removed despite guard: %v", err)
}
// Отсутствие раздачи не ошибка — вызов всё равно состоялся идемпотентно.
if len(f.qb.deleted) != 1 {
t.Errorf("qbt.Delete calls = %d, want 1 (idempotent)", len(f.qb.deleted))
}
}
func TestDelete_FromTargetMissing_Idempotent(t *testing.T) {
f := newApplyFixture(t, seriesResult().Plan)
if err := f.w.Apply(context.Background(), "1"); err != nil {
t.Fatalf("Apply: %v", err)
}
// Цель удалена вручную, источник на месте.
dst1 := filepath.Join(f.series, "Show (2006)", "Season 02", "Show (2006) S02E01.mkv")
dst2 := filepath.Join(f.series, "Show (2006)", "Season 02", "Show (2006) S02E02.mkv")
_ = os.Remove(dst1)
_ = os.Remove(dst2)
f.st.downloads["1"].State = store.StateTargetMissing
if err := f.w.Delete(context.Background(), "1"); err != nil {
t.Fatalf("Delete: %v", err)
}
if f.st.downloads["1"].State != store.StateDeleted {
t.Errorf("state = %q, want deleted", f.st.downloads["1"].State)
}
if len(f.qb.deleted) != 1 { // остаточную раздачу снесли
t.Errorf("qbt.Delete calls = %d, want 1", len(f.qb.deleted))
}
}
func TestDelete_WrongStateRejected(t *testing.T) {
f := newApplyFixture(t, seriesResult().Plan) // состояние review
err := f.w.Delete(context.Background(), "1")
if !errors.Is(err, ErrConflict) {
t.Fatalf("Delete from review = %v, want ErrConflict", err)
}
if f.st.downloads["1"].State != store.StateReview {
t.Errorf("state = %q, want review (unchanged)", f.st.downloads["1"].State)
}
if len(f.qb.deleted) != 0 {
t.Errorf("qbt.Delete must not be called, got %v", f.qb.deleted)
}
}
func TestDelete_QbtErrorKeepsState(t *testing.T) {
f := newApplyFixture(t, seriesResult().Plan)
if err := f.w.Apply(context.Background(), "1"); err != nil {
t.Fatalf("Apply: %v", err)
}
f.qb.deleteErr = errors.New("qbittorrent boom")
if err := f.w.Delete(context.Background(), "1"); err == nil {
t.Fatal("Delete must return qbittorrent error")
}
// Ложно в deleted не уходим (место не освобождено).
if f.st.downloads["1"].State == store.StateDeleted {
t.Errorf("state = deleted despite qbt error; want unchanged (done)")
}
// Повторный delete после устранения сбоя идемпотентно дожимает удаление:
// цель уже снята (file_links удалены), источник сносится, задача → deleted.
f.qb.deleteErr = nil
if err := f.w.Delete(context.Background(), "1"); err != nil {
t.Fatalf("repeat Delete: %v", err)
}
if f.st.downloads["1"].State != store.StateDeleted {
t.Errorf("state = %q after repeat, want deleted", f.st.downloads["1"].State)
}
}
func TestReviewData(t *testing.T) {
plan := seriesResult().Plan
f := newApplyFixture(t, plan)
+2
View File
@@ -90,6 +90,7 @@ type QBittorrent interface {
Torrents(ctx context.Context, category string) ([]qbt.Torrent, error)
Add(ctx context.Context, ar qbt.AddRequest) error
Files(ctx context.Context, hash string) ([]qbt.File, error)
Delete(ctx context.Context, hashes []string, deleteFiles bool) error
}
// Recognizer — распознаватель (recognize.Recognizer).
@@ -108,6 +109,7 @@ type Layouter interface {
BuildLinks(p layout.Plan) ([]layout.Link, error)
Apply(ctx context.Context, links []layout.Link) ([]layout.Result, error)
Undo(ctx context.Context, links []layout.Link) (int, error)
Remove(ctx context.Context, links []layout.Link) (int, error)
}
// NotifyEvent — повод позвать пользователя.
+10
View File
@@ -259,6 +259,8 @@ type fakeQbt struct {
added []qbt.AddRequest
addErr error
files []qbt.File
deleted [][]string // хеши каждого вызова Delete
deleteErr error
}
// Torrents имитирует /torrents/info: пустая категория — все торренты, иначе
@@ -290,6 +292,14 @@ func (f *fakeQbt) Files(_ context.Context, _ string) ([]qbt.File, error) {
return f.files, nil
}
func (f *fakeQbt) Delete(_ context.Context, hashes []string, _ bool) error {
if f.deleteErr != nil {
return f.deleteErr
}
f.deleted = append(f.deleted, hashes)
return nil
}
func newTestWorker(st *fakeStore, qb *fakeQbt) *Worker {
w := New(st, qb, nil, nil, Config{
Category: "jellybit",
@@ -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`.
@@ -0,0 +1,74 @@
## Why
«Единое окно» реализовано наполовину: jellybit распознаёт **ручное** удаление и
метит рассинхрон (`state-reconciliation``target_missing`/`orphaned`/`deleted`,
безопасный `Undo` с гардом последней копии, preflight). Не хватает второй
половины — **удалять загрузку из самого jellybit**, не идя руками в
qBittorrent/Jellyfin.
Основной сценарий одного окна — «досмотрел → освободить место»: убрать и раздачу,
и файлы в библиотеке разом. Из-за хардлинков иначе место не вернуть — файл в
`downloads/` (qBittorrent) и наша ссылка в библиотеке указывают на **один инод**,
диск освобождается только когда исчезает последняя ссылка. Значит удаление обязано
снять **обе** стороны. Это осознанный выход за инвариант «источник неприкосновенен»,
поэтому — с подтверждением и отдельным следом в логе/причине перехода.
`Undo` и `delete` — разные операции, и это надо зафиксировать:
- **Undo** — «перераспознать»: снимает только наши ссылки, раздачу в qBittorrent
сохраняет, гард последней копии **включён**. Уже готов.
- **delete** — «убрать окончательно, освободить место»: снимает наши ссылки И
сносит раздачу+файлы из qBittorrent, гард **выключен**, состояние терминальное
`deleted`.
## What Changes
- Ввести пользовательскую команду **«Удалить»** в `worker`, доступную из
`done`, `orphaned`, `target_missing` (подчистить остатки за собой из любого
состояния «уже разложено / рассинхрон»). Действие: снять наши библиотечные
хардлинки последнего батча (переиспользуем механику `Undo`, `superseded`
пропускаем) с **выключенным** гардом последней копии **+** удалить раздачу с
файлами из qBittorrent. Идемпотентно к отсутствующей стороне (в `orphaned` нет
источника, в `target_missing` нет цели).
- Переиспользовать существующее терминальное состояние `deleted`**без** нового
статуса; рёбра `done/orphaned/target_missing → deleted` уже есть в
`allowedTransitions`. Инициатора-пользователя отличать от сверки через
`error_code = "user_delete"` (сверка кладёт `"reconcile"`), человекочитаемую
причину — в `error_msg` и лог перехода.
- Добавить в `qbt` метод удаления раздачи (`POST /api/v2/torrents/delete`,
`deleteFiles=true`) и объявить его в интерфейсе `QBittorrent` воркера.
- Добавить в `layout` путь снятия ссылок **без** гарда последней копии (delete
сознательно снимает последнюю копию, в отличие от `Undo`).
- Обязательное **подтверждение** во всех транспортах: веб-UI — danger-секция с
кнопкой в самом низу страницы загрузки; Telegram — двухшаговый inline-confirm.
## Capabilities
### New Capabilities
<!-- нет новых capability -->
### Modified Capabilities
- `state-reconciliation`: добавляется пользовательская команда «Удалить»
(снять источник и цель, гард последней копии выключен) с переходом
`done`/`orphaned`/`target_missing` → терминальный `deleted`, отличимая от
reconcile-`deleted` инициатором, с обязательным подтверждением.
## Impact
- Код воркера: `internal/worker/review.go` (новый метод `Delete`; путь снятия
ссылок с выключенным гардом), `internal/worker/worker.go` (расширить интерфейс
`QBittorrent` методом удаления; проброс в конструктор при необходимости).
- Инфраструктура: `internal/qbt/qbt.go` (метод `Delete(hashes, deleteFiles)`
`/api/v2/torrents/delete`), `internal/layout/layout.go` (снятие ссылок без
`ErrLastCopy`).
- Транспорты: `internal/httpapi` (роут `POST /ui/downloads/{id}/delete` + confirm,
флаг `Deletable` по состоянию; опц. JSON-роут), `web/templates/partials`
(danger-секция внизу страницы загрузки), `internal/tgbot` (действие `delete` +
двухшаговый confirm-keyboard).
- Данные: **осознанное** снятие последней копии данных при удалении из
`orphaned` — только по явному подтверждению; инвариант «источник
неприкосновенен» обходится намеренно и логируется как пользовательское
удаление. Схема БД не меняется (переиспользуем `error_code`/`error_msg`).
- Тесты: `internal/worker/review_test.go` (delete из каждого из трёх состояний,
идемпотентность к отсутствующей стороне, отказ из прочих состояний),
`internal/qbt/qbt_test.go` (форма запроса delete), транспортные тесты confirm.
@@ -0,0 +1,92 @@
## ADDED Requirements
### Requirement: Полное удаление загрузки пользователем
Система SHALL предоставлять пользователю команду **«Удалить»** (delete),
доступную из состояний `done`, `orphaned` и `target_missing` во всех транспортах
(веб-UI и Telegram, опц. REST). Команда SHALL снимать **обе** стороны загрузки —
целевые библиотечные хардлинки И раздачу с файлами в qBittorrent — и переводить
задачу в терминальное `deleted`. Из прочих состояний команда доступна SHALL NOT.
Снятие цели SHALL идти по механике снятия ссылок последнего батча (как в `Undo`:
`superseded` пропускаются как забранные другой загрузкой), но **отдельным путём с
выключенным** гардом последней копии — не переиспользуя guarded-`Undo`: в отличие
от `Undo`, delete SHALL снимать целевую ссылку, даже если она — последняя копия
данных (`nlink <= 1`). Это осознанный выход за
инвариант «источник неприкосновенен», поэтому delete SHALL требовать явного
**подтверждения** пользователя перед выполнением и SHALL NOT срабатывать по
одиночному клику/тапу. Снятие цели SHALL затрагивать только собственные ссылки
загрузки строго под `paths.movies`/`series`; файлы источника под
`paths.downloads` система сама трогать SHALL NOT — их удаляет qBittorrent по
вызову API с `deleteFiles=true`.
В отличие от прочих команд, требующих источника, delete синхронный source-preflight
выполнять SHALL NOT и под требование «Принудительная проверка источника/цели перед
действием» не подпадает: цель delete — снять источник, поэтому его отсутствие
трактуется как уже снятая сторона, а не как повод привести состояние сверкой и
отказать. Удаление SHALL быть идемпотентным к отсутствующей стороне: в `orphaned`
(нет источника) отсутствие раздачи в qBittorrent ошибкой считаться SHALL NOT; в
`target_missing` (нет цели) пустой список живых ссылок обрабатывается как «нечего
снимать». Если qBittorrent вернул ошибку при удалении присутствующей раздачи,
система в `deleted` переходить SHALL NOT (не заявляем освобождение места, которого
не произошло), SHALL сообщить пользователю причину отказа (это не `ErrConflict`,
а ошибка внешнего сервиса — транслируется как таковая), и повторный delete
идемпотентно дожимает удаление, опираясь на оставшийся `done` либо приведённый
сверкой к реальности `target_missing` (кратковременное рассогласование до тика
сверки ожидаемо).
Инициатора перехода в `deleted` система SHALL отличать от фоновой сверки:
пользовательское удаление SHALL помечаться `error_code = "user_delete"` (сверка
кладёт `"reconcile"`), человекочитаемую причину — в `error_msg` и лог перехода.
Новый статус для этого система вводить SHALL NOT — переиспользуется существующее
терминальное `deleted` (сверка его не переоценивает, см. требование о `deleted`).
#### Scenario: Удаление из done снимает обе стороны и освобождает место
- **GIVEN** задача в `done`: раздача присутствует в qBittorrent, её библиотечные
хардлинки существуют
- **WHEN** пользователь подтверждает «Удалить»
- **THEN** библиотечные ссылки последнего батча снимаются
- **AND** раздача с файлами удаляется из qBittorrent (`deleteFiles=true`)
- **AND** задача переходит в `deleted` с `error_code = "user_delete"`
#### Scenario: Удаление из orphaned снимает последнюю копию осознанно
- **GIVEN** задача в `orphaned`: источник пропал, библиотечный хардлинк остался
единственной копией данных (`nlink <= 1`)
- **WHEN** пользователь подтверждает «Удалить»
- **THEN** библиотечная ссылка снимается несмотря на то, что она последняя копия
(гард последней копии выключен, в отличие от `Undo`)
- **AND** отсутствие раздачи в qBittorrent ошибкой не считается
- **AND** задача переходит в `deleted`
#### Scenario: Удаление из target_missing сносит остаточную раздачу
- **GIVEN** задача в `target_missing`: источник присутствует, цель уже удалена
вручную
- **WHEN** пользователь подтверждает «Удалить»
- **THEN** снятие цели идемпотентно (живых ссылок нет)
- **AND** раздача с файлами удаляется из qBittorrent
- **AND** задача переходит в `deleted`
#### Scenario: Удаление требует подтверждения
- **GIVEN** задача в `done`
- **WHEN** пользователь инициирует «Удалить», но не подтверждает действие
- **THEN** ни ссылки, ни раздача не удаляются, состояние остаётся `done`
#### Scenario: Удаление недоступно из прочих состояний
- **GIVEN** задача в `review` (или ином состоянии вне `done`/`orphaned`/
`target_missing`)
- **WHEN** приходит команда «Удалить»
- **THEN** команда отклоняется с конфликтом, состояние не меняется
#### Scenario: Ошибка qBittorrent не метит deleted ложно
- **GIVEN** задача в `done`, раздача присутствует, но qBittorrent вернул ошибку
на удаление
- **WHEN** пользователь подтверждает «Удалить»
- **THEN** задача в `deleted` не переходит (место не освобождено)
- **AND** пользователю сообщается причина отказа (ошибка qBittorrent, не тихий успех)
- **AND** повторный delete идемпотентно дожимает удаление
@@ -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` и строку из индекса беклога.
@@ -443,3 +443,94 @@ SHALL задевать раскладку в полёте.
- **WHEN** выполняется тик поллинга
- **THEN** восстановление `linking` их состояние не меняет
### Requirement: Полное удаление загрузки пользователем
Система SHALL предоставлять пользователю команду **«Удалить»** (delete),
доступную из состояний `done`, `orphaned` и `target_missing` во всех транспортах
(веб-UI и Telegram, опц. REST). Команда SHALL снимать **обе** стороны загрузки —
целевые библиотечные хардлинки И раздачу с файлами в qBittorrent — и переводить
задачу в терминальное `deleted`. Из прочих состояний команда доступна SHALL NOT.
Снятие цели SHALL идти по механике снятия ссылок последнего батча (как в `Undo`:
`superseded` пропускаются как забранные другой загрузкой), но **отдельным путём с
выключенным** гардом последней копии — не переиспользуя guarded-`Undo`: в отличие
от `Undo`, delete SHALL снимать целевую ссылку, даже если она — последняя копия
данных (`nlink <= 1`). Это осознанный выход за
инвариант «источник неприкосновенен», поэтому delete SHALL требовать явного
**подтверждения** пользователя перед выполнением и SHALL NOT срабатывать по
одиночному клику/тапу. Снятие цели SHALL затрагивать только собственные ссылки
загрузки строго под `paths.movies`/`series`; файлы источника под
`paths.downloads` система сама трогать SHALL NOT — их удаляет qBittorrent по
вызову API с `deleteFiles=true`.
В отличие от прочих команд, требующих источника, delete синхронный source-preflight
выполнять SHALL NOT и под требование «Принудительная проверка источника/цели перед
действием» не подпадает: цель delete — снять источник, поэтому его отсутствие
трактуется как уже снятая сторона, а не как повод привести состояние сверкой и
отказать. Удаление SHALL быть идемпотентным к отсутствующей стороне: в `orphaned`
(нет источника) отсутствие раздачи в qBittorrent ошибкой считаться SHALL NOT; в
`target_missing` (нет цели) пустой список живых ссылок обрабатывается как «нечего
снимать». Если qBittorrent вернул ошибку при удалении присутствующей раздачи,
система в `deleted` переходить SHALL NOT (не заявляем освобождение места, которого
не произошло), SHALL сообщить пользователю причину отказа (это не `ErrConflict`,
а ошибка внешнего сервиса — транслируется как таковая), и повторный delete
идемпотентно дожимает удаление, опираясь на оставшийся `done` либо приведённый
сверкой к реальности `target_missing` (кратковременное рассогласование до тика
сверки ожидаемо).
Инициатора перехода в `deleted` система SHALL отличать от фоновой сверки:
пользовательское удаление SHALL помечаться `error_code = "user_delete"` (сверка
кладёт `"reconcile"`), человекочитаемую причину — в `error_msg` и лог перехода.
Новый статус для этого система вводить SHALL NOT — переиспользуется существующее
терминальное `deleted` (сверка его не переоценивает, см. требование о `deleted`).
#### Scenario: Удаление из done снимает обе стороны и освобождает место
- **GIVEN** задача в `done`: раздача присутствует в qBittorrent, её библиотечные
хардлинки существуют
- **WHEN** пользователь подтверждает «Удалить»
- **THEN** библиотечные ссылки последнего батча снимаются
- **AND** раздача с файлами удаляется из qBittorrent (`deleteFiles=true`)
- **AND** задача переходит в `deleted` с `error_code = "user_delete"`
#### Scenario: Удаление из orphaned снимает последнюю копию осознанно
- **GIVEN** задача в `orphaned`: источник пропал, библиотечный хардлинк остался
единственной копией данных (`nlink <= 1`)
- **WHEN** пользователь подтверждает «Удалить»
- **THEN** библиотечная ссылка снимается несмотря на то, что она последняя копия
(гард последней копии выключен, в отличие от `Undo`)
- **AND** отсутствие раздачи в qBittorrent ошибкой не считается
- **AND** задача переходит в `deleted`
#### Scenario: Удаление из target_missing сносит остаточную раздачу
- **GIVEN** задача в `target_missing`: источник присутствует, цель уже удалена
вручную
- **WHEN** пользователь подтверждает «Удалить»
- **THEN** снятие цели идемпотентно (живых ссылок нет)
- **AND** раздача с файлами удаляется из qBittorrent
- **AND** задача переходит в `deleted`
#### Scenario: Удаление требует подтверждения
- **GIVEN** задача в `done`
- **WHEN** пользователь инициирует «Удалить», но не подтверждает действие
- **THEN** ни ссылки, ни раздача не удаляются, состояние остаётся `done`
#### Scenario: Удаление недоступно из прочих состояний
- **GIVEN** задача в `review` (или ином состоянии вне `done`/`orphaned`/
`target_missing`)
- **WHEN** приходит команда «Удалить»
- **THEN** команда отклоняется с конфликтом, состояние не меняется
#### Scenario: Ошибка qBittorrent не метит deleted ложно
- **GIVEN** задача в `done`, раздача присутствует, но qBittorrent вернул ошибку
на удаление
- **WHEN** пользователь подтверждает «Удалить»
- **THEN** задача в `deleted` не переходит (место не освобождено)
- **AND** пользователю сообщается причина отказа (ошибка qBittorrent, не тихий успех)
- **AND** повторный delete идемпотентно дожимает удаление
+21 -1
View File
@@ -77,7 +77,7 @@
{{end}}
<!-- Информация о торренте -->
<div class="section" style="margin-bottom:0">
<div class="section">
<div class="section-head"><h2>Информация о торренте</h2><span class="hint">сырой источник и infohash</span></div>
<dl class="kv">
<dt>Тип источника</dt><dd>{{.SourceType}}</dd>
@@ -90,5 +90,25 @@
</div>
{{end}}
</div>
<!-- Опасная зона: полное удаление загрузки (снять хардлинки + снести раздачу с
файлами из qBittorrent). Секция скрыта в details (раскрыть сознательно). С JS
hx-confirm показывает диалог подтверждения; без JS гейт — только раскрытие
details и явный submit (диалога нет). -->
{{if .Deletable}}
<details class="section danger-zone" style="margin-bottom:0;border-color:color-mix(in srgb,var(--st-err) 35%,transparent)">
<summary style="color:var(--st-err);cursor:pointer;font-weight:600">Опасная зона</summary>
<p class="muted" style="margin:var(--sp-3) 0">
Полное удаление снимет библиотечные хардлинки <strong>и</strong> снесёт раздачу
с файлами из qBittorrent — освободит место. Действие необратимо.
</p>
<form method="post" action="/ui/downloads/{{.ID}}/delete"
hx-post="/ui/downloads/{{.ID}}/delete" hx-target="#download-main" hx-swap="outerHTML"
hx-confirm="Удалить загрузку? Раздача и файлы будут снесены, место освобождено. Действие необратимо.">
<input type="hidden" name="surface" value="download">
<button class="btn btn-danger" type="submit">Удалить загрузку</button>
</form>
</details>
{{end}}
</main>
{{end}}