## Context Воркер (`internal/worker`) уже поллит qBittorrent каждые `PollInterval` (дефолт 5с): `Poll` тянет `Torrents(ctx, "")` (все торренты) и строит `byHash` (infohash → `qbt.Torrent`) для reconcile/discovery. Это единственная точка в системе, где есть свежее состояние раздач. Прогресс в БД не хранится (в `store.Download` полей нет — и не нужно, значение волатильно). Веб-UI (`internal/httpapi`) server-rendered: `index.html` рендерит карточки из `[]downloadView` (есть `Infohash`), `download.html` — детальную страницу из `downloadDetailView`. htmx уже вендорится и грузится во всех шаблонах, но ни одного `hx-*` пока нет. Зависимости транспорта собираются в `cmd/jellybit/serve.go` (`httpapi.Deps`), воркер `wrk` уже передаётся как `Commander`/`Reviewer`. Сейчас прогресс виден только при ручной перезагрузке, статистики раздачи нет. ## Goals / Non-Goals **Goals:** - Живой прогресс активных загрузок на главной без перезагрузки страницы и без сброса клиентских фильтров/поиска. - Секция «Раздача» на странице загрузки (рейтинг, сиды/пиры, отдано, скорость отдачи). - Снимок телеметрии в памяти воркера, обновляемый на том же тике поллинга, без лишних сетевых вызовов и без записи в БД. - Контракт чтения снимка изолирован так, чтобы позже заменить поллинг на SSE без переделки доменного слоя. **Non-Goals:** - SSE/WebSocket в этой фазе (только htmx-поллинг фрагментов). - Хранение истории прогресса/скорости в БД, графики. - Управление раздачей (пауза/лимиты/удаление торрента) — источник неприкосновенен. - Живое обновление страницы ревью. ## Decisions ### 1. Снимок в воркере, ключ — infohash Воркер ведёт `map[string]Live` (ключ — lowercase infohash), обновляемый целиком (swap готовой карты) под **отдельным** `sync.RWMutex` — не `w.mu`. Причина: `w.mu` сериализует переходы состояний и команды (cancel/retry), а UI читает телеметрию часто (каждые несколько секунд × число вкладок); смешивать частые чтения с замком переходов — лишняя конкуренция. Снимок строится из уже полученного `torrents` — дополнительного вызова qBittorrent нет. **Три ключа на торрент.** Как и существующий `byHash` (`worker.go:237-244`), снимок кладёт каждую раздачу под все три значения: `t.Hash`, `t.InfohashV1`, `t.InfohashV2` (lowercase). Иначе v2-торрент, у которого `d.Infohash` = infohash_v1, а в карту положили только по `Hash`, не найдётся при чтении. **Момент свопа — сразу после построения карты, до store-операций.** `Poll` после получения `torrents` делает ещё несколько шагов с ранними `return` по ошибкам store (`ListDownloadsByState` и т.п.). Снимок зависит только от `torrents`, поэтому собираем и свопаем его сразу после построения `byHash` — тогда телеметрия обновится даже если последующий reconcile упадёт. Ключ по infohash, а не по `download_id`: `Poll` уже держит торренты по infohash, а каждая `downloadView`/`downloadDetailView` несёт `Infohash`. Это избавляет воркер от загрузки всех записей `store.Download` ради маппинга id и естественно покрывает и активные, и сидирующие задачи (снимок = все торренты последнего тика). Дедуп активных по infohash уже гарантирован воркером. _Альтернатива (ключ download_id, как в исходной заметке):_ потребовал бы в снимке резолвить torrent→download_id, т.е. держать обратный маппинг и грузить записи. Отверг — infohash проще и уже под рукой на обеих сторонах. ### 2. Тип `worker.Live` — курированный, не `qbt.Torrent` В снимок кладём отдельный `worker.Live{Progress, DlSpeed, ETA, State, Seeding, Ratio, Seeds, Peers, Uploaded, UpSpeed}`, а не сырой `qbt.Torrent`. Так `httpapi` не зависит от пакета qbt и контракт чтения остаётся узким (легче заменить источник). `qbt.Torrent` дополняется полями `Dlspeed`, `Eta`, `Ratio`, `NumSeeds`, `NumLeechs`, `Uploaded`, `Upspeed` (теги `json:"dlspeed"` и т.д.) — они приходят в том же ответе `/torrents/info`. **Флаг «сидирует» вычисляет воркер.** «Сидирует» — это свойство qbt-состояния (`uploading`/`stalledUP`/…), а не доменного состояния задачи в БД. Воркер уже владеет `classify` (`worker.go:466-479`), поэтому при сборке снимка он считает `Seeding bool` (= `classify(state) == classReady`) и кладёт в `Live`. Так httpapi не дублирует перечень qbt-состояний и трактовка завершённости живёт в одном месте. ### 3. Контракт чтения: dep-интерфейс `LiveStatus` в httpapi ```go // в internal/httpapi type LiveStatus interface { Live(infohash string) (worker.Live, bool) // ok=false → нет данных } ``` Воркер реализует `Live(infohash string) (Live, bool)` чтением снимка под RLock. В `httpapi.Deps` добавляется поле `Live LiveStatus`; в `serve.go` передаётся тот же `wrk`. `bool` — явный признак отсутствия (graceful degradation). Геттер по одному infohash достаточен: и список (по карточке), и страница загрузки читают по одной задаче. ### 4. Доставка в браузер: per-card htmx-поллинг, swap фрагмента карточки На главной каждая карточка **активной** загрузки содержит элемент с `hx-get="/fragments/downloads/{id}/progress"`, `hx-trigger="every Ns"`, `hx-swap="outerHTML"`. Поллит не весь список, а свой прогресс-блок — поэтому клиентские фильтры/поиск/прокрутка (на уровне `#list`) не затрагиваются. Когда задача покидает `downloading`, фрагмент возвращается **без** htmx-атрибутов поллинга (или с `hx-trigger` снятым) — поллинг сам собой прекращается. Смена набора действий/бейджа при завершении остаётся за обычной навигацией/ручным обновлением (живой пересбор всей карточки с действиями — вне scope, чтобы не дублировать в htmx-ветке логику доступных действий). На странице загрузки секция «Раздача» поллится аналогично (`/fragments/downloads/{id}/seeding`), пока задача сидирует. **Интервал поллинга UI — фиксированный 3s** (статичный `hx-trigger="every 3s"` в шаблоне). Решение: не прокидывать `PollInterval` в шаблоны — для однопользова- тельского домашнего сервиса лишние идентичные ответы при `PollInterval` > 3s ничего не стоят, а плавность и простота важнее. Браузер читает только снимок — qBittorrent при этом не дёргается (Decision 1), так что «лишние» запросы не доходят до qBittorrent. Требование спеки сформулировано соответственно (ключевой инвариант — браузер не опрашивает qBittorrent напрямую и не видит данные свежее тика, а не «строго не чаще тика»). _Альтернатива (интервал из `PollInterval`):_ буквально «не чаще, чем меняются данные», но требует проводки cfg → Deps → шаблон ради экономии, которой здесь нет. Отверг. _Альтернатива (swap всего `#list`):_ сбрасывал бы клиентские фильтры (они на JS через display) — отверг. _SSE:_ отложено, контракт `LiveStatus` оставляет путь. ### 5. Фрагмент-роуты и шаблоны-партиалы Новые роуты в `NewRouter`: `GET /fragments/downloads/{id}/progress` и `GET /fragments/downloads/{id}/seeding`. Оба отдают HTML-партиал (не JSON): читают `GetDownload` (для infohash/состояния) + `Live(infohash)` и рендерят партиал `partials/progress.html` / `partials/seeding.html`. **Начальный кадр — сразу со значениями (без мигания).** Те же партиалы включаются при первом полном рендере карточки/страницы, и значения для них готовятся там же: `handleIndex`/`handleDownload` для нужных задач тоже зовут `s.deps.Live.Live(infohash)` и кладут телеметрию в view-модель. Для этого `downloadView` (карточка) и `downloadDetailView` (страница) расширяются полями телеметрии (та же модель, что отдаёт фрагмент-роут) — один источник разметки и данных для начального рендера и для поллинга. При `ok=false` партиал рендерит нейтральный плейсхолдер/скрывает секцию (graceful degradation), а не падает. Уровень лога для фрагментов — DEBUG (навигационный GET, как прочие страницы; `requestLogLevel` уже понижает не-`/api` GET — отдельной правки логов не нужно). ## Risks / Trade-offs - **[Карточка и фрагмент рассинхронятся по доступным действиям]** при завершении загрузки фоновым поллингом обновится только прогресс-блок, а кнопки — нет → Mitigation: фрагмент при выходе из `downloading` гасит свой поллинг и показывает финальное состояние прогресс-блока; полный актуальный набор действий пользователь видит при следующем заходе/обновлении. Это осознанный компромисс ради простоты (не тянем логику действий в htmx-ветку). - **[Снимок устаревает на рестарте]** до первого тика снимок пуст → Mitigation: `Live` возвращает `ok=false`, UI рендерит без живых значений (спека требует graceful degradation). - **[Рост карты снимка]** свопаем карту целиком на каждом тике из актуального списка торрентов → исчезнувшие раздачи естественно выпадают, утечки нет. - **[Гонка чтения/записи снимка]** → отдельный `RWMutex`, запись — atomic swap готовой карты в конце `Poll`, чтения под RLock. - **[Пустой infohash]** у задачи (теоретически) → `Live("")` возвращает `ok=false`, без паник. - **[Sentinel-значения qBittorrent]** `eta=8640000` означает «∞/неизвестно», `ratio` может быть `-1`, скорости/размеры — в байтах → Mitigation: хелперы форматирования (httpapi) трактуют sentinel'ы явно (ETA → «—»/«∞», `ratio<0` → «—») и переводят байты в человекочитаемые единицы. Берём `num_seeds`/ `num_leechs` (подключённые пиры), не `num_complete`/`num_incomplete` (рой) — выбор фиксируем в хелпере/партиале. ## Migration Plan Изменение аддитивное: новые поля `qbt.Torrent` (обратносовместимо), новый снимок и геттер в воркере, новый dep + роуты в httpapi, htmx-атрибуты в шаблонах. БД не меняется — миграций нет. Откат — обратный revert коммита; рантайм-состояние волатильно, чистить нечего. Деплой — обычный (бинарь на umbar). ## Open Questions Разрешены на чекпоинте ревью дизайна: - **Начальный кадр** — рендерим сразу со значениями (Decision 5): `handleIndex`/ `handleDownload` читают `Live` и кладут телеметрию во view. - **«Сидирует»** — вычисляемый флаг `Seeding` в `worker.Live` на базе `classify` (Decision 2), без дублирования состояний в httpapi. - **Интервал поллинга** — фиксированный `every 3s` в шаблоне (Decision 4), `PollInterval` в шаблоны не прокидывается; формулировка спеки смягчена. Остаётся уточнить при apply (мелочь, не блокер): - Конкретные единицы/стиль отображения скоростей и размеров — согласовать с дизайн-системой (`jellybit.css`).