Ingest: контекст распознавания из полей magnet-ссылки
Синтезируем контекст из полей самой 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>
This commit is contained in:
@@ -122,16 +122,21 @@ func (s *Service) Ingest(ctx context.Context, req Request) (Result, error) {
|
||||
// Выводится синхронно (param rename действует только при добавлении) и
|
||||
// ДО CreateDownload, чтобы возможный медленный вызов LLM не расширял окно
|
||||
// «строка в БД есть, в qBittorrent ещё нет». Имя от строки БД не зависит.
|
||||
// Namer получает СЫРОЙ req.Context (+ dn-hint), не обогащённый: строки-факты
|
||||
// синтеза (Размер:/Трекер:) не должны становиться отображаемым именем.
|
||||
var rename string
|
||||
if s.namer != nil {
|
||||
rename = s.namer.DeriveName(ctx, req.Context, info.DisplayName)
|
||||
}
|
||||
|
||||
// Контекст распознавания дополняем фактами из полей самой magnet-ссылки
|
||||
// (dn/xl/tr/xs/kt) — без сети. Пользовательский текст идёт первым. Результат
|
||||
// уходит только в download.Context (его читают recognition и веб-UI).
|
||||
d := &store.Download{
|
||||
SourceType: store.SourceMagnet,
|
||||
SourceRef: source,
|
||||
DisplayName: rename, // то же имя, что уходит в qBittorrent (rename); заголовок в веб-UI
|
||||
Context: req.Context,
|
||||
Context: mergeContext(req.Context, info.Context()),
|
||||
State: store.StateDownloading,
|
||||
}
|
||||
// Все хеши из magnet (гибридный несёт v1 и v2); kind store выведет по длине.
|
||||
@@ -186,6 +191,20 @@ func (s *Service) Ingest(ctx context.Context, req Request) (Result, error) {
|
||||
}, nil
|
||||
}
|
||||
|
||||
// mergeContext склеивает контекст от транспорта с синтезом из полей magnet:
|
||||
// пользовательский текст идёт первым, затем факты из ссылки. Пустые части
|
||||
// опускаются; при пустых обеих — пустая строка (пустой контекст допустим).
|
||||
func mergeContext(userText, synth string) string {
|
||||
parts := make([]string, 0, 2)
|
||||
if s := strings.TrimSpace(userText); s != "" {
|
||||
parts = append(parts, s)
|
||||
}
|
||||
if s := strings.TrimSpace(synth); s != "" {
|
||||
parts = append(parts, s)
|
||||
}
|
||||
return strings.Join(parts, "\n")
|
||||
}
|
||||
|
||||
// attached — итог дедупа на быстром чеке: присоединились к уже активной
|
||||
// задаче и доносим ей недостающие хеши источника (гибридный magnet мог
|
||||
// принести хеш, которого задача ещё не знает; guarded-путь через
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"errors"
|
||||
"io"
|
||||
"log/slog"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -110,8 +111,10 @@ func TestIngestHappyPath(t *testing.T) {
|
||||
if len(fs.created) != 1 {
|
||||
t.Fatalf("создано задач: %d, want 1", len(fs.created))
|
||||
}
|
||||
if got := fs.created[0]; got.Context != "Дюна 2" {
|
||||
t.Errorf("сохранённая задача: %+v", got)
|
||||
// download.Context = пользовательский текст + синтез из полей magnet
|
||||
// (dn=Dune). Текст пользователя идёт первым.
|
||||
if got := fs.created[0].Context; !strings.HasPrefix(got, "Дюна 2") || !strings.Contains(got, "Dune") {
|
||||
t.Errorf("сохранённый контекст = %q", got)
|
||||
}
|
||||
if len(fs.hashes) != 1 || len(fs.hashes[0]) != 1 || fs.hashes[0][0] != sampleInfohash {
|
||||
t.Errorf("хеши задачи: %v", fs.hashes)
|
||||
@@ -204,6 +207,75 @@ func TestIngestDedupTopsUpHashes(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// Голый magnet без текста: download.Context синтезируется из полей ссылки
|
||||
// (dn-имя + размер), приём проходит штатно.
|
||||
func TestIngestMagnetOnlySynthesizesContext(t *testing.T) {
|
||||
const raw = "magnet:?xt=urn:btih:541ADCFF3B6DD5DBA7088EA83317D9D6FAC331D6" +
|
||||
"&dn=Dune.Part.Two.2024.2160p&xl=2200000000"
|
||||
fs := &fakeStore{}
|
||||
if _, err := newService(fs, &fakeQbt{}).Ingest(context.Background(),
|
||||
Request{Source: raw}); err != nil {
|
||||
t.Fatalf("Ingest: %v", err)
|
||||
}
|
||||
if len(fs.created) != 1 {
|
||||
t.Fatalf("создано задач: %d, want 1", len(fs.created))
|
||||
}
|
||||
ctx := fs.created[0].Context
|
||||
if !strings.Contains(ctx, "Dune.Part.Two.2024.2160p") || !strings.Contains(ctx, "Размер:") {
|
||||
t.Errorf("контекст не синтезирован из magnet: %q", ctx)
|
||||
}
|
||||
}
|
||||
|
||||
// Регресс B1: строки-факты синтеза (Трекер:/Размер:) не должны становиться
|
||||
// отображаемым именем. Namer получает СЫРОЙ контекст (+ dn-hint), а domain
|
||||
// уходит только в download.Context для recognition. Заглушка-dn как строка-
|
||||
// название в контекст не попадает.
|
||||
func TestIngestSynthFactsNeverBecomeName(t *testing.T) {
|
||||
const raw = "magnet:?xt=urn:btih:541ADCFF3B6DD5DBA7088EA83317D9D6FAC331D6" +
|
||||
"&dn=rutracker-topic-6514485&tr=http%3A%2F%2Fbt.t-ru.org%2Fann%3Fmagnet"
|
||||
fs := &fakeStore{}
|
||||
fq := &fakeQbt{}
|
||||
nm := &fakeNamer{name: "rutracker-topic-6514485"} // как вывел бы фолбек из dn-hint
|
||||
if _, err := newServiceWithNamer(fs, fq, nm).Ingest(context.Background(),
|
||||
Request{Source: raw}); err != nil {
|
||||
t.Fatalf("Ingest: %v", err)
|
||||
}
|
||||
if nm.gotContext != "" {
|
||||
t.Errorf("namer получил не сырой контекст: %q", nm.gotContext)
|
||||
}
|
||||
if strings.Contains(fq.added[0].Rename, "Трекер") {
|
||||
t.Errorf("строка-факт просочилась в rename: %q", fq.added[0].Rename)
|
||||
}
|
||||
got := fs.created[0].Context
|
||||
if !strings.Contains(got, "t-ru.org") {
|
||||
t.Errorf("download.Context не обогащён доменом трекера: %q", got)
|
||||
}
|
||||
if strings.Contains(got, "rutracker-topic") {
|
||||
t.Errorf("заглушка-dn просочилась в контекст как имя: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// Реальная рутрекер-ссылка без текста: download.Context = релиз-заголовок из
|
||||
// dn (раскодирован) + домен трекера; namer получает пустой контекст и dn-hint.
|
||||
func TestIngestRealRutrackerMagnetOnly(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%5B2026%2C%20%D0%A1%D0%A8%D0%90%2C%20WEB-DL%201080p%5D"
|
||||
fs := &fakeStore{}
|
||||
nm := &fakeNamer{name: "Бухта вдов (2026)"}
|
||||
if _, err := newServiceWithNamer(fs, &fakeQbt{}, nm).Ingest(context.Background(),
|
||||
Request{Source: raw}); err != nil {
|
||||
t.Fatalf("Ingest: %v", err)
|
||||
}
|
||||
if nm.gotContext != "" {
|
||||
t.Errorf("namer получил не сырой контекст: %q", nm.gotContext)
|
||||
}
|
||||
ctx := fs.created[0].Context
|
||||
if !strings.Contains(ctx, "Widow's Bay") || !strings.Contains(ctx, "Трекер: t-ru.org") {
|
||||
t.Errorf("download.Context не обогащён: %q", ctx)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIngestQbitErrorMarksFailed(t *testing.T) {
|
||||
fs := &fakeStore{}
|
||||
fq := &fakeQbt{err: errors.New("connection refused")}
|
||||
|
||||
+138
-3
@@ -11,6 +11,8 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
@@ -20,6 +22,9 @@ type Info struct {
|
||||
Infohashes []string // все хеши ссылки (гибридный magnet несёт btih и btmh); v1 раньше v2
|
||||
DisplayName string // dn — человекочитаемое имя, если задано
|
||||
Trackers []string // tr — трекеры
|
||||
Sources []string // xs — exact source (ссылки/источники)
|
||||
Keywords []string // kt — ключевые слова
|
||||
ExactLength int64 // xl — размер в байтах; 0, если не задан/некорректен
|
||||
}
|
||||
|
||||
// ErrNotMagnet возвращается, если строка не является magnet-ссылкой.
|
||||
@@ -29,9 +34,8 @@ var ErrNotMagnet = errors.New("not a magnet link")
|
||||
// 32-символьный base32) и btmh (v2: sha256-multihash). При нескольких xt
|
||||
// предпочитается v1.
|
||||
func Parse(raw string) (Info, error) {
|
||||
raw = strings.TrimSpace(raw)
|
||||
u, err := url.Parse(raw)
|
||||
if err != nil || !strings.EqualFold(u.Scheme, "magnet") {
|
||||
u, ok := magnetURL(strings.TrimSpace(raw))
|
||||
if !ok {
|
||||
return Info{}, ErrNotMagnet
|
||||
}
|
||||
vals := u.Query()
|
||||
@@ -60,14 +64,41 @@ func Parse(raw string) (Info, error) {
|
||||
return Info{}, fmt.Errorf("magnet without a usable infohash (xt)")
|
||||
}
|
||||
|
||||
var xl int64
|
||||
if s := strings.TrimSpace(vals.Get("xl")); s != "" {
|
||||
if n, err := strconv.ParseInt(s, 10, 64); err == nil && n > 0 {
|
||||
xl = n
|
||||
}
|
||||
}
|
||||
|
||||
return Info{
|
||||
Infohash: hashes[0],
|
||||
Infohashes: hashes,
|
||||
DisplayName: vals.Get("dn"),
|
||||
Trackers: vals["tr"],
|
||||
Sources: vals["xs"],
|
||||
Keywords: vals["kt"],
|
||||
ExactLength: xl,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// magnetURL разбирает строку как magnet-ссылку. Если ссылку скопировали
|
||||
// целиком в процент-кодировке (magnet%3A%3Fxt%3D…, например вытащили из
|
||||
// другого URL или из HTML-атрибута), пробует раскодировать её один раз и
|
||||
// разобрать снова. Разовый unescape применяется ТОЛЬКО когда прямой разбор не
|
||||
// дал magnet — обычную ссылку (где «+» в query значим) он не затрагивает.
|
||||
func magnetURL(raw string) (*url.URL, bool) {
|
||||
if u, err := url.Parse(raw); err == nil && strings.EqualFold(u.Scheme, "magnet") {
|
||||
return u, true
|
||||
}
|
||||
if dec, err := url.QueryUnescape(raw); err == nil && dec != raw {
|
||||
if u, err := url.Parse(strings.TrimSpace(dec)); err == nil && strings.EqualFold(u.Scheme, "magnet") {
|
||||
return u, true
|
||||
}
|
||||
}
|
||||
return nil, false
|
||||
}
|
||||
|
||||
// normalizeBTIH нормализует v1-infohash (SHA-1, 20 байт) к нижнему hex.
|
||||
func normalizeBTIH(h string) (string, error) {
|
||||
switch len(h) {
|
||||
@@ -90,6 +121,110 @@ func normalizeBTIH(h string) (string, error) {
|
||||
}
|
||||
}
|
||||
|
||||
// topicStubRe распознаёт dn-заглушку вида «rutracker-topic-6514485» —
|
||||
// идентификатор темы трекера, а не название релиза (частый случай для голых
|
||||
// magnet). Такое имя как строку-название в контекст не берём.
|
||||
var topicStubRe = regexp.MustCompile(`(?i)^[\w.-]+-topic-\w+$`)
|
||||
|
||||
// announceLabelRe — служебные поддомены анонса трекера, которые убираем из
|
||||
// домена происхождения ради чистого сигнала (bt.t-ru.org → t-ru.org).
|
||||
var announceLabelRe = regexp.MustCompile(`^(?:bt\d*|www|announce|tracker|open)\.`)
|
||||
|
||||
// Context синтезирует человекочитаемый контекст распознавания из полей самой
|
||||
// ссылки (без сети): название релиза (dn, если это не заглушка-идентификатор),
|
||||
// размер (xl), происхождение по домену трекера/источника (tr/xs) и ключевые
|
||||
// слова (kt). Возвращает строки-факты, склеенные через "\n"; пустую строку —
|
||||
// если пригодных полей нет. Результат предназначен для download.Context
|
||||
// (recognition, веб-UI), но НЕ для вывода отображаемого имени.
|
||||
func (i Info) Context() string {
|
||||
var lines []string
|
||||
|
||||
if name := strings.TrimSpace(i.DisplayName); name != "" && !topicStubRe.MatchString(name) {
|
||||
lines = append(lines, name)
|
||||
}
|
||||
if i.ExactLength > 0 {
|
||||
lines = append(lines, "Размер: "+humanSize(i.ExactLength))
|
||||
}
|
||||
if d := originDomains(append(append([]string{}, i.Trackers...), i.Sources...)); d != "" {
|
||||
lines = append(lines, "Трекер: "+d)
|
||||
}
|
||||
if kw := joinKeywords(i.Keywords); kw != "" {
|
||||
lines = append(lines, "Ключевые слова: "+kw)
|
||||
}
|
||||
|
||||
return strings.Join(lines, "\n")
|
||||
}
|
||||
|
||||
// humanSize форматирует размер в байтах человекочитаемо (≈ 2.1 GiB).
|
||||
func humanSize(n int64) string {
|
||||
const unit = 1024
|
||||
if n < unit {
|
||||
return fmt.Sprintf("%d B", n)
|
||||
}
|
||||
f := float64(n)
|
||||
i := -1
|
||||
for _, u := range []string{"KiB", "MiB", "GiB", "TiB", "PiB"} {
|
||||
f /= unit
|
||||
i++
|
||||
if f < unit {
|
||||
return fmt.Sprintf("≈ %.1f %s", f, u)
|
||||
}
|
||||
}
|
||||
return fmt.Sprintf("≈ %.1f PiB", f)
|
||||
}
|
||||
|
||||
// originDomains извлекает уникальные домены происхождения из tr/xs (только
|
||||
// http(s)/udp-ссылки с хостом), убирая служебные поддомены анонса. Порядок
|
||||
// сохраняется; результат — через ", ".
|
||||
func originDomains(refs []string) string {
|
||||
var out []string
|
||||
seen := map[string]bool{}
|
||||
for _, ref := range refs {
|
||||
u, err := url.Parse(strings.TrimSpace(ref))
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
switch strings.ToLower(u.Scheme) {
|
||||
case "http", "https", "udp":
|
||||
default:
|
||||
continue
|
||||
}
|
||||
host := strings.ToLower(u.Hostname())
|
||||
if host == "" {
|
||||
continue
|
||||
}
|
||||
// Срезаем служебный поддомен анонса, но лишь пока остаётся хотя бы две
|
||||
// метки — иначе для хоста вида «tracker.org» получили бы голый TLD.
|
||||
if s := announceLabelRe.ReplaceAllString(host, ""); strings.Contains(s, ".") {
|
||||
host = s
|
||||
}
|
||||
if seen[host] {
|
||||
continue
|
||||
}
|
||||
seen[host] = true
|
||||
out = append(out, host)
|
||||
}
|
||||
return strings.Join(out, ", ")
|
||||
}
|
||||
|
||||
// joinKeywords нормализует kt: отдельные ключевые слова без пустых и дублей.
|
||||
// В magnet kt разделяются «+», но url.Query() декодирует «+» в пробел ещё при
|
||||
// разборе, поэтому режем по пробельным символам (strings.Fields).
|
||||
func joinKeywords(kt []string) string {
|
||||
var out []string
|
||||
seen := map[string]bool{}
|
||||
for _, v := range kt {
|
||||
for kw := range strings.FieldsSeq(v) {
|
||||
if seen[kw] {
|
||||
continue
|
||||
}
|
||||
seen[kw] = true
|
||||
out = append(out, kw)
|
||||
}
|
||||
}
|
||||
return strings.Join(out, ", ")
|
||||
}
|
||||
|
||||
// normalizeBTMH нормализует v2-infohash. Multihash sha256 имеет вид
|
||||
// 1220<64-hex>; возвращаем сами 64-hex (так его отдаёт qBittorrent в
|
||||
// infohash_v2).
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
package magnet
|
||||
|
||||
import "testing"
|
||||
import (
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestParse(t *testing.T) {
|
||||
tests := []struct {
|
||||
@@ -79,6 +83,154 @@ func TestParseErrors(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
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"
|
||||
|
||||
Reference in New Issue
Block a user