Files
jellybit/internal/ingest/ingest.go
T

152 lines
6.0 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"
// 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
}
// 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}
}
// 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: %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, "qbit_add", addErr.Error()); setErr != nil {
log.Error("mark download failed after qbit error failed", "error", setErr)
}
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
}