From ef75a0d30264c7b0eef571fb5156221964d887f0 Mon Sep 17 00:00:00 2001 From: Anton Vakhrushev Date: Tue, 30 Jun 2026 20:38:25 +0300 Subject: [PATCH] =?UTF-8?q?=D0=96=D0=B8=D0=B2=D1=8B=D0=B5=20=D0=BE=D0=B1?= =?UTF-8?q?=D0=BD=D0=BE=D0=B2=D0=BB=D0=B5=D0=BD=D0=B8=D1=8F=20=D0=BF=D1=80?= =?UTF-8?q?=D0=BE=D0=B3=D1=80=D0=B5=D1=81=D1=81=D0=B0=20=D0=B8=20=D1=80?= =?UTF-8?q?=D0=B0=D0=B7=D0=B4=D0=B5=D0=BB=20=C2=AB=D0=A0=D0=B0=D0=B7=D0=B4?= =?UTF-8?q?=D0=B0=D1=87=D0=B0=C2=BB=20(live-status)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Воркер ведёт 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) --- cmd/jellybit/serve.go | 1 + internal/httpapi/download.go | 8 + internal/httpapi/httpapi.go | 32 ++- internal/httpapi/live.go | 186 +++++++++++++++++ internal/httpapi/live_test.go | 107 ++++++++++ internal/httpapi/render_test.go | 14 ++ internal/qbt/qbt.go | 14 +- internal/worker/live_test.go | 62 ++++++ internal/worker/worker.go | 72 ++++++- .../2026-06-30-live-status/.openspec.yaml | 2 + .../archive/2026-06-30-live-status/design.md | 196 ++++++++++++++++++ .../2026-06-30-live-status/proposal.md | 57 +++++ .../specs/live-status/spec.md | 111 ++++++++++ .../specs/web-ui/spec.md | 30 +++ .../archive/2026-06-30-live-status/tasks.md | 64 ++++++ openspec/specs/live-status/spec.md | 115 ++++++++++ openspec/specs/web-ui/spec.md | 16 +- web/static/css/jellybit.css | 2 + web/templates/download.html | 3 + web/templates/index.html | 2 + web/templates/partials/progress.html | 4 + web/templates/partials/seeding.html | 10 + 22 files changed, 1094 insertions(+), 14 deletions(-) create mode 100644 internal/httpapi/live.go create mode 100644 internal/httpapi/live_test.go create mode 100644 internal/worker/live_test.go create mode 100644 openspec/changes/archive/2026-06-30-live-status/.openspec.yaml create mode 100644 openspec/changes/archive/2026-06-30-live-status/design.md create mode 100644 openspec/changes/archive/2026-06-30-live-status/proposal.md create mode 100644 openspec/changes/archive/2026-06-30-live-status/specs/live-status/spec.md create mode 100644 openspec/changes/archive/2026-06-30-live-status/specs/web-ui/spec.md create mode 100644 openspec/changes/archive/2026-06-30-live-status/tasks.md create mode 100644 openspec/specs/live-status/spec.md create mode 100644 web/templates/partials/progress.html create mode 100644 web/templates/partials/seeding.html diff --git a/cmd/jellybit/serve.go b/cmd/jellybit/serve.go index 276b3cd..90d012a 100644 --- a/cmd/jellybit/serve.go +++ b/cmd/jellybit/serve.go @@ -154,6 +154,7 @@ func runServe(args []string) error { Commander: wrk, Reader: st, Reviewer: wrk, + Live: wrk, }) if err != nil { return err diff --git a/internal/httpapi/download.go b/internal/httpapi/download.go index 337c461..72b1bf6 100644 --- a/internal/httpapi/download.go +++ b/internal/httpapi/download.go @@ -36,6 +36,9 @@ type downloadDetailView struct { Confidence string Files []fileRow + // Живая статистика раздачи (заполняется из снимка воркера). + Seeding seedingView + // Действия по состоянию (как на главной). Terminal bool Reviewable bool @@ -104,5 +107,10 @@ func (s *server) handleDownload(w http.ResponseWriter, r *http.Request) { view.Files = buildFileRows(rd.Plan, rd.Preview) } + // Живая статистика раздачи — со значениями уже в первом кадре; секция + // деградирует (пустой контейнер), если торрент не сидирует/данных нет. + l, ok := s.deps.Live.Live(d.Infohash.String) + view.Seeding = buildSeeding(id, l, ok) + s.render(w, "download.html", view) } diff --git a/internal/httpapi/httpapi.go b/internal/httpapi/httpapi.go index 42b94fe..b40c006 100644 --- a/internal/httpapi/httpapi.go +++ b/internal/httpapi/httpapi.go @@ -52,6 +52,7 @@ type Deps struct { Commander Commander Reader Reader Reviewer Reviewer + Live LiveStatus } type server struct { @@ -80,6 +81,9 @@ func NewRouter(d Deps) (http.Handler, error) { if err != nil { return nil, err } + if d.Live == nil { + d.Live = noLive{} // источник телеметрии не подключён — деградируем штатно + } s := &server{deps: d, tmpl: tmpl, assetVer: assetVer} r := chi.NewRouter() @@ -95,6 +99,10 @@ func NewRouter(d Deps) (http.Handler, error) { // Веб-UI. r.Get("/", s.handleIndex) r.Get("/download/{id}", s.handleDownload) + + // Живые фрагменты телеметрии (htmx-поллинг; читают снимок воркера). + r.Get("/fragments/downloads/{id}/progress", s.handleFragProgress) + r.Get("/fragments/downloads/{id}/seeding", s.handleFragSeeding) r.Post("/ui/downloads", s.handleUIAdd) r.Post("/ui/downloads/{id}/cancel", s.handleUICancel) r.Post("/ui/downloads/{id}/retry", s.handleUIRetry) @@ -148,12 +156,14 @@ type downloadView struct { SearchText string // haystack для клиентского поиска (lowercase) Error string Terminal bool - Deleted bool // скрыт по умолчанию на главной - Reviewable bool // review/deferred — есть экран ревью - Undoable bool // done — можно откатить раскладку - Relinkable bool // reverted/cancelled/target_missing — можно перепривязать заново - Retriable bool // failed/stuck — можно повторить попытку - Note string // пояснение рассинхрона (target_missing/orphaned/deleted) + IsDownloading bool // активная загрузка → живой прогресс-бар + поллинг + Progress progressView // живой прогресс (заполняется в handleIndex из снимка) + Deleted bool // скрыт по умолчанию на главной + Reviewable bool // review/deferred — есть экран ревью + Undoable bool // done — можно откатить раскладку + Relinkable bool // reverted/cancelled/target_missing — можно перепривязать заново + Retriable bool // failed/stuck — можно повторить попытку + Note string // пояснение рассинхрона (target_missing/orphaned/deleted) } func (s *server) handleIndex(w http.ResponseWriter, r *http.Request) { @@ -165,7 +175,14 @@ func (s *server) handleIndex(w http.ResponseWriter, r *http.Request) { } view := indexView{Error: r.URL.Query().Get("err")} for _, d := range downloads { - view.Downloads = append(view.Downloads, toView(d)) + v := toView(d) + // Живой прогресс активных загрузок — со значениями уже в первом кадре + // (без мигания); дальше карточка дозапрашивает фрагмент поллингом. + if v.IsDownloading { + l, ok := s.deps.Live.Live(d.Infohash.String) + v.Progress = buildProgress(d.ID, true, l, ok) + } + view.Downloads = append(view.Downloads, v) } s.render(w, "index.html", view) } @@ -351,6 +368,7 @@ func toView(d store.Download) downloadView { SearchText: strings.ToLower(d.SourceRef + " " + d.Infohash.String + " " + d.Context), Error: d.ErrorMsg.String, Terminal: d.State.IsTerminal(), + IsDownloading: d.State == store.StateDownloading, Deleted: d.State == store.StateDeleted, Reviewable: d.State == store.StateReview || d.State == store.StateDeferred, Undoable: d.State == store.StateDone, diff --git a/internal/httpapi/live.go b/internal/httpapi/live.go new file mode 100644 index 0000000..2298cf5 --- /dev/null +++ b/internal/httpapi/live.go @@ -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) +} diff --git a/internal/httpapi/live_test.go b/internal/httpapi/live_test.go new file mode 100644 index 0000000..e11ed34 --- /dev/null +++ b/internal/httpapi/live_test.go @@ -0,0 +1,107 @@ +package httpapi + +import ( + "net/http" + "strings" + "testing" + + "git.vakhrushev.me/av/jellybit/internal/store" + "git.vakhrushev.me/av/jellybit/internal/worker" +) + +// TestFragProgressDownloading: активная задача → фрагмент с прогрессом, +// значениями снимка и атрибутами htmx-поллинга. +func TestFragProgressDownloading(t *testing.T) { + dl := store.Download{ID: 5, Infohash: store.NullString("ih5"), State: store.StateDownloading} + lv := stubLive{m: map[string]worker.Live{"ih5": {Progress: 0.42, DlSpeed: 6400000, ETA: 720}}} + h := testRouterLive(t, stubReader{one: &dl}, stubReviewer{}, lv) + + rr := get(t, h, "/fragments/downloads/5/progress") + if rr.Code != http.StatusOK { + t.Fatalf("status = %d, want 200", rr.Code) + } + body := rr.Body.String() + for _, want := range []string{`hx-trigger="every 3s"`, "/fragments/downloads/5/progress", "width:42%", "42%"} { + if !strings.Contains(body, want) { + t.Errorf("фрагмент прогресса не содержит %q\n%s", want, body) + } + } +} + +// TestFragProgressStopsWhenNotDownloading: когда задача покинула downloading, +// фрагмент отдаётся без атрибутов поллинга (поллинг прекращается). +func TestFragProgressStopsWhenNotDownloading(t *testing.T) { + dl := store.Download{ID: 5, Infohash: store.NullString("ih5"), State: store.StateDone} + h := testRouterLive(t, stubReader{one: &dl}, stubReviewer{}, stubLive{}) + + rr := get(t, h, "/fragments/downloads/5/progress") + if rr.Code != http.StatusOK { + t.Fatalf("status = %d, want 200", rr.Code) + } + if body := rr.Body.String(); strings.Contains(body, "hx-trigger") { + t.Errorf("завершённая задача всё ещё поллит:\n%s", body) + } +} + +// TestFragSeeding: сидирующая задача → секция «Раздача» со статистикой и +// поллингом. +func TestFragSeeding(t *testing.T) { + dl := store.Download{ID: 9, Infohash: store.NullString("ih9"), State: store.StateDone} + lv := stubLive{m: map[string]worker.Live{"ih9": { + Seeding: true, Progress: 1, Ratio: 2.41, Seeds: 38, Peers: 14, Uploaded: 1 << 30, UpSpeed: 1153433, + }}} + h := testRouterLive(t, stubReader{one: &dl}, stubReviewer{}, lv) + + rr := get(t, h, "/fragments/downloads/9/seeding") + if rr.Code != http.StatusOK { + t.Fatalf("status = %d, want 200", rr.Code) + } + body := rr.Body.String() + for _, want := range []string{"Раздача", "2.41", "38 / 14", `hx-trigger="every 3s"`} { + if !strings.Contains(body, want) { + t.Errorf("фрагмент раздачи не содержит %q\n%s", want, body) + } + } +} + +// TestFragSeedingDegrades: нет живых данных → секция отсутствует, поллинга нет. +func TestFragSeedingDegrades(t *testing.T) { + dl := store.Download{ID: 9, Infohash: store.NullString("ih9"), State: store.StateDone} + h := testRouterLive(t, stubReader{one: &dl}, stubReviewer{}, stubLive{}) + + rr := get(t, h, "/fragments/downloads/9/seeding") + if rr.Code != http.StatusOK { + t.Fatalf("status = %d, want 200", rr.Code) + } + body := rr.Body.String() + if strings.Contains(body, "Раздача") || strings.Contains(body, "hx-trigger") { + t.Errorf("секция раздачи не деградировала:\n%s", body) + } +} + +// TestIndexCardShowsLiveProgress: активная карточка в списке несёт прогресс уже +// в первом кадре (значения снимка) и атрибуты поллинга. +func TestIndexCardShowsLiveProgress(t *testing.T) { + dl := store.Download{ID: 3, SourceRef: "The.Bear.S03", Infohash: store.NullString("ih3"), State: store.StateDownloading} + lv := stubLive{m: map[string]worker.Live{"ih3": {Progress: 0.46, DlSpeed: 6400000, ETA: 720}}} + h := testRouterLive(t, stubReader{list: []store.Download{dl}}, stubReviewer{}, lv) + + rr := get(t, h, "/") + if rr.Code != http.StatusOK { + t.Fatalf("status = %d, want 200", rr.Code) + } + body := rr.Body.String() + for _, want := range []string{`class="progress"`, "width:46%", "/fragments/downloads/3/progress"} { + if !strings.Contains(body, want) { + t.Errorf("карточка без живого прогресса: нет %q", want) + } + } +} + +// TestFragNotFound: фрагмент несуществующей задачи → 404. +func TestFragNotFound(t *testing.T) { + h := testRouterLive(t, stubReader{}, stubReviewer{}, stubLive{}) + if rr := get(t, h, "/fragments/downloads/404/progress"); rr.Code != http.StatusNotFound { + t.Fatalf("status = %d, want 404", rr.Code) + } +} diff --git a/internal/httpapi/render_test.go b/internal/httpapi/render_test.go index 4e37291..ffc5b52 100644 --- a/internal/httpapi/render_test.go +++ b/internal/httpapi/render_test.go @@ -48,12 +48,26 @@ func (stubReviewer) ChooseCandidate(context.Context, int64, int64) error func (stubReviewer) SetProviderID(context.Context, int64, string, string) error { return nil } func (stubReviewer) ClearProvider(context.Context, int64) error { return nil } +// stubLive — заглушка источника живой телеметрии. +type stubLive struct{ m map[string]worker.Live } + +func (s stubLive) Live(infohash string) (worker.Live, bool) { + l, ok := s.m[infohash] + return l, ok +} + func testRouter(t *testing.T, r stubReader, rv stubReviewer) http.Handler { + t.Helper() + return testRouterLive(t, r, rv, stubLive{}) +} + +func testRouterLive(t *testing.T, r stubReader, rv stubReviewer, lv stubLive) http.Handler { t.Helper() h, err := NewRouter(Deps{ Logger: slog.New(slog.NewTextHandler(io.Discard, nil)), Reader: r, Reviewer: rv, + Live: lv, }) if err != nil { t.Fatalf("NewRouter: %v", err) diff --git a/internal/qbt/qbt.go b/internal/qbt/qbt.go index 10e4e52..a9ee666 100644 --- a/internal/qbt/qbt.go +++ b/internal/qbt/qbt.go @@ -44,7 +44,10 @@ type Client struct { mu sync.Mutex // сериализует логин } -// Torrent — подмножество полей /torrents/info, нужное jellybit. +// Torrent — подмножество полей /torrents/info, нужное jellybit. Помимо полей +// для машины состояний и сопоставления несёт живую телеметрию (скорости, ETA, +// статистика раздачи) — её собирает снимок воркера для веб-UI; все эти поля +// приходят в том же ответе, отдельного вызова не нужно. type Torrent struct { Hash string `json:"hash"` Name string `json:"name"` @@ -58,6 +61,15 @@ type Torrent struct { AddedOn int64 `json:"added_on"` InfohashV1 string `json:"infohash_v1"` InfohashV2 string `json:"infohash_v2"` + + // Живая телеметрия (для снимка воркера и веб-UI). + Dlspeed int64 `json:"dlspeed"` // скорость загрузки, байт/с + Upspeed int64 `json:"upspeed"` // скорость отдачи, байт/с + Eta int64 `json:"eta"` // оценка до завершения, с (8640000 ≈ ∞) + Ratio float64 `json:"ratio"` // рейтинг отдачи (может быть -1 = ∞/н/д) + NumSeeds int `json:"num_seeds"` // подключённые сиды + NumLeechs int `json:"num_leechs"` // подключённые личи (пиры) + Uploaded int64 `json:"uploaded"` // отдано всего, байт } // File — элемент /torrents/files: путь файла относительно save_path diff --git a/internal/worker/live_test.go b/internal/worker/live_test.go new file mode 100644 index 0000000..8f60a54 --- /dev/null +++ b/internal/worker/live_test.go @@ -0,0 +1,62 @@ +package worker + +import ( + "context" + "testing" + + "git.vakhrushev.me/av/jellybit/internal/qbt" + "git.vakhrushev.me/av/jellybit/internal/store" +) + +// TestPollBuildsLiveSnapshot: после Poll снимок несёт телеметрию качающейся и +// сидирующей задач (с верным Seeding), доступную по любому из трёх хэшей; +// неизвестный/пустой infohash → ok=false. +func TestPollBuildsLiveSnapshot(t *testing.T) { + qb := &fakeQbt{torrents: []qbt.Torrent{ + // Качается (Category пуст → discover не усыновляет, store не мешает). + {Hash: "aaa", State: "downloading", Progress: 0.5, Dlspeed: 1000, Eta: 120}, + // Сидирует, торрент v2 (три ключа). + { + Hash: "bbb", InfohashV1: "bbb1", InfohashV2: "BBB2", + State: "uploading", Progress: 1.0, + Ratio: 2.5, NumSeeds: 3, NumLeechs: 1, Uploaded: 999, Upspeed: 50, + }, + }} + w := newTestWorker(&fakeStore{downloads: map[int64]*store.Download{}}, qb) + + if err := w.Poll(context.Background()); err != nil { + t.Fatalf("Poll: %v", err) + } + + dl, ok := w.Live("aaa") + if !ok { + t.Fatal("нет телеметрии качающейся задачи") + } + if dl.Seeding { + t.Error("качающаяся задача помечена Seeding") + } + if dl.Progress != 0.5 || dl.DlSpeed != 1000 || dl.ETA != 120 { + t.Errorf("телеметрия качания неверна: %+v", dl) + } + + // Сидирующая задача находится по любому из трёх хэшей (lowercase). + for _, h := range []string{"bbb", "bbb1", "BBB2", "bbb2"} { + sd, ok := w.Live(h) + if !ok { + t.Fatalf("нет телеметрии раздачи по ключу %q", h) + } + if !sd.Seeding { + t.Errorf("ключ %q: Seeding=false для uploading", h) + } + if sd.Ratio != 2.5 || sd.Seeds != 3 || sd.Peers != 1 || sd.Uploaded != 999 || sd.UpSpeed != 50 { + t.Errorf("ключ %q: статистика раздачи неверна: %+v", h, sd) + } + } + + if _, ok := w.Live("unknownhash"); ok { + t.Error("неизвестный infohash вернул ok=true") + } + if _, ok := w.Live(""); ok { + t.Error("пустой infohash вернул ok=true") + } +} diff --git a/internal/worker/worker.go b/internal/worker/worker.go index bf6e1fe..26776a3 100644 --- a/internal/worker/worker.go +++ b/internal/worker/worker.go @@ -134,6 +134,40 @@ type Config struct { SourceMissingThreshold int } +// Live — живая телеметрия одной раздачи из снимка воркера. Курированный срез +// qbt.Torrent: читатели (httpapi) не зависят от пакета qbt, контракт чтения +// узкий. Seeding вычисляется воркером (classify), чтобы трактовка завершённости +// не дублировалась в транспорте. +type Live struct { + Progress float64 // доля 0..1 + DlSpeed int64 // скорость загрузки, байт/с + ETA int64 // оценка до завершения, с (8640000 ≈ ∞) + State string // сырое состояние qBittorrent + Seeding bool // торрент завершён и раздаётся + + Ratio float64 // рейтинг отдачи (может быть <0 = ∞/н/д) + Seeds int // подключённые сиды + Peers int // подключённые личи + Uploaded int64 // отдано всего, байт + UpSpeed int64 // скорость отдачи, байт/с +} + +// liveFrom собирает Live из торрента qBittorrent (Seeding — через classify). +func liveFrom(t qbt.Torrent) Live { + return Live{ + Progress: t.Progress, + DlSpeed: t.Dlspeed, + ETA: t.Eta, + State: t.State, + Seeding: classify(t.State) == classReady, + Ratio: t.Ratio, + Seeds: t.NumSeeds, + Peers: t.NumLeechs, + Uploaded: t.Uploaded, + UpSpeed: t.Upspeed, + } +} + // Worker — поллер и владелец переходов. type Worker struct { store Store @@ -149,6 +183,14 @@ type Worker struct { notifier Notifier // опц. исходящие пинги scanner Scanner // опц. пересканирование Jellyfin + // live — снимок живой телеметрии раздач (ключ — lowercase infohash, по три + // ключа на торрент, как byHash). Обновляется атомарным свопом карты на + // каждом тике Poll. Отдельный RWMutex (не w.mu): UI читает телеметрию часто, + // смешивать частые чтения с замком переходов — лишняя конкуренция. Снимок + // волатилен, в БД не хранится. + liveMu sync.RWMutex + live map[string]Live + // failNotified — дебаунс повторных EventFailed по задаче (download_id → // время последнего пинга). Мерцающий stalled-торрент колеблется // stuck↔downloading; без дебаунса каждый цикл слал бы уведомление. Память @@ -179,9 +221,30 @@ func New(st Store, qb QBittorrent, rec Recognizer, lay Layouter, cfg Config, log now: time.Now, newID: defaultBatchID, failNotified: map[int64]time.Time{}, + live: map[string]Live{}, } } +// Live возвращает живую телеметрию раздачи по infohash (любому из v1/v2/hash). +// ok=false, если infohash пуст или раздачи не было в последнем тике поллинга — +// тогда читатель деградирует без живых значений. Чтение под RLock. +func (w *Worker) Live(infohash string) (Live, bool) { + if infohash == "" { + return Live{}, false + } + w.liveMu.RLock() + defer w.liveMu.RUnlock() + l, ok := w.live[strings.ToLower(infohash)] + return l, ok +} + +// setLive атомарно подменяет снимок телеметрии готовой картой. +func (w *Worker) setLive(snap map[string]Live) { + w.liveMu.Lock() + w.live = snap + w.liveMu.Unlock() +} + // defaultBatchID — уникальный идентификатор батча раскладки. func defaultBatchID() string { return fmt.Sprintf("b-%d", time.Now().UnixNano()) @@ -235,13 +298,20 @@ func (w *Worker) Poll(ctx context.Context) error { return fmt.Errorf("poll: list torrents: %w", err) } byHash := make(map[string]qbt.Torrent, len(torrents)*2) + live := make(map[string]Live, len(torrents)*2) for _, t := range torrents { + l := liveFrom(t) for _, h := range []string{t.Hash, t.InfohashV1, t.InfohashV2} { if h != "" { - byHash[strings.ToLower(h)] = t + key := strings.ToLower(h) + byHash[key] = t + live[key] = l } } } + // Снимок зависит только от torrents — свопаем сразу, до store-операций + // ниже (их ранний return по ошибке не должен лишать UI свежей телеметрии). + w.setLive(live) w.mu.Lock() defer w.mu.Unlock() diff --git a/openspec/changes/archive/2026-06-30-live-status/.openspec.yaml b/openspec/changes/archive/2026-06-30-live-status/.openspec.yaml new file mode 100644 index 0000000..d6b53de --- /dev/null +++ b/openspec/changes/archive/2026-06-30-live-status/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-06-30 diff --git a/openspec/changes/archive/2026-06-30-live-status/design.md b/openspec/changes/archive/2026-06-30-live-status/design.md new file mode 100644 index 0000000..5a5a920 --- /dev/null +++ b/openspec/changes/archive/2026-06-30-live-status/design.md @@ -0,0 +1,196 @@ +## Context + +Воркер (`internal/worker`) уже поллит qBittorrent каждые `PollInterval` (дефолт +5с): `Poll` тянет `Torrents(ctx, "")` (все торренты) и строит `byHash` +(infohash → `qbt.Torrent`) для reconcile/discovery. Это единственная точка в +системе, где есть свежее состояние раздач. Прогресс в БД не хранится (в +`store.Download` полей нет — и не нужно, значение волатильно). + +Веб-UI (`internal/httpapi`) server-rendered: `index.html` рендерит карточки из +`[]downloadView` (есть `Infohash`), `download.html` — детальную страницу из +`downloadDetailView`. htmx уже вендорится и грузится во всех шаблонах, но ни +одного `hx-*` пока нет. Зависимости транспорта собираются в `cmd/jellybit/serve.go` +(`httpapi.Deps`), воркер `wrk` уже передаётся как `Commander`/`Reviewer`. + +Сейчас прогресс виден только при ручной перезагрузке, статистики раздачи нет. + +## Goals / Non-Goals + +**Goals:** + +- Живой прогресс активных загрузок на главной без перезагрузки страницы и без + сброса клиентских фильтров/поиска. +- Секция «Раздача» на странице загрузки (рейтинг, сиды/пиры, отдано, скорость + отдачи). +- Снимок телеметрии в памяти воркера, обновляемый на том же тике поллинга, без + лишних сетевых вызовов и без записи в БД. +- Контракт чтения снимка изолирован так, чтобы позже заменить поллинг на SSE + без переделки доменного слоя. + +**Non-Goals:** + +- SSE/WebSocket в этой фазе (только htmx-поллинг фрагментов). +- Хранение истории прогресса/скорости в БД, графики. +- Управление раздачей (пауза/лимиты/удаление торрента) — источник + неприкосновенен. +- Живое обновление страницы ревью. + +## Decisions + +### 1. Снимок в воркере, ключ — infohash + +Воркер ведёт `map[string]Live` (ключ — lowercase infohash), обновляемый +целиком (swap готовой карты) под **отдельным** `sync.RWMutex` — не `w.mu`. +Причина: `w.mu` сериализует переходы состояний и команды (cancel/retry), а UI +читает телеметрию часто (каждые несколько секунд × число вкладок); смешивать +частые чтения с замком переходов — лишняя конкуренция. Снимок строится из уже +полученного `torrents` — дополнительного вызова qBittorrent нет. + +**Три ключа на торрент.** Как и существующий `byHash` (`worker.go:237-244`), +снимок кладёт каждую раздачу под все три значения: `t.Hash`, `t.InfohashV1`, +`t.InfohashV2` (lowercase). Иначе v2-торрент, у которого `d.Infohash` = +infohash_v1, а в карту положили только по `Hash`, не найдётся при чтении. + +**Момент свопа — сразу после построения карты, до store-операций.** `Poll` +после получения `torrents` делает ещё несколько шагов с ранними `return` по +ошибкам store (`ListDownloadsByState` и т.п.). Снимок зависит только от +`torrents`, поэтому собираем и свопаем его сразу после построения `byHash` — +тогда телеметрия обновится даже если последующий reconcile упадёт. + +Ключ по infohash, а не по `download_id`: `Poll` уже держит торренты по +infohash, а каждая `downloadView`/`downloadDetailView` несёт `Infohash`. Это +избавляет воркер от загрузки всех записей `store.Download` ради маппинга id и +естественно покрывает и активные, и сидирующие задачи (снимок = все торренты +последнего тика). Дедуп активных по infohash уже гарантирован воркером. + +_Альтернатива (ключ download_id, как в исходной заметке):_ потребовал бы в +снимке резолвить torrent→download_id, т.е. держать обратный маппинг и грузить +записи. Отверг — infohash проще и уже под рукой на обеих сторонах. + +### 2. Тип `worker.Live` — курированный, не `qbt.Torrent` + +В снимок кладём отдельный `worker.Live{Progress, DlSpeed, ETA, State, Seeding, +Ratio, Seeds, Peers, Uploaded, UpSpeed}`, а не сырой `qbt.Torrent`. Так +`httpapi` не зависит от пакета qbt и контракт чтения остаётся узким (легче +заменить источник). `qbt.Torrent` дополняется полями `Dlspeed`, `Eta`, +`Ratio`, `NumSeeds`, `NumLeechs`, `Uploaded`, `Upspeed` (теги `json:"dlspeed"` +и т.д.) — они приходят в том же ответе `/torrents/info`. + +**Флаг «сидирует» вычисляет воркер.** «Сидирует» — это свойство qbt-состояния +(`uploading`/`stalledUP`/…), а не доменного состояния задачи в БД. Воркер уже +владеет `classify` (`worker.go:466-479`), поэтому при сборке снимка он считает +`Seeding bool` (= `classify(state) == classReady`) и кладёт в `Live`. Так +httpapi не дублирует перечень qbt-состояний и трактовка завершённости живёт в +одном месте. + +### 3. Контракт чтения: dep-интерфейс `LiveStatus` в httpapi + +```go +// в internal/httpapi +type LiveStatus interface { + Live(infohash string) (worker.Live, bool) // ok=false → нет данных +} +``` + +Воркер реализует `Live(infohash string) (Live, bool)` чтением снимка под +RLock. В `httpapi.Deps` добавляется поле `Live LiveStatus`; в `serve.go` +передаётся тот же `wrk`. `bool` — явный признак отсутствия (graceful +degradation). Геттер по одному infohash достаточен: и список (по карточке), и +страница загрузки читают по одной задаче. + +### 4. Доставка в браузер: per-card htmx-поллинг, swap фрагмента карточки + +На главной каждая карточка **активной** загрузки содержит элемент с +`hx-get="/fragments/downloads/{id}/progress"`, `hx-trigger="every Ns"`, +`hx-swap="outerHTML"`. Поллит не весь список, а свой прогресс-блок — поэтому +клиентские фильтры/поиск/прокрутка (на уровне `#list`) не затрагиваются. + +Когда задача покидает `downloading`, фрагмент возвращается **без** htmx-атрибутов +поллинга (или с `hx-trigger` снятым) — поллинг сам собой прекращается. Смена +набора действий/бейджа при завершении остаётся за обычной навигацией/ручным +обновлением (живой пересбор всей карточки с действиями — вне scope, чтобы не +дублировать в htmx-ветке логику доступных действий). На странице загрузки +секция «Раздача» поллится аналогично (`/fragments/downloads/{id}/seeding`), +пока задача сидирует. + +**Интервал поллинга UI — фиксированный 3s** (статичный `hx-trigger="every 3s"` +в шаблоне). Решение: не прокидывать `PollInterval` в шаблоны — для однопользова- +тельского домашнего сервиса лишние идентичные ответы при `PollInterval` > 3s +ничего не стоят, а плавность и простота важнее. Браузер читает только снимок — +qBittorrent при этом не дёргается (Decision 1), так что «лишние» запросы не +доходят до qBittorrent. Требование спеки сформулировано соответственно (ключевой +инвариант — браузер не опрашивает qBittorrent напрямую и не видит данные свежее +тика, а не «строго не чаще тика»). + +_Альтернатива (интервал из `PollInterval`):_ буквально «не чаще, чем меняются +данные», но требует проводки cfg → Deps → шаблон ради экономии, которой здесь +нет. Отверг. _Альтернатива (swap всего `#list`):_ сбрасывал бы клиентские +фильтры (они на JS через display) — отверг. _SSE:_ отложено, контракт +`LiveStatus` оставляет путь. + +### 5. Фрагмент-роуты и шаблоны-партиалы + +Новые роуты в `NewRouter`: `GET /fragments/downloads/{id}/progress` и +`GET /fragments/downloads/{id}/seeding`. Оба отдают HTML-партиал (не JSON): +читают `GetDownload` (для infohash/состояния) + `Live(infohash)` и рендерят +партиал `partials/progress.html` / `partials/seeding.html`. + +**Начальный кадр — сразу со значениями (без мигания).** Те же партиалы +включаются при первом полном рендере карточки/страницы, и значения для них +готовятся там же: `handleIndex`/`handleDownload` для нужных задач тоже зовут +`s.deps.Live.Live(infohash)` и кладут телеметрию в view-модель. Для этого +`downloadView` (карточка) и `downloadDetailView` (страница) расширяются полями +телеметрии (та же модель, что отдаёт фрагмент-роут) — один источник разметки и +данных для начального рендера и для поллинга. При `ok=false` партиал рендерит +нейтральный плейсхолдер/скрывает секцию (graceful degradation), а не падает. + +Уровень лога для фрагментов — DEBUG (навигационный GET, как прочие страницы; +`requestLogLevel` уже понижает не-`/api` GET — отдельной правки логов не нужно). + +## Risks / Trade-offs + +- **[Карточка и фрагмент рассинхронятся по доступным действиям]** при + завершении загрузки фоновым поллингом обновится только прогресс-блок, а + кнопки — нет → Mitigation: фрагмент при выходе из `downloading` гасит свой + поллинг и показывает финальное состояние прогресс-блока; полный актуальный + набор действий пользователь видит при следующем заходе/обновлении. Это + осознанный компромисс ради простоты (не тянем логику действий в htmx-ветку). +- **[Снимок устаревает на рестарте]** до первого тика снимок пуст → + Mitigation: `Live` возвращает `ok=false`, UI рендерит без живых значений + (спека требует graceful degradation). +- **[Рост карты снимка]** свопаем карту целиком на каждом тике из актуального + списка торрентов → исчезнувшие раздачи естественно выпадают, утечки нет. +- **[Гонка чтения/записи снимка]** → отдельный `RWMutex`, запись — atomic swap + готовой карты в конце `Poll`, чтения под RLock. +- **[Пустой infohash]** у задачи (теоретически) → `Live("")` возвращает + `ok=false`, без паник. +- **[Sentinel-значения qBittorrent]** `eta=8640000` означает «∞/неизвестно», + `ratio` может быть `-1`, скорости/размеры — в байтах → Mitigation: хелперы + форматирования (httpapi) трактуют sentinel'ы явно (ETA → «—»/«∞», `ratio<0` → + «—») и переводят байты в человекочитаемые единицы. Берём `num_seeds`/ + `num_leechs` (подключённые пиры), не `num_complete`/`num_incomplete` (рой) — + выбор фиксируем в хелпере/партиале. + +## Migration Plan + +Изменение аддитивное: новые поля `qbt.Torrent` (обратносовместимо), новый +снимок и геттер в воркере, новый dep + роуты в httpapi, htmx-атрибуты в +шаблонах. БД не меняется — миграций нет. Откат — обратный revert коммита; +рантайм-состояние волатильно, чистить нечего. Деплой — обычный (бинарь на +umbar). + +## Open Questions + +Разрешены на чекпоинте ревью дизайна: + +- **Начальный кадр** — рендерим сразу со значениями (Decision 5): `handleIndex`/ + `handleDownload` читают `Live` и кладут телеметрию во view. +- **«Сидирует»** — вычисляемый флаг `Seeding` в `worker.Live` на базе + `classify` (Decision 2), без дублирования состояний в httpapi. +- **Интервал поллинга** — фиксированный `every 3s` в шаблоне (Decision 4), + `PollInterval` в шаблоны не прокидывается; формулировка спеки смягчена. + +Остаётся уточнить при apply (мелочь, не блокер): + +- Конкретные единицы/стиль отображения скоростей и размеров — согласовать с + дизайн-системой (`jellybit.css`). diff --git a/openspec/changes/archive/2026-06-30-live-status/proposal.md b/openspec/changes/archive/2026-06-30-live-status/proposal.md new file mode 100644 index 0000000..742fa23 --- /dev/null +++ b/openspec/changes/archive/2026-06-30-live-status/proposal.md @@ -0,0 +1,57 @@ +## Why + +Сейчас прогресс скачивания в веб-UI виден только при ручной перезагрузке +страницы, а статистики раздачи (рейтинг, сиды/пиры, отдано) нет вовсе — в +фазе 1 секцию намеренно отложили до появления живых данных. Воркер уже +опрашивает qBittorrent каждые несколько секунд и держит свежее состояние +каждой задачи — нужно лишь сохранить эту телеметрию в памяти и показать её в +UI без перезагрузки. Это «однооконный» сервис: смотреть, как идёт загрузка, +должно быть видно вживую. + +## What Changes + +- Воркер на каждом тике поллинга сохраняет **in-memory снимок** телеметрии + всех известных раздач (прогресс, скорость загрузки, ETA + для раздачи: + рейтинг, число сидов/пиров, отдано, скорость отдачи). Снимок волатильный, + в БД не пишется. +- `qbt.Torrent` дополняется полями телеметрии (`dlspeed`, `eta`, `ratio`, + `num_seeds`, `num_leechs`, `uploaded`, `upspeed`) из `/torrents/info` — + лишний сетевой вызов не добавляется, они приходят в том же ответе. +- `httpapi` получает новую зависимость-источник телеметрии и **фрагмент-роут**; + активные карточки на главной поллят свой прогресс через htmx и обновляют + прогресс-бар/скорость/ETA на месте, без перезагрузки страницы и без сброса + клиентских фильтров. +- На странице загрузки `/download/{id}` появляется секция **«Раздача»** с + живой статистикой (рейтинг, сиды/пиры, отдано, скорость отдачи) для задач, + чей торрент сидирует. +- htmx, который уже вендорится и грузится, впервые задействуется (`hx-*`). + +## Capabilities + +### New Capabilities + +- `live-status`: живая телеметрия загрузок и раздач — воркер как единственный + сэмплер qBittorrent ведёт in-memory снимок прогресса/скорости/ETA и + статистики раздачи; веб-UI отображает её в реальном времени (поллинг + фрагментов) без перезагрузки страницы и без хранения в БД. + +### Modified Capabilities + +- `web-ui`: страница загрузки `/download/{id}` получает секцию живой + статистики раздачи (в фазе 1 явно вынесена из scope «вводится вместе с + живыми обновлениями»); карточки активных загрузок на главной показывают + живой прогресс-бар через htmx-поллинг. + +## Impact + +- **Код:** `internal/qbt` (поля `Torrent`), `internal/worker` (снимок + + геттер телеметрии под отдельным RWMutex), `internal/httpapi` (dep-интерфейс + `LiveStatus`, фрагмент-роут, секция раздачи, прогресс во view), `cmd/jellybit` + (инъекция снимка в `httpapi.Deps`), `web/templates` (прогресс-бар на + карточках + htmx-атрибуты, секция «Раздача»), `web/static/css` при + необходимости (стили прогресс-бара уже есть в дизайн-системе). +- **БД:** изменений нет — телеметрия волатильна, миграций/ER не требуется. +- **Внешние вызовы:** дополнительных нет (расширяется только разбор + существующего ответа `/torrents/info`). +- **Безопасность:** телеметрия не содержит секретов; контракт чтения снимка + изолируется так, чтобы будущий апгрейд доставки до SSE не ломал API. diff --git a/openspec/changes/archive/2026-06-30-live-status/specs/live-status/spec.md b/openspec/changes/archive/2026-06-30-live-status/specs/live-status/spec.md new file mode 100644 index 0000000..8a603fc --- /dev/null +++ b/openspec/changes/archive/2026-06-30-live-status/specs/live-status/spec.md @@ -0,0 +1,111 @@ +## ADDED Requirements + +### Requirement: Снимок живой телеметрии + +Воркер — единственный сэмплер qBittorrent — SHALL на каждом тике поллинга +обновлять in-memory снимок телеметрии всех известных раздач. Снимок MUST NOT +персиститься в БД: он волатилен и переживает только до рестарта процесса. + +#### Scenario: Обновление снимка на тике + +- **WHEN** воркер завершает успешный тик поллинга qBittorrent +- **THEN** снимок телеметрии содержит актуальные данные по каждой раздаче, + присутствующей в ответе qBittorrent + +#### Scenario: Снимок волатилен + +- **WHEN** процесс только что перезапущен и первый тик поллинга ещё не прошёл +- **THEN** снимок пуст, а UI отображает задачи без живых значений, не падая + +### Requirement: Состав телеметрии + +Телеметрия одной раздачи SHALL включать прогресс (доля 0..1), скорость +загрузки и ETA, а для сидирующих раздач дополнительно — рейтинг, число сидов +и пиров, объём отданного и скорость отдачи. Значения SHALL извлекаться из +ответа qBittorrent `/torrents/info` без дополнительного сетевого вызова. + +#### Scenario: Телеметрия качающейся задачи + +- **WHEN** торрент задачи находится в состоянии загрузки +- **THEN** в снимке для неё доступны прогресс, скорость загрузки и ETA + +#### Scenario: Телеметрия сидирующей задачи + +- **WHEN** торрент задачи завершён и раздаётся +- **THEN** в снимке для неё доступны рейтинг, число сидов/пиров, объём + отданного и скорость отдачи + +### Requirement: Чтение телеметрии транспортом + +Сервис SHALL предоставлять чтение снимка телеметрии по задаче через +изолированный контракт, не зависящий от способа доставки в браузер (поллинг +сейчас, SSE в будущем). Если для задачи нет записи в снимке (соответствующий +торрент отсутствовал в qBittorrent на последнем тике), чтение SHALL сообщать +об отсутствии данных, а UI MUST деградировать без живых значений, не падая. + +#### Scenario: Данные есть + +- **WHEN** транспорт читает телеметрию задачи, чей торрент был в последнем тике +- **THEN** он получает живые значения этой задачи + +#### Scenario: Данных нет + +- **WHEN** транспорт читает телеметрию задачи, торрента которой нет в qBittorrent +- **THEN** он получает признак отсутствия данных и рендерит страницу без живых + значений + +### Requirement: Свежесть не выше тика поллинга + +Живые значения, видимые в браузере, SHALL быть не свежее последнего тика +поллинга воркера; браузер MUST NOT опрашивать qBittorrent напрямую. Любой +запрос UI за телеметрией SHALL обслуживаться из in-memory снимка, не порождая +обращения к qBittorrent — поэтому частота обновления UI может быть выбрана +свободно (в т.ч. чаще тика для плавности), не нагружая qBittorrent. + +#### Scenario: Браузер не обгоняет воркер + +- **WHEN** браузер запрашивает фрагмент телеметрии чаще, чем длится тик + поллинга +- **THEN** он получает значения последнего тика, и обращения к qBittorrent при + этом не происходит + +### Requirement: Живой прогресс активных загрузок + +Веб-UI SHALL обновлять прогресс активных (downloading) загрузок на главной без +перезагрузки страницы — поллингом фрагмента через htmx. Обновление MUST NOT +сбрасывать клиентские фильтр, поиск и прокрутку. Когда задача покидает +состояние downloading, поллинг её прогресса SHALL прекращаться. + +#### Scenario: Прогресс растёт без перезагрузки + +- **WHEN** загрузка качается и пользователь смотрит на главную +- **THEN** её прогресс-бар, скорость и ETA обновляются на месте без + перезагрузки страницы + +#### Scenario: Клиентское состояние сохраняется + +- **WHEN** применён фильтр или поиск и происходит фоновое обновление прогресса +- **THEN** выбранный фильтр, текст поиска и позиция прокрутки не сбрасываются + +#### Scenario: Завершение останавливает поллинг + +- **WHEN** загрузка переходит из downloading в другое состояние +- **THEN** фоновый поллинг прогресса для этой карточки прекращается + +### Requirement: Секция раздачи на странице загрузки + +Страница `/download/{id}` SHALL показывать секцию «Раздача» с живой статистикой +(рейтинг, число сидов и пиров, объём отданного, скорость отдачи) для задач, +чей торрент сидирует. Если живых данных по задаче нет, секция SHALL +отсутствовать либо явно показывать «нет данных», не ломая остальную страницу. + +#### Scenario: Сидирующая задача показывает раздачу + +- **WHEN** открыта страница задачи, торрент которой раздаётся +- **THEN** в секции «Раздача» видны рейтинг, сиды/пиры, отдано и скорость отдачи + +#### Scenario: Нет живых данных — секция деградирует + +- **WHEN** открыта страница задачи, торрента которой нет в qBittorrent +- **THEN** секция «Раздача» отсутствует или показывает «нет данных», а + распознавание, файлы и история отображаются нормально diff --git a/openspec/changes/archive/2026-06-30-live-status/specs/web-ui/spec.md b/openspec/changes/archive/2026-06-30-live-status/specs/web-ui/spec.md new file mode 100644 index 0000000..7902533 --- /dev/null +++ b/openspec/changes/archive/2026-06-30-live-status/specs/web-ui/spec.md @@ -0,0 +1,30 @@ +## MODIFIED Requirements + +### Requirement: Страницы веб-UI + +Веб-UI SHALL предоставлять страницы: список загрузок с единым окном +добавления и фильтром/поиском (`/`), экран ревью одной загрузки (`/review/{id}`) +и страницу просмотра одной загрузки (`/download/{id}`) с распознаванием, +файлами→раскладкой, историей и — для сидирующих задач — секцией живой +статистики раздачи. Карточки активных (downloading) загрузок в списке SHALL +содержать индикатор прогресса. Состояние `deleted` SHALL быть скрыто в списке +по умолчанию (с переключателем «показать всё»). Механика живого обновления +прогресса и наполнение секции раздачи определяются capability `live-status`. + +#### Scenario: Просмотр одной загрузки + +- **WHEN** клиент открывает `GET /download/{id}` существующей загрузки +- **THEN** отрисовывается страница с её распознаванием, файлами, раскладкой и + историей + +#### Scenario: Прогресс активной загрузки в списке + +- **WHEN** в списке есть загрузка в состоянии `downloading` +- **THEN** её карточка содержит индикатор прогресса (прогресс-бар со скоростью + и ETA) + +#### Scenario: Удалённые скрыты по умолчанию + +- **WHEN** в списке есть загрузки в состоянии `deleted` и фильтр «показать + всё» не включён +- **THEN** они не отображаются, но доступны при включённом переключателе diff --git a/openspec/changes/archive/2026-06-30-live-status/tasks.md b/openspec/changes/archive/2026-06-30-live-status/tasks.md new file mode 100644 index 0000000..8fe5b6f --- /dev/null +++ b/openspec/changes/archive/2026-06-30-live-status/tasks.md @@ -0,0 +1,64 @@ +## 1. Телеметрия в клиенте qBittorrent + +- [x] 1.1 Добавить в `qbt.Torrent` поля `Dlspeed`, `Eta`, `Ratio`, `NumSeeds`, + `NumLeechs`, `Uploaded`, `Upspeed` с json-тегами из `/torrents/info` + (`dlspeed`, `eta`, `ratio`, `num_seeds`, `num_leechs`, `uploaded`, `upspeed`) +- [x] 1.2 Обновить комментарий-доку `Torrent` (подмножество полей расширено) + +## 2. Снимок телеметрии в воркере + +- [x] 2.1 Добавить тип `worker.Live{Progress, DlSpeed, ETA, State, Seeding, + Ratio, Seeds, Peers, Uploaded, UpSpeed}` (курированный, без зависимости на + qbt у читателей); `Seeding` вычислять через `classify(state) == classReady` +- [x] 2.2 Добавить в `Worker` поле снимка `live map[string]Live` + отдельный + `sync.RWMutex` (не `w.mu`); инициализация в `New` +- [x] 2.3 В `Poll` собрать новую карту из полученного `torrents` — **три ключа + на торрент** (lowercase `Hash`/`InfohashV1`/`InfohashV2`, как `byHash`) — и + атомарно подменить снимок **сразу после построения `byHash`, до store- + операций** (телеметрия обновляется даже при последующем сбое reconcile) +- [x] 2.4 Реализовать `Live(infohash string) (Live, bool)` — чтение под RLock, + `ok=false` при пустом/неизвестном infohash +- [x] 2.5 Тесты: снимок обновляется после `Poll`; `Live` отдаёт данные + качающейся и сидирующей задачи (с верным `Seeding`); поиск по любому из трёх + хэшей; неизвестный/пустой infohash → `ok=false` + +## 3. Транспорт: контракт и фрагмент-роуты + +- [x] 3.1 Объявить интерфейс `LiveStatus` в `internal/httpapi` и добавить поле + `Live LiveStatus` в `Deps` +- [x] 3.2 Прокинуть `Live: wrk` в `httpapi.Deps` в `cmd/jellybit/serve.go` +- [x] 3.3 Добавить роуты `GET /fragments/downloads/{id}/progress` и + `GET /fragments/downloads/{id}/seeding` в `NewRouter` +- [x] 3.4 Реализовать обработчики фрагментов: `GetDownload` → infohash/состояние + + `Live(infohash)`; рендер партиала; `ok=false` → деградация без живых значений +- [x] 3.5 **Начальный рендер со значениями:** расширить `downloadView` и + `downloadDetailView` полями телеметрии; в `handleIndex`/`handleDownload` + вызвать `Live(infohash)` для нужных задач и положить во view — одна модель для + страницы и для фрагмент-роута (без мигания при первой загрузке) +- [x] 3.6 Добавить хелперы форматирования скорости/размера/ETA: человекочитаемые + единицы (байты→КиБ/МиБ, скорость/с), sentinel'ы qBittorrent (`eta=8640000`→ + «—»/«∞», `ratio<0`→«—»); зафиксировать выбор `num_seeds`/`num_leechs` + +## 4. Шаблоны и живое обновление + +- [x] 4.1 Партиал `partials/progress.html` (прогресс-бар + скорость + ETA); + включить его в карточку `index.html` для активных загрузок +- [x] 4.2 Навесить на прогресс-блок активной карточки `hx-get` / + `hx-trigger="every 3s"` / `hx-swap="outerHTML"`; при выходе из downloading + фрагмент не содержит атрибутов поллинга (поллинг прекращается) +- [x] 4.3 Партиал `partials/seeding.html` (рейтинг, сиды/пиры, отдано, скорость + отдачи); секция «Раздача» в `download.html` для сидирующих задач, с + htmx-поллингом и деградацией при отсутствии данных +- [x] 4.4 Проверить, что фоновое обновление не сбрасывает фильтр/поиск/прокрутку + на главной (поллинг точечный, на уровне карточки) +- [x] 4.5 При необходимости — стили прогресс-бара/раздачи в `jellybit.css` + (использовать существующие токены, без инлайн-хардкода цветов) + +## 5. Проверки и ревью + +- [x] 5.1 `task test` (включая новые тесты воркера и httpapi-рендера фрагментов; + в т.ч. тест «фрагмент при выходе из downloading отдаётся без htmx-атрибутов + поллинга») +- [x] 5.2 `task lint`, `gofmt`, `openspec validate --strict` +- [x] 5.3 Ревью кода (второй чекпоинт) перед archive: соответствие спекам и + конвенциям (логирование без секретов, ошибки `%w`, отдельный RWMutex) diff --git a/openspec/specs/live-status/spec.md b/openspec/specs/live-status/spec.md new file mode 100644 index 0000000..6c51907 --- /dev/null +++ b/openspec/specs/live-status/spec.md @@ -0,0 +1,115 @@ +# live-status Specification + +## Purpose +TBD - created by archiving change live-status. Update Purpose after archive. +## Requirements +### Requirement: Снимок живой телеметрии + +Воркер — единственный сэмплер qBittorrent — SHALL на каждом тике поллинга +обновлять in-memory снимок телеметрии всех известных раздач. Снимок MUST NOT +персиститься в БД: он волатилен и переживает только до рестарта процесса. + +#### Scenario: Обновление снимка на тике + +- **WHEN** воркер завершает успешный тик поллинга qBittorrent +- **THEN** снимок телеметрии содержит актуальные данные по каждой раздаче, + присутствующей в ответе qBittorrent + +#### Scenario: Снимок волатилен + +- **WHEN** процесс только что перезапущен и первый тик поллинга ещё не прошёл +- **THEN** снимок пуст, а UI отображает задачи без живых значений, не падая + +### Requirement: Состав телеметрии + +Телеметрия одной раздачи SHALL включать прогресс (доля 0..1), скорость +загрузки и ETA, а для сидирующих раздач дополнительно — рейтинг, число сидов +и пиров, объём отданного и скорость отдачи. Значения SHALL извлекаться из +ответа qBittorrent `/torrents/info` без дополнительного сетевого вызова. + +#### Scenario: Телеметрия качающейся задачи + +- **WHEN** торрент задачи находится в состоянии загрузки +- **THEN** в снимке для неё доступны прогресс, скорость загрузки и ETA + +#### Scenario: Телеметрия сидирующей задачи + +- **WHEN** торрент задачи завершён и раздаётся +- **THEN** в снимке для неё доступны рейтинг, число сидов/пиров, объём + отданного и скорость отдачи + +### Requirement: Чтение телеметрии транспортом + +Сервис SHALL предоставлять чтение снимка телеметрии по задаче через +изолированный контракт, не зависящий от способа доставки в браузер (поллинг +сейчас, SSE в будущем). Если для задачи нет записи в снимке (соответствующий +торрент отсутствовал в qBittorrent на последнем тике), чтение SHALL сообщать +об отсутствии данных, а UI MUST деградировать без живых значений, не падая. + +#### Scenario: Данные есть + +- **WHEN** транспорт читает телеметрию задачи, чей торрент был в последнем тике +- **THEN** он получает живые значения этой задачи + +#### Scenario: Данных нет + +- **WHEN** транспорт читает телеметрию задачи, торрента которой нет в qBittorrent +- **THEN** он получает признак отсутствия данных и рендерит страницу без живых + значений + +### Requirement: Свежесть не выше тика поллинга + +Живые значения, видимые в браузере, SHALL быть не свежее последнего тика +поллинга воркера; браузер MUST NOT опрашивать qBittorrent напрямую. Любой +запрос UI за телеметрией SHALL обслуживаться из in-memory снимка, не порождая +обращения к qBittorrent — поэтому частота обновления UI может быть выбрана +свободно (в т.ч. чаще тика для плавности), не нагружая qBittorrent. + +#### Scenario: Браузер не обгоняет воркер + +- **WHEN** браузер запрашивает фрагмент телеметрии чаще, чем длится тик + поллинга +- **THEN** он получает значения последнего тика, и обращения к qBittorrent при + этом не происходит + +### Requirement: Живой прогресс активных загрузок + +Веб-UI SHALL обновлять прогресс активных (downloading) загрузок на главной без +перезагрузки страницы — поллингом фрагмента через htmx. Обновление MUST NOT +сбрасывать клиентские фильтр, поиск и прокрутку. Когда задача покидает +состояние downloading, поллинг её прогресса SHALL прекращаться. + +#### Scenario: Прогресс растёт без перезагрузки + +- **WHEN** загрузка качается и пользователь смотрит на главную +- **THEN** её прогресс-бар, скорость и ETA обновляются на месте без + перезагрузки страницы + +#### Scenario: Клиентское состояние сохраняется + +- **WHEN** применён фильтр или поиск и происходит фоновое обновление прогресса +- **THEN** выбранный фильтр, текст поиска и позиция прокрутки не сбрасываются + +#### Scenario: Завершение останавливает поллинг + +- **WHEN** загрузка переходит из downloading в другое состояние +- **THEN** фоновый поллинг прогресса для этой карточки прекращается + +### Requirement: Секция раздачи на странице загрузки + +Страница `/download/{id}` SHALL показывать секцию «Раздача» с живой статистикой +(рейтинг, число сидов и пиров, объём отданного, скорость отдачи) для задач, +чей торрент сидирует. Если живых данных по задаче нет, секция SHALL +отсутствовать либо явно показывать «нет данных», не ломая остальную страницу. + +#### Scenario: Сидирующая задача показывает раздачу + +- **WHEN** открыта страница задачи, торрент которой раздаётся +- **THEN** в секции «Раздача» видны рейтинг, сиды/пиры, отдано и скорость отдачи + +#### Scenario: Нет живых данных — секция деградирует + +- **WHEN** открыта страница задачи, торрента которой нет в qBittorrent +- **THEN** секция «Раздача» отсутствует или показывает «нет данных», а + распознавание, файлы и история отображаются нормально + diff --git a/openspec/specs/web-ui/spec.md b/openspec/specs/web-ui/spec.md index c35eb20..5527e16 100644 --- a/openspec/specs/web-ui/spec.md +++ b/openspec/specs/web-ui/spec.md @@ -9,7 +9,6 @@ self-hosted шрифтов, единая дизайн-система (`jellybit. спойлер контекста). Превью раскладки берётся из единой логики `internal/layout`, а не дублируется в шаблонах. Тонкий транспорт над ядром (приём — `ingest`, команды — `worker`, чтение — `store`). - ## Requirements ### Requirement: Встроенная отдача статики @@ -91,10 +90,11 @@ self-hosted шрифтов, единая дизайн-система (`jellybit. Веб-UI SHALL предоставлять страницы: список загрузок с единым окном добавления и фильтром/поиском (`/`), экран ревью одной загрузки (`/review/{id}`) и страницу просмотра одной загрузки (`/download/{id}`) с распознаванием, -файлами→раскладкой и историей. Состояние `deleted` SHALL -быть скрыто в списке по умолчанию (с переключателем «показать всё»). -(Раздел живой статистики раздачи на странице загрузки — вне scope этого change, -вводится вместе с живыми обновлениями.) +файлами→раскладкой, историей и — для сидирующих задач — секцией живой +статистики раздачи. Карточки активных (downloading) загрузок в списке SHALL +содержать индикатор прогресса. Состояние `deleted` SHALL быть скрыто в списке +по умолчанию (с переключателем «показать всё»). Механика живого обновления +прогресса и наполнение секции раздачи определяются capability `live-status`. #### Scenario: Просмотр одной загрузки @@ -102,6 +102,12 @@ self-hosted шрифтов, единая дизайн-система (`jellybit. - **THEN** отрисовывается страница с её распознаванием, файлами, раскладкой и историей +#### Scenario: Прогресс активной загрузки в списке + +- **WHEN** в списке есть загрузка в состоянии `downloading` +- **THEN** её карточка содержит индикатор прогресса (прогресс-бар со скоростью + и ETA) + #### Scenario: Удалённые скрыты по умолчанию - **WHEN** в списке есть загрузки в состоянии `deleted` и фильтр «показать diff --git a/web/static/css/jellybit.css b/web/static/css/jellybit.css index f1b1275..2e09398 100644 --- a/web/static/css/jellybit.css +++ b/web/static/css/jellybit.css @@ -216,6 +216,8 @@ a:hover{text-decoration:underline} /* прогресс */ .progress{height:5px;background:var(--surface-2);border-radius:3px;overflow:hidden;margin-top:10px} .progress i{display:block;height:100%;background:var(--st-prog);border-radius:3px;transition:width .3s} +.dl-live:empty{display:none} +.dl-stats{margin-top:6px;font-size:var(--fs-xs);color:var(--text-dim);font-variant-numeric:tabular-nums} /* ---------- 7. Спойлер ---------- */ details.spoiler{margin-top:var(--sp-3)} diff --git a/web/templates/download.html b/web/templates/download.html index 4b5bbcc..32105a2 100644 --- a/web/templates/download.html +++ b/web/templates/download.html @@ -69,6 +69,9 @@ {{end}} + + {{template "seeding" .Seeding}} +

История

diff --git a/web/templates/index.html b/web/templates/index.html index 098f713..2356e77 100644 --- a/web/templates/index.html +++ b/web/templates/index.html @@ -65,6 +65,8 @@ {{template "status_badge" .State}}
+ {{if .IsDownloading}}{{template "progress" .Progress}}{{end}} + {{if .Context}}
контекст diff --git a/web/templates/partials/progress.html b/web/templates/partials/progress.html new file mode 100644 index 0000000..7ffe26e --- /dev/null +++ b/web/templates/partials/progress.html @@ -0,0 +1,4 @@ +{{define "progress"}}
{{if .Active}} +
+ {{if .Has}}
{{.Percent}}% · ↓ {{.DlSpeed}}{{if .ETA}} · осталось {{.ETA}}{{end}}
{{end}} +{{end}}
{{end}} diff --git a/web/templates/partials/seeding.html b/web/templates/partials/seeding.html new file mode 100644 index 0000000..0655070 --- /dev/null +++ b/web/templates/partials/seeding.html @@ -0,0 +1,10 @@ +{{define "seeding"}}{{if .Has}}
+

Раздача

qBittorrent · источник продолжает раздаваться
+
+
{{.Percent}}%
скачано
+
{{.Ratio}}
рейтинг
+
{{.Uploaded}}
отдано
+
{{.Seeds}} / {{.Peers}}
сиды / пиры
+
↑ {{.UpSpeed}}
сейчас
+
+
{{else}}
{{end}}{{end}}