Приём: добавление загрузки по .torrent-файлу
Принимаем .torrent как загруженные байты — через файл-пикер в веб-форме и Telegram-документ, наряду с magnet. Файл несёт полные метаданные: работает там, где magnet не резолвится (закрытые трекеры, без DHT), и даёт максимум контекста для распознавания без сети. - internal/torrent: парсер поверх anacrolix/torrent/metainfo — инфохэш(и) (v1 SHA1 исходных байтов info; v2 BEP52 при наличии) + Context() из имени, дерева файлов, размера, трекеров. Извлечение файлов панико-безопасно (недоверенный вход). - Персистентность байтов: таблица-спутник download_torrent (миграция 0009); пишется в транзакции создания загрузки, только на ветке создания (не при дедупе). Байты живут весь срок строки — нужны для повторного добавления при retry. - ingest: Request.TorrentData/TorrentName, диспетч парсера; source_ref — человекочитаемый референс (имя раздачи/файла), не адрес добавления. - worker: общий sourceAddParts ветвит по source_type в ОБОИХ add-путях — processCatched и Retry (torrent добавляется файлом, не magnet-хешем). - Транспорты: multipart-форма с файл-пикером (деградация без JS) и приём Telegram-документа (скачивание с редактированием токена из ошибок — секрет не в логи; обработка до ветки pending/текста). Разработка по OpenSpec (SDD): change torrent-file-ingest, два чекпоинта ревью (дизайн до кода, код до архива) сабагентами; дельты влиты в спеки, change архивирован. Ручная проверка на живом qBittorrent (7.3) — за деплоем. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
+87
-25
@@ -15,6 +15,7 @@ import (
|
||||
"git.vakhrushev.me/av/jellybit/internal/logctx"
|
||||
"git.vakhrushev.me/av/jellybit/internal/magnet"
|
||||
"git.vakhrushev.me/av/jellybit/internal/store"
|
||||
"git.vakhrushev.me/av/jellybit/internal/torrent"
|
||||
)
|
||||
|
||||
// capIngest — стадия приёма для поля capability в логах.
|
||||
@@ -28,7 +29,9 @@ type Store interface {
|
||||
// CreateDownloadIfNoActive атомарно проверяет инвариант «одна активная
|
||||
// загрузка на infohash» и заводит задачу; вернувшаяся existing ≠ nil —
|
||||
// дедуп на активную задачу (недостающие хеши вызова метод доносит сам).
|
||||
CreateDownloadIfNoActive(ctx context.Context, d *store.Download, hashes []string) (*store.Download, error)
|
||||
// torrentBlob (для source_type=torrent) пишется в той же транзакции только
|
||||
// на ветке создания; при дедупе не пишется. Для magnet — nil.
|
||||
CreateDownloadIfNoActive(ctx context.Context, d *store.Download, hashes []string, torrentBlob []byte) (*store.Download, error)
|
||||
// AddInfohashes доносит задаче недостающие хеши (guarded). Нужен на
|
||||
// быстром дедуп-пути, который не доходит до CreateDownloadIfNoActive.
|
||||
AddInfohashes(ctx context.Context, downloadID string, hashes []string) error
|
||||
@@ -45,10 +48,13 @@ func New(st Store, log *slog.Logger) *Service {
|
||||
return &Service{store: st, log: log}
|
||||
}
|
||||
|
||||
// Request — входной запрос приёма.
|
||||
// Request — входной запрос приёма. Источник задаётся либо строкой (magnet), либо
|
||||
// байтами `.torrent` (TorrentData); при непустом TorrentData он в приоритете.
|
||||
type Request struct {
|
||||
Source string // пока — magnet-ссылка
|
||||
Context string // подсказка для распознавания (опц.)
|
||||
Source string // magnet-ссылка (когда TorrentData пуст)
|
||||
TorrentData []byte // байты .torrent-файла (опц.); при наличии — источник torrent
|
||||
TorrentName string // имя загруженного файла (опц.); фолбек для source_ref
|
||||
Context string // подсказка для распознавания (опц.)
|
||||
}
|
||||
|
||||
// Result — итог приёма.
|
||||
@@ -64,39 +70,39 @@ type Result struct {
|
||||
// `catched` и сразу возвращает результат. Добавление в qBittorrent и вывод
|
||||
// имени выполняет worker (см. download-tracking).
|
||||
func (s *Service) Ingest(ctx context.Context, req Request) (Result, error) {
|
||||
source := strings.TrimSpace(req.Source)
|
||||
info, err := magnet.Parse(source)
|
||||
src, err := s.parse(req)
|
||||
if err != nil {
|
||||
// Ф1: поддержан только magnet. .torrent/url — следующий заход.
|
||||
return Result{}, fmt.Errorf("ingest: parse source: %w", err)
|
||||
return Result{}, err
|
||||
}
|
||||
|
||||
// Scoped-логгер стадии приёма: download_id допишется после CreateDownload.
|
||||
log := s.log.With("capability", capIngest, "infohash", info.Infohash)
|
||||
log := s.log.With("capability", capIngest, "infohash", src.infohashes[0])
|
||||
ctx = logctx.With(ctx, log)
|
||||
|
||||
// Быстрый дедуп-чек; авторитетная (атомарная) проверка — внутри
|
||||
// CreateDownloadIfNoActive ниже. Дедуп — по ЛЮБОМУ из хешей источника:
|
||||
// гибридный magnet несёт и v1, и v2.
|
||||
if existing, err := s.store.FindActiveByInfohash(ctx, info.Infohashes...); err != nil {
|
||||
// гибридный несёт и v1, и v2.
|
||||
if existing, err := s.store.FindActiveByInfohash(ctx, src.infohashes...); 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 s.attached(ctx, info, existing), nil
|
||||
return s.attached(ctx, src, existing), nil
|
||||
}
|
||||
|
||||
// Контекст распознавания дополняем фактами из полей самой magnet-ссылки
|
||||
// (dn/xl/tr/xs/kt) — без сети. Пользовательский текст идёт первым.
|
||||
// Контекст распознавания дополняем фактами из полей источника (magnet-поля
|
||||
// или дерево `.torrent`) — без сети. Пользовательский текст идёт первым.
|
||||
// DisplayName пуст: имя выведет worker на шаге добавления (rename действует
|
||||
// только при добавлении, а тут медленный LLM в пути ответа недопустим).
|
||||
d := &store.Download{
|
||||
SourceType: store.SourceMagnet,
|
||||
SourceRef: source,
|
||||
Context: mergeContext(req.Context, info.Context()),
|
||||
SourceType: src.sourceType,
|
||||
SourceRef: src.sourceRef,
|
||||
Context: mergeContext(req.Context, src.synthContext),
|
||||
State: store.StateCatched,
|
||||
}
|
||||
// Все хеши из magnet (гибридный несёт v1 и v2); kind store выведет по длине.
|
||||
existing, err := s.store.CreateDownloadIfNoActive(ctx, d, info.Infohashes)
|
||||
// Все хеши источника (гибрид несёт v1 и v2); kind store выведет по длине.
|
||||
// torrentBlob непуст только для source_type=torrent — пишется в той же
|
||||
// транзакции лишь на ветке создания.
|
||||
existing, err := s.store.CreateDownloadIfNoActive(ctx, d, src.infohashes, src.torrentBlob)
|
||||
if err != nil {
|
||||
return Result{}, fmt.Errorf("ingest: create download: %w", err)
|
||||
}
|
||||
@@ -107,7 +113,7 @@ func (s *Service) Ingest(ctx context.Context, req Request) (Result, error) {
|
||||
log.Info("download attached to active", "download_id", existing.ID, "state", existing.State)
|
||||
return Result{
|
||||
DownloadID: existing.ID,
|
||||
Infohashes: info.Infohashes,
|
||||
Infohashes: src.infohashes,
|
||||
State: existing.State,
|
||||
Deduplicated: true,
|
||||
}, nil
|
||||
@@ -116,11 +122,67 @@ func (s *Service) Ingest(ctx context.Context, req Request) (Result, error) {
|
||||
log.Info("download catched", "download_id", d.ID)
|
||||
return Result{
|
||||
DownloadID: d.ID,
|
||||
Infohashes: info.Infohashes,
|
||||
Infohashes: src.infohashes,
|
||||
State: store.StateCatched,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// MaxTorrentSize — предел размера принимаемого `.torrent` (защита от разбухания
|
||||
// БД и oversized-загрузок). Реальные торрент-файлы много меньше; крупные (много
|
||||
// файлов → много piece-хешей) отсекаются здесь.
|
||||
const MaxTorrentSize = 8 << 20 // 8 MiB
|
||||
|
||||
// parsedSource — нормализованный источник приёма (magnet или .torrent).
|
||||
type parsedSource struct {
|
||||
sourceType store.SourceType
|
||||
sourceRef string // референс для человека/логов (magnet-URI или имя torrent)
|
||||
infohashes []string // все хеши источника; v1 раньше v2
|
||||
synthContext string // синтез контекста из полей источника (без сети)
|
||||
torrentBlob []byte // байты .torrent (для source_type=torrent); иначе nil
|
||||
}
|
||||
|
||||
// parse разбирает источник запроса: при непустом TorrentData — как `.torrent`
|
||||
// (в приоритете), иначе — как magnet. Синтез контекста и хеши берутся из полей
|
||||
// источника, без сети.
|
||||
func (s *Service) parse(req Request) (parsedSource, error) {
|
||||
if len(req.TorrentData) > 0 {
|
||||
if len(req.TorrentData) > MaxTorrentSize {
|
||||
return parsedSource{}, fmt.Errorf("ingest: torrent too large: %d > %d bytes", len(req.TorrentData), MaxTorrentSize)
|
||||
}
|
||||
info, err := torrent.Parse(req.TorrentData)
|
||||
if err != nil {
|
||||
return parsedSource{}, fmt.Errorf("ingest: parse torrent: %w", err)
|
||||
}
|
||||
// SourceRef — человекочитаемый референс (имя раздачи), НЕ адрес
|
||||
// добавления: torrent добавляется байтами (см. worker), не по SourceRef.
|
||||
// Фолбек на имя файла, если у раздачи нет содержательного имени
|
||||
// (пустое или NoName-сентинел "-").
|
||||
ref := strings.TrimSpace(info.DisplayName)
|
||||
if ref == "" || ref == "-" {
|
||||
ref = strings.TrimSpace(req.TorrentName)
|
||||
}
|
||||
return parsedSource{
|
||||
sourceType: store.SourceTorrent,
|
||||
sourceRef: ref,
|
||||
infohashes: info.Infohashes,
|
||||
synthContext: info.Context(),
|
||||
torrentBlob: req.TorrentData,
|
||||
}, nil
|
||||
}
|
||||
|
||||
source := strings.TrimSpace(req.Source)
|
||||
info, err := magnet.Parse(source)
|
||||
if err != nil {
|
||||
return parsedSource{}, fmt.Errorf("ingest: parse source: %w", err)
|
||||
}
|
||||
return parsedSource{
|
||||
sourceType: store.SourceMagnet,
|
||||
sourceRef: source,
|
||||
infohashes: info.Infohashes,
|
||||
synthContext: info.Context(),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// mergeContext склеивает контекст от транспорта с синтезом из полей magnet:
|
||||
// пользовательский текст идёт первым, затем факты из ссылки. Пустые части
|
||||
// опускаются; при пустых обеих — пустая строка (пустой контекст допустим).
|
||||
@@ -140,15 +202,15 @@ func mergeContext(userText, synth string) string {
|
||||
// принести хеш, которого задача ещё не знает; guarded-путь через
|
||||
// CreateDownloadIfNoActive сюда не доходит). Донос — best-effort: конфликт
|
||||
// хеша с другой активной задачей логируется, приём не валится.
|
||||
func (s *Service) attached(ctx context.Context, info magnet.Info, existing *store.Download) Result {
|
||||
if len(info.Infohashes) > len(existing.Infohashes) {
|
||||
if err := s.store.AddInfohashes(ctx, existing.ID, info.Infohashes); err != nil {
|
||||
func (s *Service) attached(ctx context.Context, src parsedSource, existing *store.Download) Result {
|
||||
if len(src.infohashes) > len(existing.Infohashes) {
|
||||
if err := s.store.AddInfohashes(ctx, existing.ID, src.infohashes); err != nil {
|
||||
logctx.FromOr(ctx, s.log).Warn("ingest top-up infohashes failed", "error", err)
|
||||
}
|
||||
}
|
||||
return Result{
|
||||
DownloadID: existing.ID,
|
||||
Infohashes: info.Infohashes,
|
||||
Infohashes: src.infohashes,
|
||||
State: existing.State,
|
||||
Deduplicated: true,
|
||||
}
|
||||
|
||||
@@ -19,6 +19,7 @@ type fakeStore struct {
|
||||
active *store.Download
|
||||
created []store.Download
|
||||
hashes [][]string
|
||||
blobs [][]byte
|
||||
toppedUp []string
|
||||
}
|
||||
|
||||
@@ -26,13 +27,14 @@ func (f *fakeStore) FindActiveByInfohash(_ context.Context, _ ...string) (*store
|
||||
return f.active, nil
|
||||
}
|
||||
|
||||
func (f *fakeStore) CreateDownloadIfNoActive(_ context.Context, d *store.Download, hashes []string) (*store.Download, error) {
|
||||
func (f *fakeStore) CreateDownloadIfNoActive(_ context.Context, d *store.Download, hashes []string, torrentBlob []byte) (*store.Download, error) {
|
||||
if f.active != nil {
|
||||
return f.active, nil
|
||||
}
|
||||
d.ID = ident.NewID()
|
||||
f.created = append(f.created, *d)
|
||||
f.hashes = append(f.hashes, hashes)
|
||||
f.blobs = append(f.blobs, torrentBlob)
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
package ingest
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/sha1"
|
||||
"encoding/hex"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/anacrolix/torrent/bencode"
|
||||
"github.com/anacrolix/torrent/metainfo"
|
||||
|
||||
"git.vakhrushev.me/av/jellybit/internal/store"
|
||||
)
|
||||
|
||||
// buildTorrent собирает валидные байты .torrent и ожидаемый v1-инфохэш.
|
||||
func buildTorrent(t *testing.T, name, announce string) (data []byte, infohash string) {
|
||||
t.Helper()
|
||||
info := metainfo.Info{Name: name, Length: 1024, PieceLength: 512, Pieces: make([]byte, 40)}
|
||||
infoBytes, err := bencode.Marshal(info)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal info: %v", err)
|
||||
}
|
||||
sum := sha1.Sum(infoBytes)
|
||||
mi := metainfo.MetaInfo{InfoBytes: infoBytes, Announce: announce}
|
||||
var buf bytes.Buffer
|
||||
if err := mi.Write(&buf); err != nil {
|
||||
t.Fatalf("write metainfo: %v", err)
|
||||
}
|
||||
return buf.Bytes(), hex.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
func TestIngestTorrentFile(t *testing.T) {
|
||||
data, wantHash := buildTorrent(t, "Dune.Part.Two.2024.mkv", "http://bt.rutracker.org/ann")
|
||||
fs := &fakeStore{}
|
||||
res, err := newService(fs).Ingest(context.Background(), Request{TorrentData: data, Context: "мой текст"})
|
||||
if err != nil {
|
||||
t.Fatalf("Ingest: %v", err)
|
||||
}
|
||||
if res.State != store.StateCatched || res.Deduplicated {
|
||||
t.Errorf("res = %+v", res)
|
||||
}
|
||||
if len(res.Infohashes) != 1 || res.Infohashes[0] != wantHash {
|
||||
t.Errorf("infohashes = %v, want [%s]", res.Infohashes, wantHash)
|
||||
}
|
||||
if len(fs.created) != 1 {
|
||||
t.Fatalf("создано задач: %d", len(fs.created))
|
||||
}
|
||||
d := fs.created[0]
|
||||
if d.SourceType != store.SourceTorrent {
|
||||
t.Errorf("source_type = %q, want torrent", d.SourceType)
|
||||
}
|
||||
if d.SourceRef != "Dune.Part.Two.2024.mkv" {
|
||||
t.Errorf("source_ref = %q (имя раздачи, не URL)", d.SourceRef)
|
||||
}
|
||||
// Контекст: текст пользователя первым, затем синтез из полей файла.
|
||||
if !strings.HasPrefix(d.Context, "мой текст") {
|
||||
t.Errorf("context не с текста пользователя: %q", d.Context)
|
||||
}
|
||||
if !strings.Contains(d.Context, "Dune.Part.Two") || !strings.Contains(d.Context, "rutracker.org") {
|
||||
t.Errorf("context без синтеза из файла: %q", d.Context)
|
||||
}
|
||||
// Байты сохранены в транзакции создания.
|
||||
if len(fs.blobs) != 1 || !bytes.Equal(fs.blobs[0], data) {
|
||||
t.Errorf("байты .torrent не переданы в CreateDownloadIfNoActive")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIngestTorrentDedupNoBlob(t *testing.T) {
|
||||
data, _ := buildTorrent(t, "X", "http://t/ann")
|
||||
// Активная задача уже есть → дедуп; байты писаться не должны.
|
||||
fs := &fakeStore{active: &store.Download{ID: "existing", State: store.StateDownloading}}
|
||||
res, err := newService(fs).Ingest(context.Background(), Request{TorrentData: data})
|
||||
if err != nil {
|
||||
t.Fatalf("Ingest: %v", err)
|
||||
}
|
||||
if !res.Deduplicated || res.DownloadID != "existing" {
|
||||
t.Errorf("ожидался дедуп, res = %+v", res)
|
||||
}
|
||||
if len(fs.created) != 0 || len(fs.blobs) != 0 {
|
||||
t.Errorf("при дедупе не должно быть создания/записи байтов")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIngestTorrentTooLarge(t *testing.T) {
|
||||
fs := &fakeStore{}
|
||||
big := make([]byte, MaxTorrentSize+1)
|
||||
_, err := newService(fs).Ingest(context.Background(), Request{TorrentData: big})
|
||||
if err == nil {
|
||||
t.Fatal("ожидалась ошибка превышения размера")
|
||||
}
|
||||
if len(fs.created) != 0 {
|
||||
t.Errorf("при превышении размера задача не создаётся")
|
||||
}
|
||||
}
|
||||
|
||||
// У раздачи без имени source_ref берётся из имени файла (фолбек).
|
||||
func TestIngestTorrentNameFallback(t *testing.T) {
|
||||
// Info без name → BestName() == "" → фолбек на TorrentName.
|
||||
info := metainfo.Info{Name: "", Length: 1024, PieceLength: 512, Pieces: make([]byte, 40)}
|
||||
infoBytes, err := bencode.Marshal(info)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal: %v", err)
|
||||
}
|
||||
mi := metainfo.MetaInfo{InfoBytes: infoBytes, Announce: "http://t/ann"}
|
||||
var buf bytes.Buffer
|
||||
if err := mi.Write(&buf); err != nil {
|
||||
t.Fatalf("write: %v", err)
|
||||
}
|
||||
|
||||
fs := &fakeStore{}
|
||||
_, err = newService(fs).Ingest(context.Background(),
|
||||
Request{TorrentData: buf.Bytes(), TorrentName: "Fallback.Name.torrent"})
|
||||
if err != nil {
|
||||
t.Fatalf("Ingest: %v", err)
|
||||
}
|
||||
if len(fs.created) != 1 || fs.created[0].SourceRef != "Fallback.Name.torrent" {
|
||||
t.Errorf("source_ref = %q, want фолбек на имя файла", fs.created[0].SourceRef)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIngestTorrentInvalid(t *testing.T) {
|
||||
fs := &fakeStore{}
|
||||
_, err := newService(fs).Ingest(context.Background(), Request{TorrentData: []byte("not a torrent")})
|
||||
if err == nil {
|
||||
t.Fatal("ожидалась ошибка разбора .torrent")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user