Быстрый приём: сохранение в 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:
@@ -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))
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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/задаче
|
||||
@@ -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`)
|
||||
|
||||
|
||||
@@ -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: Множество инфохэшей загрузки
|
||||
|
||||
|
||||
@@ -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,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,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}}
|
||||
|
||||
Reference in New Issue
Block a user