Метки времени в SQLite переведены с формата datetime('now')
(«2006-01-02 15:04:05») на RFC 3339 всегда-UTC («2006-01-02T15:04:05Z»):
самоописываемое хранилище (зона в значении), валидный ISO 8601, единый
формат с логами. Фиксированная ширина сохраняет лексикографическую
сортировку TEXT = хронологию (COALESCE(source_added_at, created_at)).
- Единая точка генерации времени в Go: store.Now()/FormatTime; DEFAULT
(datetime('now')) снят со всех колонок — время всегда пишет приложение
(зеркально ident.NewID для id), fail-loud при забытой вставке (NOT NULL).
Все INSERT-сайты в store передают created_at/updated_at явно.
- Миграция 0008 (rebuild 7 таблиц без DEFAULT + backfill strftime, FK/PK/
индексы сохранены байт-в-байт по образцу 0006); симметричная down.
- Новая секция конфига [general] с полем timezone (дефолт UTC) — зона
ОТОБРАЖЕНИЯ в веб-UI; хранение остаётся UTC. Жёсткая валидация зоны на
старте; zoneinfo встроен (time/tzdata), заменён зашитый Europe/Moscow.
- Тесты: round-trip миграции (up/down, NULL source_added_at), валидация
зоны, сдвиг даты по зоне; обновлены фикстуры и TestUlidMigration.
- Docs: конвенции database/config, ER-схема; спека web-ui (таймзона).
OpenSpec change time-storage-rfc3339 (заархивирован).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
261 lines
8.8 KiB
Go
261 lines
8.8 KiB
Go
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)
|
||
}
|
||
|
||
// 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)
|
||
}
|