Принимаем .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>
130 lines
4.7 KiB
Go
130 lines
4.7 KiB
Go
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")
|
|
}
|
|
}
|