Единое окно: полное пользовательское удаление загрузки (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:
@@ -18,7 +18,6 @@ Tududi (проект `jellybit`) больше **не** держит беклог
|
|||||||
|
|
||||||
- [Проблема второго сезона (сходимость папки сериала)](vtoroy-sezon-shodimost-papki.md) — Второй/третий сезон должен ложиться в ТУ ЖЕ папку сериала, а не заводить рядом почти…
|
- [Проблема второго сезона (сходимость папки сериала)](vtoroy-sezon-shodimost-papki.md) — Второй/третий сезон должен ложиться в ТУ ЖЕ папку сериала, а не заводить рядом почти…
|
||||||
- [Раздачи с докачиванием (merge при повторном добавлении)](merge-dokachivanie.md) — Свежий сериал раздают по мере выхода: торрент с 5 из 10 эпизодов позже перезаливают…
|
- [Раздачи с докачиванием (merge при повторном добавлении)](merge-dokachivanie.md) — Свежий сериал раздают по мере выхода: торрент с 5 из 10 эпизодов позже перезаливают…
|
||||||
- [Полное удаление загрузки из jellybit («единое окно», path 2)](udalenie-edinoe-okno.md) — Действие «Удалить»: снять хардлинки + снести раздачу+файлы из qBittorrent → освободить место (отдельно от undo)
|
|
||||||
- [Ретеншн и очистка БД](retention-ochistka-bd.md) — Терминальные задачи (done/cancelled/failed/reverted), их попытки recognition с сырыми…
|
- [Ретеншн и очистка БД](retention-ochistka-bd.md) — Терминальные задачи (done/cancelled/failed/reverted), их попытки recognition с сырыми…
|
||||||
- [Eval-харнес распознавания (корпус кейсов + метрика точности)](eval-harness-raspoznavaniya.md) — Распознавание — ядро продукта, но смена модели или правка промпта сейчас вслепую…
|
- [Eval-харнес распознавания (корпус кейсов + метрика точности)](eval-harness-raspoznavaniya.md) — Распознавание — ядро продукта, но смена модели или правка промпта сейчас вслепую…
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
|
||||||
+17
-3
@@ -51,10 +51,11 @@ stateDiagram-v2
|
|||||||
|
|
||||||
done --> target_missing: сверка — цель удалена
|
done --> target_missing: сверка — цель удалена
|
||||||
done --> orphaned: сверка — источник пропал
|
done --> orphaned: сверка — источник пропал
|
||||||
|
done --> deleted: Удалить (delete)
|
||||||
target_missing --> recognizing: Привязать заново
|
target_missing --> recognizing: Привязать заново
|
||||||
target_missing --> orphaned: источник тоже пропал
|
target_missing --> orphaned: источник тоже пропал
|
||||||
target_missing --> deleted: источник тоже пропал
|
target_missing --> deleted: Удалить / источник тоже пропал (сверка)
|
||||||
orphaned --> deleted: цель тоже удалена
|
orphaned --> deleted: Удалить / цель тоже удалена (сверка)
|
||||||
target_missing --> done: healing (цель вернулась)
|
target_missing --> done: healing (цель вернулась)
|
||||||
orphaned --> done: healing (источник вернулся)
|
orphaned --> done: healing (источник вернулся)
|
||||||
|
|
||||||
@@ -96,7 +97,8 @@ stateDiagram-v2
|
|||||||
[architecture.md](architecture.md) → «Раскладка файлов».
|
[architecture.md](architecture.md) → «Раскладка файлов».
|
||||||
- **done** — при входе неблокирующе дёргаем пересканирование Jellyfin
|
- **done** — при входе неблокирующе дёргаем пересканирование Jellyfin
|
||||||
(опц., см. [architecture.md](architecture.md) → «Пересканирование
|
(опц., см. [architecture.md](architecture.md) → «Пересканирование
|
||||||
Jellyfin»); доступен **Undo** → `reverted` (убрать созданные ссылки).
|
Jellyfin»); доступен **Undo** → `reverted` (убрать созданные ссылки) и
|
||||||
|
**Удалить** → `deleted` (полное удаление, см. ниже).
|
||||||
- **stuck / failed / cancelled** — не качается дольше таймаута; ошибка
|
- **stuck / failed / cancelled** — не качается дольше таймаута; ошибка
|
||||||
(ретраибельна); «Отклонить».
|
(ретраибельна); «Отклонить».
|
||||||
- **reverted / cancelled → recognizing** — «Привязать заново»: после
|
- **reverted / cancelled → recognizing** — «Привязать заново»: после
|
||||||
@@ -120,6 +122,18 @@ stateDiagram-v2
|
|||||||
- **deleted** — нет ни источника, ни цели; **терминально**: сверка его
|
- **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` — терминальный
|
Сверка трогает только `done`/`target_missing`/`orphaned` — терминальный
|
||||||
`deleted`, активные и пользовательски-терминальные (`reverted`/`cancelled`/
|
`deleted`, активные и пользовательски-терминальные (`reverted`/`cancelled`/
|
||||||
`failed`/`stuck`) состояния не задевает. Реальность «лечится» сама: при
|
`failed`/`stuck`) состояния не задевает. Реальность «лечится» сама: при
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ func (s stubCommander) Retry(context.Context, string) error { return s.retryErr
|
|||||||
type actionReviewer struct {
|
type actionReviewer struct {
|
||||||
stubReviewer
|
stubReviewer
|
||||||
undoErr error
|
undoErr error
|
||||||
|
deleteErr error
|
||||||
relinkErr error
|
relinkErr error
|
||||||
rerecognizeErr error
|
rerecognizeErr error
|
||||||
refineErr error
|
refineErr error
|
||||||
@@ -32,6 +33,7 @@ type actionReviewer struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (a actionReviewer) Undo(context.Context, string) error { return a.undoErr }
|
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) Relink(context.Context, string) error { return a.relinkErr }
|
||||||
func (a actionReviewer) Rerecognize(context.Context, string) error { return a.rerecognizeErr }
|
func (a actionReviewer) Rerecognize(context.Context, string) error { return a.rerecognizeErr }
|
||||||
func (a actionReviewer) Refine(_ context.Context, _ string, hint string) error {
|
func (a actionReviewer) Refine(_ context.Context, _ string, hint string) error {
|
||||||
|
|||||||
@@ -53,6 +53,7 @@ type downloadDetailView struct {
|
|||||||
Undoable bool
|
Undoable bool
|
||||||
Relinkable bool
|
Relinkable bool
|
||||||
Retriable bool
|
Retriable bool
|
||||||
|
Deletable bool // полное удаление доступно (done/orphaned/target_missing)
|
||||||
}
|
}
|
||||||
|
|
||||||
// detailTitle — заголовок страницы просмотра: имя раздачи (display_name) →
|
// 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 ||
|
Relinkable: d.State == store.StateReverted || d.State == store.StateCancelled ||
|
||||||
d.State == store.StateTargetMissing,
|
d.State == store.StateTargetMissing,
|
||||||
Retriable: d.State == store.StateFailed || d.State == store.StateStuck,
|
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,
|
// Дата добавления рядом с шапкой (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}/defer", s.handleDefer)
|
||||||
r.Post("/ui/downloads/{id}/undo", s.handleUndo)
|
r.Post("/ui/downloads/{id}/undo", s.handleUndo)
|
||||||
r.Post("/ui/downloads/{id}/relink", s.handleRelink)
|
r.Post("/ui/downloads/{id}/relink", s.handleRelink)
|
||||||
|
r.Post("/ui/downloads/{id}/delete", s.handleDelete)
|
||||||
|
|
||||||
// REST API.
|
// REST API.
|
||||||
r.Route("/api", func(r chi.Router) {
|
r.Route("/api", func(r chi.Router) {
|
||||||
|
|||||||
@@ -454,6 +454,7 @@ type fakeReviewer struct {
|
|||||||
applied []string
|
applied []string
|
||||||
deferred []string
|
deferred []string
|
||||||
undone []string
|
undone []string
|
||||||
|
deleted []string
|
||||||
relinked []string
|
relinked []string
|
||||||
rerecognized []string
|
rerecognized []string
|
||||||
cleared []string
|
cleared []string
|
||||||
@@ -491,6 +492,10 @@ func (f *fakeReviewer) Undo(_ context.Context, id string) error {
|
|||||||
f.undone = append(f.undone, id)
|
f.undone = append(f.undone, id)
|
||||||
return nil
|
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 {
|
func (f *fakeReviewer) Relink(_ context.Context, id string) error {
|
||||||
f.relinked = append(f.relinked, id)
|
f.relinked = append(f.relinked, id)
|
||||||
return nil
|
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) IgnoreFile(context.Context, string, string) error { return nil }
|
||||||
func (stubReviewer) Defer(context.Context, string) error { return nil }
|
func (stubReviewer) Defer(context.Context, string) error { return nil }
|
||||||
func (stubReviewer) Undo(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) Relink(context.Context, string) error { return nil }
|
||||||
func (stubReviewer) Rerecognize(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 }
|
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
|
IgnoreFile(ctx context.Context, id string, src string) error
|
||||||
Defer(ctx context.Context, id string) error
|
Defer(ctx context.Context, id string) error
|
||||||
Undo(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
|
Relink(ctx context.Context, id string) error
|
||||||
Rerecognize(ctx context.Context, id string) error
|
Rerecognize(ctx context.Context, id string) error
|
||||||
ChooseCandidate(ctx context.Context, id, candidateID 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))
|
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 повторно привязывает откатанную задачу: перезапускает
|
// handleRelink повторно привязывает откатанную задачу: перезапускает
|
||||||
// распознавание, задача пройдёт recognizing → review для подтверждения.
|
// распознавание, задача пройдёт recognizing → review для подтверждения.
|
||||||
func (s *server) handleRelink(w http.ResponseWriter, r *http.Request) {
|
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
|
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, либо ошибку,
|
// undoRoot возвращает корень библиотеки, под которым лежит dst, либо ошибку,
|
||||||
// если путь не под movies/series (откат трогает только библиотеку).
|
// если путь не под movies/series (откат трогает только библиотеку).
|
||||||
func undoRoot(l *Layouter, dst string) (string, error) {
|
func undoRoot(l *Layouter, dst string) (string, error) {
|
||||||
|
|||||||
@@ -256,6 +256,56 @@ func (c *Client) Add(ctx context.Context, ar AddRequest) error {
|
|||||||
return nil
|
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 возвращает задачи указанной категории (пустая — все).
|
// Torrents возвращает задачи указанной категории (пустая — все).
|
||||||
func (c *Client) Torrents(ctx context.Context, category string) ([]Torrent, error) {
|
func (c *Client) Torrents(ctx context.Context, category string) ([]Torrent, error) {
|
||||||
log := logctx.FromOr(ctx, c.log)
|
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) {
|
func TestAddPerformsLazyLogin(t *testing.T) {
|
||||||
srv := fakeQBittorrent(t, "[]")
|
srv := fakeQBittorrent(t, "[]")
|
||||||
c := newClient(t, srv.URL)
|
c := newClient(t, srv.URL)
|
||||||
|
|||||||
@@ -45,6 +45,7 @@ type Reviewer interface {
|
|||||||
Defer(ctx context.Context, id string) error
|
Defer(ctx context.Context, id string) error
|
||||||
Cancel(ctx context.Context, id string) error
|
Cancel(ctx context.Context, id string) error
|
||||||
Retry(ctx context.Context, id string) error
|
Retry(ctx context.Context, id string) error
|
||||||
|
Delete(ctx context.Context, id string) error
|
||||||
}
|
}
|
||||||
|
|
||||||
// Config — параметры бота.
|
// Config — параметры бота.
|
||||||
@@ -305,6 +306,20 @@ func (b *Bot) handleCallback(ctx context.Context, cq *tgbotapi.CallbackQuery) {
|
|||||||
case "retry":
|
case "retry":
|
||||||
err = b.reviewer.Retry(ctx, id)
|
err = b.reviewer.Retry(ctx, id)
|
||||||
note = "Повторяю…"
|
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":
|
case "type":
|
||||||
err = b.reviewer.SetType(ctx, id, val)
|
err = b.reviewer.SetType(ctx, id, val)
|
||||||
note = "Меняю тип…"
|
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) {
|
func (b *Bot) setPending(chatID int64, id string) {
|
||||||
b.mu.Lock()
|
b.mu.Lock()
|
||||||
b.pending[chatID] = id
|
b.pending[chatID] = id
|
||||||
|
|||||||
@@ -69,6 +69,7 @@ type fakeReviewer struct {
|
|||||||
deferred []string
|
deferred []string
|
||||||
canceled []string
|
canceled []string
|
||||||
retried []string
|
retried []string
|
||||||
|
deleted []string
|
||||||
}
|
}
|
||||||
|
|
||||||
func (f *fakeReviewer) ReviewData(context.Context, string) (*worker.ReviewData, error) {
|
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)
|
f.retried = append(f.retried, id)
|
||||||
return nil
|
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).
|
// tid — валидный lowercase-ULID (callback-data валидируется как ULID).
|
||||||
const tid = "01arz3ndektsv4rrffq69g5fav"
|
const tid = "01arz3ndektsv4rrffq69g5fav"
|
||||||
|
|||||||
@@ -24,15 +24,19 @@ func (b *Bot) renderCard(rd *worker.ReviewData) (string, *tgbotapi.InlineKeyboar
|
|||||||
case store.StateLinking:
|
case store.StateLinking:
|
||||||
return "⏳ Раскладываю #" + id + "…", nil
|
return "⏳ Раскладываю #" + id + "…", nil
|
||||||
case store.StateDone:
|
case store.StateDone:
|
||||||
return b.renderDone(rd), b.webOnly(id)
|
return b.renderDone(rd), b.deletableKeyboard(id)
|
||||||
default:
|
default:
|
||||||
text := fmt.Sprintf("Задача #%s — %s.", id, state)
|
text := fmt.Sprintf("Задача #%s — %s.", id, state)
|
||||||
if msg := rd.Download.ErrorMsg.String; msg != "" {
|
if msg := rd.Download.ErrorMsg.String; msg != "" {
|
||||||
text += "\n" + msg
|
text += "\n" + msg
|
||||||
}
|
}
|
||||||
// failed/stuck — даём кнопку повтора; остальное только «в вебе».
|
switch state {
|
||||||
if state == store.StateFailed || state == store.StateStuck {
|
case store.StateFailed, store.StateStuck:
|
||||||
|
// failed/stuck — даём кнопку повтора.
|
||||||
return text, b.retryKeyboard(id)
|
return text, b.retryKeyboard(id)
|
||||||
|
case store.StateOrphaned, store.StateTargetMissing:
|
||||||
|
// Рассинхрон — можно подчистить остатки полным удалением.
|
||||||
|
return text, b.deletableKeyboard(id)
|
||||||
}
|
}
|
||||||
return text, b.webOnly(id)
|
return text, b.webOnly(id)
|
||||||
}
|
}
|
||||||
@@ -164,6 +168,29 @@ func (b *Bot) retryKeyboard(id string) *tgbotapi.InlineKeyboardMarkup {
|
|||||||
return &kb
|
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 {
|
func (b *Bot) webOnly(id string) *tgbotapi.InlineKeyboardMarkup {
|
||||||
url := b.reviewURL(id)
|
url := b.reviewURL(id)
|
||||||
if url == "" {
|
if url == "" {
|
||||||
|
|||||||
+84
-11
@@ -537,17 +537,7 @@ func (w *Worker) Undo(ctx context.Context, id string) error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("undo: %w", err)
|
return fmt.Errorf("undo: %w", err)
|
||||||
}
|
}
|
||||||
// Снимаем только реально разложенные нами ссылки. superseded-строки —
|
n, err := w.layouter.Undo(ctx, laidOutLinks(rows))
|
||||||
// путь забрала другая загрузка (см. 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)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("undo: %w", err)
|
return fmt.Errorf("undo: %w", err)
|
||||||
}
|
}
|
||||||
@@ -559,6 +549,89 @@ func (w *Worker) Undo(ctx context.Context, id string) error {
|
|||||||
return nil
|
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.
|
// requireReviewable проверяет, что задача в review/deferred. Вызывается под mu.
|
||||||
func (w *Worker) requireReviewable(ctx context.Context, id string, op string) (*store.Download, error) {
|
func (w *Worker) requireReviewable(ctx context.Context, id string, op string) (*store.Download, error) {
|
||||||
d, err := w.store.GetDownload(ctx, id)
|
d, err := w.store.GetDownload(ctx, id)
|
||||||
|
|||||||
@@ -872,6 +872,7 @@ func TestDefer(t *testing.T) {
|
|||||||
type applyFixture struct {
|
type applyFixture struct {
|
||||||
w *Worker
|
w *Worker
|
||||||
st *memStore
|
st *memStore
|
||||||
|
qb *fakeQbt
|
||||||
downloads string
|
downloads string
|
||||||
movies string
|
movies string
|
||||||
series 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"}}}
|
qb := &fakeQbt{torrents: []qbt.Torrent{{Hash: ihTest, SavePath: downloads, Category: "jellybit", State: "uploading"}}}
|
||||||
w := testWorkerWith(st, qb, &fakeRecognizer{}, lay)
|
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) {
|
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) {
|
func TestReviewData(t *testing.T) {
|
||||||
plan := seriesResult().Plan
|
plan := seriesResult().Plan
|
||||||
f := newApplyFixture(t, plan)
|
f := newApplyFixture(t, plan)
|
||||||
|
|||||||
@@ -90,6 +90,7 @@ type QBittorrent interface {
|
|||||||
Torrents(ctx context.Context, category string) ([]qbt.Torrent, error)
|
Torrents(ctx context.Context, category string) ([]qbt.Torrent, error)
|
||||||
Add(ctx context.Context, ar qbt.AddRequest) error
|
Add(ctx context.Context, ar qbt.AddRequest) error
|
||||||
Files(ctx context.Context, hash string) ([]qbt.File, error)
|
Files(ctx context.Context, hash string) ([]qbt.File, error)
|
||||||
|
Delete(ctx context.Context, hashes []string, deleteFiles bool) error
|
||||||
}
|
}
|
||||||
|
|
||||||
// Recognizer — распознаватель (recognize.Recognizer).
|
// Recognizer — распознаватель (recognize.Recognizer).
|
||||||
@@ -108,6 +109,7 @@ type Layouter interface {
|
|||||||
BuildLinks(p layout.Plan) ([]layout.Link, error)
|
BuildLinks(p layout.Plan) ([]layout.Link, error)
|
||||||
Apply(ctx context.Context, links []layout.Link) ([]layout.Result, error)
|
Apply(ctx context.Context, links []layout.Link) ([]layout.Result, error)
|
||||||
Undo(ctx context.Context, links []layout.Link) (int, error)
|
Undo(ctx context.Context, links []layout.Link) (int, error)
|
||||||
|
Remove(ctx context.Context, links []layout.Link) (int, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
// NotifyEvent — повод позвать пользователя.
|
// NotifyEvent — повод позвать пользователя.
|
||||||
|
|||||||
@@ -259,6 +259,8 @@ type fakeQbt struct {
|
|||||||
added []qbt.AddRequest
|
added []qbt.AddRequest
|
||||||
addErr error
|
addErr error
|
||||||
files []qbt.File
|
files []qbt.File
|
||||||
|
deleted [][]string // хеши каждого вызова Delete
|
||||||
|
deleteErr error
|
||||||
}
|
}
|
||||||
|
|
||||||
// Torrents имитирует /torrents/info: пустая категория — все торренты, иначе
|
// Torrents имитирует /torrents/info: пустая категория — все торренты, иначе
|
||||||
@@ -290,6 +292,14 @@ func (f *fakeQbt) Files(_ context.Context, _ string) ([]qbt.File, error) {
|
|||||||
return f.files, nil
|
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 {
|
func newTestWorker(st *fakeStore, qb *fakeQbt) *Worker {
|
||||||
w := New(st, qb, nil, nil, Config{
|
w := New(st, qb, nil, nil, Config{
|
||||||
Category: "jellybit",
|
Category: "jellybit",
|
||||||
|
|||||||
@@ -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=<h1>|<h2>|…` (все известные хеши
|
||||||
|
задачи, v1/v2 — qBittorrent матчит присутствующий), `deleteFiles=true`.
|
||||||
|
Логирование — `ExtCall{Operation:"torrents/delete"}`, как у `Add`. Пустой/2xx
|
||||||
|
ответ — успех; отсутствие хеша ошибкой не считается (идемпотентность).
|
||||||
|
|
||||||
|
### 4. Подтверждение (обязательно, во всех транспортах)
|
||||||
|
|
||||||
|
Терминальное необратимое действие с обходом инварианта — только по явному
|
||||||
|
подтверждению (в проекте подтверждений опасных действий ещё нет, вводим впервые).
|
||||||
|
|
||||||
|
- **Веб-UI:** danger-секция в самом низу `download_main.html` (виз. отделена).
|
||||||
|
Кнопка «Удалить» раскрывает confirm (htmx-своп фрагмента: «Точно удалить?
|
||||||
|
Раздача и файлы будут снесены — [Да, удалить] [Отмена]»); фактическое удаление
|
||||||
|
— `POST /ui/downloads/{id}/delete`. Деградация без htmx (web-ui-конвенция):
|
||||||
|
секция — обычная форма с подтверждающей кнопкой на отдельном шаге/`<details>`,
|
||||||
|
ошибка на htmx-пути = 200 + фрагмент.
|
||||||
|
- **Telegram:** двухшаговый inline — `delete:<id>` показывает confirm-keyboard
|
||||||
|
(`delete_confirm:<id>` / «Отмена»), само удаление — на подтверждающем callback.
|
||||||
|
|
||||||
|
Флаг `Deletable` (состояние ∈ {done, orphaned, target_missing}) считается там же,
|
||||||
|
где `Undoable`/`Retriable` (`httpapi.go:648`, `download.go:108`).
|
||||||
|
|
||||||
|
## Что осознанно НЕ делаем
|
||||||
|
|
||||||
|
- **Мультивыбор «удалить выбранное»** — из scope вынесено (задача помечала как
|
||||||
|
опциональное). Остаётся тонкой обёрткой над `Delete` на будущее, если понадобится.
|
||||||
|
- **Новый статус** — не заводим, `deleted` переиспользуется.
|
||||||
|
- **Миграция БД / новое поле инициатора** — не нужны, различаем через `error_code`.
|
||||||
|
- **Дельта `review`** — исходная задача упоминала `review`, но delete не команда
|
||||||
|
экрана ревью (недоступна из `review`); правим только `state-reconciliation`.
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
## Why
|
||||||
|
|
||||||
|
«Единое окно» реализовано наполовину: jellybit распознаёт **ручное** удаление и
|
||||||
|
метит рассинхрон (`state-reconciliation` — `target_missing`/`orphaned`/`deleted`,
|
||||||
|
безопасный `Undo` с гардом последней копии, preflight). Не хватает второй
|
||||||
|
половины — **удалять загрузку из самого jellybit**, не идя руками в
|
||||||
|
qBittorrent/Jellyfin.
|
||||||
|
|
||||||
|
Основной сценарий одного окна — «досмотрел → освободить место»: убрать и раздачу,
|
||||||
|
и файлы в библиотеке разом. Из-за хардлинков иначе место не вернуть — файл в
|
||||||
|
`downloads/` (qBittorrent) и наша ссылка в библиотеке указывают на **один инод**,
|
||||||
|
диск освобождается только когда исчезает последняя ссылка. Значит удаление обязано
|
||||||
|
снять **обе** стороны. Это осознанный выход за инвариант «источник неприкосновенен»,
|
||||||
|
поэтому — с подтверждением и отдельным следом в логе/причине перехода.
|
||||||
|
|
||||||
|
`Undo` и `delete` — разные операции, и это надо зафиксировать:
|
||||||
|
|
||||||
|
- **Undo** — «перераспознать»: снимает только наши ссылки, раздачу в qBittorrent
|
||||||
|
сохраняет, гард последней копии **включён**. Уже готов.
|
||||||
|
- **delete** — «убрать окончательно, освободить место»: снимает наши ссылки И
|
||||||
|
сносит раздачу+файлы из qBittorrent, гард **выключен**, состояние терминальное
|
||||||
|
`deleted`.
|
||||||
|
|
||||||
|
## What Changes
|
||||||
|
|
||||||
|
- Ввести пользовательскую команду **«Удалить»** в `worker`, доступную из
|
||||||
|
`done`, `orphaned`, `target_missing` (подчистить остатки за собой из любого
|
||||||
|
состояния «уже разложено / рассинхрон»). Действие: снять наши библиотечные
|
||||||
|
хардлинки последнего батча (переиспользуем механику `Undo`, `superseded`
|
||||||
|
пропускаем) с **выключенным** гардом последней копии **+** удалить раздачу с
|
||||||
|
файлами из qBittorrent. Идемпотентно к отсутствующей стороне (в `orphaned` нет
|
||||||
|
источника, в `target_missing` нет цели).
|
||||||
|
- Переиспользовать существующее терминальное состояние `deleted` — **без** нового
|
||||||
|
статуса; рёбра `done/orphaned/target_missing → deleted` уже есть в
|
||||||
|
`allowedTransitions`. Инициатора-пользователя отличать от сверки через
|
||||||
|
`error_code = "user_delete"` (сверка кладёт `"reconcile"`), человекочитаемую
|
||||||
|
причину — в `error_msg` и лог перехода.
|
||||||
|
- Добавить в `qbt` метод удаления раздачи (`POST /api/v2/torrents/delete`,
|
||||||
|
`deleteFiles=true`) и объявить его в интерфейсе `QBittorrent` воркера.
|
||||||
|
- Добавить в `layout` путь снятия ссылок **без** гарда последней копии (delete
|
||||||
|
сознательно снимает последнюю копию, в отличие от `Undo`).
|
||||||
|
- Обязательное **подтверждение** во всех транспортах: веб-UI — danger-секция с
|
||||||
|
кнопкой в самом низу страницы загрузки; Telegram — двухшаговый inline-confirm.
|
||||||
|
|
||||||
|
## Capabilities
|
||||||
|
|
||||||
|
### New Capabilities
|
||||||
|
<!-- нет новых capability -->
|
||||||
|
|
||||||
|
### Modified Capabilities
|
||||||
|
- `state-reconciliation`: добавляется пользовательская команда «Удалить»
|
||||||
|
(снять источник и цель, гард последней копии выключен) с переходом
|
||||||
|
`done`/`orphaned`/`target_missing` → терминальный `deleted`, отличимая от
|
||||||
|
reconcile-`deleted` инициатором, с обязательным подтверждением.
|
||||||
|
|
||||||
|
## Impact
|
||||||
|
|
||||||
|
- Код воркера: `internal/worker/review.go` (новый метод `Delete`; путь снятия
|
||||||
|
ссылок с выключенным гардом), `internal/worker/worker.go` (расширить интерфейс
|
||||||
|
`QBittorrent` методом удаления; проброс в конструктор при необходимости).
|
||||||
|
- Инфраструктура: `internal/qbt/qbt.go` (метод `Delete(hashes, deleteFiles)` →
|
||||||
|
`/api/v2/torrents/delete`), `internal/layout/layout.go` (снятие ссылок без
|
||||||
|
`ErrLastCopy`).
|
||||||
|
- Транспорты: `internal/httpapi` (роут `POST /ui/downloads/{id}/delete` + confirm,
|
||||||
|
флаг `Deletable` по состоянию; опц. JSON-роут), `web/templates/partials`
|
||||||
|
(danger-секция внизу страницы загрузки), `internal/tgbot` (действие `delete` +
|
||||||
|
двухшаговый confirm-keyboard).
|
||||||
|
- Данные: **осознанное** снятие последней копии данных при удалении из
|
||||||
|
`orphaned` — только по явному подтверждению; инвариант «источник
|
||||||
|
неприкосновенен» обходится намеренно и логируется как пользовательское
|
||||||
|
удаление. Схема БД не меняется (переиспользуем `error_code`/`error_msg`).
|
||||||
|
- Тесты: `internal/worker/review_test.go` (delete из каждого из трёх состояний,
|
||||||
|
идемпотентность к отсутствующей стороне, отказ из прочих состояний),
|
||||||
|
`internal/qbt/qbt_test.go` (форма запроса delete), транспортные тесты confirm.
|
||||||
+92
@@ -0,0 +1,92 @@
|
|||||||
|
## ADDED Requirements
|
||||||
|
|
||||||
|
### Requirement: Полное удаление загрузки пользователем
|
||||||
|
|
||||||
|
Система SHALL предоставлять пользователю команду **«Удалить»** (delete),
|
||||||
|
доступную из состояний `done`, `orphaned` и `target_missing` во всех транспортах
|
||||||
|
(веб-UI и Telegram, опц. REST). Команда SHALL снимать **обе** стороны загрузки —
|
||||||
|
целевые библиотечные хардлинки И раздачу с файлами в qBittorrent — и переводить
|
||||||
|
задачу в терминальное `deleted`. Из прочих состояний команда доступна SHALL NOT.
|
||||||
|
|
||||||
|
Снятие цели SHALL идти по механике снятия ссылок последнего батча (как в `Undo`:
|
||||||
|
`superseded` пропускаются как забранные другой загрузкой), но **отдельным путём с
|
||||||
|
выключенным** гардом последней копии — не переиспользуя guarded-`Undo`: в отличие
|
||||||
|
от `Undo`, delete SHALL снимать целевую ссылку, даже если она — последняя копия
|
||||||
|
данных (`nlink <= 1`). Это осознанный выход за
|
||||||
|
инвариант «источник неприкосновенен», поэтому delete SHALL требовать явного
|
||||||
|
**подтверждения** пользователя перед выполнением и SHALL NOT срабатывать по
|
||||||
|
одиночному клику/тапу. Снятие цели SHALL затрагивать только собственные ссылки
|
||||||
|
загрузки строго под `paths.movies`/`series`; файлы источника под
|
||||||
|
`paths.downloads` система сама трогать SHALL NOT — их удаляет qBittorrent по
|
||||||
|
вызову API с `deleteFiles=true`.
|
||||||
|
|
||||||
|
В отличие от прочих команд, требующих источника, delete синхронный source-preflight
|
||||||
|
выполнять SHALL NOT и под требование «Принудительная проверка источника/цели перед
|
||||||
|
действием» не подпадает: цель delete — снять источник, поэтому его отсутствие
|
||||||
|
трактуется как уже снятая сторона, а не как повод привести состояние сверкой и
|
||||||
|
отказать. Удаление SHALL быть идемпотентным к отсутствующей стороне: в `orphaned`
|
||||||
|
(нет источника) отсутствие раздачи в qBittorrent ошибкой считаться SHALL NOT; в
|
||||||
|
`target_missing` (нет цели) пустой список живых ссылок обрабатывается как «нечего
|
||||||
|
снимать». Если qBittorrent вернул ошибку при удалении присутствующей раздачи,
|
||||||
|
система в `deleted` переходить SHALL NOT (не заявляем освобождение места, которого
|
||||||
|
не произошло), SHALL сообщить пользователю причину отказа (это не `ErrConflict`,
|
||||||
|
а ошибка внешнего сервиса — транслируется как таковая), и повторный delete
|
||||||
|
идемпотентно дожимает удаление, опираясь на оставшийся `done` либо приведённый
|
||||||
|
сверкой к реальности `target_missing` (кратковременное рассогласование до тика
|
||||||
|
сверки ожидаемо).
|
||||||
|
|
||||||
|
Инициатора перехода в `deleted` система SHALL отличать от фоновой сверки:
|
||||||
|
пользовательское удаление SHALL помечаться `error_code = "user_delete"` (сверка
|
||||||
|
кладёт `"reconcile"`), человекочитаемую причину — в `error_msg` и лог перехода.
|
||||||
|
Новый статус для этого система вводить SHALL NOT — переиспользуется существующее
|
||||||
|
терминальное `deleted` (сверка его не переоценивает, см. требование о `deleted`).
|
||||||
|
|
||||||
|
#### Scenario: Удаление из done снимает обе стороны и освобождает место
|
||||||
|
|
||||||
|
- **GIVEN** задача в `done`: раздача присутствует в qBittorrent, её библиотечные
|
||||||
|
хардлинки существуют
|
||||||
|
- **WHEN** пользователь подтверждает «Удалить»
|
||||||
|
- **THEN** библиотечные ссылки последнего батча снимаются
|
||||||
|
- **AND** раздача с файлами удаляется из qBittorrent (`deleteFiles=true`)
|
||||||
|
- **AND** задача переходит в `deleted` с `error_code = "user_delete"`
|
||||||
|
|
||||||
|
#### Scenario: Удаление из orphaned снимает последнюю копию осознанно
|
||||||
|
|
||||||
|
- **GIVEN** задача в `orphaned`: источник пропал, библиотечный хардлинк остался
|
||||||
|
единственной копией данных (`nlink <= 1`)
|
||||||
|
- **WHEN** пользователь подтверждает «Удалить»
|
||||||
|
- **THEN** библиотечная ссылка снимается несмотря на то, что она последняя копия
|
||||||
|
(гард последней копии выключен, в отличие от `Undo`)
|
||||||
|
- **AND** отсутствие раздачи в qBittorrent ошибкой не считается
|
||||||
|
- **AND** задача переходит в `deleted`
|
||||||
|
|
||||||
|
#### Scenario: Удаление из target_missing сносит остаточную раздачу
|
||||||
|
|
||||||
|
- **GIVEN** задача в `target_missing`: источник присутствует, цель уже удалена
|
||||||
|
вручную
|
||||||
|
- **WHEN** пользователь подтверждает «Удалить»
|
||||||
|
- **THEN** снятие цели идемпотентно (живых ссылок нет)
|
||||||
|
- **AND** раздача с файлами удаляется из qBittorrent
|
||||||
|
- **AND** задача переходит в `deleted`
|
||||||
|
|
||||||
|
#### Scenario: Удаление требует подтверждения
|
||||||
|
|
||||||
|
- **GIVEN** задача в `done`
|
||||||
|
- **WHEN** пользователь инициирует «Удалить», но не подтверждает действие
|
||||||
|
- **THEN** ни ссылки, ни раздача не удаляются, состояние остаётся `done`
|
||||||
|
|
||||||
|
#### Scenario: Удаление недоступно из прочих состояний
|
||||||
|
|
||||||
|
- **GIVEN** задача в `review` (или ином состоянии вне `done`/`orphaned`/
|
||||||
|
`target_missing`)
|
||||||
|
- **WHEN** приходит команда «Удалить»
|
||||||
|
- **THEN** команда отклоняется с конфликтом, состояние не меняется
|
||||||
|
|
||||||
|
#### Scenario: Ошибка qBittorrent не метит deleted ложно
|
||||||
|
|
||||||
|
- **GIVEN** задача в `done`, раздача присутствует, но qBittorrent вернул ошибку
|
||||||
|
на удаление
|
||||||
|
- **WHEN** пользователь подтверждает «Удалить»
|
||||||
|
- **THEN** задача в `deleted` не переходит (место не освобождено)
|
||||||
|
- **AND** пользователю сообщается причина отказа (ошибка qBittorrent, не тихий успех)
|
||||||
|
- **AND** повторный delete идемпотентно дожимает удаление
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
## 1. qbt — удаление раздачи
|
||||||
|
|
||||||
|
- [x] 1.1 В `internal/qbt/qbt.go` добавить `Delete(ctx, hashes []string, deleteFiles bool) error`:
|
||||||
|
`POST /api/v2/torrents/delete`, форма `hashes=<h1>|<h2>|…` + `deleteFiles`.
|
||||||
|
Логирование `ExtCall{Operation:"torrents/delete"}` как у `Add`; 2xx/пустой
|
||||||
|
ответ — успех, отсутствие хеша ошибкой не считать.
|
||||||
|
- [x] 1.2 Тест `internal/qbt/qbt_test.go`: форма запроса delete (hashes через `|`,
|
||||||
|
`deleteFiles=true`), обработка ответа.
|
||||||
|
|
||||||
|
## 2. layout — снятие ссылок без гарда последней копии
|
||||||
|
|
||||||
|
- [x] 2.1 Добавить в `Layouter` и реализацию `internal/layout/layout.go` путь
|
||||||
|
снятия целевых ссылок **без** `ErrLastCopy` (напр. `Remove(ctx, links)`):
|
||||||
|
безусловный `unlink` только своих ссылок строго под `paths.movies`/`series`;
|
||||||
|
санитизация/проверка «под библиотекой» сохраняется, `paths.downloads` не
|
||||||
|
трогаем.
|
||||||
|
|
||||||
|
## 3. worker — команда Delete
|
||||||
|
|
||||||
|
- [x] 3.1 Расширить интерфейс `QBittorrent` воркера методом `Delete(...)`
|
||||||
|
(`internal/worker/worker.go`); проброс реализации `qbt.Client`.
|
||||||
|
- [x] 3.2 В `internal/worker/review.go` добавить `Delete(ctx, id string) error`:
|
||||||
|
под `w.mu`; `GetDownload`; предусловие `d.State ∈ {done, orphaned,
|
||||||
|
target_missing}`, иначе `ErrConflict`. Порядок: (а) снять цель как в `Undo`
|
||||||
|
(`LatestBatchID`/`ListFileLinksByBatch`/фильтр `isLaidOut`, `superseded`
|
||||||
|
пропускаем) через новый `layouter.Remove`, затем `DeleteFileLinksByBatch`; (б)
|
||||||
|
`qbt.Delete(d.Infohashes, deleteFiles=true)` (хеши уже загружены с задачей,
|
||||||
|
отдельный store-геттер не нужен) — ошибку присутствующей раздачи пробросить
|
||||||
|
(не метить `deleted`), отсутствие раздачи ошибкой не считать; (в)
|
||||||
|
`transition(ctx, d, StateDeleted, "user_delete", <причина>)`. Delete
|
||||||
|
source-preflight (`ensureSourceReady`/`reconcileToReality`) НЕ вызывает —
|
||||||
|
толерантен к отсутствию источника.
|
||||||
|
- [x] 3.3 Логировать удаление как осознанное пользовательское снятие источника
|
||||||
|
(`slog`, без секретов): id, состояние-источник, снятые ссылки, `code=user_delete`.
|
||||||
|
|
||||||
|
## 4. Транспорт HTTP/веб + подтверждение
|
||||||
|
|
||||||
|
- [x] 4.1 Роут `POST /ui/downloads/{id}/delete` → хендлер (по образцу
|
||||||
|
`handleUndo`), финализация через `surfaceAction`; трансляция `ErrConflict` →
|
||||||
|
409/фрагмент как у прочих действий, а ошибка qBittorrent (не `ErrConflict`) —
|
||||||
|
внятный отказ пользователю (не тихий успех), не 200-без-эффекта.
|
||||||
|
- [x] 4.2 Флаг `Deletable` (состояние ∈ {done, orphaned, target_missing}) в
|
||||||
|
расчётах кнопок (`httpapi.go:648`, `download.go:108`).
|
||||||
|
- [x] 4.3 Danger-секция в самом низу `web/templates/partials/download_main.html`:
|
||||||
|
кнопка «Удалить» → confirm (htmx-своп фрагмента «Точно удалить? …
|
||||||
|
[Да, удалить] [Отмена]») → `POST …/delete`. Деградация без htmx
|
||||||
|
(`<details>`/отдельный шаг), ошибка на htmx-пути = 200 + фрагмент.
|
||||||
|
- [ ] 4.4 (Опц., НЕ в этом change) JSON-роут `POST /api/downloads/{id}/delete`.
|
||||||
|
Осознанно отложено: REST в спеке помечен опциональным, а деструктивный endpoint
|
||||||
|
без естественного шага подтверждения заводим только по реальной нужде.
|
||||||
|
|
||||||
|
## 5. Транспорт Telegram + подтверждение
|
||||||
|
|
||||||
|
- [x] 5.1 В `internal/tgbot` добавить действие `delete`: callback `delete:<id>`
|
||||||
|
показывает confirm-keyboard (`delete_confirm:<id>` / «Отмена»); удаление — на
|
||||||
|
подтверждающем callback (`Reviewer.Delete`). Кнопка «Удалить» — в карточке
|
||||||
|
задачи для состояний done/orphaned/target_missing.
|
||||||
|
- [x] 5.2 Ветка ошибок как у прочих действий (`ErrConflict` → понятный отказ;
|
||||||
|
ошибка qBittorrent → отдельное сообщение об отказе, не тихий успех), после
|
||||||
|
успеха — `answer` + `refreshCard`.
|
||||||
|
|
||||||
|
## 6. Тесты воркера
|
||||||
|
|
||||||
|
- [x] 6.1 Delete из `done`: сняты ссылки + вызван `qbt.Delete(deleteFiles=true)`
|
||||||
|
+ переход `deleted`/`user_delete`.
|
||||||
|
- [x] 6.2 Delete из `orphaned`: последняя копия снята (гард выключен), отсутствие
|
||||||
|
раздачи не ошибка, переход `deleted`.
|
||||||
|
- [x] 6.3 Delete из `target_missing`: снятие цели идемпотентно (нет живых ссылок),
|
||||||
|
раздача снесена, переход `deleted`.
|
||||||
|
- [x] 6.4 Delete из `review`/иного — отказ `ErrConflict`, состояние не меняется.
|
||||||
|
- [x] 6.5 Ошибка `qbt.Delete` при присутствующей раздаче — задача остаётся, в
|
||||||
|
`deleted` не уходит; повторный delete идемпотентен.
|
||||||
|
|
||||||
|
## 7. Ревью и сверка
|
||||||
|
|
||||||
|
- [x] 7.1 `task test` и `task lint` зелёные.
|
||||||
|
- [x] 7.2 Ревью кода (второй чекпоинт) перед archive.
|
||||||
|
- [x] 7.3 `openspec validate download-delete --strict` зелёный.
|
||||||
|
- [x] 7.4 Синк нарратива `docs/specs/workflow.md`: явные пользовательские рёбра
|
||||||
|
`done/orphaned/target_missing → deleted` (delete) и различие Undo vs delete;
|
||||||
|
удалить `docs/backlog/udalenie-edinoe-okno.md` и строку из индекса беклога.
|
||||||
@@ -443,3 +443,94 @@ SHALL задевать раскладку в полёте.
|
|||||||
- **WHEN** выполняется тик поллинга
|
- **WHEN** выполняется тик поллинга
|
||||||
- **THEN** восстановление `linking` их состояние не меняет
|
- **THEN** восстановление `linking` их состояние не меняет
|
||||||
|
|
||||||
|
### Requirement: Полное удаление загрузки пользователем
|
||||||
|
|
||||||
|
Система SHALL предоставлять пользователю команду **«Удалить»** (delete),
|
||||||
|
доступную из состояний `done`, `orphaned` и `target_missing` во всех транспортах
|
||||||
|
(веб-UI и Telegram, опц. REST). Команда SHALL снимать **обе** стороны загрузки —
|
||||||
|
целевые библиотечные хардлинки И раздачу с файлами в qBittorrent — и переводить
|
||||||
|
задачу в терминальное `deleted`. Из прочих состояний команда доступна SHALL NOT.
|
||||||
|
|
||||||
|
Снятие цели SHALL идти по механике снятия ссылок последнего батча (как в `Undo`:
|
||||||
|
`superseded` пропускаются как забранные другой загрузкой), но **отдельным путём с
|
||||||
|
выключенным** гардом последней копии — не переиспользуя guarded-`Undo`: в отличие
|
||||||
|
от `Undo`, delete SHALL снимать целевую ссылку, даже если она — последняя копия
|
||||||
|
данных (`nlink <= 1`). Это осознанный выход за
|
||||||
|
инвариант «источник неприкосновенен», поэтому delete SHALL требовать явного
|
||||||
|
**подтверждения** пользователя перед выполнением и SHALL NOT срабатывать по
|
||||||
|
одиночному клику/тапу. Снятие цели SHALL затрагивать только собственные ссылки
|
||||||
|
загрузки строго под `paths.movies`/`series`; файлы источника под
|
||||||
|
`paths.downloads` система сама трогать SHALL NOT — их удаляет qBittorrent по
|
||||||
|
вызову API с `deleteFiles=true`.
|
||||||
|
|
||||||
|
В отличие от прочих команд, требующих источника, delete синхронный source-preflight
|
||||||
|
выполнять SHALL NOT и под требование «Принудительная проверка источника/цели перед
|
||||||
|
действием» не подпадает: цель delete — снять источник, поэтому его отсутствие
|
||||||
|
трактуется как уже снятая сторона, а не как повод привести состояние сверкой и
|
||||||
|
отказать. Удаление SHALL быть идемпотентным к отсутствующей стороне: в `orphaned`
|
||||||
|
(нет источника) отсутствие раздачи в qBittorrent ошибкой считаться SHALL NOT; в
|
||||||
|
`target_missing` (нет цели) пустой список живых ссылок обрабатывается как «нечего
|
||||||
|
снимать». Если qBittorrent вернул ошибку при удалении присутствующей раздачи,
|
||||||
|
система в `deleted` переходить SHALL NOT (не заявляем освобождение места, которого
|
||||||
|
не произошло), SHALL сообщить пользователю причину отказа (это не `ErrConflict`,
|
||||||
|
а ошибка внешнего сервиса — транслируется как таковая), и повторный delete
|
||||||
|
идемпотентно дожимает удаление, опираясь на оставшийся `done` либо приведённый
|
||||||
|
сверкой к реальности `target_missing` (кратковременное рассогласование до тика
|
||||||
|
сверки ожидаемо).
|
||||||
|
|
||||||
|
Инициатора перехода в `deleted` система SHALL отличать от фоновой сверки:
|
||||||
|
пользовательское удаление SHALL помечаться `error_code = "user_delete"` (сверка
|
||||||
|
кладёт `"reconcile"`), человекочитаемую причину — в `error_msg` и лог перехода.
|
||||||
|
Новый статус для этого система вводить SHALL NOT — переиспользуется существующее
|
||||||
|
терминальное `deleted` (сверка его не переоценивает, см. требование о `deleted`).
|
||||||
|
|
||||||
|
#### Scenario: Удаление из done снимает обе стороны и освобождает место
|
||||||
|
|
||||||
|
- **GIVEN** задача в `done`: раздача присутствует в qBittorrent, её библиотечные
|
||||||
|
хардлинки существуют
|
||||||
|
- **WHEN** пользователь подтверждает «Удалить»
|
||||||
|
- **THEN** библиотечные ссылки последнего батча снимаются
|
||||||
|
- **AND** раздача с файлами удаляется из qBittorrent (`deleteFiles=true`)
|
||||||
|
- **AND** задача переходит в `deleted` с `error_code = "user_delete"`
|
||||||
|
|
||||||
|
#### Scenario: Удаление из orphaned снимает последнюю копию осознанно
|
||||||
|
|
||||||
|
- **GIVEN** задача в `orphaned`: источник пропал, библиотечный хардлинк остался
|
||||||
|
единственной копией данных (`nlink <= 1`)
|
||||||
|
- **WHEN** пользователь подтверждает «Удалить»
|
||||||
|
- **THEN** библиотечная ссылка снимается несмотря на то, что она последняя копия
|
||||||
|
(гард последней копии выключен, в отличие от `Undo`)
|
||||||
|
- **AND** отсутствие раздачи в qBittorrent ошибкой не считается
|
||||||
|
- **AND** задача переходит в `deleted`
|
||||||
|
|
||||||
|
#### Scenario: Удаление из target_missing сносит остаточную раздачу
|
||||||
|
|
||||||
|
- **GIVEN** задача в `target_missing`: источник присутствует, цель уже удалена
|
||||||
|
вручную
|
||||||
|
- **WHEN** пользователь подтверждает «Удалить»
|
||||||
|
- **THEN** снятие цели идемпотентно (живых ссылок нет)
|
||||||
|
- **AND** раздача с файлами удаляется из qBittorrent
|
||||||
|
- **AND** задача переходит в `deleted`
|
||||||
|
|
||||||
|
#### Scenario: Удаление требует подтверждения
|
||||||
|
|
||||||
|
- **GIVEN** задача в `done`
|
||||||
|
- **WHEN** пользователь инициирует «Удалить», но не подтверждает действие
|
||||||
|
- **THEN** ни ссылки, ни раздача не удаляются, состояние остаётся `done`
|
||||||
|
|
||||||
|
#### Scenario: Удаление недоступно из прочих состояний
|
||||||
|
|
||||||
|
- **GIVEN** задача в `review` (или ином состоянии вне `done`/`orphaned`/
|
||||||
|
`target_missing`)
|
||||||
|
- **WHEN** приходит команда «Удалить»
|
||||||
|
- **THEN** команда отклоняется с конфликтом, состояние не меняется
|
||||||
|
|
||||||
|
#### Scenario: Ошибка qBittorrent не метит deleted ложно
|
||||||
|
|
||||||
|
- **GIVEN** задача в `done`, раздача присутствует, но qBittorrent вернул ошибку
|
||||||
|
на удаление
|
||||||
|
- **WHEN** пользователь подтверждает «Удалить»
|
||||||
|
- **THEN** задача в `deleted` не переходит (место не освобождено)
|
||||||
|
- **AND** пользователю сообщается причина отказа (ошибка qBittorrent, не тихий успех)
|
||||||
|
- **AND** повторный delete идемпотентно дожимает удаление
|
||||||
|
|
||||||
|
|||||||
@@ -77,7 +77,7 @@
|
|||||||
{{end}}
|
{{end}}
|
||||||
|
|
||||||
<!-- Информация о торренте -->
|
<!-- Информация о торренте -->
|
||||||
<div class="section" style="margin-bottom:0">
|
<div class="section">
|
||||||
<div class="section-head"><h2>Информация о торренте</h2><span class="hint">сырой источник и infohash</span></div>
|
<div class="section-head"><h2>Информация о торренте</h2><span class="hint">сырой источник и infohash</span></div>
|
||||||
<dl class="kv">
|
<dl class="kv">
|
||||||
<dt>Тип источника</dt><dd>{{.SourceType}}</dd>
|
<dt>Тип источника</dt><dd>{{.SourceType}}</dd>
|
||||||
@@ -90,5 +90,25 @@
|
|||||||
</div>
|
</div>
|
||||||
{{end}}
|
{{end}}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Опасная зона: полное удаление загрузки (снять хардлинки + снести раздачу с
|
||||||
|
файлами из qBittorrent). Секция скрыта в details (раскрыть сознательно). С JS
|
||||||
|
hx-confirm показывает диалог подтверждения; без JS гейт — только раскрытие
|
||||||
|
details и явный submit (диалога нет). -->
|
||||||
|
{{if .Deletable}}
|
||||||
|
<details class="section danger-zone" style="margin-bottom:0;border-color:color-mix(in srgb,var(--st-err) 35%,transparent)">
|
||||||
|
<summary style="color:var(--st-err);cursor:pointer;font-weight:600">Опасная зона</summary>
|
||||||
|
<p class="muted" style="margin:var(--sp-3) 0">
|
||||||
|
Полное удаление снимет библиотечные хардлинки <strong>и</strong> снесёт раздачу
|
||||||
|
с файлами из qBittorrent — освободит место. Действие необратимо.
|
||||||
|
</p>
|
||||||
|
<form method="post" action="/ui/downloads/{{.ID}}/delete"
|
||||||
|
hx-post="/ui/downloads/{{.ID}}/delete" hx-target="#download-main" hx-swap="outerHTML"
|
||||||
|
hx-confirm="Удалить загрузку? Раздача и файлы будут снесены, место освобождено. Действие необратимо.">
|
||||||
|
<input type="hidden" name="surface" value="download">
|
||||||
|
<button class="btn btn-danger" type="submit">Удалить загрузку</button>
|
||||||
|
</form>
|
||||||
|
</details>
|
||||||
|
{{end}}
|
||||||
</main>
|
</main>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|||||||
Reference in New Issue
Block a user