Быстрый приём: сохранение в catched, добавление в qBittorrent — шаг worker'а
Приём (Ingest) стал быстрым: синхронно только парс magnet, синтез контекста из полей ссылки, атомарный дедуп и запись загрузки в новое состояние `catched` — ответ клиенту сразу. Медленный вывод имени (LLM) и добавление в qBittorrent вынесены в асинхронный шаг машины состояний, который двигает worker. - store: состояние `catched` (нетерминальное, активная группа); атомарный переход PromoteCatched (catched → downloading + display_name) с гардом state='catched' (ре-валидация после сетевых вызовов вне блокировки) - ingest: убраны namer/qbt из пути приёма; пишем `catched`, отвечаем сразу - worker.processCatched: вне w.mu выводит имя и qbt.Add, под w.mu — короткий переход; сбой add оставляет catched (ретрай тиком); предохранитель catch_timeout → failed(qbit_add)+notify; catched исключён из проверок пропажи - config: worker.catch_timeout (дефолт 10m) - веб-UI: бейдж catched, активная группа, самозавершающийся htmx-поллинг карточки/страницы до перехода в downloading; Telegram-текст без сырого catched - OpenSpec: дельты ingest/download-tracking/web-ui влиты в спеки, change заархивирован Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
+64
-205
@@ -2,15 +2,12 @@ package ingest
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"log/slog"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.vakhrushev.me/av/jellybit/internal/ident"
|
||||
"git.vakhrushev.me/av/jellybit/internal/qbt"
|
||||
"git.vakhrushev.me/av/jellybit/internal/store"
|
||||
)
|
||||
|
||||
@@ -19,18 +16,10 @@ const sampleMagnet = "magnet:?xt=urn:btih:541ADCFF3B6DD5DBA7088EA83317D9D6FAC331
|
||||
const sampleInfohash = "541adcff3b6dd5dba7088ea83317d9d6fac331d6"
|
||||
|
||||
type fakeStore struct {
|
||||
active *store.Download
|
||||
created []store.Download
|
||||
hashes [][]string
|
||||
toppedUp []string
|
||||
stateCalls []stateCall
|
||||
}
|
||||
|
||||
type stateCall struct {
|
||||
id string
|
||||
state store.State
|
||||
code string
|
||||
msg string
|
||||
active *store.Download
|
||||
created []store.Download
|
||||
hashes [][]string
|
||||
toppedUp []string
|
||||
}
|
||||
|
||||
func (f *fakeStore) FindActiveByInfohash(_ context.Context, _ ...string) (*store.Download, error) {
|
||||
@@ -53,123 +42,101 @@ func (f *fakeStore) AddInfohashes(_ context.Context, id string, hashes []string)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *fakeStore) SetDownloadState(_ context.Context, id string, st store.State, code, msg string) error {
|
||||
f.stateCalls = append(f.stateCalls, stateCall{id, st, code, msg})
|
||||
return nil
|
||||
func newService(st Store) *Service {
|
||||
return New(st, slog.New(slog.NewTextHandler(io.Discard, nil)))
|
||||
}
|
||||
|
||||
type fakeQbt struct {
|
||||
added []qbt.AddRequest
|
||||
err error
|
||||
}
|
||||
|
||||
func (f *fakeQbt) Add(_ context.Context, ar qbt.AddRequest) error {
|
||||
if f.err != nil {
|
||||
return f.err
|
||||
}
|
||||
f.added = append(f.added, ar)
|
||||
return nil
|
||||
}
|
||||
|
||||
// fakeNamer возвращает заранее заданное имя; фиксирует переданные аргументы.
|
||||
type fakeNamer struct {
|
||||
name string
|
||||
gotContext string
|
||||
gotHint string
|
||||
called bool
|
||||
}
|
||||
|
||||
func (f *fakeNamer) DeriveName(_ context.Context, contextText, hint string) string {
|
||||
f.called = true
|
||||
f.gotContext = contextText
|
||||
f.gotHint = hint
|
||||
return f.name
|
||||
}
|
||||
|
||||
func newService(st Store, qb QBittorrent) *Service {
|
||||
return newServiceWithNamer(st, qb, nil)
|
||||
}
|
||||
|
||||
func newServiceWithNamer(st Store, qb QBittorrent, nm Namer) *Service {
|
||||
return New(st, qb, nm, Config{Category: "jellybit", SavePath: "/srv/media/downloads"},
|
||||
slog.New(slog.NewTextHandler(io.Discard, nil)))
|
||||
}
|
||||
|
||||
func TestIngestHappyPath(t *testing.T) {
|
||||
// Быстрый приём: сохраняем загрузку в catched и сразу отвечаем; qBittorrent и
|
||||
// вывод имени в пути приёма не участвуют (это делает worker).
|
||||
func TestIngestCatchesFast(t *testing.T) {
|
||||
fs := &fakeStore{}
|
||||
fq := &fakeQbt{}
|
||||
res, err := newService(fs, fq).Ingest(context.Background(), Request{Source: sampleMagnet, Context: "Дюна 2"})
|
||||
res, err := newService(fs).Ingest(context.Background(), Request{Source: sampleMagnet, Context: "Дюна 2"})
|
||||
if err != nil {
|
||||
t.Fatalf("Ingest: %v", err)
|
||||
}
|
||||
if len(res.Infohashes) != 1 || res.Infohashes[0] != sampleInfohash {
|
||||
t.Errorf("infohashes = %v", res.Infohashes)
|
||||
}
|
||||
if res.State != store.StateDownloading || res.Deduplicated {
|
||||
if res.State != store.StateCatched || res.Deduplicated {
|
||||
t.Errorf("res = %+v", res)
|
||||
}
|
||||
if len(fs.created) != 1 {
|
||||
t.Fatalf("создано задач: %d, want 1", len(fs.created))
|
||||
}
|
||||
got := fs.created[0]
|
||||
if got.State != store.StateCatched {
|
||||
t.Errorf("state задачи = %q, want catched", got.State)
|
||||
}
|
||||
// Имя выводит worker на шаге добавления — при приёме display_name пуст.
|
||||
if got.DisplayName != "" {
|
||||
t.Errorf("display_name при приёме = %q, want пусто", got.DisplayName)
|
||||
}
|
||||
// download.Context = пользовательский текст + синтез из полей magnet
|
||||
// (dn=Dune). Текст пользователя идёт первым.
|
||||
if got := fs.created[0].Context; !strings.HasPrefix(got, "Дюна 2") || !strings.Contains(got, "Dune") {
|
||||
t.Errorf("сохранённый контекст = %q", got)
|
||||
if !strings.HasPrefix(got.Context, "Дюна 2") || !strings.Contains(got.Context, "Dune") {
|
||||
t.Errorf("сохранённый контекст = %q", got.Context)
|
||||
}
|
||||
if len(fs.hashes) != 1 || len(fs.hashes[0]) != 1 || fs.hashes[0][0] != sampleInfohash {
|
||||
t.Errorf("хеши задачи: %v", fs.hashes)
|
||||
}
|
||||
if len(fq.added) != 1 {
|
||||
t.Fatalf("вызовов qbt.Add: %d, want 1", len(fq.added))
|
||||
}
|
||||
|
||||
// Голый magnet без текста: download.Context синтезируется из полей ссылки
|
||||
// (dn-имя + размер), приём проходит штатно.
|
||||
func TestIngestMagnetOnlySynthesizesContext(t *testing.T) {
|
||||
const raw = "magnet:?xt=urn:btih:541ADCFF3B6DD5DBA7088EA83317D9D6FAC331D6" +
|
||||
"&dn=Dune.Part.Two.2024.2160p&xl=2200000000"
|
||||
fs := &fakeStore{}
|
||||
if _, err := newService(fs).Ingest(context.Background(), Request{Source: raw}); err != nil {
|
||||
t.Fatalf("Ingest: %v", err)
|
||||
}
|
||||
add := fq.added[0]
|
||||
if len(add.URLs) != 1 || add.URLs[0] != sampleMagnet {
|
||||
t.Errorf("URLs = %v", add.URLs)
|
||||
if len(fs.created) != 1 {
|
||||
t.Fatalf("создано задач: %d, want 1", len(fs.created))
|
||||
}
|
||||
if add.Category != "jellybit" || add.SavePath != "/srv/media/downloads" {
|
||||
t.Errorf("category/savepath = %q/%q", add.Category, add.SavePath)
|
||||
ctx := fs.created[0].Context
|
||||
if !strings.Contains(ctx, "Dune.Part.Two.2024.2160p") || !strings.Contains(ctx, "Размер:") {
|
||||
t.Errorf("контекст не синтезирован из magnet: %q", ctx)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIngestSetsDisplayName(t *testing.T) {
|
||||
// Заглушка-dn (rutracker-topic-*) как строка-название в контекст не попадает,
|
||||
// но домен трекера — попадает (сигнал для recognition).
|
||||
func TestIngestSynthDropsStubName(t *testing.T) {
|
||||
const raw = "magnet:?xt=urn:btih:541ADCFF3B6DD5DBA7088EA83317D9D6FAC331D6" +
|
||||
"&dn=rutracker-topic-6514485&tr=http%3A%2F%2Fbt.t-ru.org%2Fann%3Fmagnet"
|
||||
fs := &fakeStore{}
|
||||
fq := &fakeQbt{}
|
||||
nm := &fakeNamer{name: "Дюна: Часть вторая (2024)"}
|
||||
_, err := newServiceWithNamer(fs, fq, nm).Ingest(context.Background(),
|
||||
Request{Source: sampleMagnet, Context: "Дюна 2"})
|
||||
if err != nil {
|
||||
if _, err := newService(fs).Ingest(context.Background(), Request{Source: raw}); err != nil {
|
||||
t.Fatalf("Ingest: %v", err)
|
||||
}
|
||||
if !nm.called || nm.gotContext != "Дюна 2" || nm.gotHint != "Dune" {
|
||||
t.Errorf("namer получил context=%q hint=%q (called=%v)", nm.gotContext, nm.gotHint, nm.called)
|
||||
got := fs.created[0].Context
|
||||
if !strings.Contains(got, "t-ru.org") {
|
||||
t.Errorf("download.Context не обогащён доменом трекера: %q", got)
|
||||
}
|
||||
if len(fq.added) != 1 || fq.added[0].Rename != "Дюна: Часть вторая (2024)" {
|
||||
t.Errorf("rename = %q, want %q", fq.added[0].Rename, "Дюна: Часть вторая (2024)")
|
||||
}
|
||||
// То же имя сохраняется у загрузки — заголовок в веб-UI.
|
||||
if len(fs.created) != 1 || fs.created[0].DisplayName != "Дюна: Часть вторая (2024)" {
|
||||
t.Errorf("display_name = %q, want %q", fs.created[0].DisplayName, "Дюна: Часть вторая (2024)")
|
||||
if strings.Contains(got, "rutracker-topic") {
|
||||
t.Errorf("заглушка-dn просочилась в контекст как имя: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIngestEmptyNameOmitsRename(t *testing.T) {
|
||||
// Реальная рутрекер-ссылка без текста: download.Context = релиз-заголовок из
|
||||
// dn (раскодирован) + домен трекера.
|
||||
func TestIngestRealRutrackerMagnetOnly(t *testing.T) {
|
||||
const raw = "magnet:?xt=urn:btih:BACA24E18C7382A9E9A44132C8D7DB86C4D319C2" +
|
||||
"&tr=http%3A%2F%2Fbt4.t-ru.org%2Fann%3Fmagnet" +
|
||||
"&dn=%D0%91%D1%83%D1%85%D1%82%D0%B0%20%D0%B2%D0%B4%D0%BE%D0%B2%20%2F%20Widow's%20Bay%20%2F%20%D0%A1%D0%B5%D0%B7%D0%BE%D0%BD%3A%201%20%5B2026%2C%20%D0%A1%D0%A8%D0%90%2C%20WEB-DL%201080p%5D"
|
||||
fs := &fakeStore{}
|
||||
fq := &fakeQbt{}
|
||||
nm := &fakeNamer{name: ""} // имя не получено
|
||||
if _, err := newServiceWithNamer(fs, fq, nm).Ingest(context.Background(),
|
||||
Request{Source: sampleMagnet}); err != nil {
|
||||
if _, err := newService(fs).Ingest(context.Background(), Request{Source: raw}); err != nil {
|
||||
t.Fatalf("Ingest: %v", err)
|
||||
}
|
||||
if len(fq.added) != 1 || fq.added[0].Rename != "" {
|
||||
t.Errorf("rename = %q, want пусто", fq.added[0].Rename)
|
||||
ctx := fs.created[0].Context
|
||||
if !strings.Contains(ctx, "Widow's Bay") || !strings.Contains(ctx, "Трекер: t-ru.org") {
|
||||
t.Errorf("download.Context не обогащён: %q", ctx)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIngestIdempotent(t *testing.T) {
|
||||
existing := &store.Download{ID: "01hzzzexisting000000000000", State: store.StateDownloading}
|
||||
existing := &store.Download{ID: "01hzzzexisting000000000000", State: store.StateCatched}
|
||||
fs := &fakeStore{active: existing}
|
||||
fq := &fakeQbt{}
|
||||
res, err := newService(fs, fq).Ingest(context.Background(), Request{Source: sampleMagnet})
|
||||
res, err := newService(fs).Ingest(context.Background(), Request{Source: sampleMagnet})
|
||||
if err != nil {
|
||||
t.Fatalf("Ingest: %v", err)
|
||||
}
|
||||
@@ -179,9 +146,6 @@ func TestIngestIdempotent(t *testing.T) {
|
||||
if len(fs.created) != 0 {
|
||||
t.Error("не должно создаваться новой задачи")
|
||||
}
|
||||
if len(fq.added) != 0 {
|
||||
t.Error("не должно быть повторного добавления в qBittorrent")
|
||||
}
|
||||
}
|
||||
|
||||
// Быстрый дедуп-путь доносит существующей задаче недостающие хеши
|
||||
@@ -190,11 +154,11 @@ func TestIngestIdempotent(t *testing.T) {
|
||||
func TestIngestDedupTopsUpHashes(t *testing.T) {
|
||||
const v2 = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
|
||||
existing := &store.Download{
|
||||
ID: "01hzzzexisting000000000000", State: store.StateDownloading,
|
||||
ID: "01hzzzexisting000000000000", State: store.StateCatched,
|
||||
Infohashes: []store.Infohash{{DownloadID: "01hzzzexisting000000000000", Infohash: sampleInfohash, Kind: store.HashV1}},
|
||||
}
|
||||
fs := &fakeStore{active: existing}
|
||||
res, err := newService(fs, &fakeQbt{}).Ingest(context.Background(),
|
||||
res, err := newService(fs).Ingest(context.Background(),
|
||||
Request{Source: sampleMagnet + "&xt=urn:btmh:1220" + v2})
|
||||
if err != nil {
|
||||
t.Fatalf("Ingest: %v", err)
|
||||
@@ -207,117 +171,12 @@ func TestIngestDedupTopsUpHashes(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// Голый magnet без текста: download.Context синтезируется из полей ссылки
|
||||
// (dn-имя + размер), приём проходит штатно.
|
||||
func TestIngestMagnetOnlySynthesizesContext(t *testing.T) {
|
||||
const raw = "magnet:?xt=urn:btih:541ADCFF3B6DD5DBA7088EA83317D9D6FAC331D6" +
|
||||
"&dn=Dune.Part.Two.2024.2160p&xl=2200000000"
|
||||
fs := &fakeStore{}
|
||||
if _, err := newService(fs, &fakeQbt{}).Ingest(context.Background(),
|
||||
Request{Source: raw}); err != nil {
|
||||
t.Fatalf("Ingest: %v", err)
|
||||
}
|
||||
if len(fs.created) != 1 {
|
||||
t.Fatalf("создано задач: %d, want 1", len(fs.created))
|
||||
}
|
||||
ctx := fs.created[0].Context
|
||||
if !strings.Contains(ctx, "Dune.Part.Two.2024.2160p") || !strings.Contains(ctx, "Размер:") {
|
||||
t.Errorf("контекст не синтезирован из magnet: %q", ctx)
|
||||
}
|
||||
}
|
||||
|
||||
// Регресс B1: строки-факты синтеза (Трекер:/Размер:) не должны становиться
|
||||
// отображаемым именем. Namer получает СЫРОЙ контекст (+ dn-hint), а domain
|
||||
// уходит только в download.Context для recognition. Заглушка-dn как строка-
|
||||
// название в контекст не попадает.
|
||||
func TestIngestSynthFactsNeverBecomeName(t *testing.T) {
|
||||
const raw = "magnet:?xt=urn:btih:541ADCFF3B6DD5DBA7088EA83317D9D6FAC331D6" +
|
||||
"&dn=rutracker-topic-6514485&tr=http%3A%2F%2Fbt.t-ru.org%2Fann%3Fmagnet"
|
||||
fs := &fakeStore{}
|
||||
fq := &fakeQbt{}
|
||||
nm := &fakeNamer{name: "rutracker-topic-6514485"} // как вывел бы фолбек из dn-hint
|
||||
if _, err := newServiceWithNamer(fs, fq, nm).Ingest(context.Background(),
|
||||
Request{Source: raw}); err != nil {
|
||||
t.Fatalf("Ingest: %v", err)
|
||||
}
|
||||
if nm.gotContext != "" {
|
||||
t.Errorf("namer получил не сырой контекст: %q", nm.gotContext)
|
||||
}
|
||||
if strings.Contains(fq.added[0].Rename, "Трекер") {
|
||||
t.Errorf("строка-факт просочилась в rename: %q", fq.added[0].Rename)
|
||||
}
|
||||
got := fs.created[0].Context
|
||||
if !strings.Contains(got, "t-ru.org") {
|
||||
t.Errorf("download.Context не обогащён доменом трекера: %q", got)
|
||||
}
|
||||
if strings.Contains(got, "rutracker-topic") {
|
||||
t.Errorf("заглушка-dn просочилась в контекст как имя: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// Реальная рутрекер-ссылка без текста: download.Context = релиз-заголовок из
|
||||
// dn (раскодирован) + домен трекера; namer получает пустой контекст и dn-hint.
|
||||
func TestIngestRealRutrackerMagnetOnly(t *testing.T) {
|
||||
const raw = "magnet:?xt=urn:btih:BACA24E18C7382A9E9A44132C8D7DB86C4D319C2" +
|
||||
"&tr=http%3A%2F%2Fbt4.t-ru.org%2Fann%3Fmagnet" +
|
||||
"&dn=%D0%91%D1%83%D1%85%D1%82%D0%B0%20%D0%B2%D0%B4%D0%BE%D0%B2%20%2F%20Widow's%20Bay%20%2F%20%D0%A1%D0%B5%D0%B7%D0%BE%D0%BD%3A%201%20%5B2026%2C%20%D0%A1%D0%A8%D0%90%2C%20WEB-DL%201080p%5D"
|
||||
fs := &fakeStore{}
|
||||
nm := &fakeNamer{name: "Бухта вдов (2026)"}
|
||||
if _, err := newServiceWithNamer(fs, &fakeQbt{}, nm).Ingest(context.Background(),
|
||||
Request{Source: raw}); err != nil {
|
||||
t.Fatalf("Ingest: %v", err)
|
||||
}
|
||||
if nm.gotContext != "" {
|
||||
t.Errorf("namer получил не сырой контекст: %q", nm.gotContext)
|
||||
}
|
||||
ctx := fs.created[0].Context
|
||||
if !strings.Contains(ctx, "Widow's Bay") || !strings.Contains(ctx, "Трекер: t-ru.org") {
|
||||
t.Errorf("download.Context не обогащён: %q", ctx)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIngestQbitErrorMarksFailed(t *testing.T) {
|
||||
fs := &fakeStore{}
|
||||
fq := &fakeQbt{err: errors.New("connection refused")}
|
||||
res, err := newService(fs, fq).Ingest(context.Background(), Request{Source: sampleMagnet})
|
||||
if err == nil {
|
||||
t.Fatal("ожидалась ошибка")
|
||||
}
|
||||
if res.State != store.StateFailed {
|
||||
t.Errorf("state = %q, want failed", res.State)
|
||||
}
|
||||
if len(fs.stateCalls) != 1 || fs.stateCalls[0].state != store.StateFailed {
|
||||
t.Errorf("ожидался перевод в failed: %+v", fs.stateCalls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIngestQbitErrorNotifies(t *testing.T) {
|
||||
fs := &fakeStore{}
|
||||
fq := &fakeQbt{err: errors.New("connection refused")}
|
||||
svc := newService(fs, fq)
|
||||
got := make(chan string, 1)
|
||||
svc.SetFailureNotifier(func(id string) { got <- id })
|
||||
|
||||
if _, err := svc.Ingest(context.Background(), Request{Source: sampleMagnet}); err == nil {
|
||||
t.Fatal("ожидалась ошибка")
|
||||
}
|
||||
select {
|
||||
case id := <-got:
|
||||
if id == "" {
|
||||
t.Errorf("уведомление с пустым id")
|
||||
}
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("уведомление о падении приёма не пришло")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIngestRejectsNonMagnet(t *testing.T) {
|
||||
fs := &fakeStore{}
|
||||
fq := &fakeQbt{}
|
||||
if _, err := newService(fs, fq).Ingest(context.Background(), Request{Source: "https://example.com/x.torrent"}); err == nil {
|
||||
if _, err := newService(fs).Ingest(context.Background(), Request{Source: "https://example.com/x.torrent"}); err == nil {
|
||||
t.Fatal("ожидалась ошибка для не-magnet источника")
|
||||
}
|
||||
if len(fs.created) != 0 || len(fq.added) != 0 {
|
||||
t.Error("не должно быть ни записи, ни добавления")
|
||||
if len(fs.created) != 0 {
|
||||
t.Error("не должно быть записи задачи")
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user