Живые обновления прогресса и раздел «Раздача» (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:
@@ -0,0 +1,186 @@
|
||||
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)
|
||||
}
|
||||
Reference in New Issue
Block a user