Долгий metaDL больше не убивается агрессивным таймаутом: дефолт magnet_timeout 30m → 24h (страховочный предохранитель), базис отсчёта — added_on из qBittorrent, а не created_at (переживает retry/усыновление). Авто-восстановление: фоновая сверка возвращает в поток задачи, упавшие по нашей нетерпеливости (magnet_timeout/stalled), когда источник ожил и продвинулся за условие падения (downloading/completed по статусу торрента); qbit_error не воскрешается. Конфликт idempotency (infohash занят другой активной задачей) — оставляем в failed. Уведомления: любой переход в failed/stuck пингует автора (включая приёмный qbit_add через ingest), с дебаунсом против спама при флаппинге stalled. Ручной retry добавлен в веб-UI и Telegram; Retry перецепляется к живому торренту вместо слепого Add. Дельта state-reconciliation влита в живые спеки; обновлён workflow.md. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
169 lines
7.3 KiB
Go
169 lines
7.3 KiB
Go
// Package ingest — use-case приёма загрузки, общий для всех транспортов
|
||
// (HTTP, Telegram, CLI). Принимает источник + контекст, отдаёт источник в
|
||
// qBittorrent и заводит/находит задачу в БД.
|
||
package ingest
|
||
|
||
import (
|
||
"context"
|
||
"fmt"
|
||
"log/slog"
|
||
"strings"
|
||
|
||
"git.vakhrushev.me/av/jellybit/internal/logctx"
|
||
"git.vakhrushev.me/av/jellybit/internal/magnet"
|
||
"git.vakhrushev.me/av/jellybit/internal/qbt"
|
||
"git.vakhrushev.me/av/jellybit/internal/store"
|
||
)
|
||
|
||
// capIngest — стадия приёма для поля capability в логах.
|
||
const capIngest = "ingest"
|
||
|
||
// errCodeQbitAdd — error_code задачи, упавшей на добавлении источника в
|
||
// qBittorrent (раздачи в qBittorrent нет, восстановлению не подлежит).
|
||
const errCodeQbitAdd = "qbit_add"
|
||
|
||
// Store — нужная ingest часть хранилища.
|
||
type Store interface {
|
||
FindActiveByInfohash(ctx context.Context, infohash string) (*store.Download, error)
|
||
CreateDownload(ctx context.Context, d *store.Download) (int64, error)
|
||
SetDownloadState(ctx context.Context, id int64, state store.State, errCode, errMsg string) error
|
||
}
|
||
|
||
// QBittorrent — нужная ingest часть клиента qBittorrent.
|
||
type QBittorrent interface {
|
||
Add(ctx context.Context, ar qbt.AddRequest) error
|
||
}
|
||
|
||
// Namer выводит человекочитаемое отображаемое имя торрента из контекста.
|
||
// Пустой результат → имя в qBittorrent не задаём. nil → шаг пропускается.
|
||
type Namer interface {
|
||
DeriveName(ctx context.Context, contextText, hint string) string
|
||
}
|
||
|
||
// Config — параметры добавления в qBittorrent.
|
||
type Config struct {
|
||
Category string
|
||
SavePath string
|
||
}
|
||
|
||
// Service — реализация приёма.
|
||
type Service struct {
|
||
store Store
|
||
qbt QBittorrent
|
||
namer Namer
|
||
cfg Config
|
||
log *slog.Logger
|
||
|
||
// notifyFailed — опц. пинг автору о падении приёма (добавление в qBittorrent
|
||
// не удалось). Closure, а не worker.Notifier: приёмное падение в qBit не
|
||
// попадает в поллинг-цикл worker (раздачи нет), поэтому уведомляет ingest
|
||
// сам; closure избавляет ядро приёма от зависимости на пакет worker.
|
||
notifyFailed func(downloadID int64)
|
||
}
|
||
|
||
// New собирает сервис приёма. namer опционален (nil → отображаемое имя не
|
||
// выводится; qBittorrent оставит своё).
|
||
func New(st Store, qb QBittorrent, namer Namer, cfg Config, log *slog.Logger) *Service {
|
||
return &Service{store: st, qbt: qb, namer: namer, cfg: cfg, log: log}
|
||
}
|
||
|
||
// SetFailureNotifier подключает пинг о падении приёма (до начала работы).
|
||
func (s *Service) SetFailureNotifier(fn func(downloadID int64)) { s.notifyFailed = fn }
|
||
|
||
// Request — входной запрос приёма.
|
||
type Request struct {
|
||
Source string // пока — magnet-ссылка
|
||
Context string // подсказка для распознавания (опц.)
|
||
}
|
||
|
||
// Result — итог приёма.
|
||
type Result struct {
|
||
DownloadID int64
|
||
Infohash string
|
||
State store.State
|
||
Deduplicated bool // присоединились к уже активной задаче, нового добавления не было
|
||
}
|
||
|
||
// Ingest принимает источник: извлекает infohash, дедуплицирует по активной
|
||
// задаче, иначе заводит задачу и отдаёт источник в qBittorrent.
|
||
func (s *Service) Ingest(ctx context.Context, req Request) (Result, error) {
|
||
source := strings.TrimSpace(req.Source)
|
||
info, err := magnet.Parse(source)
|
||
if err != nil {
|
||
// Ф1: поддержан только magnet. .torrent/url — следующий заход.
|
||
return Result{}, fmt.Errorf("ingest: parse source: %w", err)
|
||
}
|
||
|
||
// Scoped-логгер стадии приёма: download_id допишется после CreateDownload.
|
||
// Кладём в ctx, чтобы внешние клиенты (qBittorrent, LLM-namer) дописывали
|
||
// ключи корреляции к своим ext.*-записям сами.
|
||
log := s.log.With("capability", capIngest, "infohash", info.Infohash)
|
||
ctx = logctx.With(ctx, log)
|
||
|
||
if existing, err := s.store.FindActiveByInfohash(ctx, info.Infohash); err != nil {
|
||
return Result{}, fmt.Errorf("ingest: lookup active: %w", err)
|
||
} else if existing != nil {
|
||
log.Info("download attached to active", "download_id", existing.ID, "state", existing.State)
|
||
return Result{
|
||
DownloadID: existing.ID,
|
||
Infohash: info.Infohash,
|
||
State: existing.State,
|
||
Deduplicated: true,
|
||
}, nil
|
||
}
|
||
|
||
// Отображаемое имя для списка qBit — best-effort: не валит приём.
|
||
// Выводится синхронно (param rename действует только при добавлении) и
|
||
// ДО CreateDownload, чтобы возможный медленный вызов LLM не расширял окно
|
||
// «строка в БД есть, в qBittorrent ещё нет». Имя от строки БД не зависит.
|
||
var rename string
|
||
if s.namer != nil {
|
||
rename = s.namer.DeriveName(ctx, req.Context, info.DisplayName)
|
||
}
|
||
|
||
d := &store.Download{
|
||
SourceType: store.SourceMagnet,
|
||
SourceRef: source,
|
||
Context: req.Context,
|
||
Infohash: store.NullString(info.Infohash),
|
||
IdempotencyKey: store.NullString(info.Infohash),
|
||
State: store.StateDownloading,
|
||
}
|
||
id, err := s.store.CreateDownload(ctx, d)
|
||
if err != nil {
|
||
return Result{}, fmt.Errorf("ingest: create download: %w", err)
|
||
}
|
||
log = log.With("download_id", id)
|
||
ctx = logctx.With(ctx, log)
|
||
|
||
addErr := s.qbt.Add(ctx, qbt.AddRequest{
|
||
URLs: []string{source},
|
||
Category: s.cfg.Category,
|
||
SavePath: s.cfg.SavePath,
|
||
Rename: rename,
|
||
})
|
||
if addErr != nil {
|
||
// Граница доменной операции приёма: логируем исход один раз (ERROR).
|
||
// Поведение самого вызова qBittorrent уже залогировал клиент (ext.*) —
|
||
// это разные факты, не дубль.
|
||
log.Error("download accept failed", "error", addErr)
|
||
// Задача уже в БД — помечаем failed, чтобы worker её не подхватил.
|
||
if setErr := s.store.SetDownloadState(ctx, id, store.StateFailed, errCodeQbitAdd, addErr.Error()); setErr != nil {
|
||
log.Error("mark download failed after qbit error failed", "error", setErr)
|
||
} else if s.notifyFailed != nil {
|
||
// Это падение минует worker.transition (раздачи в qBit нет) — уведомляем
|
||
// сами, чтобы приёмные провалы тоже доходили до автора.
|
||
go s.notifyFailed(id)
|
||
}
|
||
return Result{DownloadID: id, Infohash: info.Infohash, State: store.StateFailed},
|
||
fmt.Errorf("ingest: add to qbittorrent: %w", addErr)
|
||
}
|
||
|
||
log.Info("download accepted", "category", s.cfg.Category)
|
||
return Result{
|
||
DownloadID: id,
|
||
Infohash: info.Infohash,
|
||
State: store.StateDownloading,
|
||
}, nil
|
||
}
|