Восстановление зависших загрузок и уведомления о падении (state-reconciliation)
Долгий metaDL больше не убивается агрессивным таймаутом: дефолт magnet_timeout 30m → 24h (страховочный предохранитель), базис отсчёта — added_on из qBittorrent, а не created_at (переживает retry/усыновление). Авто-восстановление: фоновая сверка возвращает в поток задачи, упавшие по нашей нетерпеливости (magnet_timeout/stalled), когда источник ожил и продвинулся за условие падения (downloading/completed по статусу торрента); qbit_error не воскрешается. Конфликт idempotency (infohash занят другой активной задачей) — оставляем в failed. Уведомления: любой переход в failed/stuck пингует автора (включая приёмный qbit_add через ingest), с дебаунсом против спама при флаппинге stalled. Ручной retry добавлен в веб-UI и Telegram; Retry перецепляется к живому торренту вместо слепого Add. Дельта state-reconciliation влита в живые спеки; обновлён workflow.md. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -84,6 +84,7 @@ func NewRouter(d Deps) (http.Handler, error) {
|
||||
r.Get("/", s.handleIndex)
|
||||
r.Post("/ui/downloads", s.handleUIAdd)
|
||||
r.Post("/ui/downloads/{id}/cancel", s.handleUICancel)
|
||||
r.Post("/ui/downloads/{id}/retry", s.handleUIRetry)
|
||||
|
||||
// Веб-UI: ревью раскладки.
|
||||
r.Get("/review/{id}", s.handleReview)
|
||||
@@ -133,6 +134,7 @@ type downloadView struct {
|
||||
Reviewable bool // review/deferred — есть экран ревью
|
||||
Undoable bool // done — можно откатить раскладку
|
||||
Relinkable bool // reverted/cancelled/target_missing — можно перепривязать заново
|
||||
Retriable bool // failed/stuck — можно повторить попытку
|
||||
Note string // пояснение рассинхрона (target_missing/orphaned/deleted)
|
||||
}
|
||||
|
||||
@@ -182,6 +184,19 @@ func (s *server) handleUICancel(w http.ResponseWriter, r *http.Request) {
|
||||
http.Redirect(w, r, "/", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
func (s *server) handleUIRetry(w http.ResponseWriter, r *http.Request) {
|
||||
id, err := pathID(r)
|
||||
if err != nil {
|
||||
redirectErr(w, r, "некорректный id")
|
||||
return
|
||||
}
|
||||
if err := s.deps.Commander.Retry(r.Context(), id); err != nil {
|
||||
redirectErr(w, r, userErr(r, err, id))
|
||||
return
|
||||
}
|
||||
http.Redirect(w, r, "/", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
// --- REST API ---
|
||||
|
||||
type downloadDTO struct {
|
||||
@@ -320,7 +335,8 @@ func toView(d store.Download) downloadView {
|
||||
Undoable: d.State == store.StateDone,
|
||||
Relinkable: d.State == store.StateReverted || d.State == store.StateCancelled ||
|
||||
d.State == store.StateTargetMissing,
|
||||
Note: desyncNote(d.State),
|
||||
Retriable: d.State == store.StateFailed || d.State == store.StateStuck,
|
||||
Note: desyncNote(d.State),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -160,6 +160,23 @@ func TestAPICancel(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestUIRetry(t *testing.T) {
|
||||
cmd := &fakeCommander{}
|
||||
srv := newServer(t, httpapi.Deps{Ingestor: &fakeIngestor{}, Commander: cmd, Reader: &fakeReader{}})
|
||||
|
||||
resp, err := http.Post(srv.URL+"/ui/downloads/5/retry", "application/x-www-form-urlencoded", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK { // 303 → редирект на / → 200
|
||||
t.Fatalf("status = %d, want 200", resp.StatusCode)
|
||||
}
|
||||
if len(cmd.retried) != 1 || cmd.retried[0] != 5 {
|
||||
t.Errorf("retry вызван неверно: %v", cmd.retried)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAPICommandConflict(t *testing.T) {
|
||||
// Конфликт состояния (worker.ErrConflict) → 409, не 500.
|
||||
cmd := &fakeCommander{err: fmt.Errorf("cancel: download 5 in wrong state: %w", worker.ErrConflict)}
|
||||
|
||||
Reference in New Issue
Block a user