## 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`, т.к. корень — невозможность добавить).