Быстрый приём: сохранение в 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:
@@ -24,6 +24,7 @@ import (
|
||||
"git.vakhrushev.me/av/jellybit/internal/ident"
|
||||
"git.vakhrushev.me/av/jellybit/internal/layout"
|
||||
"git.vakhrushev.me/av/jellybit/internal/logctx"
|
||||
"git.vakhrushev.me/av/jellybit/internal/magnet"
|
||||
"git.vakhrushev.me/av/jellybit/internal/qbt"
|
||||
"git.vakhrushev.me/av/jellybit/internal/recognize"
|
||||
"git.vakhrushev.me/av/jellybit/internal/store"
|
||||
@@ -44,6 +45,9 @@ type Store interface {
|
||||
ListRecoverable(ctx context.Context, codes ...string) ([]store.Download, error)
|
||||
GetDownload(ctx context.Context, id string) (*store.Download, error)
|
||||
SetDownloadState(ctx context.Context, id string, state store.State, errCode, errMsg string) error
|
||||
// PromoteCatched атомарно переводит catched → downloading с записью имени
|
||||
// (гард state='catched' — ре-валидация после сетевых вызовов вне блокировки).
|
||||
PromoteCatched(ctx context.Context, id, displayName string) error
|
||||
SetSourceMissCount(ctx context.Context, id string, n int) error
|
||||
SetSourceAddedAt(ctx context.Context, id string, t time.Time) error
|
||||
|
||||
@@ -85,6 +89,12 @@ type Recognizer interface {
|
||||
Recognize(ctx context.Context, in recognize.Input) (recognize.Result, error)
|
||||
}
|
||||
|
||||
// Namer выводит человекочитаемое отображаемое имя из контекста (naming.Namer).
|
||||
// Пустой результат → rename в qBittorrent не задаём. nil → имя не выводим.
|
||||
type Namer interface {
|
||||
DeriveName(ctx context.Context, contextText, hint string) string
|
||||
}
|
||||
|
||||
// Layouter — раскладчик хардлинками (layout.Layouter).
|
||||
type Layouter interface {
|
||||
BuildLinks(p layout.Plan) ([]layout.Link, error)
|
||||
@@ -111,6 +121,10 @@ const (
|
||||
errCodeMagnetTimeout = "magnet_timeout"
|
||||
errCodeStalled = "stalled"
|
||||
errCodeQbitError = "qbit_error"
|
||||
// errCodeQbitAdd — не удалось добавить пойманную загрузку в qBittorrent за
|
||||
// catch_timeout (устойчивая недоступность qBit). Раздачи в qBittorrent нет,
|
||||
// восстановлению сверкой не подлежит.
|
||||
errCodeQbitAdd = "qbit_add"
|
||||
)
|
||||
|
||||
// Notifier — исходящие пинги (Telegram). Вызывается неблокирующе.
|
||||
@@ -134,6 +148,7 @@ type Config struct {
|
||||
PollInterval time.Duration
|
||||
StuckAfter time.Duration // stalledDL дольше → stuck
|
||||
MagnetTimeout time.Duration // metaDL дольше → failed
|
||||
CatchTimeout time.Duration // catched дольше (не удалось добавить в qBit) → failed
|
||||
// SourceMissingThreshold — порог дебаунса пропажи источника (тиков сверки).
|
||||
// <1 трактуется как 1 (помечаем при первой же устойчивой пропаже).
|
||||
SourceMissingThreshold int
|
||||
@@ -181,6 +196,7 @@ type Worker struct {
|
||||
qbt QBittorrent
|
||||
recognizer Recognizer
|
||||
layouter Layouter
|
||||
namer Namer // опц. вывод отображаемого имени на шаге добавления catched
|
||||
cfg Config
|
||||
log *slog.Logger
|
||||
|
||||
@@ -209,6 +225,10 @@ type Worker struct {
|
||||
// одной задачи (см. failNotified).
|
||||
const failNotifyDebounce = time.Hour
|
||||
|
||||
// SetNamer подключает вывод отображаемого имени для шага добавления catched
|
||||
// (до запуска Run). nil → имя не выводим, добавляем без rename.
|
||||
func (w *Worker) SetNamer(n Namer) { w.namer = n }
|
||||
|
||||
// SetNotifier подключает исходящие пинги (до запуска Run).
|
||||
func (w *Worker) SetNotifier(n Notifier) { w.notifier = n }
|
||||
|
||||
@@ -291,12 +311,95 @@ func (w *Worker) pollOnce(ctx context.Context) {
|
||||
if err := w.Poll(ctx); err != nil {
|
||||
w.log.Warn("poll failed", "error", err)
|
||||
}
|
||||
// Быстрый приём отложил добавление в qBittorrent: подхватываем пойманные
|
||||
// (catched) загрузки и добавляем их (сеть — вне блокировки переходов).
|
||||
w.processCatched(ctx)
|
||||
// Ф3: распознаём завершённые загрузки (и перезапускаем по подсказке).
|
||||
if w.recognizer != nil {
|
||||
w.recognizePending(ctx)
|
||||
}
|
||||
}
|
||||
|
||||
// processCatched — асинхронный шаг добавления пойманных загрузок в qBittorrent.
|
||||
// Для каждой catched: (предохранитель) если висит дольше catch_timeout — уводим
|
||||
// в failed; иначе выводим имя и добавляем в qBit. Медленные вызовы (LLM-namer,
|
||||
// qbt.Add) идут ВНЕ w.mu, чтобы не задерживать команды транспортов и поллинг;
|
||||
// под w.mu берутся только короткие DB-переходы (с ре-валидацией state=catched).
|
||||
func (w *Worker) processCatched(ctx context.Context) {
|
||||
w.mu.Lock()
|
||||
catched, err := w.store.ListDownloadsByState(ctx, store.StateCatched)
|
||||
w.mu.Unlock()
|
||||
if err != nil {
|
||||
w.log.Warn("list catched failed", "capability", capIngest, "error", err)
|
||||
return
|
||||
}
|
||||
for _, d := range catched {
|
||||
cctx := w.scoped(ctx, capIngest, d.ID, d.PrimaryInfohash())
|
||||
|
||||
// Предохранитель: устойчивая невозможность добавить в qBittorrent.
|
||||
if w.cfg.CatchTimeout > 0 {
|
||||
if age, ok := w.catchedAge(d); ok && age > w.cfg.CatchTimeout {
|
||||
w.mu.Lock()
|
||||
// Ре-валидация под замком: список catched снят раньше, задачу
|
||||
// могли отменить (catched → cancelled) в это окно — тогда failed
|
||||
// не навязываем (иначе затёрли бы cancelled и слали лишний пинг).
|
||||
if cur, err := w.store.GetDownload(cctx, d.ID); err == nil && cur.State == store.StateCatched {
|
||||
w.transition(cctx, d, store.StateFailed, errCodeQbitAdd,
|
||||
fmt.Sprintf("not added to qBittorrent after %s", age.Truncate(time.Second)))
|
||||
}
|
||||
w.mu.Unlock()
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
// Вне w.mu: вывод имени (потенциально медленный LLM) и добавление.
|
||||
var rename string
|
||||
if w.namer != nil {
|
||||
hint := ""
|
||||
if info, perr := magnet.Parse(d.SourceRef); perr == nil {
|
||||
hint = info.DisplayName
|
||||
}
|
||||
rename = w.namer.DeriveName(cctx, d.Context, hint)
|
||||
}
|
||||
addErr := w.qbt.Add(cctx, qbt.AddRequest{
|
||||
URLs: []string{d.SourceRef},
|
||||
Category: w.cfg.Category,
|
||||
SavePath: w.cfg.SavePath,
|
||||
Rename: rename,
|
||||
})
|
||||
if addErr != nil {
|
||||
// Транзиентный сбой (qBit недоступен) — остаёмся в catched, повтор на
|
||||
// следующем тике. Поведение вызова qBit уже залогировал клиент (ext.*).
|
||||
logctx.From(cctx).Warn("catched add to qbittorrent failed, will retry", "error", addErr)
|
||||
continue
|
||||
}
|
||||
|
||||
// Успех: короткий переход под w.mu с ре-валидацией state=catched
|
||||
// (загрузку могли отменить, пока шли сетевые вызовы).
|
||||
w.mu.Lock()
|
||||
if err := w.store.PromoteCatched(cctx, d.ID, rename); err != nil {
|
||||
logctx.From(cctx).Info("catched promote skipped", "reason", err.Error())
|
||||
} else {
|
||||
logctx.From(cctx).Info("state transition", "from", store.StateCatched,
|
||||
"to", store.StateDownloading)
|
||||
}
|
||||
w.mu.Unlock()
|
||||
}
|
||||
}
|
||||
|
||||
// catchedAge — возраст пойманной загрузки от created_at (у catched раздачи в
|
||||
// qBittorrent ещё нет, added_on недоступен). ok=false — created_at не разобрать.
|
||||
func (w *Worker) catchedAge(d store.Download) (time.Duration, bool) {
|
||||
created, err := d.CreatedTime()
|
||||
if err != nil {
|
||||
w.log.Warn("cannot determine catched age",
|
||||
"capability", capIngest, "download_id", d.ID,
|
||||
"created_at", d.CreatedAt, "error", err)
|
||||
return 0, false
|
||||
}
|
||||
return w.now().Sub(created), true
|
||||
}
|
||||
|
||||
// Poll сверяет активные задачи с состоянием qBittorrent и двигает их.
|
||||
// Листаем все торренты (а не только свою категорию), чтобы reconcile нашёл и
|
||||
// усыновлённые по тегу раздачи, а discovery — увидел новые.
|
||||
|
||||
Reference in New Issue
Block a user