Синтезируем контекст из полей самой magnet-ссылки (dn, xl, tr/xs, kt) без сети и дополняем им текст от транспорта: пользовательский текст первым, при пустом — синтез единственный. Обогащённый контекст идёт только в download.Context (его читают recognition и веб-UI); вход namer и отображаемое имя не меняются — строки-факты (Размер:/Трекер:) в display_name не текут. - magnet.Info: поля ExactLength/Sources/Keywords + Info.Context() (синтез, отсев dn-заглушек *-topic-<id>, домен трекера, человекочитаемый размер) - Parse устойчив к ссылке в процент-кодировке (разовый QueryUnescape) - ingest: mergeContext → download.Context, namer на сыром req.Context - Влита дельта capability ingest в openspec/specs, change заархивирован Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
249 lines
8.6 KiB
Go
249 lines
8.6 KiB
Go
package magnet
|
|
|
|
import (
|
|
"net/url"
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
func TestParse(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
raw string
|
|
infohash string
|
|
dn string
|
|
trackers int
|
|
}{
|
|
{
|
|
name: "btih hex (из BRIEF)",
|
|
raw: "magnet:?xt=urn:btih:541ADCFF3B6DD5DBA7088EA83317D9D6FAC331D6&tr=http%3A%2F%2Fbt.t-ru.org%2Fann%3Fmagnet&dn=rutracker-topic-6514485",
|
|
infohash: "541adcff3b6dd5dba7088ea83317d9d6fac331d6",
|
|
dn: "rutracker-topic-6514485",
|
|
trackers: 1,
|
|
},
|
|
{
|
|
name: "btih base32 (20 нулевых байт)",
|
|
raw: "magnet:?xt=urn:btih:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA",
|
|
infohash: "0000000000000000000000000000000000000000",
|
|
},
|
|
{
|
|
name: "btmh v2 sha256",
|
|
raw: "magnet:?xt=urn:btmh:12200123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
|
|
infohash: "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
|
|
},
|
|
}
|
|
for _, tc := range tests {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
got, err := Parse(tc.raw)
|
|
if err != nil {
|
|
t.Fatalf("Parse: %v", err)
|
|
}
|
|
if got.Infohash != tc.infohash {
|
|
t.Errorf("infohash = %q, want %q", got.Infohash, tc.infohash)
|
|
}
|
|
if got.DisplayName != tc.dn {
|
|
t.Errorf("dn = %q, want %q", got.DisplayName, tc.dn)
|
|
}
|
|
if len(got.Trackers) != tc.trackers {
|
|
t.Errorf("trackers = %d, want %d", len(got.Trackers), tc.trackers)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// Гибридный magnet несёт оба хеша: Infohash — v1 (приоритет), Infohashes — оба.
|
|
func TestParseHybridKeepsBothHashes(t *testing.T) {
|
|
const (
|
|
v1 = "541adcff3b6dd5dba7088ea83317d9d6fac331d6"
|
|
v2 = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
|
|
)
|
|
got, err := Parse("magnet:?xt=urn:btih:" + v1 + "&xt=urn:btmh:1220" + v2)
|
|
if err != nil {
|
|
t.Fatalf("Parse: %v", err)
|
|
}
|
|
if got.Infohash != v1 {
|
|
t.Errorf("primary = %q, want v1", got.Infohash)
|
|
}
|
|
if len(got.Infohashes) != 2 || got.Infohashes[0] != v1 || got.Infohashes[1] != v2 {
|
|
t.Errorf("infohashes = %v, want [v1 v2]", got.Infohashes)
|
|
}
|
|
}
|
|
|
|
func TestParseErrors(t *testing.T) {
|
|
cases := []string{
|
|
"https://example.com/file.torrent", // не magnet
|
|
"magnet:?dn=no-infohash", // нет xt
|
|
"magnet:?xt=urn:btih:zzzz", // некорректный hash
|
|
"", // пусто
|
|
}
|
|
for _, raw := range cases {
|
|
if _, err := Parse(raw); err == nil {
|
|
t.Errorf("Parse(%q): ожидалась ошибка", raw)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestParseExtraFields(t *testing.T) {
|
|
raw := "magnet:?xt=urn:btih:541adcff3b6dd5dba7088ea83317d9d6fac331d6" +
|
|
"&xl=2200000000&xs=http%3A%2F%2Fmirror.example%2Ft.torrent&kt=dune+scifi"
|
|
got, err := Parse(raw)
|
|
if err != nil {
|
|
t.Fatalf("Parse: %v", err)
|
|
}
|
|
if got.ExactLength != 2200000000 {
|
|
t.Errorf("xl = %d, want 2200000000", got.ExactLength)
|
|
}
|
|
if len(got.Sources) != 1 || got.Sources[0] != "http://mirror.example/t.torrent" {
|
|
t.Errorf("xs = %v", got.Sources)
|
|
}
|
|
if len(got.Keywords) != 1 || got.Keywords[0] != "dune scifi" {
|
|
t.Errorf("kt = %v", got.Keywords)
|
|
}
|
|
}
|
|
|
|
func TestParseInvalidXLIgnored(t *testing.T) {
|
|
for _, raw := range []string{
|
|
"magnet:?xt=urn:btih:541adcff3b6dd5dba7088ea83317d9d6fac331d6&xl=notanumber",
|
|
"magnet:?xt=urn:btih:541adcff3b6dd5dba7088ea83317d9d6fac331d6&xl=-5",
|
|
"magnet:?xt=urn:btih:541adcff3b6dd5dba7088ea83317d9d6fac331d6",
|
|
} {
|
|
got, err := Parse(raw)
|
|
if err != nil {
|
|
t.Fatalf("Parse(%q): %v", raw, err)
|
|
}
|
|
if got.ExactLength != 0 {
|
|
t.Errorf("Parse(%q): xl = %d, want 0", raw, got.ExactLength)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestInfoContext(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
info Info
|
|
want []string // подстроки, которые ДОЛЖНЫ быть
|
|
notWant []string // подстроки, которых быть НЕ должно
|
|
}{
|
|
{
|
|
name: "содержательный dn",
|
|
info: Info{DisplayName: "Dune.Part.Two.2024.2160p"},
|
|
want: []string{"Dune.Part.Two.2024.2160p"},
|
|
notWant: []string{"Размер:", "Трекер:"},
|
|
},
|
|
{
|
|
name: "заглушка-dn не берётся как имя",
|
|
info: Info{DisplayName: "rutracker-topic-6514485", Trackers: []string{"http://bt.t-ru.org/ann?magnet"}},
|
|
want: []string{"Трекер: t-ru.org"},
|
|
notWant: []string{"rutracker-topic"},
|
|
},
|
|
{
|
|
name: "только размер",
|
|
info: Info{ExactLength: 2200000000},
|
|
want: []string{"Размер: ≈ 2.0 GiB"},
|
|
},
|
|
{
|
|
name: "домен из xs, служебный поддомен убран",
|
|
info: Info{Sources: []string{"udp://tracker.example.org:80/announce"}},
|
|
want: []string{"Трекер: example.org"},
|
|
notWant: []string{"tracker.example.org"},
|
|
},
|
|
{
|
|
name: "все поля вместе",
|
|
info: Info{
|
|
DisplayName: "Dune",
|
|
ExactLength: 1500,
|
|
Trackers: []string{"http://bt.t-ru.org/ann"},
|
|
Keywords: []string{"scifi dune"}, // как отдаёт url.Query() из kt=scifi+dune
|
|
},
|
|
want: []string{"Dune", "Размер:", "Трекер: t-ru.org", "Ключевые слова: scifi, dune"},
|
|
},
|
|
{
|
|
name: "пустой Info → пустой контекст",
|
|
info: Info{},
|
|
want: nil,
|
|
},
|
|
}
|
|
for _, tc := range tests {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
got := tc.info.Context()
|
|
if len(tc.want) == 0 && got != "" {
|
|
t.Errorf("Context() = %q, want пусто", got)
|
|
}
|
|
for _, w := range tc.want {
|
|
if !strings.Contains(got, w) {
|
|
t.Errorf("Context() = %q, want содержит %q", got, w)
|
|
}
|
|
}
|
|
for _, nw := range tc.notWant {
|
|
if strings.Contains(got, nw) {
|
|
t.Errorf("Context() = %q, НЕ должно содержать %q", got, nw)
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// Реальная ссылка с рутрекера: dn несёт полный релиз-заголовок в
|
|
// процент-кодировке (кириллица + латиница, сезон/серии/год/жанры/качество),
|
|
// tr — announce-хост bt4.t-ru.org. Проверяем полный путь до Context().
|
|
func TestParseRealRutrackerLink(t *testing.T) {
|
|
const raw = "magnet:?xt=urn:btih:BACA24E18C7382A9E9A44132C8D7DB86C4D319C2" +
|
|
"&tr=http%3A%2F%2Fbt4.t-ru.org%2Fann%3Fmagnet" +
|
|
"&dn=%D0%91%D1%83%D1%85%D1%82%D0%B0%20%D0%B2%D0%B4%D0%BE%D0%B2%20%2F%20Widow's%20Bay%20%2F%20%D0%A1%D0%B5%D0%B7%D0%BE%D0%BD%3A%201%20%2F%20%D0%A1%D0%B5%D1%80%D0%B8%D0%B8%3A%201-10%20%D0%B8%D0%B7%2010%20(%D0%A5%D0%B8%D1%80%D0%BE%20%D0%9C%D1%83%D1%80%D0%B0%D0%B9)%20%5B2026%2C%20%D0%A1%D0%A8%D0%90%2C%20%D0%A3%D0%B6%D0%B0%D1%81%D1%8B%2C%20%D0%B4%D1%80%D0%B0%D0%BC%D0%B0%2C%20%D0%BA%D0%BE%D0%BC%D0%B5%D0%B4%D0%B8%D1%8F%2C%20WEB-DL%201080p%5D%207%20x%20MVO"
|
|
got, err := Parse(raw)
|
|
if err != nil {
|
|
t.Fatalf("Parse: %v", err)
|
|
}
|
|
if got.Infohash != "baca24e18c7382a9e9a44132c8d7db86c4d319c2" {
|
|
t.Errorf("infohash = %q", got.Infohash)
|
|
}
|
|
// dn раскодирован url.Query() — без ручного urldecode.
|
|
const wantName = "Бухта вдов / Widow's Bay / Сезон: 1 / Серии: 1-10 из 10 " +
|
|
"(Хиро Мурай) [2026, США, Ужасы, драма, комедия, WEB-DL 1080p] 7 x MVO"
|
|
if got.DisplayName != wantName {
|
|
t.Errorf("dn = %q", got.DisplayName)
|
|
}
|
|
ctx := got.Context()
|
|
if !strings.Contains(ctx, "Widow's Bay") || !strings.Contains(ctx, "2026") {
|
|
t.Errorf("Context() без релиз-данных: %q", ctx)
|
|
}
|
|
if !strings.Contains(ctx, "Трекер: t-ru.org") {
|
|
t.Errorf("Context() без домена трекера (bt4. должен срезаться): %q", ctx)
|
|
}
|
|
if strings.Contains(ctx, "bt4.") {
|
|
t.Errorf("служебный поддомен не срезан: %q", ctx)
|
|
}
|
|
}
|
|
|
|
// Ссылку могли скопировать целиком в процент-кодировке (например вытащили из
|
|
// другого URL) — Parse должен раскодировать её один раз и разобрать.
|
|
func TestParseDoubleEncoded(t *testing.T) {
|
|
const inner = "magnet:?xt=urn:btih:541adcff3b6dd5dba7088ea83317d9d6fac331d6&dn=Dune"
|
|
got, err := Parse(url.QueryEscape(inner))
|
|
if err != nil {
|
|
t.Fatalf("Parse(double-encoded): %v", err)
|
|
}
|
|
if got.Infohash != "541adcff3b6dd5dba7088ea83317d9d6fac331d6" {
|
|
t.Errorf("infohash = %q", got.Infohash)
|
|
}
|
|
if got.DisplayName != "Dune" {
|
|
t.Errorf("dn = %q", got.DisplayName)
|
|
}
|
|
}
|
|
|
|
func TestParseNormalisesCase(t *testing.T) {
|
|
lower := "magnet:?xt=urn:btih:541adcff3b6dd5dba7088ea83317d9d6fac331d6"
|
|
upper := "magnet:?xt=urn:btih:541ADCFF3B6DD5DBA7088EA83317D9D6FAC331D6"
|
|
a, err := Parse(lower)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
b, err := Parse(upper)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if a.Infohash != b.Infohash {
|
|
t.Errorf("регистр влияет: %q != %q", a.Infohash, b.Infohash)
|
|
}
|
|
}
|