## 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"`, `<input type="file"
  name="torrent" accept=".torrent,application/x-bittorrent">` рядом со строкой
  источника; строка источника не обязательна при выбранном файле
- [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`
