Веб-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
+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 {