- Список: серверные фильтр по группе состояний, поиск и пагинация (GET
f/q/page/all, по 25), сортировка по времени добавления в qBittorrent
(added_on) с фолбеком на created_at и tie-break по id.
- Заголовок загрузки = имя раздачи (display_name) → распознанное название →
усечённый источник; сырой magnet вынесен в блок «Информация о торренте».
- Матч метабазы показан ссылкой на запись (страница загрузки и ревью);
URL берётся у выбранного кандидата либо строится по provider+id и типу.
- Полировка вёрстки; клиентская JS-фильтрация убрана (всё серверное, без JS).
- Миграция 0005 (display_name, source_added_at); воркер однократно
фиксирует source_added_at при поллинге/усыновлении; ER-схема обновлена.
- OpenSpec: дельты влиты в specs/{web-ui,ingest}, change заархивирован.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
170 lines
7.4 KiB
Go
170 lines
7.4 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,
|
||
DisplayName: rename, // то же имя, что уходит в qBittorrent (rename); заголовок в веб-UI
|
||
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
|
||
}
|