Воркер ведёт 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>
15 KiB
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
// в 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).