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) } // Интервалы самообновления поверхностей (значение hx-trigger="every …"). // // - pollFast — поверхность с живыми цифрами качания (карточка в downloading). // Равен [worker].poll_interval: воркер снимает телеметрию раз в 5 с, и // опрашивать чаще значит возвращать тот же кадр (docs/database.md). // - pollSlow — все прочие наблюдаемые поверхности, включая страницу // /download/{id} в любом состоянии: там меняется только состояние, а сборка // страницы считает предпросмотр раскладки и ходит в ФС. const ( pollFast = "5s" pollSlow = "15s" ) // noLive — заглушка на случай, когда источник телеметрии не подключён // (Deps.Live == nil): живых данных нет, UI деградирует штатно. type noLive struct{} func (noLive) Live(string) (worker.Live, bool) { return worker.Live{}, false } // progressView — живой прогресс активной загрузки (вложенный блок карточки). // Active управляется store-состоянием (downloading), а не qbt: вне downloading // скорость и ETA смысла не имеют, и блок не рисуется. Своего опроса блок не // ведёт — цифры приезжают с тиком карточки (web-ui, «Самообновление живой // задачи»). type progressView struct { ID string Active bool // store-состояние downloading → показываем бар Has bool // есть данные снимка Percent int DlSpeed string ETA string } // seedingView — живая статистика раздачи (секция страницы загрузки). // 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 не свопит 4xx/5xx и не снимает hx-trigger, поэтому удалённый маршрут // заставил бы старую вкладку стучать бесконечно, а партиал без поллинга гасит // её первым же тиком. Убирается отдельной уборкой после деплоя. 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.fragTickErr(w, err, id, "dl-live-"+id) return } active := d.State == store.StateDownloading l, ok := s.liveFor(*d) s.render(w, "progress", buildProgress(id, active, l, ok)) } // handleFragCard отдаёт карточку списка целиком — это тик её самообновления. // Пока задача наблюдаема (State.IsObservable), карточка опрашивает себя и на // каждом тике приносит текущее состояние целиком: бейдж, заголовок, набор // действий и живые цифры. Перестала быть наблюдаемой — свежая карточка уже не // несёт самополлинга, и цикл завершается сам. func (s *server) handleFragCard(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.fragTickErr(w, err, id, "card-"+id) return } // Размер читаем так же, как своповый путь действия: самообновление // обслуживает и состояния с разложенными файлами, и подмена известного // размера прочерком была бы потерей поля полного рендера. sizes, err := s.deps.Reader.LayoutSizeByDownload(r.Context(), []string{id}) if err != nil { // WARN, а не ERROR: тик повторится сам (docs/conventions/logging.md). s.deps.Logger.Warn("layout sizes", "download_id", id, "error", err) sizes = nil // деградируем: размер уедет в фолбэк, тик не падает } s.render(w, "card", s.buildCardView(*d, store.Now(), sizes[id])) } // handleFragSeeding отдаёт партиал секции «Раздача». // // Как и у прогресса, потребителя в новой разметке нет: секция едет с тиком // страницы. Маршрут оставлен гасителем старых вкладок — см. handleFragProgress. 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.fragTickErr(w, err, id, "seeding-"+id) return } l, ok := s.liveFor(*d) s.render(w, "seeding", buildSeeding(id, l, ok)) } // fragTickErr — отказ чтения на повторяющемся тике самообновления: 200 и // фрагмент, который объясняет положение дел и НЕ несёт самообновления. // // Статусом ошибки отвечать нельзя: htmx не свопит DOM на 4xx/5xx, поэтому // поверхность осталась бы прежней навсегда (человек не отличит «ничего не // изменилось» от «сервер не отвечает»), а её опрос продолжался бы бесконечно — // при затяжном отказе хранилища это поток записей в журнал с каждой открытой // вкладки. Фрагмент без hx-* завершает цикл сам (web-ui, «Самообновление живой // задачи»). // // Уровень WARN, а не ERROR: у тика есть штатный ретрай — следующий тик повторит // (docs/conventions/logging.md, «Ошибки»). func (s *server) fragTickErr(w http.ResponseWriter, err error, id, rootID string) { s.fragNote(w, err, id, rootID, false) } // fragActionErr — отказ чтения на разовом действии человека: тот же // самозавершающийся фрагмент, но ERROR: ретрая у действия нет. func (s *server) fragActionErr(w http.ResponseWriter, err error, id, rootID string) { s.fragNote(w, err, id, rootID, true) } // fragNote отдаёт фрагмент отказа с корнем rootID. Корень обязателен и // приходит от вызывающего: htmx свопит outerHTML, и фрагмент без целевого id // снёс бы узел вместе с якорем — следующее действие и поллер цели не нашли бы // (docs/conventions/web-ui.md, «Единый источник разметки»). func (s *server) fragNote(w http.ResponseWriter, err error, id, rootID string, oneShot bool) { text := "задача не найдена — обновите страницу" if !errors.Is(err, store.ErrNotFound) { if oneShot { s.deps.Logger.Error("live fragment", "download_id", id, "error", err) } else { s.deps.Logger.Warn("live fragment", "download_id", id, "error", err) } text = "не удалось обновить — обновите страницу" } s.render(w, "frag_note", fragNoteView{RootID: rootID, Text: text}) } // fragNoteView — самозавершающийся фрагмент отказа (см. fragNote). RootID — // id узла, который фрагмент собой заменяет. type fragNoteView struct { RootID string Text string } // --- форматирование телеметрии --- // 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) } // fmtDate — абсолютная дата добавления для карточки в таймзоне отображения // (general.timezone; хранение всегда UTC). loc не бывает nil — NewRouter // подставляет UTC по умолчанию. func fmtDate(t time.Time, loc *time.Location) string { return t.In(loc).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) }