UI/UX списка и карточки загрузки: серверные фильтр/поиск/пагинация, матч-ссылка, имя раздачи (web-ui-list-detail)

- Список: серверные фильтр по группе состояний, поиск и пагинация (GET
  f/q/page/all, по 25), сортировка по времени добавления в qBittorrent
  (added_on) с фолбеком на created_at и tie-break по id.
- Заголовок загрузки = имя раздачи (display_name) → распознанное название →
  усечённый источник; сырой magnet вынесен в блок «Информация о торренте».
- Матч метабазы показан ссылкой на запись (страница загрузки и ревью);
  URL берётся у выбранного кандидата либо строится по provider+id и типу.
- Полировка вёрстки; клиентская JS-фильтрация убрана (всё серверное, без JS).
- Миграция 0005 (display_name, source_added_at); воркер однократно
  фиксирует source_added_at при поллинге/усыновлении; ER-схема обновлена.
- OpenSpec: дельты влиты в specs/{web-ui,ingest}, change заархивирован.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
av
2026-07-01 09:50:24 +03:00
co-authored by Claude Opus 4.8
parent 2a9bf7efb0
commit c0b5ab7295
33 changed files with 1679 additions and 110 deletions
@@ -0,0 +1,218 @@
## Context
Современный server-rendered веб-UI уже даёт список-карточки, страницу просмотра
`/download/{id}`, ревью и живой прогресс (htmx-поллинг снимка воркера). Осталось
закрыть хвосты беклога по UI/UX. Текущее состояние по пунктам:
- **Список** (`handleIndex` + `index.html`): грузит `ListDownloads` целиком,
фильтр-чипы и поиск — клиентский JS по `data-group`/`data-text` уже
отрендеренных карточек, `deleted` прячется чекбоксом `showAll`. Пагинации нет.
- **Заголовок** карточки и шапки страницы — `d.SourceRef` (для magnet это сырая
ссылка). Красивое имя есть только в блоке «Распознано как» и только после
распознавания (`rd.Plan.Title`). Имя `rename`, уходящее в qBittorrent при
приёме (`internal/ingest`, `Namer.DeriveName`), нигде не сохраняется.
- **Матч метабазы**: на ревью кандидаты уже показываются со ссылкой
(`candidate_url`, миграция 0004); но подтверждённый выбор («Выбрано: provider
id») и блок «Распознано как» на `/download/{id}` ссылки не имеют.
- **Полировка**: в «Распознано как» список полей ограничен `max-width:360px`; в
layout-виджете стрелка не приклеена к строке файла-источника.
Ограничения: один статический бинарь, без сборки фронта и реактивных фреймворков;
чтение — через `store`, тонкий транспорт `httpapi`; SQLite (одиночное соединение,
сериализация записи). Целевой масштаб — сотни–тысячи строк `download`.
## Goals / Non-Goals
**Goals:**
- Список масштабируется на рост БД: серверные фильтр по состоянию, поиск и
пагинация через GET-параметры, работающие без JS и шарящиеся ссылкой.
- Заголовок загрузки — человекочитаемое имя раздачи qBittorrent, доступное с
первого кадра (до распознавания), с внятным фолбеком.
- Подтверждённый матч метабазы виден ссылкой на запись во всех веб-местах
(карточка просмотра, «Источник совпадения» ревью).
- Сырой источник/magnet не мозолит глаза в заголовке — вынесен в отдельный блок.
- Мелкие дефекты вёрстки устранены.
**Non-Goals:**
- Полный лог переходов состояний (нужна таблица истории — отдельная задача).
- Собственный идентификатор загрузки, слияние по infohash.
- Полноценный «выбор источника матча с предпросмотром полей» (отдельная задача
беклога); здесь — только ссылка на уже подтверждённый матч.
- Показ матча в Telegram, перевод живых обновлений на SSE.
## Decisions
### 1. Список: серверные фильтр/поиск/пагинация через GET-параметры
Параметры: `f` (группа состояний: `all|review|active|done|problem`, по умолчанию
`all` без `deleted`), `q` (строка поиска), `page` (1-based), плюс `all=1`
(показать в т.ч. `deleted`). Размер страницы — константа в `httpapi` (**25**).
Новый метод `store` возвращает страницу + общее число строк под фильтром для
рендера пагинации: например `ListDownloadsPage(ctx, ListFilter) ([]Download,
int, error)`, где `ListFilter{Group, Query, Deleted, Limit, Offset}`. В SQL:
`WHERE` по множеству состояний группы (маппинг из существующего `stateGroup`,
вынести в общий источник истины состояние↔группа), `deleted` включается только
при `all`; поиск — `LIKE '%'||?||'%'` по `source_ref`, `display_name`, `context`,
`infohash` (регистронезависимо, `COLLATE NOCASE`); сортировка
`ORDER BY COALESCE(source_added_at, created_at) DESC, id DESC` (см. решение 5);
`LIMIT/OFFSET`. Итог count — отдельный `COUNT(*)` с тем же `WHERE`.
`page` клэмпится: `< 1`/нечисловой → `1`; за последней страницей → пустая
страница (не ошибка). Метасимволы `%`/`_` в `q` не экранируем (параметризованный
`LIKE`, не security-issue; при необходимости добавим `ESCAPE` позже).
Пустое состояние (`len(page)==0`) рендерит сервер: «ничего не найдено», если
активны фильтр/поиск, иначе «пока пусто» — клиентского JS для этого больше нет.
**Почему GET, а не htmx/JSON:** закладки/шаринг, работа без JS, минимум кода;
согласуется с «клиентская логика без сборки». Пагинация и чипы — обычные ссылки,
сохраняющие текущие `f`/`q`. Клиентская JS-фильтрация удаляется (заменена
серверной), копирование infohash и `<details>` остаются.
**Живой прогресс:** карточки текущей страницы по-прежнему сами поллят свой
фрагмент прогресса — механика `live-status` не затрагивается. Форма поиска —
обычный GET-submit; фильтр-чипы — ссылки.
Альтернатива (клиентская пагинация или «Только пагинация» с клиентским
фильтром) отвергнута: фильтрует лишь текущую страницу и вводит в заблуждение.
### 2. Персистентность отображаемого имени (`display_name`)
Миграция `0005`: `ALTER TABLE download ADD COLUMN display_name TEXT NOT NULL
DEFAULT ''`. При приёме (`internal/ingest`) выведенное имя `rename` пишется и в
`download.display_name` (та же строка, что уходит в qBittorrent). Инвариант
ingest-спеки сохраняется: имя не влияет на пути/распознавание/раскладку.
Для **усыновлённых** торрентов (`discover.adopt()`) приёма через `ingest` нет и
`rename` не выводится; там `SourceRef` — искусственный `magnet:?xt=urn:btih:…`.
Чтобы заголовок таких задач не был голым btih, при усыновлении пишем
`display_name = t.Name` (имя торрента из qBittorrent).
Заголовок в веб-UI (карточка списка и шапка `/download/{id}`) — фолбек-цепочка:
1. `display_name` (если непустой);
2. распознанное `rd.Plan.Title` (если есть план);
3. сырой источник (`source_ref`), **усечённый в одну строку** как обычный
заголовок.
Старые строки БД получат `display_name=''` → отработает п.2/п.3, регресса нет.
**Почему колонка, а не вывод на лету:** `rename` выводится единожды при приёме
(в т.ч. через LLM) и в БД сейчас не сохраняется; пересчитывать на каждый рендер
дорого и недетерминированно. Хранение — дёшево и даёт стабильный заголовок с
первого кадра.
### 3. Ссылка на запись метабазы
Приоритет — URL уже сохранённого выбранного кандидата (`candidate_url`,
миграция 0004): в `ReviewData` есть `Candidates` с флагом `Chosen` и `URL`
(детальная страница их грузит — `handleDownload``ReviewData`, проверено).
**Важно:** URL выбранного кандидата берём только если его `provider`+`id`
совпадают с **эффективными** `rd.Provider`/`rd.ProviderID` (пользователь мог
выбрать кандидата, а затем вручную переопределить `provider_id` — тогда
`Chosen`-кандидат указывает на другую запись). Если не совпадают или URL у
кандидата нет — строим канонический URL функцией `providerURL(provider, id,
mediaType)`.
`providerURL` учитывает тип медиа: для `tmdb``/movie/{id}` vs `/tv/{id}`
(см. `internal/metadata/tmdb.go`), для `tvdb` — медиазависимый путь (см.
`internal/metadata/tvdb.go`), для `imdb``/title/{id}`. Тип на детальной
странице известен (`view.IsSeries`/`rd.Plan.Type`). Если для провайдера/типа
надёжный URL не построить — показываем матч текстом (провайдер, id) без ссылки
(предусмотрено сценарием «URL записи неизвестен»).
Ссылка показывается в «Распознано как» на `/download/{id}` и в строке «Выбрано»
блока «Источник совпадения» ревью. Ссылки — `target=_blank rel=noopener` (как у
кандидатов).
### 4. Блок «Информация о торренте» на `/download/{id}`
Отдельная секция: тип источника, полный `source_ref`/magnet и `infohash` с
кнопкой копирования. Разгружает заголовок (см. решение 2) и собирает «сырьё» в
одном месте.
### 5. Сортировка списка по времени добавления в источник
Список сортируется по времени добавления торрента в qBittorrent (`added_on`), с
фолбеком на время создания загрузки в jellybit (`created_at`). Причина: jellybit
может **захватывать уже добавленные** (усыновлённые) торренты, поэтому
`created_at` не отражает реальный порядок появления раздачи; `added_on` — более
верный базис (тем же соображением воркер уже считает возраст задачи от
`added_on`, см. `worker.torrentAge`).
`added_on` не хранится в БД — воркер берёт его из живого `qbt.Torrent`. Добавляем
колонку `download.source_added_at` (nullable) и **один раз** персистим её из
воркера. Значение неизменно (время добавления не меняется), пишем только при
первом наблюдении.
**Формат (критично для сортировки):** `source_added_at` хранится байт-в-байт в
том же формате, что `created_at``YYYY-MM-DD HH:MM:SS` в UTC
(`store.sqliteTimeLayout`). Воркер форматирует
`time.Unix(t.AddedOn,0).UTC().Format(...)` через хелпер `store` (не RFC3339 и не
число), иначе лексикографическое сравнение TEXT в `COALESCE(...)` даст неверный
порядок при смешивании заполненных и фолбек-строк.
**Точка записи:** в цикле `Poll` сразу после успешного сопоставления активной
задачи с торрентом по `byHash` (до `reconcile`), для любого класса состояния
торрента — иначе задача, увиденная тиком уже готовой (`classReady` → сразу
`transition`), запись пропустит. Для усыновлённых торрентов `added_on` доступен
уже в `adopt()` (`discover.go`) — пишем и там. Всё под `w.mu`. Идемпотентность:
проверка `d.SourceAddedAt` в Go + SQL-гард `WHERE source_added_at IS NULL` в
методе стора (не писать на каждом тике).
**Сортировка** в запросе списка — `ORDER BY COALESCE(source_added_at,
created_at) DESC, id DESC`. Вторичный ключ `id DESC` обязателен: без него при
равных метках времени (пакетное добавление в одну секунду) SQLite даёт
неустойчивый порядок, и при `LIMIT/OFFSET` строки задваиваются/пропадают между
страницами — это нарушило бы требование «порядок согласован между страницами».
Альтернатива (сортировка по `id DESC` или только `created_at`) отвергнута:
порядок разъедется для усыновлённых торрентов.
### 6. Полировка вёрстки (CSS/шаблоны)
- «Распознано как»: снять `max-width:360px` у `dl.kv` — блок на всю ширину.
- Layout-виджет: приклеить стрелку к строке файла-источника (источник и цель
строго друг под другом) — правка `layout_widget.html`/CSS.
Инвариант дизайн-системы соблюдаем: без инлайн-`<style>` с хардкодом цветов,
правки уходят в `jellybit.css`.
## Risks / Trade-offs
- **UX: перезагрузка страницы вместо мгновенной клиентской фильтрации** →
GET-параметры быстры на server-rendered UI; форма/ссылки дёшевы; выигрыш —
масштаб и шаринг ссылок.
- **Производительность `LIKE '%q%'`** (не использует индекс) → на целевом
масштабе (сотни–тысячи строк) приемлемо; при нужде позже — FTS/индекс, вне
объёма.
- **Взаимодействие пагинации с живым прогрессом** → карточки поллят
индивидуальные фрагменты по id; пагинация не меняет `live-status`.
- **Пустой `display_name` у старых строк** → фолбек-цепочка покрывает; бэкофилл
не требуется.
- **Ручной `provider_id` без кандидата** → `providerURL` строит ссылку по
провайдеру; для неизвестного провайдера ссылку не показываем (только текст).
## Migration Plan
1. Добавить goose-миграцию `0005` — колонки `download.display_name` и
`download.source_added_at` (up: `ADD COLUMN`; down: снятие колонок по
возможностям SQLite/goose).
2. Обновить ER-схему `docs/specs/database.md` (обе новые колонки).
3. Писать `display_name` в `ingest` при `CreateDownload`.
4. В `worker` персистить `source_added_at` при поллинге (однократно, при первом
наблюдении `added_on`).
5. Добавить запрос списка с фильтром/поиском/сортировкой/пагинацией и count в `store`.
6. Перевести `handleIndex` на параметры + новый запрос, обновить шаблоны и CSS,
убрать клиентскую фильтрацию.
Откат: обратная миграция (down) снимает колонку; UI-код фолбечит на
`source_ref`. Изменения аддитивны, REST-контракт `/api/downloads` не меняется.
## Open Questions
- Размер страницы (25) — уточним по ощущению на реальных данных; вынести в
конфиг при необходимости (сейчас константа).