## Context

Текущий `Ingest` (`internal/ingest/ingest.go`) синхронно: парсит magnet,
дедуплицирует, выводит имя через `namer.DeriveName` (потенциально **медленный
LLM** — `extractViaLLM`), создаёт `download` сразу в `downloading` и вызывает
`qbt.Add`, и лишь затем возвращает `Result`. Медленный LLM и внешний вызов к
qBit задерживают ответ транспорту (HTTP/веб-UI/Telegram) и расширяют окно
«строка в БД есть, в qBittorrent ещё нет».

Worker (`internal/worker`) уже ведёт поллинг-цикл под единой блокировкой
переходов: сверяет раздачи (`discover` усыновляет по категории/тегу с дедупом
по infohash), ходит по `ListDownloadsByState(StateDownloading)` и реагирует на
«active download not found in qbittorrent». Реконсилятор оперирует своими
`desyncStates` (`orphaned`, …), в которые `catched` не входит.

Состояния — `internal/store/download.go` (`State`, `terminalStates`,
`statesInGroup`). Активность выводится из `state` (нетерминальное = активное).

## Goals / Non-Goals

**Goals:**

- Быстрый ответ приёма: синхронно только парс + дедуп + запись `catched`.
- Вынести медленный вывод имени и `qbt.Add` в асинхронный шаг worker'а.
- Сохранить инвариант «≤1 активная загрузка на infohash» (в т.ч. с `catched`).
- Корректно показать `catched` в веб-UI; не считать его пропажей раздачи.

**Non-Goals:**

- Отдельное состояние `added-to-qbittorrent` — схлопнуто в `catched →
  downloading` (решение развилки).
- Немедленный пинок фоновой добавки — двигаем worker-циклом (решение развилки);
  задержка ≤ `poll_interval` приемлема, ведь клиенту уже ответили.
- Изменение схемы БД, API транспортов, распознавания/раскладки.
- Перенос синтеза контекста из полей magnet — он дёшев и остаётся в приёме.

## Decisions

### Р1. Новое состояние `catched`, нетерминальное активное

`StateCatched = "catched"`. Не входит в `terminalStates` → автоматически
считается активным для `CreateDownloadIfNoActive` и инварианта. Добавляется в
`statesInGroup(GroupActive)` рядом с `downloading` — чтобы попадать в активную
группу списка и в поиск.

### Р2. Приём пишет `catched`, без namer и без qBit

`Ingest`: парс → синтез контекста (как сейчас) → `CreateDownloadIfNoActive` с
`State: StateCatched`, `DisplayName: ""` (имя выведет worker). Ни
`namer.DeriveName`, ни `qbt.Add` в приёме не вызываются. `Result` возвращается
сразу после записи. Транспорты (`httpapi`, `tgbot`) не меняются — они уже
работают через `Result`.

Зависимость `Namer` из `ingest.Service` **переезжает** в worker (или worker
получает её отдельно). `ingest` перестаёт зависеть от `naming`/`qbt` в пути
приёма (qbt-зависимость в ingest может уйти совсем, если не нужна для дедупа).

### Р3. Асинхронный шаг worker'а: `catched → downloading`, сеть — вне замка

**Критично:** `w.mu` в worker'е сериализует ВЕСЬ поллинг-цикл И команды
транспортов (`Cancel`, `Retry`, review). Медленный `namer.DeriveName` (до
`max_retries` сетевых попыток) и `qbt.Add` под этим замком заморозили бы все
действия пользователя на секунды каждый тик — это ровно та блокировка, которую
change устраняет. Поэтому:

1. Под `w.mu` (быстро): снять список `ListDownloadsByState(StateCatched)`.
2. **Вне `w.mu`** (для каждой загрузки): вывести имя `namer.DeriveName(ctx,
   d.Context, dnHint)`, вызвать `qbt.Add(urls=d.SourceRef, category, savepath,
   rename=name)`. Имя выводится непосредственно перед `add` (`rename` действует
   только при добавлении).
3. Снова под `w.mu` (быстро): **ре-валидировать** `state == catched` (мог быть
   отменён/добавлен параллельно) и записать переход `catched → downloading` +
   `display_name`. Ре-валидацию обеспечивает гард `setState` (target
   нетерминальный → `state NOT IN terminalStates`): если пользователь успел
   `catched → cancelled`, переход корректно отклонится.

