Принимаем .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>
236 lines
7.5 KiB
Go
236 lines
7.5 KiB
Go
package torrent
|
|
|
|
import (
|
|
"bytes"
|
|
"crypto/sha1"
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/anacrolix/torrent/bencode"
|
|
"github.com/anacrolix/torrent/metainfo"
|
|
)
|
|
|
|
// writeTorrent оборачивает готовые info-байты в metainfo и возвращает байты
|
|
// .torrent (announce задаёт трекер). Позволяет строить v2/гибридные фикстуры,
|
|
// у которых info собран вручную.
|
|
func writeTorrent(t *testing.T, infoBytes []byte) []byte {
|
|
t.Helper()
|
|
mi := metainfo.MetaInfo{InfoBytes: infoBytes, Announce: "http://t/ann"}
|
|
var buf bytes.Buffer
|
|
if err := mi.Write(&buf); err != nil {
|
|
t.Fatalf("write metainfo: %v", err)
|
|
}
|
|
return buf.Bytes()
|
|
}
|
|
|
|
// v2-only торрент (meta version 2, без v1-полей) сохраняется под v2-хешем
|
|
// (64-hex = SHA256 исходных байтов info) — как его отдаёт qBittorrent.
|
|
func TestParseV2OnlyHash(t *testing.T) {
|
|
// Без Files/Length/Pieces + meta version 2 → HasV1()=false, HasV2()=true.
|
|
info := metainfo.Info{Name: "V2.Only.Release", MetaVersion: 2}
|
|
infoBytes, err := bencode.Marshal(info)
|
|
if err != nil {
|
|
t.Fatalf("marshal: %v", err)
|
|
}
|
|
sum := sha256.Sum256(infoBytes)
|
|
wantV2 := hex.EncodeToString(sum[:])
|
|
|
|
got, err := Parse(writeTorrent(t, infoBytes))
|
|
if err != nil {
|
|
t.Fatalf("parse: %v", err)
|
|
}
|
|
if len(got.Infohashes) != 1 {
|
|
t.Fatalf("infohashes = %v, want один v2-хеш", got.Infohashes)
|
|
}
|
|
if got.Infohash != wantV2 {
|
|
t.Errorf("infohash = %q, want v2 %q", got.Infohash, wantV2)
|
|
}
|
|
if len(got.Infohash) != 64 {
|
|
t.Errorf("длина хеша = %d, want 64 (v2)", len(got.Infohash))
|
|
}
|
|
}
|
|
|
|
// Гибрид (v1 + v2): оба хеша, v1 (40-hex) первым, затем v2 (64-hex).
|
|
func TestParseHybridHashes(t *testing.T) {
|
|
info := metainfo.Info{
|
|
Name: "Hybrid", Length: 100, PieceLength: 1024,
|
|
Pieces: make([]byte, 20), MetaVersion: 2,
|
|
}
|
|
infoBytes, err := bencode.Marshal(info)
|
|
if err != nil {
|
|
t.Fatalf("marshal: %v", err)
|
|
}
|
|
sha1sum := sha1.Sum(infoBytes)
|
|
sha256sum := sha256.Sum256(infoBytes)
|
|
wantV1 := hex.EncodeToString(sha1sum[:])
|
|
wantV2 := hex.EncodeToString(sha256sum[:])
|
|
|
|
got, err := Parse(writeTorrent(t, infoBytes))
|
|
if err != nil {
|
|
t.Fatalf("parse: %v", err)
|
|
}
|
|
if len(got.Infohashes) != 2 {
|
|
t.Fatalf("infohashes = %v, want [v1, v2]", got.Infohashes)
|
|
}
|
|
if got.Infohashes[0] != wantV1 || got.Infohashes[1] != wantV2 {
|
|
t.Errorf("infohashes = %v, want [%s, %s]", got.Infohashes, wantV1, wantV2)
|
|
}
|
|
}
|
|
|
|
// build собирает валидные байты `.torrent` из info и метаданных, возвращая их
|
|
// вместе с ожидаемым v1-инфохэшем (SHA1 исходных байтов info) — так тест
|
|
// проверяет извлечение, не переизобретая хеширование.
|
|
func build(t *testing.T, info metainfo.Info, announce string, comment string) (data []byte, wantV1 string) {
|
|
t.Helper()
|
|
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, Comment: comment}
|
|
var buf bytes.Buffer
|
|
if err := mi.Write(&buf); err != nil {
|
|
t.Fatalf("write metainfo: %v", err)
|
|
}
|
|
return buf.Bytes(), hex.EncodeToString(sum[:])
|
|
}
|
|
|
|
// v1-piece заглушка (20 байт) — присутствие Pieces делает info v1.
|
|
func pieces(n int) []byte { return make([]byte, 20*n) }
|
|
|
|
func TestParseSingleFile(t *testing.T) {
|
|
data, wantV1 := build(t, metainfo.Info{
|
|
Name: "Dune.Part.Two.2024.2160p.mkv",
|
|
Length: 2100,
|
|
PieceLength: 1024,
|
|
Pieces: pieces(3),
|
|
}, "http://bt.rutracker.org/announce", "")
|
|
|
|
info, err := Parse(data)
|
|
if err != nil {
|
|
t.Fatalf("parse: %v", err)
|
|
}
|
|
if info.Infohash != wantV1 {
|
|
t.Errorf("infohash = %q, want %q", info.Infohash, wantV1)
|
|
}
|
|
if len(info.Infohashes) != 1 || info.Infohashes[0] != wantV1 {
|
|
t.Errorf("infohashes = %v, want [%s]", info.Infohashes, wantV1)
|
|
}
|
|
if info.DisplayName != "Dune.Part.Two.2024.2160p.mkv" {
|
|
t.Errorf("name = %q", info.DisplayName)
|
|
}
|
|
if info.TotalLength != 2100 {
|
|
t.Errorf("total = %d, want 2100", info.TotalLength)
|
|
}
|
|
if len(info.Files) != 1 {
|
|
t.Errorf("files = %v, want 1", info.Files)
|
|
}
|
|
ctx := info.Context()
|
|
if !strings.Contains(ctx, "Dune.Part.Two") {
|
|
t.Errorf("context без имени: %q", ctx)
|
|
}
|
|
if !strings.Contains(ctx, "Размер:") {
|
|
t.Errorf("context без размера: %q", ctx)
|
|
}
|
|
if !strings.Contains(ctx, "rutracker.org") {
|
|
t.Errorf("context без домена трекера: %q", ctx)
|
|
}
|
|
}
|
|
|
|
func TestParseMultiFile(t *testing.T) {
|
|
data, _ := build(t, metainfo.Info{
|
|
Name: "Fargo.S01",
|
|
PieceLength: 1024,
|
|
Pieces: pieces(5),
|
|
Files: []metainfo.FileInfo{
|
|
{Length: 700, Path: []string{"Fargo.S01E01.mkv"}},
|
|
{Length: 800, Path: []string{"Fargo.S01E02.mkv"}},
|
|
{Length: 5, Path: []string{"subs", "e01.srt"}},
|
|
},
|
|
}, "udp://tracker.example.com:80", "")
|
|
|
|
info, err := Parse(data)
|
|
if err != nil {
|
|
t.Fatalf("parse: %v", err)
|
|
}
|
|
if len(info.Files) != 3 {
|
|
t.Fatalf("files = %d, want 3", len(info.Files))
|
|
}
|
|
if info.TotalLength != 1505 {
|
|
t.Errorf("total = %d, want 1505", info.TotalLength)
|
|
}
|
|
// путь из сегментов через "/"
|
|
if info.Files[2].Path != "subs/e01.srt" {
|
|
t.Errorf("path = %q, want subs/e01.srt", info.Files[2].Path)
|
|
}
|
|
ctx := info.Context()
|
|
if !strings.Contains(ctx, "Файлов: 3") {
|
|
t.Errorf("нет сигнала файлов: %q", ctx)
|
|
}
|
|
if !strings.Contains(ctx, "mkv") || !strings.Contains(ctx, "srt") {
|
|
t.Errorf("нет расширений в сигнале: %q", ctx)
|
|
}
|
|
}
|
|
|
|
func TestParseTrackersDedupAndComment(t *testing.T) {
|
|
data, _ := build(t, metainfo.Info{
|
|
Name: "X",
|
|
Length: 10,
|
|
PieceLength: 1024,
|
|
Pieces: pieces(1),
|
|
}, "http://bt2.t-ru.org/ann", "Многострочный\nкомментарий")
|
|
|
|
info, err := Parse(data)
|
|
if err != nil {
|
|
t.Fatalf("parse: %v", err)
|
|
}
|
|
if len(info.Trackers) == 0 {
|
|
t.Fatal("нет трекеров")
|
|
}
|
|
ctx := info.Context()
|
|
// служебный поддомен bt2. срезается
|
|
if !strings.Contains(ctx, "t-ru.org") {
|
|
t.Errorf("домен не нормализован: %q", ctx)
|
|
}
|
|
// комментарий схлопнут в одну строку
|
|
if !strings.Contains(ctx, "Комментарий: Многострочный комментарий") {
|
|
t.Errorf("комментарий: %q", ctx)
|
|
}
|
|
}
|
|
|
|
func TestParseInvalidBytes(t *testing.T) {
|
|
for _, tc := range []struct {
|
|
name string
|
|
data []byte
|
|
}{
|
|
{"empty", nil},
|
|
{"garbage", []byte("this is not a torrent")},
|
|
{"bencode-not-dict", []byte("i42e")},
|
|
} {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
if _, err := Parse(tc.data); err == nil {
|
|
t.Errorf("ожидалась ошибка для %s", tc.name)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestContextEmptyWhenNoFields(t *testing.T) {
|
|
// info без имени (NoName-сентинел), без трекера/комментария: строк-фактов
|
|
// минимум. Проверяем, что Context не паникует и не тянет сеть (чистая
|
|
// функция — сетевых вызовов в коде нет по построению).
|
|
data, _ := build(t, metainfo.Info{
|
|
Name: "-", // metainfo.NoName
|
|
Length: 0,
|
|
PieceLength: 1024,
|
|
Pieces: pieces(1),
|
|
}, "", "")
|
|
info, err := Parse(data)
|
|
if err != nil {
|
|
t.Fatalf("parse: %v", err)
|
|
}
|
|
_ = info.Context() // не паникует; состав строк — по полям
|
|
}
|