Живые обновления прогресса и раздел «Раздача» (live-status)
Воркер ведёт 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) <noreply@anthropic.com>
This commit is contained in:
@@ -154,6 +154,7 @@ func runServe(args []string) error {
|
|||||||
Commander: wrk,
|
Commander: wrk,
|
||||||
Reader: st,
|
Reader: st,
|
||||||
Reviewer: wrk,
|
Reviewer: wrk,
|
||||||
|
Live: wrk,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
|
|||||||
@@ -36,6 +36,9 @@ type downloadDetailView struct {
|
|||||||
Confidence string
|
Confidence string
|
||||||
Files []fileRow
|
Files []fileRow
|
||||||
|
|
||||||
|
// Живая статистика раздачи (заполняется из снимка воркера).
|
||||||
|
Seeding seedingView
|
||||||
|
|
||||||
// Действия по состоянию (как на главной).
|
// Действия по состоянию (как на главной).
|
||||||
Terminal bool
|
Terminal bool
|
||||||
Reviewable bool
|
Reviewable bool
|
||||||
@@ -104,5 +107,10 @@ func (s *server) handleDownload(w http.ResponseWriter, r *http.Request) {
|
|||||||
view.Files = buildFileRows(rd.Plan, rd.Preview)
|
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)
|
s.render(w, "download.html", view)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -52,6 +52,7 @@ type Deps struct {
|
|||||||
Commander Commander
|
Commander Commander
|
||||||
Reader Reader
|
Reader Reader
|
||||||
Reviewer Reviewer
|
Reviewer Reviewer
|
||||||
|
Live LiveStatus
|
||||||
}
|
}
|
||||||
|
|
||||||
type server struct {
|
type server struct {
|
||||||
@@ -80,6 +81,9 @@ func NewRouter(d Deps) (http.Handler, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
if d.Live == nil {
|
||||||
|
d.Live = noLive{} // источник телеметрии не подключён — деградируем штатно
|
||||||
|
}
|
||||||
s := &server{deps: d, tmpl: tmpl, assetVer: assetVer}
|
s := &server{deps: d, tmpl: tmpl, assetVer: assetVer}
|
||||||
|
|
||||||
r := chi.NewRouter()
|
r := chi.NewRouter()
|
||||||
@@ -95,6 +99,10 @@ func NewRouter(d Deps) (http.Handler, error) {
|
|||||||
// Веб-UI.
|
// Веб-UI.
|
||||||
r.Get("/", s.handleIndex)
|
r.Get("/", s.handleIndex)
|
||||||
r.Get("/download/{id}", s.handleDownload)
|
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", s.handleUIAdd)
|
||||||
r.Post("/ui/downloads/{id}/cancel", s.handleUICancel)
|
r.Post("/ui/downloads/{id}/cancel", s.handleUICancel)
|
||||||
r.Post("/ui/downloads/{id}/retry", s.handleUIRetry)
|
r.Post("/ui/downloads/{id}/retry", s.handleUIRetry)
|
||||||
@@ -148,12 +156,14 @@ type downloadView struct {
|
|||||||
SearchText string // haystack для клиентского поиска (lowercase)
|
SearchText string // haystack для клиентского поиска (lowercase)
|
||||||
Error string
|
Error string
|
||||||
Terminal bool
|
Terminal bool
|
||||||
Deleted bool // скрыт по умолчанию на главной
|
IsDownloading bool // активная загрузка → живой прогресс-бар + поллинг
|
||||||
Reviewable bool // review/deferred — есть экран ревью
|
Progress progressView // живой прогресс (заполняется в handleIndex из снимка)
|
||||||
Undoable bool // done — можно откатить раскладку
|
Deleted bool // скрыт по умолчанию на главной
|
||||||
Relinkable bool // reverted/cancelled/target_missing — можно перепривязать заново
|
Reviewable bool // review/deferred — есть экран ревью
|
||||||
Retriable bool // failed/stuck — можно повторить попытку
|
Undoable bool // done — можно откатить раскладку
|
||||||
Note string // пояснение рассинхрона (target_missing/orphaned/deleted)
|
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) {
|
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")}
|
view := indexView{Error: r.URL.Query().Get("err")}
|
||||||
for _, d := range downloads {
|
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)
|
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),
|
SearchText: strings.ToLower(d.SourceRef + " " + d.Infohash.String + " " + d.Context),
|
||||||
Error: d.ErrorMsg.String,
|
Error: d.ErrorMsg.String,
|
||||||
Terminal: d.State.IsTerminal(),
|
Terminal: d.State.IsTerminal(),
|
||||||
|
IsDownloading: d.State == store.StateDownloading,
|
||||||
Deleted: d.State == store.StateDeleted,
|
Deleted: d.State == store.StateDeleted,
|
||||||
Reviewable: d.State == store.StateReview || d.State == store.StateDeferred,
|
Reviewable: d.State == store.StateReview || d.State == store.StateDeferred,
|
||||||
Undoable: d.State == store.StateDone,
|
Undoable: d.State == store.StateDone,
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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) SetProviderID(context.Context, int64, string, string) error { return nil }
|
||||||
func (stubReviewer) ClearProvider(context.Context, int64) 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 {
|
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()
|
t.Helper()
|
||||||
h, err := NewRouter(Deps{
|
h, err := NewRouter(Deps{
|
||||||
Logger: slog.New(slog.NewTextHandler(io.Discard, nil)),
|
Logger: slog.New(slog.NewTextHandler(io.Discard, nil)),
|
||||||
Reader: r,
|
Reader: r,
|
||||||
Reviewer: rv,
|
Reviewer: rv,
|
||||||
|
Live: lv,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("NewRouter: %v", err)
|
t.Fatalf("NewRouter: %v", err)
|
||||||
|
|||||||
+13
-1
@@ -44,7 +44,10 @@ type Client struct {
|
|||||||
mu sync.Mutex // сериализует логин
|
mu sync.Mutex // сериализует логин
|
||||||
}
|
}
|
||||||
|
|
||||||
// Torrent — подмножество полей /torrents/info, нужное jellybit.
|
// Torrent — подмножество полей /torrents/info, нужное jellybit. Помимо полей
|
||||||
|
// для машины состояний и сопоставления несёт живую телеметрию (скорости, ETA,
|
||||||
|
// статистика раздачи) — её собирает снимок воркера для веб-UI; все эти поля
|
||||||
|
// приходят в том же ответе, отдельного вызова не нужно.
|
||||||
type Torrent struct {
|
type Torrent struct {
|
||||||
Hash string `json:"hash"`
|
Hash string `json:"hash"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
@@ -58,6 +61,15 @@ type Torrent struct {
|
|||||||
AddedOn int64 `json:"added_on"`
|
AddedOn int64 `json:"added_on"`
|
||||||
InfohashV1 string `json:"infohash_v1"`
|
InfohashV1 string `json:"infohash_v1"`
|
||||||
InfohashV2 string `json:"infohash_v2"`
|
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
|
// File — элемент /torrents/files: путь файла относительно save_path
|
||||||
|
|||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -134,6 +134,40 @@ type Config struct {
|
|||||||
SourceMissingThreshold int
|
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 — поллер и владелец переходов.
|
// Worker — поллер и владелец переходов.
|
||||||
type Worker struct {
|
type Worker struct {
|
||||||
store Store
|
store Store
|
||||||
@@ -149,6 +183,14 @@ type Worker struct {
|
|||||||
notifier Notifier // опц. исходящие пинги
|
notifier Notifier // опц. исходящие пинги
|
||||||
scanner Scanner // опц. пересканирование Jellyfin
|
scanner Scanner // опц. пересканирование Jellyfin
|
||||||
|
|
||||||
|
// live — снимок живой телеметрии раздач (ключ — lowercase infohash, по три
|
||||||
|
// ключа на торрент, как byHash). Обновляется атомарным свопом карты на
|
||||||
|
// каждом тике Poll. Отдельный RWMutex (не w.mu): UI читает телеметрию часто,
|
||||||
|
// смешивать частые чтения с замком переходов — лишняя конкуренция. Снимок
|
||||||
|
// волатилен, в БД не хранится.
|
||||||
|
liveMu sync.RWMutex
|
||||||
|
live map[string]Live
|
||||||
|
|
||||||
// failNotified — дебаунс повторных EventFailed по задаче (download_id →
|
// failNotified — дебаунс повторных EventFailed по задаче (download_id →
|
||||||
// время последнего пинга). Мерцающий stalled-торрент колеблется
|
// время последнего пинга). Мерцающий stalled-торрент колеблется
|
||||||
// stuck↔downloading; без дебаунса каждый цикл слал бы уведомление. Память
|
// stuck↔downloading; без дебаунса каждый цикл слал бы уведомление. Память
|
||||||
@@ -179,9 +221,30 @@ func New(st Store, qb QBittorrent, rec Recognizer, lay Layouter, cfg Config, log
|
|||||||
now: time.Now,
|
now: time.Now,
|
||||||
newID: defaultBatchID,
|
newID: defaultBatchID,
|
||||||
failNotified: map[int64]time.Time{},
|
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 — уникальный идентификатор батча раскладки.
|
// defaultBatchID — уникальный идентификатор батча раскладки.
|
||||||
func defaultBatchID() string {
|
func defaultBatchID() string {
|
||||||
return fmt.Sprintf("b-%d", time.Now().UnixNano())
|
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)
|
return fmt.Errorf("poll: list torrents: %w", err)
|
||||||
}
|
}
|
||||||
byHash := make(map[string]qbt.Torrent, len(torrents)*2)
|
byHash := make(map[string]qbt.Torrent, len(torrents)*2)
|
||||||
|
live := make(map[string]Live, len(torrents)*2)
|
||||||
for _, t := range torrents {
|
for _, t := range torrents {
|
||||||
|
l := liveFrom(t)
|
||||||
for _, h := range []string{t.Hash, t.InfohashV1, t.InfohashV2} {
|
for _, h := range []string{t.Hash, t.InfohashV1, t.InfohashV2} {
|
||||||
if h != "" {
|
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()
|
w.mu.Lock()
|
||||||
defer w.mu.Unlock()
|
defer w.mu.Unlock()
|
||||||
|
|||||||
@@ -0,0 +1,2 @@
|
|||||||
|
schema: spec-driven
|
||||||
|
created: 2026-06-30
|
||||||
@@ -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`).
|
||||||
@@ -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.
|
||||||
@@ -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** секция «Раздача» отсутствует или показывает «нет данных», а
|
||||||
|
распознавание, файлы и история отображаются нормально
|
||||||
@@ -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** они не отображаются, но доступны при включённом переключателе
|
||||||
@@ -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)
|
||||||
@@ -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** секция «Раздача» отсутствует или показывает «нет данных», а
|
||||||
|
распознавание, файлы и история отображаются нормально
|
||||||
|
|
||||||
@@ -9,7 +9,6 @@ self-hosted шрифтов, единая дизайн-система (`jellybit.
|
|||||||
спойлер контекста). Превью раскладки берётся из единой логики `internal/layout`,
|
спойлер контекста). Превью раскладки берётся из единой логики `internal/layout`,
|
||||||
а не дублируется в шаблонах. Тонкий транспорт над ядром (приём — `ingest`,
|
а не дублируется в шаблонах. Тонкий транспорт над ядром (приём — `ingest`,
|
||||||
команды — `worker`, чтение — `store`).
|
команды — `worker`, чтение — `store`).
|
||||||
|
|
||||||
## Requirements
|
## Requirements
|
||||||
### Requirement: Встроенная отдача статики
|
### Requirement: Встроенная отдача статики
|
||||||
|
|
||||||
@@ -91,10 +90,11 @@ self-hosted шрифтов, единая дизайн-система (`jellybit.
|
|||||||
Веб-UI SHALL предоставлять страницы: список загрузок с единым окном
|
Веб-UI SHALL предоставлять страницы: список загрузок с единым окном
|
||||||
добавления и фильтром/поиском (`/`), экран ревью одной загрузки (`/review/{id}`)
|
добавления и фильтром/поиском (`/`), экран ревью одной загрузки (`/review/{id}`)
|
||||||
и страницу просмотра одной загрузки (`/download/{id}`) с распознаванием,
|
и страницу просмотра одной загрузки (`/download/{id}`) с распознаванием,
|
||||||
файлами→раскладкой и историей. Состояние `deleted` SHALL
|
файлами→раскладкой, историей и — для сидирующих задач — секцией живой
|
||||||
быть скрыто в списке по умолчанию (с переключателем «показать всё»).
|
статистики раздачи. Карточки активных (downloading) загрузок в списке SHALL
|
||||||
(Раздел живой статистики раздачи на странице загрузки — вне scope этого change,
|
содержать индикатор прогресса. Состояние `deleted` SHALL быть скрыто в списке
|
||||||
вводится вместе с живыми обновлениями.)
|
по умолчанию (с переключателем «показать всё»). Механика живого обновления
|
||||||
|
прогресса и наполнение секции раздачи определяются capability `live-status`.
|
||||||
|
|
||||||
#### Scenario: Просмотр одной загрузки
|
#### Scenario: Просмотр одной загрузки
|
||||||
|
|
||||||
@@ -102,6 +102,12 @@ self-hosted шрифтов, единая дизайн-система (`jellybit.
|
|||||||
- **THEN** отрисовывается страница с её распознаванием, файлами, раскладкой и
|
- **THEN** отрисовывается страница с её распознаванием, файлами, раскладкой и
|
||||||
историей
|
историей
|
||||||
|
|
||||||
|
#### Scenario: Прогресс активной загрузки в списке
|
||||||
|
|
||||||
|
- **WHEN** в списке есть загрузка в состоянии `downloading`
|
||||||
|
- **THEN** её карточка содержит индикатор прогресса (прогресс-бар со скоростью
|
||||||
|
и ETA)
|
||||||
|
|
||||||
#### Scenario: Удалённые скрыты по умолчанию
|
#### Scenario: Удалённые скрыты по умолчанию
|
||||||
|
|
||||||
- **WHEN** в списке есть загрузки в состоянии `deleted` и фильтр «показать
|
- **WHEN** в списке есть загрузки в состоянии `deleted` и фильтр «показать
|
||||||
|
|||||||
@@ -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{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}
|
.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. Спойлер ---------- */
|
/* ---------- 7. Спойлер ---------- */
|
||||||
details.spoiler{margin-top:var(--sp-3)}
|
details.spoiler{margin-top:var(--sp-3)}
|
||||||
|
|||||||
@@ -69,6 +69,9 @@
|
|||||||
</div>
|
</div>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|
||||||
|
<!-- Раздача (живая статистика; секция деградирует, если торрент не сидирует) -->
|
||||||
|
{{template "seeding" .Seeding}}
|
||||||
|
|
||||||
<!-- История -->
|
<!-- История -->
|
||||||
<div class="section">
|
<div class="section">
|
||||||
<div class="section-head"><h2>История</h2></div>
|
<div class="section-head"><h2>История</h2></div>
|
||||||
|
|||||||
@@ -65,6 +65,8 @@
|
|||||||
{{template "status_badge" .State}}
|
{{template "status_badge" .State}}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{{if .IsDownloading}}{{template "progress" .Progress}}{{end}}
|
||||||
|
|
||||||
{{if .Context}}
|
{{if .Context}}
|
||||||
<details class="spoiler">
|
<details class="spoiler">
|
||||||
<summary>контекст</summary>
|
<summary>контекст</summary>
|
||||||
|
|||||||
@@ -0,0 +1,4 @@
|
|||||||
|
{{define "progress"}}<div class="dl-live" id="dl-live-{{.ID}}"{{if .Active}} hx-get="/fragments/downloads/{{.ID}}/progress" hx-trigger="every 3s" hx-swap="outerHTML"{{end}}>{{if .Active}}
|
||||||
|
<div class="progress"><i style="width:{{.Percent}}%"></i></div>
|
||||||
|
{{if .Has}}<div class="dl-stats">{{.Percent}}% · ↓ {{.DlSpeed}}{{if .ETA}} · осталось {{.ETA}}{{end}}</div>{{end}}
|
||||||
|
{{end}}</div>{{end}}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
{{define "seeding"}}{{if .Has}}<div class="section" id="seeding-{{.ID}}" hx-get="/fragments/downloads/{{.ID}}/seeding" hx-trigger="every 3s" hx-swap="outerHTML">
|
||||||
|
<div class="section-head"><h2>Раздача</h2><span class="hint">qBittorrent · источник продолжает раздаваться</span></div>
|
||||||
|
<div class="stat-row">
|
||||||
|
<div class="stat"><div class="v good">{{.Percent}}%</div><div class="k">скачано</div></div>
|
||||||
|
<div class="stat"><div class="v mono">{{.Ratio}}</div><div class="k">рейтинг</div></div>
|
||||||
|
<div class="stat"><div class="v mono">{{.Uploaded}}</div><div class="k">отдано</div></div>
|
||||||
|
<div class="stat"><div class="v mono">{{.Seeds}} / {{.Peers}}</div><div class="k">сиды / пиры</div></div>
|
||||||
|
<div class="stat"><div class="v mono">↑ {{.UpSpeed}}</div><div class="k">сейчас</div></div>
|
||||||
|
</div>
|
||||||
|
</div>{{else}}<div id="seeding-{{.ID}}"></div>{{end}}{{end}}
|
||||||
Reference in New Issue
Block a user