Приём: добавление загрузки по .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:
av
2026-07-08 10:52:31 +03:00
co-authored by Claude Opus 4.8
parent 90fd8640ed
commit 14d615a7c2
38 changed files with 2519 additions and 100 deletions
+212
View File
@@ -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), " ")
}
+235
View File
@@ -0,0 +1,235 @@
package torrent
import (
"bytes"
"crypto/sha1"
"crypto/sha256"
"encoding/hex"
"strings"
"testing"
"github.com/anacrolix/torrent/bencode"
"github.com/anacrolix/torrent/metainfo"
)
// writeTorrent оборачивает готовые info-байты в metainfo и возвращает байты
// .torrent (announce задаёт трекер). Позволяет строить v2/гибридные фикстуры,
// у которых info собран вручную.
func writeTorrent(t *testing.T, infoBytes []byte) []byte {
t.Helper()
mi := metainfo.MetaInfo{InfoBytes: infoBytes, Announce: "http://t/ann"}
var buf bytes.Buffer
if err := mi.Write(&buf); err != nil {
t.Fatalf("write metainfo: %v", err)
}
return buf.Bytes()
}
// v2-only торрент (meta version 2, без v1-полей) сохраняется под v2-хешем
// (64-hex = SHA256 исходных байтов info) — как его отдаёт qBittorrent.
func TestParseV2OnlyHash(t *testing.T) {
// Без Files/Length/Pieces + meta version 2 → HasV1()=false, HasV2()=true.
info := metainfo.Info{Name: "V2.Only.Release", MetaVersion: 2}
infoBytes, err := bencode.Marshal(info)
if err != nil {
t.Fatalf("marshal: %v", err)
}
sum := sha256.Sum256(infoBytes)
wantV2 := hex.EncodeToString(sum[:])
got, err := Parse(writeTorrent(t, infoBytes))
if err != nil {
t.Fatalf("parse: %v", err)
}
if len(got.Infohashes) != 1 {
t.Fatalf("infohashes = %v, want один v2-хеш", got.Infohashes)
}
if got.Infohash != wantV2 {
t.Errorf("infohash = %q, want v2 %q", got.Infohash, wantV2)
}
if len(got.Infohash) != 64 {
t.Errorf("длина хеша = %d, want 64 (v2)", len(got.Infohash))
}
}
// Гибрид (v1 + v2): оба хеша, v1 (40-hex) первым, затем v2 (64-hex).
func TestParseHybridHashes(t *testing.T) {
info := metainfo.Info{
Name: "Hybrid", Length: 100, PieceLength: 1024,
Pieces: make([]byte, 20), MetaVersion: 2,
}
infoBytes, err := bencode.Marshal(info)
if err != nil {
t.Fatalf("marshal: %v", err)
}
sha1sum := sha1.Sum(infoBytes)
sha256sum := sha256.Sum256(infoBytes)
wantV1 := hex.EncodeToString(sha1sum[:])
wantV2 := hex.EncodeToString(sha256sum[:])
got, err := Parse(writeTorrent(t, infoBytes))
if err != nil {
t.Fatalf("parse: %v", err)
}
if len(got.Infohashes) != 2 {
t.Fatalf("infohashes = %v, want [v1, v2]", got.Infohashes)
}
if got.Infohashes[0] != wantV1 || got.Infohashes[1] != wantV2 {
t.Errorf("infohashes = %v, want [%s, %s]", got.Infohashes, wantV1, wantV2)
}
}
// build собирает валидные байты `.torrent` из info и метаданных, возвращая их
// вместе с ожидаемым v1-инфохэшем (SHA1 исходных байтов info) — так тест
// проверяет извлечение, не переизобретая хеширование.
func build(t *testing.T, info metainfo.Info, announce string, comment string) (data []byte, wantV1 string) {
t.Helper()
infoBytes, err := bencode.Marshal(info)
if err != nil {
t.Fatalf("marshal info: %v", err)
}
sum := sha1.Sum(infoBytes)
mi := metainfo.MetaInfo{InfoBytes: infoBytes, Announce: announce, Comment: comment}
var buf bytes.Buffer
if err := mi.Write(&buf); err != nil {
t.Fatalf("write metainfo: %v", err)
}
return buf.Bytes(), hex.EncodeToString(sum[:])
}
// v1-piece заглушка (20 байт) — присутствие Pieces делает info v1.
func pieces(n int) []byte { return make([]byte, 20*n) }
func TestParseSingleFile(t *testing.T) {
data, wantV1 := build(t, metainfo.Info{
Name: "Dune.Part.Two.2024.2160p.mkv",
Length: 2100,
PieceLength: 1024,
Pieces: pieces(3),
}, "http://bt.rutracker.org/announce", "")
info, err := Parse(data)
if err != nil {
t.Fatalf("parse: %v", err)
}
if info.Infohash != wantV1 {
t.Errorf("infohash = %q, want %q", info.Infohash, wantV1)
}
if len(info.Infohashes) != 1 || info.Infohashes[0] != wantV1 {
t.Errorf("infohashes = %v, want [%s]", info.Infohashes, wantV1)
}
if info.DisplayName != "Dune.Part.Two.2024.2160p.mkv" {
t.Errorf("name = %q", info.DisplayName)
}
if info.TotalLength != 2100 {
t.Errorf("total = %d, want 2100", info.TotalLength)
}
if len(info.Files) != 1 {
t.Errorf("files = %v, want 1", info.Files)
}
ctx := info.Context()
if !strings.Contains(ctx, "Dune.Part.Two") {
t.Errorf("context без имени: %q", ctx)
}
if !strings.Contains(ctx, "Размер:") {
t.Errorf("context без размера: %q", ctx)
}
if !strings.Contains(ctx, "rutracker.org") {
t.Errorf("context без домена трекера: %q", ctx)
}
}
func TestParseMultiFile(t *testing.T) {
data, _ := build(t, metainfo.Info{
Name: "Fargo.S01",
PieceLength: 1024,
Pieces: pieces(5),
Files: []metainfo.FileInfo{
{Length: 700, Path: []string{"Fargo.S01E01.mkv"}},
{Length: 800, Path: []string{"Fargo.S01E02.mkv"}},
{Length: 5, Path: []string{"subs", "e01.srt"}},
},
}, "udp://tracker.example.com:80", "")
info, err := Parse(data)
if err != nil {
t.Fatalf("parse: %v", err)
}
if len(info.Files) != 3 {
t.Fatalf("files = %d, want 3", len(info.Files))
}
if info.TotalLength != 1505 {
t.Errorf("total = %d, want 1505", info.TotalLength)
}
// путь из сегментов через "/"
if info.Files[2].Path != "subs/e01.srt" {
t.Errorf("path = %q, want subs/e01.srt", info.Files[2].Path)
}
ctx := info.Context()
if !strings.Contains(ctx, "Файлов: 3") {
t.Errorf("нет сигнала файлов: %q", ctx)
}
if !strings.Contains(ctx, "mkv") || !strings.Contains(ctx, "srt") {
t.Errorf("нет расширений в сигнале: %q", ctx)
}
}
func TestParseTrackersDedupAndComment(t *testing.T) {
data, _ := build(t, metainfo.Info{
Name: "X",
Length: 10,
PieceLength: 1024,
Pieces: pieces(1),
}, "http://bt2.t-ru.org/ann", "Многострочный\nкомментарий")
info, err := Parse(data)
if err != nil {
t.Fatalf("parse: %v", err)
}
if len(info.Trackers) == 0 {
t.Fatal("нет трекеров")
}
ctx := info.Context()
// служебный поддомен bt2. срезается
if !strings.Contains(ctx, "t-ru.org") {
t.Errorf("домен не нормализован: %q", ctx)
}
// комментарий схлопнут в одну строку
if !strings.Contains(ctx, "Комментарий: Многострочный комментарий") {
t.Errorf("комментарий: %q", ctx)
}
}
func TestParseInvalidBytes(t *testing.T) {
for _, tc := range []struct {
name string
data []byte
}{
{"empty", nil},
{"garbage", []byte("this is not a torrent")},
{"bencode-not-dict", []byte("i42e")},
} {
t.Run(tc.name, func(t *testing.T) {
if _, err := Parse(tc.data); err == nil {
t.Errorf("ожидалась ошибка для %s", tc.name)
}
})
}
}
func TestContextEmptyWhenNoFields(t *testing.T) {
// info без имени (NoName-сентинел), без трекера/комментария: строк-фактов
// минимум. Проверяем, что Context не паникует и не тянет сеть (чистая
// функция — сетевых вызовов в коде нет по построению).
data, _ := build(t, metainfo.Info{
Name: "-", // metainfo.NoName
Length: 0,
PieceLength: 1024,
Pieces: pieces(1),
}, "", "")
info, err := Parse(data)
if err != nil {
t.Fatalf("parse: %v", err)
}
_ = info.Context() // не паникует; состав строк — по полям
}