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