Быстрый приём: сохранение в 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:
@@ -0,0 +1,2 @@
|
||||
schema: spec-driven
|
||||
created: 2026-07-07
|
||||
@@ -0,0 +1,150 @@
|
||||
## 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`, т.к. корень — невозможность добавить).
|
||||
@@ -0,0 +1,78 @@
|
||||
## Why
|
||||
|
||||
Сейчас приём (`Ingest`) синхронно делает всё: парсит источник, выводит
|
||||
отображаемое имя (потенциально **медленный вызов LLM** в `namer.DeriveName`) и
|
||||
добавляет источник в qBittorrent — и только потом отвечает клиенту. Долгий LLM
|
||||
и внешний запрос к qBit задерживают ответ HTTP API / веб-UI / Telegram и
|
||||
расширяют окно «строка в БД есть, в qBittorrent ещё нет».
|
||||
|
||||
Идея: сделать приём **быстрым** — синхронно только валидировать и сохранить
|
||||
загрузку (новое состояние `catched`), сразу вернув ответ; вывод имени и
|
||||
добавление в qBittorrent вынести в отдельный **асинхронный шаг машины
|
||||
состояний**, который двигает worker.
|
||||
|
||||
## What Changes
|
||||
|
||||
- Новое состояние **`catched`** — загрузка поймана и персистентно сохранена
|
||||
(быстрый путь). Нетерминальное, активное (участвует в инварианте «≤1 активная
|
||||
загрузка на infohash»).
|
||||
- **Приём (`ingest`) — быстрый**: парс magnet, извлечение инфохэшей, синтез
|
||||
контекста из полей ссылки (дёшево, без сети), атомарный дедуп и запись
|
||||
загрузки в `catched`. Ответ клиенту сразу. Синхронного вывода имени и
|
||||
добавления в qBittorrent в приёме больше нет.
|
||||
- **Асинхронный шаг (`download-tracking`, worker)**: на каждом тике worker
|
||||
подхватывает `catched`-загрузки, выводит отображаемое имя из контекста (LLM +
|
||||
фолбек), добавляет источник в qBittorrent (категория/savepath/rename) и
|
||||
переводит `catched → downloading` (отдельного `added-to-qbittorrent` нет —
|
||||
`downloading` и так значит «в qBit, возможно metaDL»).
|
||||
- **Обработка сбоев вне запроса клиента**: неуспешный `add` оставляет загрузку
|
||||
в `catched` (worker перетыкивает на следующем тике); предохранитель
|
||||
`catch_timeout` уводит долго-зависший `catched` в `failed`
|
||||
(`error_code` `qbit_add`) с уведомлением автора.
|
||||
- `catched` **исключён** из проверок «раздача не найдена» (у него раздачи нет
|
||||
по дизайну) — ни поллинг, ни сверка не считают его рассинхроном/orphaned.
|
||||
- **Веб-UI** показывает промежуточное состояние `catched` (бейдж/фаза
|
||||
жизненного цикла), заголовок деградирует, пока имя не выведено (фолбек уже
|
||||
есть). `catched` попадает в активную группу списка.
|
||||
|
||||
## Capabilities
|
||||
|
||||
### New Capabilities
|
||||
|
||||
_Нет._ Изменение переиспользует существующие capabilities.
|
||||
|
||||
### Modified Capabilities
|
||||
|
||||
- `ingest`: приём становится быстрым — сохранение в `catched` и мгновенный
|
||||
ответ; синхронный вывод имени и добавление в qBittorrent из приёма убраны
|
||||
(переезжают в асинхронный шаг). Требования по выводу имени переформулированы:
|
||||
выполняются на шаге добавления, а не в пути ответа клиента.
|
||||
- `download-tracking`: добавляется шаг «добавление пойманной загрузки в
|
||||
qBittorrent» (вывод имени + `add` + переход `catched → downloading`),
|
||||
предохранитель `catch_timeout`, исключение `catched` из проверок пропажи
|
||||
раздачи.
|
||||
- `web-ui`: человекочитаемый бейдж для `catched`; активная группа включает
|
||||
`catched`; заголовок при пустом имени — по фолбеку; карточка `catched`
|
||||
самообновляется htmx-поллингом до перехода в `downloading`.
|
||||
|
||||
_Без спек-правок:_ `state-reconciliation` и `live-status` не меняются.
|
||||
Исключение `catched` из проверок рассинхрона нормативно закреплено требованием
|
||||
`download-tracking` «catched не считается пропажей раздачи» (само поведение уже
|
||||
верно — `catched` не входит в `desyncStates` и дебаунс пропажи источника его не
|
||||
трогает). Иллюстративное перечисление активных состояний в тексте
|
||||
`state-reconciliation` остаётся на кросс-ссылке и будет выверено при следующем
|
||||
касании этой спеки. `live-status`: у `catched` нет qBit-телеметрии по дизайну —
|
||||
живой прогресс для него корректно отсутствует.
|
||||
|
||||
## Impact
|
||||
|
||||
- Код: `internal/store` (состояние `StateCatched`, группа active), `internal/
|
||||
ingest` (быстрый путь: убрать namer/qbt-add, писать `catched`), `internal/
|
||||
worker` (новый шаг обработки `catched`: namer + qbt-add + переход, таймаут),
|
||||
`internal/httpapi`+`internal/tgbot` (без правок API — ответ и так по Result),
|
||||
веб-UI шаблоны (бейдж/фаза `catched`).
|
||||
- Конфиг: новый `catch_timeout` (предохранитель), дефолт консервативный.
|
||||
- Данные: у активной загрузки теперь есть фаза без infohash-раздачи; схема БД
|
||||
не меняется (используем существующие `state`/`error_code`/`error_msg`).
|
||||
- Совместимость: существующие загрузки не затронуты; переход одноразовый на
|
||||
уровне логики приёма.
|
||||
@@ -0,0 +1,78 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Добавление пойманной загрузки в qBittorrent
|
||||
|
||||
Worker SHALL периодически (в поллинг-цикле, под единой блокировкой переходов)
|
||||
подхватывать загрузки в состоянии `catched` и для каждой: вывести отображаемое
|
||||
имя из контекста (см. `ingest` «Отображаемое имя торрента из контекста»),
|
||||
добавить источник в qBittorrent (категория `qbittorrent.category`, savepath,
|
||||
`rename`) и перевести загрузку `catched → downloading`. Отдельного состояния
|
||||
между `catched` и `downloading` быть SHALL NOT — успешный `add` сразу переводит
|
||||
в `downloading` (которое и означает «в qBit, возможно `metaDL`»).
|
||||
|
||||
Неуспешный `add` (qBittorrent недоступен и т.п.) SHALL оставлять загрузку в
|
||||
`catched` для повторной попытки на следующем тике; переход в терминальное
|
||||
состояние по единичному сбою происходить SHALL NOT (ретраи — естественными
|
||||
тиками поллинга).
|
||||
|
||||
Медленные вызовы (вывод имени через LLM, `qbt.Add`) SHALL выполняться **вне**
|
||||
блокировки сериализации переходов, чтобы не задерживать команды транспортов и
|
||||
поллинг. Под блокировкой сериализуется только **запись перехода** `catched →
|
||||
downloading` (см. «Переходы состояний сериализуются воркером»), с
|
||||
ре-валидацией, что загрузка всё ещё в `catched` (иначе переход отклоняется —
|
||||
например, при параллельной отмене).
|
||||
|
||||
#### Scenario: Пойманная загрузка добавляется в qBittorrent
|
||||
|
||||
- **GIVEN** загрузка в состоянии `catched`
|
||||
- **WHEN** worker обрабатывает тик
|
||||
- **THEN** выводится отображаемое имя, источник добавляется в qBittorrent с
|
||||
нашей категорией и `rename`
|
||||
- **AND** загрузка переходит в `downloading`
|
||||
|
||||
#### Scenario: Временный сбой добавления — повтор
|
||||
|
||||
- **GIVEN** загрузка в `catched`, qBittorrent временно недоступен
|
||||
- **WHEN** worker пытается добавить источник и `add` не удался
|
||||
- **THEN** загрузка остаётся в `catched`
|
||||
- **AND** на следующем тике попытка добавления повторяется
|
||||
|
||||
#### Scenario: Отмена во время добавления
|
||||
|
||||
- **GIVEN** загрузка в `catched`, worker выводит имя и добавляет её вне
|
||||
блокировки
|
||||
- **WHEN** параллельно приходит команда отмены (`catched → cancelled`), а затем
|
||||
worker берёт блокировку для записи перехода
|
||||
- **THEN** ре-валидация видит, что загрузка уже не в `catched`, и переход в
|
||||
`downloading` не применяется
|
||||
|
||||
### Requirement: Предохранитель зависшего catched
|
||||
|
||||
Система SHALL переводить загрузку, задержавшуюся в `catched` дольше
|
||||
`catch_timeout` (конфигурируемый предохранитель, дефолт консервативный), в
|
||||
`failed` (`error_code` `qbit_add`) и уведомлять автора. Возраст SHALL считать
|
||||
от времени попадания в `catched` (создания загрузки). Предохранитель —
|
||||
редкий страховочный механизм на случай устойчивой недоступности qBittorrent, а
|
||||
не штатный путь.
|
||||
|
||||
#### Scenario: catched висит дольше таймаута
|
||||
|
||||
- **GIVEN** загрузка в `catched` дольше `catch_timeout`
|
||||
- **WHEN** идёт тик поллинга
|
||||
- **THEN** загрузка переходит в `failed` с `error_code` `qbit_add`
|
||||
- **AND** автор загрузки уведомляется
|
||||
|
||||
### Requirement: catched не считается пропажей раздачи
|
||||
|
||||
Система SHALL исключать состояние `catched` из проверок «раздача не найдена в
|
||||
qBittorrent» — как в поллинге активных загрузок, так и в сверке рассинхрона
|
||||
(`state-reconciliation`). У пойманной загрузки раздачи в qBittorrent ещё нет по
|
||||
дизайну, поэтому её отсутствие система SHALL NOT трактовать как рассинхрон,
|
||||
`orphaned` или пропажу источника.
|
||||
|
||||
#### Scenario: Отсутствие раздачи у catched — не рассинхрон
|
||||
|
||||
- **GIVEN** загрузка в `catched` (раздачи в qBittorrent ещё нет)
|
||||
- **WHEN** идёт тик поллинга и сверки
|
||||
- **THEN** загрузка не считается пропавшей/рассинхронизированной и остаётся в
|
||||
`catched` (до добавления воркером или срабатывания `catch_timeout`)
|
||||
@@ -0,0 +1,81 @@
|
||||
## MODIFIED Requirements
|
||||
|
||||
### Requirement: Приём источника и заведение загрузки
|
||||
|
||||
Приём SHALL быть единым **быстрым** use-case, общим для всех транспортов (HTTP,
|
||||
Telegram, CLI): по источнику (Ф1 — magnet) и текстовому контексту система SHALL
|
||||
синхронно извлечь инфохэши, синтезировать контекст из полей ссылки (без сети),
|
||||
дедуплицировать по активной задаче и при отсутствии дубля завести загрузку
|
||||
(`download` в состоянии **`catched`** + записи `download_infohash`), после чего
|
||||
**сразу вернуть ответ** транспорту. Заведение загрузки и запись её хешей SHALL
|
||||
выполняться атомарно (см. «Атомарность возврата загрузки в активное
|
||||
состояние»).
|
||||
|
||||
Синхронный путь приёма SHALL NOT обращаться к qBittorrent и SHALL NOT выводить
|
||||
отображаемое имя (потенциально медленный LLM): и добавление источника в
|
||||
qBittorrent, и вывод имени выполняются отдельным асинхронным шагом машины
|
||||
состояний (worker) — см. `download-tracking` «Добавление пойманной загрузки в
|
||||
qBittorrent».
|
||||
|
||||
`catched` — нетерминальное активное состояние: оно участвует в инварианте «не
|
||||
более одной активной загрузки на infohash» наравне с прочими активными.
|
||||
|
||||
#### Scenario: Быстрый приём magnet
|
||||
|
||||
- **GIVEN** валидная magnet-ссылка и контекст
|
||||
- **WHEN** вызывается приём
|
||||
- **THEN** создаётся `download` в состоянии `catched` с записями
|
||||
`download_infohash`
|
||||
- **AND** ответ транспорту отдан без обращения к qBittorrent и без вывода имени
|
||||
|
||||
#### Scenario: Дубль по активной задаче на быстром пути
|
||||
|
||||
- **GIVEN** уже есть активная (в т.ч. `catched`) загрузка с тем же infohash
|
||||
- **WHEN** вызывается приём
|
||||
- **THEN** новая загрузка не создаётся, возвращается существующая
|
||||
|
||||
### Requirement: Отображаемое имя торрента из контекста
|
||||
|
||||
На шаге добавления пойманной загрузки в qBittorrent (worker) система SHALL
|
||||
выводить из контекста загрузки человекочитаемое отображаемое имя и передавать
|
||||
его в qBittorrent (параметр `rename` API `/torrents/add`), чтобы задача в списке
|
||||
qBit не показывалась безликим `dn` magnet-ссылки. Это же имя система SHALL
|
||||
**сохранять у загрузки** (`download.display_name`) для последующего показа
|
||||
заголовком в веб-UI.
|
||||
|
||||
Имя SHALL быть коротким читаемым ярлыком (название, опционально режиссёр и
|
||||
год; для сериала — номер сезона, если он определён), а не куском сырого
|
||||
контекста. Имя SHALL очищаться от управляющих символов и переводов строк и
|
||||
SHALL обрезаться по ограничению длины.
|
||||
|
||||
Вывод имени SHALL выполняться на шаге добавления, непосредственно перед вызовом
|
||||
`add` (параметр `rename` действует только в момент добавления), а НЕ в
|
||||
синхронном пути ответа приёма. В состоянии `catched` (до добавления)
|
||||
`download.display_name` ещё пуст — веб-UI берёт заголовок из фолбека.
|
||||
|
||||
Отображаемое имя SHALL влиять только на отображение (в qBittorrent и как
|
||||
заголовок в веб-UI) и SHALL NOT влиять на пути файлов на диске, распознавание
|
||||
или раскладку — реальные пути система по-прежнему читает из qBit API.
|
||||
|
||||
#### Scenario: Имя из контекста передаётся в qBittorrent
|
||||
|
||||
- **WHEN** на шаге добавления получен непустой контекст, из которого удалось
|
||||
вывести имя
|
||||
- **THEN** система передаёт это имя в qBittorrent в параметре `rename`
|
||||
- **AND** имя — короткий читаемый ярлык вида «название (режиссёр, год)»,
|
||||
где режиссёр и год опциональны
|
||||
|
||||
#### Scenario: Имя сохраняется у загрузки
|
||||
|
||||
- **WHEN** на шаге добавления выведено непустое отображаемое имя
|
||||
- **THEN** система сохраняет его в `download.display_name` (обновлением записи
|
||||
загрузки)
|
||||
- **AND** веб-UI использует его заголовком карточки и страницы загрузки
|
||||
|
||||
#### Scenario: Контекст пуст или имя не получено
|
||||
|
||||
- **WHEN** контекста нет либо ни один способ вывода не дал непустого имени
|
||||
- **THEN** система добавляет загрузку без параметра `rename`
|
||||
- **AND** qBittorrent оставляет собственное имя (из `dn`/торрента)
|
||||
- **AND** `download.display_name` остаётся пустым, а веб-UI берёт заголовок из
|
||||
фолбека (распознанное название или усечённый источник)
|
||||
@@ -0,0 +1,42 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Отображение промежуточного состояния catched
|
||||
|
||||
Веб-UI SHALL отображать состояние `catched` как штатную промежуточную фазу
|
||||
(«поймано, добавляется в qBittorrent»): бейдж статуса загрузки SHALL иметь
|
||||
понятную человекочитаемую подпись для `catched` (а не сырое `catched`), а
|
||||
загрузка в `catched` SHALL относиться к **активной** группе списка.
|
||||
|
||||
Пока отображаемое имя ещё не выведено (в `catched` `download.display_name`
|
||||
пуст), заголовок загрузки SHALL деградировать по существующему фолбеку
|
||||
(распознанное название или усечённый источник) — см. «Заголовок загрузки из
|
||||
имени раздачи». Секция раздачи/живого прогресса для `catched` SHALL корректно
|
||||
отсутствовать (раздачи в qBittorrent ещё нет), не создавая ошибок отображения.
|
||||
|
||||
Карточка/страница загрузки в `catched` SHALL самообновляться самозавершающимся
|
||||
htmx-поллингом (см. конвенцию веб-UI): по переходе загрузки в `downloading`
|
||||
интерфейс SHALL отражать это без перезагрузки страницы (подхватить бейдж,
|
||||
выведенное имя и появившийся живой прогресс), а поллинг фазы `catched` SHALL
|
||||
завершаться, как только загрузка её покинула.
|
||||
|
||||
#### Scenario: Бейдж и группа для catched
|
||||
|
||||
- **WHEN** загрузка находится в состоянии `catched`
|
||||
- **THEN** её бейдж статуса имеет человекочитаемую подпись для `catched`
|
||||
- **AND** загрузка попадает в активную группу списка
|
||||
|
||||
#### Scenario: Заголовок catched без имени
|
||||
|
||||
- **GIVEN** загрузка в `catched` с пустым `download.display_name`
|
||||
- **WHEN** рендерится карточка/страница загрузки
|
||||
- **THEN** заголовок берётся из фолбека (распознанное название или усечённый
|
||||
источник), без ошибок отображения
|
||||
- **AND** секция раздачи/живого прогресса не показывается (раздачи ещё нет)
|
||||
|
||||
#### Scenario: Самообновление при переходе в downloading
|
||||
|
||||
- **GIVEN** открытая карточка загрузки в `catched`
|
||||
- **WHEN** worker перевёл загрузку в `downloading`
|
||||
- **THEN** интерфейс без перезагрузки показывает состояние `downloading`
|
||||
(бейдж, имя, живой прогресс)
|
||||
- **AND** поллинг фазы `catched` завершается
|
||||
@@ -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/задаче
|
||||
Reference in New Issue
Block a user