Веб-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
@@ -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. Откат безопасен: новые поля в карточке деградируют до «—», старый бинарь
игнорирует колонку.