## 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).
