Единое окно: полное пользовательское удаление загрузки (delete)
Вторая половина «единого окна»: команда «Удалить» снимает наши библиотечные хардлинки (гард последней копии осознанно выключен, в отличие от Undo) и сносит раздачу с файлами из qBittorrent (deleteFiles=true) → терминальный deleted. Доступна из done/orphaned/target_missing, идемпотентна к отсутствующей стороне; инициатор различается через error_code=user_delete. Подтверждение обязательно: веб — danger-секция внизу страницы (hx-confirm + details), Telegram — двухшаговый inline-confirm. qbt.Delete + layout.Remove (unlink без ErrLastCopy, только свои ссылки под movies/series). Граф переходов не менялся — рёбра уже были. OpenSpec: state-reconciliation +1 требование; синк workflow.md; беклог закрыт. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
+84
-11
@@ -537,17 +537,7 @@ func (w *Worker) Undo(ctx context.Context, id string) error {
|
||||
if err != nil {
|
||||
return fmt.Errorf("undo: %w", err)
|
||||
}
|
||||
// Снимаем только реально разложенные нами ссылки. superseded-строки —
|
||||
// путь забрала другая загрузка (см. state-reconciliation, владение
|
||||
// путём); файл по нему теперь её хардлинк, трогать его нельзя.
|
||||
links := make([]layout.Link, 0, len(rows))
|
||||
for _, r := range rows {
|
||||
if !isLaidOut(r.Status) {
|
||||
continue
|
||||
}
|
||||
links = append(links, layout.Link{Src: r.SrcPath, Dst: r.DstPath, Kind: layout.Kind(r.Kind)})
|
||||
}
|
||||
n, err := w.layouter.Undo(ctx, links)
|
||||
n, err := w.layouter.Undo(ctx, laidOutLinks(rows))
|
||||
if err != nil {
|
||||
return fmt.Errorf("undo: %w", err)
|
||||
}
|
||||
@@ -559,6 +549,89 @@ func (w *Worker) Undo(ctx context.Context, id string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// laidOutLinks отбирает реально разложенные нами ссылки батча и маппит их в
|
||||
// layout.Link. superseded-строки пропускаем: путь забрала другая загрузка (см.
|
||||
// state-reconciliation, владение путём), файл по нему — её хардлинк, трогать
|
||||
// нельзя. Общий для Undo (снимает с гардом) и Delete (снимает без гарда).
|
||||
func laidOutLinks(rows []store.FileLink) []layout.Link {
|
||||
links := make([]layout.Link, 0, len(rows))
|
||||
for _, r := range rows {
|
||||
if !isLaidOut(r.Status) {
|
||||
continue
|
||||
}
|
||||
links = append(links, layout.Link{Src: r.SrcPath, Dst: r.DstPath, Kind: layout.Kind(r.Kind)})
|
||||
}
|
||||
return links
|
||||
}
|
||||
|
||||
// Delete — пользовательское удаление загрузки: снять наши библиотечные ссылки
|
||||
// (гард последней копии ВЫКЛЮЧЕН, в отличие от Undo) И снести раздачу с файлами
|
||||
// из qBittorrent, переведя задачу в терминальный deleted. Осознанный выход за
|
||||
// инвариант «источник неприкосновенен» — вызывается только после подтверждения
|
||||
// в транспорте. Доступно из done/orphaned/target_missing; идемпотентно к
|
||||
// отсутствующей стороне. Source-preflight НЕ делает (цель — снять источник,
|
||||
// его отсутствие трактуем как уже снятую сторону).
|
||||
func (w *Worker) Delete(ctx context.Context, id string) error {
|
||||
w.mu.Lock()
|
||||
defer w.mu.Unlock()
|
||||
if w.layouter == nil {
|
||||
return fmt.Errorf("delete: layouter not configured")
|
||||
}
|
||||
|
||||
d, err := w.store.GetDownload(ctx, id)
|
||||
if err != nil {
|
||||
return fmt.Errorf("delete: %w", err)
|
||||
}
|
||||
switch d.State {
|
||||
case store.StateDone, store.StateOrphaned, store.StateTargetMissing:
|
||||
default:
|
||||
return fmt.Errorf("delete: download %s is in state %s (expected done/orphaned/target_missing): %w", id, d.State, ErrConflict)
|
||||
}
|
||||
ctx = w.scoped(ctx, capFileLayout, id, d.PrimaryInfohash())
|
||||
|
||||
// (а) Снять цель — наши библиотечные ссылки последнего батча БЕЗ гарда
|
||||
// последней копии. superseded пропускаем: путь забрала другая загрузка (см.
|
||||
// state-reconciliation, владение путём), её хардлинк трогать нельзя. В
|
||||
// target_missing / после ручного удаления живых ссылок нет — снятие
|
||||
// идемпотентно.
|
||||
batch, err := w.store.LatestBatchID(ctx, id)
|
||||
if err != nil {
|
||||
return fmt.Errorf("delete: %w", err)
|
||||
}
|
||||
removed := 0
|
||||
if batch != "" {
|
||||
rows, err := w.store.ListFileLinksByBatch(ctx, batch)
|
||||
if err != nil {
|
||||
return fmt.Errorf("delete: %w", err)
|
||||
}
|
||||
removed, err = w.layouter.Remove(ctx, laidOutLinks(rows))
|
||||
if err != nil {
|
||||
return fmt.Errorf("delete: %w", err)
|
||||
}
|
||||
if err := w.store.DeleteFileLinksByBatch(ctx, batch); err != nil {
|
||||
return fmt.Errorf("delete: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// (б) Снять источник — раздачу с файлами из qBittorrent. Идемпотентно:
|
||||
// отсутствие раздачи (orphaned) не ошибка — qBit не найдёт хеш и ответит OK.
|
||||
// Реальную ошибку API пробрасываем и в deleted НЕ уводим: не заявляем
|
||||
// освобождённое место, которого не произошло (цель уже снята → сверка
|
||||
// приведёт запись к target_missing; повторный delete идемпотентно дожмёт).
|
||||
if hashes := d.HashList(); len(hashes) > 0 {
|
||||
if err := w.qbt.Delete(ctx, hashes, true); err != nil {
|
||||
return fmt.Errorf("delete: qbittorrent: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// (в) Терминальный deleted с пользовательским маркером инициатора
|
||||
// (отличает от reconcile-deleted, который кладёт "reconcile").
|
||||
w.transition(ctx, *d, store.StateDeleted, "user_delete", "удалено пользователем")
|
||||
logctx.From(ctx).Info("download deleted by user",
|
||||
"from", d.State, "removed_links", removed, "code", "user_delete")
|
||||
return nil
|
||||
}
|
||||
|
||||
// requireReviewable проверяет, что задача в review/deferred. Вызывается под mu.
|
||||
func (w *Worker) requireReviewable(ctx context.Context, id string, op string) (*store.Download, error) {
|
||||
d, err := w.store.GetDownload(ctx, id)
|
||||
|
||||
Reference in New Issue
Block a user