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