// 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) 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: meta.BestName(), Files: fs, TotalLength: total, Trackers: mi.UpvertedAnnounceList().DistinctValues(), Comment: mi.Comment, }, nil } // 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 if name := strings.TrimSpace(i.DisplayName); name != "" { lines = append(lines, name) } 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), " ") }