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