Веб-UI: обзор жизненного цикла в карточке загрузки
Карточка списка на главной теперь даёт краткий обзор «от загрузки до
решения об удалении»: метка «ID:» перед идентификатором, дата добавления
(абсолютная + относительная, всегда), размер раздачи и рейтинг отдачи.
Спойлер контекста убран — контекст смотрят на /download/{id}.
Данные:
- рейтинг и общий размер — из живого снимка воркера (qbt total_size →
worker.Live.TotalSize); размер доступен для любой раздачи в снимке;
- размер-фолбэк, когда торрента нет в qBittorrent (orphaned) — сумма
размеров разложенных файлов: новая колонка file_link.size, layouter
пишет размер при линковке, ридер LayoutSizeByDownload суммирует по
странице одним запросом (дедуп по dst_path);
- дата — source_added_at → фолбэк created_at, показ в TZ сервера.
handleIndex читает снимок для всех карточек (map-lookup), рейтинг/размер
статичны на рендере (без поллинга). Миграция 0007, ER-схема обновлена.
Change download-card-lifecycle-overview влит в спеки и заархивирован.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,124 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.vakhrushev.me/av/jellybit/internal/store"
|
||||
"git.vakhrushev.me/av/jellybit/internal/worker"
|
||||
)
|
||||
|
||||
func TestHumanizeAge(t *testing.T) {
|
||||
now := time.Date(2026, 7, 4, 12, 0, 0, 0, time.UTC)
|
||||
cases := []struct {
|
||||
ago time.Duration
|
||||
want string
|
||||
}{
|
||||
{30 * time.Second, "только что"},
|
||||
{time.Minute, "1 минуту назад"},
|
||||
{5 * time.Minute, "5 минут назад"},
|
||||
{2 * time.Hour, "2 часа назад"},
|
||||
{24 * time.Hour, "1 день назад"},
|
||||
{5 * 24 * time.Hour, "5 дней назад"},
|
||||
{40 * 24 * time.Hour, "1 месяц назад"},
|
||||
{400 * 24 * time.Hour, "1 год назад"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := humanizeAge(now.Add(-c.ago), now); got != c.want {
|
||||
t.Errorf("humanizeAge(-%s) = %q, want %q", c.ago, got, c.want)
|
||||
}
|
||||
}
|
||||
// Будущее (рассинхрон часов) не должно давать «-N»: схлопывается в «только что».
|
||||
if got := humanizeAge(now.Add(time.Hour), now); got != "только что" {
|
||||
t.Errorf("humanizeAge(future) = %q, want «только что»", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlural(t *testing.T) {
|
||||
cases := []struct {
|
||||
n int
|
||||
want string
|
||||
}{{1, "день"}, {2, "дня"}, {4, "дня"}, {5, "дней"}, {11, "дней"}, {14, "дней"}, {21, "день"}, {22, "дня"}, {25, "дней"}}
|
||||
for _, c := range cases {
|
||||
if got := plural(c.n, "день", "дня", "дней"); got != c.want {
|
||||
t.Errorf("plural(%d) = %q, want %q", c.n, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSizeAndRatioText(t *testing.T) {
|
||||
// Снимок есть → размер из total_size, рейтинг из снимка.
|
||||
l := worker.Live{TotalSize: 2 << 30, Ratio: 1.42}
|
||||
if got := sizeText(l, true, 0); got != fmtBytes(2<<30) {
|
||||
t.Errorf("size (снимок) = %q, want %q", got, fmtBytes(2<<30))
|
||||
}
|
||||
if got := ratioText(l, true); got != "1.42" {
|
||||
t.Errorf("ratio (снимок) = %q, want 1.42", got)
|
||||
}
|
||||
// Снимка нет → размер из фолбэка по файлам, рейтинг «—».
|
||||
if got := sizeText(worker.Live{}, false, 512); got != fmtBytes(512) {
|
||||
t.Errorf("size (фолбэк) = %q, want %q", got, fmtBytes(512))
|
||||
}
|
||||
if got := ratioText(worker.Live{}, false); got != "—" {
|
||||
t.Errorf("ratio (нет снимка) = %q, want «—»", got)
|
||||
}
|
||||
// Ни снимка, ни файлов → «—».
|
||||
if got := sizeText(worker.Live{}, false, 0); got != "—" {
|
||||
t.Errorf("size (нет данных) = %q, want «—»", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAddedTimeFallback(t *testing.T) {
|
||||
// source_added_at приоритетнее created_at.
|
||||
added := time.Date(2026, 6, 30, 10, 0, 0, 0, time.UTC)
|
||||
created := time.Date(2026, 6, 1, 10, 0, 0, 0, time.UTC)
|
||||
d := store.Download{
|
||||
CreatedAt: store.FormatTime(created),
|
||||
SourceAddedAt: store.NullString(store.FormatTime(added)),
|
||||
}
|
||||
got, ok := addedTime(d)
|
||||
if !ok || !got.Equal(added) {
|
||||
t.Errorf("addedTime = %v (ok=%v), want %v", got, ok, added)
|
||||
}
|
||||
// Без source_added_at — фолбэк на created_at.
|
||||
d.SourceAddedAt = store.NullString("")
|
||||
got, ok = addedTime(d)
|
||||
if !ok || !got.Equal(created) {
|
||||
t.Errorf("addedTime (фолбэк) = %v (ok=%v), want %v", got, ok, created)
|
||||
}
|
||||
// Нечего парсить — ok=false.
|
||||
if _, ok := addedTime(store.Download{}); ok {
|
||||
t.Error("addedTime пустой должен вернуть ok=false")
|
||||
}
|
||||
}
|
||||
|
||||
// TestIndexCardMeta: карточка списка несёт обзорную мета-строку (метка ID, дата
|
||||
// добавления, размер и рейтинг из снимка) и НЕ показывает контекст.
|
||||
func TestIndexCardMeta(t *testing.T) {
|
||||
added := time.Date(2026, 6, 30, 10, 0, 0, 0, time.UTC)
|
||||
dl := store.Download{
|
||||
ID: testULID, SourceRef: "Dune", DisplayName: "Dune (2024)", Context: "секретный контекст",
|
||||
Infohashes: []store.Infohash{{DownloadID: testULID, Infohash: "ihmeta", Kind: store.HashV1}},
|
||||
State: store.StateDone,
|
||||
SourceAddedAt: store.NullString(store.FormatTime(added)),
|
||||
}
|
||||
lv := stubLive{m: map[string]worker.Live{"ihmeta": {Seeding: true, TotalSize: 2 << 30, Ratio: 1.42}}}
|
||||
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{"ID:", "2026-06-30", "назад", "1.42", fmtBytes(2 << 30)} {
|
||||
if !strings.Contains(body, want) {
|
||||
t.Errorf("карточка не содержит %q", want)
|
||||
}
|
||||
}
|
||||
// Контекст из карточки убран (доступен на /download/{id}).
|
||||
if strings.Contains(body, "секретный контекст") {
|
||||
t.Error("контекст всё ещё показан в карточке списка")
|
||||
}
|
||||
}
|
||||
@@ -45,6 +45,9 @@ type Reader interface {
|
||||
ListDownloads(ctx context.Context) ([]store.Download, error)
|
||||
ListDownloadsPage(ctx context.Context, f store.ListFilter) ([]store.Download, int, error)
|
||||
GetDownload(ctx context.Context, id string) (*store.Download, error)
|
||||
// LayoutSizeByDownload — суммарный размер разложенных файлов по каждой из
|
||||
// загрузок (фолбэк размера раздачи в карточке, когда торрента нет в снимке).
|
||||
LayoutSizeByDownload(ctx context.Context, ids []string) (map[string]int64, error)
|
||||
}
|
||||
|
||||
// Deps — зависимости транспорта.
|
||||
@@ -181,7 +184,6 @@ type pageLink struct {
|
||||
type downloadView struct {
|
||||
ID string
|
||||
Title string // отображаемый заголовок карточки
|
||||
Context string
|
||||
State string
|
||||
Error string
|
||||
Terminal bool
|
||||
@@ -192,6 +194,12 @@ type downloadView struct {
|
||||
Relinkable bool // reverted/cancelled/target_missing — можно перепривязать заново
|
||||
Retriable bool // failed/stuck — можно повторить попытку
|
||||
Note string // пояснение рассинхрона (target_missing/orphaned/deleted)
|
||||
|
||||
// Обзор жизненного цикла (мета-строка карточки).
|
||||
Added string // абсолютная дата добавления (TZ сервера), «2006-01-02»
|
||||
AddedAgo string // относительная давность, «5 дней назад»
|
||||
Size string // размер раздачи (снимок → фолбэк по файлам → «—»)
|
||||
Ratio string // рейтинг отдачи (снимок → «—»)
|
||||
}
|
||||
|
||||
// listChips — определения чипов фильтра списка (порядок = порядок показа).
|
||||
@@ -263,12 +271,29 @@ func (s *server) handleIndex(w http.ResponseWriter, r *http.Request) {
|
||||
})
|
||||
}
|
||||
|
||||
// Суммарный размер разложенных файлов по странице — фолбэк размера раздачи,
|
||||
// когда торрента нет в живом снимке (один батч-запрос, не N+1).
|
||||
ids := make([]string, len(downloads))
|
||||
for i, d := range downloads {
|
||||
ids[i] = d.ID
|
||||
}
|
||||
layoutSizes, err := s.deps.Reader.LayoutSizeByDownload(r.Context(), ids)
|
||||
if err != nil {
|
||||
s.deps.Logger.Error("layout sizes", "error", err)
|
||||
layoutSizes = nil // деградируем: размер уедет в фолбэк «—», страница не падает
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
for _, d := range downloads {
|
||||
v := toView(d)
|
||||
v := toView(d, now)
|
||||
// Живой снимок читаем для всех карточек (map-lookup, без сети/БД): рейтинг
|
||||
// и размер нужны в любом состоянии, пока торрент есть в qBittorrent.
|
||||
l, ok := s.liveFor(d)
|
||||
v.Ratio = ratioText(l, ok)
|
||||
v.Size = sizeText(l, ok, layoutSizes[d.ID])
|
||||
// Живой прогресс активных загрузок — со значениями уже в первом кадре
|
||||
// (без мигания); дальше карточка дозапрашивает фрагмент поллингом.
|
||||
if v.IsDownloading {
|
||||
l, ok := s.liveFor(d)
|
||||
v.Progress = buildProgress(d.ID, true, l, ok)
|
||||
}
|
||||
view.Downloads = append(view.Downloads, v)
|
||||
@@ -517,12 +542,11 @@ func toDTO(d store.Download) downloadDTO {
|
||||
}
|
||||
}
|
||||
|
||||
func toView(d store.Download) downloadView {
|
||||
func toView(d store.Download, now time.Time) downloadView {
|
||||
state := string(d.State)
|
||||
return downloadView{
|
||||
v := downloadView{
|
||||
ID: d.ID,
|
||||
Title: downloadTitle(d),
|
||||
Context: d.Context,
|
||||
State: state,
|
||||
Error: d.ErrorMsg.String,
|
||||
Terminal: d.State.IsTerminal(),
|
||||
@@ -534,6 +558,13 @@ func toView(d store.Download) downloadView {
|
||||
Retriable: d.State == store.StateFailed || d.State == store.StateStuck,
|
||||
Note: desyncNote(d.State),
|
||||
}
|
||||
// Дата добавления в карточке — всегда (source_added_at → фолбэк created_at,
|
||||
// как в порядке списка); неразбираемое время просто опускаем.
|
||||
if t, ok := addedTime(d); ok {
|
||||
v.Added = fmtDate(t)
|
||||
v.AddedAgo = humanizeAge(t, now)
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
// downloadTitle — заголовок загрузки для списка: имя раздачи (display_name,
|
||||
@@ -556,6 +587,21 @@ func oneLine(s string) string {
|
||||
return strings.Join(strings.Fields(s), " ")
|
||||
}
|
||||
|
||||
// addedTime — базис даты добавления карточки: время добавления в источник
|
||||
// (source_added_at, qBittorrent added_on) с фолбэком на время создания загрузки
|
||||
// (created_at), согласованно с порядком списка. ok=false — распарсить нечего.
|
||||
func addedTime(d store.Download) (time.Time, bool) {
|
||||
s := d.CreatedAt
|
||||
if d.SourceAddedAt.Valid && d.SourceAddedAt.String != "" {
|
||||
s = d.SourceAddedAt.String
|
||||
}
|
||||
t, err := store.ParseTime(s)
|
||||
if err != nil {
|
||||
return time.Time{}, false
|
||||
}
|
||||
return t, true
|
||||
}
|
||||
|
||||
// desyncNote — пояснение состояния рассинхрона для UI (см. state-reconciliation).
|
||||
func desyncNote(s store.State) string {
|
||||
switch s {
|
||||
|
||||
@@ -91,6 +91,9 @@ func (f *fakeReader) GetDownload(_ context.Context, id string) (*store.Download,
|
||||
}
|
||||
return &store.Download{ID: id, State: store.StateCancelled}, nil
|
||||
}
|
||||
func (f *fakeReader) LayoutSizeByDownload(_ context.Context, _ []string) (map[string]int64, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func newServer(t *testing.T, d httpapi.Deps) *httptest.Server {
|
||||
t.Helper()
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"git.vakhrushev.me/av/jellybit/internal/store"
|
||||
"git.vakhrushev.me/av/jellybit/internal/worker"
|
||||
@@ -184,3 +185,84 @@ func fmtRatio(r float64) string {
|
||||
}
|
||||
return fmt.Sprintf("%.2f", r)
|
||||
}
|
||||
|
||||
// mskLoc — TZ сервера (Europe/Moscow) для показа дат в веб-UI (инвариант «время
|
||||
// с явным TZ»). Если zoneinfo недоступен в окружении — деградируем до UTC:
|
||||
// дата остаётся корректной с точностью до смещения, UI не падает.
|
||||
var mskLoc = func() *time.Location {
|
||||
if loc, err := time.LoadLocation("Europe/Moscow"); err == nil {
|
||||
return loc
|
||||
}
|
||||
return time.UTC
|
||||
}()
|
||||
|
||||
// fmtDate — абсолютная дата добавления для карточки в TZ сервера.
|
||||
func fmtDate(t time.Time) string {
|
||||
return t.In(mskLoc).Format("2006-01-02")
|
||||
}
|
||||
|
||||
// humanizeAge — относительная давность («5 дней назад») от now до t. Будущее
|
||||
// (рассинхрон часов) схлопывается в «только что». Единицы огрубляются к
|
||||
// минутам/часам/дням/месяцам/годам — для обзора возраста этого достаточно.
|
||||
func humanizeAge(t, now time.Time) string {
|
||||
d := now.Sub(t)
|
||||
if d < time.Minute {
|
||||
return "только что"
|
||||
}
|
||||
switch {
|
||||
case d < time.Hour:
|
||||
n := int(d / time.Minute)
|
||||
return fmt.Sprintf("%d %s назад", n, plural(n, "минуту", "минуты", "минут"))
|
||||
case d < 24*time.Hour:
|
||||
n := int(d / time.Hour)
|
||||
return fmt.Sprintf("%d %s назад", n, plural(n, "час", "часа", "часов"))
|
||||
case d < 30*24*time.Hour:
|
||||
n := int(d / (24 * time.Hour))
|
||||
return fmt.Sprintf("%d %s назад", n, plural(n, "день", "дня", "дней"))
|
||||
case d < 365*24*time.Hour:
|
||||
n := int(d / (30 * 24 * time.Hour))
|
||||
return fmt.Sprintf("%d %s назад", n, plural(n, "месяц", "месяца", "месяцев"))
|
||||
default:
|
||||
n := int(d / (365 * 24 * time.Hour))
|
||||
return fmt.Sprintf("%d %s назад", n, plural(n, "год", "года", "лет"))
|
||||
}
|
||||
}
|
||||
|
||||
// plural выбирает русскую форму по числу (1 файл / 2 файла / 5 файлов).
|
||||
func plural(n int, one, few, many string) string {
|
||||
if n < 0 {
|
||||
n = -n
|
||||
}
|
||||
if m := n % 100; m >= 11 && m <= 14 {
|
||||
return many
|
||||
}
|
||||
switch n % 10 {
|
||||
case 1:
|
||||
return one
|
||||
case 2, 3, 4:
|
||||
return few
|
||||
default:
|
||||
return many
|
||||
}
|
||||
}
|
||||
|
||||
// sizeText — размер раздачи для карточки: живой общий размер из снимка, иначе
|
||||
// суммарный размер разложенных файлов (фолбэк для orphaned), иначе «—».
|
||||
func sizeText(l worker.Live, ok bool, layoutSize int64) string {
|
||||
if ok && l.TotalSize > 0 {
|
||||
return fmtBytes(l.TotalSize)
|
||||
}
|
||||
if layoutSize > 0 {
|
||||
return fmtBytes(layoutSize)
|
||||
}
|
||||
return "—"
|
||||
}
|
||||
|
||||
// ratioText — рейтинг отдачи для карточки: из живого снимка, иначе «—» (торрента
|
||||
// нет в qBittorrent).
|
||||
func ratioText(l worker.Live, ok bool) string {
|
||||
if !ok {
|
||||
return "—"
|
||||
}
|
||||
return fmtRatio(l.Ratio)
|
||||
}
|
||||
|
||||
@@ -15,8 +15,9 @@ import (
|
||||
|
||||
// stubReader — минимальный Reader для проверки рендера списка.
|
||||
type stubReader struct {
|
||||
list []store.Download
|
||||
one *store.Download
|
||||
list []store.Download
|
||||
one *store.Download
|
||||
sizes map[string]int64 // размеры разложенных файлов по download_id (фолбэк)
|
||||
}
|
||||
|
||||
func (s stubReader) ListDownloads(context.Context) ([]store.Download, error) { return s.list, nil }
|
||||
@@ -29,6 +30,9 @@ func (s stubReader) GetDownload(context.Context, string) (*store.Download, error
|
||||
}
|
||||
return s.one, nil
|
||||
}
|
||||
func (s stubReader) LayoutSizeByDownload(context.Context, []string) (map[string]int64, error) {
|
||||
return s.sizes, nil
|
||||
}
|
||||
|
||||
// stubReviewer — Reviewer-заглушка (нужна для /download/{id}).
|
||||
type stubReviewer struct{ data *worker.ReviewData }
|
||||
|
||||
Reference in New Issue
Block a user