Единое окно: полное пользовательское удаление загрузки (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
+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 — повод позвать пользователя.
+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",