Files
jellybit/internal/httpapi/live.go
T
avandClaude Opus 4.8 bb245a90a3 Веб-UI: обзор жизненного цикла в карточке загрузки
Карточка списка на главной теперь даёт краткий обзор «от загрузки до
решения об удалении»: метка «ID:» перед идентификатором, дата добавления
(абсолютная + относительная, всегда), размер раздачи и рейтинг отдачи.
Спойлер контекста убран — контекст смотрят на /download/{id}.

Данные:
- рейтинг и общий размер — из живого снимка воркера (qbt total_size →
  worker.Live.TotalSize); размер доступен для любой раздачи в снимке;
- размер-фолбэк, когда торрента нет в qBittorrent (orphaned) — сумма
  размеров разложенных файлов: новая колонка file_link.size, layouter
  пишет размер при линковке, ридер LayoutSizeByDownload суммирует по
  странице одним запросом (дедуп по dst_path);
- дата — source_added_at → фолбэк created_at, показ в TZ сервера.

handleIndex читает снимок для всех карточек (map-lookup), рейтинг/размер
статичны на рендере (без поллинга). Миграция 0007, ER-схема обновлена.
Change download-card-lifecycle-overview влит в спеки и заархивирован.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-04 10:34:45 +03:00

269 lines
9.1 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"
"time"
"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 string
Active bool // store-состояние downloading → показываем бар и поллим
Has bool // есть данные снимка
Percent int
DlSpeed string
ETA string
}
// seedingView — живая статистика раздачи (для страницы и фрагмента /seeding).
// Has истинно только если торрент сидирует и данные есть — иначе секция
// деградирует (пустой контейнер, поллинг прекращается).
type seedingView struct {
ID string
Has bool
Percent int
Ratio string
Uploaded string
Seeds int
Peers int
UpSpeed string
}
func buildProgress(id string, 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 string, 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, "не найдено", http.StatusNotFound)
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.liveFor(*d)
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, "не найдено", http.StatusNotFound)
return
}
d, err := s.deps.Reader.GetDownload(r.Context(), id)
if err != nil {
s.fragErr(w, err, id)
return
}
l, ok := s.liveFor(*d)
s.render(w, "seeding", buildSeeding(id, l, ok))
}
// fragErr транслирует ошибку чтения задачи для фрагмент-роутов: ErrNotFound →
// 404, прочее → 500 (полная ошибка уже залогирована на доменной границе).
func (s *server) fragErr(w http.ResponseWriter, err error, id string) {
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)
}
// mskLoc — TZ сервера (Europe/Moscow) для показа дат в веб-UI (инвариант «время
// с явным TZ»). Если zoneinfo недоступен в окружении — деградируем до UTC:
// дата остаётся корректной с точностью до смещения, UI не падает.
var mskLoc = func() *time.Location {
if loc, err := time.LoadLocation("Europe/Moscow"); err == nil {
return loc
}
return time.UTC
}()
// fmtDate — абсолютная дата добавления для карточки в TZ сервера.
func fmtDate(t time.Time) string {
return t.In(mskLoc).Format("2006-01-02")
}
// humanizeAge — относительная давность («5 дней назад») от now до t. Будущее
// (рассинхрон часов) схлопывается в «только что». Единицы огрубляются к
// минутам/часам/дням/месяцам/годам — для обзора возраста этого достаточно.
func humanizeAge(t, now time.Time) string {
d := now.Sub(t)
if d < time.Minute {
return "только что"
}
switch {
case d < time.Hour:
n := int(d / time.Minute)
return fmt.Sprintf("%d %s назад", n, plural(n, "минуту", "минуты", "минут"))
case d < 24*time.Hour:
n := int(d / time.Hour)
return fmt.Sprintf("%d %s назад", n, plural(n, "час", "часа", "часов"))
case d < 30*24*time.Hour:
n := int(d / (24 * time.Hour))
return fmt.Sprintf("%d %s назад", n, plural(n, "день", "дня", "дней"))
case d < 365*24*time.Hour:
n := int(d / (30 * 24 * time.Hour))
return fmt.Sprintf("%d %s назад", n, plural(n, "месяц", "месяца", "месяцев"))
default:
n := int(d / (365 * 24 * time.Hour))
return fmt.Sprintf("%d %s назад", n, plural(n, "год", "года", "лет"))
}
}
// plural выбирает русскую форму по числу (1 файл / 2 файла / 5 файлов).
func plural(n int, one, few, many string) string {
if n < 0 {
n = -n
}
if m := n % 100; m >= 11 && m <= 14 {
return many
}
switch n % 10 {
case 1:
return one
case 2, 3, 4:
return few
default:
return many
}
}
// sizeText — размер раздачи для карточки: живой общий размер из снимка, иначе
// суммарный размер разложенных файлов (фолбэк для orphaned), иначе «—».
func sizeText(l worker.Live, ok bool, layoutSize int64) string {
if ok && l.TotalSize > 0 {
return fmtBytes(l.TotalSize)
}
if layoutSize > 0 {
return fmtBytes(layoutSize)
}
return "—"
}
// ratioText — рейтинг отдачи для карточки: из живого снимка, иначе «—» (торрента
// нет в qBittorrent).
func ratioText(l worker.Live, ok bool) string {
if !ok {
return "—"
}
return fmtRatio(l.Ratio)
}