Воркер ведёт 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>
197 lines
15 KiB
Markdown
197 lines
15 KiB
Markdown
## 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`).
|