Быстрый приём: сохранение в 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:
av
2026-07-07 21:29:28 +03:00
co-authored by Claude Opus 4.8
parent f0ce6b4bc8
commit 0d263270cb
30 changed files with 1198 additions and 348 deletions
+8 -9
View File
@@ -80,13 +80,13 @@ func runServe(args []string) error {
}
// Вывод отображаемого имени торрента из контекста (best-effort). Без LLM
// работает только алгоритмический фолбек.
// работает только алгоритмический фолбек. Namer зовёт worker на шаге
// добавления пойманной загрузки (не синхронный приём).
namer := naming.New(llmProvider, cfg.LLM.MaxRetries, logger)
ingestor := ingest.New(st, qb, namer, ingest.Config{
Category: cfg.QBittorrent.Category,
SavePath: cfg.QBittorrent.SavePath,
}, logger)
// Быстрый приём: сохраняет загрузку в catched и сразу отвечает; добавление в
// qBittorrent и вывод имени делает worker (см. download-tracking).
ingestor := ingest.New(st, logger)
// Ф4: базы метаданных (опц.). Без них авто-раскладки нет — всё в review.
providers, err := metadataProviders(cfg, logger)
@@ -126,8 +126,11 @@ func runServe(args []string) error {
PollInterval: cfg.Worker.PollInterval.Std(),
StuckAfter: cfg.Worker.StuckAfter.Std(),
MagnetTimeout: cfg.Worker.MagnetTimeout.Std(),
CatchTimeout: cfg.Worker.CatchTimeout.Std(),
SourceMissingThreshold: cfg.Worker.SourceMissingThreshold,
}, logger)
// Вывод имени на шаге добавления пойманной загрузки (best-effort).
wrk.SetNamer(namer)
// Пересканирование Jellyfin после раскладки (опц.). Недоступность Jellyfin
// не валит сервис — скан просто не сработает (залогируется в воркере).
@@ -188,10 +191,6 @@ func runServe(args []string) error {
WebBaseURL: cfg.Telegram.WebBaseURL,
}, logger)
wrk.SetNotifier(bot)
// Приёмные падения (qbit_add) минуют worker — уведомляем напрямую.
ingestor.SetFailureNotifier(func(id string) {
bot.Notify(context.Background(), id, worker.EventFailed)
})
go bot.Run(ctx)
logger.Info("telegram bot enabled",
"bot", api.Self.UserName, "allowed_users", len(cfg.Telegram.AllowedUserIDs))
+1
View File
@@ -67,6 +67,7 @@ timeout = "10s" # таймаут запроса к Jellyfin
poll_interval = "5s" # как часто опрашивать qBittorrent; Go-duration (s/m/h)
stuck_after = "1h" # сколько ждать прогресса, прежде чем счесть раздачу зависшей; Go-duration
magnet_timeout = "24h" # страховочный предел ожидания метаданных magnet (не рабочий механизм: ожившие задачи воскрешаются сверкой); Go-duration
catch_timeout = "10m" # страховочный предел: пойманная (catched) задача не добавилась в qBittorrent за это время → failed; Go-duration
source_missing_threshold = 3 # подряд тиков сверки без раздачи в qBittorrent, чтобы счесть источник удалённым (дебаунс)
[recognition]
+5
View File
@@ -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},
+2
View File
@@ -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,
+5
View File
@@ -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 ||
+27
View File
@@ -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) }
+20
View File
@@ -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)
+2
View File
@@ -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
View File
@@ -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
View File
@@ -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("не должно быть записи задачи")
}
}
+26
View File
@@ -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 не даёт молча оживить терминальную задачу.
+58
View File
@@ -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 {
+1 -1
View File
@@ -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:
+2 -2
View File
@@ -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)
}
+152
View File
@@ -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)
}
}
+11
View File
@@ -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
+103
View File
@@ -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 — увидел новые.
+14
View File
@@ -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 {
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-07-07
@@ -0,0 +1,150 @@
## Context
Текущий `Ingest` (`internal/ingest/ingest.go`) синхронно: парсит magnet,
дедуплицирует, выводит имя через `namer.DeriveName` (потенциально **медленный
LLM** — `extractViaLLM`), создаёт `download` сразу в `downloading` и вызывает
`qbt.Add`, и лишь затем возвращает `Result`. Медленный LLM и внешний вызов к
qBit задерживают ответ транспорту (HTTP/веб-UI/Telegram) и расширяют окно
«строка в БД есть, в qBittorrent ещё нет».
Worker (`internal/worker`) уже ведёт поллинг-цикл под единой блокировкой
переходов: сверяет раздачи (`discover` усыновляет по категории/тегу с дедупом
по infohash), ходит по `ListDownloadsByState(StateDownloading)` и реагирует на
«active download not found in qbittorrent». Реконсилятор оперирует своими
`desyncStates` (`orphaned`, …), в которые `catched` не входит.
Состояния — `internal/store/download.go` (`State`, `terminalStates`,
`statesInGroup`). Активность выводится из `state` (нетерминальное = активное).
## Goals / Non-Goals
**Goals:**
- Быстрый ответ приёма: синхронно только парс + дедуп + запись `catched`.
- Вынести медленный вывод имени и `qbt.Add` в асинхронный шаг worker'а.
- Сохранить инвариант «≤1 активная загрузка на infohash» (в т.ч. с `catched`).
- Корректно показать `catched` в веб-UI; не считать его пропажей раздачи.
**Non-Goals:**
- Отдельное состояние `added-to-qbittorrent` — схлопнуто в `catched →
downloading` (решение развилки).
- Немедленный пинок фоновой добавки — двигаем worker-циклом (решение развилки);
задержка ≤ `poll_interval` приемлема, ведь клиенту уже ответили.
- Изменение схемы БД, API транспортов, распознавания/раскладки.
- Перенос синтеза контекста из полей magnet — он дёшев и остаётся в приёме.
## Decisions
### Р1. Новое состояние `catched`, нетерминальное активное
`StateCatched = "catched"`. Не входит в `terminalStates` → автоматически
считается активным для `CreateDownloadIfNoActive` и инварианта. Добавляется в
`statesInGroup(GroupActive)` рядом с `downloading` — чтобы попадать в активную
группу списка и в поиск.
### Р2. Приём пишет `catched`, без namer и без qBit
`Ingest`: парс → синтез контекста (как сейчас) → `CreateDownloadIfNoActive` с
`State: StateCatched`, `DisplayName: ""` (имя выведет worker). Ни
`namer.DeriveName`, ни `qbt.Add` в приёме не вызываются. `Result` возвращается
сразу после записи. Транспорты (`httpapi`, `tgbot`) не меняются — они уже
работают через `Result`.
Зависимость `Namer` из `ingest.Service` **переезжает** в worker (или worker
получает её отдельно). `ingest` перестаёт зависеть от `naming`/`qbt` в пути
приёма (qbt-зависимость в ingest может уйти совсем, если не нужна для дедупа).
### Р3. Асинхронный шаг worker'а: `catched → downloading`, сеть — вне замка
**Критично:** `w.mu` в worker'е сериализует ВЕСЬ поллинг-цикл И команды
транспортов (`Cancel`, `Retry`, review). Медленный `namer.DeriveName` (до
`max_retries` сетевых попыток) и `qbt.Add` под этим замком заморозили бы все
действия пользователя на секунды каждый тик — это ровно та блокировка, которую
change устраняет. Поэтому:
1. Под `w.mu` (быстро): снять список `ListDownloadsByState(StateCatched)`.
2. **Вне `w.mu`** (для каждой загрузки): вывести имя `namer.DeriveName(ctx,
d.Context, dnHint)`, вызвать `qbt.Add(urls=d.SourceRef, category, savepath,
rename=name)`. Имя выводится непосредственно перед `add` (`rename` действует
только при добавлении).
3. Снова под `w.mu` (быстро): **ре-валидировать** `state == catched` (мог быть
отменён/добавлен параллельно) и записать переход `catched → downloading` +
`display_name`. Ре-валидацию обеспечивает гард `setState` (target
нетерминальный → `state NOT IN terminalStates`): если пользователь успел
`catched → cancelled`, переход корректно отклонится.
То есть под сериализацией переходов — только запись перехода в БД, а не сетевые
вызовы. Спека («Добавление пойманной загрузки») формулирует это так же: под
блокировкой сериализуется переход, не `add`/namer.
`dnHint` (dn из magnet) worker получает разбором `d.SourceRef`
(`magnet.Parse`) — дёшево, без сети; для `add` используется сам `d.SourceRef`
(URL), хеши уже есть в `d.Infohashes`.
_Альтернатива:_ хранить hint отдельным полем. Отвергнуто — `SourceRef` уже есть,
повторный парс тривиален, схему не трогаем.
### Р4. Сбой `add` — ретрай тиком, предохранитель `catch_timeout`
Парс magnet уже прошёл синхронно в приёме, поэтому в `catched` ссылка
валидна — сбои `add` почти всегда транзиентны (qBit недоступен). Поэтому
неуспешный `add` **оставляет** загрузку в `catched` (повтор на следующем тике),
а не уводит в `failed` по первому сбою. Страховка от устойчивой недоступности —
предохранитель `catch_timeout` (новый конфиг, дефолт консервативный, напр. по
образцу `magnet_timeout`): `catched` старше него → `failed` (`qbit_add`) +
уведомление автора. Это переиспользует существующий паттерн
таймаутов-предохранителей (`magnet_timeout`/`stuck_after`).
### Р5. `catched` исключён из проверок пропажи раздачи
Поллинг активных (`worker.go`: `ListDownloadsByState(StateDownloading)`) уже не
включает `catched` — но фиксируем это требованием и тестом. Реконсилятор
(`desyncStates`) `catched` не содержит. `discover`: когда worker добавит
раздачу catched-загрузки, следующий тик увидит её по категории, но exists-чек по
infohash найдёт активную загрузку и не заведёт дубль (инвариант держится).
### Р6. Веб-UI: бейдж/фаза `catched`
Добавить подпись бейджа и фазу жизненного цикла для `catched` (перед
`downloading`), включить в активную группу. Заголовок при пустом `display_name`
уже деградирует по фолбеку. Секции раздачи/живого прогресса для `catched` нет
(нет qBit-записи) — шаблон должен это переносить без ошибок (обычно уже так, т.к.
телеметрия ищется по infohash и не находится).
## Risks / Trade-offs
- [Задержка появления в qBit до ~`poll_interval` (5с)] → Приемлемо: клиенту уже
ответили; пользователь видит `catched` в UI. При желании позже — немедленный
пинок, но вне объёма.
- [Гонка discover ↔ шаг добавления (worker добавил, тот же/следующий тик
усыновляет)] → Дедуп по infohash в `discover` (exists-чек) уже защищает;
оба пути под общей блокировкой переходов.
- [Пустой `display_name` в `catched` виден в UI] → Фолбек заголовка уже есть
(распознанное/усечённый источник); визуально корректно.
- [Namer/LLM-ошибка на шаге добавления] → Как и раньше best-effort: пустое имя →
`add` без `rename`; шаг добавления не срывается из-за namer.
- [Учёт `catched` во всех местах, где перечислены активные состояния] →
Единая точка `statesInGroup` + аудит по `StateDownloading`-упоминаниям в
worker/store; покрыть тестами дедупа и группировки.
## Migration Plan
Аддитивно: новое состояние и новый конфиг `catch_timeout` (с дефолтом — старый
конфиг валиден). Существующие загрузки в `downloading`/терминальных не
затронуты. Новый путь приёма применяется к новым загрузкам.
Откат — ревертом кода. Загрузки, застрявшие в `catched` на момент отката,
старая логика не знает и `retry` их не поднимет (`Retry` разрешён только из
`failed`/`stuck`), а как активные они ещё и блокируют повторный приём того же
infohash. Окно мало (`catched` живёт секунды до тика worker'а), но при откате
такие строки нужно снять вручную: `UPDATE download SET state='failed',
error_code='qbit_add' WHERE state='catched'` — после чего они доступны штатному
`retry`. Зафиксировать в задаче/рантбуке отката.
## Open Questions
- Значение дефолта `catch_timeout` (5–15 мин?) — уточнить при apply, на спеку
не влияет.
- Нужен ли отдельный `error_code` для `catch_timeout` или переиспользуем
`qbit_add` (взято `qbit_add`, т.к. корень — невозможность добавить).
@@ -0,0 +1,78 @@
## Why
Сейчас приём (`Ingest`) синхронно делает всё: парсит источник, выводит
отображаемое имя (потенциально **медленный вызов LLM** в `namer.DeriveName`) и
добавляет источник в qBittorrent — и только потом отвечает клиенту. Долгий LLM
и внешний запрос к qBit задерживают ответ HTTP API / веб-UI / Telegram и
расширяют окно «строка в БД есть, в qBittorrent ещё нет».
Идея: сделать приём **быстрым** — синхронно только валидировать и сохранить
загрузку (новое состояние `catched`), сразу вернув ответ; вывод имени и
добавление в qBittorrent вынести в отдельный **асинхронный шаг машины
состояний**, который двигает worker.
## What Changes
- Новое состояние **`catched`** — загрузка поймана и персистентно сохранена
(быстрый путь). Нетерминальное, активное (участвует в инварианте «≤1 активная
загрузка на infohash»).
- **Приём (`ingest`) — быстрый**: парс magnet, извлечение инфохэшей, синтез
контекста из полей ссылки (дёшево, без сети), атомарный дедуп и запись
загрузки в `catched`. Ответ клиенту сразу. Синхронного вывода имени и
добавления в qBittorrent в приёме больше нет.
- **Асинхронный шаг (`download-tracking`, worker)**: на каждом тике worker
подхватывает `catched`-загрузки, выводит отображаемое имя из контекста (LLM +
фолбек), добавляет источник в qBittorrent (категория/savepath/rename) и
переводит `catched → downloading` (отдельного `added-to-qbittorrent` нет —
`downloading` и так значит «в qBit, возможно metaDL»).
- **Обработка сбоев вне запроса клиента**: неуспешный `add` оставляет загрузку
в `catched` (worker перетыкивает на следующем тике); предохранитель
`catch_timeout` уводит долго-зависший `catched` в `failed`
(`error_code` `qbit_add`) с уведомлением автора.
- `catched` **исключён** из проверок «раздача не найдена» (у него раздачи нет
по дизайну) — ни поллинг, ни сверка не считают его рассинхроном/orphaned.
- **Веб-UI** показывает промежуточное состояние `catched` (бейдж/фаза
жизненного цикла), заголовок деградирует, пока имя не выведено (фолбек уже
есть). `catched` попадает в активную группу списка.
## Capabilities
### New Capabilities
_Нет._ Изменение переиспользует существующие capabilities.
### Modified Capabilities
- `ingest`: приём становится быстрым — сохранение в `catched` и мгновенный
ответ; синхронный вывод имени и добавление в qBittorrent из приёма убраны
(переезжают в асинхронный шаг). Требования по выводу имени переформулированы:
выполняются на шаге добавления, а не в пути ответа клиента.
- `download-tracking`: добавляется шаг «добавление пойманной загрузки в
qBittorrent» (вывод имени + `add` + переход `catched → downloading`),
предохранитель `catch_timeout`, исключение `catched` из проверок пропажи
раздачи.
- `web-ui`: человекочитаемый бейдж для `catched`; активная группа включает
`catched`; заголовок при пустом имени — по фолбеку; карточка `catched`
самообновляется htmx-поллингом до перехода в `downloading`.
_Без спек-правок:_ `state-reconciliation` и `live-status` не меняются.
Исключение `catched` из проверок рассинхрона нормативно закреплено требованием
`download-tracking` «catched не считается пропажей раздачи» (само поведение уже
верно — `catched` не входит в `desyncStates` и дебаунс пропажи источника его не
трогает). Иллюстративное перечисление активных состояний в тексте
`state-reconciliation` остаётся на кросс-ссылке и будет выверено при следующем
касании этой спеки. `live-status`: у `catched` нет qBit-телеметрии по дизайну —
живой прогресс для него корректно отсутствует.
## Impact
- Код: `internal/store` (состояние `StateCatched`, группа active), `internal/
ingest` (быстрый путь: убрать namer/qbt-add, писать `catched`), `internal/
worker` (новый шаг обработки `catched`: namer + qbt-add + переход, таймаут),
`internal/httpapi`+`internal/tgbot` (без правок API — ответ и так по Result),
веб-UI шаблоны (бейдж/фаза `catched`).
- Конфиг: новый `catch_timeout` (предохранитель), дефолт консервативный.
- Данные: у активной загрузки теперь есть фаза без infohash-раздачи; схема БД
не меняется (используем существующие `state`/`error_code`/`error_msg`).
- Совместимость: существующие загрузки не затронуты; переход одноразовый на
уровне логики приёма.
@@ -0,0 +1,78 @@
## ADDED Requirements
### Requirement: Добавление пойманной загрузки в qBittorrent
Worker SHALL периодически (в поллинг-цикле, под единой блокировкой переходов)
подхватывать загрузки в состоянии `catched` и для каждой: вывести отображаемое
имя из контекста (см. `ingest` «Отображаемое имя торрента из контекста»),
добавить источник в qBittorrent (категория `qbittorrent.category`, savepath,
`rename`) и перевести загрузку `catched → downloading`. Отдельного состояния
между `catched` и `downloading` быть SHALL NOT — успешный `add` сразу переводит
в `downloading` (которое и означает «в qBit, возможно `metaDL`»).
Неуспешный `add` (qBittorrent недоступен и т.п.) SHALL оставлять загрузку в
`catched` для повторной попытки на следующем тике; переход в терминальное
состояние по единичному сбою происходить SHALL NOT (ретраи — естественными
тиками поллинга).
Медленные вызовы (вывод имени через LLM, `qbt.Add`) SHALL выполняться **вне**
блокировки сериализации переходов, чтобы не задерживать команды транспортов и
поллинг. Под блокировкой сериализуется только **запись перехода** `catched →
downloading` (см. «Переходы состояний сериализуются воркером»), с
ре-валидацией, что загрузка всё ещё в `catched` (иначе переход отклоняется —
например, при параллельной отмене).
#### Scenario: Пойманная загрузка добавляется в qBittorrent
- **GIVEN** загрузка в состоянии `catched`
- **WHEN** worker обрабатывает тик
- **THEN** выводится отображаемое имя, источник добавляется в qBittorrent с
нашей категорией и `rename`
- **AND** загрузка переходит в `downloading`
#### Scenario: Временный сбой добавления — повтор
- **GIVEN** загрузка в `catched`, qBittorrent временно недоступен
- **WHEN** worker пытается добавить источник и `add` не удался
- **THEN** загрузка остаётся в `catched`
- **AND** на следующем тике попытка добавления повторяется
#### Scenario: Отмена во время добавления
- **GIVEN** загрузка в `catched`, worker выводит имя и добавляет её вне
блокировки
- **WHEN** параллельно приходит команда отмены (`catched → cancelled`), а затем
worker берёт блокировку для записи перехода
- **THEN** ре-валидация видит, что загрузка уже не в `catched`, и переход в
`downloading` не применяется
### Requirement: Предохранитель зависшего catched
Система SHALL переводить загрузку, задержавшуюся в `catched` дольше
`catch_timeout` (конфигурируемый предохранитель, дефолт консервативный), в
`failed` (`error_code` `qbit_add`) и уведомлять автора. Возраст SHALL считать
от времени попадания в `catched` (создания загрузки). Предохранитель —
редкий страховочный механизм на случай устойчивой недоступности qBittorrent, а
не штатный путь.
#### Scenario: catched висит дольше таймаута
- **GIVEN** загрузка в `catched` дольше `catch_timeout`
- **WHEN** идёт тик поллинга
- **THEN** загрузка переходит в `failed` с `error_code` `qbit_add`
- **AND** автор загрузки уведомляется
### Requirement: catched не считается пропажей раздачи
Система SHALL исключать состояние `catched` из проверок «раздача не найдена в
qBittorrent» — как в поллинге активных загрузок, так и в сверке рассинхрона
(`state-reconciliation`). У пойманной загрузки раздачи в qBittorrent ещё нет по
дизайну, поэтому её отсутствие система SHALL NOT трактовать как рассинхрон,
`orphaned` или пропажу источника.
#### Scenario: Отсутствие раздачи у catched — не рассинхрон
- **GIVEN** загрузка в `catched` (раздачи в qBittorrent ещё нет)
- **WHEN** идёт тик поллинга и сверки
- **THEN** загрузка не считается пропавшей/рассинхронизированной и остаётся в
`catched` (до добавления воркером или срабатывания `catch_timeout`)
@@ -0,0 +1,81 @@
## MODIFIED Requirements
### Requirement: Приём источника и заведение загрузки
Приём SHALL быть единым **быстрым** use-case, общим для всех транспортов (HTTP,
Telegram, CLI): по источнику (Ф1 — magnet) и текстовому контексту система SHALL
синхронно извлечь инфохэши, синтезировать контекст из полей ссылки (без сети),
дедуплицировать по активной задаче и при отсутствии дубля завести загрузку
(`download` в состоянии **`catched`** + записи `download_infohash`), после чего
**сразу вернуть ответ** транспорту. Заведение загрузки и запись её хешей SHALL
выполняться атомарно (см. «Атомарность возврата загрузки в активное
состояние»).
Синхронный путь приёма SHALL NOT обращаться к qBittorrent и SHALL NOT выводить
отображаемое имя (потенциально медленный LLM): и добавление источника в
qBittorrent, и вывод имени выполняются отдельным асинхронным шагом машины
состояний (worker) — см. `download-tracking` «Добавление пойманной загрузки в
qBittorrent».
`catched` — нетерминальное активное состояние: оно участвует в инварианте «не
более одной активной загрузки на infohash» наравне с прочими активными.
#### Scenario: Быстрый приём magnet
- **GIVEN** валидная magnet-ссылка и контекст
- **WHEN** вызывается приём
- **THEN** создаётся `download` в состоянии `catched` с записями
`download_infohash`
- **AND** ответ транспорту отдан без обращения к qBittorrent и без вывода имени
#### Scenario: Дубль по активной задаче на быстром пути
- **GIVEN** уже есть активная (в т.ч. `catched`) загрузка с тем же infohash
- **WHEN** вызывается приём
- **THEN** новая загрузка не создаётся, возвращается существующая
### Requirement: Отображаемое имя торрента из контекста
На шаге добавления пойманной загрузки в qBittorrent (worker) система SHALL
выводить из контекста загрузки человекочитаемое отображаемое имя и передавать
его в qBittorrent (параметр `rename` API `/torrents/add`), чтобы задача в списке
qBit не показывалась безликим `dn` magnet-ссылки. Это же имя система SHALL
**сохранять у загрузки** (`download.display_name`) для последующего показа
заголовком в веб-UI.
Имя SHALL быть коротким читаемым ярлыком (название, опционально режиссёр и
год; для сериала — номер сезона, если он определён), а не куском сырого
контекста. Имя SHALL очищаться от управляющих символов и переводов строк и
SHALL обрезаться по ограничению длины.
Вывод имени SHALL выполняться на шаге добавления, непосредственно перед вызовом
`add` (параметр `rename` действует только в момент добавления), а НЕ в
синхронном пути ответа приёма. В состоянии `catched` (до добавления)
`download.display_name` ещё пуст — веб-UI берёт заголовок из фолбека.
Отображаемое имя SHALL влиять только на отображение (в qBittorrent и как
заголовок в веб-UI) и SHALL NOT влиять на пути файлов на диске, распознавание
или раскладку — реальные пути система по-прежнему читает из qBit API.
#### Scenario: Имя из контекста передаётся в qBittorrent
- **WHEN** на шаге добавления получен непустой контекст, из которого удалось
вывести имя
- **THEN** система передаёт это имя в qBittorrent в параметре `rename`
- **AND** имя — короткий читаемый ярлык вида «название (режиссёр, год)»,
где режиссёр и год опциональны
#### Scenario: Имя сохраняется у загрузки
- **WHEN** на шаге добавления выведено непустое отображаемое имя
- **THEN** система сохраняет его в `download.display_name` (обновлением записи
загрузки)
- **AND** веб-UI использует его заголовком карточки и страницы загрузки
#### Scenario: Контекст пуст или имя не получено
- **WHEN** контекста нет либо ни один способ вывода не дал непустого имени
- **THEN** система добавляет загрузку без параметра `rename`
- **AND** qBittorrent оставляет собственное имя (из `dn`/торрента)
- **AND** `download.display_name` остаётся пустым, а веб-UI берёт заголовок из
фолбека (распознанное название или усечённый источник)
@@ -0,0 +1,42 @@
## ADDED Requirements
### Requirement: Отображение промежуточного состояния catched
Веб-UI SHALL отображать состояние `catched` как штатную промежуточную фазу
(«поймано, добавляется в qBittorrent»): бейдж статуса загрузки SHALL иметь
понятную человекочитаемую подпись для `catched` (а не сырое `catched`), а
загрузка в `catched` SHALL относиться к **активной** группе списка.
Пока отображаемое имя ещё не выведено (в `catched` `download.display_name`
пуст), заголовок загрузки SHALL деградировать по существующему фолбеку
(распознанное название или усечённый источник) — см. «Заголовок загрузки из
имени раздачи». Секция раздачи/живого прогресса для `catched` SHALL корректно
отсутствовать (раздачи в qBittorrent ещё нет), не создавая ошибок отображения.
Карточка/страница загрузки в `catched` SHALL самообновляться самозавершающимся
htmx-поллингом (см. конвенцию веб-UI): по переходе загрузки в `downloading`
интерфейс SHALL отражать это без перезагрузки страницы (подхватить бейдж,
выведенное имя и появившийся живой прогресс), а поллинг фазы `catched` SHALL
завершаться, как только загрузка её покинула.
#### Scenario: Бейдж и группа для catched
- **WHEN** загрузка находится в состоянии `catched`
- **THEN** её бейдж статуса имеет человекочитаемую подпись для `catched`
- **AND** загрузка попадает в активную группу списка
#### Scenario: Заголовок catched без имени
- **GIVEN** загрузка в `catched` с пустым `download.display_name`
- **WHEN** рендерится карточка/страница загрузки
- **THEN** заголовок берётся из фолбека (распознанное название или усечённый
источник), без ошибок отображения
- **AND** секция раздачи/живого прогресса не показывается (раздачи ещё нет)
#### Scenario: Самообновление при переходе в downloading
- **GIVEN** открытая карточка загрузки в `catched`
- **WHEN** worker перевёл загрузку в `downloading`
- **THEN** интерфейс без перезагрузки показывает состояние `downloading`
(бейдж, имя, живой прогресс)
- **AND** поллинг фазы `catched` завершается
@@ -0,0 +1,77 @@
## 1. Состояние catched
- [x] 1.1 Добавить `StateCatched State = "catched"` в `internal/store/download.go`;
убедиться, что оно НЕ в `terminalStates` (нетерминальное = активное)
- [x] 1.2 Включить `StateCatched` в `statesInGroup(GroupActive)`; проверить
поиск/фильтры списка
- [x] 1.3 Тест: `catched` активно для дедупа (`CreateDownloadIfNoActive` держит
инвариант ≤1 активная на infohash с участием `catched`)
## 2. Быстрый приём (ingest)
- [x] 2.1 `Ingest`: писать `download` в `StateCatched` с пустым `DisplayName`;
убрать из синхронного пути `namer.DeriveName` и `qbt.Add`; вернуть `Result`
сразу после записи
- [x] 2.2 Убрать из `ingest.Service` ставшие ненужными зависимости/код:
`Namer`, `QBittorrent`, `notifyFailed`/`SetFailureNotifier`, `qbit_add`-путь
падения add (падение теперь у worker'а — п.4)
- [x] 2.3 Обновить тесты ingest: приём создаёт `catched`, не зовёт qBit/namer;
дедуп по активной (в т.ч. `catched`); синтез контекста сохраняется как прежде
- [x] 2.4 Транспорты (`httpapi`, `tgbot`): ответ по `Result` корректен для
`catched` (без правок API); Telegram-текст не показывает сырое `catched`
(дружелюбная формулировка «принято/добавляется»)
## 3. Асинхронный шаг добавления (worker)
- [x] 3.1 Внедрить в worker зависимости `Namer` и `qbt.Add`; добавить в
интерфейс `worker.Store` новый метод записи имени (`SetDisplayName`)
- [x] 3.2 Шаг в поллинг-цикле, сеть ВНЕ `w.mu`: под замком снять
`ListDownloadsByState(StateCatched)`; вне замка для каждой — разобрать
`d.SourceRef` (`magnet.Parse`) для dn-hint, вывести имя (`namer.DeriveName`),
вызвать `qbt.Add(urls=d.SourceRef, category, savepath, rename)`; снова под
замком — ре-валидировать `state==catched` и записать переход `catched →
downloading` + `display_name`
- [x] 3.3 Сбой `add`: оставить в `catched` (повтор на следующем тике), не уводить
в терминальное по единичному сбою; логировать (ext.* уже логирует клиент)
- [x] 3.4 Тесты worker: `catched → downloading` при успехе (rename передан,
display_name сохранён, сетевые вызовы вне замка); транзиентный сбой оставляет
`catched` и повторяет; отмена во время add (ре-валидация отбрасывает переход)
## 4. Предохранитель catch_timeout
- [x] 4.1 Конфиг `catch_timeout` (+ дефолт, валидация на старте) — по образцу
`magnet_timeout`; документация конфига
- [x] 4.2 В поллинг-цикле: `catched` старше `catch_timeout` (от создания) →
`failed` (`error_code` `qbit_add`) + уведомление автора
- [x] 4.3 Тест: `catched` за таймаутом → `failed` + notify
## 5. Исключение catched из проверок пропажи
- [x] 5.1 Убедиться (и закрепить тестом), что поллинг активных и сверка
рассинхрона (`state-reconciliation`) не трактуют `catched` как пропажу/
orphaned
- [x] 5.2 Тест на гонку discover ↔ шаг добавления: дубль по infohash не заводится
## 6. Веб-UI
- [x] 6.1 Человекочитаемая подпись бейджа для `catched` (`badgeLabel`), чтобы не
показывать сырое `catched`; `catched` в активной группе списка
- [x] 6.2 Заголовок при пустом `display_name` (фолбек) и отсутствие секции
раздачи/живого прогресса для `catched` без ошибок
- [x] 6.3 Самозавершающийся htmx-поллинг карточки/страницы в `catched`: пока
`catched` — опрашивает фрагмент; по переходе в `downloading` показывает
прогресс/имя без перезагрузки и завершает поллинг фазы (правка live-рендера
для не-`downloading` активной фазы)
- [x] 6.4 Тест httpapi/шаблонов: карточка/страница `catched` рендерится;
фрагмент-поллинг отдаётся для `catched` и завершается после перехода
## 7. Проверка
- [x] 7.1 `task test` и `task lint` зелёные
- [x] 7.2 `openspec validate fast-catch-ingest --strict` проходит
- [x] 7.3 Прогон вручную/через verify: приём отвечает быстро (без LLM в пути),
загрузка проходит `catched → downloading`, карточка обновляется без
перезагрузки
- [x] 7.4 Рантбук отката: снять застрявшие `catched` (`UPDATE download SET
state='failed', error_code='qbit_add' WHERE state='catched'`) — зафиксировать
в описании change/задаче
+77
View File
@@ -92,3 +92,80 @@ Worker SHALL периодически сверять раздачи qBittorrent
- **WHEN** они обрабатываются
- **THEN** переходы применяются последовательно под блокировкой, без гонки
### Requirement: Добавление пойманной загрузки в qBittorrent
Worker SHALL периодически (в поллинг-цикле, под единой блокировкой переходов)
подхватывать загрузки в состоянии `catched` и для каждой: вывести отображаемое
имя из контекста (см. `ingest` «Отображаемое имя торрента из контекста»),
добавить источник в qBittorrent (категория `qbittorrent.category`, savepath,
`rename`) и перевести загрузку `catched → downloading`. Отдельного состояния
между `catched` и `downloading` быть SHALL NOT — успешный `add` сразу переводит
в `downloading` (которое и означает «в qBit, возможно `metaDL`»).
Неуспешный `add` (qBittorrent недоступен и т.п.) SHALL оставлять загрузку в
`catched` для повторной попытки на следующем тике; переход в терминальное
состояние по единичному сбою происходить SHALL NOT (ретраи — естественными
тиками поллинга).
Медленные вызовы (вывод имени через LLM, `qbt.Add`) SHALL выполняться **вне**
блокировки сериализации переходов, чтобы не задерживать команды транспортов и
поллинг. Под блокировкой сериализуется только **запись перехода** `catched →
downloading` (см. «Переходы состояний сериализуются воркером»), с
ре-валидацией, что загрузка всё ещё в `catched` (иначе переход отклоняется —
например, при параллельной отмене).
#### Scenario: Пойманная загрузка добавляется в qBittorrent
- **GIVEN** загрузка в состоянии `catched`
- **WHEN** worker обрабатывает тик
- **THEN** выводится отображаемое имя, источник добавляется в qBittorrent с
нашей категорией и `rename`
- **AND** загрузка переходит в `downloading`
#### Scenario: Временный сбой добавления — повтор
- **GIVEN** загрузка в `catched`, qBittorrent временно недоступен
- **WHEN** worker пытается добавить источник и `add` не удался
- **THEN** загрузка остаётся в `catched`
- **AND** на следующем тике попытка добавления повторяется
#### Scenario: Отмена во время добавления
- **GIVEN** загрузка в `catched`, worker выводит имя и добавляет её вне
блокировки
- **WHEN** параллельно приходит команда отмены (`catched → cancelled`), а затем
worker берёт блокировку для записи перехода
- **THEN** ре-валидация видит, что загрузка уже не в `catched`, и переход в
`downloading` не применяется
### Requirement: Предохранитель зависшего catched
Система SHALL переводить загрузку, задержавшуюся в `catched` дольше
`catch_timeout` (конфигурируемый предохранитель, дефолт консервативный), в
`failed` (`error_code` `qbit_add`) и уведомлять автора. Возраст SHALL считать
от времени попадания в `catched` (создания загрузки). Предохранитель —
редкий страховочный механизм на случай устойчивой недоступности qBittorrent, а
не штатный путь.
#### Scenario: catched висит дольше таймаута
- **GIVEN** загрузка в `catched` дольше `catch_timeout`
- **WHEN** идёт тик поллинга
- **THEN** загрузка переходит в `failed` с `error_code` `qbit_add`
- **AND** автор загрузки уведомляется
### Requirement: catched не считается пропажей раздачи
Система SHALL исключать состояние `catched` из проверок «раздача не найдена в
qBittorrent» — как в поллинге активных загрузок, так и в сверке рассинхрона
(`state-reconciliation`). У пойманной загрузки раздачи в qBittorrent ещё нет по
дизайну, поэтому её отсутствие система SHALL NOT трактовать как рассинхрон,
`orphaned` или пропажу источника.
#### Scenario: Отсутствие раздачи у catched — не рассинхрон
- **GIVEN** загрузка в `catched` (раздачи в qBittorrent ещё нет)
- **WHEN** идёт тик поллинга и сверки
- **THEN** загрузка не считается пропавшей/рассинхронизированной и остаётся в
`catched` (до добавления воркером или срабатывания `catch_timeout`)
+40 -30
View File
@@ -11,20 +11,22 @@
## Requirements
### Requirement: Отображаемое имя торрента из контекста
При добавлении загрузки в qBittorrent система SHALL выводить из контекста
загрузки человекочитаемое отображаемое имя и передавать его в qBittorrent
(параметр `rename` API `/torrents/add`), чтобы задача в списке qBit не
показывалась безликим `dn` magnet-ссылки. Это же имя система SHALL **сохранять
у загрузки** (`download.display_name`) для последующего показа заголовком в
веб-UI.
На шаге добавления пойманной загрузки в qBittorrent (worker) система SHALL
выводить из контекста загрузки человекочитаемое отображаемое имя и передавать
его в qBittorrent (параметр `rename` API `/torrents/add`), чтобы задача в списке
qBit не показывалась безликим `dn` magnet-ссылки. Это же имя система SHALL
**сохранять у загрузки** (`download.display_name`) для последующего показа
заголовком в веб-UI.
Имя SHALL быть коротким читаемым ярлыком (название, опционально режиссёр и
год; для сериала — номер сезона, если он определён), а не куском сырого
контекста. Имя SHALL очищаться от управляющих символов и переводов строк и
SHALL обрезаться по ограничению длины.
Вывод имени SHALL выполняться синхронно перед отдачей источника в
qBittorrent (параметр `rename` действует только в момент добавления).
Вывод имени SHALL выполняться на шаге добавления, непосредственно перед вызовом
`add` (параметр `rename` действует только в момент добавления), а НЕ в
синхронном пути ответа приёма. В состоянии `catched` (до добавления)
`download.display_name` ещё пуст — веб-UI берёт заголовок из фолбека.
Отображаемое имя SHALL влиять только на отображение (в qBittorrent и как
заголовок в веб-UI) и SHALL NOT влиять на пути файлов на диске, распознавание
@@ -32,17 +34,17 @@ qBittorrent (параметр `rename` действует только в мом
#### Scenario: Имя из контекста передаётся в qBittorrent
- **WHEN** загрузку добавляют с непустым контекстом, из которого удалось
получить имя
- **WHEN** на шаге добавления получен непустой контекст, из которого удалось
вывести имя
- **THEN** система передаёт это имя в qBittorrent в параметре `rename`
- **AND** имя — короткий читаемый ярлык вида «название (режиссёр, год)»,
где режиссёр и год опциональны
#### Scenario: Имя сохраняется у загрузки
- **WHEN** при приёме получено непустое отображаемое имя
- **THEN** система сохраняет его в `download.display_name` вместе с созданием
загрузки
- **WHEN** на шаге добавления выведено непустое отображаемое имя
- **THEN** система сохраняет его в `download.display_name` (обновлением записи
загрузки)
- **AND** веб-UI использует его заголовком карточки и страницы загрузки
#### Scenario: Контекст пуст или имя не получено
@@ -111,29 +113,37 @@ JSON-вывод), извлекая из контекста тип (movie/series)
### Requirement: Приём источника и заведение загрузки
Приём SHALL быть единым use-case, общим для всех транспортов (HTTP, Telegram,
CLI): по источнику (Ф1 — magnet) и текстовому контексту система SHALL извлечь
инфохэши, дедуплицировать по активной задаче, при отсутствии дубля завести
загрузку (`download` в состоянии `downloading` + записи `download_infohash`) и
отдать источник в qBittorrent (категория `qbittorrent.category`, savepath). Если
добавление в qBittorrent не удалось, система SHALL перевести уже заведённую
загрузку в `failed` (`error_code` `qbit_add`) и уведомить автора. Заведение
загрузки и запись её хешей SHALL выполняться атомарно (см. «Атомарность возврата
загрузки в активное состояние»).
Приём SHALL быть единым **быстрым** use-case, общим для всех транспортов (HTTP,
Telegram, CLI): по источнику (Ф1 — magnet) и текстовому контексту система SHALL
синхронно извлечь инфохэши, синтезировать контекст из полей ссылки (без сети),
дедуплицировать по активной задаче и при отсутствии дубля завести загрузку
(`download` в состоянии **`catched`** + записи `download_infohash`), после чего
**сразу вернуть ответ** транспорту. Заведение загрузки и запись её хешей SHALL
выполняться атомарно (см. «Атомарность возврата загрузки в активное
состояние»).
#### Scenario: Успешный приём magnet
Синхронный путь приёма SHALL NOT обращаться к qBittorrent и SHALL NOT выводить
отображаемое имя (потенциально медленный LLM): и добавление источника в
qBittorrent, и вывод имени выполняются отдельным асинхронным шагом машины
состояний (worker) — см. `download-tracking` «Добавление пойманной загрузки в
qBittorrent».
`catched` — нетерминальное активное состояние: оно участвует в инварианте «не
более одной активной загрузки на infohash» наравне с прочими активными.
#### Scenario: Быстрый приём magnet
- **GIVEN** валидная magnet-ссылка и контекст
- **WHEN** вызывается приём
- **THEN** создаётся `download` в `downloading` с записями `download_infohash`
- **AND** источник отдан в qBittorrent с нашей категорией
- **THEN** создаётся `download` в состоянии `catched` с записями
`download_infohash`
- **AND** ответ транспорту отдан без обращения к qBittorrent и без вывода имени
#### Scenario: Падение добавления в qBittorrent
#### Scenario: Дубль по активной задаче на быстром пути
- **GIVEN** заведённую загрузку не удалось добавить в qBittorrent
- **WHEN** обрабатывается ошибка добавления
- **THEN** загрузка переходит в `failed` с `error_code` `qbit_add`
- **AND** автор загрузки уведомляется
- **GIVEN** уже есть активная (в т.ч. `catched`) загрузка с тем же infohash
- **WHEN** вызывается приём
- **THEN** новая загрузка не создаётся, возвращается существующая
### Requirement: Множество инфохэшей загрузки
+41
View File
@@ -432,3 +432,44 @@ PRG-редиректом, и действие исполняется тем же
- **THEN** карточка подменяется на месте новым состоянием и остаётся видимой до
следующей полной загрузки списка, без клиентского переупорядочивания
### Requirement: Отображение промежуточного состояния catched
Веб-UI SHALL отображать состояние `catched` как штатную промежуточную фазу
(«поймано, добавляется в qBittorrent»): бейдж статуса загрузки SHALL иметь
понятную человекочитаемую подпись для `catched` (а не сырое `catched`), а
загрузка в `catched` SHALL относиться к **активной** группе списка.
Пока отображаемое имя ещё не выведено (в `catched` `download.display_name`
пуст), заголовок загрузки SHALL деградировать по существующему фолбеку
(распознанное название или усечённый источник) — см. «Заголовок загрузки из
имени раздачи». Секция раздачи/живого прогресса для `catched` SHALL корректно
отсутствовать (раздачи в qBittorrent ещё нет), не создавая ошибок отображения.
Карточка/страница загрузки в `catched` SHALL самообновляться самозавершающимся
htmx-поллингом (см. конвенцию веб-UI): по переходе загрузки в `downloading`
интерфейс SHALL отражать это без перезагрузки страницы (подхватить бейдж,
выведенное имя и появившийся живой прогресс), а поллинг фазы `catched` SHALL
завершаться, как только загрузка её покинула.
#### Scenario: Бейдж и группа для catched
- **WHEN** загрузка находится в состоянии `catched`
- **THEN** её бейдж статуса имеет человекочитаемую подпись для `catched`
- **AND** загрузка попадает в активную группу списка
#### Scenario: Заголовок catched без имени
- **GIVEN** загрузка в `catched` с пустым `download.display_name`
- **WHEN** рендерится карточка/страница загрузки
- **THEN** заголовок берётся из фолбека (распознанное название или усечённый
источник), без ошибок отображения
- **AND** секция раздачи/живого прогресса не показывается (раздачи ещё нет)
#### Scenario: Самообновление при переходе в downloading
- **GIVEN** открытая карточка загрузки в `catched`
- **WHEN** worker перевёл загрузку в `downloading`
- **THEN** интерфейс без перезагрузки показывает состояние `downloading`
(бейдж, имя, живой прогресс)
- **AND** поллинг фазы `catched` завершается
+1 -1
View File
@@ -1,5 +1,5 @@
{{define "card"}}
<article class="card" id="card-{{.ID}}">
<article class="card" id="card-{{.ID}}"{{if .SelfPoll}} hx-get="/fragments/downloads/{{.ID}}/card" hx-trigger="every 3s" hx-swap="outerHTML"{{end}}>
<div class="card-top">
<div>
<div class="card-title"><a href="/download/{{.ID}}" style="color:inherit">{{.Title}}</a></div>
+1 -1
View File
@@ -1,5 +1,5 @@
{{define "download_main"}}
<main class="main" id="download-main">
<main class="main" id="download-main"{{if .SelfPoll}} hx-get="/download/{{.ID}}" hx-trigger="every 3s" hx-select="#download-main" hx-swap="outerHTML"{{end}}>
<a class="back-link" href="/">← ко всем загрузкам</a>
{{if .Error}}<div class="result-banner" style="background:var(--st-err-bg);border-color:color-mix(in srgb,var(--st-err) 35%,transparent)"><h2 style="color:var(--st-err)">{{.Error}}</h2></div>{{end}}