Единое окно: полное пользовательское удаление загрузки (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
+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 — повод позвать пользователя.
+14 -4
View File
@@ -255,10 +255,12 @@ func (f *fakeStore) GetCandidate(_ context.Context, _ string) (*store.MetadataCa
func (f *fakeStore) SetCandidateChosen(_ context.Context, _, _ string) error { return nil }
type fakeQbt struct {
torrents []qbt.Torrent
added []qbt.AddRequest
addErr error
files []qbt.File
torrents []qbt.Torrent
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",