Быстрый приём: сохранение в catched, добавление в qBittorrent — шаг worker'а

Приём (Ingest) стал быстрым: синхронно только парс magnet, синтез контекста из
полей ссылки, атомарный дедуп и запись загрузки в новое состояние `catched` —
ответ клиенту сразу. Медленный вывод имени (LLM) и добавление в qBittorrent
вынесены в асинхронный шаг машины состояний, который двигает worker.

- store: состояние `catched` (нетерминальное, активная группа); атомарный
  переход PromoteCatched (catched → downloading + display_name) с гардом
  state='catched' (ре-валидация после сетевых вызовов вне блокировки)
- ingest: убраны namer/qbt из пути приёма; пишем `catched`, отвечаем сразу
- worker.processCatched: вне w.mu выводит имя и qbt.Add, под w.mu — короткий
  переход; сбой add оставляет catched (ретрай тиком); предохранитель
  catch_timeout → failed(qbit_add)+notify; catched исключён из проверок пропажи
- config: worker.catch_timeout (дефолт 10m)
- веб-UI: бейдж catched, активная группа, самозавершающийся htmx-поллинг
  карточки/страницы до перехода в downloading; Telegram-текст без сырого catched
- OpenSpec: дельты ingest/download-tracking/web-ui влиты в спеки, change
  заархивирован

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
av
2026-07-07 21:29:28 +03:00
co-authored by Claude Opus 4.8
parent f0ce6b4bc8
commit 0d263270cb
30 changed files with 1198 additions and 348 deletions
@@ -0,0 +1,77 @@
## 1. Состояние catched
- [x] 1.1 Добавить `StateCatched State = "catched"` в `internal/store/download.go`;
убедиться, что оно НЕ в `terminalStates` (нетерминальное = активное)
- [x] 1.2 Включить `StateCatched` в `statesInGroup(GroupActive)`; проверить
поиск/фильтры списка
- [x] 1.3 Тест: `catched` активно для дедупа (`CreateDownloadIfNoActive` держит
инвариант ≤1 активная на infohash с участием `catched`)
## 2. Быстрый приём (ingest)
- [x] 2.1 `Ingest`: писать `download` в `StateCatched` с пустым `DisplayName`;
убрать из синхронного пути `namer.DeriveName` и `qbt.Add`; вернуть `Result`
сразу после записи
- [x] 2.2 Убрать из `ingest.Service` ставшие ненужными зависимости/код:
`Namer`, `QBittorrent`, `notifyFailed`/`SetFailureNotifier`, `qbit_add`-путь
падения add (падение теперь у worker'а — п.4)
- [x] 2.3 Обновить тесты ingest: приём создаёт `catched`, не зовёт qBit/namer;
дедуп по активной (в т.ч. `catched`); синтез контекста сохраняется как прежде
- [x] 2.4 Транспорты (`httpapi`, `tgbot`): ответ по `Result` корректен для
`catched` (без правок API); Telegram-текст не показывает сырое `catched`
(дружелюбная формулировка «принято/добавляется»)
## 3. Асинхронный шаг добавления (worker)
- [x] 3.1 Внедрить в worker зависимости `Namer` и `qbt.Add`; добавить в
интерфейс `worker.Store` новый метод записи имени (`SetDisplayName`)
- [x] 3.2 Шаг в поллинг-цикле, сеть ВНЕ `w.mu`: под замком снять
`ListDownloadsByState(StateCatched)`; вне замка для каждой — разобрать
`d.SourceRef` (`magnet.Parse`) для dn-hint, вывести имя (`namer.DeriveName`),
вызвать `qbt.Add(urls=d.SourceRef, category, savepath, rename)`; снова под
замком — ре-валидировать `state==catched` и записать переход `catched →
downloading` + `display_name`
- [x] 3.3 Сбой `add`: оставить в `catched` (повтор на следующем тике), не уводить
в терминальное по единичному сбою; логировать (ext.* уже логирует клиент)
- [x] 3.4 Тесты worker: `catched → downloading` при успехе (rename передан,
display_name сохранён, сетевые вызовы вне замка); транзиентный сбой оставляет
`catched` и повторяет; отмена во время add (ре-валидация отбрасывает переход)
## 4. Предохранитель catch_timeout
- [x] 4.1 Конфиг `catch_timeout` (+ дефолт, валидация на старте) — по образцу
`magnet_timeout`; документация конфига
- [x] 4.2 В поллинг-цикле: `catched` старше `catch_timeout` (от создания) →
`failed` (`error_code` `qbit_add`) + уведомление автора
- [x] 4.3 Тест: `catched` за таймаутом → `failed` + notify
## 5. Исключение catched из проверок пропажи
- [x] 5.1 Убедиться (и закрепить тестом), что поллинг активных и сверка
рассинхрона (`state-reconciliation`) не трактуют `catched` как пропажу/
orphaned
- [x] 5.2 Тест на гонку discover ↔ шаг добавления: дубль по infohash не заводится
## 6. Веб-UI
- [x] 6.1 Человекочитаемая подпись бейджа для `catched` (`badgeLabel`), чтобы не
показывать сырое `catched`; `catched` в активной группе списка
- [x] 6.2 Заголовок при пустом `display_name` (фолбек) и отсутствие секции
раздачи/живого прогресса для `catched` без ошибок
- [x] 6.3 Самозавершающийся htmx-поллинг карточки/страницы в `catched`: пока
`catched` — опрашивает фрагмент; по переходе в `downloading` показывает
прогресс/имя без перезагрузки и завершает поллинг фазы (правка live-рендера
для не-`downloading` активной фазы)
- [x] 6.4 Тест httpapi/шаблонов: карточка/страница `catched` рендерится;
фрагмент-поллинг отдаётся для `catched` и завершается после перехода
## 7. Проверка
- [x] 7.1 `task test` и `task lint` зелёные
- [x] 7.2 `openspec validate fast-catch-ingest --strict` проходит
- [x] 7.3 Прогон вручную/через verify: приём отвечает быстро (без LLM в пути),
загрузка проходит `catched → downloading`, карточка обновляется без
перезагрузки
- [x] 7.4 Рантбук отката: снять застрявшие `catched` (`UPDATE download SET
state='failed', error_code='qbit_add' WHERE state='catched'`) — зафиксировать
в описании change/задаче