Веб-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:
@@ -0,0 +1,2 @@
|
||||
schema: spec-driven
|
||||
created: 2026-07-04
|
||||
@@ -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. Откат безопасен: новые поля в карточке деградируют до «—», старый бинарь
|
||||
игнорирует колонку.
|
||||
@@ -0,0 +1,61 @@
|
||||
## Why
|
||||
|
||||
Карточка загрузки в списке на главной сейчас показывает состояние, заголовок,
|
||||
идентификатор и (для качающихся) прогресс. Для решения «пора ли удалять
|
||||
раздачу» этого мало: пользователь удаляет по **возрасту** и по **рейтингу
|
||||
отдачи**, а этих полей в обзоре нет. Одновременно карточка тратит место на
|
||||
спойлер контекста, который логичнее смотреть на странице загрузки. Цель —
|
||||
сделать карточку кратким обзором жизненного цикла: от загрузки до момента, когда
|
||||
раздачу пора убирать.
|
||||
|
||||
## What Changes
|
||||
|
||||
- Убрать из карточки списка спойлер «контекст»; контекст остаётся доступен на
|
||||
странице загрузки `/download/{id}`.
|
||||
- Дописать метку `ID:` перед идентификатором загрузки — чтобы было понятно, что
|
||||
за значение копируется.
|
||||
- Показывать в карточке **всегда** дату добавления раздачи в формате
|
||||
«`2026-06-30 · 5 дней назад`» (абсолютная + относительная). Источник — время
|
||||
добавления в источник (`source_added_at`, qBittorrent `added_on`) с фолбэком
|
||||
на время создания загрузки (`created_at`).
|
||||
- Показывать в карточке **рейтинг отдачи** (ratio) из живого снимка воркера;
|
||||
если торрента нет в qBittorrent — прочерк «—».
|
||||
- Показывать в карточке **размер раздачи**: из живого снимка (общий размер
|
||||
торрента), а когда торрента нет в qBittorrent — из суммарного размера
|
||||
разложенных файлов.
|
||||
- Расширить состав живой телеметрии общим размером раздачи (доступен для любой
|
||||
раздачи в снимке, не только сидирующей).
|
||||
- Раскладка при линковке SHALL сохранять размер каждого созданного файла — чтобы
|
||||
фолбэк размера работал и после исчезновения торрента (`orphaned`).
|
||||
|
||||
Явно вне объёма: сиды/пиры, время сидирования и объём отданного в карточку не
|
||||
добавляем — пользователю для решения об удалении достаточно возраста и рейтинга.
|
||||
|
||||
## Capabilities
|
||||
|
||||
### New Capabilities
|
||||
|
||||
Нет.
|
||||
|
||||
### Modified Capabilities
|
||||
|
||||
- `web-ui`: карточка списка получает обзорную мета-строку (ID-метка, дата
|
||||
добавления всегда, размер, рейтинг); спойлер контекста уходит из карточки на
|
||||
страницу загрузки.
|
||||
- `live-status`: состав телеметрии дополняется общим размером раздачи, доступным
|
||||
для любой раздачи в снимке (не только сидирующей).
|
||||
- `file-layout`: раскладка сохраняет размер каждого разложенного файла (основа
|
||||
фолбэка размера раздачи, когда торрента нет в qBittorrent).
|
||||
|
||||
## Impact
|
||||
|
||||
- `internal/qbt` — поле `TotalSize` (`json:"total_size"`) в `Torrent`.
|
||||
- `internal/worker` — `Live.TotalSize`, проброс из `qbt.Torrent`.
|
||||
- `internal/store` — колонка `file_link.size` (миграция goose), запись размера
|
||||
при линковке, чтение суммарного размера разложенных файлов для карточки.
|
||||
- `internal/httpapi` — новые поля `downloadView`, форматтеры даты/относительного
|
||||
времени/размера/рейтинга; `handleIndex` читает живой снимок для всех карточек
|
||||
списка, а не только качающихся.
|
||||
- `web/templates/index.html` — мета-строка карточки (+ возможный партиал), удаление
|
||||
спойлера контекста.
|
||||
- `docs/specs/database.md` — обновить ER-схему (новая колонка `file_link.size`).
|
||||
+24
@@ -0,0 +1,24 @@
|
||||
# file-layout Specification (delta)
|
||||
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Запись размера разложенного файла
|
||||
|
||||
При линковке файла раскладка SHALL сохранять его размер в байтах вместе с
|
||||
записью о созданной ссылке (`file_link`). Сохранённый размер SHALL позволять
|
||||
вычислить суммарный размер разложенных файлов загрузки — он служит фолбэком
|
||||
размера раздачи, когда торрента нет в qBittorrent (например, состояние
|
||||
`orphaned`, где файлы библиотеки — последняя копия данных). Запись размера MUST
|
||||
NOT влиять на инвариант неприкосновенности источника (по-прежнему только
|
||||
`mkdir`/`link`/копия в цель).
|
||||
|
||||
#### Scenario: Размер сохраняется при линковке
|
||||
|
||||
- **WHEN** раскладка создаёт хардлинк (или копию при copy-fallback) файла
|
||||
- **THEN** размер этого файла в байтах сохраняется в записи ссылки `file_link`
|
||||
|
||||
#### Scenario: Суммарный размер доступен без торрента
|
||||
|
||||
- **WHEN** у загрузки есть разложенные файлы, а её торрента нет в qBittorrent
|
||||
- **THEN** суммарный размер разложенных файлов доступен как размер раздачи для
|
||||
показа в карточке
|
||||
+28
@@ -0,0 +1,28 @@
|
||||
# live-status Specification (delta)
|
||||
|
||||
## MODIFIED Requirements
|
||||
|
||||
### Requirement: Состав телеметрии
|
||||
|
||||
Телеметрия одной раздачи SHALL включать прогресс (доля 0..1), скорость загрузки,
|
||||
ETA и общий размер раздачи (total size); а для сидирующих раздач дополнительно —
|
||||
рейтинг, число сидов и пиров, объём отданного и скорость отдачи. Общий размер
|
||||
SHALL быть доступен для любой раздачи, присутствующей в снимке (не только
|
||||
сидирующей). Значения SHALL извлекаться из ответа qBittorrent `/torrents/info`
|
||||
без дополнительного сетевого вызова.
|
||||
|
||||
#### Scenario: Телеметрия качающейся задачи
|
||||
|
||||
- **WHEN** торрент задачи находится в состоянии загрузки
|
||||
- **THEN** в снимке для неё доступны прогресс, скорость загрузки и ETA
|
||||
|
||||
#### Scenario: Общий размер доступен для любой раздачи в снимке
|
||||
|
||||
- **WHEN** торрент задачи присутствует в снимке в любом состоянии
|
||||
- **THEN** в телеметрии для неё доступен общий размер раздачи
|
||||
|
||||
#### Scenario: Телеметрия сидирующей задачи
|
||||
|
||||
- **WHEN** торрент задачи завершён и раздаётся
|
||||
- **THEN** в снимке для неё доступны рейтинг, число сидов/пиров, объём
|
||||
отданного и скорость отдачи
|
||||
+79
@@ -0,0 +1,79 @@
|
||||
# web-ui Specification (delta)
|
||||
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Обзор жизненного цикла в карточке списка
|
||||
|
||||
Карточка загрузки в списке SHALL показывать обзорную мета-строку для решения о
|
||||
судьбе раздачи: метку `ID:` перед копируемым идентификатором загрузки, дату
|
||||
добавления раздачи (всегда), размер раздачи и рейтинг отдачи. Контекст загрузки
|
||||
MUST NOT показываться в карточке списка — он доступен на странице `/download/{id}`.
|
||||
|
||||
Дата добавления SHALL показываться всегда как абсолютная дата и относительная
|
||||
давность (например «`2026-06-30 · 5 дней назад`»); источником SHALL быть время
|
||||
добавления раздачи в источник (`source_added_at`, qBittorrent `added_on`) с
|
||||
фолбэком на время создания загрузки (`created_at`), согласованным с порядком
|
||||
списка.
|
||||
|
||||
Рейтинг отдачи SHALL браться из живого снимка телеметрии; если торрента нет в
|
||||
снимке (источник ушёл из qBittorrent), рейтинг SHALL отображаться прочерком «—».
|
||||
|
||||
Размер раздачи SHALL браться из живого снимка (общий размер торрента), а при
|
||||
отсутствии торрента в снимке — из суммарного размера разложенных файлов загрузки;
|
||||
если неизвестно ни то, ни другое — прочерк «—».
|
||||
|
||||
#### Scenario: Метка идентификатора
|
||||
|
||||
- **WHEN** рендерится карточка загрузки в списке
|
||||
- **THEN** перед значением `download.id` показана метка «ID:», а кнопка
|
||||
копирования копирует именно `download.id`
|
||||
|
||||
#### Scenario: Дата добавления показана всегда
|
||||
|
||||
- **WHEN** рендерится любая карточка списка
|
||||
- **THEN** в ней показана дата добавления раздачи абсолютной датой и
|
||||
относительной давностью
|
||||
- **AND** если `source_added_at` неизвестно, используется `created_at`
|
||||
|
||||
#### Scenario: Рейтинг из живого снимка
|
||||
|
||||
- **WHEN** торрент загрузки присутствует в живом снимке
|
||||
- **THEN** в карточке показан его рейтинг отдачи
|
||||
- **AND** если торрента в снимке нет, рейтинг показан прочерком «—»
|
||||
|
||||
#### Scenario: Размер с фолбэком на разложенные файлы
|
||||
|
||||
- **WHEN** торрент загрузки присутствует в живом снимке
|
||||
- **THEN** размер раздачи в карточке берётся из общего размера торрента
|
||||
- **AND** если торрента в снимке нет, но у загрузки есть разложенные файлы —
|
||||
размер берётся из суммарного размера этих файлов
|
||||
|
||||
#### Scenario: Контекст не в карточке
|
||||
|
||||
- **WHEN** у загрузки есть переданный контекст
|
||||
- **THEN** он не показывается в карточке списка, но доступен на странице
|
||||
`/download/{id}`
|
||||
|
||||
## MODIFIED Requirements
|
||||
|
||||
### Requirement: Клиентские взаимодействия без сборки
|
||||
|
||||
Веб-UI SHALL реализовывать клиентскую логику без шага сборки и без реактивных
|
||||
фреймворков: копирование идентификатора загрузки (vanilla JS). Основной
|
||||
копируемый идентификатор в карточке списка и в шапке страницы просмотра SHALL
|
||||
быть `download.id` (ULID) — тот же ключ, что пишется в логи (`download_id`). Все
|
||||
действия над загрузкой SHALL выполняться через формы/htmx (раундтрип на сервер),
|
||||
без клиентского пересчёта доменного состояния.
|
||||
|
||||
#### Scenario: Копирование идентификатора загрузки
|
||||
|
||||
- **WHEN** пользователь нажимает кнопку копирования рядом с идентификатором
|
||||
загрузки (`download.id`) в карточке списка или шапке страницы просмотра
|
||||
- **THEN** значение `download.id` копируется в буфер обмена без перезагрузки
|
||||
страницы
|
||||
|
||||
#### Scenario: Действия только через раундтрип
|
||||
|
||||
- **WHEN** пользователь выполняет действие над загрузкой
|
||||
- **THEN** оно исполняется формой/htmx-запросом на сервер, без клиентского
|
||||
пересчёта доменного состояния
|
||||
@@ -0,0 +1,37 @@
|
||||
## 1. Живой размер раздачи (снимок)
|
||||
|
||||
- [x] 1.1 `internal/qbt`: добавить поле `TotalSize int64 \`json:"total_size"\`` в `Torrent`
|
||||
- [x] 1.2 `internal/worker`: добавить `TotalSize int64` в `Live`, пробросить в `liveFrom`
|
||||
- [x] 1.3 `internal/worker/live_test.go`: покрыть проброс `TotalSize` в снимок
|
||||
|
||||
## 2. Размер разложенных файлов (БД)
|
||||
|
||||
- [x] 2.1 Goose-миграция `internal/store/migrations`: `ALTER TABLE file_link ADD COLUMN size INTEGER NOT NULL DEFAULT 0` (+ down)
|
||||
- [x] 2.2 `internal/store`: писать `size` при вставке `file_link` (layouter передаёт размер файла, известный на момент линковки)
|
||||
- [x] 2.3 `internal/store`: метод чтения суммарного размера разложенных файлов по id загрузок (батч по странице, как `attachInfohashes`)
|
||||
- [x] 2.4 `internal/layout` (или вызывающий раскладку): передать размер файла (`os.Stat`/`qbt.File.Size`) в запись `file_link`
|
||||
- [x] 2.5 Тест: запись `file_link.size` при линковке и copy-fallback; `SUM(size)` по загрузке
|
||||
- [x] 2.6 Обновить ER-схему `docs/specs/database.md` (колонка `file_link.size`)
|
||||
|
||||
## 3. View-model и форматтеры (httpapi)
|
||||
|
||||
- [x] 3.1 `downloadView`: добавить поля `Added`, `AddedAgo`, `Size`, `Ratio` (строки для шаблона); убрать зависимость карточки от `Context`
|
||||
- [x] 3.2 Форматтер даты добавления: абсолют `2006-01-02` в TZ `Europe/Moscow`, базис `source_added_at` → фолбэк `created_at`
|
||||
- [x] 3.3 Форматтер относительной давности («N дней/часов назад») от `now` в TZ сервера
|
||||
- [x] 3.4 `toView`/сборка карточки: размер (снимок `TotalSize` → фолбэк `SUM(file_link.size)` → «—»), рейтинг (`fmtRatio` из снимка → «—»), переиспользовать `fmtBytes`
|
||||
- [x] 3.5 `handleIndex`: читать `liveFor(d)` для всех карточек страницы, а не только `downloading`; прокинуть суммарные размеры файлов в сборку карточек
|
||||
- [x] 3.6 Тесты httpapi: дата (с фолбэком на `created_at`), размер (снимок и фолбэк по файлам, и «—»), рейтинг («—» без снимка)
|
||||
|
||||
## 4. Шаблон карточки
|
||||
|
||||
- [x] 4.1 `web/templates/index.html`: удалить спойлер контекста из карточки
|
||||
- [x] 4.2 `web/templates/index.html`: метка `ID:` перед идентификатором (кнопка копирования копирует `download.id`)
|
||||
- [x] 4.3 `web/templates/index.html`: мета-строка карточки (дата · размер · рейтинг), при необходимости вынести в партиал
|
||||
- [x] 4.4 Проверить деградацию: значения «—» когда данных нет, вёрстка не ломается
|
||||
|
||||
## 5. Проверка
|
||||
|
||||
- [x] 5.1 `task lint` и `task test` зелёные
|
||||
- [x] 5.2 Ручная проверка карточек в разных состояниях (downloading / done+сидирование / orphaned) — дата, размер, рейтинг отображаются корректно
|
||||
- [x] 5.3 `openspec validate download-card-lifecycle-overview --strict`
|
||||
- [x] 5.4 Ревью кода перед archive (второй чекпоинт)
|
||||
Reference in New Issue
Block a user