Восстановление зависших загрузок и уведомления о падении (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
View File
@@ -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)}