## 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/…`); ошибки транспорта (`*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).