## 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 и `
` остаются. **Живой прогресс:** карточки текущей страницы по-прежнему сами поллят свой фрагмент прогресса — механика `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. Инвариант дизайн-системы соблюдаем: без инлайн-`