diff --git a/docs/backlog/README.md b/docs/backlog/README.md index c283f7e..4b47359 100644 --- a/docs/backlog/README.md +++ b/docs/backlog/README.md @@ -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) — Распознавание — ядро продукта, но смена модели или правка промпта сейчас вслепую… diff --git a/docs/backlog/udalenie-edinoe-okno.md b/docs/backlog/udalenie-edinoe-okno.md deleted file mode 100644 index b78ea3b..0000000 --- a/docs/backlog/udalenie-edinoe-okno.md +++ /dev/null @@ -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. diff --git a/docs/specs/workflow.md b/docs/specs/workflow.md index c06beb7..189d6b2 100644 --- a/docs/specs/workflow.md +++ b/docs/specs/workflow.md @@ -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`) состояния не задевает. Реальность «лечится» сама: при diff --git a/internal/httpapi/action_swap_test.go b/internal/httpapi/action_swap_test.go index 4356f53..6e23671 100644 --- a/internal/httpapi/action_swap_test.go +++ b/internal/httpapi/action_swap_test.go @@ -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 { diff --git a/internal/httpapi/download.go b/internal/httpapi/download.go index 0131c27..e644c9a 100644 --- a/internal/httpapi/download.go +++ b/internal/httpapi/download.go @@ -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, // как в порядке и карточках списка); неразбираемое время просто опускаем. diff --git a/internal/httpapi/httpapi.go b/internal/httpapi/httpapi.go index bda875f..e550ae9 100644 --- a/internal/httpapi/httpapi.go +++ b/internal/httpapi/httpapi.go @@ -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) { diff --git a/internal/httpapi/httpapi_test.go b/internal/httpapi/httpapi_test.go index 5421300..c66969e 100644 --- a/internal/httpapi/httpapi_test.go +++ b/internal/httpapi/httpapi_test.go @@ -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 diff --git a/internal/httpapi/render_test.go b/internal/httpapi/render_test.go index 63c34cf..e142f77 100644 --- a/internal/httpapi/render_test.go +++ b/internal/httpapi/render_test.go @@ -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 } diff --git a/internal/httpapi/review.go b/internal/httpapi/review.go index 4afbab7..81288af 100644 --- a/internal/httpapi/review.go +++ b/internal/httpapi/review.go @@ -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) { diff --git a/internal/layout/layout.go b/internal/layout/layout.go index f203d3e..0daf977 100644 --- a/internal/layout/layout.go +++ b/internal/layout/layout.go @@ -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) { diff --git a/internal/qbt/qbt.go b/internal/qbt/qbt.go index a55aa40..ade0c4f 100644 --- a/internal/qbt/qbt.go +++ b/internal/qbt/qbt.go @@ -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) diff --git a/internal/qbt/qbt_test.go b/internal/qbt/qbt_test.go index 447f1f9..4bdb8ad 100644 --- a/internal/qbt/qbt_test.go +++ b/internal/qbt/qbt_test.go @@ -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) diff --git a/internal/tgbot/bot.go b/internal/tgbot/bot.go index 41d3de1..6c72c68 100644 --- a/internal/tgbot/bot.go +++ b/internal/tgbot/bot.go @@ -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 diff --git a/internal/tgbot/bot_test.go b/internal/tgbot/bot_test.go index 83c557c..d84eede 100644 --- a/internal/tgbot/bot_test.go +++ b/internal/tgbot/bot_test.go @@ -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" diff --git a/internal/tgbot/render.go b/internal/tgbot/render.go index 8dc21c1..11ab37f 100644 --- a/internal/tgbot/render.go +++ b/internal/tgbot/render.go @@ -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 == "" { diff --git a/internal/worker/review.go b/internal/worker/review.go index 3cf50b6..e701c83 100644 --- a/internal/worker/review.go +++ b/internal/worker/review.go @@ -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) diff --git a/internal/worker/review_test.go b/internal/worker/review_test.go index 9cbdb35..0bfc18c 100644 --- a/internal/worker/review_test.go +++ b/internal/worker/review_test.go @@ -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) diff --git a/internal/worker/worker.go b/internal/worker/worker.go index 2888875..168a584 100644 --- a/internal/worker/worker.go +++ b/internal/worker/worker.go @@ -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 — повод позвать пользователя. diff --git a/internal/worker/worker_test.go b/internal/worker/worker_test.go index b0211d4..efc09d3 100644 --- a/internal/worker/worker_test.go +++ b/internal/worker/worker_test.go @@ -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", diff --git a/openspec/changes/archive/2026-07-10-download-delete/design.md b/openspec/changes/archive/2026-07-10-download-delete/design.md new file mode 100644 index 0000000..31f6a60 --- /dev/null +++ b/openspec/changes/archive/2026-07-10-download-delete/design.md @@ -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=
+ Полное удаление снимет библиотечные хардлинки и снесёт раздачу + с файлами из qBittorrent — освободит место. Действие необратимо. +
+ +