// Package magnet разбирает magnet-ссылки: извлекает infohash и метаданные. // // infohash нормализуется к нижнему hex — в этом же виде его отдаёт // qBittorrent (поля hash/infohash_v1/infohash_v2), что позволяет // сопоставлять задачи с торрентами при поллинге. package magnet import ( "encoding/base32" "encoding/hex" "errors" "fmt" "net/url" "regexp" "strconv" "strings" ) // Info — разобранная magnet-ссылка. type Info struct { Infohash string // первичный хеш: v1 приоритетно (нижний hex, 40 для v1, 64 для v2) 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-ссылкой. var ErrNotMagnet = errors.New("not a magnet link") // Parse разбирает magnet-ссылку. Поддерживаются btih (v1: 40-hex или // 32-символьный base32) и btmh (v2: sha256-multihash). При нескольких xt // предпочитается v1. func Parse(raw string) (Info, error) { u, ok := magnetURL(strings.TrimSpace(raw)) if !ok { return Info{}, ErrNotMagnet } vals := u.Query() var v1, v2 string for _, xt := range vals["xt"] { switch { case strings.HasPrefix(xt, "urn:btih:"): if h, err := normalizeBTIH(strings.TrimPrefix(xt, "urn:btih:")); err == nil && v1 == "" { v1 = h } case strings.HasPrefix(xt, "urn:btmh:"): if h, err := normalizeBTMH(strings.TrimPrefix(xt, "urn:btmh:")); err == nil && v2 == "" { v2 = h } } } var hashes []string for _, h := range []string{v1, v2} { if h != "" { hashes = append(hashes, h) } } if len(hashes) == 0 { 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) { case 40: // hex if _, err := hex.DecodeString(h); err != nil { return "", fmt.Errorf("btih hex: %w", err) } return strings.ToLower(h), nil case 32: // base32 (RFC 4648, без паддинга) b, err := base32.StdEncoding.DecodeString(strings.ToUpper(h)) if err != nil { return "", fmt.Errorf("btih base32: %w", err) } if len(b) != 20 { return "", fmt.Errorf("btih base32: got %d bytes, want 20", len(b)) } return hex.EncodeToString(b), nil default: return "", fmt.Errorf("btih: unexpected length %d", len(h)) } } // 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). func normalizeBTMH(h string) (string, error) { h = strings.ToLower(h) if _, err := hex.DecodeString(h); err != nil { return "", fmt.Errorf("btmh hex: %w", err) } if len(h) != 68 || !strings.HasPrefix(h, "1220") { return "", fmt.Errorf("btmh: unsupported multihash %q", h) } return h[4:], nil }