Единое окно: полное пользовательское удаление загрузки (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:
+84
-11
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 — повод позвать пользователя.
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user