Реализация, фаза 1: добавление данных в qbittorrent
This commit is contained in:
@@ -1,4 +0,0 @@
|
||||
// Package ingest — use-case приёма загрузки, общий для всех транспортов.
|
||||
//
|
||||
// Заглушка: реализация в фазе Ф1 (см. docs/specs/architecture.md).
|
||||
package ingest
|
||||
@@ -0,0 +1,120 @@
|
||||
// Package ingest — use-case приёма загрузки, общий для всех транспортов
|
||||
// (HTTP, Telegram, CLI). Принимает источник + контекст, отдаёт источник в
|
||||
// qBittorrent и заводит/находит задачу в БД.
|
||||
package ingest
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"strings"
|
||||
|
||||
"git.vakhrushev.me/av/jellybit/internal/magnet"
|
||||
"git.vakhrushev.me/av/jellybit/internal/qbt"
|
||||
"git.vakhrushev.me/av/jellybit/internal/store"
|
||||
)
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
// Config — параметры добавления в qBittorrent.
|
||||
type Config struct {
|
||||
Category string
|
||||
SavePath string
|
||||
}
|
||||
|
||||
// Service — реализация приёма.
|
||||
type Service struct {
|
||||
store Store
|
||||
qbt QBittorrent
|
||||
cfg Config
|
||||
log *slog.Logger
|
||||
}
|
||||
|
||||
// New собирает сервис приёма.
|
||||
func New(st Store, qb QBittorrent, cfg Config, log *slog.Logger) *Service {
|
||||
return &Service{store: st, qbt: qb, 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)
|
||||
}
|
||||
|
||||
if existing, err := s.store.FindActiveByInfohash(ctx, info.Infohash); err != nil {
|
||||
return Result{}, fmt.Errorf("ingest: lookup active: %w", err)
|
||||
} else if existing != nil {
|
||||
s.log.Info("ingest: attached to active download",
|
||||
"download_id", existing.ID, "infohash", info.Infohash, "state", existing.State)
|
||||
return Result{
|
||||
DownloadID: existing.ID,
|
||||
Infohash: info.Infohash,
|
||||
State: existing.State,
|
||||
Deduplicated: true,
|
||||
}, nil
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
addErr := s.qbt.Add(ctx, qbt.AddRequest{
|
||||
URLs: []string{source},
|
||||
Category: s.cfg.Category,
|
||||
SavePath: s.cfg.SavePath,
|
||||
})
|
||||
if addErr != nil {
|
||||
// Задача уже в БД — помечаем failed, чтобы worker её не подхватил.
|
||||
if setErr := s.store.SetDownloadState(ctx, id, store.StateFailed, "qbit_add", addErr.Error()); setErr != nil {
|
||||
s.log.Error("ingest: failed to mark download failed after qbit error",
|
||||
"download_id", id, "err", setErr)
|
||||
}
|
||||
return Result{DownloadID: id, Infohash: info.Infohash, State: store.StateFailed},
|
||||
fmt.Errorf("ingest: add to qbittorrent: %w", addErr)
|
||||
}
|
||||
|
||||
s.log.Info("ingest: download accepted",
|
||||
"download_id", id, "infohash", info.Infohash, "category", s.cfg.Category)
|
||||
return Result{
|
||||
DownloadID: id,
|
||||
Infohash: info.Infohash,
|
||||
State: store.StateDownloading,
|
||||
}, nil
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
package ingest
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"log/slog"
|
||||
"testing"
|
||||
|
||||
"git.vakhrushev.me/av/jellybit/internal/qbt"
|
||||
"git.vakhrushev.me/av/jellybit/internal/store"
|
||||
)
|
||||
|
||||
const sampleMagnet = "magnet:?xt=urn:btih:541ADCFF3B6DD5DBA7088EA83317D9D6FAC331D6&dn=Dune"
|
||||
|
||||
const sampleInfohash = "541adcff3b6dd5dba7088ea83317d9d6fac331d6"
|
||||
|
||||
type fakeStore struct {
|
||||
active *store.Download
|
||||
created []store.Download
|
||||
nextID int64
|
||||
stateCalls []stateCall
|
||||
}
|
||||
|
||||
type stateCall struct {
|
||||
id int64
|
||||
state store.State
|
||||
code string
|
||||
msg string
|
||||
}
|
||||
|
||||
func (f *fakeStore) FindActiveByInfohash(_ context.Context, _ string) (*store.Download, error) {
|
||||
return f.active, nil
|
||||
}
|
||||
|
||||
func (f *fakeStore) CreateDownload(_ context.Context, d *store.Download) (int64, error) {
|
||||
f.nextID++
|
||||
d.ID = f.nextID
|
||||
f.created = append(f.created, *d)
|
||||
return f.nextID, nil
|
||||
}
|
||||
|
||||
func (f *fakeStore) SetDownloadState(_ context.Context, id int64, st store.State, code, msg string) error {
|
||||
f.stateCalls = append(f.stateCalls, stateCall{id, st, code, msg})
|
||||
return nil
|
||||
}
|
||||
|
||||
type fakeQbt struct {
|
||||
added []qbt.AddRequest
|
||||
err error
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
func newService(st Store, qb QBittorrent) *Service {
|
||||
return New(st, qb, Config{Category: "jellybit", SavePath: "/srv/media/downloads"},
|
||||
slog.New(slog.NewTextHandler(io.Discard, nil)))
|
||||
}
|
||||
|
||||
func TestIngestHappyPath(t *testing.T) {
|
||||
fs := &fakeStore{}
|
||||
fq := &fakeQbt{}
|
||||
res, err := newService(fs, fq).Ingest(context.Background(), Request{Source: sampleMagnet, Context: "Дюна 2"})
|
||||
if err != nil {
|
||||
t.Fatalf("Ingest: %v", err)
|
||||
}
|
||||
if res.Infohash != sampleInfohash {
|
||||
t.Errorf("infohash = %q", res.Infohash)
|
||||
}
|
||||
if res.State != store.StateDownloading || res.Deduplicated {
|
||||
t.Errorf("res = %+v", res)
|
||||
}
|
||||
if len(fs.created) != 1 {
|
||||
t.Fatalf("создано задач: %d, want 1", len(fs.created))
|
||||
}
|
||||
if got := fs.created[0]; got.Context != "Дюна 2" || got.Infohash.String != sampleInfohash {
|
||||
t.Errorf("сохранённая задача: %+v", got)
|
||||
}
|
||||
if len(fq.added) != 1 {
|
||||
t.Fatalf("вызовов qbt.Add: %d, want 1", len(fq.added))
|
||||
}
|
||||
add := fq.added[0]
|
||||
if len(add.URLs) != 1 || add.URLs[0] != sampleMagnet {
|
||||
t.Errorf("URLs = %v", add.URLs)
|
||||
}
|
||||
if add.Category != "jellybit" || add.SavePath != "/srv/media/downloads" {
|
||||
t.Errorf("category/savepath = %q/%q", add.Category, add.SavePath)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIngestIdempotent(t *testing.T) {
|
||||
existing := &store.Download{ID: 7, State: store.StateDownloading}
|
||||
fs := &fakeStore{active: existing}
|
||||
fq := &fakeQbt{}
|
||||
res, err := newService(fs, fq).Ingest(context.Background(), Request{Source: sampleMagnet})
|
||||
if err != nil {
|
||||
t.Fatalf("Ingest: %v", err)
|
||||
}
|
||||
if !res.Deduplicated || res.DownloadID != 7 {
|
||||
t.Errorf("ожидалось присоединение к задаче 7: %+v", res)
|
||||
}
|
||||
if len(fs.created) != 0 {
|
||||
t.Error("не должно создаваться новой задачи")
|
||||
}
|
||||
if len(fq.added) != 0 {
|
||||
t.Error("не должно быть повторного добавления в qBittorrent")
|
||||
}
|
||||
}
|
||||
|
||||
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 TestIngestRejectsNonMagnet(t *testing.T) {
|
||||
fs := &fakeStore{}
|
||||
fq := &fakeQbt{}
|
||||
if _, err := newService(fs, fq).Ingest(context.Background(), Request{Source: "https://example.com/x.torrent"}); err == nil {
|
||||
t.Fatal("ожидалась ошибка для не-magnet источника")
|
||||
}
|
||||
if len(fs.created) != 0 || len(fq.added) != 0 {
|
||||
t.Error("не должно быть ни записи, ни добавления")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user