diff --git a/docs/backlog/README.md b/docs/backlog/README.md index 67193d8..a761078 100644 --- a/docs/backlog/README.md +++ b/docs/backlog/README.md @@ -36,7 +36,6 @@ Tududi (проект `jellybit`) больше **не** держит беклог - [Confidence-гейт авто-раскладки: узаконить в спеке + сделать выключаемым (дефолт 0.7)](gate-confidence-spec-vs-code.md) — Решено (B): гейт оставляем как доп. проверку на ревью — выключаемый порог, дефолт 0.85→0.7, записать в спеку - [Внешние субтитры: пары VobSub и языковой суффикс](vneshnie-subtitry.md) — Привязка субтитр→серия уже работает; остались пары VobSub .idx+.sub и потеря Lang/Flags - [Defer из catched → лимбо → необратимый deleted (MAJOR-6)](review-major6-defer-catched.md) — Defer из ещё-не-добавленного catched уводит задачу в необратимый deleted _(ревью 2026-07-08)_ -- [processCatched: promote-without-add если торрент уже в qBittorrent (F2)](review-f2-promote-without-add.md) — торрент уже в qBittorrent → processCatched зациклен на Add вместо promote _(ревью 2026-07-08)_ - [Cancel во время add оставляет неуправляемый торрент в qBittorrent (F3/NIT-13)](review-f3-cancel-during-add.md) — Cancel во время add оставляет неуправляемый торрент в qBittorrent _(ревью 2026-07-08)_ ## Низкий diff --git a/docs/backlog/review-f2-promote-without-add.md b/docs/backlog/review-f2-promote-without-add.md deleted file mode 100644 index f24f4fa..0000000 --- a/docs/backlog/review-f2-promote-without-add.md +++ /dev/null @@ -1,19 +0,0 @@ -# processCatched: promote-without-add если торрент уже в qBittorrent (F2) - -**Приоритет:** средний · **Теги:** ingest, review-2026-07-08, lifecycle - -Ревью Fable 2026-07-08 (приём). worker.go:361-391, sourceAddParts :407-432, qbt.go:243-246. - -Сценарий: торрент уже в qBittorrent БЕЗ нашей категории/тега (юзер добавил вручную раньше → discover не усыновляет). Юзер грузит тот же .torrent в jellybit → catched → qbt.Add файлом; для file-add дубль → «Fails.» → Add ошибка → «will retry» каждый тик, вечно, до catch_timeout → failed/qbit_add, который reconcileRecovery НЕ воскрешает (worker.go:124-132). Торрент жив всё это время; юзер видит failed. Retry уже решает это alive-проверкой (worker.go:692-698 «повторный Add вреден»), а processCatched — нет, хотя live-снимок byHash того же тика доступен. Также лечит сценарий B (Add успех, PromoteCatched падает на транзиентной ошибке → снова Add дубля). - -Замечание: поведение qBit на дубль file-add версионно-зависимо («Fails.» vs «Ok.») — проверить на целевой версии. - -Фикс: перед Add проверить присутствие хешей в qBit; есть → promote без Add (зеркалит Retry). - -Смежное (ревью 2026-07-08, кластер A): апгрейд F6 (`UpgradeCatchedMagnetToTorrent`) -оставил узкое окно — `processCatched` читает снимок `source_type` вне `w.mu`, поэтому -при точном оверлапе тика воркера с апгрейдом воркер добавит magnet из устаревшего -снимка, хотя БД уже `torrent`. Тот же фикс закрывает и это: перечитать источник под -`w.mu` (или проверить присутствие хешей в qBit) перед Add. - -Вердикт: change (малая спека-дельта download-tracking + код). diff --git a/internal/tgbot/bot.go b/internal/tgbot/bot.go index 9fae1ec..a5d9369 100644 --- a/internal/tgbot/bot.go +++ b/internal/tgbot/bot.go @@ -266,11 +266,12 @@ func (b *Bot) ingestAndReply(ctx context.Context, chatID int64, req ingest.Reque b.send(chatID, opErr("Не удалось принять загрузку", res.DownloadID), nil) return } - msg := fmt.Sprintf("Принято #%s — добавляю в qBittorrent.", res.DownloadID) if res.Deduplicated { - msg = fmt.Sprintf("Уже в работе #%s.", res.DownloadID) + // Дубль на уже активную задачу: новую загрузку не заводим, лишь сообщаем. + b.send(chatID, fmt.Sprintf("♻️ Дубль уже активной загрузки #%s — добавление отменено.", res.DownloadID), nil) + return } - b.send(chatID, msg+"\nПозову, когда нужно подтверждение.", nil) + b.send(chatID, fmt.Sprintf("Принято #%s — добавляю в qBittorrent.\nПозову, когда нужно подтверждение.", res.DownloadID), nil) } const helpText = `jellybit-бот: пришлите magnet-ссылку, .torrent-файл или перешлите сообщение торрент-бота — поставлю на закачку. diff --git a/internal/tgbot/bot_test.go b/internal/tgbot/bot_test.go index d84eede..b7d7b92 100644 --- a/internal/tgbot/bot_test.go +++ b/internal/tgbot/bot_test.go @@ -162,6 +162,23 @@ func TestBot_IngestFromMagnet(t *testing.T) { } } +// Дедуп на приёме (дубль на уже активную задачу) → явный ответ «дубль … +// добавление отменено», а не «Принято». +func TestBot_IngestDeduplicated(t *testing.T) { + b, api, ing, _ := newTestBot(t, []int64{7}) + ing.res = ingest.Result{DownloadID: tid, State: store.StateDownloading, Deduplicated: true} + + b.handleMessage(context.Background(), msgFrom(7, "magnet:?xt=urn:btih:ABC")) + + if len(api.sent) != 1 { + t.Fatalf("sent = %+v", api.sent) + } + txt := api.sent[0].text + if !strings.Contains(txt, "Дубль") || !strings.Contains(txt, tid) || strings.Contains(txt, "Принято") { + t.Errorf("ожидалось сообщение о дубле с #%s, got %q", tid, txt) + } +} + func TestBot_DeniesUnknownUser(t *testing.T) { b, api, ing, _ := newTestBot(t, []int64{7}) b.handleMessage(context.Background(), msgFrom(999, "magnet:?xt=urn:btih:ABC")) diff --git a/internal/worker/catched_test.go b/internal/worker/catched_test.go index 4c05d2e..f73054c 100644 --- a/internal/worker/catched_test.go +++ b/internal/worker/catched_test.go @@ -6,6 +6,7 @@ import ( "testing" "time" + "git.vakhrushev.me/av/jellybit/internal/qbt" "git.vakhrushev.me/av/jellybit/internal/store" ) @@ -14,10 +15,12 @@ import ( type fakeNamer struct { name string gotContext string + calls int onCall func() } func (f *fakeNamer) DeriveName(_ context.Context, contextText, _ string) string { + f.calls++ f.gotContext = contextText if f.onCall != nil { f.onCall() @@ -77,6 +80,84 @@ func TestProcessCatchedAddsToQbit(t *testing.T) { } } +// Торрент пойманной загрузки уже присутствует в qBittorrent (добавлен ранее +// вручную/другим клиентом) → усыновляем: promote в downloading без повторного +// Add (и без 409) и без namer; имя берём из раздачи снимка. +func TestProcessCatchedPresentAdopts(t *testing.T) { + st := catchedStore("1", catchedIH, nowStr, "Дюна 2") + qb := &fakeQbt{torrents: []qbt.Torrent{ + {Hash: catchedIH, Name: "Dune.2024.1080p"}, // без нашей категории — добавлен вручную + }} + w := newTestWorker(st, qb) + nm := &fakeNamer{name: "не должно вызваться"} + w.SetNamer(nm) + + w.processCatched(context.Background()) + + if len(qb.added) != 0 { + t.Errorf("qbt.Add не должен вызываться для уже присутствующего торрента, calls = %d", len(qb.added)) + } + if nm.calls != 0 { + t.Errorf("namer (LLM) не должен вызываться, calls = %d", nm.calls) + } + d := st.downloads["1"] + if d.State != store.StateDownloading { + t.Errorf("state = %q, want downloading", d.State) + } + if d.DisplayName != "Dune.2024.1080p" { + t.Errorf("display_name = %q, want имя раздачи из снимка", d.DisplayName) + } +} + +// Гонка F6: список catched снят как magnet, но апгрейд до .torrent случился +// между снимком и re-read под замком. processCatched перечитывает source_type +// под w.mu, поэтому добавляет файлом (Torrents), а не magnet-ссылкой. +func TestProcessCatchedReReadsSourceTypeUnderLock(t *testing.T) { + st := catchedStore("1", catchedIH, nowStr, "ctx") // снят как magnet + st.torrents = map[string][]byte{} + qb := &fakeQbt{} // раздачи нет → absent-ветка (обычное добавление) + // Апгрейд «под носом»: между листингом (снимок magnet) и re-read под замком + // БД уже стала torrent с сохранёнными байтами. + qb.onTorrents = func() { + st.downloads["1"].SourceType = store.SourceTorrent + st.torrents["1"] = []byte("d4:infod-fake-torrent-bytes-ee") + } + w := newTestWorker(st, qb) + w.SetNamer(&fakeNamer{name: "X"}) + + w.processCatched(context.Background()) + + if len(qb.added) != 1 { + t.Fatalf("qbt.Add calls = %d, want 1", len(qb.added)) + } + if len(qb.added[0].Torrents) != 1 { + t.Errorf("после апгрейда ожидалось добавление файлом (Torrents), got Torrents=%v URLs=%v", + qb.added[0].Torrents, qb.added[0].URLs) + } + if len(qb.added[0].URLs) != 0 { + t.Errorf("magnet-ссылка не должна использоваться после апгрейда в torrent: %v", qb.added[0].URLs) + } +} + +// Листинг qBittorrent провалился (недоступен) → пойманную не трогаем: остаётся +// catched, ни namer, ни Add не вызываются (повтор на следующем тике). +func TestProcessCatchedListErrorKeepsCatched(t *testing.T) { + st := catchedStore("1", catchedIH, nowStr, "ctx") + qb := &fakeQbt{torrentsErr: errors.New("connection refused")} + w := newTestWorker(st, qb) + nm := &fakeNamer{name: "X"} + w.SetNamer(nm) + + w.processCatched(context.Background()) + + if st.downloads["1"].State != store.StateCatched { + t.Errorf("state = %q, want catched (повтор)", st.downloads["1"].State) + } + if nm.calls != 0 || len(qb.added) != 0 { + t.Errorf("при недоступности qBit namer/Add не должны вызываться: namer=%d add=%d", nm.calls, len(qb.added)) + } +} + // Транзиентный сбой add — остаёмся в catched для повтора на следующем тике. func TestProcessCatchedTransientFailureKeepsCatched(t *testing.T) { st := catchedStore("1", catchedIH, nowStr, "ctx") diff --git a/internal/worker/worker.go b/internal/worker/worker.go index 8c299c1..f75f7af 100644 --- a/internal/worker/worker.go +++ b/internal/worker/worker.go @@ -387,10 +387,34 @@ func (w *Worker) processCatched(ctx context.Context) { w.log.Warn("list catched failed", "capability", capIngest, "error", err) return } + if len(catched) == 0 { + return + } + // Снимок присутствия раздач в qBittorrent (один листинг на тик): по нему ДО + // вызова namer решаем, добавлять ли задачу вообще. Провал листинга — + // qBittorrent недоступен: пойманные в этот тик не трогаем (ни namer, ни Add), + // повтор на следующем; устойчивая недоступность отсекается предохранителем + // catch_timeout. + torrents, err := w.qbt.Torrents(ctx, "") + if err != nil { + w.log.Warn("list torrents for catched failed", "capability", capIngest, "error", err) + return + } + byHash := torrentsByHash(torrents) + for _, d := range catched { cctx := w.scoped(ctx, capIngest, d.ID, d.PrimaryInfohash()) - // Предохранитель: устойчивая невозможность добавить в qBittorrent. + // Торрент уже в qBittorrent — повторный Add не нужен (и вреден: qBittorrent + // отверг бы дубль, 409) и LLM-namer не зовём: усыновляем раздачу, доводя + // задачу до downloading. См. promoteExisting. + if t, ok := torrentFor(d, byHash); ok { + w.promoteExisting(cctx, d, t) + continue + } + + // Предохранитель: устойчивая невозможность добавить в qBittorrent (раздачи + // в снимке нет и висит дольше catch_timeout). if w.cfg.CatchTimeout > 0 { if age, ok := w.catchedAge(d); ok && age > w.cfg.CatchTimeout { w.mu.Lock() @@ -406,9 +430,20 @@ func (w *Worker) processCatched(ctx context.Context) { } } - // Вне w.mu: сбор параметров добавления по типу источника (для torrent — - // чтение байтов), вывод имени (потенциально медленный LLM) и добавление. - hint, addReq, prepErr := w.sourceAddParts(cctx, d) + // Раздачи в qBittorrent нет — обычный путь добавления. Перечитываем запись + // под замком: (а) актуальный source_type (апгрейд magnet→torrent мог + // случиться после снятия списка catched — иначе добавили бы magnet из + // устаревшего снимка), (б) ре-валидация state=catched. Тяжёлые вызовы + // (чтение байтов, namer, Add) — вне замка. + w.mu.Lock() + cur, gerr := w.store.GetDownload(cctx, d.ID) + fresh := gerr == nil && cur != nil && cur.State == store.StateCatched + w.mu.Unlock() + if !fresh { + continue // отменили/пропала, пока шёл листинг — не трогаем + } + + hint, addReq, prepErr := w.sourceAddParts(cctx, *cur) if prepErr != nil { // Байты torrent недоступны (не должно быть при штатном приёме) — // остаёмся в catched, повтор на следующем тике. @@ -417,13 +452,13 @@ func (w *Worker) processCatched(ctx context.Context) { } var rename string if w.namer != nil { - rename = w.namer.DeriveName(cctx, d.Context, hint) + rename = w.namer.DeriveName(cctx, cur.Context, hint) } addReq.Rename = rename addErr := w.qbt.Add(cctx, addReq) if addErr != nil { - // Транзиентный сбой (qBit недоступен) — остаёмся в catched, повтор на - // следующем тике. Поведение вызова qBit уже залогировал клиент (ext.*). + // Транзиентный сбой (qBit отверг/недоступен) — остаёмся в catched, + // повтор на следующем тике. Вызов qBit уже залогировал клиент (ext.*). logctx.From(cctx).Warn("catched add to qbittorrent failed, will retry", "error", addErr) continue } @@ -441,6 +476,42 @@ func (w *Worker) processCatched(ctx context.Context) { } } +// promoteExisting усыновляет пойманную загрузку, чей торрент уже присутствует в +// qBittorrent (снимок тика): переводит catched → downloading БЕЗ повторного Add +// (иначе qBittorrent отверг бы дубль — 409 — и задача зациклилась бы) и без LLM. +// Имя берём из раздачи снимка (t.Name); у свежего magnet без метаданных (metaDL) +// оно может быть пустым — распознавание дольёт имя позже. Инвариант приёма +// гарантирует, что сюда доходит лишь загрузка без другой активной задачи на тот +// же infohash, поэтому присутствие раздачи трактуем как «усыновить и разложить», +// а не как конфликт. Короткий DB-переход под w.mu; атомарный гард PromoteCatched +// (WHERE state='catched') сам отсекает гонку отмены, случившуюся, пока шёл листинг +// вне замка, — отдельный re-read не нужен. +func (w *Worker) promoteExisting(ctx context.Context, d store.Download, t qbt.Torrent) { + w.mu.Lock() + defer w.mu.Unlock() + if err := w.store.PromoteCatched(ctx, d.ID, t.Name); err != nil { + logctx.From(ctx).Info("catched promote skipped", "reason", err.Error()) + return + } + logctx.From(ctx).Info("state transition", "from", store.StateCatched, + "to", store.StateDownloading, "reason", "already present in qbittorrent") +} + +// torrentsByHash индексирует раздачи по каждому из их хешей (lowercase), как это +// делает Poll для своего снимка. Использует processCatched, чтобы проверить, есть +// ли торрент пойманной загрузки уже в qBittorrent. +func torrentsByHash(torrents []qbt.Torrent) map[string]qbt.Torrent { + byHash := make(map[string]qbt.Torrent, len(torrents)*2) + for _, t := range torrents { + for _, h := range []string{t.Hash, t.InfohashV1, t.InfohashV2} { + if h != "" { + byHash[strings.ToLower(h)] = t + } + } + } + return byHash +} + // catchedAge — возраст пойманной загрузки от created_at (у catched раздачи в // qBittorrent ещё нет, added_on недоступен). ok=false — created_at не разобрать. func (w *Worker) catchedAge(d store.Download) (time.Duration, bool) { diff --git a/internal/worker/worker_test.go b/internal/worker/worker_test.go index 6267978..5e65a96 100644 --- a/internal/worker/worker_test.go +++ b/internal/worker/worker_test.go @@ -269,6 +269,7 @@ func (f *fakeStore) SetCandidateChosen(_ context.Context, _, _ string) error { r type fakeQbt struct { torrents []qbt.Torrent torrentsErr error + onTorrents func() // вклинивается в момент листинга (симуляция гонки между снимком и re-read) added []qbt.AddRequest addErr error files []qbt.File @@ -288,6 +289,9 @@ type renameCall struct { // регрессии: раздача, усыновлённая по тегу, имеет чужую категорию и не должна // теряться при поиске по infohash. func (f *fakeQbt) Torrents(_ context.Context, category string) ([]qbt.Torrent, error) { + if f.onTorrents != nil { + f.onTorrents() + } if f.torrentsErr != nil { return nil, f.torrentsErr } diff --git a/openspec/changes/archive/2026-07-10-catched-promote-without-readd/.openspec.yaml b/openspec/changes/archive/2026-07-10-catched-promote-without-readd/.openspec.yaml new file mode 100644 index 0000000..eb5fa80 --- /dev/null +++ b/openspec/changes/archive/2026-07-10-catched-promote-without-readd/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-07-10 diff --git a/openspec/changes/archive/2026-07-10-catched-promote-without-readd/design.md b/openspec/changes/archive/2026-07-10-catched-promote-without-readd/design.md new file mode 100644 index 0000000..27f3ef7 --- /dev/null +++ b/openspec/changes/archive/2026-07-10-catched-promote-without-readd/design.md @@ -0,0 +1,87 @@ +## Context + +`processCatched` (`internal/worker/worker.go`) вызывается из `pollOnce` сразу +после `Poll`. Сейчас он безусловно зовёт namer (LLM) и `qbt.Add`, а `409`/`Fails.` +на дубле трактует как транзиентный сбой → вечный повтор с тратой LLM. + +Приём (`ingest`) уже дедуплицирует по infohash на **активную** задачу +(`FindActiveByInfohash`/`CreateDownloadIfNoActive`). Значит до `processCatched` +доходит только загрузка, для которой в jellybit нет другой активной задачи. +Отсюда упрощение: если торрент такой загрузки уже присутствует в qBittorrent, +это не «конфликт с чужой задачей», а «раздачу уже кто-то (пользователь вручную, +прошлый тик) добавил» — надо просто **усыновить** её и разложить. + +## Goals / Non-Goals + +**Goals:** + +- Пойманная загрузка, чей торрент уже в qBittorrent, доводится до `downloading` + без повторного `add` (без 409) и без LLM; дальше — обычная раскладка. +- LLM-namer не вызывается ни при усыновлении, ни при недоступности qBittorrent. +- Гонка апгрейда F6 сужена перечитыванием `source_type` под блокировкой. +- Повторное добавление уже активной в jellybit загрузки транспорт отражает как + дубль (сообщение + лог), без новой записи. + +**Non-Goals:** + +- Не заводим новых состояний загрузки. Дедуп на приёме записи не создаёт; + усыновление — это `downloading`, а не отдельный статус. +- Не различаем «наш/чужой» торрент по категории/тегу: инвариант приёма делает + различие ненужным (до воркера доходит лишь загрузка без другой активной). +- Не добавляем счётчик попыток `add` (предел — время `catch_timeout`). +- Не проверяем присутствие на приёме (`ingest` остаётся быстрым, без qBittorrent). + +## Decisions + +**1. Источник снимка присутствия: один листинг `qbt.Torrents("")` на входе в +`processCatched`.** Строим `byHash` (по `Hash`/`InfohashV1`/`InfohashV2`, +lowercase), переиспользуем для всех catched-задач тика (как это делает Poll для +своего снимка). Провал листинга → qBittorrent недоступен → в этот тик пойманные +не трогаем (namer не зовём), повтор на следующем; отсечка — `catch_timeout`. +Поиск торрента задачи — существующий `torrentFor(d, byHash)`. + +**2. Присутствует → усыновляем; ветвление до namer.** Если `torrentFor` нашёл +раздачу — `PromoteCatched(id, t.Name)` (перевод `catched → downloading` + имя из +`qbt.Torrent.Name`, без LLM), под коротким замком с ре-валидацией `state='catched'`. +Иначе — обычный путь: re-read под замком → namer → `sourceAddParts` → `Add` → +`PromoteCatched`. namer (LLM) на ветке усыновления и при недоступности qBit не +зовётся. + +**3. Никакого различия «наш/чужой» и никакого `duplicated`.** Инвариант приёма +(«одна активная на infohash») гарантирует, что усыновляемая раздача не отберётся +у другой активной задачи. `PromoteCatched` (гард `state='catched'`) корректен; +`ActivateIfNoOtherActive` (как в `Retry` из терминального `failed`) здесь не +нужен — `catched` нетерминален и уже единственный активный владелец infohash. +Имя раздачи в `display_name` полезно и уведомлениям, и заголовку в UI (у `catched` +оно пусто). + +**4. Re-read `source_type` под замком перед добавлением (сужение гонки F6).** +На absent-ветке перед сбором `addReq` берём короткий замок, перечитываем запись +(`GetDownload`): ре-валидация `state='catched'` и актуальный `source_type` +(апгрейд magnet→torrent мог случиться после снятия списка). Тяжёлые вызовы +(`GetTorrentData`, namer, `Add`) — вне замка. + +**5. Дедуп на приёме (case 1) — только сообщение.** `ingest` при попадании на +активную задачу уже возвращает `Deduplicated=true` (запись не создаётся). Меняем +лишь текст ответа транспорта: вместо «Уже в работе #id» — «♻️ дубль уже активной +#id, добавление отменено». Лог дедупа (`download attached to active`) уже есть. +Новых состояний/записей не заводим. + +## Risks / Trade-offs + +- **[Остаточное окно F6]** → re-read `source_type` под замком + `Add` вне замка + окно резко **сужают**, но не закрывают полностью. Полное закрытие требует + держать замок через `Add`, что нарушает инвариант «тяжёлые вызовы вне замка». + Оверлап крайне редок, цена промаха — один неудачный magnet-add, повтор на + следующем тике уже увидит `torrent`. Принимаем суженное окно осознанно. +- **[Снимок присутствия на тик «отстаёт»]** → каждая catched-задача + обрабатывается раз за тик; если наш `add` прошёл, а запись перехода сорвалась, + усыновление случится на следующем тике, где листинг уже видит раздачу. +- **[Усыновление раздачи, добавленной вручную с иными savepath/категорией]** → + инвариант источника не нарушается: файлы не наши, раскладка хардлинчит + отдельно; infohash совпадает — контент тот же. Осознанное поведение (как + `discover`). + +## Open Questions + +Нет. diff --git a/openspec/changes/archive/2026-07-10-catched-promote-without-readd/proposal.md b/openspec/changes/archive/2026-07-10-catched-promote-without-readd/proposal.md new file mode 100644 index 0000000..f016e44 --- /dev/null +++ b/openspec/changes/archive/2026-07-10-catched-promote-without-readd/proposal.md @@ -0,0 +1,57 @@ +## Why + +Пойманная (`catched`) загрузка, чей торрент **уже присутствует в qBittorrent** +(добавлен раньше вручную/другим клиентом или прошлой попыткой jellybit), уходит в +бесконечный цикл: `processCatched` на каждом тике зовёт `qbt.Add`, qBittorrent +отбивает дубль (`409 Conflict`), сбой трактуется как транзиентный → задача +остаётся в `catched` → повтор, и на каждом безнадёжном тике впустую вызывается +LLM-namer. Диагноз: `docs/backlog/review-f2-promote-without-add.md`. + +Решение: перед добавлением проверять присутствие торрента в qBittorrent. Раз +инвариант приёма гарантирует, что до воркера доходит лишь загрузка, для которой в +jellybit нет другой активной задачи (дубль на активную отсекается ещё на приёме), +присутствие торрента в qBittorrent означает «его надо **усыновить**» — довести до +`downloading` без повторного `add` и разложить, а не пытаться добавить дубль и +ловить 409. + +Отдельно: повторное добавление торрента, который jellybit **уже ведёт активной +задачей**, транспорт должен явно отражать как дубль (сообщение «добавление +отменено»), а не молчаливым «уже в работе». + +## What Changes + +- В `processCatched` перед `qbt.Add` — **проверка присутствия торрента в + qBittorrent** (один листинг на тик). Присутствует → `catched → downloading` + **без `add`** (усыновление; `display_name` из имени раздачи, без LLM); нет → + прежний путь добавления. Проверка — **до namer**, чтобы не жечь LLM. +- При недоступности qBittorrent (листинг не удался) тик пропускается без вызова + LLM; предел ретрая — существующий предохранитель `catch_timeout`. +- Гонка апгрейда F6 сужается: `source_type` перечитывается под блокировкой + переходов перед добавлением. +- Транспорт Telegram на дедуп приёма (дубль на уже активную задачу) отвечает + явным «дубль уже активной #id — добавление отменено» (+ лог), без создания + новой записи. + +## Capabilities + +### New Capabilities + + + +### Modified Capabilities + +- `download-tracking`: требование «Добавление пойманной загрузки в qBittorrent» + дополняется проверкой присутствия и усыновлением (promote без повторного + `add`) при наличии торрента, перечитыванием источника под замком и пропуском + тика при недоступности qBittorrent. + +## Impact + +- Код: `internal/worker/worker.go` (`processCatched`, presence-check, + усыновление вместо повторного `add`), `internal/tgbot/bot.go` (текст ответа на + дедуп). +- Внешние вызовы: убирает лишние `qbt.Add` (и 409) и `chat.completions` + (LLM-namer) на повторах; добавляет один `qbt.Torrents`-листинг на тик в + `processCatched`. +- Тесты: `internal/worker/catched_test.go`. +- **БД-миграции, новых состояний, конфигурации и внешнего API — нет.** diff --git a/openspec/changes/archive/2026-07-10-catched-promote-without-readd/specs/download-tracking/spec.md b/openspec/changes/archive/2026-07-10-catched-promote-without-readd/specs/download-tracking/spec.md new file mode 100644 index 0000000..75a4712 --- /dev/null +++ b/openspec/changes/archive/2026-07-10-catched-promote-without-readd/specs/download-tracking/spec.md @@ -0,0 +1,114 @@ +## MODIFIED Requirements + +### Requirement: Добавление пойманной загрузки в qBittorrent + +Worker SHALL периодически (в поллинг-цикле, под единой блокировкой переходов) +подхватывать загрузки в состоянии `catched` и для каждой (кроме случая уже +присутствующего в qBittorrent торрента, см. ниже): вывести отображаемое имя из +контекста (см. `ingest` «Отображаемое имя торрента из контекста»), добавить +источник в qBittorrent (категория `qbittorrent.category`, savepath, `rename`) и +перевести загрузку `catched → downloading`. Отдельного состояния между `catched` +и `downloading` быть SHALL NOT — успешный `add` сразу переводит в `downloading` +(которое и означает «в qBit, возможно `metaDL`»). + +Перед добавлением worker SHALL проверять, **присутствует ли торрент загрузки уже +в qBittorrent** (по любому из её infohash), опираясь на листинг раздач того же +тика. Если торрент уже присутствует, worker SHALL **усыновить** его: перевести +загрузку `catched → downloading` **без повторного `add`** и без вывода имени +через LLM (`display_name` берётся из имени присутствующей раздачи). Повторный +`add` здесь не нужен и вреден — qBittorrent отверг бы дубль (напр. `409 +Conflict`), и загрузка зациклилась бы на ретраях. Усыновлённая раздача дальше +идёт обычным путём отслеживания и раскладки. Проверка присутствия SHALL +выполняться **до вывода отображаемого имени**, чтобы не тратить LLM-вызов на +загрузку, которую добавлять не требуется. + +Инвариант приёма («одна активная загрузка на infohash», см. `ingest`) гарантирует, +что до этого шага доходит лишь загрузка, для которой в jellybit НЕТ другой +активной задачи; поэтому присутствие торрента в qBittorrent worker трактует как +«усыновить и разложить», а не как конфликт с чужой задачей. + +Если листинг раздач qBittorrent недоступен (сетевой сбой), worker пойманную +загрузку в этот тик трогать SHALL NOT (ни `add`, ни namer) и повторить на +следующем; устойчивая недоступность отсекается предохранителем `catch_timeout` +(см. «Предохранитель зависшего catched»). + +Добавление в qBittorrent worker SHALL выполнять **по типу источника** +(`source_type`): + +- Для `magnet`/`url` — передавать `source_ref` как ссылку (`urls` API + `/torrents/add`); подсказку отображаемого имени брать из полей самой ссылки. +- Для `torrent` — загружать сохранённые байты `.torrent` (привязанные к + загрузке при приёме) и передавать их **файлом** (`torrents` API + `/torrents/add`), НЕ как ссылку; подсказку отображаемого имени брать из + метаданных торрента (имя раздачи). Добавление байтами SHALL сохранять полные + метаданные (qBittorrent стартует без докачки), поэтому воскрешать раздачу по + magnet-хешу вместо файла система SHALL NOT. + +`source_type` для выбора способа добавления worker SHALL перечитывать **под +блокировкой переходов** непосредственно перед добавлением (а не полагаться на +снимок, снятый ранее вне блокировки): иначе при точном оверлапе тика с апгрейдом +пойманной magnet-задачи до `.torrent` (см. `ingest`) воркер добавил бы magnet из +устаревшего снимка, хотя БД уже `torrent`. + +Неуспешный `add` (qBittorrent временно отверг/недоступен) SHALL оставлять +загрузку в `catched` для повторной попытки на следующем тике; переход в +терминальное состояние по единичному сбою происходить SHALL NOT (ретраи — +естественными тиками поллинга, отсечка — `catch_timeout`). + +Медленные вызовы (вывод имени через LLM, `qbt.Add`) SHALL выполняться **вне** +блокировки сериализации переходов, чтобы не задерживать команды транспортов и +поллинг. Под блокировкой сериализуется только **запись перехода** `catched → +downloading` (см. «Переходы состояний сериализуются воркером»), с ре-валидацией, +что загрузка всё ещё в `catched` (иначе переход отклоняется — например, при +параллельной отмене). + +#### Scenario: Пойманная magnet-загрузка добавляется в qBittorrent + +- **GIVEN** загрузка в состоянии `catched` с `source_type = magnet`, торрента + ещё нет в qBittorrent +- **WHEN** worker обрабатывает тик +- **THEN** выводится отображаемое имя, ссылка добавляется в qBittorrent с + нашей категорией и `rename` +- **AND** загрузка переходит в `downloading` + +#### Scenario: Пойманная .torrent-загрузка добавляется файлом + +- **GIVEN** загрузка в состоянии `catched` с `source_type = torrent` и + сохранёнными байтами файла, торрента ещё нет в qBittorrent +- **WHEN** worker обрабатывает тик +- **THEN** сохранённые байты добавляются в qBittorrent файлом (`torrents`), с + нашей категорией и `rename`, без обращения к magnet-хешу +- **AND** загрузка переходит в `downloading` + +#### Scenario: Торрент уже присутствует в qBittorrent — усыновление без add + +- **GIVEN** загрузка в состоянии `catched`, торрент которой уже присутствует в + qBittorrent (добавлен ранее вручную/другим клиентом либо `add` прошёл на + прошлом тике, а запись перехода не удалась) +- **WHEN** worker обрабатывает тик +- **THEN** worker НЕ вызывает `qbt.Add` и НЕ выводит отображаемое имя через LLM +- **AND** `display_name` записывается из имени присутствующей раздачи +- **AND** загрузка переходит в `downloading` и идёт обычным путём к раскладке + +#### Scenario: qBittorrent недоступен при проверке присутствия — повтор + +- **GIVEN** загрузка в `catched`, листинг раздач qBittorrent не удался +- **WHEN** worker обрабатывает тик +- **THEN** worker НЕ вызывает namer и НЕ добавляет источник +- **AND** загрузка остаётся в `catched` и попытка повторяется на следующем тике + +#### Scenario: Временный сбой добавления — повтор + +- **GIVEN** загрузка в `catched`, торрента в qBittorrent нет, но `add` не удался +- **WHEN** worker пытается добавить источник и `add` возвращает ошибку +- **THEN** загрузка остаётся в `catched` +- **AND** на следующем тике попытка добавления повторяется + +#### Scenario: Отмена во время добавления + +- **GIVEN** загрузка в `catched`, worker выводит имя и добавляет её вне + блокировки +- **WHEN** параллельно приходит команда отмены (`catched → cancelled`), а затем + worker берёт блокировку для записи перехода +- **THEN** ре-валидация видит, что загрузка уже не в `catched`, и переход в + `downloading` не применяется diff --git a/openspec/changes/archive/2026-07-10-catched-promote-without-readd/tasks.md b/openspec/changes/archive/2026-07-10-catched-promote-without-readd/tasks.md new file mode 100644 index 0000000..609e0b5 --- /dev/null +++ b/openspec/changes/archive/2026-07-10-catched-promote-without-readd/tasks.md @@ -0,0 +1,37 @@ +## 1. Усыновление в processCatched + +- [x] 1.1 В начале `processCatched` один раз получить листинг `qbt.Torrents("")` + и построить `byHash` (по `Hash`/`InfohashV1`/`InfohashV2`, lowercase); провал + листинга → WARN и ранний выход (пойманные не трогаем этот тик, namer не зовём). +- [x] 1.2 Для каждой catched-задачи: `torrentFor(d, byHash)`. Присутствует → + усыновление БЕЗ namer/Add: под замком с ре-валидацией `state='catched'` + `PromoteCatched(id, t.Name)` (`catched → downloading`, имя из раздачи снимка). +- [x] 1.3 Отсутствует в `byHash` — прежний путь, но с re-read записи под `w.mu` + перед сбором `addReq` (свежий `source_type`, ре-валидация `state='catched'`); + тяжёлые вызовы (`GetTorrentData`, namer, `qbt.Add`) — вне замка. +- [x] 1.4 Убедиться, что namer и `qbt.Add` не вызываются на ветке присутствия и + при провале листинга. + +## 2. Сообщение о дубле на приёме + +- [x] 2.1 В `internal/tgbot/bot.go` (`ingestAndReply`) на `res.Deduplicated` + отвечать явным «♻️ Дубль уже активной загрузки #id — добавление отменено» + (вместо «Уже в работе #id»). Лог дедупа в `ingest` уже есть. + +## 3. Тесты + +- [x] 3.1 `catched_test.go`: торрент присутствует в снимке qBittorrent → + `downloading` без `Add` и без namer; `display_name` = имя раздачи. (Фейк qBit + отдаёт торрент в снимке ДО обработки задачи.) +- [x] 3.2 `catched_test.go`: листинг qBittorrent провалился → задача осталась + `catched`, namer/Add не вызывались. +- [x] 3.3 `catched_test.go`: торрента нет в снимке → обычный путь (namer + Add + + promote) остаётся зелёным; re-read `source_type` под замком берёт актуальный тип. +- [x] 3.4 Регресс: catch_timeout-предохранитель, отмена во время добавления. + +## 4. Проверки и ревью + +- [x] 4.1 `task test` и `task lint` зелёные. +- [x] 4.2 Ревью кода (чекпоинт перед archive): jellybit-review-code + + jellybit-review-specs (сверка со спекой download-tracking). +- [x] 4.3 `openspec validate --strict catched-promote-without-readd`. diff --git a/openspec/specs/download-tracking/spec.md b/openspec/specs/download-tracking/spec.md index ce5bf8b..9538928 100644 --- a/openspec/specs/download-tracking/spec.md +++ b/openspec/specs/download-tracking/spec.md @@ -95,12 +95,34 @@ Worker SHALL периодически сверять раздачи qBittorrent ### Requirement: Добавление пойманной загрузки в qBittorrent Worker SHALL периодически (в поллинг-цикле, под единой блокировкой переходов) -подхватывать загрузки в состоянии `catched` и для каждой: вывести отображаемое -имя из контекста (см. `ingest` «Отображаемое имя торрента из контекста»), -добавить источник в qBittorrent (категория `qbittorrent.category`, savepath, -`rename`) и перевести загрузку `catched → downloading`. Отдельного состояния -между `catched` и `downloading` быть SHALL NOT — успешный `add` сразу переводит -в `downloading` (которое и означает «в qBit, возможно `metaDL`»). +подхватывать загрузки в состоянии `catched` и для каждой (кроме случая уже +присутствующего в qBittorrent торрента, см. ниже): вывести отображаемое имя из +контекста (см. `ingest` «Отображаемое имя торрента из контекста»), добавить +источник в qBittorrent (категория `qbittorrent.category`, savepath, `rename`) и +перевести загрузку `catched → downloading`. Отдельного состояния между `catched` +и `downloading` быть SHALL NOT — успешный `add` сразу переводит в `downloading` +(которое и означает «в qBit, возможно `metaDL`»). + +Перед добавлением worker SHALL проверять, **присутствует ли торрент загрузки уже +в qBittorrent** (по любому из её infohash), опираясь на листинг раздач того же +тика. Если торрент уже присутствует, worker SHALL **усыновить** его: перевести +загрузку `catched → downloading` **без повторного `add`** и без вывода имени +через LLM (`display_name` берётся из имени присутствующей раздачи). Повторный +`add` здесь не нужен и вреден — qBittorrent отверг бы дубль (напр. `409 +Conflict`), и загрузка зациклилась бы на ретраях. Усыновлённая раздача дальше +идёт обычным путём отслеживания и раскладки. Проверка присутствия SHALL +выполняться **до вывода отображаемого имени**, чтобы не тратить LLM-вызов на +загрузку, которую добавлять не требуется. + +Инвариант приёма («одна активная загрузка на infohash», см. `ingest`) гарантирует, +что до этого шага доходит лишь загрузка, для которой в jellybit НЕТ другой +активной задачи; поэтому присутствие торрента в qBittorrent worker трактует как +«усыновить и разложить», а не как конфликт с чужой задачей. + +Если листинг раздач qBittorrent недоступен (сетевой сбой), worker пойманную +загрузку в этот тик трогать SHALL NOT (ни `add`, ни namer) и повторить на +следующем; устойчивая недоступность отсекается предохранителем `catch_timeout` +(см. «Предохранитель зависшего catched»). Добавление в qBittorrent worker SHALL выполнять **по типу источника** (`source_type`): @@ -114,21 +136,28 @@ Worker SHALL периодически (в поллинг-цикле, под ед метаданные (qBittorrent стартует без докачки), поэтому воскрешать раздачу по magnet-хешу вместо файла система SHALL NOT. -Неуспешный `add` (qBittorrent недоступен и т.п.) SHALL оставлять загрузку в -`catched` для повторной попытки на следующем тике; переход в терминальное -состояние по единичному сбою происходить SHALL NOT (ретраи — естественными -тиками поллинга). +`source_type` для выбора способа добавления worker SHALL перечитывать **под +блокировкой переходов** непосредственно перед добавлением (а не полагаться на +снимок, снятый ранее вне блокировки): иначе при точном оверлапе тика с апгрейдом +пойманной magnet-задачи до `.torrent` (см. `ingest`) воркер добавил бы magnet из +устаревшего снимка, хотя БД уже `torrent`. + +Неуспешный `add` (qBittorrent временно отверг/недоступен) SHALL оставлять +загрузку в `catched` для повторной попытки на следующем тике; переход в +терминальное состояние по единичному сбою происходить SHALL NOT (ретраи — +естественными тиками поллинга, отсечка — `catch_timeout`). Медленные вызовы (вывод имени через LLM, `qbt.Add`) SHALL выполняться **вне** блокировки сериализации переходов, чтобы не задерживать команды транспортов и поллинг. Под блокировкой сериализуется только **запись перехода** `catched → -downloading` (см. «Переходы состояний сериализуются воркером»), с -ре-валидацией, что загрузка всё ещё в `catched` (иначе переход отклоняется — -например, при параллельной отмене). +downloading` (см. «Переходы состояний сериализуются воркером»), с ре-валидацией, +что загрузка всё ещё в `catched` (иначе переход отклоняется — например, при +параллельной отмене). #### Scenario: Пойманная magnet-загрузка добавляется в qBittorrent -- **GIVEN** загрузка в состоянии `catched` с `source_type = magnet` +- **GIVEN** загрузка в состоянии `catched` с `source_type = magnet`, торрента + ещё нет в qBittorrent - **WHEN** worker обрабатывает тик - **THEN** выводится отображаемое имя, ссылка добавляется в qBittorrent с нашей категорией и `rename` @@ -137,16 +166,33 @@ downloading` (см. «Переходы состояний сериализуют #### Scenario: Пойманная .torrent-загрузка добавляется файлом - **GIVEN** загрузка в состоянии `catched` с `source_type = torrent` и - сохранёнными байтами файла + сохранёнными байтами файла, торрента ещё нет в qBittorrent - **WHEN** worker обрабатывает тик - **THEN** сохранённые байты добавляются в qBittorrent файлом (`torrents`), с нашей категорией и `rename`, без обращения к magnet-хешу - **AND** загрузка переходит в `downloading` +#### Scenario: Торрент уже присутствует в qBittorrent — усыновление без add + +- **GIVEN** загрузка в состоянии `catched`, торрент которой уже присутствует в + qBittorrent (добавлен ранее вручную/другим клиентом либо `add` прошёл на + прошлом тике, а запись перехода не удалась) +- **WHEN** worker обрабатывает тик +- **THEN** worker НЕ вызывает `qbt.Add` и НЕ выводит отображаемое имя через LLM +- **AND** `display_name` записывается из имени присутствующей раздачи +- **AND** загрузка переходит в `downloading` и идёт обычным путём к раскладке + +#### Scenario: qBittorrent недоступен при проверке присутствия — повтор + +- **GIVEN** загрузка в `catched`, листинг раздач qBittorrent не удался +- **WHEN** worker обрабатывает тик +- **THEN** worker НЕ вызывает namer и НЕ добавляет источник +- **AND** загрузка остаётся в `catched` и попытка повторяется на следующем тике + #### Scenario: Временный сбой добавления — повтор -- **GIVEN** загрузка в `catched`, qBittorrent временно недоступен -- **WHEN** worker пытается добавить источник и `add` не удался +- **GIVEN** загрузка в `catched`, торрента в qBittorrent нет, но `add` не удался +- **WHEN** worker пытается добавить источник и `add` возвращает ошибку - **THEN** загрузка остаётся в `catched` - **AND** на следующем тике попытка добавления повторяется