Живые обновления прогресса и раздел «Раздача» (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>
This commit is contained in:
av
2026-06-30 20:38:25 +03:00
co-authored by Claude Opus 4.8
parent b646381cf9
commit ef75a0d302
22 changed files with 1094 additions and 14 deletions
+71 -1
View File
@@ -134,6 +134,40 @@ type Config struct {
SourceMissingThreshold int
}
// Live — живая телеметрия одной раздачи из снимка воркера. Курированный срез
// qbt.Torrent: читатели (httpapi) не зависят от пакета qbt, контракт чтения
// узкий. Seeding вычисляется воркером (classify), чтобы трактовка завершённости
// не дублировалась в транспорте.
type Live struct {
Progress float64 // доля 0..1
DlSpeed int64 // скорость загрузки, байт/с
ETA int64 // оценка до завершения, с (8640000 ≈ ∞)
State string // сырое состояние qBittorrent
Seeding bool // торрент завершён и раздаётся
Ratio float64 // рейтинг отдачи (может быть <0 = ∞/н/д)
Seeds int // подключённые сиды
Peers int // подключённые личи
Uploaded int64 // отдано всего, байт
UpSpeed int64 // скорость отдачи, байт/с
}
// liveFrom собирает Live из торрента qBittorrent (Seeding — через classify).
func liveFrom(t qbt.Torrent) Live {
return Live{
Progress: t.Progress,
DlSpeed: t.Dlspeed,
ETA: t.Eta,
State: t.State,
Seeding: classify(t.State) == classReady,
Ratio: t.Ratio,
Seeds: t.NumSeeds,
Peers: t.NumLeechs,
Uploaded: t.Uploaded,
UpSpeed: t.Upspeed,
}
}
// Worker — поллер и владелец переходов.
type Worker struct {
store Store
@@ -149,6 +183,14 @@ type Worker struct {
notifier Notifier // опц. исходящие пинги
scanner Scanner // опц. пересканирование Jellyfin
// live — снимок живой телеметрии раздач (ключ — lowercase infohash, по три
// ключа на торрент, как byHash). Обновляется атомарным свопом карты на
// каждом тике Poll. Отдельный RWMutex (не w.mu): UI читает телеметрию часто,
// смешивать частые чтения с замком переходов — лишняя конкуренция. Снимок
// волатилен, в БД не хранится.
liveMu sync.RWMutex
live map[string]Live
// failNotified — дебаунс повторных EventFailed по задаче (download_id →
// время последнего пинга). Мерцающий stalled-торрент колеблется
// stuck↔downloading; без дебаунса каждый цикл слал бы уведомление. Память
@@ -179,9 +221,30 @@ func New(st Store, qb QBittorrent, rec Recognizer, lay Layouter, cfg Config, log
now: time.Now,
newID: defaultBatchID,
failNotified: map[int64]time.Time{},
live: map[string]Live{},
}
}
// Live возвращает живую телеметрию раздачи по infohash (любому из v1/v2/hash).
// ok=false, если infohash пуст или раздачи не было в последнем тике поллинга —
// тогда читатель деградирует без живых значений. Чтение под RLock.
func (w *Worker) Live(infohash string) (Live, bool) {
if infohash == "" {
return Live{}, false
}
w.liveMu.RLock()
defer w.liveMu.RUnlock()
l, ok := w.live[strings.ToLower(infohash)]
return l, ok
}
// setLive атомарно подменяет снимок телеметрии готовой картой.
func (w *Worker) setLive(snap map[string]Live) {
w.liveMu.Lock()
w.live = snap
w.liveMu.Unlock()
}
// defaultBatchID — уникальный идентификатор батча раскладки.
func defaultBatchID() string {
return fmt.Sprintf("b-%d", time.Now().UnixNano())
@@ -235,13 +298,20 @@ func (w *Worker) Poll(ctx context.Context) error {
return fmt.Errorf("poll: list torrents: %w", err)
}
byHash := make(map[string]qbt.Torrent, len(torrents)*2)
live := make(map[string]Live, len(torrents)*2)
for _, t := range torrents {
l := liveFrom(t)
for _, h := range []string{t.Hash, t.InfohashV1, t.InfohashV2} {
if h != "" {
byHash[strings.ToLower(h)] = t
key := strings.ToLower(h)
byHash[key] = t
live[key] = l
}
}
}
// Снимок зависит только от torrents — свопаем сразу, до store-операций
// ниже (их ранний return по ошибке не должен лишать UI свежей телеметрии).
w.setLive(live)
w.mu.Lock()
defer w.mu.Unlock()