Приём: добавление загрузки по .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>
This commit is contained in:
@@ -0,0 +1,212 @@
|
||||
// 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), " ")
|
||||
}
|
||||
Reference in New Issue
Block a user