Приём: усыновление присутствующего в 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:
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user