Files
jellybit/internal/magnet/magnet.go
T
avandClaude Opus 4.8 b8017d65eb Приём/UI: пачка фиксов границ и парсинга (F7–F10, N2)
Пять независимых bugfix'ов из ревью приёма (docs/backlog/review-f7-f10-ingest-ui-fixes.md):

- F7: oversized .torrent через веб отдавал 500. Введён sentinel
  ingest.ErrTorrentTooLarge, classifyErr транслирует его в 400.
- F8: гонка fast-path attach с cancel. Пред-рид
  FindReingestBlockingByInfohash больше не короткозамыкает активную
  запись — авторитетное дедуп-решение принимает CreateDownloadIfNoActive
  под BEGIN IMMEDIATE; короткозамыкание оставлено только для терминальных
  desync-записей (target_missing/orphaned). F6-апгрейд сохранён.
- F9: magnet — регистронезависимый URN-префикс xt (RFC 2141);
  tgbot.ParseMessage срезает хвостовую пунктуацию, приклеенную жадным
  matchем.
- F10: cap контекста до 16 KiB в ingest.Ingest (единственное место
  слияния — покрывает все транспорты), рунобезопасная обрезка + маркер.
- N2: httpapi.shorten режет по рунам, не байтам — кириллица не рвётся в
  U+FFFD.

Добавлены юнит-тесты на каждое исправленное поведение.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-17 20:35:14 +03:00

254 lines
9.5 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")
// URN-префиксы xt-топиков. По RFC 2141 namespace-идентификатор URN
// регистронезависим, поэтому сверяем их EqualFold (magnet:?xt=URN:BTIH:… —
// валиден). Сам хеш нормализует по регистру normalizeBTIH/normalizeBTMH.
const (
prefixBTIH = "urn:btih:"
prefixBTMH = "urn:btmh:"
)
// hasURNPrefix сообщает, начинается ли xt с URN-префикса регистронезависимо.
func hasURNPrefix(xt, prefix string) bool {
return len(xt) >= len(prefix) && strings.EqualFold(xt[:len(prefix)], prefix)
}
// 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 hasURNPrefix(xt, prefixBTIH):
if h, err := normalizeBTIH(xt[len(prefixBTIH):]); err == nil && v1 == "" {
v1 = h
}
case hasURNPrefix(xt, prefixBTMH):
if h, err := normalizeBTMH(xt[len(prefixBTMH):]); 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
}