diff --git a/cmd/jellybit/serve.go b/cmd/jellybit/serve.go index 11b5ea0..276b3cd 100644 --- a/cmd/jellybit/serve.go +++ b/cmd/jellybit/serve.go @@ -182,6 +182,10 @@ func runServe(args []string) error { WebBaseURL: cfg.Telegram.WebBaseURL, }, logger) wrk.SetNotifier(bot) + // Приёмные падения (qbit_add) минуют worker — уведомляем напрямую. + ingestor.SetFailureNotifier(func(id int64) { + bot.Notify(context.Background(), id, worker.EventFailed) + }) go bot.Run(ctx) logger.Info("telegram bot enabled", "bot", api.Self.UserName, "allowed_users", len(cfg.Telegram.AllowedUserIDs)) diff --git a/config.example.toml b/config.example.toml index 6e10db8..eded010 100644 --- a/config.example.toml +++ b/config.example.toml @@ -62,7 +62,7 @@ timeout = "10s" # таймаут запроса к Jellyfin [worker] poll_interval = "5s" # как часто опрашивать qBittorrent; Go-duration (s/m/h) stuck_after = "1h" # сколько ждать прогресса, прежде чем счесть раздачу зависшей; Go-duration -magnet_timeout = "30m" # ждать метаданные magnet не дольше; Go-duration +magnet_timeout = "24h" # страховочный предел ожидания метаданных magnet (не рабочий механизм: ожившие задачи воскрешаются сверкой); Go-duration source_missing_threshold = 3 # подряд тиков сверки без раздачи в qBittorrent, чтобы счесть источник удалённым (дебаунс) [recognition] diff --git a/docs/specs/workflow.md b/docs/specs/workflow.md index 4095565..80c4c15 100644 --- a/docs/specs/workflow.md +++ b/docs/specs/workflow.md @@ -14,7 +14,7 @@ stateDiagram-v2 downloading --> completed: файлы на месте downloading --> stuck: stalledDL дольше stuck_after - downloading --> failed: metaDL дольше magnet_timeout / error + downloading --> failed: metaDL дольше magnet_timeout (страховка) / error completed --> recognizing @@ -36,8 +36,10 @@ stateDiagram-v2 reverted --> recognizing: Привязать заново cancelled --> recognizing: Привязать заново - stuck --> downloading: Retry - failed --> downloading: Retry + stuck --> downloading: Retry / сверка (раздача ожила) + failed --> downloading: Retry / сверка (метаданные пришли) + failed --> completed: сверка (торрент уже готов) + stuck --> completed: сверка (торрент уже готов) done --> target_missing: сверка — цель удалена done --> orphaned: сверка — источник пропал @@ -147,10 +149,33 @@ SQLite; `worker` периодически сверяет qBittorrent с БД и проверку (готовность не объявляем, даже если флаги «UP»). - **ещё качается:** `downloading`/`stalledDL`/`metaDL`/`forcedMetaDL`/ `queuedDL`/`checkingDL`/`forcedDL`/`pausedDL`/`stoppedDL`. -- **застряло/ошибка по таймауту:** `metaDL`/`forcedMetaDL` дольше - `magnet_timeout` → `failed`; `stalledDL` дольше `stuck_after` → `stuck` - (восстановимо ретраем). Возраст считаем от создания задачи. -- **ошибка:** `error`/`missingFiles` → `failed`. +- **застряло по таймауту (страховка):** `metaDL`/`forcedMetaDL` дольше + `magnet_timeout` → `failed`; `stalledDL` дольше `stuck_after` → `stuck`. + `magnet_timeout` — **редкий страховочный предохранитель** (дефолт `24h`), а + не рабочий механизм: долгий `metaDL` (медленные трекеры/мало пиров) — это + норма, его не убиваем агрессивно. Возраст считаем от времени добавления + торрента в qBittorrent (`added_on`), а не от создания задачи (базис + переживает retry и усыновление). +- **ошибка:** `error`/`missingFiles` → `failed` (`error_code` `qbit_error`) — + это настоящий провал, в отличие от таймаута. + +### Уведомление и восстановление + +- Любой переход в `failed`/`stuck` **уведомляет** автора загрузки + (`notifier`), чтобы падение не оставалось незамеченным — включая приёмное + падение `qbit_add` (не удалось добавить в qBittorrent), которое идёт мимо + поллинг-цикла. Повторные падения одной задачи в пределах окна дебаунса + уведомляют лишь раз — чтобы мерцающий `stalled`-торрент + (`stuck`↔`downloading`) не спамил. +- `failed`/`stuck` из-за нашей нетерпеливости (`error_code` `magnet_timeout`/ + `stalled`) **не тупик**: фоновая сверка возвращает задачу в поток, как + только источник в qBittorrent ожил и продвинулся за условие падения + (получил метаданные → `downloading`; уже готов → `completed`). Пока торрент + всё ещё в `metaDL`/`stalledDL`, задача остаётся упавшей (без зацикливания). + Настоящие провалы (`qbit_error`) сверкой не воскрешаются. +- Дополнительно доступен **ручной retry** из веб-UI и Telegram (не только + REST): возвращает в `downloading`, перецепляясь к живому торренту без + повторного `Add`. Пути файлов берём из API (`save_path` + относительные имена из `/torrents/files`, уже включающие корневую папку торрента), не из diff --git a/internal/config/config.go b/internal/config/config.go index a3f3f6b..dea5e0a 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -181,7 +181,7 @@ func Default() *Config { Worker: Worker{ PollInterval: Duration(5 * time.Second), StuckAfter: Duration(time.Hour), - MagnetTimeout: Duration(30 * time.Minute), + MagnetTimeout: Duration(24 * time.Hour), SourceMissingThreshold: 3, }, Recognition: Recognition{AutoConfidenceThreshold: 0.85}, diff --git a/internal/httpapi/httpapi.go b/internal/httpapi/httpapi.go index 33a5155..e13c111 100644 --- a/internal/httpapi/httpapi.go +++ b/internal/httpapi/httpapi.go @@ -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), } } diff --git a/internal/httpapi/httpapi_test.go b/internal/httpapi/httpapi_test.go index 4bbb3b1..0559ebb 100644 --- a/internal/httpapi/httpapi_test.go +++ b/internal/httpapi/httpapi_test.go @@ -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)} diff --git a/internal/ingest/ingest.go b/internal/ingest/ingest.go index 3cbcb64..f4898be 100644 --- a/internal/ingest/ingest.go +++ b/internal/ingest/ingest.go @@ -18,6 +18,10 @@ import ( // capIngest — стадия приёма для поля capability в логах. const capIngest = "ingest" +// errCodeQbitAdd — error_code задачи, упавшей на добавлении источника в +// qBittorrent (раздачи в qBittorrent нет, восстановлению не подлежит). +const errCodeQbitAdd = "qbit_add" + // Store — нужная ingest часть хранилища. type Store interface { FindActiveByInfohash(ctx context.Context, infohash string) (*store.Download, error) @@ -49,6 +53,12 @@ type Service struct { namer Namer cfg Config log *slog.Logger + + // notifyFailed — опц. пинг автору о падении приёма (добавление в qBittorrent + // не удалось). Closure, а не worker.Notifier: приёмное падение в qBit не + // попадает в поллинг-цикл worker (раздачи нет), поэтому уведомляет ingest + // сам; closure избавляет ядро приёма от зависимости на пакет worker. + notifyFailed func(downloadID int64) } // New собирает сервис приёма. namer опционален (nil → отображаемое имя не @@ -57,6 +67,9 @@ func New(st Store, qb QBittorrent, namer Namer, cfg Config, log *slog.Logger) *S return &Service{store: st, qbt: qb, namer: namer, cfg: cfg, log: log} } +// SetFailureNotifier подключает пинг о падении приёма (до начала работы). +func (s *Service) SetFailureNotifier(fn func(downloadID int64)) { s.notifyFailed = fn } + // Request — входной запрос приёма. type Request struct { Source string // пока — magnet-ссылка @@ -135,8 +148,12 @@ func (s *Service) Ingest(ctx context.Context, req Request) (Result, error) { // это разные факты, не дубль. log.Error("download accept failed", "error", addErr) // Задача уже в БД — помечаем failed, чтобы worker её не подхватил. - if setErr := s.store.SetDownloadState(ctx, id, store.StateFailed, "qbit_add", addErr.Error()); setErr != nil { + if setErr := s.store.SetDownloadState(ctx, id, store.StateFailed, errCodeQbitAdd, addErr.Error()); setErr != nil { log.Error("mark download failed after qbit error failed", "error", setErr) + } else if s.notifyFailed != nil { + // Это падение минует worker.transition (раздачи в qBit нет) — уведомляем + // сами, чтобы приёмные провалы тоже доходили до автора. + go s.notifyFailed(id) } return Result{DownloadID: id, Infohash: info.Infohash, State: store.StateFailed}, fmt.Errorf("ingest: add to qbittorrent: %w", addErr) diff --git a/internal/ingest/ingest_test.go b/internal/ingest/ingest_test.go index 1ca40e8..03ea537 100644 --- a/internal/ingest/ingest_test.go +++ b/internal/ingest/ingest_test.go @@ -6,6 +6,7 @@ import ( "io" "log/slog" "testing" + "time" "git.vakhrushev.me/av/jellybit/internal/qbt" "git.vakhrushev.me/av/jellybit/internal/store" @@ -177,6 +178,26 @@ func TestIngestQbitErrorMarksFailed(t *testing.T) { } } +func TestIngestQbitErrorNotifies(t *testing.T) { + fs := &fakeStore{} + fq := &fakeQbt{err: errors.New("connection refused")} + svc := newService(fs, fq) + got := make(chan int64, 1) + svc.SetFailureNotifier(func(id int64) { got <- id }) + + if _, err := svc.Ingest(context.Background(), Request{Source: sampleMagnet}); err == nil { + t.Fatal("ожидалась ошибка") + } + select { + case id := <-got: + if id == 0 { + t.Errorf("уведомление с нулевым id") + } + case <-time.After(2 * time.Second): + t.Fatal("уведомление о падении приёма не пришло") + } +} + func TestIngestRejectsNonMagnet(t *testing.T) { fs := &fakeStore{} fq := &fakeQbt{} diff --git a/internal/store/download.go b/internal/store/download.go index 40f2b9a..1906ceb 100644 --- a/internal/store/download.go +++ b/internal/store/download.go @@ -40,10 +40,13 @@ const ( // ТОЛЬКО сюда — иначе семантика «активности» разъедется (idempotency_key // снимается по IsTerminal, а активность считалась бы по другому списку). // -// Состояния рассинхрона (target_missing/orphaned/deleted) — терминальны по -// тем же причинам, что reverted/cancelled: дальше двигает либо человек -// (relink из target_missing), либо фоновая сверка (healing/прогрессия), -// напрямую через SetDownloadState; ключ идемпотентности при этом не нужен. +// Состояния рассинхрона (target_missing/orphaned/deleted), а также +// failed/stuck — терминальны для idempotency_key, но не «мертвы»: дальше +// двигает либо человек (relink из target_missing, retry из failed/stuck), либо +// фоновая сверка (healing/прогрессия desync; авто-восстановление failed/stuck +// при оживлении источника, см. state-reconciliation) — напрямую через +// SetDownloadState, который восстановит ключ для нетерминального целевого +// состояния. var terminalStates = []State{ StateDone, StateCancelled, StateFailed, StateReverted, StateTargetMissing, StateOrphaned, StateDeleted, @@ -157,6 +160,30 @@ func (s *Store) ListDownloadsByState(ctx context.Context, states ...State) ([]Do return out, nil } +// ListRecoverable возвращает задачи в failed/stuck с одним из переданных +// error_code — кандидатов на авто-восстановление (см. state-reconciliation). +// Фильтр по коду в SQL, чтобы не вычитывать на каждом тике поллинга все +// накопленные провалы (qbit_error и пр.), которые восстановлению не подлежат. +func (s *Store) ListRecoverable(ctx context.Context, codes ...string) ([]Download, error) { + if len(codes) == 0 { + return nil, nil + } + ph := make([]string, len(codes)) + args := make([]any, 0, len(codes)+2) + for i, c := range codes { + ph[i] = "?" + args = append(args, c) + } + args = append(args, string(StateFailed), string(StateStuck)) + q := `SELECT * FROM download WHERE error_code IN (` + strings.Join(ph, ",") + + `) AND state IN (?, ?) ORDER BY id DESC` + var out []Download + if err := s.DB.SelectContext(ctx, &out, q, args...); err != nil { + return nil, fmt.Errorf("list recoverable: %w", err) + } + return out, nil +} + // FindActiveByInfohash возвращает незавершённую задачу для infohash либо // (nil, nil), если её нет. Основа идемпотентного приёма. func (s *Store) FindActiveByInfohash(ctx context.Context, infohash string) (*Download, error) { diff --git a/internal/tgbot/bot.go b/internal/tgbot/bot.go index c9232b7..53acfee 100644 --- a/internal/tgbot/bot.go +++ b/internal/tgbot/bot.go @@ -36,6 +36,7 @@ type Reviewer interface { SetType(ctx context.Context, id int64, mediaType string) error Defer(ctx context.Context, id int64) error Cancel(ctx context.Context, id int64) error + Retry(ctx context.Context, id int64) error } // Config — параметры бота. @@ -191,6 +192,9 @@ func (b *Bot) handleCallback(ctx context.Context, cq *tgbotapi.CallbackQuery) { case "reject": err = b.reviewer.Cancel(ctx, id) note = "Отклонено" + case "retry": + err = b.reviewer.Retry(ctx, id) + note = "Повторяю…" case "type": err = b.reviewer.SetType(ctx, id, val) note = "Меняю тип…" @@ -248,6 +252,8 @@ func (b *Bot) Notify(ctx context.Context, downloadID int64, event worker.NotifyE text = b.renderDone(rd) case worker.EventTargetMissing, worker.EventOrphaned: text, kb = b.renderDesync(rd, event), b.webOnly(downloadID) + case worker.EventFailed: + text, kb = b.renderFailed(rd) default: text, kb = b.renderCard(rd) } diff --git a/internal/tgbot/bot_test.go b/internal/tgbot/bot_test.go index cb4a395..1c4ad06 100644 --- a/internal/tgbot/bot_test.go +++ b/internal/tgbot/bot_test.go @@ -65,6 +65,7 @@ type fakeReviewer struct { typed map[int64]string deferred []int64 canceled []int64 + retried []int64 } func (f *fakeReviewer) ReviewData(context.Context, int64) (*worker.ReviewData, error) { @@ -96,6 +97,10 @@ func (f *fakeReviewer) Cancel(_ context.Context, id int64) error { f.canceled = append(f.canceled, id) return nil } +func (f *fakeReviewer) Retry(_ context.Context, id int64) error { + f.retried = append(f.retried, id) + return nil +} func reviewData(state store.State) *worker.ReviewData { s, e := 2, 1 @@ -252,6 +257,29 @@ func TestBot_NotifyDone(t *testing.T) { } } +func TestBot_NotifyFailed(t *testing.T) { + b, api, _, rev := newTestBot(t, []int64{7}) + rev.data = reviewData(store.StateFailed) + b.Notify(context.Background(), 5, worker.EventFailed) + + if len(api.sent) != 1 || !strings.Contains(api.sent[0].text, "не удалась") { + t.Errorf("sent = %+v", api.sent) + } + if !api.sent[0].hasKB { // кнопка повтора + t.Error("уведомление о падении без клавиатуры повтора") + } +} + +func TestBot_CallbackRetry(t *testing.T) { + b, _, _, rev := newTestBot(t, []int64{7}) + rev.data = reviewData(store.StateFailed) + b.handleCallback(context.Background(), cbFrom(7, "retry:5")) + + if len(rev.retried) != 1 || rev.retried[0] != 5 { + t.Errorf("retried = %v", rev.retried) + } +} + func TestParseCallback(t *testing.T) { a, id, v := parseCallback("type:5:series") if a != "type" || id != 5 || v != "series" { diff --git a/internal/tgbot/render.go b/internal/tgbot/render.go index bca9a2b..8ac5bf9 100644 --- a/internal/tgbot/render.go +++ b/internal/tgbot/render.go @@ -31,6 +31,10 @@ func (b *Bot) renderCard(rd *worker.ReviewData) (string, *tgbotapi.InlineKeyboar if msg := rd.Download.ErrorMsg.String; msg != "" { text += "\n" + msg } + // failed/stuck — даём кнопку повтора; остальное только «в вебе». + if state == store.StateFailed || state == store.StateStuck { + return text, b.retryKeyboard(id) + } return text, b.webOnly(id) } } @@ -111,6 +115,41 @@ func (b *Bot) renderDesync(rd *worker.ReviewData, event worker.NotifyEvent) stri } } +// renderFailed — уведомление об упавшей/зависшей задаче с кнопкой повтора. +func (b *Bot) renderFailed(rd *worker.ReviewData) (string, *tgbotapi.InlineKeyboardMarkup) { + id := rd.Download.ID + var sb strings.Builder + verb := "не удалась" + if rd.Download.State == store.StateStuck { + verb = "зависла" + } + fmt.Fprintf(&sb, "❌ Задача #%d %s", id, verb) + if code := rd.Download.ErrorCode.String; code != "" { + fmt.Fprintf(&sb, " (%s)", code) + } + sb.WriteString(".") + if msg := rd.Download.ErrorMsg.String; msg != "" { + sb.WriteString("\n") + sb.WriteString(msg) + } + if src := contextOrSource(rd); src != "" { + fmt.Fprintf(&sb, "\nИсточник: %s", shorten(src, 80)) + } + return sb.String(), b.retryKeyboard(id) +} + +// retryKeyboard — клавиатура для failed/stuck: повтор + опц. ссылка в веб. +func (b *Bot) retryKeyboard(id int64) *tgbotapi.InlineKeyboardMarkup { + row := []tgbotapi.InlineKeyboardButton{ + tgbotapi.NewInlineKeyboardButtonData("🔄 Повторить", "retry:"+itoa(id)), + } + if url := b.reviewURL(id); url != "" { + row = append(row, tgbotapi.NewInlineKeyboardButtonURL("🌐 В вебе", url)) + } + kb := tgbotapi.NewInlineKeyboardMarkup(tgbotapi.NewInlineKeyboardRow(row...)) + return &kb +} + func (b *Bot) webOnly(id int64) *tgbotapi.InlineKeyboardMarkup { url := b.reviewURL(id) if url == "" { diff --git a/internal/worker/reconcile.go b/internal/worker/reconcile.go index b02a8da..fc1c7c0 100644 --- a/internal/worker/reconcile.go +++ b/internal/worker/reconcile.go @@ -160,6 +160,96 @@ func reconcileReason(sourcePresent, targetPresent bool) string { } } +// --- Восстановление зависших загрузок (failed/stuck → поток) --- + +// reconcileRecovery воскрешает задачи, упавшие из-за нашей нетерпеливости +// (magnet_timeout/stalled), когда их источник в qBittorrent ожил и продвинулся +// за условие падения. Вызывается из Poll под w.mu. Реальные/пользовательские +// провалы (qbit_error/reverted/cancelled/deleted) сюда не попадают. +func (w *Worker) reconcileRecovery(ctx context.Context, byHash map[string]qbt.Torrent) { + cands, err := w.store.ListRecoverable(ctx, errCodeMagnetTimeout, errCodeStalled) + if err != nil { + w.log.Warn("recovery list failed", "capability", capIngest, "error", err) + return + } + for _, d := range cands { + w.reconcileOneRecovery(ctx, d, byHash) + } +} + +// reconcileOneRecovery возвращает одну зависшую задачу в поток, если её торрент +// присутствует и продвинулся за условие падения. +func (w *Worker) reconcileOneRecovery(ctx context.Context, d store.Download, byHash map[string]qbt.Torrent) { + if !d.Infohash.Valid { + return + } + t, ok := byHash[strings.ToLower(d.Infohash.String)] + if !ok { + return // источника нет — оставляем как есть (вернёт ручной retry) + } + if !torrentProgressed(d, t) { + return // торрент всё ещё в metaDL/stalledDL или ошибочен — не воскрешаем + } + want := recoveredState(t.State) + if want == "" { + return // переходное состояние qBit (moving/checking) — ждём + } + ctx = w.scoped(ctx, capIngest, d.ID, d.Infohash.String) + + // Конфликт идемпотентности: пока задача лежала в failed, тот же infohash мог + // взять другая активная задача (idempotency_key снят при падении). Оба + // целевых состояния (downloading/completed) нетерминальны → SetDownloadState + // восстановит idempotency_key = infohash; при занятом ключе упёрлись бы в + // unique-индекс. Поэтому проверяем владельца независимо от целевого состояния + // и оставляем старую задачу в failed. + other, err := w.store.FindActiveByInfohash(ctx, d.Infohash.String) + if err != nil { + logctx.From(ctx).Warn("recovery active lookup failed", "error", err) + return + } + if other != nil && other.ID != d.ID { + logctx.From(ctx).Info("recovery skipped, infohash taken by active download", + "conflict_download_id", other.ID) + return + } + // error_code/error_msg не пишем — задача снова здорова; причину в лог, а не в + // поле ошибки (иначе она светилась бы в UI/REST как ошибка живой задачи). + logctx.From(ctx).Info("recovery from failure", "to", want, "qbit_state", t.State) + w.transition(ctx, d, want, "", "") +} + +// torrentProgressed сообщает, продвинулся ли торрент за условие, по которому +// задача упала: для magnet_timeout — получил метаданные (вышел из metaDL); для +// stalled — раздача ожила (вышла из stalledDL). Ошибочные состояния qBittorrent +// продвижением не считаем (их ведёт обычный reconcile в qbit_error). +func torrentProgressed(d store.Download, t qbt.Torrent) bool { + if classify(t.State) == classErrored { + return false + } + switch d.ErrorCode.String { + case errCodeMagnetTimeout: + return !isMeta(t.State) + case errCodeStalled: + return !isStalledDL(t.State) + default: + return false + } +} + +// recoveredState выводит состояние воскрешённой задачи из состояния торрента: +// готов к раскладке → completed; ещё качается → downloading. Переходные +// (moving/checking) и ошибочные состояния не восстанавливаем (пусто). +func recoveredState(state string) store.State { + switch classify(state) { + case classReady: + return store.StateCompleted + case classDownloading: + return store.StateDownloading + default: + return "" + } +} + // --- Синхронный preflight перед действием (не доверяем state в БД) --- // ensureSourcePresent синхронно (без дебаунса) проверяет, что раздача есть в diff --git a/internal/worker/recovery_test.go b/internal/worker/recovery_test.go new file mode 100644 index 0000000..cebb193 --- /dev/null +++ b/internal/worker/recovery_test.go @@ -0,0 +1,170 @@ +package worker + +import ( + "context" + "testing" + "time" + + "git.vakhrushev.me/av/jellybit/internal/qbt" + "git.vakhrushev.me/av/jellybit/internal/store" +) + +// addedRecent — added_on торрента «минуту назад» относительно зафиксированного +// в newTestWorker now (2026-06-14 10:00:00 UTC). +var addedRecent = time.Date(2026, 6, 14, 9, 59, 0, 0, time.UTC).Unix() + +func oneFailed(state store.State, code, infohash, createdAt string) *fakeStore { + return &fakeStore{downloads: map[int64]*store.Download{ + 1: { + ID: 1, + State: state, + SourceType: store.SourceMagnet, + SourceRef: "magnet:?xt=urn:btih:" + infohash, + Infohash: store.NullString(infohash), + ErrorCode: store.NullString(code), + CreatedAt: createdAt, + }, + }} +} + +func TestRecovery(t *testing.T) { + const ih = "541adcff3b6dd5dba7088ea83317d9d6fac331d6" + tests := []struct { + name string + state store.State + code string + qbitState string + want store.State + }{ + {"метаданные пришли → downloading", store.StateFailed, errCodeMagnetTimeout, "downloading", store.StateDownloading}, + {"торрент готов → completed", store.StateFailed, errCodeMagnetTimeout, "uploading", store.StateCompleted}, + {"всё ещё metaDL → остаётся failed", store.StateFailed, errCodeMagnetTimeout, "metaDL", store.StateFailed}, + {"stalled ожил → downloading", store.StateStuck, errCodeStalled, "downloading", store.StateDownloading}, + {"stalled всё ещё stalledDL → остаётся stuck", store.StateStuck, errCodeStalled, "stalledDL", store.StateStuck}, + {"qbit_error не восстанавливается", store.StateFailed, errCodeQbitError, "downloading", store.StateFailed}, + {"ошибка торрента не восстанавливает", store.StateFailed, errCodeMagnetTimeout, "error", store.StateFailed}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + st := oneFailed(tc.state, tc.code, ih, timeOld) + qb := &fakeQbt{torrents: []qbt.Torrent{{Hash: ih, State: tc.qbitState, AddedOn: addedRecent}}} + w := newTestWorker(st, qb) + if err := w.Poll(context.Background()); err != nil { + t.Fatalf("Poll: %v", err) + } + if got := st.downloads[1].State; got != tc.want { + t.Errorf("state = %q, want %q", got, tc.want) + } + }) + } +} + +// Источник пропал (торрента нет в qBittorrent) — задача остаётся failed, +// воскрешать нечего (вернёт ручной retry). +func TestRecoveryNoSourceStaysFailed(t *testing.T) { + const ih = "541adcff3b6dd5dba7088ea83317d9d6fac331d6" + st := oneFailed(store.StateFailed, errCodeMagnetTimeout, ih, timeOld) + w := newTestWorker(st, &fakeQbt{torrents: nil}) + if err := w.Poll(context.Background()); err != nil { + t.Fatal(err) + } + if st.downloads[1].State != store.StateFailed { + t.Errorf("без источника задача должна остаться failed, got %q", st.downloads[1].State) + } +} + +// Конфликт идемпотентности: тот же infohash уже взяла другая активная задача — +// упавшую не воскрешаем (иначе нарушим «одна активная задача на infohash»). +func TestRecoverySkipsOnIdempotencyConflict(t *testing.T) { + const ih = "541adcff3b6dd5dba7088ea83317d9d6fac331d6" + st := oneFailed(store.StateFailed, errCodeMagnetTimeout, ih, timeOld) + st.downloads[2] = &store.Download{ + ID: 2, + State: store.StateDownloading, + SourceType: store.SourceMagnet, + Infohash: store.NullString(ih), + CreatedAt: timeRecent, + } + qb := &fakeQbt{torrents: []qbt.Torrent{{Hash: ih, State: "downloading", AddedOn: addedRecent}}} + w := newTestWorker(st, qb) + if err := w.Poll(context.Background()); err != nil { + t.Fatal(err) + } + if st.downloads[1].State != store.StateFailed { + t.Errorf("при конфликте ключа задача #1 должна остаться failed, got %q", st.downloads[1].State) + } +} + +// Тот же конфликт ключа, но торрент уже готов (recovery хочет completed): +// completed тоже нетерминален и восстановил бы idempotency_key — проверка +// конфликта обязана покрывать и эту ветку. +func TestRecoverySkipsConflictOnCompleted(t *testing.T) { + const ih = "541adcff3b6dd5dba7088ea83317d9d6fac331d6" + st := oneFailed(store.StateFailed, errCodeMagnetTimeout, ih, timeOld) + st.downloads[2] = &store.Download{ + ID: 2, + State: store.StateDownloading, + SourceType: store.SourceMagnet, + Infohash: store.NullString(ih), + CreatedAt: timeRecent, + } + qb := &fakeQbt{torrents: []qbt.Torrent{{Hash: ih, State: "uploading", AddedOn: addedRecent}}} + w := newTestWorker(st, qb) + if err := w.Poll(context.Background()); err != nil { + t.Fatal(err) + } + if st.downloads[1].State != store.StateFailed { + t.Errorf("при конфликте ключа задача #1 не должна уходить в completed, got %q", st.downloads[1].State) + } +} + +// Повторное падение одной задачи в пределах окна дебаунса шлёт уведомление лишь +// раз (защита от спама при флаппинге stuck↔downloading). +func TestFailNotifyDebounce(t *testing.T) { + const ih = "541adcff3b6dd5dba7088ea83317d9d6fac331d6" + st := oneFailed(store.StateStuck, errCodeStalled, ih, timeOld) + w := newTestWorker(st, &fakeQbt{}) + n := &recordingNotifier{ch: make(chan notifyEvent, 4)} + w.SetNotifier(n) + d := *st.downloads[1] + + w.transition(context.Background(), d, store.StateStuck, errCodeStalled, "") + if e := waitNotify(t, n); e.ev != EventFailed { + t.Fatalf("первый пинг: ev=%v, want failed", e.ev) + } + // Второе падение при том же w.now() — в пределах дебаунса, без пинга. + w.transition(context.Background(), d, store.StateStuck, errCodeStalled, "") + select { + case e := <-n.ch: + t.Fatalf("повторный пинг в пределах дебаунса не ожидался: %+v", e) + case <-time.After(200 * time.Millisecond): + } +} + +// Retry при живом торренте перецепляется к нему (без повторного Add) и не падает +// снова на ближайшем тике: базис таймаута берётся от added_on, а не от старого +// created_at. +func TestRetryReattachesNoReadd(t *testing.T) { + const ih = "541adcff3b6dd5dba7088ea83317d9d6fac331d6" + st := oneFailed(store.StateFailed, errCodeMagnetTimeout, ih, timeOld) + // Торрент жив, всё ещё тянет метаданные, но добавлен только что (added_on). + qb := &fakeQbt{torrents: []qbt.Torrent{{Hash: ih, State: "metaDL", AddedOn: addedRecent}}} + w := newTestWorker(st, qb) + + if err := w.Retry(context.Background(), 1); err != nil { + t.Fatalf("Retry: %v", err) + } + if st.downloads[1].State != store.StateDownloading { + t.Fatalf("после retry ожидался downloading, got %q", st.downloads[1].State) + } + if len(qb.added) != 0 { + t.Errorf("живой торрент не должен добавляться повторно, got %d Add", len(qb.added)) + } + // Ближайший тик: metaDL свежий (added_on минуту назад) — не падает по таймауту. + if err := w.Poll(context.Background()); err != nil { + t.Fatal(err) + } + if st.downloads[1].State != store.StateDownloading { + t.Errorf("свежий metaDL не должен падать после retry, got %q", st.downloads[1].State) + } +} diff --git a/internal/worker/review_test.go b/internal/worker/review_test.go index 16fcd46..beeded2 100644 --- a/internal/worker/review_test.go +++ b/internal/worker/review_test.go @@ -250,6 +250,22 @@ func (m *memStore) ListDownloadsByState(_ context.Context, states ...store.State return out, nil } +func (m *memStore) ListRecoverable(_ context.Context, codes ...string) ([]store.Download, error) { + var out []store.Download + for _, d := range m.downloads { + if d.State != store.StateFailed && d.State != store.StateStuck { + continue + } + for _, c := range codes { + if d.ErrorCode.Valid && d.ErrorCode.String == c { + out = append(out, *d) + break + } + } + } + return out, nil +} + func (m *memStore) ExistsByInfohash(_ context.Context, infohash string) (bool, error) { for _, d := range m.downloads { if d.Infohash.Valid && d.Infohash.String == infohash { diff --git a/internal/worker/worker.go b/internal/worker/worker.go index 596441e..bf6e1fe 100644 --- a/internal/worker/worker.go +++ b/internal/worker/worker.go @@ -38,6 +38,7 @@ const ( // Store — нужная worker часть хранилища. type Store interface { ListDownloadsByState(ctx context.Context, states ...store.State) ([]store.Download, error) + ListRecoverable(ctx context.Context, codes ...string) ([]store.Download, error) GetDownload(ctx context.Context, id int64) (*store.Download, error) SetDownloadState(ctx context.Context, id int64, state store.State, errCode, errMsg string) error SetSourceMissCount(ctx context.Context, id int64, n int) error @@ -94,6 +95,17 @@ const ( EventDone NotifyEvent = "done" // раскладка завершена EventOrphaned NotifyEvent = "orphaned" // источник пропал, цель — последняя копия EventTargetMissing NotifyEvent = "target_missing" // цель удалена, доступен relink + EventFailed NotifyEvent = "failed" // задача упала/зависла (failed/stuck) +) + +// Коды ошибок (error_code) при переходе в failed/stuck. Восстановимые +// (magnet_timeout/stalled) — следствие нашей нетерпеливости: сверка воскрешает +// такие задачи при оживлении источника (см. reconcileRecovery). qbit_error — +// реальная ошибка qBittorrent, восстановлению не подлежит. +const ( + errCodeMagnetTimeout = "magnet_timeout" + errCodeStalled = "stalled" + errCodeQbitError = "qbit_error" ) // Notifier — исходящие пинги (Telegram). Вызывается неблокирующе. @@ -136,8 +148,18 @@ type Worker struct { newID func() string // генератор apply_batch_id (подменяется в тестах) notifier Notifier // опц. исходящие пинги scanner Scanner // опц. пересканирование Jellyfin + + // failNotified — дебаунс повторных EventFailed по задаче (download_id → + // время последнего пинга). Мерцающий stalled-торрент колеблется + // stuck↔downloading; без дебаунса каждый цикл слал бы уведомление. Память + // процесса: при рестарте дебаунс сбрасывается — допустимо. Доступ под w.mu. + failNotified map[int64]time.Time } +// failNotifyDebounce — минимальный интервал между уведомлениями о падении +// одной задачи (см. failNotified). +const failNotifyDebounce = time.Hour + // SetNotifier подключает исходящие пинги (до запуска Run). func (w *Worker) SetNotifier(n Notifier) { w.notifier = n } @@ -148,14 +170,15 @@ func (w *Worker) SetScanner(s Scanner) { w.scanner = s } // распознавания и раскладки) — тогда completed-задачи не двигаются дальше. func New(st Store, qb QBittorrent, rec Recognizer, lay Layouter, cfg Config, log *slog.Logger) *Worker { return &Worker{ - store: st, - qbt: qb, - recognizer: rec, - layouter: lay, - cfg: cfg, - log: log, - now: time.Now, - newID: defaultBatchID, + store: st, + qbt: qb, + recognizer: rec, + layouter: lay, + cfg: cfg, + log: log, + now: time.Now, + newID: defaultBatchID, + failNotified: map[int64]time.Time{}, } } @@ -246,6 +269,10 @@ func (w *Worker) Poll(ctx context.Context) error { // Сверка разложенных задач с реальностью (источник в qBit + хардлинки на ФС) // — отдельно от активных, по двумерной матрице (см. state-reconciliation). w.reconcileDesync(ctx, byHash) + + // Восстановление задач, упавших по нашей нетерпеливости (magnet_timeout/ + // stalled), если их источник в qBittorrent ожил и продвинулся. + w.reconcileRecovery(ctx, byHash) return nil } @@ -257,7 +284,7 @@ func (w *Worker) reconcile(ctx context.Context, d store.Download, t qbt.Torrent) case classReady: w.transition(ctx, d, store.StateCompleted, "", "") case classErrored: - w.transition(ctx, d, store.StateFailed, "qbit_error", "qBittorrent state: "+t.State) + w.transition(ctx, d, store.StateFailed, errCodeQbitError, "qBittorrent state: "+t.State) case classDownloading: w.checkTimeouts(ctx, d, t) case classBusy: @@ -265,26 +292,43 @@ func (w *Worker) reconcile(ctx context.Context, d store.Download, t qbt.Torrent) } } -// checkTimeouts помечает зависшие задачи. Возраст считаем от created_at: -// для metaDL это время с момента добавления (огрублённо, но достаточно). +// checkTimeouts помечает зависшие задачи. Возраст считаем от факта в +// qBittorrent (added_on), а не от created_at: базис переживает retry и +// усыновление раздачи (см. design download-failure-recovery). magnet_timeout — +// редкий страховочный предохранитель (дефолт 24h); настоящие провалы ловит +// classErrored, а ожившие задачи воскрешает reconcileRecovery. func (w *Worker) checkTimeouts(ctx context.Context, d store.Download, t qbt.Torrent) { - created, err := d.CreatedTime() - if err != nil { - logctx.From(ctx).Warn("cannot parse created_at", "value", d.CreatedAt, "error", err) - return - } - age := w.now().Sub(created) + age := w.torrentAge(d, t) switch { case isMeta(t.State) && w.cfg.MagnetTimeout > 0 && age > w.cfg.MagnetTimeout: - w.transition(ctx, d, store.StateFailed, "magnet_timeout", + w.transition(ctx, d, store.StateFailed, errCodeMagnetTimeout, fmt.Sprintf("no metadata after %s", age.Truncate(time.Second))) case isStalledDL(t.State) && w.cfg.StuckAfter > 0 && age > w.cfg.StuckAfter: - w.transition(ctx, d, store.StateStuck, "stalled", + w.transition(ctx, d, store.StateStuck, errCodeStalled, fmt.Sprintf("stalled for %s", age.Truncate(time.Second))) } } +// torrentAge — возраст торрента: от added_on в qBittorrent (надёжный базис, +// переживает retry/усыновление), с фолбэком на created_at задачи, если qBit не +// отдал added_on. +func (w *Worker) torrentAge(d store.Download, t qbt.Torrent) time.Duration { + if t.AddedOn > 0 { + return w.now().Sub(time.Unix(t.AddedOn, 0).UTC()) + } + created, err := d.CreatedTime() + if err != nil { + // Ни added_on от qBit, ни разбираемого created_at — возраст неизвестен, + // таймауты не сработают; фиксируем диагностикой. + w.log.Warn("cannot determine torrent age", + "capability", capIngest, "download_id", d.ID, + "created_at", d.CreatedAt, "error", err) + return 0 + } + return w.now().Sub(created) +} + // transition пишет новое состояние и логирует переход. func (w *Worker) transition(ctx context.Context, d store.Download, state store.State, code, msg string) { // FromOr, а не From: если вызывающий не завёл scoped-логгер, падаем на @@ -308,6 +352,10 @@ func (w *Worker) transition(ctx context.Context, d store.Download, state store.S go w.notifier.Notify(context.Background(), d.ID, EventOrphaned) case store.StateTargetMissing: go w.notifier.Notify(context.Background(), d.ID, EventTargetMissing) + case store.StateFailed, store.StateStuck: + if w.shouldNotifyFail(d.ID) { + go w.notifier.Notify(context.Background(), d.ID, EventFailed) + } } } @@ -323,6 +371,25 @@ func (w *Worker) transition(ctx context.Context, d store.Download, state store.S } } +// shouldNotifyFail дебаунсит повторные уведомления о падении одной задачи +// (мерцающий stalled-торрент: stuck↔downloading), чтобы не спамить. Вызывается +// под w.mu. НЕ сбрасываем запись при восстановлении — иначе дебаунс не гасил бы +// флаппинг. +func (w *Worker) shouldNotifyFail(id int64) bool { + now := w.now() + if last, ok := w.failNotified[id]; ok && now.Sub(last) < failNotifyDebounce { + return false + } + w.failNotified[id] = now + // Лёгкая чистка устаревших записей, чтобы карта не росла без предела. + for k, t := range w.failNotified { + if now.Sub(t) >= failNotifyDebounce { + delete(w.failNotified, k) + } + } + return true +} + // Cancel отклоняет задачу. Торрент в qBittorrent не трогаем — он продолжает // раздачу (источник неприкосновенен). func (w *Worker) Cancel(ctx context.Context, id int64) error { @@ -356,7 +423,18 @@ func (w *Worker) Retry(ctx context.Context, id int64) error { if d.State != store.StateFailed && d.State != store.StateStuck { return fmt.Errorf("retry: download %d is %s, only failed/stuck are retriable", id, d.State) } - if d.SourceType == store.SourceMagnet { + // Если раздача уже жива в qBittorrent — перецепляемся к ней, повторный Add + // не нужен (и вреден: вслепую дублировал бы торрент). Add — только когда + // источника в qBittorrent нет. Базис таймаута берётся от added_on, поэтому + // возврат в downloading не роняет задачу снова на ближайшем тике. + alive := false + if d.Infohash.Valid { + _, alive, err = w.torrentByInfohash(ctx, d.Infohash.String) + if err != nil { + return fmt.Errorf("retry: %w", err) + } + } + if !alive && d.SourceType == store.SourceMagnet { if err := w.qbt.Add(ctx, qbt.AddRequest{ URLs: []string{d.SourceRef}, Category: w.cfg.Category, diff --git a/internal/worker/worker_test.go b/internal/worker/worker_test.go index 03b50dc..d8a2c00 100644 --- a/internal/worker/worker_test.go +++ b/internal/worker/worker_test.go @@ -42,6 +42,22 @@ func (f *fakeStore) ListDownloadsByState(_ context.Context, states ...store.Stat return out, nil } +func (f *fakeStore) ListRecoverable(_ context.Context, codes ...string) ([]store.Download, error) { + var out []store.Download + for _, d := range f.downloads { + if d.State != store.StateFailed && d.State != store.StateStuck { + continue + } + for _, c := range codes { + if d.ErrorCode.Valid && d.ErrorCode.String == c { + out = append(out, *d) + break + } + } + } + return out, nil +} + func (f *fakeStore) GetDownload(_ context.Context, id int64) (*store.Download, error) { d, ok := f.downloads[id] if !ok { diff --git a/openspec/changes/archive/2026-06-30-download-failure-recovery/.openspec.yaml b/openspec/changes/archive/2026-06-30-download-failure-recovery/.openspec.yaml new file mode 100644 index 0000000..d6b53de --- /dev/null +++ b/openspec/changes/archive/2026-06-30-download-failure-recovery/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-06-30 diff --git a/openspec/changes/archive/2026-06-30-download-failure-recovery/design.md b/openspec/changes/archive/2026-06-30-download-failure-recovery/design.md new file mode 100644 index 0000000..8ee06b4 --- /dev/null +++ b/openspec/changes/archive/2026-06-30-download-failure-recovery/design.md @@ -0,0 +1,158 @@ +## Context + +Машина состояний загрузок живёт в `internal/worker/worker.go` +(`Poll`/`reconcile`/`checkTimeouts`/`transition`/`Retry`), список состояний и +терминальность — в `internal/store/download.go`. Граф переходов описан в +`docs/specs/workflow.md` (ещё не мигрирован в OpenSpec — он остаётся +источником истины по жизненному циклу). Сверка реальности с БД (capability +`state-reconciliation`) реализована в `reconcileDesync` и уже исключает +`failed`/`stuck`. + +Текущее поведение, породившее инцидент: + +- `checkTimeouts` (worker.go:268-285) меряет возраст задачи от `created_at` и + при `metaDL` дольше `magnet_timeout` гонит в `failed`/`magnet_timeout`. + Дефолт `magnet_timeout` = 30m (`config.go:184`), но для долгих magnet это + слишком агрессивно. +- `failed`/`stuck` терминальны, выхода нет; `transition` уведомляет только + `review`/`done`/`orphaned`/`target_missing` (worker.go:301-312) — падение + молчит. +- `Worker.Retry` (worker.go:348-373) возвращает в `downloading`, но базис + таймаута (`created_at`) не меняется → `checkTimeouts` роняет задачу снова + на ближайшем тике; retry экспонирован только в REST. + +`qbt.Torrent` уже содержит `AddedOn` (unix, секунды) — время добавления +торрента в qBittorrent. + +## Goals / Non-Goals + +**Goals:** + +- Долгий `metaDL` не убивается агрессивно; `magnet_timeout` — редкий + страховочный предохранитель (дефолт 24h), а не рабочий механизм. +- Корректный базис таймаута — от факта в qBittorrent (`added_on`), не от + `created_at`. +- Любой переход в `failed`/`stuck` уведомляет автора. +- Авто-восстановление задач, упавших по нашей нетерпеливости + (`magnet_timeout`/`stalled`), когда источник в qBittorrent ожил и + продвинулся. +- Ручной retry в веб-UI и Telegram; перецепление к живому торренту вместо + слепого повторного `Add`. + +**Non-Goals:** + +- Не воскрешаем реальные/пользовательские провалы: `qbit_error`, `reverted`, + `cancelled`, `deleted`. +- Не трогаем сам торрент в qBittorrent при падении (источник + неприкосновенен). +- Без миграций БД и без новых внешних зависимостей. +- Не вводим отдельную capability `notifications` — преждевременно. + +## Decisions + +### 1. Базис таймаута — `added_on`, а не `created_at` + +`checkTimeouts` считает `age = now - torrent.AddedOn` (UTC). Это чинит +неверный отсчёт для усыновлённых раздач и — главное — делает retry/восстановление +устойчивым: после возврата в `downloading` базис не сбрасывается в «сейчас», +он привязан к реальному возрасту торрента. Отдельный сброс `created_at` при +Retry больше не нужен. + +*Альтернатива:* хранить «время входа в metaDL» отдельным полем БД — точнее, +но требует миграции и записи на каждый тик. `added_on` достаточно (огрубление +в большую сторону безопасно при 24h-предохранителе). + +### 2. Дефолт `magnet_timeout` → 24h + +Меняем дефолт в `config.go` и `config.example.toml`. Реальные провалы ловятся +классом `classErrored` (`error`/`missingFiles` → `qbit_error`) — это уже +работает и не зависит от wall-clock. У qBittorrent нет статуса «magnet мёртв», +поэтому большой страховочный таймаут — единственный сигнал на безнадёжный +magnet. + +### 3. Уведомление о падении + +В `transition` добавляем ветки для `StateFailed` и `StateStuck` → новый +`EventFailed`. Сообщение в `notifier` (tgbot/httpapi) читает состояние и +`error_code` задачи и формирует текст. Один `Event` на оба состояния — +дробить на `EventStuck` смысла нет (различие видно из `error_code`). + +### 4. Авто-восстановление в сверке + +Отдельный проход `reconcileRecovery` (рядом с `reconcileDesync`, под `w.mu`, +из `Poll`): берём задачи в `failed`/`stuck` с восстановимым `error_code` +(`magnet_timeout`/`stalled`), находим их торрент в уже построенном индексе +`byHash`. Воскрешаем **только если торрент продвинулся за условие падения**: + +- `magnet_timeout`: восстанавливаем, когда `!isMeta(state)` и не `classErrored` + (метаданные получены); +- `stalled`: восстанавливаем, когда `!isStalledDL(state)` и не `classErrored` + (раздача ожила). + +Иначе (торрент всё ещё в `metaDL`/`stalledDL`, либо отсутствует) — оставляем +как есть. Это **критично против зацикливания**: при 24h-предохранителе мёртвый +magnet, упавший по таймауту, остаётся в `metaDL`; без проверки прогресса +восстановление вернуло бы его в `downloading`, и он падал бы снова каждые 24ч. + +Целевое состояние выводим из `classify(state)`: `classReady` → `completed`, +`classDownloading` → `downloading`. При возврате в `downloading` +восстанавливаем `idempotency_key = infohash` (нужен метод стора, т.к. +`SetDownloadState` его при терминальном переходе снимает), чтобы повторный +приём снова дедуплицировался. + +*Альтернатива:* расширить `reconcileDesync` матрицей «источник × цель». Не +подходит: у `failed`/`stuck` нет разложенной цели, ось другая (прогресс +источника), отдельный проход чище. + +### 5. Починка `Worker.Retry` + кнопки в UI/Telegram + +`Retry`: если торрент задачи уже есть в qBittorrent (живой) — не делаем +повторный `Add`, только переводим в `downloading` и восстанавливаем +`idempotency_key`; `Add` выполняем, только когда раздачи нет. Базис таймаута +теперь `added_on`, поэтому немедленного повторного падения нет (корень бага +устранён решением 1). В `internal/httpapi` (веб-UI) и `internal/tgbot` +добавляем действие retry рядом с существующим Cancel, вызывающее тот же +`Worker.Retry`. + +### 6. `error_code` в именованные константы + +Строки `"magnet_timeout"`, `"stalled"`, `"qbit_error"`, `"qbit_add"` выносим в +именованные константы (рядом с состояниями в `store` или в `worker`), чтобы +проверка восстановимости (`magnet_timeout`/`stalled`) и присвоение не +расходились по литералам. Требует, чтобы `error_code` задачи был доступен из +`store.Download` (проверить наличие поля; при отсутствии — добавить чтение, +без миграции, столбец уже есть). + +## Risks / Trade-offs + +- **Флаппинг уведомлений** (fail → восстановление → fail) → при дефолте 24h и + условии «торрент продвинулся» падение и воскрешение редки; повторный fail + возможен только если раздача снова реально застрянет. Доп. дебаунс не + вводим — усложнение без явной нужды. +- **Базис `added_on` огрубляет** (re-add торрента сбрасывает возраст) → при + 24h-предохранителе и авто-восстановлении это не приводит к ложным провалам; + ранее проблема была в 30m-агрессии, которую и убираем. +- **`magnet_timeout` всё ещё может ложно сработать** на очень медленном, но + живом magnet (>24h до метаданных) → теперь это не тупик: уведомление + при + получении метаданных авто-восстановление вернёт задачу, плюс есть ручной + retry. +- **`idempotency_key` восстановление** при воскрешении: если за время в + `failed` пользователь успел повторно принять тот же infohash и завести + новую задачу, ключ уже занят. Обрабатываем как конфликт (не воскрешаем + старую либо логируем и оставляем в failed) — уточнить в реализации. + +## Migration Plan + +Изменение поведения + конфига, без миграций БД и без слома API. Деплой — +обычный (новый бинарь на umbar). Дефолт `magnet_timeout` меняется; явное +значение в существующем `config.toml` сохраняет поведение пользователя. +Откат — предыдущий бинарь; данные совместимы. + +## Open Questions + +Решены на ревью дизайна: + +- **Занятый `idempotency_key` при авто-восстановлении** → старую задачу не + воскрешаем, оставляем в `failed` и логируем конфликт. +- **Уведомление об успешном авто-восстановлении** → не шлём, достаточно + лога. diff --git a/openspec/changes/archive/2026-06-30-download-failure-recovery/proposal.md b/openspec/changes/archive/2026-06-30-download-failure-recovery/proposal.md new file mode 100644 index 0000000..505c312 --- /dev/null +++ b/openspec/changes/archive/2026-06-30-download-failure-recovery/proposal.md @@ -0,0 +1,75 @@ +## Why + +Загрузка magnet'ом ушла в терминальный `failed`/`magnet_timeout` по +wall-clock таймауту (возраст от `created_at`, ~1ч), хотя qBittorrent просто +долго тянул метаданные (медленные трекеры / мало пиров). Метаданные в итоге +пришли, торрент жив и качается, но задача застряла в терминальном состоянии +без выхода — восстановить её нельзя. Вдобавок падение происходит молча (нет +уведомления автору), а единственный путь возврата `Worker.Retry` баговый +(не сбрасывает базис времени → задача мгновенно снова падает) и доступен +только через REST, но не из веб-UI и Telegram. + +## What Changes + +- **Терпеливость к `metaDL`.** Перестаём убивать долгий magnet агрессивным + таймаутом. Дефолт `[worker].magnet_timeout` поднимается до `24h` — это + редкий страховочный предохранитель, а не рабочий механизм. Настоящие + провалы определяются по статусам ошибок qBittorrent (`error`/`missingFiles` + → `qbit_error`), а не по wall-clock. *(У qBittorrent нет статуса «magnet + мёртв» — зависший magnet вечно висит в `metaDL`, поэтому единственный + сигнал на этот кейс — большой страховочный таймаут.)* +- **Базис таймаута — от факта, а не от `created_at`.** Возраст для + `magnet_timeout`/`stalled` считаем от времени добавления торрента в + qBittorrent (`added_on`), а не от создания записи. Это чинит неверный + отсчёт для усыновлённых раздач и устраняет мгновенное повторное падение + после возврата в `downloading`. +- **Уведомление о любом падении.** Переход в `failed` (любой `error_code`) + и `stuck` уведомляет автора загрузки через `notifier` (раньше уведомления + слались только для `review`/`done`/`orphaned`/`target_missing`). +- **Авто-восстановление из `failed`/`stuck`.** Фоновая сверка + (state-reconciliation) замечает, что у задачи в восстановимом + `failed`/`stuck` (наша нетерпеливость: `magnet_timeout`, `stalled`) + источник в qBittorrent жив и продвинулся, и возвращает задачу в поток + (`downloading` либо `completed` по статусу торрента). Пользовательские + и реальные провалы (`qbit_error`, `reverted`, `cancelled`) сверка не + воскрешает. +- **Ручной retry из UI и Telegram.** Кнопка повторной попытки добавляется в + веб-UI и Telegram-бот (раньше — только Cancel; retry был только в REST). + `Worker.Retry` чинится: перецепляется к уже живому торренту вместо слепого + повторного `Add`, базис таймаута сбрасывается. + +## Capabilities + +### New Capabilities + +Нет. Уведомления о падении и семантика таймаута относятся к жизненному циклу +загрузки, который пока живёт в `docs/specs/workflow.md` (ещё не мигрирован в +OpenSpec); заводить отдельную capability `notifications` сейчас — +преждевременное дробление. + +### Modified Capabilities + +- `state-reconciliation`: восстановимые `failed`/`stuck` (`magnet_timeout`, + `stalled`) перестают быть «неприкосновенными» для сверки и подлежат + авто-восстановлению при живом продвинувшемся источнике; добавляется + требование о восстановлении и о доступности ручного retry. `qbit_error`, + `reverted`, `cancelled`, `deleted` остаются вне восстановления. + +## Impact + +- **Спеки:** дельта `state-reconciliation`; обновление графа переходов и + семантики таймаута/уведомлений в `docs/specs/workflow.md` (источник истины + по жизненному циклу до миграции). +- **Конфиг:** дефолт `[worker].magnet_timeout` → `24h` + (`internal/config/config.go`), `config.example.toml`. +- **Код:** `internal/worker/worker.go` — `transition` (уведомление о + failed/stuck), `checkTimeouts` (базис от `added_on`), `reconcile`/ + `reconcileDesync` (воскрешение из failed/stuck), `Retry` (перецепление + + сброс базиса); новый `Event` падения и его обработка в `notifier`/ + `tgbot`/`httpapi`; кнопка retry в `internal/httpapi` и `internal/tgbot`; + вынос строки `"magnet_timeout"` (и смежных `error_code`) в именованные + константы рядом с состояниями. +- **qBittorrent-клиент:** возможно потребуется поле `added_on` в + `qbt.Torrent` (если ещё не читается). +- **Миграции БД:** не ожидаются (восстановление опирается на состояние qBit и + существующие поля задачи). diff --git a/openspec/changes/archive/2026-06-30-download-failure-recovery/specs/state-reconciliation/spec.md b/openspec/changes/archive/2026-06-30-download-failure-recovery/specs/state-reconciliation/spec.md new file mode 100644 index 0000000..273fdcd --- /dev/null +++ b/openspec/changes/archive/2026-06-30-download-failure-recovery/specs/state-reconciliation/spec.md @@ -0,0 +1,141 @@ +## MODIFIED Requirements + +### Requirement: Периодическая сверка состояния с реальностью + +`worker` SHALL периодически (на тике поллинга) сверять задачи, для которых +ожидаются разложенные файлы, с фактом на файловой системе и в qBittorrent, и +выводить состояние задачи из двух независимых признаков: присутствия +**источника** (раздача с `download.infohash` в выдаче qBittorrent) и +присутствия **цели** (см. требование о владении целевым путём: существуют все +ссылки последнего батча со статусом раскладки, всё ещё принадлежащие этой +загрузке). + +Сверке по матрице «источник × цель» SHALL подвергаться состояния `done`, +`target_missing`, `orphaned`. Состояние `deleted` сверка трогать SHALL NOT — +оно терминально. Активные (`downloading`/`recognizing`/`review`/`deferred`/ +`linking`) и пользовательски-терминальные (`reverted`/`cancelled`) состояния +сверка по матрице трогать SHALL NOT. + +**Восстановимые** `failed`/`stuck` (с `error_code` `magnet_timeout` или +`stalled` — задержки, вызванные нашей нетерпеливостью, а не реальной ошибкой) +сверка SHALL рассматривать отдельно — на предмет оживления источника (см. +требование о восстановлении зависшей загрузки), не по матрице «источник × +цель». Прочие `failed` (например `qbit_error`) сверка трогать SHALL NOT. + +Состояние SHALL переписываться только при его изменении (без записи и логов, +когда выведенное состояние совпадает с текущим). + +#### Scenario: Источник и цель на месте — состояние не меняется + +- **WHEN** для задачи в `done` раздача присутствует в qBittorrent и все её + разложенные хардлинки существуют +- **THEN** задача остаётся в `done` +- **AND** запись состояния и лог перехода не выполняются + +#### Scenario: Частичная пропажа цели считается отсутствием + +- **WHEN** часть разложенных хардлинков задачи удалена, а источник на месте +- **THEN** цель считается отсутствующей и задача переходит в `target_missing` + +#### Scenario: Задача в deleted сверкой не переоценивается + +- **WHEN** задача находится в `deleted` +- **THEN** сверка её не рассматривает и состояние не меняет, даже если по её + бывшему пути появился файл другой загрузки + +#### Scenario: Провал по ошибке qBittorrent восстановлению не подлежит + +- **WHEN** задача в `failed` с `error_code` `qbit_error` +- **THEN** сверка её не рассматривает и состояние не меняет + +## ADDED Requirements + +### Requirement: Восстановление зависшей загрузки при оживлении источника + +Система SHALL возвращать в активный поток задачу, упавшую из-за нашей +нетерпеливости (`failed`/`magnet_timeout` или `stuck`/`stalled`), если её +источник в qBittorrent жив и продвинулся: переход выводится из текущего +состояния торрента так же, как при штатной сверке загрузки +(`uploading`/`stalledUP`/… → `completed`; `downloading`/`metaDL`/… → +`downloading`). Восстановление SHALL опираться на фактическое состояние +торрента в qBittorrent, а не на время с момента создания записи. + +При возврате в любое нетерминальное состояние (`downloading` или +`completed`) система SHALL восстанавливать идемпотентность задачи +(`idempotency_key`), чтобы повторный приём того же infohash снова +дедуплицировался на эту задачу. Если за время простоя в `failed`/`stuck` тем +же infohash уже завладела другая активная задача (ключ снимается при падении и +мог быть перехвачен новым приёмом), система SHALL NOT воскрешать упавшую +задачу и SHALL оставить её в `failed`/`stuck`, сохраняя инвариант «не более +одной активной задачи на infohash». + +`magnet_timeout`/`stalled` SHALL быть редким страховочным исходом, а не +рабочим механизмом: пока торрент в `metaDL`/`forcedMetaDL` или иным образом +прогрессирует в пределах страховочного таймаута, задача в `failed`/`stuck` +из-за него оказаться SHALL NOT (см. требование о терпеливости к долгим +метаданным в `docs/specs/workflow.md`). + +#### Scenario: Метаданные пришли после magnet_timeout + +- **GIVEN** задача в `failed` с `error_code` `magnet_timeout`, а её торрент + в qBittorrent уже получил метаданные и качается (`downloading`) +- **WHEN** срабатывает фоновая сверка +- **THEN** задача возвращается в `downloading` +- **AND** её `idempotency_key` восстанавливается + +#### Scenario: Торрент уже завершился, пока задача была в failed + +- **GIVEN** задача в `failed` с `error_code` `magnet_timeout`, а её торрент + в qBittorrent уже готов к раскладке (`uploading`/`stalledUP`) +- **WHEN** срабатывает фоновая сверка +- **THEN** задача переходит в `completed` и продолжает обычный поток + (распознавание/раскладка) + +#### Scenario: Источник так и не ожил — состояние не меняется + +- **GIVEN** задача в `failed` с `error_code` `magnet_timeout`, а её торрент + всё ещё висит в `metaDL` без метаданных (или отсутствует в qBittorrent) +- **WHEN** срабатывает фоновая сверка +- **THEN** задача остаётся в `failed` + +#### Scenario: infohash уже занят другой активной задачей + +- **GIVEN** задача #1 в `failed`/`magnet_timeout`, а тем же infohash уже + владеет другая активная задача #2 (приём повторили, пока #1 лежала упавшей) +- **WHEN** источник ожил (торрент получил метаданные или готов) и сверка + пытается воскресить #1 +- **THEN** #1 остаётся в `failed` (восстановление не выполняется) +- **AND** активной по этому infohash остаётся #2 + +### Requirement: Ручной повтор зависшей/упавшей загрузки из транспортов + +Система SHALL предоставлять пользователю команду повторной попытки (retry) +для задач в `failed`/`stuck` из веб-UI и Telegram (не только через REST API). +Retry SHALL переводить задачу обратно в `downloading`, не вызывая её +немедленного повторного падения по таймауту: базис отсчёта таймаута SHALL +сбрасываться (отсчёт ведётся от факта в qBittorrent, а не от старого +`created_at`). + +Если источник задачи уже жив в qBittorrent, retry SHALL перецепляться к +существующему торренту, а не добавлять источник повторно вслепую; повторный +`Add` выполняется, только когда раздачи в qBittorrent нет. + +#### Scenario: Retry упавшей magnet-загрузки из веб-UI + +- **GIVEN** задача в `failed`, её торрент жив в qBittorrent +- **WHEN** пользователь нажимает retry в веб-UI +- **THEN** задача возвращается в `downloading` без повторного `Add` +- **AND** не падает снова на ближайшем тике сверки по таймауту + +#### Scenario: Retry доступен в Telegram + +- **WHEN** для задачи в `failed`/`stuck` пользователь вызывает retry в + Telegram-боте +- **THEN** задача возвращается в `downloading` + +#### Scenario: Retry без живого источника добавляет торрент заново + +- **GIVEN** задача в `failed`, раздачи в qBittorrent нет +- **WHEN** пользователь инициирует retry +- **THEN** источник (magnet) добавляется в qBittorrent заново +- **AND** задача переходит в `downloading` diff --git a/openspec/changes/archive/2026-06-30-download-failure-recovery/tasks.md b/openspec/changes/archive/2026-06-30-download-failure-recovery/tasks.md new file mode 100644 index 0000000..b0362d2 --- /dev/null +++ b/openspec/changes/archive/2026-06-30-download-failure-recovery/tasks.md @@ -0,0 +1,76 @@ +## 1. Константы и базис таймаута + +- [x] 1.1 Вынести `error_code`-строки (`magnet_timeout`, `stalled`, + `qbit_error` — в `worker`; `qbit_add` — в `ingest`) в именованные константы; + `error_code` уже читается из `store.Download.ErrorCode` (миграция не нужна) +- [x] 1.2 В `checkTimeouts` считать возраст от `torrent.AddedOn` (UTC) через + хелпер `torrentAge` (фолбэк на `created_at`, если `added_on` нет) +- [x] 1.3 Поднять дефолт `[worker].magnet_timeout` до `24h` в + `internal/config/config.go` и `config.example.toml` (с пометкой про страховку) + +## 2. Уведомление о падении + +- [x] 2.1 Добавить `EventFailed` в `NotifyEvent` (worker.go) +- [x] 2.2 В `transition` слать `EventFailed` при переходе в `StateFailed` и + `StateStuck` (неблокирующе, вне `w.mu`) +- [x] 2.3 Обработать `EventFailed` в Telegram (`renderFailed` + `retryKeyboard`); + httpapi `Notifier` не реализует — только tgbot + +## 3. Авто-восстановление в сверке + +- [x] 3.1 Восстановление `idempotency_key` отдельным методом стора НЕ нужно: + `SetDownloadState` сам ставит ключ в `infohash` для нетерминального состояния +- [x] 3.2 Реализовать `reconcileRecovery` (под `w.mu`, из `Poll`): задачи в + `failed`/`stuck` с `error_code` `magnet_timeout`/`stalled`, поиск в `byHash` +- [x] 3.3 Воскрешать только при прогрессе торрента (`torrentProgressed`): + `magnet_timeout` — `!isMeta`; `stalled` — `!isStalledDL`; не `classErrored`; + целевое состояние из `classify` (`recoveredState`: ready → `completed`, + downloading → `downloading`) +- [x] 3.4 Конфликт занятого `idempotency_key`: пре-проверка через + `FindActiveByInfohash` — оставляем в `failed` + лог +- [x] 3.5 Тесты (`recovery_test.go`): метаданные → `downloading`; готов → + `completed`; всё ещё `metaDL` → `failed`; `qbit_error`/ошибка не воскрешаются; + нет источника → `failed`; конфликт ключа → `failed` + +## 4. Починка Retry и ручной retry в транспортах + +- [x] 4.1 `Worker.Retry`: при живом торренте — без повторного `Add`, только + `downloading`; `Add` лишь когда раздачи нет +- [x] 4.2 Тест: retry при живом торренте не делает `Add` и не падает на + ближайшем тике (базис `added_on`) +- [x] 4.3 Кнопка retry в веб-UI (`internal/httpapi` + `index.html`) для + `failed`/`stuck`; роут `/ui/downloads/{id}/retry`; тест `TestUIRetry` +- [x] 4.4 Действие retry в Telegram-боте (колбэк `retry:` + кнопка); тесты + `TestBot_CallbackRetry`, `TestBot_NotifyFailed` + +## 5. Документация спек + +- [x] 5.1 Обновить `docs/specs/workflow.md`: `magnet_timeout` как страховка, + базис `added_on`, уведомление о `failed`/`stuck`, восстановление и retry +- [x] 5.2 `openspec validate --strict download-failure-recovery` — без ошибок + +## 6. Проверка + +- [x] 6.1 `task lint` (0 issues) и `task test` (зелёные) +- [x] 6.2 Ревью кода (чекпоинт до archive) — 8-угловой multi-agent проход + +## 7. Фиксы по ревью кода + +- [x] 7.1 Конфликт `idempotency_key`: проверка `FindActiveByInfohash` + распространена на ветку `completed` (а не только `downloading`) — + иначе constraint-ошибка и зависание в `failed` с логом каждый тик + (тест `TestRecoverySkipsConflictOnCompleted`) +- [x] 7.2 Recovery не пишет в `error_msg` здоровой задачи (передаём `""`, + причину — в лог), иначе заметка светилась бы как ошибка в UI/REST +- [x] 7.3 Эффективность: `reconcileRecovery` грузит кандидатов через + `store.ListRecoverable` (SQL-фильтр по `error_code`), а не вычитывает все + failed/stuck каждый тик +- [x] 7.4 Дебаунс уведомлений о падении (`shouldNotifyFail`, окно 1h) — + мерцающий stalled-торрент не спамит `EventFailed` (тест + `TestFailNotifyDebounce`) +- [x] 7.5 Уведомление о падении `qbit_add` в ingest через closure + (`SetFailureNotifier`, wiring в serve.go), тест + `TestIngestQbitErrorNotifies` +- [x] 7.6 Конвенция логирования: убран неймспейс-префикс `recovery:` из `msg` +- [x] 7.7 Мелочи: дедуп ветки `renderCard`, диагностика в `torrentAge`, + `time.Unix(...).UTC()`, актуализирован комментарий `terminalStates` diff --git a/openspec/specs/state-reconciliation/spec.md b/openspec/specs/state-reconciliation/spec.md index 744ad84..244ea05 100644 --- a/openspec/specs/state-reconciliation/spec.md +++ b/openspec/specs/state-reconciliation/spec.md @@ -22,11 +22,17 @@ qBittorrent. Capability описывает периодическую и при ссылки последнего батча со статусом раскладки, всё ещё принадлежащие этой загрузке). -Сверке SHALL подвергаться только состояния `done`, `target_missing`, -`orphaned`. Состояние `deleted` сверка трогать SHALL NOT — оно терминально. -Активные (`downloading`/`recognizing`/`review`/`deferred`/`linking`) и -пользовательски-терминальные (`reverted`/`cancelled`/`failed`/`stuck`) -состояния сверка трогать SHALL NOT. +Сверке по матрице «источник × цель» SHALL подвергаться состояния `done`, +`target_missing`, `orphaned`. Состояние `deleted` сверка трогать SHALL NOT — +оно терминально. Активные (`downloading`/`recognizing`/`review`/`deferred`/ +`linking`) и пользовательски-терминальные (`reverted`/`cancelled`) состояния +сверка по матрице трогать SHALL NOT. + +**Восстановимые** `failed`/`stuck` (с `error_code` `magnet_timeout` или +`stalled` — задержки, вызванные нашей нетерпеливостью, а не реальной ошибкой) +сверка SHALL рассматривать отдельно — на предмет оживления источника (см. +требование о восстановлении зависшей загрузки), не по матрице «источник × +цель». Прочие `failed` (например `qbit_error`) сверка трогать SHALL NOT. Состояние SHALL переписываться только при его изменении (без записи и логов, когда выведенное состояние совпадает с текущим). @@ -49,6 +55,101 @@ qBittorrent. Capability описывает периодическую и при - **THEN** сверка её не рассматривает и состояние не меняет, даже если по её бывшему пути появился файл другой загрузки +#### Scenario: Провал по ошибке qBittorrent восстановлению не подлежит + +- **WHEN** задача в `failed` с `error_code` `qbit_error` +- **THEN** сверка её не рассматривает и состояние не меняет + +### Requirement: Восстановление зависшей загрузки при оживлении источника + +Система SHALL возвращать в активный поток задачу, упавшую из-за нашей +нетерпеливости (`failed`/`magnet_timeout` или `stuck`/`stalled`), если её +источник в qBittorrent жив и продвинулся: переход выводится из текущего +состояния торрента так же, как при штатной сверке загрузки +(`uploading`/`stalledUP`/… → `completed`; `downloading`/`metaDL`/… → +`downloading`). Восстановление SHALL опираться на фактическое состояние +торрента в qBittorrent, а не на время с момента создания записи. + +При возврате в любое нетерминальное состояние (`downloading` или +`completed`) система SHALL восстанавливать идемпотентность задачи +(`idempotency_key`), чтобы повторный приём того же infohash снова +дедуплицировался на эту задачу. Если за время простоя в `failed`/`stuck` тем +же infohash уже завладела другая активная задача (ключ снимается при падении и +мог быть перехвачен новым приёмом), система SHALL NOT воскрешать упавшую +задачу и SHALL оставить её в `failed`/`stuck`, сохраняя инвариант «не более +одной активной задачи на infohash». + +`magnet_timeout`/`stalled` SHALL быть редким страховочным исходом, а не +рабочим механизмом: пока торрент в `metaDL`/`forcedMetaDL` или иным образом +прогрессирует в пределах страховочного таймаута, задача в `failed`/`stuck` +из-за него оказаться SHALL NOT (см. требование о терпеливости к долгим +метаданным в `docs/specs/workflow.md`). + +#### Scenario: Метаданные пришли после magnet_timeout + +- **GIVEN** задача в `failed` с `error_code` `magnet_timeout`, а её торрент + в qBittorrent уже получил метаданные и качается (`downloading`) +- **WHEN** срабатывает фоновая сверка +- **THEN** задача возвращается в `downloading` +- **AND** её `idempotency_key` восстанавливается + +#### Scenario: Торрент уже завершился, пока задача была в failed + +- **GIVEN** задача в `failed` с `error_code` `magnet_timeout`, а её торрент + в qBittorrent уже готов к раскладке (`uploading`/`stalledUP`) +- **WHEN** срабатывает фоновая сверка +- **THEN** задача переходит в `completed` и продолжает обычный поток + (распознавание/раскладка) + +#### Scenario: Источник так и не ожил — состояние не меняется + +- **GIVEN** задача в `failed` с `error_code` `magnet_timeout`, а её торрент + всё ещё висит в `metaDL` без метаданных (или отсутствует в qBittorrent) +- **WHEN** срабатывает фоновая сверка +- **THEN** задача остаётся в `failed` + +#### Scenario: infohash уже занят другой активной задачей + +- **GIVEN** задача #1 в `failed`/`magnet_timeout`, а тем же infohash уже + владеет другая активная задача #2 (приём повторили, пока #1 лежала упавшей) +- **WHEN** источник ожил (торрент получил метаданные или готов) и сверка + пытается воскресить #1 +- **THEN** #1 остаётся в `failed` (восстановление не выполняется) +- **AND** активной по этому infohash остаётся #2 + +### Requirement: Ручной повтор зависшей/упавшей загрузки из транспортов + +Система SHALL предоставлять пользователю команду повторной попытки (retry) +для задач в `failed`/`stuck` из веб-UI и Telegram (не только через REST API). +Retry SHALL переводить задачу обратно в `downloading`, не вызывая её +немедленного повторного падения по таймауту: базис отсчёта таймаута SHALL +сбрасываться (отсчёт ведётся от факта в qBittorrent, а не от старого +`created_at`). + +Если источник задачи уже жив в qBittorrent, retry SHALL перецепляться к +существующему торренту, а не добавлять источник повторно вслепую; повторный +`Add` выполняется, только когда раздачи в qBittorrent нет. + +#### Scenario: Retry упавшей magnet-загрузки из веб-UI + +- **GIVEN** задача в `failed`, её торрент жив в qBittorrent +- **WHEN** пользователь нажимает retry в веб-UI +- **THEN** задача возвращается в `downloading` без повторного `Add` +- **AND** не падает снова на ближайшем тике сверки по таймауту + +#### Scenario: Retry доступен в Telegram + +- **WHEN** для задачи в `failed`/`stuck` пользователь вызывает retry в + Telegram-боте +- **THEN** задача возвращается в `downloading` + +#### Scenario: Retry без живого источника добавляет торрент заново + +- **GIVEN** задача в `failed`, раздачи в qBittorrent нет +- **WHEN** пользователь инициирует retry +- **THEN** источник (magnet) добавляется в qBittorrent заново +- **AND** задача переходит в `downloading` + ### Requirement: Принудительная проверка источника/цели перед действием Команда workflow, требующая наличия источника или цели, SHALL синхронно diff --git a/web/templates/index.html b/web/templates/index.html index 8d5662b..9bac91c 100644 --- a/web/templates/index.html +++ b/web/templates/index.html @@ -78,6 +78,11 @@ {{end}} + {{if .Retriable}} +
+ +
+ {{end}} {{if not .Terminal}}