Files
jellybit/internal/torrent/torrent.go
T
avandClaude Opus 4.8 14d615a7c2 Приём: добавление загрузки по .torrent-файлу
Принимаем .torrent как загруженные байты — через файл-пикер в веб-форме и
Telegram-документ, наряду с magnet. Файл несёт полные метаданные: работает
там, где magnet не резолвится (закрытые трекеры, без DHT), и даёт максимум
контекста для распознавания без сети.

- internal/torrent: парсер поверх anacrolix/torrent/metainfo — инфохэш(и)
  (v1 SHA1 исходных байтов info; v2 BEP52 при наличии) + Context() из имени,
  дерева файлов, размера, трекеров. Извлечение файлов панико-безопасно
  (недоверенный вход).
- Персистентность байтов: таблица-спутник download_torrent (миграция 0009);
  пишется в транзакции создания загрузки, только на ветке создания (не при
  дедупе). Байты живут весь срок строки — нужны для повторного добавления
  при retry.
- ingest: Request.TorrentData/TorrentName, диспетч парсера; source_ref —
  человекочитаемый референс (имя раздачи/файла), не адрес добавления.
- worker: общий sourceAddParts ветвит по source_type в ОБОИХ add-путях —
  processCatched и Retry (torrent добавляется файлом, не magnet-хешем).
- Транспорты: multipart-форма с файл-пикером (деградация без JS) и приём
  Telegram-документа (скачивание с редактированием токена из ошибок — секрет
  не в логи; обработка до ветки pending/текста).

Разработка по OpenSpec (SDD): change torrent-file-ingest, два чекпоинта ревью
(дизайн до кода, код до архива) сабагентами; дельты влиты в спеки, change
архивирован. Ручная проверка на живом qBittorrent (7.3) — за деплоем.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-08 10:52:31 +03:00

213 lines
7.8 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)
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), " ")
}