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:
@@ -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) — уточним по ощущению на реальных данных; вынести в
|
||||
конфиг при необходимости (сейчас константа).
|
||||
Reference in New Issue
Block a user