Приём: усыновление присутствующего в qBittorrent торрента вместо дубль-Add (409)
processCatched перед Add проверяет присутствие торрента в qBittorrent (один листинг на тик): если раздача уже есть — усыновляем (promote catched→downloading без повторного Add и без LLM-namer, имя из раздачи), иначе добавляем как раньше. Это убирает бесконечный цикл дубль-Add → 409 → ретрай и лишние вызовы LLM. Инвариант приёма «одна активная на infohash» делает различие «наш/чужой» ненужным. source_type перечитывается под замком (сужение гонки апгрейда F6); при недоступности qBittorrent тик пропускается без вызова LLM. Дедуп на приёме (дубль на уже активную задачу) теперь отражается явным ответом бота «дубль уже активной #id — добавление отменено». Спека download-tracking обновлена (OpenSpec change заархивирован); закрыта задача беклога review-f2-promote-without-add. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -36,7 +36,6 @@ Tududi (проект `jellybit`) больше **не** держит беклог
|
|||||||
- [Confidence-гейт авто-раскладки: узаконить в спеке + сделать выключаемым (дефолт 0.7)](gate-confidence-spec-vs-code.md) — Решено (B): гейт оставляем как доп. проверку на ревью — выключаемый порог, дефолт 0.85→0.7, записать в спеку
|
- [Confidence-гейт авто-раскладки: узаконить в спеке + сделать выключаемым (дефолт 0.7)](gate-confidence-spec-vs-code.md) — Решено (B): гейт оставляем как доп. проверку на ревью — выключаемый порог, дефолт 0.85→0.7, записать в спеку
|
||||||
- [Внешние субтитры: пары VobSub и языковой суффикс](vneshnie-subtitry.md) — Привязка субтитр→серия уже работает; остались пары VobSub .idx+.sub и потеря Lang/Flags
|
- [Внешние субтитры: пары 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)_
|
- [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)_
|
- [Cancel во время add оставляет неуправляемый торрент в qBittorrent (F3/NIT-13)](review-f3-cancel-during-add.md) — Cancel во время add оставляет неуправляемый торрент в qBittorrent _(ревью 2026-07-08)_
|
||||||
|
|
||||||
## Низкий
|
## Низкий
|
||||||
|
|||||||
@@ -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 + код).
|
|
||||||
@@ -266,11 +266,12 @@ func (b *Bot) ingestAndReply(ctx context.Context, chatID int64, req ingest.Reque
|
|||||||
b.send(chatID, opErr("Не удалось принять загрузку", res.DownloadID), nil)
|
b.send(chatID, opErr("Не удалось принять загрузку", res.DownloadID), nil)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
msg := fmt.Sprintf("Принято #%s — добавляю в qBittorrent.", res.DownloadID)
|
|
||||||
if res.Deduplicated {
|
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-файл или перешлите сообщение торрент-бота — поставлю на закачку.
|
const helpText = `jellybit-бот: пришлите magnet-ссылку, .torrent-файл или перешлите сообщение торрент-бота — поставлю на закачку.
|
||||||
|
|||||||
@@ -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) {
|
func TestBot_DeniesUnknownUser(t *testing.T) {
|
||||||
b, api, ing, _ := newTestBot(t, []int64{7})
|
b, api, ing, _ := newTestBot(t, []int64{7})
|
||||||
b.handleMessage(context.Background(), msgFrom(999, "magnet:?xt=urn:btih:ABC"))
|
b.handleMessage(context.Background(), msgFrom(999, "magnet:?xt=urn:btih:ABC"))
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"git.vakhrushev.me/av/jellybit/internal/qbt"
|
||||||
"git.vakhrushev.me/av/jellybit/internal/store"
|
"git.vakhrushev.me/av/jellybit/internal/store"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -14,10 +15,12 @@ import (
|
|||||||
type fakeNamer struct {
|
type fakeNamer struct {
|
||||||
name string
|
name string
|
||||||
gotContext string
|
gotContext string
|
||||||
|
calls int
|
||||||
onCall func()
|
onCall func()
|
||||||
}
|
}
|
||||||
|
|
||||||
func (f *fakeNamer) DeriveName(_ context.Context, contextText, _ string) string {
|
func (f *fakeNamer) DeriveName(_ context.Context, contextText, _ string) string {
|
||||||
|
f.calls++
|
||||||
f.gotContext = contextText
|
f.gotContext = contextText
|
||||||
if f.onCall != nil {
|
if f.onCall != nil {
|
||||||
f.onCall()
|
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 для повтора на следующем тике.
|
// Транзиентный сбой add — остаёмся в catched для повтора на следующем тике.
|
||||||
func TestProcessCatchedTransientFailureKeepsCatched(t *testing.T) {
|
func TestProcessCatchedTransientFailureKeepsCatched(t *testing.T) {
|
||||||
st := catchedStore("1", catchedIH, nowStr, "ctx")
|
st := catchedStore("1", catchedIH, nowStr, "ctx")
|
||||||
|
|||||||
@@ -387,10 +387,34 @@ func (w *Worker) processCatched(ctx context.Context) {
|
|||||||
w.log.Warn("list catched failed", "capability", capIngest, "error", err)
|
w.log.Warn("list catched failed", "capability", capIngest, "error", err)
|
||||||
return
|
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 {
|
for _, d := range catched {
|
||||||
cctx := w.scoped(ctx, capIngest, d.ID, d.PrimaryInfohash())
|
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 w.cfg.CatchTimeout > 0 {
|
||||||
if age, ok := w.catchedAge(d); ok && age > w.cfg.CatchTimeout {
|
if age, ok := w.catchedAge(d); ok && age > w.cfg.CatchTimeout {
|
||||||
w.mu.Lock()
|
w.mu.Lock()
|
||||||
@@ -406,9 +430,20 @@ func (w *Worker) processCatched(ctx context.Context) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Вне w.mu: сбор параметров добавления по типу источника (для torrent —
|
// Раздачи в qBittorrent нет — обычный путь добавления. Перечитываем запись
|
||||||
// чтение байтов), вывод имени (потенциально медленный LLM) и добавление.
|
// под замком: (а) актуальный source_type (апгрейд magnet→torrent мог
|
||||||
hint, addReq, prepErr := w.sourceAddParts(cctx, d)
|
// случиться после снятия списка 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 {
|
if prepErr != nil {
|
||||||
// Байты torrent недоступны (не должно быть при штатном приёме) —
|
// Байты torrent недоступны (не должно быть при штатном приёме) —
|
||||||
// остаёмся в catched, повтор на следующем тике.
|
// остаёмся в catched, повтор на следующем тике.
|
||||||
@@ -417,13 +452,13 @@ func (w *Worker) processCatched(ctx context.Context) {
|
|||||||
}
|
}
|
||||||
var rename string
|
var rename string
|
||||||
if w.namer != nil {
|
if w.namer != nil {
|
||||||
rename = w.namer.DeriveName(cctx, d.Context, hint)
|
rename = w.namer.DeriveName(cctx, cur.Context, hint)
|
||||||
}
|
}
|
||||||
addReq.Rename = rename
|
addReq.Rename = rename
|
||||||
addErr := w.qbt.Add(cctx, addReq)
|
addErr := w.qbt.Add(cctx, addReq)
|
||||||
if addErr != nil {
|
if addErr != nil {
|
||||||
// Транзиентный сбой (qBit недоступен) — остаёмся в catched, повтор на
|
// Транзиентный сбой (qBit отверг/недоступен) — остаёмся в catched,
|
||||||
// следующем тике. Поведение вызова qBit уже залогировал клиент (ext.*).
|
// повтор на следующем тике. Вызов qBit уже залогировал клиент (ext.*).
|
||||||
logctx.From(cctx).Warn("catched add to qbittorrent failed, will retry", "error", addErr)
|
logctx.From(cctx).Warn("catched add to qbittorrent failed, will retry", "error", addErr)
|
||||||
continue
|
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 раздачи в
|
// catchedAge — возраст пойманной загрузки от created_at (у catched раздачи в
|
||||||
// qBittorrent ещё нет, added_on недоступен). ok=false — created_at не разобрать.
|
// qBittorrent ещё нет, added_on недоступен). ok=false — created_at не разобрать.
|
||||||
func (w *Worker) catchedAge(d store.Download) (time.Duration, bool) {
|
func (w *Worker) catchedAge(d store.Download) (time.Duration, bool) {
|
||||||
|
|||||||
@@ -269,6 +269,7 @@ func (f *fakeStore) SetCandidateChosen(_ context.Context, _, _ string) error { r
|
|||||||
type fakeQbt struct {
|
type fakeQbt struct {
|
||||||
torrents []qbt.Torrent
|
torrents []qbt.Torrent
|
||||||
torrentsErr error
|
torrentsErr error
|
||||||
|
onTorrents func() // вклинивается в момент листинга (симуляция гонки между снимком и re-read)
|
||||||
added []qbt.AddRequest
|
added []qbt.AddRequest
|
||||||
addErr error
|
addErr error
|
||||||
files []qbt.File
|
files []qbt.File
|
||||||
@@ -288,6 +289,9 @@ type renameCall struct {
|
|||||||
// регрессии: раздача, усыновлённая по тегу, имеет чужую категорию и не должна
|
// регрессии: раздача, усыновлённая по тегу, имеет чужую категорию и не должна
|
||||||
// теряться при поиске по infohash.
|
// теряться при поиске по infohash.
|
||||||
func (f *fakeQbt) Torrents(_ context.Context, category string) ([]qbt.Torrent, error) {
|
func (f *fakeQbt) Torrents(_ context.Context, category string) ([]qbt.Torrent, error) {
|
||||||
|
if f.onTorrents != nil {
|
||||||
|
f.onTorrents()
|
||||||
|
}
|
||||||
if f.torrentsErr != nil {
|
if f.torrentsErr != nil {
|
||||||
return nil, f.torrentsErr
|
return nil, f.torrentsErr
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,2 @@
|
|||||||
|
schema: spec-driven
|
||||||
|
created: 2026-07-10
|
||||||
@@ -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
|
||||||
|
|
||||||
|
Нет.
|
||||||
@@ -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
|
||||||
|
|
||||||
|
<!-- нет новых capability -->
|
||||||
|
|
||||||
|
### 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 — нет.**
|
||||||
+114
@@ -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` не применяется
|
||||||
@@ -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`.
|
||||||
@@ -95,12 +95,34 @@ Worker SHALL периодически сверять раздачи qBittorrent
|
|||||||
### Requirement: Добавление пойманной загрузки в qBittorrent
|
### Requirement: Добавление пойманной загрузки в qBittorrent
|
||||||
|
|
||||||
Worker SHALL периодически (в поллинг-цикле, под единой блокировкой переходов)
|
Worker SHALL периодически (в поллинг-цикле, под единой блокировкой переходов)
|
||||||
подхватывать загрузки в состоянии `catched` и для каждой: вывести отображаемое
|
подхватывать загрузки в состоянии `catched` и для каждой (кроме случая уже
|
||||||
имя из контекста (см. `ingest` «Отображаемое имя торрента из контекста»),
|
присутствующего в qBittorrent торрента, см. ниже): вывести отображаемое имя из
|
||||||
добавить источник в qBittorrent (категория `qbittorrent.category`, savepath,
|
контекста (см. `ingest` «Отображаемое имя торрента из контекста»), добавить
|
||||||
`rename`) и перевести загрузку `catched → downloading`. Отдельного состояния
|
источник в qBittorrent (категория `qbittorrent.category`, savepath, `rename`) и
|
||||||
между `catched` и `downloading` быть SHALL NOT — успешный `add` сразу переводит
|
перевести загрузку `catched → downloading`. Отдельного состояния между `catched`
|
||||||
в `downloading` (которое и означает «в qBit, возможно `metaDL`»).
|
и `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 выполнять **по типу источника**
|
Добавление в qBittorrent worker SHALL выполнять **по типу источника**
|
||||||
(`source_type`):
|
(`source_type`):
|
||||||
@@ -114,21 +136,28 @@ Worker SHALL периодически (в поллинг-цикле, под ед
|
|||||||
метаданные (qBittorrent стартует без докачки), поэтому воскрешать раздачу по
|
метаданные (qBittorrent стартует без докачки), поэтому воскрешать раздачу по
|
||||||
magnet-хешу вместо файла система SHALL NOT.
|
magnet-хешу вместо файла система SHALL NOT.
|
||||||
|
|
||||||
Неуспешный `add` (qBittorrent недоступен и т.п.) SHALL оставлять загрузку в
|
`source_type` для выбора способа добавления worker SHALL перечитывать **под
|
||||||
`catched` для повторной попытки на следующем тике; переход в терминальное
|
блокировкой переходов** непосредственно перед добавлением (а не полагаться на
|
||||||
состояние по единичному сбою происходить SHALL NOT (ретраи — естественными
|
снимок, снятый ранее вне блокировки): иначе при точном оверлапе тика с апгрейдом
|
||||||
тиками поллинга).
|
пойманной magnet-задачи до `.torrent` (см. `ingest`) воркер добавил бы magnet из
|
||||||
|
устаревшего снимка, хотя БД уже `torrent`.
|
||||||
|
|
||||||
|
Неуспешный `add` (qBittorrent временно отверг/недоступен) SHALL оставлять
|
||||||
|
загрузку в `catched` для повторной попытки на следующем тике; переход в
|
||||||
|
терминальное состояние по единичному сбою происходить SHALL NOT (ретраи —
|
||||||
|
естественными тиками поллинга, отсечка — `catch_timeout`).
|
||||||
|
|
||||||
Медленные вызовы (вывод имени через LLM, `qbt.Add`) SHALL выполняться **вне**
|
Медленные вызовы (вывод имени через LLM, `qbt.Add`) SHALL выполняться **вне**
|
||||||
блокировки сериализации переходов, чтобы не задерживать команды транспортов и
|
блокировки сериализации переходов, чтобы не задерживать команды транспортов и
|
||||||
поллинг. Под блокировкой сериализуется только **запись перехода** `catched →
|
поллинг. Под блокировкой сериализуется только **запись перехода** `catched →
|
||||||
downloading` (см. «Переходы состояний сериализуются воркером»), с
|
downloading` (см. «Переходы состояний сериализуются воркером»), с ре-валидацией,
|
||||||
ре-валидацией, что загрузка всё ещё в `catched` (иначе переход отклоняется —
|
что загрузка всё ещё в `catched` (иначе переход отклоняется — например, при
|
||||||
например, при параллельной отмене).
|
параллельной отмене).
|
||||||
|
|
||||||
#### Scenario: Пойманная magnet-загрузка добавляется в qBittorrent
|
#### Scenario: Пойманная magnet-загрузка добавляется в qBittorrent
|
||||||
|
|
||||||
- **GIVEN** загрузка в состоянии `catched` с `source_type = magnet`
|
- **GIVEN** загрузка в состоянии `catched` с `source_type = magnet`, торрента
|
||||||
|
ещё нет в qBittorrent
|
||||||
- **WHEN** worker обрабатывает тик
|
- **WHEN** worker обрабатывает тик
|
||||||
- **THEN** выводится отображаемое имя, ссылка добавляется в qBittorrent с
|
- **THEN** выводится отображаемое имя, ссылка добавляется в qBittorrent с
|
||||||
нашей категорией и `rename`
|
нашей категорией и `rename`
|
||||||
@@ -137,16 +166,33 @@ downloading` (см. «Переходы состояний сериализуют
|
|||||||
#### Scenario: Пойманная .torrent-загрузка добавляется файлом
|
#### Scenario: Пойманная .torrent-загрузка добавляется файлом
|
||||||
|
|
||||||
- **GIVEN** загрузка в состоянии `catched` с `source_type = torrent` и
|
- **GIVEN** загрузка в состоянии `catched` с `source_type = torrent` и
|
||||||
сохранёнными байтами файла
|
сохранёнными байтами файла, торрента ещё нет в qBittorrent
|
||||||
- **WHEN** worker обрабатывает тик
|
- **WHEN** worker обрабатывает тик
|
||||||
- **THEN** сохранённые байты добавляются в qBittorrent файлом (`torrents`), с
|
- **THEN** сохранённые байты добавляются в qBittorrent файлом (`torrents`), с
|
||||||
нашей категорией и `rename`, без обращения к magnet-хешу
|
нашей категорией и `rename`, без обращения к magnet-хешу
|
||||||
- **AND** загрузка переходит в `downloading`
|
- **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: Временный сбой добавления — повтор
|
#### Scenario: Временный сбой добавления — повтор
|
||||||
|
|
||||||
- **GIVEN** загрузка в `catched`, qBittorrent временно недоступен
|
- **GIVEN** загрузка в `catched`, торрента в qBittorrent нет, но `add` не удался
|
||||||
- **WHEN** worker пытается добавить источник и `add` не удался
|
- **WHEN** worker пытается добавить источник и `add` возвращает ошибку
|
||||||
- **THEN** загрузка остаётся в `catched`
|
- **THEN** загрузка остаётся в `catched`
|
||||||
- **AND** на следующем тике попытка добавления повторяется
|
- **AND** на следующем тике попытка добавления повторяется
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user