Files
jellybit/openspec/changes/archive/2026-07-01-web-ui-list-detail/design.md
T
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

17 KiB
Raw Blame History

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 (детальная страница их грузит — handleDownloadReviewData, проверено).

Важно: 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_atYYYY-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) — уточним по ощущению на реальных данных; вынести в конфиг при необходимости (сейчас константа).