Files
jellybit/internal/magnet/magnet.go
T
avandClaude Opus 4.8 e9ec26d09a 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>
2026-07-07 20:38:01 +03:00

241 lines
8.9 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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
}