Files
jellybit/internal/httpapi/live.go
T
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

187 lines
6.2 KiB
Go
Raw 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.
package httpapi
import (
"errors"
"fmt"
"net/http"
"git.vakhrushev.me/av/jellybit/internal/store"
"git.vakhrushev.me/av/jellybit/internal/worker"
)
// LiveStatus — источник живой телеметрии загрузок (снимок воркера). Контракт
// узкий и не зависит от способа доставки в браузер (поллинг сейчас, SSE позже).
type LiveStatus interface {
// Live возвращает телеметрию по infohash; ok=false — данных нет (нет
// торрента в последнем тике), читатель деградирует без живых значений.
Live(infohash string) (worker.Live, bool)
}
// noLive — заглушка на случай, когда источник телеметрии не подключён
// (Deps.Live == nil): живых данных нет, UI деградирует штатно.
type noLive struct{}
func (noLive) Live(string) (worker.Live, bool) { return worker.Live{}, false }
// progressView — живой прогресс активной загрузки (для карточки и фрагмента
// /progress). Active управляется store-состоянием (downloading), а не qbt:
// когда задача покидает downloading, фрагмент возвращается без поллинга.
type progressView struct {
ID int64
Active bool // store-состояние downloading → показываем бар и поллим
Has bool // есть данные снимка
Percent int
DlSpeed string
ETA string
}
// seedingView — живая статистика раздачи (для страницы и фрагмента /seeding).
// Has истинно только если торрент сидирует и данные есть — иначе секция
// деградирует (пустой контейнер, поллинг прекращается).
type seedingView struct {
ID int64
Has bool
Percent int
Ratio string
Uploaded string
Seeds int
Peers int
UpSpeed string
}
func buildProgress(id int64, active bool, l worker.Live, ok bool) progressView {
v := progressView{ID: id, Active: active}
if ok {
v.Has = true
v.Percent = pct(l.Progress)
v.DlSpeed = fmtSpeed(l.DlSpeed)
// ETA опускаем при неизвестном/sentinel (stalled) — «осталось —» уродливо;
// шаблонный {{if .ETA}} тогда скрывает хвост строки.
if e := fmtETA(l.ETA); e != "—" {
v.ETA = e
}
}
return v
}
func buildSeeding(id int64, l worker.Live, ok bool) seedingView {
v := seedingView{ID: id}
if ok && l.Seeding {
v.Has = true
v.Percent = pct(l.Progress)
v.Ratio = fmtRatio(l.Ratio)
v.Uploaded = fmtBytes(l.Uploaded)
v.Seeds = l.Seeds
v.Peers = l.Peers
v.UpSpeed = fmtSpeed(l.UpSpeed)
}
return v
}
// handleFragProgress отдаёт партиал живого прогресса карточки (htmx-поллинг).
func (s *server) handleFragProgress(w http.ResponseWriter, r *http.Request) {
id, err := pathID(r)
if err != nil {
http.Error(w, "некорректный id", http.StatusBadRequest)
return
}
d, err := s.deps.Reader.GetDownload(r.Context(), id)
if err != nil {
s.fragErr(w, err, id)
return
}
active := d.State == store.StateDownloading
l, ok := s.deps.Live.Live(d.Infohash.String)
s.render(w, "progress", buildProgress(id, active, l, ok))
}
// handleFragSeeding отдаёт партиал секции «Раздача» (htmx-поллинг).
func (s *server) handleFragSeeding(w http.ResponseWriter, r *http.Request) {
id, err := pathID(r)
if err != nil {
http.Error(w, "некорректный id", http.StatusBadRequest)
return
}
d, err := s.deps.Reader.GetDownload(r.Context(), id)
if err != nil {
s.fragErr(w, err, id)
return
}
l, ok := s.deps.Live.Live(d.Infohash.String)
s.render(w, "seeding", buildSeeding(id, l, ok))
}
// fragErr транслирует ошибку чтения задачи для фрагмент-роутов: ErrNotFound →
// 404, прочее → 500 (полная ошибка уже залогирована на доменной границе).
func (s *server) fragErr(w http.ResponseWriter, err error, id int64) {
if errors.Is(err, store.ErrNotFound) {
http.Error(w, "не найдено", http.StatusNotFound)
return
}
s.deps.Logger.Error("live fragment", "download_id", id, "error", err)
http.Error(w, "внутренняя ошибка", http.StatusInternalServerError)
}
// --- форматирование телеметрии ---
// etaInfinity — sentinel qBittorrent для неизвестного/бесконечного ETA.
const etaInfinity = 8640000
func pct(progress float64) int {
if progress < 0 {
return 0
}
if progress > 1 {
return 100
}
return int(progress*100 + 0.5)
}
// fmtBytes переводит байты в человекочитаемые единицы (двоичные, IEC).
func fmtBytes(n int64) string {
if n < 1024 {
return fmt.Sprintf("%d Б", n)
}
const unit = 1024
div, exp := int64(unit), 0
units := []string{"КиБ", "МиБ", "ГиБ", "ТиБ", "ПиБ", "ЭиБ"}
for v := n / unit; v >= unit && exp < len(units)-1; v /= unit {
div *= unit
exp++
}
return fmt.Sprintf("%.1f %s", float64(n)/float64(div), units[exp])
}
// fmtSpeed форматирует скорость (байт/с).
func fmtSpeed(n int64) string {
if n <= 0 {
return "0 Б/с"
}
return fmtBytes(n) + "/с"
}
// fmtETA форматирует оценку времени; sentinel/отрицательное → «—».
func fmtETA(sec int64) string {
if sec < 0 || sec >= etaInfinity {
return "—"
}
switch {
case sec < 60:
return fmt.Sprintf("%d с", sec)
case sec < 3600:
return fmt.Sprintf("%d мин", sec/60)
case sec < 86400:
return fmt.Sprintf("%d ч %d мин", sec/3600, (sec%3600)/60)
default:
return fmt.Sprintf("%d дн", sec/86400)
}
}
// fmtRatio форматирует рейтинг отдачи; отрицательный (sentinel) → «—».
func fmtRatio(r float64) string {
if r < 0 {
return "—"
}
return fmt.Sprintf("%.2f", r)
}