Files
jellybit/internal/torrent/torrent.go
T
av d081ef1d30 ingest: закрыты мелочи приёма — вырожденное имя, контракт Result, корреляция add
- имя раздачи нормализуется на границе разбора: вырожденное `-`
  (metainfo.NoName) даёт пустое имя, пробельное схлопывается — сентинел больше
  не доходит ни до контекста распознавания, ни до source_ref, ни до подсказки
  вывода имени
- контракт «на любом пути ошибки приёма результат нулевой» объявлен в ingest и
  удерживается структурно; три транспорта перестали обещать идентификатор,
  которого нет, и коррелируют отказ по request_id
- scoped-логгер загрузки ставится до вызова внешнего сервиса в семи командах
  воркера — записи об отказе qBittorrent и метабаз получили download_id
  и infohash; граница разбора bencode записана в docs/research
2026-08-06 18:20:12 +03:00

243 lines
10 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 torrent разбирает `.torrent`-файлы (bencode): извлекает инфохэш(и) и
// метаданные раздачи для приёма загрузки байтами.
//
// Инфохэши нормализуются к нижнему hex — в этом же виде их отдаёт qBittorrent
// (поля hash/infohash_v1/infohash_v2), что позволяет сопоставлять задачи с
// раздачами при поллинге. v1-хеш берётся как SHA1 ИСХОДНЫХ байтов info-словаря
// (библиотека хранит их и не переэнкодит), иначе хеш разошёлся бы с qBittorrent.
package torrent
import (
"bytes"
"errors"
"fmt"
"net/url"
"path"
"regexp"
"strings"
"github.com/anacrolix/torrent/metainfo"
infohashv2 "github.com/anacrolix/torrent/types/infohash-v2"
)
// ErrNotTorrent возвращается, если байты не разбираются как `.torrent`.
var ErrNotTorrent = errors.New("not a torrent file")
// File — один файл раздачи.
type File struct {
Path string // относительный путь ("dir/file.mkv")
Length int64 // размер в байтах
}
// Info — разобранный `.torrent`.
type Info struct {
Infohash string // первичный хеш: v1 приоритетно (нижний hex, 40 для v1, 64 для v2)
Infohashes []string // все хеши файла (гибрид несёт v1 и v2); v1 раньше v2
DisplayName string // имя раздачи (info.name), нормализованное displayName; пусто, если имени нет
Files []File // файлы раздачи (для одиночного — один элемент)
TotalLength int64 // суммарный размер
Trackers []string // announce + announce-list (плоско, без дублей)
Comment string // comment, если задан
}
// Parse разбирает байты `.torrent`. Извлекает те инфохэши, которые файл реально
// несёт: v1 (для v1/гибрида) и v2 (для v2/гибрида, BEP52) — каждый в том виде,
// в каком его сообщает qBittorrent. Байты info не переэнкодятся.
func Parse(data []byte) (Info, error) {
mi, err := metainfo.Load(bytes.NewReader(data))
if err != nil {
return Info{}, fmt.Errorf("%w: %v", ErrNotTorrent, err)
}
meta, err := mi.UnmarshalInfo()
if err != nil {
return Info{}, fmt.Errorf("%w: info: %v", ErrNotTorrent, err)
}
var hashes []string
if meta.HasV1() {
hashes = append(hashes, strings.ToLower(mi.HashInfoBytes().HexString()))
}
if meta.HasV2() {
v2 := infohashv2.HashBytes(mi.InfoBytes)
hashes = append(hashes, strings.ToLower(v2.HexString()))
}
if len(hashes) == 0 {
return Info{}, fmt.Errorf("%w: no usable infohash", ErrNotTorrent)
}
fs, total := files(&meta)
return Info{
Infohash: hashes[0],
Infohashes: hashes,
DisplayName: displayName(meta.BestName()),
Files: fs,
TotalLength: total,
Trackers: mi.UpvertedAnnounceList().DistinctValues(),
Comment: mi.Comment,
}, nil
}
// displayName нормализует имя раздачи на границе разбора, чтобы ниже по потоку
// (строка названия контекста, source_ref приёма, подсказка вывода отображаемого
// имени) вырожденных значений не встречалось.
//
// Пробельное схлопываем тем же oneLine, что и комментарий: имя — недоверенный
// вход, а контекст распознавания читается построчно, и имя с переводом строки
// добавило бы в него строку, выглядящую как синтезированный нами факт. Защитой
// от инъекции в промпт это НЕ является — пользовательский текст контекста
// многострочен по замыслу; правило лишь снимает несогласованность между именем
// и комментарием.
//
// Затем отбрасываем `metainfo.NoName` — раздача объявляет это значение сама,
// полем name, по конвенции «имени нет» (библиотека экспортирует константу
// именно затем, чтобы на неё ссылались). Раздача без поля name даёт пустое имя
// и так.
//
// Порядок здесь существенный, и обратный — дефект: имя `" - "` сравнение с
// сентинелом не проходит, а схлопывание превращает его ровно в сентинел, и тот
// уезжает вниз по потоку всеми тремя путями.
func displayName(name string) string {
name = oneLine(name)
if name == metainfo.NoName {
return ""
}
return name
}
// files собирает файлы раздачи и суммарный размер из info (одиночный файл или
// дерево). Байты .torrent — недоверенный вход: на кривых/вырожденных раздачах
// библиотека (UpvertedFiles/FileTree) способна паниковать (напр. делением на
// ноль при piece length 0). Дерево файлов — лишь контекст-сигнал, не критично,
// поэтому паники гасим: инфохэш и имя уже извлечены, приём не валим.
func files(meta *metainfo.Info) (out []File, total int64) {
defer func() { _ = recover() }()
for _, fi := range meta.UpvertedFiles() {
seg := fi.Path
if len(seg) == 0 {
seg = fi.PathUtf8
}
p := path.Join(seg...)
if p == "" {
p = meta.BestName()
}
out = append(out, File{Path: p, Length: fi.Length})
total += fi.Length
}
return out, total
}
// announceLabelRe — служебные поддомены анонса трекера, которые убираем из
// домена происхождения ради чистого сигнала (bt.t-ru.org → t-ru.org).
var announceLabelRe = regexp.MustCompile(`^(?:bt\d*|www|announce|tracker|open)\.`)
// Context синтезирует человекочитаемый контекст распознавания из полей файла
// (без сети): имя раздачи, суммарный размер, сигнал по дереву файлов, домен
// трекера, комментарий. Строки-факты через "\n"; пустая строка — если полей
// нет. Предназначен для download.Context (recognition, веб-UI), но НЕ для
// вывода отображаемого имени.
func (i Info) Context() string {
var lines []string
// Имя уже нормализовано разборщиком (displayName): вырожденное значение и
// разделители строк сюда не доходят. Проверка на пустоту остаётся — имени
// у раздачи может не быть вовсе, и тогда строки названия просто нет.
if i.DisplayName != "" {
lines = append(lines, i.DisplayName)
}
if i.TotalLength > 0 {
lines = append(lines, "Размер: "+humanSize(i.TotalLength))
}
if s := filesSignal(i.Files); s != "" {
lines = append(lines, s)
}
if d := originDomains(i.Trackers); d != "" {
lines = append(lines, "Трекер: "+d)
}
if c := strings.TrimSpace(i.Comment); c != "" {
lines = append(lines, "Комментарий: "+oneLine(c))
}
return strings.Join(lines, "\n")
}
// filesSignal — сигнал по дереву файлов для recognition: число файлов и
// характерные расширения (много видеофайлов → вероятно сериал). Для одиночного
// файла ничего не добавляет (имя раздачи уже есть строкой названия).
func filesSignal(files []File) string {
if len(files) <= 1 {
return ""
}
seen := map[string]bool{}
var exts []string
for _, f := range files {
ext := strings.ToLower(strings.TrimPrefix(path.Ext(f.Path), "."))
if ext == "" || seen[ext] {
continue
}
seen[ext] = true
if len(exts) < 5 {
exts = append(exts, ext)
}
}
s := fmt.Sprintf("Файлов: %d", len(files))
if len(exts) > 0 {
s += " (" + strings.Join(exts, ", ") + ")"
}
return s
}
// 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 извлекает уникальные домены происхождения из URL трекеров
// (http(s)/udp с хостом), убирая служебные поддомены анонса. Порядок
// сохраняется; результат — через ", ".
func originDomains(trackers []string) string {
var out []string
seen := map[string]bool{}
for _, ref := range trackers {
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
}
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, ", ")
}
// oneLine схлопывает переводы строк в пробелы (комментарий бывает многострочным).
func oneLine(s string) string {
return strings.Join(strings.Fields(s), " ")
}