Быстрый приём: сохранение в 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
|
// Вывод отображаемого имени торрента из контекста (best-effort). Без LLM
|
||||||
// работает только алгоритмический фолбек.
|
// работает только алгоритмический фолбек. Namer зовёт worker на шаге
|
||||||
|
// добавления пойманной загрузки (не синхронный приём).
|
||||||
namer := naming.New(llmProvider, cfg.LLM.MaxRetries, logger)
|
namer := naming.New(llmProvider, cfg.LLM.MaxRetries, logger)
|
||||||
|
|
||||||
ingestor := ingest.New(st, qb, namer, ingest.Config{
|
// Быстрый приём: сохраняет загрузку в catched и сразу отвечает; добавление в
|
||||||
Category: cfg.QBittorrent.Category,
|
// qBittorrent и вывод имени делает worker (см. download-tracking).
|
||||||
SavePath: cfg.QBittorrent.SavePath,
|
ingestor := ingest.New(st, logger)
|
||||||
}, logger)
|
|
||||||
|
|
||||||
// Ф4: базы метаданных (опц.). Без них авто-раскладки нет — всё в review.
|
// Ф4: базы метаданных (опц.). Без них авто-раскладки нет — всё в review.
|
||||||
providers, err := metadataProviders(cfg, logger)
|
providers, err := metadataProviders(cfg, logger)
|
||||||
@@ -126,8 +126,11 @@ func runServe(args []string) error {
|
|||||||
PollInterval: cfg.Worker.PollInterval.Std(),
|
PollInterval: cfg.Worker.PollInterval.Std(),
|
||||||
StuckAfter: cfg.Worker.StuckAfter.Std(),
|
StuckAfter: cfg.Worker.StuckAfter.Std(),
|
||||||
MagnetTimeout: cfg.Worker.MagnetTimeout.Std(),
|
MagnetTimeout: cfg.Worker.MagnetTimeout.Std(),
|
||||||
|
CatchTimeout: cfg.Worker.CatchTimeout.Std(),
|
||||||
SourceMissingThreshold: cfg.Worker.SourceMissingThreshold,
|
SourceMissingThreshold: cfg.Worker.SourceMissingThreshold,
|
||||||
}, logger)
|
}, logger)
|
||||||
|
// Вывод имени на шаге добавления пойманной загрузки (best-effort).
|
||||||
|
wrk.SetNamer(namer)
|
||||||
|
|
||||||
// Пересканирование Jellyfin после раскладки (опц.). Недоступность Jellyfin
|
// Пересканирование Jellyfin после раскладки (опц.). Недоступность Jellyfin
|
||||||
// не валит сервис — скан просто не сработает (залогируется в воркере).
|
// не валит сервис — скан просто не сработает (залогируется в воркере).
|
||||||
@@ -188,10 +191,6 @@ func runServe(args []string) error {
|
|||||||
WebBaseURL: cfg.Telegram.WebBaseURL,
|
WebBaseURL: cfg.Telegram.WebBaseURL,
|
||||||
}, logger)
|
}, logger)
|
||||||
wrk.SetNotifier(bot)
|
wrk.SetNotifier(bot)
|
||||||
// Приёмные падения (qbit_add) минуют worker — уведомляем напрямую.
|
|
||||||
ingestor.SetFailureNotifier(func(id string) {
|
|
||||||
bot.Notify(context.Background(), id, worker.EventFailed)
|
|
||||||
})
|
|
||||||
go bot.Run(ctx)
|
go bot.Run(ctx)
|
||||||
logger.Info("telegram bot enabled",
|
logger.Info("telegram bot enabled",
|
||||||
"bot", api.Self.UserName, "allowed_users", len(cfg.Telegram.AllowedUserIDs))
|
"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)
|
poll_interval = "5s" # как часто опрашивать qBittorrent; Go-duration (s/m/h)
|
||||||
stuck_after = "1h" # сколько ждать прогресса, прежде чем счесть раздачу зависшей; Go-duration
|
stuck_after = "1h" # сколько ждать прогресса, прежде чем счесть раздачу зависшей; Go-duration
|
||||||
magnet_timeout = "24h" # страховочный предел ожидания метаданных magnet (не рабочий механизм: ожившие задачи воскрешаются сверкой); Go-duration
|
magnet_timeout = "24h" # страховочный предел ожидания метаданных magnet (не рабочий механизм: ожившие задачи воскрешаются сверкой); Go-duration
|
||||||
|
catch_timeout = "10m" # страховочный предел: пойманная (catched) задача не добавилась в qBittorrent за это время → failed; Go-duration
|
||||||
source_missing_threshold = 3 # подряд тиков сверки без раздачи в qBittorrent, чтобы счесть источник удалённым (дебаунс)
|
source_missing_threshold = 3 # подряд тиков сверки без раздачи в qBittorrent, чтобы счесть источник удалённым (дебаунс)
|
||||||
|
|
||||||
[recognition]
|
[recognition]
|
||||||
|
|||||||
@@ -111,6 +111,10 @@ type Worker struct {
|
|||||||
PollInterval Duration `toml:"poll_interval"`
|
PollInterval Duration `toml:"poll_interval"`
|
||||||
StuckAfter Duration `toml:"stuck_after"`
|
StuckAfter Duration `toml:"stuck_after"`
|
||||||
MagnetTimeout Duration `toml:"magnet_timeout"`
|
MagnetTimeout Duration `toml:"magnet_timeout"`
|
||||||
|
// CatchTimeout — сколько пойманная (catched) загрузка может ждать добавления
|
||||||
|
// в qBittorrent, прежде чем счесть его невозможным и увести задачу в failed.
|
||||||
|
// Редкий предохранитель на случай устойчивой недоступности qBittorrent.
|
||||||
|
CatchTimeout Duration `toml:"catch_timeout"`
|
||||||
// SourceMissingThreshold — сколько подряд тиков сверки без раздачи в
|
// SourceMissingThreshold — сколько подряд тиков сверки без раздачи в
|
||||||
// qBittorrent нужно, чтобы счесть источник удалённым (дебаунс пропажи,
|
// qBittorrent нужно, чтобы счесть источник удалённым (дебаунс пропажи,
|
||||||
// см. state-reconciliation). Любое появление раздачи сбрасывает счётчик.
|
// см. state-reconciliation). Любое появление раздачи сбрасывает счётчик.
|
||||||
@@ -208,6 +212,7 @@ func Default() *Config {
|
|||||||
PollInterval: Duration(5 * time.Second),
|
PollInterval: Duration(5 * time.Second),
|
||||||
StuckAfter: Duration(time.Hour),
|
StuckAfter: Duration(time.Hour),
|
||||||
MagnetTimeout: Duration(24 * time.Hour),
|
MagnetTimeout: Duration(24 * time.Hour),
|
||||||
|
CatchTimeout: Duration(10 * time.Minute),
|
||||||
SourceMissingThreshold: 3,
|
SourceMissingThreshold: 3,
|
||||||
},
|
},
|
||||||
Recognition: Recognition{AutoConfidenceThreshold: 0.85},
|
Recognition: Recognition{AutoConfidenceThreshold: 0.85},
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ type downloadDetailView struct {
|
|||||||
Infohashes []string // все хеши загрузки (блок «Информация о торренте»)
|
Infohashes []string // все хеши загрузки (блок «Информация о торренте»)
|
||||||
Context string
|
Context string
|
||||||
State string
|
State string
|
||||||
|
SelfPoll bool // catched → страница сама опрашивает себя до перехода
|
||||||
Error string
|
Error string
|
||||||
ActionError string // ошибка действия на htmx-пути (своп download_main), не error_msg
|
ActionError string // ошибка действия на htmx-пути (своп download_main), не error_msg
|
||||||
Note string
|
Note string
|
||||||
@@ -99,6 +100,7 @@ func (s *server) buildDownloadView(id string, rd *worker.ReviewData) downloadDet
|
|||||||
Infohashes: d.HashList(),
|
Infohashes: d.HashList(),
|
||||||
Context: d.Context,
|
Context: d.Context,
|
||||||
State: string(d.State),
|
State: string(d.State),
|
||||||
|
SelfPoll: d.State == store.StateCatched,
|
||||||
Error: d.ErrorMsg.String,
|
Error: d.ErrorMsg.String,
|
||||||
Note: desyncNote(d.State),
|
Note: desyncNote(d.State),
|
||||||
CreatedAt: d.CreatedAt,
|
CreatedAt: d.CreatedAt,
|
||||||
|
|||||||
@@ -113,6 +113,9 @@ func NewRouter(d Deps) (http.Handler, error) {
|
|||||||
// Живые фрагменты телеметрии (htmx-поллинг; читают снимок воркера).
|
// Живые фрагменты телеметрии (htmx-поллинг; читают снимок воркера).
|
||||||
r.Get("/fragments/downloads/{id}/progress", s.handleFragProgress)
|
r.Get("/fragments/downloads/{id}/progress", s.handleFragProgress)
|
||||||
r.Get("/fragments/downloads/{id}/seeding", s.handleFragSeeding)
|
r.Get("/fragments/downloads/{id}/seeding", s.handleFragSeeding)
|
||||||
|
// Карточка целиком: самополлинг catched до перехода в downloading (бейдж,
|
||||||
|
// имя и появившийся прогресс обновляются без перезагрузки).
|
||||||
|
r.Get("/fragments/downloads/{id}/card", s.handleFragCard)
|
||||||
// Тело ревью для поллинга recognizing (htmx-своп до готового плана).
|
// Тело ревью для поллинга recognizing (htmx-своп до готового плана).
|
||||||
r.Get("/fragments/downloads/{id}/review", s.handleFragReview)
|
r.Get("/fragments/downloads/{id}/review", s.handleFragReview)
|
||||||
r.Post("/ui/downloads", s.handleUIAdd)
|
r.Post("/ui/downloads", s.handleUIAdd)
|
||||||
@@ -195,6 +198,7 @@ type downloadView struct {
|
|||||||
Error string
|
Error string
|
||||||
Terminal bool
|
Terminal bool
|
||||||
IsDownloading bool // активная загрузка → живой прогресс-бар + поллинг
|
IsDownloading bool // активная загрузка → живой прогресс-бар + поллинг
|
||||||
|
SelfPoll bool // catched → карточка сама опрашивает себя до перехода
|
||||||
Progress progressView // живой прогресс (заполняется в handleIndex из снимка)
|
Progress progressView // живой прогресс (заполняется в handleIndex из снимка)
|
||||||
Reviewable bool // review/deferred — есть экран ревью
|
Reviewable bool // review/deferred — есть экран ревью
|
||||||
Undoable bool // done — можно откатить раскладку
|
Undoable bool // done — можно откатить раскладку
|
||||||
@@ -618,6 +622,7 @@ func (s *server) toView(d store.Download, now time.Time) downloadView {
|
|||||||
Error: d.ErrorMsg.String,
|
Error: d.ErrorMsg.String,
|
||||||
Terminal: d.State.IsTerminal(),
|
Terminal: d.State.IsTerminal(),
|
||||||
IsDownloading: d.State == store.StateDownloading,
|
IsDownloading: d.State == store.StateDownloading,
|
||||||
|
SelfPoll: d.State == store.StateCatched,
|
||||||
Reviewable: d.State == store.StateReview || d.State == store.StateDeferred,
|
Reviewable: d.State == store.StateReview || d.State == store.StateDeferred,
|
||||||
Undoable: d.State == store.StateDone,
|
Undoable: d.State == store.StateDone,
|
||||||
Relinkable: d.State == store.StateReverted || d.State == store.StateCancelled ||
|
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
|
type ingestErr string
|
||||||
|
|
||||||
func (e ingestErr) Error() string { return string(e) }
|
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))
|
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-поллинг).
|
// handleFragSeeding отдаёт партиал секции «Раздача» (htmx-поллинг).
|
||||||
func (s *server) handleFragSeeding(w http.ResponseWriter, r *http.Request) {
|
func (s *server) handleFragSeeding(w http.ResponseWriter, r *http.Request) {
|
||||||
id, err := pathID(r)
|
id, err := pathID(r)
|
||||||
|
|||||||
@@ -40,6 +40,8 @@ func staticHandler(fsys fs.FS) http.Handler {
|
|||||||
// как есть, чтобы не терять его в UI.
|
// как есть, чтобы не терять его в UI.
|
||||||
func badgeLabel(state string) string {
|
func badgeLabel(state string) string {
|
||||||
switch state {
|
switch state {
|
||||||
|
case "catched":
|
||||||
|
return "🎣 принято, добавляется"
|
||||||
case "downloading":
|
case "downloading":
|
||||||
return "⬇ качается"
|
return "⬇ качается"
|
||||||
case "completed":
|
case "completed":
|
||||||
|
|||||||
+26
-96
@@ -1,6 +1,9 @@
|
|||||||
// Package ingest — use-case приёма загрузки, общий для всех транспортов
|
// Package ingest — use-case быстрого приёма загрузки, общий для всех
|
||||||
// (HTTP, Telegram, CLI). Принимает источник + контекст, отдаёт источник в
|
// транспортов (HTTP, Telegram, CLI). Синхронно только парсит источник,
|
||||||
// qBittorrent и заводит/находит задачу в БД.
|
// синтезирует контекст из полей ссылки, дедуплицирует и сохраняет загрузку в
|
||||||
|
// состоянии `catched`, сразу возвращая ответ. Вывод отображаемого имени
|
||||||
|
// (медленный LLM) и добавление в qBittorrent — отдельный асинхронный шаг
|
||||||
|
// worker'а (см. download-tracking).
|
||||||
package ingest
|
package ingest
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@@ -11,21 +14,16 @@ import (
|
|||||||
|
|
||||||
"git.vakhrushev.me/av/jellybit/internal/logctx"
|
"git.vakhrushev.me/av/jellybit/internal/logctx"
|
||||||
"git.vakhrushev.me/av/jellybit/internal/magnet"
|
"git.vakhrushev.me/av/jellybit/internal/magnet"
|
||||||
"git.vakhrushev.me/av/jellybit/internal/qbt"
|
|
||||||
"git.vakhrushev.me/av/jellybit/internal/store"
|
"git.vakhrushev.me/av/jellybit/internal/store"
|
||||||
)
|
)
|
||||||
|
|
||||||
// capIngest — стадия приёма для поля capability в логах.
|
// capIngest — стадия приёма для поля capability в логах.
|
||||||
const capIngest = "ingest"
|
const capIngest = "ingest"
|
||||||
|
|
||||||
// errCodeQbitAdd — error_code задачи, упавшей на добавлении источника в
|
|
||||||
// qBittorrent (раздачи в qBittorrent нет, восстановлению не подлежит).
|
|
||||||
const errCodeQbitAdd = "qbit_add"
|
|
||||||
|
|
||||||
// Store — нужная ingest часть хранилища.
|
// Store — нужная ingest часть хранилища.
|
||||||
type Store interface {
|
type Store interface {
|
||||||
// FindActiveByInfohash — быстрый читающий дедуп-чек (до вызова LLM-namer);
|
// FindActiveByInfohash — быстрый читающий дедуп-чек; авторитетная проверка —
|
||||||
// авторитетная проверка — внутри CreateDownloadIfNoActive.
|
// внутри CreateDownloadIfNoActive.
|
||||||
FindActiveByInfohash(ctx context.Context, hashes ...string) (*store.Download, error)
|
FindActiveByInfohash(ctx context.Context, hashes ...string) (*store.Download, error)
|
||||||
// CreateDownloadIfNoActive атомарно проверяет инвариант «одна активная
|
// CreateDownloadIfNoActive атомарно проверяет инвариант «одна активная
|
||||||
// загрузка на infohash» и заводит задачу; вернувшаяся existing ≠ nil —
|
// загрузка на infohash» и заводит задачу; вернувшаяся existing ≠ nil —
|
||||||
@@ -34,50 +32,19 @@ type Store interface {
|
|||||||
// AddInfohashes доносит задаче недостающие хеши (guarded). Нужен на
|
// AddInfohashes доносит задаче недостающие хеши (guarded). Нужен на
|
||||||
// быстром дедуп-пути, который не доходит до CreateDownloadIfNoActive.
|
// быстром дедуп-пути, который не доходит до CreateDownloadIfNoActive.
|
||||||
AddInfohashes(ctx context.Context, downloadID string, hashes []string) error
|
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.
|
// Service — реализация быстрого приёма.
|
||||||
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 — реализация приёма.
|
|
||||||
type Service struct {
|
type Service struct {
|
||||||
store Store
|
store Store
|
||||||
qbt QBittorrent
|
|
||||||
namer Namer
|
|
||||||
cfg Config
|
|
||||||
log *slog.Logger
|
log *slog.Logger
|
||||||
|
|
||||||
// notifyFailed — опц. пинг автору о падении приёма (добавление в qBittorrent
|
|
||||||
// не удалось). Closure, а не worker.Notifier: приёмное падение в qBit не
|
|
||||||
// попадает в поллинг-цикл worker (раздачи нет), поэтому уведомляет ingest
|
|
||||||
// сам; closure избавляет ядро приёма от зависимости на пакет worker.
|
|
||||||
notifyFailed func(downloadID string)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// New собирает сервис приёма. namer опционален (nil → отображаемое имя не
|
// New собирает сервис приёма.
|
||||||
// выводится; qBittorrent оставит своё).
|
func New(st Store, log *slog.Logger) *Service {
|
||||||
func New(st Store, qb QBittorrent, namer Namer, cfg Config, log *slog.Logger) *Service {
|
return &Service{store: st, log: log}
|
||||||
return &Service{store: st, qbt: qb, namer: namer, cfg: cfg, log: log}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// SetFailureNotifier подключает пинг о падении приёма (до начала работы).
|
|
||||||
func (s *Service) SetFailureNotifier(fn func(downloadID string)) { s.notifyFailed = fn }
|
|
||||||
|
|
||||||
// Request — входной запрос приёма.
|
// Request — входной запрос приёма.
|
||||||
type Request struct {
|
type Request struct {
|
||||||
Source string // пока — magnet-ссылка
|
Source string // пока — magnet-ссылка
|
||||||
@@ -92,8 +59,10 @@ type Result struct {
|
|||||||
Deduplicated bool // присоединились к уже активной задаче, нового добавления не было
|
Deduplicated bool // присоединились к уже активной задаче, нового добавления не было
|
||||||
}
|
}
|
||||||
|
|
||||||
// Ingest принимает источник: извлекает infohash, дедуплицирует по активной
|
// Ingest быстро принимает источник: извлекает infohash, синтезирует контекст из
|
||||||
// задаче, иначе заводит задачу и отдаёт источник в qBittorrent.
|
// полей ссылки, дедуплицирует по активной задаче, иначе сохраняет загрузку в
|
||||||
|
// `catched` и сразу возвращает результат. Добавление в qBittorrent и вывод
|
||||||
|
// имени выполняет worker (см. download-tracking).
|
||||||
func (s *Service) Ingest(ctx context.Context, req Request) (Result, error) {
|
func (s *Service) Ingest(ctx context.Context, req Request) (Result, error) {
|
||||||
source := strings.TrimSpace(req.Source)
|
source := strings.TrimSpace(req.Source)
|
||||||
info, err := magnet.Parse(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.
|
// Scoped-логгер стадии приёма: download_id допишется после CreateDownload.
|
||||||
// Кладём в ctx, чтобы внешние клиенты (qBittorrent, LLM-namer) дописывали
|
|
||||||
// ключи корреляции к своим ext.*-записям сами.
|
|
||||||
log := s.log.With("capability", capIngest, "infohash", info.Infohash)
|
log := s.log.With("capability", capIngest, "infohash", info.Infohash)
|
||||||
ctx = logctx.With(ctx, log)
|
ctx = logctx.With(ctx, log)
|
||||||
|
|
||||||
// Быстрый дедуп-чек до дорогого LLM-namer; авторитетная (атомарная)
|
// Быстрый дедуп-чек; авторитетная (атомарная) проверка — внутри
|
||||||
// проверка — внутри CreateDownloadIfNoActive ниже. Дедуп — по ЛЮБОМУ из
|
// CreateDownloadIfNoActive ниже. Дедуп — по ЛЮБОМУ из хешей источника:
|
||||||
// хешей источника: гибридный magnet несёт и v1, и v2.
|
// гибридный magnet несёт и v1, и v2.
|
||||||
if existing, err := s.store.FindActiveByInfohash(ctx, info.Infohashes...); err != nil {
|
if existing, err := s.store.FindActiveByInfohash(ctx, info.Infohashes...); err != nil {
|
||||||
return Result{}, fmt.Errorf("ingest: lookup active: %w", err)
|
return Result{}, fmt.Errorf("ingest: lookup active: %w", err)
|
||||||
} else if existing != nil {
|
} 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
|
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-ссылки
|
// Контекст распознавания дополняем фактами из полей самой magnet-ссылки
|
||||||
// (dn/xl/tr/xs/kt) — без сети. Пользовательский текст идёт первым. Результат
|
// (dn/xl/tr/xs/kt) — без сети. Пользовательский текст идёт первым.
|
||||||
// уходит только в download.Context (его читают recognition и веб-UI).
|
// DisplayName пуст: имя выведет worker на шаге добавления (rename действует
|
||||||
|
// только при добавлении, а тут медленный LLM в пути ответа недопустим).
|
||||||
d := &store.Download{
|
d := &store.Download{
|
||||||
SourceType: store.SourceMagnet,
|
SourceType: store.SourceMagnet,
|
||||||
SourceRef: source,
|
SourceRef: source,
|
||||||
DisplayName: rename, // то же имя, что уходит в qBittorrent (rename); заголовок в веб-UI
|
|
||||||
Context: mergeContext(req.Context, info.Context()),
|
Context: mergeContext(req.Context, info.Context()),
|
||||||
State: store.StateDownloading,
|
State: store.StateCatched,
|
||||||
}
|
}
|
||||||
// Все хеши из magnet (гибридный несёт v1 и v2); kind store выведет по длине.
|
// Все хеши из magnet (гибридный несёт v1 и v2); kind store выведет по длине.
|
||||||
existing, err := s.store.CreateDownloadIfNoActive(ctx, d, info.Infohashes)
|
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,
|
Deduplicated: true,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
id := d.ID
|
|
||||||
log = log.With("download_id", id)
|
|
||||||
ctx = logctx.With(ctx, log)
|
|
||||||
|
|
||||||
addErr := s.qbt.Add(ctx, qbt.AddRequest{
|
log.Info("download catched", "download_id", d.ID)
|
||||||
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)
|
|
||||||
return Result{
|
return Result{
|
||||||
DownloadID: id,
|
DownloadID: d.ID,
|
||||||
Infohashes: info.Infohashes,
|
Infohashes: info.Infohashes,
|
||||||
State: store.StateDownloading,
|
State: store.StateCatched,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+60
-201
@@ -2,15 +2,12 @@ package ingest
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
|
||||||
"io"
|
"io"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
|
||||||
|
|
||||||
"git.vakhrushev.me/av/jellybit/internal/ident"
|
"git.vakhrushev.me/av/jellybit/internal/ident"
|
||||||
"git.vakhrushev.me/av/jellybit/internal/qbt"
|
|
||||||
"git.vakhrushev.me/av/jellybit/internal/store"
|
"git.vakhrushev.me/av/jellybit/internal/store"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -23,14 +20,6 @@ type fakeStore struct {
|
|||||||
created []store.Download
|
created []store.Download
|
||||||
hashes [][]string
|
hashes [][]string
|
||||||
toppedUp []string
|
toppedUp []string
|
||||||
stateCalls []stateCall
|
|
||||||
}
|
|
||||||
|
|
||||||
type stateCall struct {
|
|
||||||
id string
|
|
||||||
state store.State
|
|
||||||
code string
|
|
||||||
msg string
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (f *fakeStore) FindActiveByInfohash(_ context.Context, _ ...string) (*store.Download, error) {
|
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
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (f *fakeStore) SetDownloadState(_ context.Context, id string, st store.State, code, msg string) error {
|
func newService(st Store) *Service {
|
||||||
f.stateCalls = append(f.stateCalls, stateCall{id, st, code, msg})
|
return New(st, slog.New(slog.NewTextHandler(io.Discard, nil)))
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type fakeQbt struct {
|
// Быстрый приём: сохраняем загрузку в catched и сразу отвечаем; qBittorrent и
|
||||||
added []qbt.AddRequest
|
// вывод имени в пути приёма не участвуют (это делает worker).
|
||||||
err error
|
func TestIngestCatchesFast(t *testing.T) {
|
||||||
}
|
|
||||||
|
|
||||||
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) {
|
|
||||||
fs := &fakeStore{}
|
fs := &fakeStore{}
|
||||||
fq := &fakeQbt{}
|
res, err := newService(fs).Ingest(context.Background(), Request{Source: sampleMagnet, Context: "Дюна 2"})
|
||||||
res, err := newService(fs, fq).Ingest(context.Background(), Request{Source: sampleMagnet, Context: "Дюна 2"})
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Ingest: %v", err)
|
t.Fatalf("Ingest: %v", err)
|
||||||
}
|
}
|
||||||
if len(res.Infohashes) != 1 || res.Infohashes[0] != sampleInfohash {
|
if len(res.Infohashes) != 1 || res.Infohashes[0] != sampleInfohash {
|
||||||
t.Errorf("infohashes = %v", res.Infohashes)
|
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)
|
t.Errorf("res = %+v", res)
|
||||||
}
|
}
|
||||||
if len(fs.created) != 1 {
|
if len(fs.created) != 1 {
|
||||||
t.Fatalf("создано задач: %d, want 1", len(fs.created))
|
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
|
// download.Context = пользовательский текст + синтез из полей magnet
|
||||||
// (dn=Dune). Текст пользователя идёт первым.
|
// (dn=Dune). Текст пользователя идёт первым.
|
||||||
if got := fs.created[0].Context; !strings.HasPrefix(got, "Дюна 2") || !strings.Contains(got, "Dune") {
|
if !strings.HasPrefix(got.Context, "Дюна 2") || !strings.Contains(got.Context, "Dune") {
|
||||||
t.Errorf("сохранённый контекст = %q", got)
|
t.Errorf("сохранённый контекст = %q", got.Context)
|
||||||
}
|
}
|
||||||
if len(fs.hashes) != 1 || len(fs.hashes[0]) != 1 || fs.hashes[0][0] != sampleInfohash {
|
if len(fs.hashes) != 1 || len(fs.hashes[0]) != 1 || fs.hashes[0][0] != sampleInfohash {
|
||||||
t.Errorf("хеши задачи: %v", fs.hashes)
|
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(fs.created) != 1 {
|
||||||
if len(add.URLs) != 1 || add.URLs[0] != sampleMagnet {
|
t.Fatalf("создано задач: %d, want 1", len(fs.created))
|
||||||
t.Errorf("URLs = %v", add.URLs)
|
|
||||||
}
|
}
|
||||||
if add.Category != "jellybit" || add.SavePath != "/srv/media/downloads" {
|
ctx := fs.created[0].Context
|
||||||
t.Errorf("category/savepath = %q/%q", add.Category, add.SavePath)
|
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{}
|
fs := &fakeStore{}
|
||||||
fq := &fakeQbt{}
|
if _, err := newService(fs).Ingest(context.Background(), Request{Source: raw}); err != nil {
|
||||||
nm := &fakeNamer{name: "Дюна: Часть вторая (2024)"}
|
|
||||||
_, err := newServiceWithNamer(fs, fq, nm).Ingest(context.Background(),
|
|
||||||
Request{Source: sampleMagnet, Context: "Дюна 2"})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Ingest: %v", err)
|
t.Fatalf("Ingest: %v", err)
|
||||||
}
|
}
|
||||||
if !nm.called || nm.gotContext != "Дюна 2" || nm.gotHint != "Dune" {
|
got := fs.created[0].Context
|
||||||
t.Errorf("namer получил context=%q hint=%q (called=%v)", nm.gotContext, nm.gotHint, nm.called)
|
if !strings.Contains(got, "t-ru.org") {
|
||||||
|
t.Errorf("download.Context не обогащён доменом трекера: %q", got)
|
||||||
}
|
}
|
||||||
if len(fq.added) != 1 || fq.added[0].Rename != "Дюна: Часть вторая (2024)" {
|
if strings.Contains(got, "rutracker-topic") {
|
||||||
t.Errorf("rename = %q, want %q", fq.added[0].Rename, "Дюна: Часть вторая (2024)")
|
t.Errorf("заглушка-dn просочилась в контекст как имя: %q", got)
|
||||||
}
|
|
||||||
// То же имя сохраняется у загрузки — заголовок в веб-UI.
|
|
||||||
if len(fs.created) != 1 || fs.created[0].DisplayName != "Дюна: Часть вторая (2024)" {
|
|
||||||
t.Errorf("display_name = %q, want %q", fs.created[0].DisplayName, "Дюна: Часть вторая (2024)")
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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{}
|
fs := &fakeStore{}
|
||||||
fq := &fakeQbt{}
|
if _, err := newService(fs).Ingest(context.Background(), Request{Source: raw}); err != nil {
|
||||||
nm := &fakeNamer{name: ""} // имя не получено
|
|
||||||
if _, err := newServiceWithNamer(fs, fq, nm).Ingest(context.Background(),
|
|
||||||
Request{Source: sampleMagnet}); err != nil {
|
|
||||||
t.Fatalf("Ingest: %v", err)
|
t.Fatalf("Ingest: %v", err)
|
||||||
}
|
}
|
||||||
if len(fq.added) != 1 || fq.added[0].Rename != "" {
|
ctx := fs.created[0].Context
|
||||||
t.Errorf("rename = %q, want пусто", fq.added[0].Rename)
|
if !strings.Contains(ctx, "Widow's Bay") || !strings.Contains(ctx, "Трекер: t-ru.org") {
|
||||||
|
t.Errorf("download.Context не обогащён: %q", ctx)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestIngestIdempotent(t *testing.T) {
|
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}
|
fs := &fakeStore{active: existing}
|
||||||
fq := &fakeQbt{}
|
res, err := newService(fs).Ingest(context.Background(), Request{Source: sampleMagnet})
|
||||||
res, err := newService(fs, fq).Ingest(context.Background(), Request{Source: sampleMagnet})
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Ingest: %v", err)
|
t.Fatalf("Ingest: %v", err)
|
||||||
}
|
}
|
||||||
@@ -179,9 +146,6 @@ func TestIngestIdempotent(t *testing.T) {
|
|||||||
if len(fs.created) != 0 {
|
if len(fs.created) != 0 {
|
||||||
t.Error("не должно создаваться новой задачи")
|
t.Error("не должно создаваться новой задачи")
|
||||||
}
|
}
|
||||||
if len(fq.added) != 0 {
|
|
||||||
t.Error("не должно быть повторного добавления в qBittorrent")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Быстрый дедуп-путь доносит существующей задаче недостающие хеши
|
// Быстрый дедуп-путь доносит существующей задаче недостающие хеши
|
||||||
@@ -190,11 +154,11 @@ func TestIngestIdempotent(t *testing.T) {
|
|||||||
func TestIngestDedupTopsUpHashes(t *testing.T) {
|
func TestIngestDedupTopsUpHashes(t *testing.T) {
|
||||||
const v2 = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
|
const v2 = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
|
||||||
existing := &store.Download{
|
existing := &store.Download{
|
||||||
ID: "01hzzzexisting000000000000", State: store.StateDownloading,
|
ID: "01hzzzexisting000000000000", State: store.StateCatched,
|
||||||
Infohashes: []store.Infohash{{DownloadID: "01hzzzexisting000000000000", Infohash: sampleInfohash, Kind: store.HashV1}},
|
Infohashes: []store.Infohash{{DownloadID: "01hzzzexisting000000000000", Infohash: sampleInfohash, Kind: store.HashV1}},
|
||||||
}
|
}
|
||||||
fs := &fakeStore{active: existing}
|
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})
|
Request{Source: sampleMagnet + "&xt=urn:btmh:1220" + v2})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Ingest: %v", err)
|
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) {
|
func TestIngestRejectsNonMagnet(t *testing.T) {
|
||||||
fs := &fakeStore{}
|
fs := &fakeStore{}
|
||||||
fq := &fakeQbt{}
|
if _, err := newService(fs).Ingest(context.Background(), Request{Source: "https://example.com/x.torrent"}); err == nil {
|
||||||
if _, err := newService(fs, fq).Ingest(context.Background(), Request{Source: "https://example.com/x.torrent"}); err == nil {
|
|
||||||
t.Fatal("ожидалась ошибка для не-magnet источника")
|
t.Fatal("ожидалась ошибка для не-magnet источника")
|
||||||
}
|
}
|
||||||
if len(fs.created) != 0 || len(fq.added) != 0 {
|
if len(fs.created) != 0 {
|
||||||
t.Error("не должно быть ни записи, ни добавления")
|
t.Error("не должно быть записи задачи")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import (
|
|||||||
type State string
|
type State string
|
||||||
|
|
||||||
const (
|
const (
|
||||||
|
StateCatched State = "catched" // поймано и сохранено; worker добавит в qBittorrent
|
||||||
StateDownloading State = "downloading"
|
StateDownloading State = "downloading"
|
||||||
StateCompleted State = "completed"
|
StateCompleted State = "completed"
|
||||||
StateRecognizing State = "recognizing" // Ф2
|
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)
|
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 — вызов из гарда
|
// setState выполняет UPDATE состояния. reviveOK=true — вызов из гарда
|
||||||
// (ActivateIfNoOtherActive), которому переход терминал→активное разрешён;
|
// (ActivateIfNoOtherActive), которому переход терминал→активное разрешён;
|
||||||
// иначе предикат в UPDATE не даёт молча оживить терминальную задачу.
|
// иначе предикат в 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; дедуп на
|
// mustCreate заводит загрузку с хешем и возвращает её id; дедуп на
|
||||||
// существующую активную — ошибка теста.
|
// существующую активную — ошибка теста.
|
||||||
func mustCreate(t *testing.T, st *Store, infohash string) string {
|
func mustCreate(t *testing.T, st *Store, infohash string) string {
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ func statesInGroup(g StateGroup) []State {
|
|||||||
case GroupReview:
|
case GroupReview:
|
||||||
return []State{StateReview, StateDeferred}
|
return []State{StateReview, StateDeferred}
|
||||||
case GroupActive:
|
case GroupActive:
|
||||||
return []State{StateDownloading, StateCompleted, StateRecognizing, StateLinking}
|
return []State{StateCatched, StateDownloading, StateCompleted, StateRecognizing, StateLinking}
|
||||||
case GroupDone:
|
case GroupDone:
|
||||||
return []State{StateDone}
|
return []State{StateDone}
|
||||||
case GroupProblem:
|
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)
|
b.send(m.Chat.ID, opErr("Не удалось принять загрузку", res.DownloadID), nil)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
msg := fmt.Sprintf("Принято #%s — %s.", res.DownloadID, res.State)
|
msg := fmt.Sprintf("Принято #%s — добавляю в qBittorrent.", res.DownloadID)
|
||||||
if res.Deduplicated {
|
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)
|
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"
|
"context"
|
||||||
"database/sql"
|
"database/sql"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"os"
|
"os"
|
||||||
@@ -346,6 +347,16 @@ func (m *memStore) SetDownloadState(_ context.Context, id string, st store.State
|
|||||||
return nil
|
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 {
|
func (m *memStore) SetSourceMissCount(_ context.Context, id string, n int) error {
|
||||||
if d, ok := m.downloads[id]; ok {
|
if d, ok := m.downloads[id]; ok {
|
||||||
d.SourceMissCount = n
|
d.SourceMissCount = n
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ import (
|
|||||||
"git.vakhrushev.me/av/jellybit/internal/ident"
|
"git.vakhrushev.me/av/jellybit/internal/ident"
|
||||||
"git.vakhrushev.me/av/jellybit/internal/layout"
|
"git.vakhrushev.me/av/jellybit/internal/layout"
|
||||||
"git.vakhrushev.me/av/jellybit/internal/logctx"
|
"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/qbt"
|
||||||
"git.vakhrushev.me/av/jellybit/internal/recognize"
|
"git.vakhrushev.me/av/jellybit/internal/recognize"
|
||||||
"git.vakhrushev.me/av/jellybit/internal/store"
|
"git.vakhrushev.me/av/jellybit/internal/store"
|
||||||
@@ -44,6 +45,9 @@ type Store interface {
|
|||||||
ListRecoverable(ctx context.Context, codes ...string) ([]store.Download, error)
|
ListRecoverable(ctx context.Context, codes ...string) ([]store.Download, error)
|
||||||
GetDownload(ctx context.Context, id 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
|
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
|
SetSourceMissCount(ctx context.Context, id string, n int) error
|
||||||
SetSourceAddedAt(ctx context.Context, id string, t time.Time) 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)
|
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).
|
// Layouter — раскладчик хардлинками (layout.Layouter).
|
||||||
type Layouter interface {
|
type Layouter interface {
|
||||||
BuildLinks(p layout.Plan) ([]layout.Link, error)
|
BuildLinks(p layout.Plan) ([]layout.Link, error)
|
||||||
@@ -111,6 +121,10 @@ const (
|
|||||||
errCodeMagnetTimeout = "magnet_timeout"
|
errCodeMagnetTimeout = "magnet_timeout"
|
||||||
errCodeStalled = "stalled"
|
errCodeStalled = "stalled"
|
||||||
errCodeQbitError = "qbit_error"
|
errCodeQbitError = "qbit_error"
|
||||||
|
// errCodeQbitAdd — не удалось добавить пойманную загрузку в qBittorrent за
|
||||||
|
// catch_timeout (устойчивая недоступность qBit). Раздачи в qBittorrent нет,
|
||||||
|
// восстановлению сверкой не подлежит.
|
||||||
|
errCodeQbitAdd = "qbit_add"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Notifier — исходящие пинги (Telegram). Вызывается неблокирующе.
|
// Notifier — исходящие пинги (Telegram). Вызывается неблокирующе.
|
||||||
@@ -134,6 +148,7 @@ type Config struct {
|
|||||||
PollInterval time.Duration
|
PollInterval time.Duration
|
||||||
StuckAfter time.Duration // stalledDL дольше → stuck
|
StuckAfter time.Duration // stalledDL дольше → stuck
|
||||||
MagnetTimeout time.Duration // metaDL дольше → failed
|
MagnetTimeout time.Duration // metaDL дольше → failed
|
||||||
|
CatchTimeout time.Duration // catched дольше (не удалось добавить в qBit) → failed
|
||||||
// SourceMissingThreshold — порог дебаунса пропажи источника (тиков сверки).
|
// SourceMissingThreshold — порог дебаунса пропажи источника (тиков сверки).
|
||||||
// <1 трактуется как 1 (помечаем при первой же устойчивой пропаже).
|
// <1 трактуется как 1 (помечаем при первой же устойчивой пропаже).
|
||||||
SourceMissingThreshold int
|
SourceMissingThreshold int
|
||||||
@@ -181,6 +196,7 @@ type Worker struct {
|
|||||||
qbt QBittorrent
|
qbt QBittorrent
|
||||||
recognizer Recognizer
|
recognizer Recognizer
|
||||||
layouter Layouter
|
layouter Layouter
|
||||||
|
namer Namer // опц. вывод отображаемого имени на шаге добавления catched
|
||||||
cfg Config
|
cfg Config
|
||||||
log *slog.Logger
|
log *slog.Logger
|
||||||
|
|
||||||
@@ -209,6 +225,10 @@ type Worker struct {
|
|||||||
// одной задачи (см. failNotified).
|
// одной задачи (см. failNotified).
|
||||||
const failNotifyDebounce = time.Hour
|
const failNotifyDebounce = time.Hour
|
||||||
|
|
||||||
|
// SetNamer подключает вывод отображаемого имени для шага добавления catched
|
||||||
|
// (до запуска Run). nil → имя не выводим, добавляем без rename.
|
||||||
|
func (w *Worker) SetNamer(n Namer) { w.namer = n }
|
||||||
|
|
||||||
// SetNotifier подключает исходящие пинги (до запуска Run).
|
// SetNotifier подключает исходящие пинги (до запуска Run).
|
||||||
func (w *Worker) SetNotifier(n Notifier) { w.notifier = n }
|
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 {
|
if err := w.Poll(ctx); err != nil {
|
||||||
w.log.Warn("poll failed", "error", err)
|
w.log.Warn("poll failed", "error", err)
|
||||||
}
|
}
|
||||||
|
// Быстрый приём отложил добавление в qBittorrent: подхватываем пойманные
|
||||||
|
// (catched) загрузки и добавляем их (сеть — вне блокировки переходов).
|
||||||
|
w.processCatched(ctx)
|
||||||
// Ф3: распознаём завершённые загрузки (и перезапускаем по подсказке).
|
// Ф3: распознаём завершённые загрузки (и перезапускаем по подсказке).
|
||||||
if w.recognizer != nil {
|
if w.recognizer != nil {
|
||||||
w.recognizePending(ctx)
|
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 и двигает их.
|
// Poll сверяет активные задачи с состоянием qBittorrent и двигает их.
|
||||||
// Листаем все торренты (а не только свою категорию), чтобы reconcile нашёл и
|
// Листаем все торренты (а не только свою категорию), чтобы reconcile нашёл и
|
||||||
// усыновлённые по тегу раздачи, а discovery — увидел новые.
|
// усыновлённые по тегу раздачи, а discovery — увидел новые.
|
||||||
|
|||||||
@@ -163,6 +163,20 @@ func (f *fakeStore) SetDownloadState(_ context.Context, id string, st store.Stat
|
|||||||
return nil
|
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 {
|
func (f *fakeStore) SetSourceMissCount(_ context.Context, id string, n int) error {
|
||||||
d, ok := f.downloads[id]
|
d, ok := f.downloads[id]
|
||||||
if !ok {
|
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** они обрабатываются
|
- **WHEN** они обрабатываются
|
||||||
- **THEN** переходы применяются последовательно под блокировкой, без гонки
|
- **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
|
## Requirements
|
||||||
### Requirement: Отображаемое имя торрента из контекста
|
### Requirement: Отображаемое имя торрента из контекста
|
||||||
|
|
||||||
При добавлении загрузки в qBittorrent система SHALL выводить из контекста
|
На шаге добавления пойманной загрузки в qBittorrent (worker) система SHALL
|
||||||
загрузки человекочитаемое отображаемое имя и передавать его в qBittorrent
|
выводить из контекста загрузки человекочитаемое отображаемое имя и передавать
|
||||||
(параметр `rename` API `/torrents/add`), чтобы задача в списке qBit не
|
его в qBittorrent (параметр `rename` API `/torrents/add`), чтобы задача в списке
|
||||||
показывалась безликим `dn` magnet-ссылки. Это же имя система SHALL **сохранять
|
qBit не показывалась безликим `dn` magnet-ссылки. Это же имя система SHALL
|
||||||
у загрузки** (`download.display_name`) для последующего показа заголовком в
|
**сохранять у загрузки** (`download.display_name`) для последующего показа
|
||||||
веб-UI.
|
заголовком в веб-UI.
|
||||||
|
|
||||||
Имя SHALL быть коротким читаемым ярлыком (название, опционально режиссёр и
|
Имя SHALL быть коротким читаемым ярлыком (название, опционально режиссёр и
|
||||||
год; для сериала — номер сезона, если он определён), а не куском сырого
|
год; для сериала — номер сезона, если он определён), а не куском сырого
|
||||||
контекста. Имя SHALL очищаться от управляющих символов и переводов строк и
|
контекста. Имя SHALL очищаться от управляющих символов и переводов строк и
|
||||||
SHALL обрезаться по ограничению длины.
|
SHALL обрезаться по ограничению длины.
|
||||||
|
|
||||||
Вывод имени SHALL выполняться синхронно перед отдачей источника в
|
Вывод имени SHALL выполняться на шаге добавления, непосредственно перед вызовом
|
||||||
qBittorrent (параметр `rename` действует только в момент добавления).
|
`add` (параметр `rename` действует только в момент добавления), а НЕ в
|
||||||
|
синхронном пути ответа приёма. В состоянии `catched` (до добавления)
|
||||||
|
`download.display_name` ещё пуст — веб-UI берёт заголовок из фолбека.
|
||||||
|
|
||||||
Отображаемое имя SHALL влиять только на отображение (в qBittorrent и как
|
Отображаемое имя SHALL влиять только на отображение (в qBittorrent и как
|
||||||
заголовок в веб-UI) и SHALL NOT влиять на пути файлов на диске, распознавание
|
заголовок в веб-UI) и SHALL NOT влиять на пути файлов на диске, распознавание
|
||||||
@@ -32,17 +34,17 @@ qBittorrent (параметр `rename` действует только в мом
|
|||||||
|
|
||||||
#### Scenario: Имя из контекста передаётся в qBittorrent
|
#### Scenario: Имя из контекста передаётся в qBittorrent
|
||||||
|
|
||||||
- **WHEN** загрузку добавляют с непустым контекстом, из которого удалось
|
- **WHEN** на шаге добавления получен непустой контекст, из которого удалось
|
||||||
получить имя
|
вывести имя
|
||||||
- **THEN** система передаёт это имя в qBittorrent в параметре `rename`
|
- **THEN** система передаёт это имя в qBittorrent в параметре `rename`
|
||||||
- **AND** имя — короткий читаемый ярлык вида «название (режиссёр, год)»,
|
- **AND** имя — короткий читаемый ярлык вида «название (режиссёр, год)»,
|
||||||
где режиссёр и год опциональны
|
где режиссёр и год опциональны
|
||||||
|
|
||||||
#### Scenario: Имя сохраняется у загрузки
|
#### Scenario: Имя сохраняется у загрузки
|
||||||
|
|
||||||
- **WHEN** при приёме получено непустое отображаемое имя
|
- **WHEN** на шаге добавления выведено непустое отображаемое имя
|
||||||
- **THEN** система сохраняет его в `download.display_name` вместе с созданием
|
- **THEN** система сохраняет его в `download.display_name` (обновлением записи
|
||||||
загрузки
|
загрузки)
|
||||||
- **AND** веб-UI использует его заголовком карточки и страницы загрузки
|
- **AND** веб-UI использует его заголовком карточки и страницы загрузки
|
||||||
|
|
||||||
#### Scenario: Контекст пуст или имя не получено
|
#### Scenario: Контекст пуст или имя не получено
|
||||||
@@ -111,29 +113,37 @@ JSON-вывод), извлекая из контекста тип (movie/series)
|
|||||||
|
|
||||||
### Requirement: Приём источника и заведение загрузки
|
### Requirement: Приём источника и заведение загрузки
|
||||||
|
|
||||||
Приём SHALL быть единым use-case, общим для всех транспортов (HTTP, Telegram,
|
Приём SHALL быть единым **быстрым** use-case, общим для всех транспортов (HTTP,
|
||||||
CLI): по источнику (Ф1 — magnet) и текстовому контексту система SHALL извлечь
|
Telegram, CLI): по источнику (Ф1 — magnet) и текстовому контексту система SHALL
|
||||||
инфохэши, дедуплицировать по активной задаче, при отсутствии дубля завести
|
синхронно извлечь инфохэши, синтезировать контекст из полей ссылки (без сети),
|
||||||
загрузку (`download` в состоянии `downloading` + записи `download_infohash`) и
|
дедуплицировать по активной задаче и при отсутствии дубля завести загрузку
|
||||||
отдать источник в qBittorrent (категория `qbittorrent.category`, savepath). Если
|
(`download` в состоянии **`catched`** + записи `download_infohash`), после чего
|
||||||
добавление в qBittorrent не удалось, система SHALL перевести уже заведённую
|
**сразу вернуть ответ** транспорту. Заведение загрузки и запись её хешей SHALL
|
||||||
загрузку в `failed` (`error_code` `qbit_add`) и уведомить автора. Заведение
|
выполняться атомарно (см. «Атомарность возврата загрузки в активное
|
||||||
загрузки и запись её хешей SHALL выполняться атомарно (см. «Атомарность возврата
|
состояние»).
|
||||||
загрузки в активное состояние»).
|
|
||||||
|
|
||||||
#### Scenario: Успешный приём magnet
|
Синхронный путь приёма SHALL NOT обращаться к qBittorrent и SHALL NOT выводить
|
||||||
|
отображаемое имя (потенциально медленный LLM): и добавление источника в
|
||||||
|
qBittorrent, и вывод имени выполняются отдельным асинхронным шагом машины
|
||||||
|
состояний (worker) — см. `download-tracking` «Добавление пойманной загрузки в
|
||||||
|
qBittorrent».
|
||||||
|
|
||||||
|
`catched` — нетерминальное активное состояние: оно участвует в инварианте «не
|
||||||
|
более одной активной загрузки на infohash» наравне с прочими активными.
|
||||||
|
|
||||||
|
#### Scenario: Быстрый приём magnet
|
||||||
|
|
||||||
- **GIVEN** валидная magnet-ссылка и контекст
|
- **GIVEN** валидная magnet-ссылка и контекст
|
||||||
- **WHEN** вызывается приём
|
- **WHEN** вызывается приём
|
||||||
- **THEN** создаётся `download` в `downloading` с записями `download_infohash`
|
- **THEN** создаётся `download` в состоянии `catched` с записями
|
||||||
- **AND** источник отдан в qBittorrent с нашей категорией
|
`download_infohash`
|
||||||
|
- **AND** ответ транспорту отдан без обращения к qBittorrent и без вывода имени
|
||||||
|
|
||||||
#### Scenario: Падение добавления в qBittorrent
|
#### Scenario: Дубль по активной задаче на быстром пути
|
||||||
|
|
||||||
- **GIVEN** заведённую загрузку не удалось добавить в qBittorrent
|
- **GIVEN** уже есть активная (в т.ч. `catched`) загрузка с тем же infohash
|
||||||
- **WHEN** обрабатывается ошибка добавления
|
- **WHEN** вызывается приём
|
||||||
- **THEN** загрузка переходит в `failed` с `error_code` `qbit_add`
|
- **THEN** новая загрузка не создаётся, возвращается существующая
|
||||||
- **AND** автор загрузки уведомляется
|
|
||||||
|
|
||||||
### Requirement: Множество инфохэшей загрузки
|
### Requirement: Множество инфохэшей загрузки
|
||||||
|
|
||||||
|
|||||||
@@ -432,3 +432,44 @@ PRG-редиректом, и действие исполняется тем же
|
|||||||
- **THEN** карточка подменяется на месте новым состоянием и остаётся видимой до
|
- **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"}}
|
{{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 class="card-top">
|
||||||
<div>
|
<div>
|
||||||
<div class="card-title"><a href="/download/{{.ID}}" style="color:inherit">{{.Title}}</a></div>
|
<div class="card-title"><a href="/download/{{.ID}}" style="color:inherit">{{.Title}}</a></div>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
{{define "download_main"}}
|
{{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>
|
<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}}
|
{{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