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

15 KiB
Raw Permalink Blame History

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).