Веб-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:
av
2026-07-04 10:34:45 +03:00
co-authored by Claude Opus 4.8
parent 9ed732e49e
commit bb245a90a3
27 changed files with 845 additions and 49 deletions
+124
View File
@@ -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("контекст всё ещё показан в карточке списка")
}
}
+52 -6
View File
@@ -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 {
+3
View File
@@ -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()
+82
View File
@@ -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)
}
+6 -2
View File
@@ -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 }
+12 -1
View File
@@ -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
}
+3
View File
@@ -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)
+1
View File
@@ -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"`
@@ -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;
+45 -3
View File
@@ -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,
+36
View File
@@ -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()
+8 -2
View File
@@ -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 {
+1
View File
@@ -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 {
+17 -15
View File
@@ -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,
}
}