Приём: усыновление присутствующего в 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:
av
2026-07-10 18:36:47 +03:00
co-authored by Claude Opus 4.8
parent 0c9421f4c1
commit b8657120fe
13 changed files with 544 additions and 47 deletions
+81
View File
@@ -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")