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