## 1. Парсер `internal/torrent` - [x] 1.1 Добавить зависимость `github.com/anacrolix/torrent` (пакет `metainfo`); `task tidy` - [x] 1.2 Пакет `internal/torrent`: `Parse(data []byte) (Info, error)` через `metainfo.Load`/`UnmarshalInfo`; `Info{Infohash, Infohashes, DisplayName, Files, TotalLength, Trackers, Comment}`. Инфохэш v1 — из `HashInfoBytes` (SHA1 исходных байтов info, нижний hex); v2/гибрид — извлечь v2 (64-hex, как `infohash_v2` qBit), для v2-only хранить именно v2. Уточнить точный v2-API anacrolix при apply - [x] 1.3 `func (Info) Context() string` — строки-факты (`\n`): имя раздачи, `Размер:` из `TotalLength`, сигнал по файлам (число/расширения), `Трекер:` домен из announce/announce-list, комментарий (если содержателен). Без сети - [x] 1.4 Хелперы размера/домена: дублировать тривиальные из `internal/magnet` или вынести общий (решение по месту) - [x] 1.5 Тесты: одиночный файл; много файлов; гибрид v1+v2; трекеры из announce-list; повреждённые/не-torrent байты → ошибка; `Context()` — содержательный и пустой случай; отсутствие сетевых вызовов ## 2. Хранилище: байты торрента - [x] 2.1 Goose-миграция: таблица `download_torrent(download_id TEXT PRIMARY KEY REFERENCES download(id) ON DELETE CASCADE, data BLOB NOT NULL)` - [x] 2.2 Обновить ER-схему `docs/specs/database.md` (новая таблица + связь) - [x] 2.3 Изменить сигнатуру `CreateDownloadIfNoActive(ctx, d, hashes, torrentBlob []byte)`: на ветке создания `INSERT INTO download_torrent` в той же tx (при дедупе — не пишет). Обновить интерфейсы `Store` в `internal/ingest` и `internal/worker` и вызовы magnet-приёма/`discover.go` (передают `nil`). Добавить `GetTorrentData(ctx, downloadID) ([]byte, error)` - [x] 2.4 Тесты store: запись/чтение байтов; при дедупе байты не пишутся; CASCADE-уборка при (гипотетическом) удалении загрузки. Байты НЕ удаляются при промоушене `catched → downloading` (нужны для retry) ## 3. Приём: ветка байтов в `ingest` - [x] 3.1 Расширить `ingest.Request` полем `TorrentData []byte` (опц.) - [x] 3.2 В `Ingest`: при непустом `TorrentData` — `torrent.Parse`, `SourceType = SourceTorrent`, `SourceRef` = имя раздачи/файла (не URL), контекст = `mergeContext(req.Context, tinfo.Context())`, запись байтов (2.3) в транзакции создания; иначе — прежний magnet-путь без изменений - [x] 3.3 Ограничение размера входа на границе (константа, дефолт напр. 8 MiB) - [x] 3.4 Тесты ingest: приём `.torrent` → `catched`, `source_type=torrent`, инфохэши, байты сохранены, контекст синтезирован; дедуп по инфохэшу (байты не пишутся); превышение лимита → ошибка ## 4. Воркер: диспетч добавления по типу источника (оба add-пути) - [x] 4.1 Общий хелпер `addSource(ctx, d, rename)`: ветвь по `d.SourceType` — magnet/url (`URLs`, hint из `magnet.Parse`); torrent (`GetTorrentData`, hint из `torrent.Parse(data).DisplayName`, `qbt.Add(AddRequest{Torrents: [][]byte{data}, …})`) - [x] 4.2 Применить хелпер в `processCatched` (worker.go:359-365) И в `Retry` (worker.go:677-691): снять magnet-only гейт в Retry — torrent без живой раздачи должен добавляться байтами, а не активироваться без `Add`. Сохранить откат активации при сбое add в Retry - [x] 4.3 Общими остаются: ре-валидация перехода, обработка сбоя `add` (`processCatched` — остаётся `catched`; Retry — откат), namer/qbt вне блокировки - [x] 4.4 Тесты воркера: (а) `catched` torrent → `qbt.Add` с `Torrents` (не `URLs`), переход в `downloading`, сбой add → остаётся `catched`; (б) `Retry` torrent без живой раздачи → `Add` с `Torrents`, переход в `downloading`; сбой add → откат в прежнее состояние ## 5. Транспорт: веб-форма (multipart) - [x] 5.1 Шаблон формы: `enctype="multipart/form-data"`, `` рядом со строкой источника; строка источника не обязательна при выбранном файле - [x] 5.2 `handleUIAdd`: `http.MaxBytesReader`/лимит, `ParseMultipartForm`; есть файл `torrent` → `Ingest(Request{TorrentData, Context})`; иначе — прежний путь. PRG-редирект/htmx как сейчас - [x] 5.3 Деградация без JS: обычный multipart-POST приводит к тому же результату - [x] 5.4 Тест обработчика: multipart с `.torrent` → загрузка заведена; без файла → прежний текстовый путь; превышение размера → ошибка ## 6. Транспорт: Telegram-документ - [x] 6.1 В `handleMessage` обрабатывать `m.Document` **до** ветки pending-hint и текстовой ветки (у документа `m.Text` пуст): mime `application/x-bittorrent` или расширение `.torrent` → скачать байты (`GetFileDirectURL` + HTTP GET, лимит), `Ingest(Request{TorrentData, Context: m.Caption})` - [x] 6.2 Документ не-`.torrent` → прежний ответ-подсказка; сохранить allowlist-проверку. `AllowedUpdates` не менять (документ — внутри `message`) - [x] 6.3 Тест парса/маршрутизации документа (mime/расширение → ветка байтов); скачивание — за интерфейсом/мок ## 7. Проверка - [x] 7.1 `task test` и `task lint` зелёные - [x] 7.2 `openspec validate torrent-file-ingest --strict` проходит - [ ] 7.3 Ручная проверка: `.torrent` через веб-пикер и Telegram-документ → `catched → downloading`, задача видна в qBittorrent с `rename`