Быстрый приём: сохранение в 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:
@@ -111,6 +111,10 @@ type Worker struct {
|
||||
PollInterval Duration `toml:"poll_interval"`
|
||||
StuckAfter Duration `toml:"stuck_after"`
|
||||
MagnetTimeout Duration `toml:"magnet_timeout"`
|
||||
// CatchTimeout — сколько пойманная (catched) загрузка может ждать добавления
|
||||
// в qBittorrent, прежде чем счесть его невозможным и увести задачу в failed.
|
||||
// Редкий предохранитель на случай устойчивой недоступности qBittorrent.
|
||||
CatchTimeout Duration `toml:"catch_timeout"`
|
||||
// SourceMissingThreshold — сколько подряд тиков сверки без раздачи в
|
||||
// qBittorrent нужно, чтобы счесть источник удалённым (дебаунс пропажи,
|
||||
// см. state-reconciliation). Любое появление раздачи сбрасывает счётчик.
|
||||
@@ -208,6 +212,7 @@ func Default() *Config {
|
||||
PollInterval: Duration(5 * time.Second),
|
||||
StuckAfter: Duration(time.Hour),
|
||||
MagnetTimeout: Duration(24 * time.Hour),
|
||||
CatchTimeout: Duration(10 * time.Minute),
|
||||
SourceMissingThreshold: 3,
|
||||
},
|
||||
Recognition: Recognition{AutoConfidenceThreshold: 0.85},
|
||||
|
||||
@@ -20,6 +20,7 @@ type downloadDetailView struct {
|
||||
Infohashes []string // все хеши загрузки (блок «Информация о торренте»)
|
||||
Context string
|
||||
State string
|
||||
SelfPoll bool // catched → страница сама опрашивает себя до перехода
|
||||
Error string
|
||||
ActionError string // ошибка действия на htmx-пути (своп download_main), не error_msg
|
||||
Note string
|
||||
@@ -99,6 +100,7 @@ func (s *server) buildDownloadView(id string, rd *worker.ReviewData) downloadDet
|
||||
Infohashes: d.HashList(),
|
||||
Context: d.Context,
|
||||
State: string(d.State),
|
||||
SelfPoll: d.State == store.StateCatched,
|
||||
Error: d.ErrorMsg.String,
|
||||
Note: desyncNote(d.State),
|
||||
CreatedAt: d.CreatedAt,
|
||||
|
||||
@@ -113,6 +113,9 @@ func NewRouter(d Deps) (http.Handler, error) {
|
||||
// Живые фрагменты телеметрии (htmx-поллинг; читают снимок воркера).
|
||||
r.Get("/fragments/downloads/{id}/progress", s.handleFragProgress)
|
||||
r.Get("/fragments/downloads/{id}/seeding", s.handleFragSeeding)
|
||||
// Карточка целиком: самополлинг catched до перехода в downloading (бейдж,
|
||||
// имя и появившийся прогресс обновляются без перезагрузки).
|
||||
r.Get("/fragments/downloads/{id}/card", s.handleFragCard)
|
||||
// Тело ревью для поллинга recognizing (htmx-своп до готового плана).
|
||||
r.Get("/fragments/downloads/{id}/review", s.handleFragReview)
|
||||
r.Post("/ui/downloads", s.handleUIAdd)
|
||||
@@ -195,6 +198,7 @@ type downloadView struct {
|
||||
Error string
|
||||
Terminal bool
|
||||
IsDownloading bool // активная загрузка → живой прогресс-бар + поллинг
|
||||
SelfPoll bool // catched → карточка сама опрашивает себя до перехода
|
||||
Progress progressView // живой прогресс (заполняется в handleIndex из снимка)
|
||||
Reviewable bool // review/deferred — есть экран ревью
|
||||
Undoable bool // done — можно откатить раскладку
|
||||
@@ -618,6 +622,7 @@ func (s *server) toView(d store.Download, now time.Time) downloadView {
|
||||
Error: d.ErrorMsg.String,
|
||||
Terminal: d.State.IsTerminal(),
|
||||
IsDownloading: d.State == store.StateDownloading,
|
||||
SelfPoll: d.State == store.StateCatched,
|
||||
Reviewable: d.State == store.StateReview || d.State == store.StateDeferred,
|
||||
Undoable: d.State == store.StateDone,
|
||||
Relinkable: d.State == store.StateReverted || d.State == store.StateCancelled ||
|
||||
|
||||
@@ -246,6 +246,33 @@ func TestIndexRenders(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// Карточка catched несёт самозавершающийся htmx-поллинг (обновится без
|
||||
// перезагрузки при переходе в downloading) и человекочитаемый бейдж.
|
||||
func TestCatchedCardSelfPolls(t *testing.T) {
|
||||
reader := &fakeReader{get: &store.Download{
|
||||
ID: tid, SourceType: store.SourceMagnet, SourceRef: "magnet:?xt=urn:btih:abc",
|
||||
State: store.StateCatched,
|
||||
}}
|
||||
srv := newServer(t, httpapi.Deps{Ingestor: &fakeIngestor{}, Commander: &fakeCommander{}, Reader: reader})
|
||||
|
||||
resp, err := http.Get(srv.URL + "/fragments/downloads/" + tid + "/card")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("status = %d", resp.StatusCode)
|
||||
}
|
||||
s := string(body)
|
||||
if !strings.Contains(s, `hx-get="/fragments/downloads/`+tid+`/card"`) {
|
||||
t.Errorf("карточка catched без самополлинга: %s", s)
|
||||
}
|
||||
if !strings.Contains(s, "принято, добавляется") {
|
||||
t.Errorf("нет подписи бейджа catched: %s", s)
|
||||
}
|
||||
}
|
||||
|
||||
type ingestErr string
|
||||
|
||||
func (e ingestErr) Error() string { return string(e) }
|
||||
|
||||
@@ -96,6 +96,26 @@ func (s *server) handleFragProgress(w http.ResponseWriter, r *http.Request) {
|
||||
s.render(w, "progress", buildProgress(id, active, l, ok))
|
||||
}
|
||||
|
||||
// handleFragCard отдаёт карточку списка целиком (htmx-самополлинг catched):
|
||||
// пока загрузка в catched, карточка опрашивает себя и по переходе в downloading
|
||||
// приносит обновлённый бейдж/имя и прогресс-поллер; выйдя из catched, свежая
|
||||
// карточка уже не несёт самополлинга — цикл завершается сам.
|
||||
func (s *server) handleFragCard(w http.ResponseWriter, r *http.Request) {
|
||||
id, err := pathID(r)
|
||||
if err != nil {
|
||||
http.Error(w, "не найдено", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
d, err := s.deps.Reader.GetDownload(r.Context(), id)
|
||||
if err != nil {
|
||||
s.fragErr(w, err, id)
|
||||
return
|
||||
}
|
||||
// layoutSize 0: у catched раскладки нет; в downloading размер берётся из
|
||||
// живого снимка внутри buildCardView.
|
||||
s.render(w, "card", s.buildCardView(*d, time.Now(), 0))
|
||||
}
|
||||
|
||||
// handleFragSeeding отдаёт партиал секции «Раздача» (htmx-поллинг).
|
||||
func (s *server) handleFragSeeding(w http.ResponseWriter, r *http.Request) {
|
||||
id, err := pathID(r)
|
||||
|
||||
@@ -40,6 +40,8 @@ func staticHandler(fsys fs.FS) http.Handler {
|
||||
// как есть, чтобы не терять его в UI.
|
||||
func badgeLabel(state string) string {
|
||||
switch state {
|
||||
case "catched":
|
||||
return "🎣 принято, добавляется"
|
||||
case "downloading":
|
||||
return "⬇ качается"
|
||||
case "completed":
|
||||
|
||||
+29
-99
@@ -1,6 +1,9 @@
|
||||
// Package ingest — use-case приёма загрузки, общий для всех транспортов
|
||||
// (HTTP, Telegram, CLI). Принимает источник + контекст, отдаёт источник в
|
||||
// qBittorrent и заводит/находит задачу в БД.
|
||||
// Package ingest — use-case быстрого приёма загрузки, общий для всех
|
||||
// транспортов (HTTP, Telegram, CLI). Синхронно только парсит источник,
|
||||
// синтезирует контекст из полей ссылки, дедуплицирует и сохраняет загрузку в
|
||||
// состоянии `catched`, сразу возвращая ответ. Вывод отображаемого имени
|
||||
// (медленный LLM) и добавление в qBittorrent — отдельный асинхронный шаг
|
||||
// worker'а (см. download-tracking).
|
||||
package ingest
|
||||
|
||||
import (
|
||||
@@ -11,21 +14,16 @@ import (
|
||||
|
||||
"git.vakhrushev.me/av/jellybit/internal/logctx"
|
||||
"git.vakhrushev.me/av/jellybit/internal/magnet"
|
||||
"git.vakhrushev.me/av/jellybit/internal/qbt"
|
||||
"git.vakhrushev.me/av/jellybit/internal/store"
|
||||
)
|
||||
|
||||
// capIngest — стадия приёма для поля capability в логах.
|
||||
const capIngest = "ingest"
|
||||
|
||||
// errCodeQbitAdd — error_code задачи, упавшей на добавлении источника в
|
||||
// qBittorrent (раздачи в qBittorrent нет, восстановлению не подлежит).
|
||||
const errCodeQbitAdd = "qbit_add"
|
||||
|
||||
// Store — нужная ingest часть хранилища.
|
||||
type Store interface {
|
||||
// FindActiveByInfohash — быстрый читающий дедуп-чек (до вызова LLM-namer);
|
||||
// авторитетная проверка — внутри CreateDownloadIfNoActive.
|
||||
// FindActiveByInfohash — быстрый читающий дедуп-чек; авторитетная проверка —
|
||||
// внутри CreateDownloadIfNoActive.
|
||||
FindActiveByInfohash(ctx context.Context, hashes ...string) (*store.Download, error)
|
||||
// CreateDownloadIfNoActive атомарно проверяет инвариант «одна активная
|
||||
// загрузка на infohash» и заводит задачу; вернувшаяся existing ≠ nil —
|
||||
@@ -34,50 +32,19 @@ type Store interface {
|
||||
// AddInfohashes доносит задаче недостающие хеши (guarded). Нужен на
|
||||
// быстром дедуп-пути, который не доходит до CreateDownloadIfNoActive.
|
||||
AddInfohashes(ctx context.Context, downloadID string, hashes []string) error
|
||||
SetDownloadState(ctx context.Context, id string, state store.State, errCode, errMsg string) error
|
||||
}
|
||||
|
||||
// QBittorrent — нужная ingest часть клиента qBittorrent.
|
||||
type QBittorrent interface {
|
||||
Add(ctx context.Context, ar qbt.AddRequest) error
|
||||
}
|
||||
|
||||
// Namer выводит человекочитаемое отображаемое имя торрента из контекста.
|
||||
// Пустой результат → имя в qBittorrent не задаём. nil → шаг пропускается.
|
||||
type Namer interface {
|
||||
DeriveName(ctx context.Context, contextText, hint string) string
|
||||
}
|
||||
|
||||
// Config — параметры добавления в qBittorrent.
|
||||
type Config struct {
|
||||
Category string
|
||||
SavePath string
|
||||
}
|
||||
|
||||
// Service — реализация приёма.
|
||||
// Service — реализация быстрого приёма.
|
||||
type Service struct {
|
||||
store Store
|
||||
qbt QBittorrent
|
||||
namer Namer
|
||||
cfg Config
|
||||
log *slog.Logger
|
||||
|
||||
// notifyFailed — опц. пинг автору о падении приёма (добавление в qBittorrent
|
||||
// не удалось). Closure, а не worker.Notifier: приёмное падение в qBit не
|
||||
// попадает в поллинг-цикл worker (раздачи нет), поэтому уведомляет ingest
|
||||
// сам; closure избавляет ядро приёма от зависимости на пакет worker.
|
||||
notifyFailed func(downloadID string)
|
||||
}
|
||||
|
||||
// New собирает сервис приёма. namer опционален (nil → отображаемое имя не
|
||||
// выводится; qBittorrent оставит своё).
|
||||
func New(st Store, qb QBittorrent, namer Namer, cfg Config, log *slog.Logger) *Service {
|
||||
return &Service{store: st, qbt: qb, namer: namer, cfg: cfg, log: log}
|
||||
// New собирает сервис приёма.
|
||||
func New(st Store, log *slog.Logger) *Service {
|
||||
return &Service{store: st, log: log}
|
||||
}
|
||||
|
||||
// SetFailureNotifier подключает пинг о падении приёма (до начала работы).
|
||||
func (s *Service) SetFailureNotifier(fn func(downloadID string)) { s.notifyFailed = fn }
|
||||
|
||||
// Request — входной запрос приёма.
|
||||
type Request struct {
|
||||
Source string // пока — magnet-ссылка
|
||||
@@ -92,8 +59,10 @@ type Result struct {
|
||||
Deduplicated bool // присоединились к уже активной задаче, нового добавления не было
|
||||
}
|
||||
|
||||
// Ingest принимает источник: извлекает infohash, дедуплицирует по активной
|
||||
// задаче, иначе заводит задачу и отдаёт источник в qBittorrent.
|
||||
// Ingest быстро принимает источник: извлекает infohash, синтезирует контекст из
|
||||
// полей ссылки, дедуплицирует по активной задаче, иначе сохраняет загрузку в
|
||||
// `catched` и сразу возвращает результат. Добавление в qBittorrent и вывод
|
||||
// имени выполняет worker (см. download-tracking).
|
||||
func (s *Service) Ingest(ctx context.Context, req Request) (Result, error) {
|
||||
source := strings.TrimSpace(req.Source)
|
||||
info, err := magnet.Parse(source)
|
||||
@@ -103,14 +72,12 @@ func (s *Service) Ingest(ctx context.Context, req Request) (Result, error) {
|
||||
}
|
||||
|
||||
// Scoped-логгер стадии приёма: download_id допишется после CreateDownload.
|
||||
// Кладём в ctx, чтобы внешние клиенты (qBittorrent, LLM-namer) дописывали
|
||||
// ключи корреляции к своим ext.*-записям сами.
|
||||
log := s.log.With("capability", capIngest, "infohash", info.Infohash)
|
||||
ctx = logctx.With(ctx, log)
|
||||
|
||||
// Быстрый дедуп-чек до дорогого LLM-namer; авторитетная (атомарная)
|
||||
// проверка — внутри CreateDownloadIfNoActive ниже. Дедуп — по ЛЮБОМУ из
|
||||
// хешей источника: гибридный magnet несёт и v1, и v2.
|
||||
// Быстрый дедуп-чек; авторитетная (атомарная) проверка — внутри
|
||||
// CreateDownloadIfNoActive ниже. Дедуп — по ЛЮБОМУ из хешей источника:
|
||||
// гибридный magnet несёт и v1, и v2.
|
||||
if existing, err := s.store.FindActiveByInfohash(ctx, info.Infohashes...); err != nil {
|
||||
return Result{}, fmt.Errorf("ingest: lookup active: %w", err)
|
||||
} else if existing != nil {
|
||||
@@ -118,26 +85,15 @@ func (s *Service) Ingest(ctx context.Context, req Request) (Result, error) {
|
||||
return s.attached(ctx, info, existing), nil
|
||||
}
|
||||
|
||||
// Отображаемое имя для списка qBit — best-effort: не валит приём.
|
||||
// Выводится синхронно (param rename действует только при добавлении) и
|
||||
// ДО CreateDownload, чтобы возможный медленный вызов LLM не расширял окно
|
||||
// «строка в БД есть, в qBittorrent ещё нет». Имя от строки БД не зависит.
|
||||
// Namer получает СЫРОЙ req.Context (+ dn-hint), не обогащённый: строки-факты
|
||||
// синтеза (Размер:/Трекер:) не должны становиться отображаемым именем.
|
||||
var rename string
|
||||
if s.namer != nil {
|
||||
rename = s.namer.DeriveName(ctx, req.Context, info.DisplayName)
|
||||
}
|
||||
|
||||
// Контекст распознавания дополняем фактами из полей самой magnet-ссылки
|
||||
// (dn/xl/tr/xs/kt) — без сети. Пользовательский текст идёт первым. Результат
|
||||
// уходит только в download.Context (его читают recognition и веб-UI).
|
||||
// (dn/xl/tr/xs/kt) — без сети. Пользовательский текст идёт первым.
|
||||
// DisplayName пуст: имя выведет worker на шаге добавления (rename действует
|
||||
// только при добавлении, а тут медленный LLM в пути ответа недопустим).
|
||||
d := &store.Download{
|
||||
SourceType: store.SourceMagnet,
|
||||
SourceRef: source,
|
||||
DisplayName: rename, // то же имя, что уходит в qBittorrent (rename); заголовок в веб-UI
|
||||
Context: mergeContext(req.Context, info.Context()),
|
||||
State: store.StateDownloading,
|
||||
SourceType: store.SourceMagnet,
|
||||
SourceRef: source,
|
||||
Context: mergeContext(req.Context, info.Context()),
|
||||
State: store.StateCatched,
|
||||
}
|
||||
// Все хеши из magnet (гибридный несёт v1 и v2); kind store выведет по длине.
|
||||
existing, err := s.store.CreateDownloadIfNoActive(ctx, d, info.Infohashes)
|
||||
@@ -156,38 +112,12 @@ func (s *Service) Ingest(ctx context.Context, req Request) (Result, error) {
|
||||
Deduplicated: true,
|
||||
}, nil
|
||||
}
|
||||
id := d.ID
|
||||
log = log.With("download_id", id)
|
||||
ctx = logctx.With(ctx, log)
|
||||
|
||||
addErr := s.qbt.Add(ctx, qbt.AddRequest{
|
||||
URLs: []string{source},
|
||||
Category: s.cfg.Category,
|
||||
SavePath: s.cfg.SavePath,
|
||||
Rename: rename,
|
||||
})
|
||||
if addErr != nil {
|
||||
// Граница доменной операции приёма: логируем исход один раз (ERROR).
|
||||
// Поведение самого вызова qBittorrent уже залогировал клиент (ext.*) —
|
||||
// это разные факты, не дубль.
|
||||
log.Error("download accept failed", "error", addErr)
|
||||
// Задача уже в БД — помечаем failed, чтобы worker её не подхватил.
|
||||
if setErr := s.store.SetDownloadState(ctx, id, store.StateFailed, errCodeQbitAdd, addErr.Error()); setErr != nil {
|
||||
log.Error("mark download failed after qbit error failed", "error", setErr)
|
||||
} else if s.notifyFailed != nil {
|
||||
// Это падение минует worker.transition (раздачи в qBit нет) — уведомляем
|
||||
// сами, чтобы приёмные провалы тоже доходили до автора.
|
||||
go s.notifyFailed(id)
|
||||
}
|
||||
return Result{DownloadID: id, Infohashes: info.Infohashes, State: store.StateFailed},
|
||||
fmt.Errorf("ingest: add to qbittorrent: %w", addErr)
|
||||
}
|
||||
|
||||
log.Info("download accepted", "category", s.cfg.Category)
|
||||
log.Info("download catched", "download_id", d.ID)
|
||||
return Result{
|
||||
DownloadID: id,
|
||||
DownloadID: d.ID,
|
||||
Infohashes: info.Infohashes,
|
||||
State: store.StateDownloading,
|
||||
State: store.StateCatched,
|
||||
}, nil
|
||||
}
|
||||
|
||||
|
||||
+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("не должно быть записи задачи")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,6 +20,7 @@ import (
|
||||
type State string
|
||||
|
||||
const (
|
||||
StateCatched State = "catched" // поймано и сохранено; worker добавит в qBittorrent
|
||||
StateDownloading State = "downloading"
|
||||
StateCompleted State = "completed"
|
||||
StateRecognizing State = "recognizing" // Ф2
|
||||
@@ -503,6 +504,31 @@ func (s *Store) SetDownloadState(ctx context.Context, id string, state State, er
|
||||
return setState(ctx, s.DB, id, state, errCode, errMsg, false)
|
||||
}
|
||||
|
||||
// PromoteCatched переводит пойманную загрузку catched → downloading, попутно
|
||||
// записывая выведенное отображаемое имя. Гард `state = 'catched'` — это
|
||||
// ре-валидация: если загрузку успели отменить (catched → cancelled) во время
|
||||
// вывода имени/добавления вне блокировки переходов, UPDATE не заденет ни строки
|
||||
// и вернёт ошибку, а переход не применится. Пустое имя допустимо (rename не
|
||||
// задавали) — тогда display_name так и остаётся пустым.
|
||||
func (s *Store) PromoteCatched(ctx context.Context, id, displayName string) error {
|
||||
res, err := s.DB.ExecContext(ctx, `
|
||||
UPDATE download
|
||||
SET state = ?, display_name = ?, updated_at = ?
|
||||
WHERE id = ? AND state = ?`,
|
||||
string(StateDownloading), displayName, FormatTime(Now()), id, string(StateCatched))
|
||||
if err != nil {
|
||||
return fmt.Errorf("promote catched %s: %w", id, err)
|
||||
}
|
||||
n, err := res.RowsAffected()
|
||||
if err != nil {
|
||||
return fmt.Errorf("promote catched %s: %w", id, err)
|
||||
}
|
||||
if n == 0 {
|
||||
return fmt.Errorf("promote catched %s: not in catched (already added or cancelled)", id)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// setState выполняет UPDATE состояния. reviveOK=true — вызов из гарда
|
||||
// (ActivateIfNoOtherActive), которому переход терминал→активное разрешён;
|
||||
// иначе предикат в UPDATE не даёт молча оживить терминальную задачу.
|
||||
|
||||
@@ -27,6 +27,64 @@ func newDownloading() *Download {
|
||||
}
|
||||
}
|
||||
|
||||
func newCatched() *Download {
|
||||
return &Download{
|
||||
SourceType: SourceMagnet,
|
||||
SourceRef: "magnet:?xt=urn:btih:test",
|
||||
Context: "ctx",
|
||||
State: StateCatched,
|
||||
}
|
||||
}
|
||||
|
||||
// catched — нетерминальное активное состояние: его наличие блокирует повторный
|
||||
// приём того же infohash (инвариант «≤1 активная на infohash»).
|
||||
func TestCatchedIsActiveForDedup(t *testing.T) {
|
||||
st := newTestStore(t)
|
||||
ctx := context.Background()
|
||||
const ih = "aabbccddeeff00112233445566778899aabbccdd"
|
||||
|
||||
d1 := newCatched()
|
||||
if existing, err := st.CreateDownloadIfNoActive(ctx, d1, []string{ih}); err != nil || existing != nil {
|
||||
t.Fatalf("первый catched: existing=%v err=%v", existing, err)
|
||||
}
|
||||
// Повторный приём того же хеша → дедуп на активную catched-задачу.
|
||||
existing, err := st.CreateDownloadIfNoActive(ctx, newCatched(), []string{ih})
|
||||
if err != nil {
|
||||
t.Fatalf("повторный приём: %v", err)
|
||||
}
|
||||
if existing == nil || existing.ID != d1.ID {
|
||||
t.Errorf("ожидался дедуп на catched %s, got %v", d1.ID, existing)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPromoteCatched(t *testing.T) {
|
||||
st := newTestStore(t)
|
||||
ctx := context.Background()
|
||||
const ih = "aabbccddeeff00112233445566778899aabbccdd"
|
||||
|
||||
d := newCatched()
|
||||
if _, err := st.CreateDownloadIfNoActive(ctx, d, []string{ih}); err != nil {
|
||||
t.Fatalf("create: %v", err)
|
||||
}
|
||||
if err := st.PromoteCatched(ctx, d.ID, "Дюна (2024)"); err != nil {
|
||||
t.Fatalf("promote: %v", err)
|
||||
}
|
||||
got, err := st.GetDownload(ctx, d.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("get: %v", err)
|
||||
}
|
||||
if got.State != StateDownloading {
|
||||
t.Errorf("state = %q, want downloading", got.State)
|
||||
}
|
||||
if got.DisplayName != "Дюна (2024)" {
|
||||
t.Errorf("display_name = %q", got.DisplayName)
|
||||
}
|
||||
// Повторный promote (уже не catched) — отклоняется гардом state='catched'.
|
||||
if err := st.PromoteCatched(ctx, d.ID, "X"); err == nil {
|
||||
t.Error("ожидалась ошибка promote для не-catched задачи")
|
||||
}
|
||||
}
|
||||
|
||||
// mustCreate заводит загрузку с хешем и возвращает её id; дедуп на
|
||||
// существующую активную — ошибка теста.
|
||||
func mustCreate(t *testing.T, st *Store, infohash string) string {
|
||||
|
||||
@@ -28,7 +28,7 @@ func statesInGroup(g StateGroup) []State {
|
||||
case GroupReview:
|
||||
return []State{StateReview, StateDeferred}
|
||||
case GroupActive:
|
||||
return []State{StateDownloading, StateCompleted, StateRecognizing, StateLinking}
|
||||
return []State{StateCatched, StateDownloading, StateCompleted, StateRecognizing, StateLinking}
|
||||
case GroupDone:
|
||||
return []State{StateDone}
|
||||
case GroupProblem:
|
||||
|
||||
@@ -151,9 +151,9 @@ func (b *Bot) handleMessage(ctx context.Context, m *tgbotapi.Message) {
|
||||
b.send(m.Chat.ID, opErr("Не удалось принять загрузку", res.DownloadID), nil)
|
||||
return
|
||||
}
|
||||
msg := fmt.Sprintf("Принято #%s — %s.", res.DownloadID, res.State)
|
||||
msg := fmt.Sprintf("Принято #%s — добавляю в qBittorrent.", res.DownloadID)
|
||||
if res.Deduplicated {
|
||||
msg = fmt.Sprintf("Уже в работе #%s — %s.", res.DownloadID, res.State)
|
||||
msg = fmt.Sprintf("Уже в работе #%s.", res.DownloadID)
|
||||
}
|
||||
b.send(m.Chat.ID, msg+"\nПозову, когда нужно подтверждение.", nil)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
package worker
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.vakhrushev.me/av/jellybit/internal/store"
|
||||
)
|
||||
|
||||
// fakeNamer — вывод имени для шага добавления. onCall позволяет вклиниться в
|
||||
// момент (медленного) вывода имени, симулируя параллельную отмену.
|
||||
type fakeNamer struct {
|
||||
name string
|
||||
gotContext string
|
||||
onCall func()
|
||||
}
|
||||
|
||||
func (f *fakeNamer) DeriveName(_ context.Context, contextText, _ string) string {
|
||||
f.gotContext = contextText
|
||||
if f.onCall != nil {
|
||||
f.onCall()
|
||||
}
|
||||
return f.name
|
||||
}
|
||||
|
||||
func catchedStore(id, infohash, createdAt, ctxText string) *fakeStore {
|
||||
return &fakeStore{downloads: map[string]*store.Download{
|
||||
id: {
|
||||
ID: id,
|
||||
State: store.StateCatched,
|
||||
SourceType: store.SourceMagnet,
|
||||
SourceRef: "magnet:?xt=urn:btih:" + infohash + "&dn=Dune",
|
||||
Infohashes: hashesOf(id, infohash),
|
||||
Context: ctxText,
|
||||
CreatedAt: createdAt,
|
||||
},
|
||||
}}
|
||||
}
|
||||
|
||||
const catchedIH = "541adcff3b6dd5dba7088ea83317d9d6fac331d6"
|
||||
|
||||
// now воркера в тестах — 2026-06-14 10:00 UTC (см. newTestWorker).
|
||||
var nowStr = store.FormatTime(time.Date(2026, 6, 14, 10, 0, 0, 0, time.UTC))
|
||||
|
||||
// Успех: выводим имя, добавляем в qBit с rename, переводим catched → downloading
|
||||
// и сохраняем display_name.
|
||||
func TestProcessCatchedAddsToQbit(t *testing.T) {
|
||||
st := catchedStore("1", catchedIH, nowStr, "Дюна 2")
|
||||
qb := &fakeQbt{}
|
||||
w := newTestWorker(st, qb)
|
||||
nm := &fakeNamer{name: "Дюна: Часть вторая (2024)"}
|
||||
w.SetNamer(nm)
|
||||
|
||||
w.processCatched(context.Background())
|
||||
|
||||
if len(qb.added) != 1 {
|
||||
t.Fatalf("qbt.Add calls = %d, want 1", len(qb.added))
|
||||
}
|
||||
add := qb.added[0]
|
||||
if add.Rename != "Дюна: Часть вторая (2024)" {
|
||||
t.Errorf("rename = %q", add.Rename)
|
||||
}
|
||||
if add.Category != "jellybit" || add.URLs[0] != st.downloads["1"].SourceRef {
|
||||
t.Errorf("add = %+v", add)
|
||||
}
|
||||
if nm.gotContext != "Дюна 2" {
|
||||
t.Errorf("namer получил контекст %q", nm.gotContext)
|
||||
}
|
||||
d := st.downloads["1"]
|
||||
if d.State != store.StateDownloading {
|
||||
t.Errorf("state = %q, want downloading", d.State)
|
||||
}
|
||||
if d.DisplayName != "Дюна: Часть вторая (2024)" {
|
||||
t.Errorf("display_name = %q", d.DisplayName)
|
||||
}
|
||||
}
|
||||
|
||||
// Транзиентный сбой add — остаёмся в catched для повтора на следующем тике.
|
||||
func TestProcessCatchedTransientFailureKeepsCatched(t *testing.T) {
|
||||
st := catchedStore("1", catchedIH, nowStr, "ctx")
|
||||
qb := &fakeQbt{addErr: errors.New("connection refused")}
|
||||
w := newTestWorker(st, qb)
|
||||
w.SetNamer(&fakeNamer{name: "X"})
|
||||
|
||||
w.processCatched(context.Background())
|
||||
|
||||
if st.downloads["1"].State != store.StateCatched {
|
||||
t.Errorf("state = %q, want catched (повтор)", st.downloads["1"].State)
|
||||
}
|
||||
}
|
||||
|
||||
// Предохранитель: catched старше catch_timeout → failed (qbit_add) + уведомление;
|
||||
// add при этом не вызывается.
|
||||
func TestProcessCatchedTimeoutFails(t *testing.T) {
|
||||
old := store.FormatTime(time.Date(2026, 6, 14, 9, 0, 0, 0, time.UTC)) // 1 час до now
|
||||
st := catchedStore("1", catchedIH, old, "ctx")
|
||||
qb := &fakeQbt{}
|
||||
w := newTestWorker(st, qb)
|
||||
w.cfg.CatchTimeout = 10 * time.Minute
|
||||
n := &recordingNotifier{ch: make(chan notifyEvent, 1)}
|
||||
w.SetNotifier(n)
|
||||
|
||||
w.processCatched(context.Background())
|
||||
|
||||
d := st.downloads["1"]
|
||||
if d.State != store.StateFailed || d.ErrorCode.String != errCodeQbitAdd {
|
||||
t.Errorf("state = %q code = %q, want failed/qbit_add", d.State, d.ErrorCode.String)
|
||||
}
|
||||
if len(qb.added) != 0 {
|
||||
t.Errorf("add не должен вызываться при таймауте, calls = %d", len(qb.added))
|
||||
}
|
||||
if e := waitNotify(t, n); e.ev != EventFailed {
|
||||
t.Errorf("событие = %q, want failed", e.ev)
|
||||
}
|
||||
}
|
||||
|
||||
// Ре-валидация: если во время сетевых вызовов (вне блокировки) задачу отменили,
|
||||
// переход в downloading не применяется — состояние остаётся cancelled.
|
||||
func TestProcessCatchedCancelledDuringAddSkipsPromote(t *testing.T) {
|
||||
st := catchedStore("1", catchedIH, nowStr, "ctx")
|
||||
qb := &fakeQbt{}
|
||||
w := newTestWorker(st, qb)
|
||||
// namer имитирует параллельную отмену во время (медленного) вывода имени.
|
||||
nm := &fakeNamer{name: "X", onCall: func() { st.downloads["1"].State = store.StateCancelled }}
|
||||
w.SetNamer(nm)
|
||||
|
||||
w.processCatched(context.Background())
|
||||
|
||||
if len(qb.added) != 1 {
|
||||
t.Fatal("add должен был вызваться (сеть идёт вне замка)")
|
||||
}
|
||||
if st.downloads["1"].State != store.StateCancelled {
|
||||
t.Errorf("ре-валидация не сработала: state = %q, want cancelled", st.downloads["1"].State)
|
||||
}
|
||||
}
|
||||
|
||||
// Поллинг активных (downloading) не трогает catched: раздачи в qBittorrent у
|
||||
// пойманной загрузки ещё нет по дизайну, это не «пропажа».
|
||||
func TestPollIgnoresCatched(t *testing.T) {
|
||||
st := catchedStore("1", catchedIH, nowStr, "ctx")
|
||||
qb := &fakeQbt{} // раздач нет
|
||||
w := newTestWorker(st, qb)
|
||||
|
||||
if err := w.Poll(context.Background()); err != nil {
|
||||
t.Fatalf("Poll: %v", err)
|
||||
}
|
||||
if st.downloads["1"].State != store.StateCatched {
|
||||
t.Errorf("catched тронут поллингом: %q", st.downloads["1"].State)
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"os"
|
||||
@@ -346,6 +347,16 @@ func (m *memStore) SetDownloadState(_ context.Context, id string, st store.State
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *memStore) PromoteCatched(_ context.Context, id, displayName string) error {
|
||||
d, ok := m.downloads[id]
|
||||
if !ok || d.State != store.StateCatched {
|
||||
return fmt.Errorf("promote catched %s: not in catched", id)
|
||||
}
|
||||
d.State = store.StateDownloading
|
||||
d.DisplayName = displayName
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *memStore) SetSourceMissCount(_ context.Context, id string, n int) error {
|
||||
if d, ok := m.downloads[id]; ok {
|
||||
d.SourceMissCount = n
|
||||
|
||||
@@ -24,6 +24,7 @@ import (
|
||||
"git.vakhrushev.me/av/jellybit/internal/ident"
|
||||
"git.vakhrushev.me/av/jellybit/internal/layout"
|
||||
"git.vakhrushev.me/av/jellybit/internal/logctx"
|
||||
"git.vakhrushev.me/av/jellybit/internal/magnet"
|
||||
"git.vakhrushev.me/av/jellybit/internal/qbt"
|
||||
"git.vakhrushev.me/av/jellybit/internal/recognize"
|
||||
"git.vakhrushev.me/av/jellybit/internal/store"
|
||||
@@ -44,6 +45,9 @@ type Store interface {
|
||||
ListRecoverable(ctx context.Context, codes ...string) ([]store.Download, error)
|
||||
GetDownload(ctx context.Context, id string) (*store.Download, error)
|
||||
SetDownloadState(ctx context.Context, id string, state store.State, errCode, errMsg string) error
|
||||
// PromoteCatched атомарно переводит catched → downloading с записью имени
|
||||
// (гард state='catched' — ре-валидация после сетевых вызовов вне блокировки).
|
||||
PromoteCatched(ctx context.Context, id, displayName string) error
|
||||
SetSourceMissCount(ctx context.Context, id string, n int) error
|
||||
SetSourceAddedAt(ctx context.Context, id string, t time.Time) error
|
||||
|
||||
@@ -85,6 +89,12 @@ type Recognizer interface {
|
||||
Recognize(ctx context.Context, in recognize.Input) (recognize.Result, error)
|
||||
}
|
||||
|
||||
// Namer выводит человекочитаемое отображаемое имя из контекста (naming.Namer).
|
||||
// Пустой результат → rename в qBittorrent не задаём. nil → имя не выводим.
|
||||
type Namer interface {
|
||||
DeriveName(ctx context.Context, contextText, hint string) string
|
||||
}
|
||||
|
||||
// Layouter — раскладчик хардлинками (layout.Layouter).
|
||||
type Layouter interface {
|
||||
BuildLinks(p layout.Plan) ([]layout.Link, error)
|
||||
@@ -111,6 +121,10 @@ const (
|
||||
errCodeMagnetTimeout = "magnet_timeout"
|
||||
errCodeStalled = "stalled"
|
||||
errCodeQbitError = "qbit_error"
|
||||
// errCodeQbitAdd — не удалось добавить пойманную загрузку в qBittorrent за
|
||||
// catch_timeout (устойчивая недоступность qBit). Раздачи в qBittorrent нет,
|
||||
// восстановлению сверкой не подлежит.
|
||||
errCodeQbitAdd = "qbit_add"
|
||||
)
|
||||
|
||||
// Notifier — исходящие пинги (Telegram). Вызывается неблокирующе.
|
||||
@@ -134,6 +148,7 @@ type Config struct {
|
||||
PollInterval time.Duration
|
||||
StuckAfter time.Duration // stalledDL дольше → stuck
|
||||
MagnetTimeout time.Duration // metaDL дольше → failed
|
||||
CatchTimeout time.Duration // catched дольше (не удалось добавить в qBit) → failed
|
||||
// SourceMissingThreshold — порог дебаунса пропажи источника (тиков сверки).
|
||||
// <1 трактуется как 1 (помечаем при первой же устойчивой пропаже).
|
||||
SourceMissingThreshold int
|
||||
@@ -181,6 +196,7 @@ type Worker struct {
|
||||
qbt QBittorrent
|
||||
recognizer Recognizer
|
||||
layouter Layouter
|
||||
namer Namer // опц. вывод отображаемого имени на шаге добавления catched
|
||||
cfg Config
|
||||
log *slog.Logger
|
||||
|
||||
@@ -209,6 +225,10 @@ type Worker struct {
|
||||
// одной задачи (см. failNotified).
|
||||
const failNotifyDebounce = time.Hour
|
||||
|
||||
// SetNamer подключает вывод отображаемого имени для шага добавления catched
|
||||
// (до запуска Run). nil → имя не выводим, добавляем без rename.
|
||||
func (w *Worker) SetNamer(n Namer) { w.namer = n }
|
||||
|
||||
// SetNotifier подключает исходящие пинги (до запуска Run).
|
||||
func (w *Worker) SetNotifier(n Notifier) { w.notifier = n }
|
||||
|
||||
@@ -291,12 +311,95 @@ func (w *Worker) pollOnce(ctx context.Context) {
|
||||
if err := w.Poll(ctx); err != nil {
|
||||
w.log.Warn("poll failed", "error", err)
|
||||
}
|
||||
// Быстрый приём отложил добавление в qBittorrent: подхватываем пойманные
|
||||
// (catched) загрузки и добавляем их (сеть — вне блокировки переходов).
|
||||
w.processCatched(ctx)
|
||||
// Ф3: распознаём завершённые загрузки (и перезапускаем по подсказке).
|
||||
if w.recognizer != nil {
|
||||
w.recognizePending(ctx)
|
||||
}
|
||||
}
|
||||
|
||||
// processCatched — асинхронный шаг добавления пойманных загрузок в qBittorrent.
|
||||
// Для каждой catched: (предохранитель) если висит дольше catch_timeout — уводим
|
||||
// в failed; иначе выводим имя и добавляем в qBit. Медленные вызовы (LLM-namer,
|
||||
// qbt.Add) идут ВНЕ w.mu, чтобы не задерживать команды транспортов и поллинг;
|
||||
// под w.mu берутся только короткие DB-переходы (с ре-валидацией state=catched).
|
||||
func (w *Worker) processCatched(ctx context.Context) {
|
||||
w.mu.Lock()
|
||||
catched, err := w.store.ListDownloadsByState(ctx, store.StateCatched)
|
||||
w.mu.Unlock()
|
||||
if err != nil {
|
||||
w.log.Warn("list catched failed", "capability", capIngest, "error", err)
|
||||
return
|
||||
}
|
||||
for _, d := range catched {
|
||||
cctx := w.scoped(ctx, capIngest, d.ID, d.PrimaryInfohash())
|
||||
|
||||
// Предохранитель: устойчивая невозможность добавить в qBittorrent.
|
||||
if w.cfg.CatchTimeout > 0 {
|
||||
if age, ok := w.catchedAge(d); ok && age > w.cfg.CatchTimeout {
|
||||
w.mu.Lock()
|
||||
// Ре-валидация под замком: список catched снят раньше, задачу
|
||||
// могли отменить (catched → cancelled) в это окно — тогда failed
|
||||
// не навязываем (иначе затёрли бы cancelled и слали лишний пинг).
|
||||
if cur, err := w.store.GetDownload(cctx, d.ID); err == nil && cur.State == store.StateCatched {
|
||||
w.transition(cctx, d, store.StateFailed, errCodeQbitAdd,
|
||||
fmt.Sprintf("not added to qBittorrent after %s", age.Truncate(time.Second)))
|
||||
}
|
||||
w.mu.Unlock()
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
// Вне w.mu: вывод имени (потенциально медленный LLM) и добавление.
|
||||
var rename string
|
||||
if w.namer != nil {
|
||||
hint := ""
|
||||
if info, perr := magnet.Parse(d.SourceRef); perr == nil {
|
||||
hint = info.DisplayName
|
||||
}
|
||||
rename = w.namer.DeriveName(cctx, d.Context, hint)
|
||||
}
|
||||
addErr := w.qbt.Add(cctx, qbt.AddRequest{
|
||||
URLs: []string{d.SourceRef},
|
||||
Category: w.cfg.Category,
|
||||
SavePath: w.cfg.SavePath,
|
||||
Rename: rename,
|
||||
})
|
||||
if addErr != nil {
|
||||
// Транзиентный сбой (qBit недоступен) — остаёмся в catched, повтор на
|
||||
// следующем тике. Поведение вызова qBit уже залогировал клиент (ext.*).
|
||||
logctx.From(cctx).Warn("catched add to qbittorrent failed, will retry", "error", addErr)
|
||||
continue
|
||||
}
|
||||
|
||||
// Успех: короткий переход под w.mu с ре-валидацией state=catched
|
||||
// (загрузку могли отменить, пока шли сетевые вызовы).
|
||||
w.mu.Lock()
|
||||
if err := w.store.PromoteCatched(cctx, d.ID, rename); err != nil {
|
||||
logctx.From(cctx).Info("catched promote skipped", "reason", err.Error())
|
||||
} else {
|
||||
logctx.From(cctx).Info("state transition", "from", store.StateCatched,
|
||||
"to", store.StateDownloading)
|
||||
}
|
||||
w.mu.Unlock()
|
||||
}
|
||||
}
|
||||
|
||||
// catchedAge — возраст пойманной загрузки от created_at (у catched раздачи в
|
||||
// qBittorrent ещё нет, added_on недоступен). ok=false — created_at не разобрать.
|
||||
func (w *Worker) catchedAge(d store.Download) (time.Duration, bool) {
|
||||
created, err := d.CreatedTime()
|
||||
if err != nil {
|
||||
w.log.Warn("cannot determine catched age",
|
||||
"capability", capIngest, "download_id", d.ID,
|
||||
"created_at", d.CreatedAt, "error", err)
|
||||
return 0, false
|
||||
}
|
||||
return w.now().Sub(created), true
|
||||
}
|
||||
|
||||
// Poll сверяет активные задачи с состоянием qBittorrent и двигает их.
|
||||
// Листаем все торренты (а не только свою категорию), чтобы reconcile нашёл и
|
||||
// усыновлённые по тегу раздачи, а discovery — увидел новые.
|
||||
|
||||
@@ -163,6 +163,20 @@ func (f *fakeStore) SetDownloadState(_ context.Context, id string, st store.Stat
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *fakeStore) PromoteCatched(_ context.Context, id, displayName string) error {
|
||||
d, ok := f.downloads[id]
|
||||
if !ok {
|
||||
return fmt.Errorf("download %s not found", id)
|
||||
}
|
||||
if d.State != store.StateCatched {
|
||||
return fmt.Errorf("promote catched %s: not in catched (%s)", id, d.State)
|
||||
}
|
||||
d.State = store.StateDownloading
|
||||
d.DisplayName = displayName
|
||||
f.transitions = append(f.transitions, transition{id, store.StateDownloading})
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *fakeStore) SetSourceMissCount(_ context.Context, id string, n int) error {
|
||||
d, ok := f.downloads[id]
|
||||
if !ok {
|
||||
|
||||
Reference in New Issue
Block a user