Восстановление зависших загрузок и уведомления о падении (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:
av
2026-06-30 14:51:57 +03:00
co-authored by Claude Opus 4.8
parent dfa182a5a9
commit 70d8758646
24 changed files with 1168 additions and 40 deletions
+17 -1
View File
@@ -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),
}
}