Синтезируем контекст из полей самой 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>
241 lines
8.9 KiB
Go
241 lines
8.9 KiB
Go
// 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
|
||
}
|