Files
avandClaude Opus 4.8 c0b5ab7295 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>
2026-07-01 09:50:24 +03:00

219 lines
17 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
## 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) — уточним по ощущению на реальных данных; вынести в
конфиг при необходимости (сейчас константа).