- Список: серверные фильтр по группе состояний, поиск и пагинация (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>
17 KiB
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}) — фолбек-цепочка:
display_name(если непустой);- распознанное
rd.Plan.Title(если есть план); - сырой источник (
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
- Добавить goose-миграцию
0005— колонкиdownload.display_nameиdownload.source_added_at(up:ADD COLUMN; down: снятие колонок по возможностям SQLite/goose). - Обновить ER-схему
docs/specs/database.md(обе новые колонки). - Писать
display_nameвingestприCreateDownload. - В
workerперсиститьsource_added_atпри поллинге (однократно, при первом наблюденииadded_on). - Добавить запрос списка с фильтром/поиском/сортировкой/пагинацией и count в
store. - Перевести
handleIndexна параметры + новый запрос, обновить шаблоны и CSS, убрать клиентскую фильтрацию.
Откат: обратная миграция (down) снимает колонку; UI-код фолбечит на
source_ref. Изменения аддитивны, REST-контракт /api/downloads не меняется.
Open Questions
- Размер страницы (25) — уточним по ощущению на реальных данных; вынести в конфиг при необходимости (сейчас константа).