// Package magnet разбирает magnet-ссылки: извлекает infohash и метаданные. // // infohash нормализуется к нижнему hex — в этом же виде его отдаёт // qBittorrent (поля hash/infohash_v1/infohash_v2), что позволяет // сопоставлять задачи с торрентами при поллинге. package magnet import ( "encoding/base32" "encoding/hex" "errors" "fmt" "net/url" "strings" ) // Info — разобранная magnet-ссылка. type Info struct { Infohash string // нормализованный нижний hex (40 для v1, 64 для v2) DisplayName string // dn — человекочитаемое имя, если задано Trackers []string // tr — трекеры } // 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) { raw = strings.TrimSpace(raw) u, err := url.Parse(raw) if err != nil || !strings.EqualFold(u.Scheme, "magnet") { 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 } } } infohash := v1 if infohash == "" { infohash = v2 } if infohash == "" { return Info{}, fmt.Errorf("magnet without a usable infohash (xt)") } return Info{ Infohash: infohash, DisplayName: vals.Get("dn"), Trackers: vals["tr"], }, nil } // 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)) } } // 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 }