diff --git a/docs/specs/database.md b/docs/specs/database.md index 84f53d1..eca429f 100644 --- a/docs/specs/database.md +++ b/docs/specs/database.md @@ -104,6 +104,7 @@ erDiagram TEXT dst_path "NOT NULL; целевой хардлинк" TEXT kind "NOT NULL; video|subtitle|..." TEXT status "NOT NULL; linked|..." + INTEGER size "NOT NULL DEFAULT 0; размер файла (байт), фолбэк размера раздачи" TEXT created_at "NOT NULL DEFAULT datetime('now')" } ``` diff --git a/internal/httpapi/format_test.go b/internal/httpapi/format_test.go new file mode 100644 index 0000000..36dd624 --- /dev/null +++ b/internal/httpapi/format_test.go @@ -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("контекст всё ещё показан в карточке списка") + } +} diff --git a/internal/httpapi/httpapi.go b/internal/httpapi/httpapi.go index 0dfbcce..d1bca57 100644 --- a/internal/httpapi/httpapi.go +++ b/internal/httpapi/httpapi.go @@ -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 { diff --git a/internal/httpapi/httpapi_test.go b/internal/httpapi/httpapi_test.go index 1b5f443..2df62e4 100644 --- a/internal/httpapi/httpapi_test.go +++ b/internal/httpapi/httpapi_test.go @@ -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() diff --git a/internal/httpapi/live.go b/internal/httpapi/live.go index 07e2b23..91b92fd 100644 --- a/internal/httpapi/live.go +++ b/internal/httpapi/live.go @@ -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) +} diff --git a/internal/httpapi/render_test.go b/internal/httpapi/render_test.go index a0fafc9..63c34cf 100644 --- a/internal/httpapi/render_test.go +++ b/internal/httpapi/render_test.go @@ -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 } diff --git a/internal/layout/layout.go b/internal/layout/layout.go index eaa8cf8..f203d3e 100644 --- a/internal/layout/layout.go +++ b/internal/layout/layout.go @@ -224,6 +224,7 @@ const ( type Result struct { Link Link Status LinkStatus + Size int64 // размер разложенного файла (байт); 0, если stat не удался } // ErrCollision — цель существует и это другой файл (нужен review). @@ -261,7 +262,17 @@ func (l *Layouter) Apply(ctx context.Context, links []Link) ([]Result, error) { } log.Debug("layout link applied", "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 } diff --git a/internal/layout/layout_test.go b/internal/layout/layout_test.go index 9b8c14a..08e6876 100644 --- a/internal/layout/layout_test.go +++ b/internal/layout/layout_test.go @@ -145,6 +145,9 @@ func TestApply_CreatesHardlink(t *testing.T) { if len(res) != 1 || res[0].Status != StatusLinked { 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, источник цел. si, _ := os.Stat(src) di, _ := os.Stat(links[0].Dst) diff --git a/internal/qbt/qbt.go b/internal/qbt/qbt.go index a9ee666..a55049e 100644 --- a/internal/qbt/qbt.go +++ b/internal/qbt/qbt.go @@ -58,6 +58,7 @@ type Torrent struct { Tags string `json:"tags"` // через запятую Progress float64 `json:"progress"` AmountLeft int64 `json:"amount_left"` + TotalSize int64 `json:"total_size"` // полный размер раздачи, байт AddedOn int64 `json:"added_on"` InfohashV1 string `json:"infohash_v1"` InfohashV2 string `json:"infohash_v2"` diff --git a/internal/store/migrations/0007_file_link_size.sql b/internal/store/migrations/0007_file_link_size.sql new file mode 100644 index 0000000..034d7a0 --- /dev/null +++ b/internal/store/migrations/0007_file_link_size.sql @@ -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; diff --git a/internal/store/recognition.go b/internal/store/recognition.go index c6869e6..89aa32d 100644 --- a/internal/store/recognition.go +++ b/internal/store/recognition.go @@ -166,6 +166,7 @@ type FileLink struct { DstPath string `db:"dst_path"` Kind string `db:"kind"` Status string `db:"status"` + Size int64 `db:"size"` // размер файла (байт), зафиксирован при линковке CreatedAt string `db:"created_at"` } @@ -181,11 +182,11 @@ func (s *Store) CreateFileLinks(ctx context.Context, links []FileLink) error { defer func() { _ = tx.Rollback() }() const q = ` -INSERT INTO file_link (id, download_id, apply_batch_id, src_path, dst_path, kind, status) -VALUES (?, ?, ?, ?, ?, ?, ?)` +INSERT INTO file_link (id, download_id, apply_batch_id, src_path, dst_path, kind, status, size) +VALUES (?, ?, ?, ?, ?, ?, ?, ?)` for _, l := range links { 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) } } @@ -249,6 +250,47 @@ func (s *Store) ListFileLinksByBatch(ctx context.Context, batchID string) ([]Fil 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 на ФС). func (s *Store) DeleteFileLinksByBatch(ctx context.Context, batchID string) error { if _, err := s.DB.ExecContext(ctx, diff --git a/internal/store/recognition_test.go b/internal/store/recognition_test.go index aaf4e39..42ff0f8 100644 --- a/internal/store/recognition_test.go +++ b/internal/store/recognition_test.go @@ -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) { st := newTestStore(t) ctx := context.Background() diff --git a/internal/worker/live_test.go b/internal/worker/live_test.go index e6b7973..fcae258 100644 --- a/internal/worker/live_test.go +++ b/internal/worker/live_test.go @@ -14,11 +14,11 @@ import ( func TestPollBuildsLiveSnapshot(t *testing.T) { qb := &fakeQbt{torrents: []qbt.Torrent{ // Качается (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 (три ключа). { 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, }, }} @@ -38,6 +38,9 @@ func TestPollBuildsLiveSnapshot(t *testing.T) { if dl.Progress != 0.5 || dl.DlSpeed != 1000 || dl.ETA != 120 { t.Errorf("телеметрия качания неверна: %+v", dl) } + if dl.TotalSize != 4096 { + t.Errorf("полный размер качающейся задачи: got %d, want 4096", dl.TotalSize) + } // Сидирующая задача находится по любому из трёх хэшей (lowercase). 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 { 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 { diff --git a/internal/worker/review.go b/internal/worker/review.go index 2e600a5..d3c63d8 100644 --- a/internal/worker/review.go +++ b/internal/worker/review.go @@ -278,6 +278,7 @@ func (w *Worker) linkPlan(ctx context.Context, d *store.Download, plan recognize DstPath: r.Link.Dst, Kind: string(r.Link.Kind), Status: string(r.Status), + Size: r.Size, }) } if len(fl) > 0 { diff --git a/internal/worker/worker.go b/internal/worker/worker.go index 6a53c19..03c9ffd 100644 --- a/internal/worker/worker.go +++ b/internal/worker/worker.go @@ -150,26 +150,28 @@ type Live struct { State string // сырое состояние qBittorrent Seeding bool // торрент завершён и раздаётся - Ratio float64 // рейтинг отдачи (может быть <0 = ∞/н/д) - Seeds int // подключённые сиды - Peers int // подключённые личи - Uploaded int64 // отдано всего, байт - UpSpeed int64 // скорость отдачи, байт/с + TotalSize int64 // полный размер раздачи, байт (доступен для любой раздачи в снимке) + 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, + Progress: t.Progress, + DlSpeed: t.Dlspeed, + ETA: t.Eta, + State: t.State, + Seeding: classify(t.State) == classReady, + TotalSize: t.TotalSize, + Ratio: t.Ratio, + Seeds: t.NumSeeds, + Peers: t.NumLeechs, + Uploaded: t.Uploaded, + UpSpeed: t.Upspeed, } } diff --git a/openspec/changes/archive/2026-07-04-download-card-lifecycle-overview/.openspec.yaml b/openspec/changes/archive/2026-07-04-download-card-lifecycle-overview/.openspec.yaml new file mode 100644 index 0000000..d86f152 --- /dev/null +++ b/openspec/changes/archive/2026-07-04-download-card-lifecycle-overview/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-07-04 diff --git a/openspec/changes/archive/2026-07-04-download-card-lifecycle-overview/design.md b/openspec/changes/archive/2026-07-04-download-card-lifecycle-overview/design.md new file mode 100644 index 0000000..c37fae0 --- /dev/null +++ b/openspec/changes/archive/2026-07-04-download-card-lifecycle-overview/design.md @@ -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. Откат безопасен: новые поля в карточке деградируют до «—», старый бинарь + игнорирует колонку. diff --git a/openspec/changes/archive/2026-07-04-download-card-lifecycle-overview/proposal.md b/openspec/changes/archive/2026-07-04-download-card-lifecycle-overview/proposal.md new file mode 100644 index 0000000..d3322b4 --- /dev/null +++ b/openspec/changes/archive/2026-07-04-download-card-lifecycle-overview/proposal.md @@ -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`). diff --git a/openspec/changes/archive/2026-07-04-download-card-lifecycle-overview/specs/file-layout/spec.md b/openspec/changes/archive/2026-07-04-download-card-lifecycle-overview/specs/file-layout/spec.md new file mode 100644 index 0000000..0c57973 --- /dev/null +++ b/openspec/changes/archive/2026-07-04-download-card-lifecycle-overview/specs/file-layout/spec.md @@ -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** суммарный размер разложенных файлов доступен как размер раздачи для + показа в карточке diff --git a/openspec/changes/archive/2026-07-04-download-card-lifecycle-overview/specs/live-status/spec.md b/openspec/changes/archive/2026-07-04-download-card-lifecycle-overview/specs/live-status/spec.md new file mode 100644 index 0000000..bc2eb5e --- /dev/null +++ b/openspec/changes/archive/2026-07-04-download-card-lifecycle-overview/specs/live-status/spec.md @@ -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** в снимке для неё доступны рейтинг, число сидов/пиров, объём + отданного и скорость отдачи diff --git a/openspec/changes/archive/2026-07-04-download-card-lifecycle-overview/specs/web-ui/spec.md b/openspec/changes/archive/2026-07-04-download-card-lifecycle-overview/specs/web-ui/spec.md new file mode 100644 index 0000000..6a8f57f --- /dev/null +++ b/openspec/changes/archive/2026-07-04-download-card-lifecycle-overview/specs/web-ui/spec.md @@ -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-запросом на сервер, без клиентского + пересчёта доменного состояния diff --git a/openspec/changes/archive/2026-07-04-download-card-lifecycle-overview/tasks.md b/openspec/changes/archive/2026-07-04-download-card-lifecycle-overview/tasks.md new file mode 100644 index 0000000..c468358 --- /dev/null +++ b/openspec/changes/archive/2026-07-04-download-card-lifecycle-overview/tasks.md @@ -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 (второй чекпоинт) diff --git a/openspec/specs/file-layout/spec.md b/openspec/specs/file-layout/spec.md index 5c6be98..a9385a8 100644 --- a/openspec/specs/file-layout/spec.md +++ b/openspec/specs/file-layout/spec.md @@ -90,3 +90,24 @@ SHALL создавать. Безопасность SHALL держаться на - **WHEN** выполняется раскладка файла - **THEN** файл копируется, ссылка получает статус `copied`, в лог пишется предупреждение +### Requirement: Запись размера разложенного файла + +При линковке файла раскладка SHALL сохранять его размер в байтах вместе с +записью о созданной ссылке (`file_link`). Сохранённый размер SHALL позволять +вычислить суммарный размер разложенных файлов загрузки — он служит фолбэком +размера раздачи, когда торрента нет в qBittorrent (например, состояние +`orphaned`, где файлы библиотеки — последняя копия данных). Запись размера MUST +NOT влиять на инвариант неприкосновенности источника (по-прежнему только +`mkdir`/`link`/копия в цель). + +#### Scenario: Размер сохраняется при линковке + +- **WHEN** раскладка создаёт хардлинк (или копию при copy-fallback) файла +- **THEN** размер этого файла в байтах сохраняется в записи ссылки `file_link` + +#### Scenario: Суммарный размер доступен без торрента + +- **WHEN** у загрузки есть разложенные файлы, а её торрента нет в qBittorrent +- **THEN** суммарный размер разложенных файлов доступен как размер раздачи для + показа в карточке + diff --git a/openspec/specs/live-status/spec.md b/openspec/specs/live-status/spec.md index 6c51907..3d97936 100644 --- a/openspec/specs/live-status/spec.md +++ b/openspec/specs/live-status/spec.md @@ -22,16 +22,23 @@ TBD - created by archiving change live-status. Update Purpose after archive. ### Requirement: Состав телеметрии -Телеметрия одной раздачи SHALL включать прогресс (доля 0..1), скорость -загрузки и ETA, а для сидирующих раздач дополнительно — рейтинг, число сидов -и пиров, объём отданного и скорость отдачи. Значения SHALL извлекаться из -ответа qBittorrent `/torrents/info` без дополнительного сетевого вызова. +Телеметрия одной раздачи SHALL включать прогресс (доля 0..1), скорость загрузки, +ETA и общий размер раздачи (total size); а для сидирующих раздач дополнительно — +рейтинг, число сидов и пиров, объём отданного и скорость отдачи. Общий размер +SHALL быть доступен для любой раздачи, присутствующей в снимке (не только +сидирующей). Значения SHALL извлекаться из ответа qBittorrent `/torrents/info` +без дополнительного сетевого вызова. #### Scenario: Телеметрия качающейся задачи - **WHEN** торрент задачи находится в состоянии загрузки - **THEN** в снимке для неё доступны прогресс, скорость загрузки и ETA +#### Scenario: Общий размер доступен для любой раздачи в снимке + +- **WHEN** торрент задачи присутствует в снимке в любом состоянии +- **THEN** в телеметрии для неё доступен общий размер раздачи + #### Scenario: Телеметрия сидирующей задачи - **WHEN** торрент задачи завершён и раздаётся diff --git a/openspec/specs/web-ui/spec.md b/openspec/specs/web-ui/spec.md index 74f7e97..b9a6b63 100644 --- a/openspec/specs/web-ui/spec.md +++ b/openspec/specs/web-ui/spec.md @@ -262,12 +262,11 @@ jellybit (`created_at`). Порядок MUST быть согласован ме ### Requirement: Клиентские взаимодействия без сборки Веб-UI SHALL реализовывать клиентскую логику без шага сборки и без реактивных -фреймворков: копирование идентификатора загрузки (vanilla JS) и раскрытие -контекста нативным `
`. Основной копируемый идентификатор в карточке -списка и в шапке страницы просмотра SHALL быть `download.id` (ULID) — тот же -ключ, что пишется в логи (`download_id`). Все действия над загрузкой SHALL -выполняться через формы/htmx (раундтрип на сервер), без клиентского пересчёта -доменного состояния. +фреймворков: копирование идентификатора загрузки (vanilla JS). Основной +копируемый идентификатор в карточке списка и в шапке страницы просмотра SHALL +быть `download.id` (ULID) — тот же ключ, что пишется в логи (`download_id`). Все +действия над загрузкой SHALL выполняться через формы/htmx (раундтрип на сервер), +без клиентского пересчёта доменного состояния. #### Scenario: Копирование идентификатора загрузки @@ -276,8 +275,61 @@ jellybit (`created_at`). Порядок MUST быть согласован ме - **THEN** значение `download.id` копируется в буфер обмена без перезагрузки страницы -#### Scenario: Раскрытие контекста без JS +#### Scenario: Действия только через раундтрип -- **WHEN** пользователь раскрывает спойлер переданного контекста -- **THEN** контекст показывается нативным `
`, без скриптов +- **WHEN** пользователь выполняет действие над загрузкой +- **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}` diff --git a/web/static/css/jellybit.css b/web/static/css/jellybit.css index f6e5dd2..af41e2c 100644 --- a/web/static/css/jellybit.css +++ b/web/static/css/jellybit.css @@ -219,6 +219,12 @@ a:hover{text-decoration:underline} .dl-live:empty{display:none} .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. Спойлер ---------- */ details.spoiler{margin-top:var(--sp-3)} .spoiler summary{cursor:pointer;color:var(--accent);font-size:var(--fs-sm); diff --git a/web/templates/index.html b/web/templates/index.html index c79cffa..ee84506 100644 --- a/web/templates/index.html +++ b/web/templates/index.html @@ -57,6 +57,7 @@
+ ID: {{.ID}}
@@ -64,14 +65,15 @@ {{template "status_badge" .State}}
- {{if .IsDownloading}}{{template "progress" .Progress}}{{end}} +
+ {{if .Added}}{{.Added}} ({{.AddedAgo}}){{else}}—{{end}} + · + {{.Size}} + · + ↑ {{.Ratio}} +
- {{if .Context}} -
- контекст -
«{{.Context}}»
-
- {{end}} + {{if .IsDownloading}}{{template "progress" .Progress}}{{end}}
{{if .Note}}{{.Note}}{{else if .Error}}{{.Error}}{{end}}