Приём: добавление загрузки по .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
@@ -0,0 +1,262 @@
## Context
Приём — быстрый (`fast-catch-ingest`): `Ingest` синхронно парсит источник,
синтезирует контекст, атомарно дедуплицирует и пишет `download` в `catched`,
сразу отвечая транспорту. Вывод имени (LLM) и `qbt.Add` делает воркер на шаге
`processCatched` (`internal/worker/worker.go:355-369`), беря источник из
`d.SourceRef` и добавляя его через `qbt.AddRequest.URLs`.
Готовое к переиспользованию:
- `qbt.AddRequest.Torrents [][]byte` + `qbt.Client.Add` уже шлют `.torrent`
байтами (multipart `torrents=fileN.torrent`, `internal/qbt/qbt.go:203`). Клиент
не трогаем.
- `store.SourceTorrent` уже объявлен (`internal/store/download.go:137`), но не
используется.
- `CreateDownloadIfNoActive(ctx, d, hashes)` (`:249`) атомарно держит инвариант
«≤1 активная загрузка на infohash» и пишет `download` + `download_infohash`.
Требует инфохэши **до** вызова.
Ключевое отличие magnet от файла: у magnet `SourceRef` (строка ссылки) — и
идентификатор, и то, что добавляется в qBittorrent. У `.torrent` «источник» —
**байты**, и без них qBittorrent на закрытых трекерах/без DHT метаданные не
получит. Значит, байты нельзя схлопнуть в magnet — их надо сохранить и
скормить воркеру как есть.
## Goals / Non-Goals
**Goals:**
- Принять `.torrent` как загруженные байты (Telegram-документ, файл-пикер
веба), извлечь из файла максимум контекста без сети, добавить в qBittorrent
байтами (метаданные не теряем).
- Переиспользовать существующий быстрый путь и инвариант дедупа: инфохэш из
файла → тот же `CreateDownloadIfNoActive`.
- Ветвление по типу источника локализовать (ingest — парс; worker — add), не
расползаясь по коду.
**Non-Goals:**
- Торрент по http(s)-ссылке (фетч, SSRF-гард) — отдельная задача.
- Изменение `naming.DeriveName` / recognition — только богатеет вход
(`download.Context`).
- Изменение схемы `download`, magnet-пути, клиента qBittorrent.
## Decisions
### Р1. Парсер `internal/torrent` поверх `anacrolix/torrent/metainfo`
Новый пакет по образцу `internal/magnet`: `Parse(data []byte) (Info, error)`.
API (проверено сборкой): `metainfo.Load(r)` хранит сырые `InfoBytes`;
`mi.HashInfoBytes()` даёт **v1**-инфохэш (SHA1 **исходных** байтов info-словаря,
без переэнкода — критично: любое переупорядочивание ключей/переформат целых
поменяло бы хеш и сломало сопоставление с qBittorrent и дедуп);
`mi.UnmarshalInfo()``info.Name`, `info.Files`, `info.TotalLength()`;
трекеры — из `mi.Announce` + плоский `mi.AnnounceList`; `mi.Comment`.
**Инфохэши — как их сообщает qBittorrent, не «всегда v1».** Формулировка
«всегда считаем v1» неверна для v2-only торрентов (BEP52): у них v1-хеша нет, а
qBittorrent отдаёт раздачу под 64-hex `infohash_v2`. Поэтому парсер извлекает те
хеши, которые файл реально несёт: для v1/гибрида — v1 (`HashInfoBytes`), для
v2/гибрида — дополнительно v2 (SHA256 info при `meta version 2`). Для чистого
v2-only мы храним именно v2, иначе `torrentFor` (`worker.go:512`) не совпадёт и
задача повиснет как «нет в qBittorrent». Точное имя v2-API anacrolix
(`HashInfoBytes` — только v1; v2 — через v2-тип библиотеки) **уточняется при
apply** и проверяется тестом на v2-only/гибридном файле; современные раздачи в
основном v1, но абсолют «SHALL v1» из спеки убран.
`Info` (аналогично `magnet.Info`):
```
type Info struct {
Infohash string // v1 приоритетно (нижний hex)
Infohashes []string // v1 (+ v2, если гибрид) — как в magnet.Info
DisplayName string // info.name
Files []File // {Path, Length}; для одиночного файла — один элемент
TotalLength int64 // сумма длин
Trackers []string // announce + announce-list (flatten)
Comment string // comment (опц.)
}
```
`func (i Info) Context() string` синтезирует строки-факты (через `\n`, тем же
стилем, что `magnet.Context()`): имя раздачи; `Размер:` человекочитаемо из
`TotalLength`; `Файлы:` — число и характерные расширения/крупнейший файл (даёт
recognition сигнал «фильм vs сериал» по числу видеофайлов); `Трекер:` — домен
происхождения; комментарий, если содержателен. Без сети.
_Совместное с magnet:_ хелперы `humanSize`/`originDomains` в `internal/magnet`
неэкспортируемы. Дублируем их тривиальные версии в `internal/torrent` (пакеты
независимы, дрейф маловероятен для «байты→GiB» и «url→домен»), либо — если при
apply дублирование ощущается лишним — выносим в маленький общий
`internal/mediactx`. Решение — при apply; на спеку не влияет.
_Альтернатива (отклонена пользователем):_ свой bencode-декодер без зависимости.
Выбрана проверенная библиотека ради корректного извлечения сырых info-байтов.
### Р2. Персистентность байтов — таблица-спутник `download_torrent`
Байты нужны воркеру на шаге добавления, а между приёмом и добавлением загрузка
живёт в `catched`. Храним байты в БД (атомарно с загрузкой, чистятся вместе с
ней, не требуют управления файлами на диске — в духе «минимум компонентов»):
```
CREATE TABLE download_torrent (
download_id TEXT PRIMARY KEY
REFERENCES download(id) ON DELETE CASCADE,
data BLOB NOT NULL
);
```
Отдельная таблица, а не колонка на `download`: выборки списка/детали читают
`download` целиком (`SELECT *`), а блоб (десятки КБ, изредка единицы МБ) там ни
к чему — тянули бы его на каждый рендер списка.
**Запись байтов — внутри транзакции создания (см. Р5, изменение API).** Тх
`CreateDownloadIfNoActive` внутренняя и коммитится сама (`download.go:256-304`) —
снаружи к ней не подцепиться. Чтобы байты писались атомарно с заведением
загрузки (иначе — окно краха: `catched`-строка без байтов, воркер её никогда не
добавит), метод получает байты параметром и пишет `download_torrent` **на ветке
создания**; при дедупе (загрузка не создана) байты не пишутся.
**Жизненный цикл блоба — байты живут весь срок строки загрузки.** `ON DELETE
CASCADE` корректна (FK включены, `store.go:40`), но сегодня `DELETE FROM
download` в коде нет — загрузки не удаляются, уходят в терминальные состояния.
Значит CASCADE — лишь страховка на будущий delete-путь, а не активная уборка.
Удалять байты после промоушена `catched → downloading` мы **не** будем: они
нужны `Retry` (Р4) — повторное добавление упавшего/застрявшего торрента идёт тем
же файлом. Плата — байты копятся (ограничены размерным лимитом × число
torrent-загрузок); это осознанный трейд-офф ради ретраев, не баг.
_Альтернатива:_ файл на data-томе, `SourceRef` = путь. Отклонено — добавляет
управление файлами (создание, уборка, рассинхрон с БД) ради экономии, которой
для торрент-файлов нет.
`.torrent` на диске бывают крупными (много файлов → много piece-хешей).
Ограничиваем размер входа на границе транспорта (константа, дефолт напр. 8 MiB
с запасом) — защита от разбухания БД и oversized-загрузок.
### Р3. `SourceRef` у torrent — человекочитаемая ссылка, не адрес добавления
Для magnet `SourceRef` двойного назначения (ref + то, что добавляют). Для
torrent «то, что добавляют» — байты (Р2), поэтому `SourceRef` несёт только
**референс для человека/логов**: имя раздачи (`info.name`) либо имя файла.
Никакой код не должен добавлять torrent-загрузку по `SourceRef` как URL — это
гарантирует диспетч воркера по `SourceType` (Р4). Инфохэш-референс у загрузки и
так есть в `download_infohash`.
_Почему не синтезировать magnet в `SourceRef`:_ строка вида
`magnet:?xt=urn:btih:…` выглядела бы «добавляемой», провоцируя код слать её в
`URLs` и терять метаданные на закрытых трекерах. Явный не-URL `SourceRef` +
диспетч по типу убирают ловушку.
### Р4. Воркер: диспетч по `SourceType` во **всех** add-путях (общий хелпер)
В воркере **два** места добавляют источник в qBittorrent, и оба сегодня
предполагают magnet-URL — диспетч нужен в обоих:
1. `processCatched` (`worker.go:359-365`) — добавление пойманной загрузки.
2. `Retry` (`worker.go:677-691`) — повторное добавление упавшей/застрявшей.
Важно: сейчас `Retry` гейтит add на `d.SourceType == store.SourceMagnet`, то
есть для torrent он **активирует загрузку в `downloading`, не добавив
раздачу** → задача повиснет как «нет в qBittorrent» (нашёл ревью, BLOCKER 1).
Особенно опасно, т.к. `errCodeQbitAdd`-фейл не воскрешается сверкой — `Retry`
единственный выход.
Выносим общий хелпер `addSource(ctx, d, rename) error`, ветвящийся по
`d.SourceType`, и зовём его из обоих мест:
- `magnet`/`url` — hint из `magnet.Parse(d.SourceRef)`,
`qbt.Add(AddRequest{URLs: []string{d.SourceRef}, …})`.
- `torrent` — байты из `store.GetTorrentData(id)`; hint из `info.DisplayName`
(парс тех же байтов `torrent.Parse`); `qbt.Add(AddRequest{Torrents:
[][]byte{data}, Category, SavePath, Rename})`.
Ре-валидация перехода под блокировкой, обработка сбоя `add``processCatched`
остаётся `catched`; в `Retry` — откат активации, `worker.go:683-689`),
предохранитель `catch_timeout` — прежние, не дублируются. Медленные вызовы
(namer, `qbt.Add`) — по-прежнему вне блокировки сериализации. Байты для `Retry`
доступны, потому что мы их не удаляем после промоушена (Р2).
### Р5. `ingest.Request` — путь для байтов; изменение API store ради атомарности
`Request` расширяется полем для байтов (`TorrentData []byte`, опц.).
Приоритет в `Ingest`: если `TorrentData` непуст — `torrent.Parse(data)`,
`SourceType = SourceTorrent`, `SourceRef` = имя (Р3), контекст =
`mergeContext(req.Context, tinfo.Context())`. Иначе — текущий magnet-путь без
изменений. Инфохэши, дедуп, атомарность — общий код `CreateDownloadIfNoActive`.
**Изменение API `CreateDownloadIfNoActive` (нашёл ревью, BLOCKER 2).** Чтобы
байты писались в той же транзакции (Р2), метод получает их аргументом (напр.
`CreateDownloadIfNoActive(ctx, d, hashes, torrentBlob []byte)`) и на ветке
создания делает `INSERT INTO download_torrent`; на ветке дедупа — не пишет.
Это тянет правку интерфейсов `Store` в `internal/ingest` и `internal/worker` и
существующих вызовов (magnet-приём и `discover.go` передают `nil`). Плюс метод
чтения `GetTorrentData(ctx, downloadID) ([]byte, error)` в те же интерфейсы для
воркера (Р4).
Форма транспортного ввода:
- **HTTP/web** (`handleUIAdd`): `multipart/form-data`; при наличии файла
`torrent` читаем байты (через `http.MaxBytesReader`/лимит Р2) → `Request{
TorrentData, Context}`; иначе — `Request{Source, Context}` как сейчас. Handler
сейчас — простой PRG `303` (не htmx, `httpapi.go:397-411`); так и остаётся,
деградация без JS сохраняется.
- **Telegram** (`handleMessage`): распознавание `.torrent`-документа надо
вставить **до** ветки pending-hint и текстовой ветки (`bot.go:124-146`):
у документа `m.Text` пуст (текст — в `m.Caption`), иначе pending-hint
«съест» документ как пустую подсказку. При `m.Document` с mime
`application/x-bittorrent` или расширением `.torrent` — скачиваем байты через
Bot API (`GetFileDirectURL`, есть в v5.5.1, `bot.go:269`, + HTTP GET, лимит
Р2) → `Request{TorrentData, Context: m.Caption}`. Скачивание с серверов
Telegram (доверенный источник, не произвольный SSRF); латентность приёмлема
для бот-взаимодействия. Документ не-`.torrent` → прежний ответ-подсказка.
`AllowedUpdates` менять не нужно — документ приходит внутри `message`.
## Risks / Trade-offs
- [Блоб в SQLite раздувает БД] → Лимит размера на границе (Р2), отдельная
таблица (не тянется в выборках), CASCADE-уборка вместе с загрузкой. Торрент-
файлы малы; крупные (много файлов) отсекаются лимитом.
- [Зависимость `anacrolix/torrent` тяжёлая] → Импортируем только подпакет
`metainfo`; сетевой/DHT-код не тянется в бинарь (только в go.sum как граф
требований). Осознанный выбор ради корректного info-хеша.
- [`SourceRef`-не-URL ломает код, ждущий magnet] → Оба add-пути воркера
(`processCatched` и `Retry`) диспетчат по `SourceType` через общий хелпер
(Р4); `magnet.Parse(SourceRef)` зовётся только в magnet-ветке. Дисплейные
читатели `SourceRef` (`httpapi.go:653`, `download.go:99`, `review.go:104`,
`tgbot/render.go:217`) толерантны к не-URL-строке — регресса нет.
- [v2-only/гибрид torrent] → v1-хеш обязателен, v2 — best-effort; дозапись
недостающего v2 из qBittorrent уже покрыта требованием «Множество инфохэшей».
- [Telegram-документ большого размера/не-торрент] → Проверка mime/расширения +
лимит размера до скачивания; аккуратный ответ на не-`.torrent`.
- [Дубль контекста: имя из файла и подпись пользователя совпадают] →
`mergeContext` ставит текст пользователя первым; recognition толерантен к
повтору (известный трейд-офф, как в magnet-синтезе).
- [Утечка токена бота в логи при скачивании из Telegram] → Прямой URL файла
Telegram содержит токен (`…/file/bot<TOKEN>/…`); ошибки транспорта
(`*url.Error`) встраивают URL. `downloadFile` пропускает такие ошибки через
`stripURL` (оставляет только первопричину без URL) — токен в логи не попадает
(нашёл code-review; инвариант «секреты не в логи», logging.md).
- [Паника парсера на кривом/вырожденном `.torrent`] → Байты недоверенные;
библиотека (`UpvertedFiles`/`FileTree`) может паниковать (деление на ноль при
`piece length` 0). Извлечение дерева файлов обёрнуто `recover` — это лишь
контекст-сигнал; инфохэш и имя уже извлечены, приём не падает.
## Migration Plan
Аддитивно и обратносовместимо: новая таблица `download_torrent` (goose-миграция,
SQL DDL) + обновление ER-схемы `docs/specs/database.md`; таблица `download` не
меняется. Существующие magnet-загрузки не затронуты (у них строки в
`download_torrent` нет; воркер их не читает — ветка magnet). Откат — ревертом
кода; осиротевшая таблица безвредна (или down-миграция `DROP TABLE`).
## Open Questions
- Точный состав строки `Файлы:` (число + расширения vs крупнейший файл) —
уточняем при apply, на спеку не влияет (спека фиксирует «сигнал по файлам»,
не формулировку).
- Дефолт лимита размера `.torrent` (8 MiB?) и нужен ли он в конфиге — решаем
при apply; на нормативную часть не влияет.
- Дублировать ли `humanSize`/`originDomains` в `internal/torrent` или вынести в
общий хелпер — мелочь реализации (Р1).