Веб-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:
@@ -104,6 +104,7 @@ erDiagram
|
|||||||
TEXT dst_path "NOT NULL; целевой хардлинк"
|
TEXT dst_path "NOT NULL; целевой хардлинк"
|
||||||
TEXT kind "NOT NULL; video|subtitle|..."
|
TEXT kind "NOT NULL; video|subtitle|..."
|
||||||
TEXT status "NOT NULL; linked|..."
|
TEXT status "NOT NULL; linked|..."
|
||||||
|
INTEGER size "NOT NULL DEFAULT 0; размер файла (байт), фолбэк размера раздачи"
|
||||||
TEXT created_at "NOT NULL DEFAULT datetime('now')"
|
TEXT created_at "NOT NULL DEFAULT datetime('now')"
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -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)
|
ListDownloads(ctx context.Context) ([]store.Download, error)
|
||||||
ListDownloadsPage(ctx context.Context, f store.ListFilter) ([]store.Download, int, error)
|
ListDownloadsPage(ctx context.Context, f store.ListFilter) ([]store.Download, int, error)
|
||||||
GetDownload(ctx context.Context, id string) (*store.Download, error)
|
GetDownload(ctx context.Context, id string) (*store.Download, error)
|
||||||
|
// LayoutSizeByDownload — суммарный размер разложенных файлов по каждой из
|
||||||
|
// загрузок (фолбэк размера раздачи в карточке, когда торрента нет в снимке).
|
||||||
|
LayoutSizeByDownload(ctx context.Context, ids []string) (map[string]int64, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Deps — зависимости транспорта.
|
// Deps — зависимости транспорта.
|
||||||
@@ -181,7 +184,6 @@ type pageLink struct {
|
|||||||
type downloadView struct {
|
type downloadView struct {
|
||||||
ID string
|
ID string
|
||||||
Title string // отображаемый заголовок карточки
|
Title string // отображаемый заголовок карточки
|
||||||
Context string
|
|
||||||
State string
|
State string
|
||||||
Error string
|
Error string
|
||||||
Terminal bool
|
Terminal bool
|
||||||
@@ -192,6 +194,12 @@ type downloadView struct {
|
|||||||
Relinkable bool // reverted/cancelled/target_missing — можно перепривязать заново
|
Relinkable bool // reverted/cancelled/target_missing — можно перепривязать заново
|
||||||
Retriable bool // failed/stuck — можно повторить попытку
|
Retriable bool // failed/stuck — можно повторить попытку
|
||||||
Note string // пояснение рассинхрона (target_missing/orphaned/deleted)
|
Note string // пояснение рассинхрона (target_missing/orphaned/deleted)
|
||||||
|
|
||||||
|
// Обзор жизненного цикла (мета-строка карточки).
|
||||||
|
Added string // абсолютная дата добавления (TZ сервера), «2006-01-02»
|
||||||
|
AddedAgo string // относительная давность, «5 дней назад»
|
||||||
|
Size string // размер раздачи (снимок → фолбэк по файлам → «—»)
|
||||||
|
Ratio string // рейтинг отдачи (снимок → «—»)
|
||||||
}
|
}
|
||||||
|
|
||||||
// listChips — определения чипов фильтра списка (порядок = порядок показа).
|
// 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 {
|
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 {
|
if v.IsDownloading {
|
||||||
l, ok := s.liveFor(d)
|
|
||||||
v.Progress = buildProgress(d.ID, true, l, ok)
|
v.Progress = buildProgress(d.ID, true, l, ok)
|
||||||
}
|
}
|
||||||
view.Downloads = append(view.Downloads, v)
|
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)
|
state := string(d.State)
|
||||||
return downloadView{
|
v := downloadView{
|
||||||
ID: d.ID,
|
ID: d.ID,
|
||||||
Title: downloadTitle(d),
|
Title: downloadTitle(d),
|
||||||
Context: d.Context,
|
|
||||||
State: state,
|
State: state,
|
||||||
Error: d.ErrorMsg.String,
|
Error: d.ErrorMsg.String,
|
||||||
Terminal: d.State.IsTerminal(),
|
Terminal: d.State.IsTerminal(),
|
||||||
@@ -534,6 +558,13 @@ func toView(d store.Download) downloadView {
|
|||||||
Retriable: d.State == store.StateFailed || d.State == store.StateStuck,
|
Retriable: d.State == store.StateFailed || d.State == store.StateStuck,
|
||||||
Note: desyncNote(d.State),
|
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,
|
// downloadTitle — заголовок загрузки для списка: имя раздачи (display_name,
|
||||||
@@ -556,6 +587,21 @@ func oneLine(s string) string {
|
|||||||
return strings.Join(strings.Fields(s), " ")
|
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).
|
// desyncNote — пояснение состояния рассинхрона для UI (см. state-reconciliation).
|
||||||
func desyncNote(s store.State) string {
|
func desyncNote(s store.State) string {
|
||||||
switch s {
|
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
|
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 {
|
func newServer(t *testing.T, d httpapi.Deps) *httptest.Server {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
"git.vakhrushev.me/av/jellybit/internal/store"
|
"git.vakhrushev.me/av/jellybit/internal/store"
|
||||||
"git.vakhrushev.me/av/jellybit/internal/worker"
|
"git.vakhrushev.me/av/jellybit/internal/worker"
|
||||||
@@ -184,3 +185,84 @@ func fmtRatio(r float64) string {
|
|||||||
}
|
}
|
||||||
return fmt.Sprintf("%.2f", r)
|
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 для проверки рендера списка.
|
// stubReader — минимальный Reader для проверки рендера списка.
|
||||||
type stubReader struct {
|
type stubReader struct {
|
||||||
list []store.Download
|
list []store.Download
|
||||||
one *store.Download
|
one *store.Download
|
||||||
|
sizes map[string]int64 // размеры разложенных файлов по download_id (фолбэк)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s stubReader) ListDownloads(context.Context) ([]store.Download, error) { return s.list, nil }
|
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
|
return s.one, nil
|
||||||
}
|
}
|
||||||
|
func (s stubReader) LayoutSizeByDownload(context.Context, []string) (map[string]int64, error) {
|
||||||
|
return s.sizes, nil
|
||||||
|
}
|
||||||
|
|
||||||
// stubReviewer — Reviewer-заглушка (нужна для /download/{id}).
|
// stubReviewer — Reviewer-заглушка (нужна для /download/{id}).
|
||||||
type stubReviewer struct{ data *worker.ReviewData }
|
type stubReviewer struct{ data *worker.ReviewData }
|
||||||
|
|||||||
@@ -224,6 +224,7 @@ const (
|
|||||||
type Result struct {
|
type Result struct {
|
||||||
Link Link
|
Link Link
|
||||||
Status LinkStatus
|
Status LinkStatus
|
||||||
|
Size int64 // размер разложенного файла (байт); 0, если stat не удался
|
||||||
}
|
}
|
||||||
|
|
||||||
// ErrCollision — цель существует и это другой файл (нужен review).
|
// ErrCollision — цель существует и это другой файл (нужен review).
|
||||||
@@ -261,7 +262,17 @@ func (l *Layouter) Apply(ctx context.Context, links []Link) ([]Result, error) {
|
|||||||
}
|
}
|
||||||
log.Debug("layout link applied",
|
log.Debug("layout link applied",
|
||||||
"src", ln.Src, "dst", ln.Dst, "kind", ln.Kind, "status", status)
|
"src", ln.Src, "dst", ln.Dst, "kind", ln.Kind, "status", status)
|
||||||
results = append(results, Result{Link: ln, Status: status})
|
// Размер разложенного файла для показа в веб-UI (фолбэк размера раздачи,
|
||||||
|
// когда торрента нет в снимке). Best-effort: файл только что слинкован/
|
||||||
|
// скопирован/уже существовал — stat должен пройти; сбой не валит
|
||||||
|
// раскладку, размер остаётся 0.
|
||||||
|
var size int64
|
||||||
|
if fi, serr := os.Stat(ln.Dst); serr == nil {
|
||||||
|
size = fi.Size()
|
||||||
|
} else {
|
||||||
|
log.Debug("layout stat size failed", "dst", ln.Dst, "error", serr)
|
||||||
|
}
|
||||||
|
results = append(results, Result{Link: ln, Status: status, Size: size})
|
||||||
}
|
}
|
||||||
return results, nil
|
return results, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -145,6 +145,9 @@ func TestApply_CreatesHardlink(t *testing.T) {
|
|||||||
if len(res) != 1 || res[0].Status != StatusLinked {
|
if len(res) != 1 || res[0].Status != StatusLinked {
|
||||||
t.Fatalf("res = %+v", res)
|
t.Fatalf("res = %+v", res)
|
||||||
}
|
}
|
||||||
|
if res[0].Size != int64(len("data")) {
|
||||||
|
t.Errorf("res.Size = %d, want %d", res[0].Size, len("data"))
|
||||||
|
}
|
||||||
// Тот же inode, источник цел.
|
// Тот же inode, источник цел.
|
||||||
si, _ := os.Stat(src)
|
si, _ := os.Stat(src)
|
||||||
di, _ := os.Stat(links[0].Dst)
|
di, _ := os.Stat(links[0].Dst)
|
||||||
|
|||||||
@@ -58,6 +58,7 @@ type Torrent struct {
|
|||||||
Tags string `json:"tags"` // через запятую
|
Tags string `json:"tags"` // через запятую
|
||||||
Progress float64 `json:"progress"`
|
Progress float64 `json:"progress"`
|
||||||
AmountLeft int64 `json:"amount_left"`
|
AmountLeft int64 `json:"amount_left"`
|
||||||
|
TotalSize int64 `json:"total_size"` // полный размер раздачи, байт
|
||||||
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"`
|
||||||
|
|||||||
@@ -0,0 +1,10 @@
|
|||||||
|
-- +goose Up
|
||||||
|
-- Размер разложенного файла (байт), фиксируется при линковке. Служит фолбэком
|
||||||
|
-- размера раздачи в веб-UI, когда торрента нет в qBittorrent (например,
|
||||||
|
-- состояние orphaned — файлы библиотеки последняя копия данных). Старые записи
|
||||||
|
-- получают 0 (бэкофилл не делаем: для done/сидирующих размер берётся из живого
|
||||||
|
-- снимка qBittorrent).
|
||||||
|
ALTER TABLE file_link ADD COLUMN size INTEGER NOT NULL DEFAULT 0;
|
||||||
|
|
||||||
|
-- +goose Down
|
||||||
|
ALTER TABLE file_link DROP COLUMN size;
|
||||||
@@ -166,6 +166,7 @@ type FileLink struct {
|
|||||||
DstPath string `db:"dst_path"`
|
DstPath string `db:"dst_path"`
|
||||||
Kind string `db:"kind"`
|
Kind string `db:"kind"`
|
||||||
Status string `db:"status"`
|
Status string `db:"status"`
|
||||||
|
Size int64 `db:"size"` // размер файла (байт), зафиксирован при линковке
|
||||||
CreatedAt string `db:"created_at"`
|
CreatedAt string `db:"created_at"`
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -181,11 +182,11 @@ func (s *Store) CreateFileLinks(ctx context.Context, links []FileLink) error {
|
|||||||
defer func() { _ = tx.Rollback() }()
|
defer func() { _ = tx.Rollback() }()
|
||||||
|
|
||||||
const q = `
|
const q = `
|
||||||
INSERT INTO file_link (id, download_id, apply_batch_id, src_path, dst_path, kind, status)
|
INSERT INTO file_link (id, download_id, apply_batch_id, src_path, dst_path, kind, status, size)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?)`
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`
|
||||||
for _, l := range links {
|
for _, l := range links {
|
||||||
if _, err := tx.ExecContext(ctx, q,
|
if _, err := tx.ExecContext(ctx, q,
|
||||||
ident.NewID(), l.DownloadID, l.ApplyBatchID, l.SrcPath, l.DstPath, l.Kind, l.Status); err != nil {
|
ident.NewID(), l.DownloadID, l.ApplyBatchID, l.SrcPath, l.DstPath, l.Kind, l.Status, l.Size); err != nil {
|
||||||
return fmt.Errorf("insert file_link: %w", err)
|
return fmt.Errorf("insert file_link: %w", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -249,6 +250,47 @@ func (s *Store) ListFileLinksByBatch(ctx context.Context, batchID string) ([]Fil
|
|||||||
return out, nil
|
return out, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// LayoutSizeByDownload возвращает суммарный размер разложенных файлов по каждой
|
||||||
|
// из загрузок ids: карта download_id → сумма байт. Учитываются только активные
|
||||||
|
// статусы раскладки (linked/copied/exists); superseded-пути принадлежат уже
|
||||||
|
// другой загрузке. Каждый dst_path считается один раз (повторный apply даёт
|
||||||
|
// новую строку 'exists' с тем же путём) — сначала схлопываем строки по пути,
|
||||||
|
// потом суммируем, иначе размер задвоился бы. Служит фолбэком размера раздачи в
|
||||||
|
// карточке, когда торрента нет в живом снимке. Загрузки без разложенных файлов
|
||||||
|
// в карте отсутствуют (читатель берёт 0).
|
||||||
|
func (s *Store) LayoutSizeByDownload(ctx context.Context, ids []string) (map[string]int64, error) {
|
||||||
|
if len(ids) == 0 {
|
||||||
|
return map[string]int64{}, nil
|
||||||
|
}
|
||||||
|
var args []any
|
||||||
|
ph := placeholders(&args, ids)
|
||||||
|
q := `SELECT download_id, SUM(size) AS total FROM (
|
||||||
|
SELECT download_id, dst_path, MAX(size) AS size FROM file_link
|
||||||
|
WHERE download_id IN (` + ph + `)
|
||||||
|
AND status IN ('linked', 'copied', 'exists')
|
||||||
|
GROUP BY download_id, dst_path
|
||||||
|
) GROUP BY download_id`
|
||||||
|
rows, err := s.DB.QueryxContext(ctx, q, args...)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("layout size by download: %w", err)
|
||||||
|
}
|
||||||
|
defer func() { _ = rows.Close() }()
|
||||||
|
|
||||||
|
out := make(map[string]int64, len(ids))
|
||||||
|
for rows.Next() {
|
||||||
|
var id string
|
||||||
|
var total int64
|
||||||
|
if err := rows.Scan(&id, &total); err != nil {
|
||||||
|
return nil, fmt.Errorf("layout size by download: scan: %w", err)
|
||||||
|
}
|
||||||
|
out[id] = total
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, fmt.Errorf("layout size by download: %w", err)
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
// DeleteFileLinksByBatch удаляет записи ссылок батча (после undo на ФС).
|
// DeleteFileLinksByBatch удаляет записи ссылок батча (после undo на ФС).
|
||||||
func (s *Store) DeleteFileLinksByBatch(ctx context.Context, batchID string) error {
|
func (s *Store) DeleteFileLinksByBatch(ctx context.Context, batchID string) error {
|
||||||
if _, err := s.DB.ExecContext(ctx,
|
if _, err := s.DB.ExecContext(ctx,
|
||||||
|
|||||||
@@ -154,6 +154,42 @@ func TestFileLinks_BatchLifecycle(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestLayoutSizeByDownload(t *testing.T) {
|
||||||
|
st := newTestStore(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
dl := seedDownload(t, st)
|
||||||
|
other := mustCreate(t, st, "bbccddeeff00112233445566778899aabbccddee")
|
||||||
|
|
||||||
|
// Активные статусы суммируются; superseded исключается; повторный apply даёт
|
||||||
|
// вторую строку с тем же dst_path — DISTINCT не даёт задвоить размер.
|
||||||
|
if err := st.CreateFileLinks(ctx, []FileLink{
|
||||||
|
{DownloadID: dl, ApplyBatchID: "b1", SrcPath: "/d/a.mkv", DstPath: "/m/A.mkv", Kind: "video", Status: "linked", Size: 100},
|
||||||
|
{DownloadID: dl, ApplyBatchID: "b1", SrcPath: "/d/a.srt", DstPath: "/m/A.ru.srt", Kind: "subtitle", Status: "copied", Size: 50},
|
||||||
|
{DownloadID: dl, ApplyBatchID: "b1", SrcPath: "/d/b.mkv", DstPath: "/m/B.mkv", Kind: "video", Status: "exists", Size: 25},
|
||||||
|
{DownloadID: dl, ApplyBatchID: "b2", SrcPath: "/d/a.mkv", DstPath: "/m/A.mkv", Kind: "video", Status: "exists", Size: 100},
|
||||||
|
{DownloadID: dl, ApplyBatchID: "b1", SrcPath: "/d/s.mkv", DstPath: "/m/S.mkv", Kind: "video", Status: "superseded", Size: 999},
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("create links: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
got, err := st.LayoutSizeByDownload(ctx, []string{dl, other})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("layout size: %v", err)
|
||||||
|
}
|
||||||
|
if got[dl] != 175 { // 100 + 50 + 25, повтор A.mkv и superseded не в счёт
|
||||||
|
t.Errorf("размер %s = %d, want 175", dl, got[dl])
|
||||||
|
}
|
||||||
|
if _, ok := got[other]; ok {
|
||||||
|
t.Errorf("загрузка без разложенных файлов не должна быть в карте: %v", got[other])
|
||||||
|
}
|
||||||
|
|
||||||
|
// Пустой список id — пустая карта, без ошибки.
|
||||||
|
empty, err := st.LayoutSizeByDownload(ctx, nil)
|
||||||
|
if err != nil || len(empty) != 0 {
|
||||||
|
t.Errorf("пустой ids: got %v, err %v", empty, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestSupersedeForeignLinks(t *testing.T) {
|
func TestSupersedeForeignLinks(t *testing.T) {
|
||||||
st := newTestStore(t)
|
st := newTestStore(t)
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
|||||||
@@ -14,11 +14,11 @@ import (
|
|||||||
func TestPollBuildsLiveSnapshot(t *testing.T) {
|
func TestPollBuildsLiveSnapshot(t *testing.T) {
|
||||||
qb := &fakeQbt{torrents: []qbt.Torrent{
|
qb := &fakeQbt{torrents: []qbt.Torrent{
|
||||||
// Качается (Category пуст → discover не усыновляет, store не мешает).
|
// Качается (Category пуст → discover не усыновляет, store не мешает).
|
||||||
{Hash: "aaa", State: "downloading", Progress: 0.5, Dlspeed: 1000, Eta: 120},
|
{Hash: "aaa", State: "downloading", Progress: 0.5, Dlspeed: 1000, Eta: 120, TotalSize: 4096},
|
||||||
// Сидирует, торрент v2 (три ключа).
|
// Сидирует, торрент v2 (три ключа).
|
||||||
{
|
{
|
||||||
Hash: "bbb", InfohashV1: "bbb1", InfohashV2: "BBB2",
|
Hash: "bbb", InfohashV1: "bbb1", InfohashV2: "BBB2",
|
||||||
State: "uploading", Progress: 1.0,
|
State: "uploading", Progress: 1.0, TotalSize: 8192,
|
||||||
Ratio: 2.5, NumSeeds: 3, NumLeechs: 1, Uploaded: 999, Upspeed: 50,
|
Ratio: 2.5, NumSeeds: 3, NumLeechs: 1, Uploaded: 999, Upspeed: 50,
|
||||||
},
|
},
|
||||||
}}
|
}}
|
||||||
@@ -38,6 +38,9 @@ func TestPollBuildsLiveSnapshot(t *testing.T) {
|
|||||||
if dl.Progress != 0.5 || dl.DlSpeed != 1000 || dl.ETA != 120 {
|
if dl.Progress != 0.5 || dl.DlSpeed != 1000 || dl.ETA != 120 {
|
||||||
t.Errorf("телеметрия качания неверна: %+v", dl)
|
t.Errorf("телеметрия качания неверна: %+v", dl)
|
||||||
}
|
}
|
||||||
|
if dl.TotalSize != 4096 {
|
||||||
|
t.Errorf("полный размер качающейся задачи: got %d, want 4096", dl.TotalSize)
|
||||||
|
}
|
||||||
|
|
||||||
// Сидирующая задача находится по любому из трёх хэшей (lowercase).
|
// Сидирующая задача находится по любому из трёх хэшей (lowercase).
|
||||||
for _, h := range []string{"bbb", "bbb1", "BBB2", "bbb2"} {
|
for _, h := range []string{"bbb", "bbb1", "BBB2", "bbb2"} {
|
||||||
@@ -51,6 +54,9 @@ func TestPollBuildsLiveSnapshot(t *testing.T) {
|
|||||||
if sd.Ratio != 2.5 || sd.Seeds != 3 || sd.Peers != 1 || sd.Uploaded != 999 || sd.UpSpeed != 50 {
|
if sd.Ratio != 2.5 || sd.Seeds != 3 || sd.Peers != 1 || sd.Uploaded != 999 || sd.UpSpeed != 50 {
|
||||||
t.Errorf("ключ %q: статистика раздачи неверна: %+v", h, sd)
|
t.Errorf("ключ %q: статистика раздачи неверна: %+v", h, sd)
|
||||||
}
|
}
|
||||||
|
if sd.TotalSize != 8192 {
|
||||||
|
t.Errorf("ключ %q: полный размер раздачи: got %d, want 8192", h, sd.TotalSize)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if _, ok := w.Live("unknownhash"); ok {
|
if _, ok := w.Live("unknownhash"); ok {
|
||||||
|
|||||||
@@ -278,6 +278,7 @@ func (w *Worker) linkPlan(ctx context.Context, d *store.Download, plan recognize
|
|||||||
DstPath: r.Link.Dst,
|
DstPath: r.Link.Dst,
|
||||||
Kind: string(r.Link.Kind),
|
Kind: string(r.Link.Kind),
|
||||||
Status: string(r.Status),
|
Status: string(r.Status),
|
||||||
|
Size: r.Size,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
if len(fl) > 0 {
|
if len(fl) > 0 {
|
||||||
|
|||||||
+17
-15
@@ -150,26 +150,28 @@ type Live struct {
|
|||||||
State string // сырое состояние qBittorrent
|
State string // сырое состояние qBittorrent
|
||||||
Seeding bool // торрент завершён и раздаётся
|
Seeding bool // торрент завершён и раздаётся
|
||||||
|
|
||||||
Ratio float64 // рейтинг отдачи (может быть <0 = ∞/н/д)
|
TotalSize int64 // полный размер раздачи, байт (доступен для любой раздачи в снимке)
|
||||||
Seeds int // подключённые сиды
|
Ratio float64 // рейтинг отдачи (может быть <0 = ∞/н/д)
|
||||||
Peers int // подключённые личи
|
Seeds int // подключённые сиды
|
||||||
Uploaded int64 // отдано всего, байт
|
Peers int // подключённые личи
|
||||||
UpSpeed int64 // скорость отдачи, байт/с
|
Uploaded int64 // отдано всего, байт
|
||||||
|
UpSpeed int64 // скорость отдачи, байт/с
|
||||||
}
|
}
|
||||||
|
|
||||||
// liveFrom собирает Live из торрента qBittorrent (Seeding — через classify).
|
// liveFrom собирает Live из торрента qBittorrent (Seeding — через classify).
|
||||||
func liveFrom(t qbt.Torrent) Live {
|
func liveFrom(t qbt.Torrent) Live {
|
||||||
return Live{
|
return Live{
|
||||||
Progress: t.Progress,
|
Progress: t.Progress,
|
||||||
DlSpeed: t.Dlspeed,
|
DlSpeed: t.Dlspeed,
|
||||||
ETA: t.Eta,
|
ETA: t.Eta,
|
||||||
State: t.State,
|
State: t.State,
|
||||||
Seeding: classify(t.State) == classReady,
|
Seeding: classify(t.State) == classReady,
|
||||||
Ratio: t.Ratio,
|
TotalSize: t.TotalSize,
|
||||||
Seeds: t.NumSeeds,
|
Ratio: t.Ratio,
|
||||||
Peers: t.NumLeechs,
|
Seeds: t.NumSeeds,
|
||||||
Uploaded: t.Uploaded,
|
Peers: t.NumLeechs,
|
||||||
UpSpeed: t.Upspeed,
|
Uploaded: t.Uploaded,
|
||||||
|
UpSpeed: t.Upspeed,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,2 @@
|
|||||||
|
schema: spec-driven
|
||||||
|
created: 2026-07-04
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
## Context
|
||||||
|
|
||||||
|
Карточка списка на главной (`web/templates/index.html`, view-model `downloadView`
|
||||||
|
в `internal/httpapi`) сейчас показывает заголовок, `download.id`, бейдж
|
||||||
|
состояния, спойлер контекста и — для качающихся — прогресс. Живая телеметрия
|
||||||
|
раздачи живёт в in-memory снимке воркера (`worker.Live`, capability
|
||||||
|
`live-status`) и в БД не персиститься; `handleIndex` читает снимок только для
|
||||||
|
карточек в состоянии `downloading`. Размер разложенных файлов нигде не хранится:
|
||||||
|
`file_link` содержит пути и статус, без размера.
|
||||||
|
|
||||||
|
Задача — дать в карточке обзор для решения «пора удалять»: возраст (дата
|
||||||
|
добавления) и рейтинг отдачи, плюс размер. Возраст и рейтинг — ровно те два
|
||||||
|
критерия, по которым пользователь удаляет раздачи.
|
||||||
|
|
||||||
|
## Goals / Non-Goals
|
||||||
|
|
||||||
|
**Goals:**
|
||||||
|
|
||||||
|
- Дата добавления в карточке всегда (абсолют + относительная давность).
|
||||||
|
- Рейтинг отдачи и размер раздачи в карточке из живого снимка.
|
||||||
|
- Размер работает и после исчезновения торрента из qBittorrent (случай
|
||||||
|
`orphaned`) — через сохранённый размер разложенных файлов.
|
||||||
|
- Убрать спойлер контекста из карточки (контекст уже есть на `/download/{id}`).
|
||||||
|
|
||||||
|
**Non-Goals:**
|
||||||
|
|
||||||
|
- Живое (поллинг) обновление рейтинга/размера в списке — значения статичны на
|
||||||
|
момент рендера (рейтинг меняется медленно; поллинг сейчас только у прогресс-бара).
|
||||||
|
- Сиды/пиры, время сидирования, объём отданного в карточке — вне объёма.
|
||||||
|
- Персист рейтинга в БД — он нужен, пока торрент в qBittorrent, тогда и доступен.
|
||||||
|
- Бэкофилл размера для уже разложенных до миграции загрузок (см. Risks).
|
||||||
|
|
||||||
|
## Decisions
|
||||||
|
|
||||||
|
### Размер разложенных файлов — колонка `file_link.size`
|
||||||
|
|
||||||
|
Фолбэк размера (когда торрента нет в снимке) берём из БД: новая колонка
|
||||||
|
`file_link.size INTEGER`, которую layouter заполняет при линковке (размер уже
|
||||||
|
известен из `os.Stat`/`qbt.File.Size` в момент создания ссылки). Ридер отдаёт
|
||||||
|
`SUM(size)` по разложенным файлам загрузки одним запросом вместе с листингом.
|
||||||
|
|
||||||
|
Почему так, а не иначе:
|
||||||
|
|
||||||
|
- *Stat на лету при рендере* — N системных вызовов на карточку под пагинацией;
|
||||||
|
I/O в горячем пути рендера, зависимость показа от доступности ФС. Отклонено.
|
||||||
|
- *Не показывать фолбэк* — у `orphaned` (файлы библиотеки = последняя копия
|
||||||
|
данных) размер как раз важен для решения об удалении. Отклонено.
|
||||||
|
- Колонка в БД: ноль I/O при рендере, размер зафиксирован в момент раскладки
|
||||||
|
(когда файл точно на месте). Цена — миграция goose + правка layouter.
|
||||||
|
|
||||||
|
### Размер при наличии торрента — `total_size` из снимка
|
||||||
|
|
||||||
|
В `qbt.Torrent` добавляем `TotalSize int64 \`json:"total_size"\`` (qBittorrent
|
||||||
|
отдаёт его в том же `/torrents/info`, без отдельного вызова), пробрасываем в
|
||||||
|
`worker.Live.TotalSize`. При рендере: снимок есть → `TotalSize`; снимка нет →
|
||||||
|
`SUM(file_link.size)`; нет ни того ни другого → «—».
|
||||||
|
|
||||||
|
### `handleIndex` читает снимок для всех карточек
|
||||||
|
|
||||||
|
Сейчас `liveFor(d)` вызывается только для `downloading`. Расширяем на все
|
||||||
|
карточки страницы — это map-lookup по волатильному снимку (`worker`), без сети и
|
||||||
|
без БД, стоимость незначительна при `pageSize` карточек. Рейтинг/размер попадают
|
||||||
|
в `downloadView` на рендере.
|
||||||
|
|
||||||
|
### Дата и относительная давность
|
||||||
|
|
||||||
|
Формат «`2006-01-02 · N дней назад`». Абсолютную часть форматируем в TZ сервера
|
||||||
|
(`Europe/Moscow`), относительную считаем от `now` в том же TZ. Базис — как в
|
||||||
|
сортировке списка: `source_added_at` → фолбэк `created_at` (оба хранятся в UTC,
|
||||||
|
парсятся `store.ParseTime`). Относительные подписи — вспомогательный форматтер в
|
||||||
|
`internal/httpapi` рядом с существующими (`fmtBytes`, `fmtRatio`, `fmtETA`).
|
||||||
|
|
||||||
|
### Форматтеры и вёрстка
|
||||||
|
|
||||||
|
Переиспользуем `fmtBytes` (размер) и `fmtRatio` (рейтинг) из `internal/httpapi/live.go`.
|
||||||
|
Мета-строку карточки выносим отдельным партиалом либо инлайним в `index.html` —
|
||||||
|
решается при реализации; спойлер контекста удаляем из `index.html`.
|
||||||
|
|
||||||
|
## Risks / Trade-offs
|
||||||
|
|
||||||
|
- **Уже разложенные до миграции загрузки не имеют `file_link.size`** (колонка
|
||||||
|
`DEFAULT 0`/NULL) → их фолбэк-размер = 0/«—», пока торрент отсутствует в
|
||||||
|
снимке. Для `done`/сидирующих торрент обычно ещё в qBittorrent, поэтому размер
|
||||||
|
берётся из `total_size` и проблема почти не проявляется. → Митигация: бэкофилл
|
||||||
|
не делаем (сложность ради редкого края); при желании — отдельная разовая
|
||||||
|
задача. Показываем «—» честно, а не 0.
|
||||||
|
- **Рейтинг/размер статичны на рендере** (без поллинга) → в списке значение
|
||||||
|
может слегка отставать от реального. → Приемлемо: рейтинг меняется медленно,
|
||||||
|
точные живые цифры есть на `/download/{id}`; полная перезагрузка списка
|
||||||
|
освежает.
|
||||||
|
- **Чтение снимка для всех карточек** чуть увеличивает работу `handleIndex`. →
|
||||||
|
Митигация: это lookup в готовой in-memory карте под `pageSize` элементов;
|
||||||
|
сетевых/БД-обращений не добавляется.
|
||||||
|
- **`SUM(file_link.size)` — доп. агрегат к листингу**. → Один запрос батчем по
|
||||||
|
id страницы (как `attachInfohashes`), не N+1.
|
||||||
|
|
||||||
|
## Migration Plan
|
||||||
|
|
||||||
|
1. Goose-миграция: `ALTER TABLE file_link ADD COLUMN size INTEGER NOT NULL DEFAULT 0`.
|
||||||
|
2. Обновить ER-схему `docs/specs/database.md` (колонка `file_link.size`).
|
||||||
|
3. Down-миграция дропает колонку (пересоздание таблицы — как в существующих
|
||||||
|
миграциях проекта, SQLite без `DROP COLUMN` до нужной версии — при
|
||||||
|
необходимости).
|
||||||
|
4. Откат безопасен: новые поля в карточке деградируют до «—», старый бинарь
|
||||||
|
игнорирует колонку.
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
## Why
|
||||||
|
|
||||||
|
Карточка загрузки в списке на главной сейчас показывает состояние, заголовок,
|
||||||
|
идентификатор и (для качающихся) прогресс. Для решения «пора ли удалять
|
||||||
|
раздачу» этого мало: пользователь удаляет по **возрасту** и по **рейтингу
|
||||||
|
отдачи**, а этих полей в обзоре нет. Одновременно карточка тратит место на
|
||||||
|
спойлер контекста, который логичнее смотреть на странице загрузки. Цель —
|
||||||
|
сделать карточку кратким обзором жизненного цикла: от загрузки до момента, когда
|
||||||
|
раздачу пора убирать.
|
||||||
|
|
||||||
|
## What Changes
|
||||||
|
|
||||||
|
- Убрать из карточки списка спойлер «контекст»; контекст остаётся доступен на
|
||||||
|
странице загрузки `/download/{id}`.
|
||||||
|
- Дописать метку `ID:` перед идентификатором загрузки — чтобы было понятно, что
|
||||||
|
за значение копируется.
|
||||||
|
- Показывать в карточке **всегда** дату добавления раздачи в формате
|
||||||
|
«`2026-06-30 · 5 дней назад`» (абсолютная + относительная). Источник — время
|
||||||
|
добавления в источник (`source_added_at`, qBittorrent `added_on`) с фолбэком
|
||||||
|
на время создания загрузки (`created_at`).
|
||||||
|
- Показывать в карточке **рейтинг отдачи** (ratio) из живого снимка воркера;
|
||||||
|
если торрента нет в qBittorrent — прочерк «—».
|
||||||
|
- Показывать в карточке **размер раздачи**: из живого снимка (общий размер
|
||||||
|
торрента), а когда торрента нет в qBittorrent — из суммарного размера
|
||||||
|
разложенных файлов.
|
||||||
|
- Расширить состав живой телеметрии общим размером раздачи (доступен для любой
|
||||||
|
раздачи в снимке, не только сидирующей).
|
||||||
|
- Раскладка при линковке SHALL сохранять размер каждого созданного файла — чтобы
|
||||||
|
фолбэк размера работал и после исчезновения торрента (`orphaned`).
|
||||||
|
|
||||||
|
Явно вне объёма: сиды/пиры, время сидирования и объём отданного в карточку не
|
||||||
|
добавляем — пользователю для решения об удалении достаточно возраста и рейтинга.
|
||||||
|
|
||||||
|
## Capabilities
|
||||||
|
|
||||||
|
### New Capabilities
|
||||||
|
|
||||||
|
Нет.
|
||||||
|
|
||||||
|
### Modified Capabilities
|
||||||
|
|
||||||
|
- `web-ui`: карточка списка получает обзорную мета-строку (ID-метка, дата
|
||||||
|
добавления всегда, размер, рейтинг); спойлер контекста уходит из карточки на
|
||||||
|
страницу загрузки.
|
||||||
|
- `live-status`: состав телеметрии дополняется общим размером раздачи, доступным
|
||||||
|
для любой раздачи в снимке (не только сидирующей).
|
||||||
|
- `file-layout`: раскладка сохраняет размер каждого разложенного файла (основа
|
||||||
|
фолбэка размера раздачи, когда торрента нет в qBittorrent).
|
||||||
|
|
||||||
|
## Impact
|
||||||
|
|
||||||
|
- `internal/qbt` — поле `TotalSize` (`json:"total_size"`) в `Torrent`.
|
||||||
|
- `internal/worker` — `Live.TotalSize`, проброс из `qbt.Torrent`.
|
||||||
|
- `internal/store` — колонка `file_link.size` (миграция goose), запись размера
|
||||||
|
при линковке, чтение суммарного размера разложенных файлов для карточки.
|
||||||
|
- `internal/httpapi` — новые поля `downloadView`, форматтеры даты/относительного
|
||||||
|
времени/размера/рейтинга; `handleIndex` читает живой снимок для всех карточек
|
||||||
|
списка, а не только качающихся.
|
||||||
|
- `web/templates/index.html` — мета-строка карточки (+ возможный партиал), удаление
|
||||||
|
спойлера контекста.
|
||||||
|
- `docs/specs/database.md` — обновить ER-схему (новая колонка `file_link.size`).
|
||||||
+24
@@ -0,0 +1,24 @@
|
|||||||
|
# file-layout Specification (delta)
|
||||||
|
|
||||||
|
## ADDED Requirements
|
||||||
|
|
||||||
|
### Requirement: Запись размера разложенного файла
|
||||||
|
|
||||||
|
При линковке файла раскладка SHALL сохранять его размер в байтах вместе с
|
||||||
|
записью о созданной ссылке (`file_link`). Сохранённый размер SHALL позволять
|
||||||
|
вычислить суммарный размер разложенных файлов загрузки — он служит фолбэком
|
||||||
|
размера раздачи, когда торрента нет в qBittorrent (например, состояние
|
||||||
|
`orphaned`, где файлы библиотеки — последняя копия данных). Запись размера MUST
|
||||||
|
NOT влиять на инвариант неприкосновенности источника (по-прежнему только
|
||||||
|
`mkdir`/`link`/копия в цель).
|
||||||
|
|
||||||
|
#### Scenario: Размер сохраняется при линковке
|
||||||
|
|
||||||
|
- **WHEN** раскладка создаёт хардлинк (или копию при copy-fallback) файла
|
||||||
|
- **THEN** размер этого файла в байтах сохраняется в записи ссылки `file_link`
|
||||||
|
|
||||||
|
#### Scenario: Суммарный размер доступен без торрента
|
||||||
|
|
||||||
|
- **WHEN** у загрузки есть разложенные файлы, а её торрента нет в qBittorrent
|
||||||
|
- **THEN** суммарный размер разложенных файлов доступен как размер раздачи для
|
||||||
|
показа в карточке
|
||||||
+28
@@ -0,0 +1,28 @@
|
|||||||
|
# live-status Specification (delta)
|
||||||
|
|
||||||
|
## MODIFIED Requirements
|
||||||
|
|
||||||
|
### Requirement: Состав телеметрии
|
||||||
|
|
||||||
|
Телеметрия одной раздачи SHALL включать прогресс (доля 0..1), скорость загрузки,
|
||||||
|
ETA и общий размер раздачи (total size); а для сидирующих раздач дополнительно —
|
||||||
|
рейтинг, число сидов и пиров, объём отданного и скорость отдачи. Общий размер
|
||||||
|
SHALL быть доступен для любой раздачи, присутствующей в снимке (не только
|
||||||
|
сидирующей). Значения SHALL извлекаться из ответа qBittorrent `/torrents/info`
|
||||||
|
без дополнительного сетевого вызова.
|
||||||
|
|
||||||
|
#### Scenario: Телеметрия качающейся задачи
|
||||||
|
|
||||||
|
- **WHEN** торрент задачи находится в состоянии загрузки
|
||||||
|
- **THEN** в снимке для неё доступны прогресс, скорость загрузки и ETA
|
||||||
|
|
||||||
|
#### Scenario: Общий размер доступен для любой раздачи в снимке
|
||||||
|
|
||||||
|
- **WHEN** торрент задачи присутствует в снимке в любом состоянии
|
||||||
|
- **THEN** в телеметрии для неё доступен общий размер раздачи
|
||||||
|
|
||||||
|
#### Scenario: Телеметрия сидирующей задачи
|
||||||
|
|
||||||
|
- **WHEN** торрент задачи завершён и раздаётся
|
||||||
|
- **THEN** в снимке для неё доступны рейтинг, число сидов/пиров, объём
|
||||||
|
отданного и скорость отдачи
|
||||||
+79
@@ -0,0 +1,79 @@
|
|||||||
|
# web-ui Specification (delta)
|
||||||
|
|
||||||
|
## ADDED Requirements
|
||||||
|
|
||||||
|
### Requirement: Обзор жизненного цикла в карточке списка
|
||||||
|
|
||||||
|
Карточка загрузки в списке SHALL показывать обзорную мета-строку для решения о
|
||||||
|
судьбе раздачи: метку `ID:` перед копируемым идентификатором загрузки, дату
|
||||||
|
добавления раздачи (всегда), размер раздачи и рейтинг отдачи. Контекст загрузки
|
||||||
|
MUST NOT показываться в карточке списка — он доступен на странице `/download/{id}`.
|
||||||
|
|
||||||
|
Дата добавления SHALL показываться всегда как абсолютная дата и относительная
|
||||||
|
давность (например «`2026-06-30 · 5 дней назад`»); источником SHALL быть время
|
||||||
|
добавления раздачи в источник (`source_added_at`, qBittorrent `added_on`) с
|
||||||
|
фолбэком на время создания загрузки (`created_at`), согласованным с порядком
|
||||||
|
списка.
|
||||||
|
|
||||||
|
Рейтинг отдачи SHALL браться из живого снимка телеметрии; если торрента нет в
|
||||||
|
снимке (источник ушёл из qBittorrent), рейтинг SHALL отображаться прочерком «—».
|
||||||
|
|
||||||
|
Размер раздачи SHALL браться из живого снимка (общий размер торрента), а при
|
||||||
|
отсутствии торрента в снимке — из суммарного размера разложенных файлов загрузки;
|
||||||
|
если неизвестно ни то, ни другое — прочерк «—».
|
||||||
|
|
||||||
|
#### Scenario: Метка идентификатора
|
||||||
|
|
||||||
|
- **WHEN** рендерится карточка загрузки в списке
|
||||||
|
- **THEN** перед значением `download.id` показана метка «ID:», а кнопка
|
||||||
|
копирования копирует именно `download.id`
|
||||||
|
|
||||||
|
#### Scenario: Дата добавления показана всегда
|
||||||
|
|
||||||
|
- **WHEN** рендерится любая карточка списка
|
||||||
|
- **THEN** в ней показана дата добавления раздачи абсолютной датой и
|
||||||
|
относительной давностью
|
||||||
|
- **AND** если `source_added_at` неизвестно, используется `created_at`
|
||||||
|
|
||||||
|
#### Scenario: Рейтинг из живого снимка
|
||||||
|
|
||||||
|
- **WHEN** торрент загрузки присутствует в живом снимке
|
||||||
|
- **THEN** в карточке показан его рейтинг отдачи
|
||||||
|
- **AND** если торрента в снимке нет, рейтинг показан прочерком «—»
|
||||||
|
|
||||||
|
#### Scenario: Размер с фолбэком на разложенные файлы
|
||||||
|
|
||||||
|
- **WHEN** торрент загрузки присутствует в живом снимке
|
||||||
|
- **THEN** размер раздачи в карточке берётся из общего размера торрента
|
||||||
|
- **AND** если торрента в снимке нет, но у загрузки есть разложенные файлы —
|
||||||
|
размер берётся из суммарного размера этих файлов
|
||||||
|
|
||||||
|
#### Scenario: Контекст не в карточке
|
||||||
|
|
||||||
|
- **WHEN** у загрузки есть переданный контекст
|
||||||
|
- **THEN** он не показывается в карточке списка, но доступен на странице
|
||||||
|
`/download/{id}`
|
||||||
|
|
||||||
|
## MODIFIED Requirements
|
||||||
|
|
||||||
|
### Requirement: Клиентские взаимодействия без сборки
|
||||||
|
|
||||||
|
Веб-UI SHALL реализовывать клиентскую логику без шага сборки и без реактивных
|
||||||
|
фреймворков: копирование идентификатора загрузки (vanilla JS). Основной
|
||||||
|
копируемый идентификатор в карточке списка и в шапке страницы просмотра SHALL
|
||||||
|
быть `download.id` (ULID) — тот же ключ, что пишется в логи (`download_id`). Все
|
||||||
|
действия над загрузкой SHALL выполняться через формы/htmx (раундтрип на сервер),
|
||||||
|
без клиентского пересчёта доменного состояния.
|
||||||
|
|
||||||
|
#### Scenario: Копирование идентификатора загрузки
|
||||||
|
|
||||||
|
- **WHEN** пользователь нажимает кнопку копирования рядом с идентификатором
|
||||||
|
загрузки (`download.id`) в карточке списка или шапке страницы просмотра
|
||||||
|
- **THEN** значение `download.id` копируется в буфер обмена без перезагрузки
|
||||||
|
страницы
|
||||||
|
|
||||||
|
#### Scenario: Действия только через раундтрип
|
||||||
|
|
||||||
|
- **WHEN** пользователь выполняет действие над загрузкой
|
||||||
|
- **THEN** оно исполняется формой/htmx-запросом на сервер, без клиентского
|
||||||
|
пересчёта доменного состояния
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
## 1. Живой размер раздачи (снимок)
|
||||||
|
|
||||||
|
- [x] 1.1 `internal/qbt`: добавить поле `TotalSize int64 \`json:"total_size"\`` в `Torrent`
|
||||||
|
- [x] 1.2 `internal/worker`: добавить `TotalSize int64` в `Live`, пробросить в `liveFrom`
|
||||||
|
- [x] 1.3 `internal/worker/live_test.go`: покрыть проброс `TotalSize` в снимок
|
||||||
|
|
||||||
|
## 2. Размер разложенных файлов (БД)
|
||||||
|
|
||||||
|
- [x] 2.1 Goose-миграция `internal/store/migrations`: `ALTER TABLE file_link ADD COLUMN size INTEGER NOT NULL DEFAULT 0` (+ down)
|
||||||
|
- [x] 2.2 `internal/store`: писать `size` при вставке `file_link` (layouter передаёт размер файла, известный на момент линковки)
|
||||||
|
- [x] 2.3 `internal/store`: метод чтения суммарного размера разложенных файлов по id загрузок (батч по странице, как `attachInfohashes`)
|
||||||
|
- [x] 2.4 `internal/layout` (или вызывающий раскладку): передать размер файла (`os.Stat`/`qbt.File.Size`) в запись `file_link`
|
||||||
|
- [x] 2.5 Тест: запись `file_link.size` при линковке и copy-fallback; `SUM(size)` по загрузке
|
||||||
|
- [x] 2.6 Обновить ER-схему `docs/specs/database.md` (колонка `file_link.size`)
|
||||||
|
|
||||||
|
## 3. View-model и форматтеры (httpapi)
|
||||||
|
|
||||||
|
- [x] 3.1 `downloadView`: добавить поля `Added`, `AddedAgo`, `Size`, `Ratio` (строки для шаблона); убрать зависимость карточки от `Context`
|
||||||
|
- [x] 3.2 Форматтер даты добавления: абсолют `2006-01-02` в TZ `Europe/Moscow`, базис `source_added_at` → фолбэк `created_at`
|
||||||
|
- [x] 3.3 Форматтер относительной давности («N дней/часов назад») от `now` в TZ сервера
|
||||||
|
- [x] 3.4 `toView`/сборка карточки: размер (снимок `TotalSize` → фолбэк `SUM(file_link.size)` → «—»), рейтинг (`fmtRatio` из снимка → «—»), переиспользовать `fmtBytes`
|
||||||
|
- [x] 3.5 `handleIndex`: читать `liveFor(d)` для всех карточек страницы, а не только `downloading`; прокинуть суммарные размеры файлов в сборку карточек
|
||||||
|
- [x] 3.6 Тесты httpapi: дата (с фолбэком на `created_at`), размер (снимок и фолбэк по файлам, и «—»), рейтинг («—» без снимка)
|
||||||
|
|
||||||
|
## 4. Шаблон карточки
|
||||||
|
|
||||||
|
- [x] 4.1 `web/templates/index.html`: удалить спойлер контекста из карточки
|
||||||
|
- [x] 4.2 `web/templates/index.html`: метка `ID:` перед идентификатором (кнопка копирования копирует `download.id`)
|
||||||
|
- [x] 4.3 `web/templates/index.html`: мета-строка карточки (дата · размер · рейтинг), при необходимости вынести в партиал
|
||||||
|
- [x] 4.4 Проверить деградацию: значения «—» когда данных нет, вёрстка не ломается
|
||||||
|
|
||||||
|
## 5. Проверка
|
||||||
|
|
||||||
|
- [x] 5.1 `task lint` и `task test` зелёные
|
||||||
|
- [x] 5.2 Ручная проверка карточек в разных состояниях (downloading / done+сидирование / orphaned) — дата, размер, рейтинг отображаются корректно
|
||||||
|
- [x] 5.3 `openspec validate download-card-lifecycle-overview --strict`
|
||||||
|
- [x] 5.4 Ревью кода перед archive (второй чекпоинт)
|
||||||
@@ -90,3 +90,24 @@ SHALL создавать. Безопасность SHALL держаться на
|
|||||||
- **WHEN** выполняется раскладка файла
|
- **WHEN** выполняется раскладка файла
|
||||||
- **THEN** файл копируется, ссылка получает статус `copied`, в лог пишется предупреждение
|
- **THEN** файл копируется, ссылка получает статус `copied`, в лог пишется предупреждение
|
||||||
|
|
||||||
|
### Requirement: Запись размера разложенного файла
|
||||||
|
|
||||||
|
При линковке файла раскладка SHALL сохранять его размер в байтах вместе с
|
||||||
|
записью о созданной ссылке (`file_link`). Сохранённый размер SHALL позволять
|
||||||
|
вычислить суммарный размер разложенных файлов загрузки — он служит фолбэком
|
||||||
|
размера раздачи, когда торрента нет в qBittorrent (например, состояние
|
||||||
|
`orphaned`, где файлы библиотеки — последняя копия данных). Запись размера MUST
|
||||||
|
NOT влиять на инвариант неприкосновенности источника (по-прежнему только
|
||||||
|
`mkdir`/`link`/копия в цель).
|
||||||
|
|
||||||
|
#### Scenario: Размер сохраняется при линковке
|
||||||
|
|
||||||
|
- **WHEN** раскладка создаёт хардлинк (или копию при copy-fallback) файла
|
||||||
|
- **THEN** размер этого файла в байтах сохраняется в записи ссылки `file_link`
|
||||||
|
|
||||||
|
#### Scenario: Суммарный размер доступен без торрента
|
||||||
|
|
||||||
|
- **WHEN** у загрузки есть разложенные файлы, а её торрента нет в qBittorrent
|
||||||
|
- **THEN** суммарный размер разложенных файлов доступен как размер раздачи для
|
||||||
|
показа в карточке
|
||||||
|
|
||||||
|
|||||||
@@ -22,16 +22,23 @@ TBD - created by archiving change live-status. Update Purpose after archive.
|
|||||||
|
|
||||||
### Requirement: Состав телеметрии
|
### Requirement: Состав телеметрии
|
||||||
|
|
||||||
Телеметрия одной раздачи SHALL включать прогресс (доля 0..1), скорость
|
Телеметрия одной раздачи SHALL включать прогресс (доля 0..1), скорость загрузки,
|
||||||
загрузки и ETA, а для сидирующих раздач дополнительно — рейтинг, число сидов
|
ETA и общий размер раздачи (total size); а для сидирующих раздач дополнительно —
|
||||||
и пиров, объём отданного и скорость отдачи. Значения SHALL извлекаться из
|
рейтинг, число сидов и пиров, объём отданного и скорость отдачи. Общий размер
|
||||||
ответа qBittorrent `/torrents/info` без дополнительного сетевого вызова.
|
SHALL быть доступен для любой раздачи, присутствующей в снимке (не только
|
||||||
|
сидирующей). Значения SHALL извлекаться из ответа qBittorrent `/torrents/info`
|
||||||
|
без дополнительного сетевого вызова.
|
||||||
|
|
||||||
#### Scenario: Телеметрия качающейся задачи
|
#### Scenario: Телеметрия качающейся задачи
|
||||||
|
|
||||||
- **WHEN** торрент задачи находится в состоянии загрузки
|
- **WHEN** торрент задачи находится в состоянии загрузки
|
||||||
- **THEN** в снимке для неё доступны прогресс, скорость загрузки и ETA
|
- **THEN** в снимке для неё доступны прогресс, скорость загрузки и ETA
|
||||||
|
|
||||||
|
#### Scenario: Общий размер доступен для любой раздачи в снимке
|
||||||
|
|
||||||
|
- **WHEN** торрент задачи присутствует в снимке в любом состоянии
|
||||||
|
- **THEN** в телеметрии для неё доступен общий размер раздачи
|
||||||
|
|
||||||
#### Scenario: Телеметрия сидирующей задачи
|
#### Scenario: Телеметрия сидирующей задачи
|
||||||
|
|
||||||
- **WHEN** торрент задачи завершён и раздаётся
|
- **WHEN** торрент задачи завершён и раздаётся
|
||||||
|
|||||||
@@ -262,12 +262,11 @@ jellybit (`created_at`). Порядок MUST быть согласован ме
|
|||||||
### Requirement: Клиентские взаимодействия без сборки
|
### Requirement: Клиентские взаимодействия без сборки
|
||||||
|
|
||||||
Веб-UI SHALL реализовывать клиентскую логику без шага сборки и без реактивных
|
Веб-UI SHALL реализовывать клиентскую логику без шага сборки и без реактивных
|
||||||
фреймворков: копирование идентификатора загрузки (vanilla JS) и раскрытие
|
фреймворков: копирование идентификатора загрузки (vanilla JS). Основной
|
||||||
контекста нативным `<details>`. Основной копируемый идентификатор в карточке
|
копируемый идентификатор в карточке списка и в шапке страницы просмотра SHALL
|
||||||
списка и в шапке страницы просмотра SHALL быть `download.id` (ULID) — тот же
|
быть `download.id` (ULID) — тот же ключ, что пишется в логи (`download_id`). Все
|
||||||
ключ, что пишется в логи (`download_id`). Все действия над загрузкой SHALL
|
действия над загрузкой SHALL выполняться через формы/htmx (раундтрип на сервер),
|
||||||
выполняться через формы/htmx (раундтрип на сервер), без клиентского пересчёта
|
без клиентского пересчёта доменного состояния.
|
||||||
доменного состояния.
|
|
||||||
|
|
||||||
#### Scenario: Копирование идентификатора загрузки
|
#### Scenario: Копирование идентификатора загрузки
|
||||||
|
|
||||||
@@ -276,8 +275,61 @@ jellybit (`created_at`). Порядок MUST быть согласован ме
|
|||||||
- **THEN** значение `download.id` копируется в буфер обмена без перезагрузки
|
- **THEN** значение `download.id` копируется в буфер обмена без перезагрузки
|
||||||
страницы
|
страницы
|
||||||
|
|
||||||
#### Scenario: Раскрытие контекста без JS
|
#### Scenario: Действия только через раундтрип
|
||||||
|
|
||||||
- **WHEN** пользователь раскрывает спойлер переданного контекста
|
- **WHEN** пользователь выполняет действие над загрузкой
|
||||||
- **THEN** контекст показывается нативным `<details>`, без скриптов
|
- **THEN** оно исполняется формой/htmx-запросом на сервер, без клиентского
|
||||||
|
пересчёта доменного состояния
|
||||||
|
|
||||||
|
### Requirement: Обзор жизненного цикла в карточке списка
|
||||||
|
|
||||||
|
Карточка загрузки в списке SHALL показывать обзорную мета-строку для решения о
|
||||||
|
судьбе раздачи: метку `ID:` перед копируемым идентификатором загрузки, дату
|
||||||
|
добавления раздачи (всегда), размер раздачи и рейтинг отдачи. Контекст загрузки
|
||||||
|
MUST NOT показываться в карточке списка — он доступен на странице `/download/{id}`.
|
||||||
|
|
||||||
|
Дата добавления SHALL показываться всегда как абсолютная дата и относительная
|
||||||
|
давность (например «`2026-06-30 · 5 дней назад`»); источником SHALL быть время
|
||||||
|
добавления раздачи в источник (`source_added_at`, qBittorrent `added_on`) с
|
||||||
|
фолбэком на время создания загрузки (`created_at`), согласованным с порядком
|
||||||
|
списка.
|
||||||
|
|
||||||
|
Рейтинг отдачи SHALL браться из живого снимка телеметрии; если торрента нет в
|
||||||
|
снимке (источник ушёл из qBittorrent), рейтинг SHALL отображаться прочерком «—».
|
||||||
|
|
||||||
|
Размер раздачи SHALL браться из живого снимка (общий размер торрента), а при
|
||||||
|
отсутствии торрента в снимке — из суммарного размера разложенных файлов загрузки;
|
||||||
|
если неизвестно ни то, ни другое — прочерк «—».
|
||||||
|
|
||||||
|
#### Scenario: Метка идентификатора
|
||||||
|
|
||||||
|
- **WHEN** рендерится карточка загрузки в списке
|
||||||
|
- **THEN** перед значением `download.id` показана метка «ID:», а кнопка
|
||||||
|
копирования копирует именно `download.id`
|
||||||
|
|
||||||
|
#### Scenario: Дата добавления показана всегда
|
||||||
|
|
||||||
|
- **WHEN** рендерится любая карточка списка
|
||||||
|
- **THEN** в ней показана дата добавления раздачи абсолютной датой и
|
||||||
|
относительной давностью
|
||||||
|
- **AND** если `source_added_at` неизвестно, используется `created_at`
|
||||||
|
|
||||||
|
#### Scenario: Рейтинг из живого снимка
|
||||||
|
|
||||||
|
- **WHEN** торрент загрузки присутствует в живом снимке
|
||||||
|
- **THEN** в карточке показан его рейтинг отдачи
|
||||||
|
- **AND** если торрента в снимке нет, рейтинг показан прочерком «—»
|
||||||
|
|
||||||
|
#### Scenario: Размер с фолбэком на разложенные файлы
|
||||||
|
|
||||||
|
- **WHEN** торрент загрузки присутствует в живом снимке
|
||||||
|
- **THEN** размер раздачи в карточке берётся из общего размера торрента
|
||||||
|
- **AND** если торрента в снимке нет, но у загрузки есть разложенные файлы —
|
||||||
|
размер берётся из суммарного размера этих файлов
|
||||||
|
|
||||||
|
#### Scenario: Контекст не в карточке
|
||||||
|
|
||||||
|
- **WHEN** у загрузки есть переданный контекст
|
||||||
|
- **THEN** он не показывается в карточке списка, но доступен на странице
|
||||||
|
`/download/{id}`
|
||||||
|
|
||||||
|
|||||||
@@ -219,6 +219,12 @@ a:hover{text-decoration:underline}
|
|||||||
.dl-live:empty{display:none}
|
.dl-live:empty{display:none}
|
||||||
.dl-stats{margin-top:6px;font-size:var(--fs-xs);color:var(--text-dim);font-variant-numeric:tabular-nums}
|
.dl-stats{margin-top:6px;font-size:var(--fs-xs);color:var(--text-dim);font-variant-numeric:tabular-nums}
|
||||||
|
|
||||||
|
/* обзор жизненного цикла в карточке (дата · размер · рейтинг) */
|
||||||
|
.card-life{display:flex;align-items:center;gap:8px;flex-wrap:wrap;margin-top:9px;
|
||||||
|
font-size:var(--fs-xs);color:var(--text-dim);font-variant-numeric:tabular-nums}
|
||||||
|
.card-life .ago,.card-life .sep{color:var(--text-faint)}
|
||||||
|
.hash-label{color:var(--text-faint)}
|
||||||
|
|
||||||
/* ---------- 7. Спойлер ---------- */
|
/* ---------- 7. Спойлер ---------- */
|
||||||
details.spoiler{margin-top:var(--sp-3)}
|
details.spoiler{margin-top:var(--sp-3)}
|
||||||
.spoiler summary{cursor:pointer;color:var(--accent);font-size:var(--fs-sm);
|
.spoiler summary{cursor:pointer;color:var(--accent);font-size:var(--fs-sm);
|
||||||
|
|||||||
@@ -57,6 +57,7 @@
|
|||||||
<div>
|
<div>
|
||||||
<div class="card-title"><a href="/download/{{.ID}}" style="color:inherit">{{.Title}}</a></div>
|
<div class="card-title"><a href="/download/{{.ID}}" style="color:inherit">{{.Title}}</a></div>
|
||||||
<div class="hash-row">
|
<div class="hash-row">
|
||||||
|
<span class="hash-label">ID:</span>
|
||||||
<span class="hash">{{.ID}}</span>
|
<span class="hash">{{.ID}}</span>
|
||||||
<button class="copy-btn" type="button" onclick="copyHash(this,'{{.ID}}')">⧉ копировать</button>
|
<button class="copy-btn" type="button" onclick="copyHash(this,'{{.ID}}')">⧉ копировать</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -64,14 +65,15 @@
|
|||||||
{{template "status_badge" .State}}
|
{{template "status_badge" .State}}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{{if .IsDownloading}}{{template "progress" .Progress}}{{end}}
|
<div class="card-life">
|
||||||
|
<span>{{if .Added}}{{.Added}} <span class="ago">({{.AddedAgo}})</span>{{else}}—{{end}}</span>
|
||||||
|
<span class="sep">·</span>
|
||||||
|
<span>{{.Size}}</span>
|
||||||
|
<span class="sep">·</span>
|
||||||
|
<span title="рейтинг раздачи (отдано/скачано)">↑ {{.Ratio}}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
{{if .Context}}
|
{{if .IsDownloading}}{{template "progress" .Progress}}{{end}}
|
||||||
<details class="spoiler">
|
|
||||||
<summary>контекст</summary>
|
|
||||||
<div class="spoiler-body">«{{.Context}}»</div>
|
|
||||||
</details>
|
|
||||||
{{end}}
|
|
||||||
|
|
||||||
<div class="card-foot">
|
<div class="card-foot">
|
||||||
<span class="card-meta">{{if .Note}}{{.Note}}{{else if .Error}}{{.Error}}{{end}}</span>
|
<span class="card-meta">{{if .Note}}{{.Note}}{{else if .Error}}{{.Error}}{{end}}</span>
|
||||||
|
|||||||
Reference in New Issue
Block a user