Единое окно: полное пользовательское удаление загрузки (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:
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
// как в порядке и карточках списка); неразбираемое время просто опускаем.
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
@@ -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