Files
avandClaude Opus 4.8 ef75a0d302 Живые обновления прогресса и раздел «Раздача» (live-status)
Воркер ведёт in-memory снимок телеметрии раздач (прогресс, скорость, ETA,
рейтинг, сиды/пиры, отдано) под отдельным RWMutex, обновляя его на каждом
тике поллинга сразу после построения byHash — без лишних вызовов qBittorrent
и без хранения в БД (волатильно). qbt.Torrent дополнен полями телеметрии.

Веб-UI читает снимок через узкий контракт LiveStatus: карточки активных
загрузок показывают живой прогресс-бар (htmx-поллинг фрагмента every 3s,
точечно — без сброса фильтров), на странице загрузки появилась секция
«Раздача» для сидирующих задач. Начальный кадр рендерится сразу со
значениями; при отсутствии данных UI деградирует штатно.

Капабилити live-status (OpenSpec), web-ui дополнен. Change заархивирован.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 20:38:25 +03:00

197 lines
15 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
## 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`).