То есть под сериализацией переходов — только запись перехода в БД, а не сетевые
вызовы. Спека («Добавление пойманной загрузки») формулирует это так же: под
блокировкой сериализуется переход, не `add`/namer.

`dnHint` (dn из magnet) worker получает разбором `d.SourceRef`
(`magnet.Parse`) — дёшево, без сети; для `add` используется сам `d.SourceRef`
(URL), хеши уже есть в `d.Infohashes`.

_Альтернатива:_ хранить hint отдельным полем. Отвергнуто — `SourceRef` уже есть,
повторный парс тривиален, схему не трогаем.

### Р4. Сбой `add` — ретрай тиком, предохранитель `catch_timeout`

Парс magnet уже прошёл синхронно в приёме, поэтому в `catched` ссылка
валидна — сбои `add` почти всегда транзиентны (qBit недоступен). Поэтому
неуспешный `add` **оставляет** загрузку в `catched` (повтор на следующем тике),
а не уводит в `failed` по первому сбою. Страховка от устойчивой недоступности —
предохранитель `catch_timeout` (новый конфиг, дефолт консервативный, напр. по
образцу `magnet_timeout`): `catched` старше него → `failed` (`qbit_add`) +
уведомление автора. Это переиспользует существующий паттерн
таймаутов-предохранителей (`magnet_timeout`/`stuck_after`).

### Р5. `catched` исключён из проверок пропажи раздачи

Поллинг активных (`worker.go`: `ListDownloadsByState(StateDownloading)`) уже не
включает `catched` — но фиксируем это требованием и тестом. Реконсилятор
(`desyncStates`) `catched` не содержит. `discover`: когда worker добавит
раздачу catched-загрузки, следующий тик увидит её по категории, но exists-чек по
infohash найдёт активную загрузку и не заведёт дубль (инвариант держится).

### Р6. Веб-UI: бейдж/фаза `catched`

Добавить подпись бейджа и фазу жизненного цикла для `catched` (перед
`downloading`), включить в активную группу. Заголовок при пустом `display_name`
уже деградирует по фолбеку. Секции раздачи/живого прогресса для `catched` нет
(нет qBit-записи) — шаблон должен это переносить без ошибок (обычно уже так, т.к.
телеметрия ищется по infohash и не находится).

## Risks / Trade-offs

- [Задержка появления в qBit до ~`poll_interval` (5с)] → Приемлемо: клиенту уже
  ответили; пользователь видит `catched` в UI. При желании позже — немедленный
  пинок, но вне объёма.
- [Гонка discover ↔ шаг добавления (worker добавил, тот же/следующий тик
  усыновляет)] → Дедуп по infohash в `discover` (exists-чек) уже защищает;
  оба пути под общей блокировкой переходов.
- [Пустой `display_name` в `catched` виден в UI] → Фолбек заголовка уже есть
  (распознанное/усечённый источник); визуально корректно.
- [Namer/LLM-ошибка на шаге добавления] → Как и раньше best-effort: пустое имя →
  `add` без `rename`; шаг добавления не срывается из-за namer.
- [Учёт `catched` во всех местах, где перечислены активные состояния] →
  Единая точка `statesInGroup` + аудит по `StateDownloading`-упоминаниям в
  worker/store; покрыть тестами дедупа и группировки.

## Migration Plan

Аддитивно: новое состояние и новый конфиг `catch_timeout` (с дефолтом — старый
конфиг валиден). Существующие загрузки в `downloading`/терминальных не
затронуты. Новый путь приёма применяется к новым загрузкам.

Откат — ревертом кода. Загрузки, застрявшие в `catched` на момент отката,
старая логика не знает и `retry` их не поднимет (`Retry` разрешён только из
`failed`/`stuck`), а как активные они ещё и блокируют повторный приём того же
infohash. Окно мало (`catched` живёт секунды до тика worker'а), но при откате
такие строки нужно снять вручную: `UPDATE download SET state='failed',
error_code='qbit_add' WHERE state='catched'` — после чего они доступны штатному
`retry`. Зафиксировать в задаче/рантбуке отката.

## Open Questions

- Значение дефолта `catch_timeout` (5–15 мин?) — уточнить при apply, на спеку
  не влияет.
- Нужен ли отдельный `error_code` для `catch_timeout` или переиспользуем
  `qbit_add` (взято `qbit_add`, т.к. корень — невозможность добавить).
