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>
This commit is contained in:
@@ -0,0 +1,2 @@
|
||||
schema: spec-driven
|
||||
created: 2026-07-01
|
||||
@@ -0,0 +1,218 @@
|
||||
## 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) — уточним по ощущению на реальных данных; вынести в
|
||||
конфиг при необходимости (сейчас константа).
|
||||
@@ -0,0 +1,72 @@
|
||||
## Why
|
||||
|
||||
Пачка UI/UX-задач беклога («Главная: список вместо таблицы», «Расширенная
|
||||
информация о загрузке», «показывать матч с записью метабазы», «список: фильтр,
|
||||
поиск, пагинация», «Полировка веб-UI») частично уже закрыта при внедрении
|
||||
современного server-rendered UI. Осталось довести хвосты: список не масштабируется
|
||||
на рост БД (нет серверной пагинации, фильтр/поиск чисто клиентские по уже
|
||||
загруженным карточкам), подтверждённый матч метабазы показывается без ссылки на
|
||||
запись, заголовок карточки — сырой magnet вместо имени раздачи, и есть мелкие
|
||||
дефекты вёрстки. Собираем это в одно небольшое изменение.
|
||||
|
||||
## What Changes
|
||||
|
||||
- **Список загрузок — серверные фильтр, поиск и пагинация.** Фильтр по группе
|
||||
состояний, поиск по названию/infohash/контексту и постраничная выдача (по 25)
|
||||
через query-параметры (`?f=`, `?q=`, `?page=`), на стороне `store`/`httpapi`;
|
||||
заменяет текущую клиентскую фильтрацию по отрендеренным карточкам. Сортировка —
|
||||
по времени добавления торрента в источник (qBittorrent `added_on`), фолбек —
|
||||
время создания загрузки в jellybit. Скрытие `deleted` по умолчанию сохраняется.
|
||||
Живой прогресс активных карточек (htmx-поллинг) не ломается.
|
||||
- **Заголовок загрузки = имя раздачи qBittorrent.** При приёме сохраняем
|
||||
выведенное отображаемое имя (`rename`, что уходит в qBittorrent) в новую колонку
|
||||
`display_name`. Заголовок карточки и шапка страницы берут его; если пусто —
|
||||
распознанное название из плана; если и его нет — сырой источник (magnet) **одной
|
||||
строкой как обычный заголовок** (усечённо), а не в три строки.
|
||||
- **Матч с записью метабазы со ссылкой.** На странице просмотра
|
||||
(`/download/{id}`) и в блоке «Источник совпадения» ревью подтверждённый матч
|
||||
(TMDB/TVDB/IMDb) показывается как ссылка на запись (название, год, провайдер-id,
|
||||
URL), а не только `провайдер · id`.
|
||||
- **Блок «Информация о торренте» на странице загрузки.** Полный magnet/сырой
|
||||
источник и infohash выносятся в отдельный блок, чтобы заголовок оставался чистым.
|
||||
- **Полировка вёрстки:** блок «Распознано как» растянуть на всю ширину (снять
|
||||
`max-width` у списка полей); в блоке «Файлы и раскладка» приклеить стрелку к
|
||||
строке файла-источника, чтобы источник и цель шли строго друг под другом.
|
||||
|
||||
Вне объёма (отдельные задачи беклога): полный лог переходов состояний (требует
|
||||
таблицы истории), собственный идентификатор загрузки, выбор источника матча с
|
||||
предпросмотром полей, показ матча в Telegram, SSE.
|
||||
|
||||
## Capabilities
|
||||
|
||||
### New Capabilities
|
||||
|
||||
Нет.
|
||||
|
||||
### Modified Capabilities
|
||||
|
||||
- `web-ui`: список получает серверные фильтр/поиск/пагинацию (было — клиентская
|
||||
фильтрация без пагинации); заголовок загрузки берётся из сохранённого имени
|
||||
раздачи с фолбеком; страница просмотра получает блок «Информация о торренте» и
|
||||
показывает подтверждённый матч метабазы ссылкой на запись.
|
||||
- `ingest`: выведенное отображаемое имя теперь не только уходит в qBittorrent, но
|
||||
и **сохраняется** у загрузки (для показа в веб-UI); прежний инвариант «не влияет
|
||||
на пути/распознавание/раскладку» сохраняется.
|
||||
|
||||
## Impact
|
||||
|
||||
- **БД (миграция):** новые колонки `download.display_name` и
|
||||
`download.source_added_at` (время добавления в qBittorrent); обновление
|
||||
ER-схемы `docs/specs/database.md`. Новые запросы списка с
|
||||
`WHERE`/`LIKE`/`ORDER BY`/`LIMIT`/`OFFSET` и счётчиком (`store`).
|
||||
- **Код:** `internal/store` (колонки, запрос списка с фильтром/поиском/сортировкой/
|
||||
пагинацией и count), `internal/ingest` (запись `display_name` при создании
|
||||
загрузки), `internal/worker` (захват `added_on` → `source_added_at` при
|
||||
поллинге),
|
||||
`internal/httpapi` (разбор query-параметров списка, view-модели, ссылка на матч,
|
||||
блок торрента, заголовок с фолбеком), шаблоны `web/templates` (index, download,
|
||||
review, layout_widget) и `web/static/css` (полировка), возможно `web/static/js`
|
||||
(упрощение/удаление клиентской фильтрации).
|
||||
- **Обратная совместимость:** REST API (`/api/downloads`) не меняется в контракте;
|
||||
добавление query-параметров списка — аддитивно. Существующие строки БД получат
|
||||
пустой `display_name` (фолбек на распознанное/источник отработает штатно).
|
||||
@@ -0,0 +1,45 @@
|
||||
## MODIFIED Requirements
|
||||
|
||||
### Requirement: Отображаемое имя торрента из контекста
|
||||
|
||||
При добавлении загрузки в qBittorrent система SHALL выводить из контекста
|
||||
загрузки человекочитаемое отображаемое имя и передавать его в qBittorrent
|
||||
(параметр `rename` API `/torrents/add`), чтобы задача в списке qBit не
|
||||
показывалась безликим `dn` magnet-ссылки. Это же имя система SHALL **сохранять
|
||||
у загрузки** (`download.display_name`) для последующего показа заголовком в
|
||||
веб-UI.
|
||||
|
||||
Имя SHALL быть коротким читаемым ярлыком (название, опционально режиссёр и
|
||||
год; для сериала — номер сезона, если он определён), а не куском сырого
|
||||
контекста. Имя SHALL очищаться от управляющих символов и переводов строк и
|
||||
SHALL обрезаться по ограничению длины.
|
||||
|
||||
Вывод имени SHALL выполняться синхронно перед отдачей источника в
|
||||
qBittorrent (параметр `rename` действует только в момент добавления).
|
||||
|
||||
Отображаемое имя SHALL влиять только на отображение (в qBittorrent и как
|
||||
заголовок в веб-UI) и SHALL NOT влиять на пути файлов на диске, распознавание
|
||||
или раскладку — реальные пути система по-прежнему читает из qBit API.
|
||||
|
||||
#### Scenario: Имя из контекста передаётся в qBittorrent
|
||||
|
||||
- **WHEN** загрузку добавляют с непустым контекстом, из которого удалось
|
||||
получить имя
|
||||
- **THEN** система передаёт это имя в qBittorrent в параметре `rename`
|
||||
- **AND** имя — короткий читаемый ярлык вида «название (режиссёр, год)»,
|
||||
где режиссёр и год опциональны
|
||||
|
||||
#### Scenario: Имя сохраняется у загрузки
|
||||
|
||||
- **WHEN** при приёме получено непустое отображаемое имя
|
||||
- **THEN** система сохраняет его в `download.display_name` вместе с созданием
|
||||
загрузки
|
||||
- **AND** веб-UI использует его заголовком карточки и страницы загрузки
|
||||
|
||||
#### Scenario: Контекст пуст или имя не получено
|
||||
|
||||
- **WHEN** контекста нет либо ни один способ вывода не дал непустого имени
|
||||
- **THEN** система добавляет загрузку без параметра `rename`
|
||||
- **AND** qBittorrent оставляет собственное имя (из `dn`/торрента)
|
||||
- **AND** `download.display_name` остаётся пустым, а веб-UI берёт заголовок из
|
||||
фолбека (распознанное название или усечённый источник)
|
||||
@@ -0,0 +1,125 @@
|
||||
## MODIFIED Requirements
|
||||
|
||||
### Requirement: Страницы веб-UI
|
||||
|
||||
Веб-UI SHALL предоставлять страницы: список загрузок с единым окном
|
||||
добавления, **серверными фильтром по группе состояний, поиском и постраничной
|
||||
выдачей (пагинацией)** (`/`), экран ревью одной загрузки (`/review/{id}`)
|
||||
и страницу просмотра одной загрузки (`/download/{id}`) с распознаванием,
|
||||
файлами→раскладкой, историей, блоком информации о торренте и — для сидирующих
|
||||
задач — секцией живой статистики раздачи. Карточки активных (downloading)
|
||||
загрузок в списке SHALL содержать индикатор прогресса. Фильтр, поиск и номер
|
||||
страницы SHALL передаваться GET-параметрами запроса (например `f`, `q`, `page`)
|
||||
и SHALL работать без клиентского JavaScript. Состояние `deleted` SHALL быть
|
||||
скрыто в списке по умолчанию (с переключателем «показать всё»). Механика живого
|
||||
обновления прогресса и наполнение секции раздачи определяются capability
|
||||
`live-status`.
|
||||
|
||||
#### Scenario: Просмотр одной загрузки
|
||||
|
||||
- **WHEN** клиент открывает `GET /download/{id}` существующей загрузки
|
||||
- **THEN** отрисовывается страница с её распознаванием, файлами, раскладкой и
|
||||
историей
|
||||
|
||||
#### Scenario: Прогресс активной загрузки в списке
|
||||
|
||||
- **WHEN** в списке есть загрузка в состоянии `downloading`
|
||||
- **THEN** её карточка содержит индикатор прогресса (прогресс-бар со скоростью
|
||||
и ETA)
|
||||
|
||||
#### Scenario: Удалённые скрыты по умолчанию
|
||||
|
||||
- **WHEN** в списке есть загрузки в состоянии `deleted` и фильтр «показать
|
||||
всё» не включён
|
||||
- **THEN** они не отображаются, но доступны при включённом переключателе
|
||||
|
||||
#### Scenario: Пагинация списка
|
||||
|
||||
- **WHEN** загрузок под текущим фильтром больше, чем помещается на одну
|
||||
страницу, и клиент запрашивает `GET /?page=N`
|
||||
- **THEN** возвращается N-я страница результатов и элементы навигации по
|
||||
страницам, сохраняющие текущие фильтр и поисковый запрос
|
||||
|
||||
#### Scenario: Серверный фильтр и поиск
|
||||
|
||||
- **WHEN** клиент запрашивает список с параметрами фильтра по состоянию и/или
|
||||
строкой поиска (`GET /?f=review&q=дюна`)
|
||||
- **THEN** сервер возвращает только подходящие загрузки (по группе состояний и
|
||||
совпадению строки в названии/infohash/контексте), отфильтрованные на стороне
|
||||
БД, а не на клиенте
|
||||
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Порядок списка загрузок
|
||||
|
||||
Список загрузок SHALL сортироваться по времени добавления торрента в источник
|
||||
(qBittorrent `added_on`), от новых к старым. Если время добавления в источник
|
||||
неизвестно, для сортировки SHALL использоваться время создания загрузки в
|
||||
jellybit (`created_at`). Порядок MUST быть согласован между страницами
|
||||
пагинации.
|
||||
|
||||
#### Scenario: Сортировка по времени добавления в источник
|
||||
|
||||
- **WHEN** отрисовывается список загрузок, у которых известно время добавления
|
||||
в qBittorrent
|
||||
- **THEN** загрузки идут от недавно добавленных к более старым по этому времени
|
||||
|
||||
#### Scenario: Фолбек порядка без времени источника
|
||||
|
||||
- **WHEN** у загрузки нет сохранённого времени добавления в источник
|
||||
- **THEN** для её позиции в списке используется время создания загрузки в
|
||||
jellybit
|
||||
|
||||
### Requirement: Заголовок загрузки из имени раздачи
|
||||
|
||||
Веб-UI SHALL показывать заголовком загрузки (в карточке списка и в шапке
|
||||
страницы `/download/{id}`) сохранённое отображаемое имя раздачи (`display_name`,
|
||||
переданное в qBittorrent при приёме). Если имя пусто, заголовок SHALL брать
|
||||
распознанное название из плана; если и его нет — сырой источник (`source_ref`),
|
||||
усечённый до одной строки как обычный заголовок. Заголовок MUST NOT занимать
|
||||
несколько строк сырым magnet.
|
||||
|
||||
#### Scenario: Заголовок из имени раздачи
|
||||
|
||||
- **WHEN** у загрузки сохранено отображаемое имя раздачи
|
||||
- **THEN** карточка и страница показывают это имя заголовком
|
||||
|
||||
#### Scenario: Фолбек до распознавания и без имени
|
||||
|
||||
- **WHEN** отображаемого имени нет, но есть распознанное название
|
||||
- **THEN** заголовком служит распознанное название
|
||||
- **AND** если нет ни того, ни другого — заголовком служит усечённый до одной
|
||||
строки сырой источник, а не многострочный magnet
|
||||
|
||||
### Requirement: Матч с записью метабазы ссылкой
|
||||
|
||||
Веб-UI SHALL показывать подтверждённый матч с записью метабазы (TMDB/TVDB/IMDb)
|
||||
как ссылку на эту запись — на странице просмотра `/download/{id}` (блок
|
||||
распознавания) и в блоке «Источник совпадения» экрана ревью. Ссылка SHALL
|
||||
открываться в новой вкладке с `rel="noopener"`. Рядом со ссылкой SHALL быть
|
||||
видны провайдер, идентификатор записи и (при наличии) год.
|
||||
|
||||
#### Scenario: Матч виден ссылкой на странице просмотра
|
||||
|
||||
- **WHEN** у загрузки подтверждён матч с записью метабазы и известен URL записи
|
||||
- **THEN** в блоке распознавания на `/download/{id}` матч показан ссылкой на
|
||||
запись с провайдером и id
|
||||
|
||||
#### Scenario: URL записи неизвестен
|
||||
|
||||
- **WHEN** матч подтверждён (например, id задан вручную), но канонический URL
|
||||
записи построить нельзя
|
||||
- **THEN** матч показывается текстом (провайдер и id) без ссылки, страница не
|
||||
ломается
|
||||
|
||||
### Requirement: Блок информации о торренте
|
||||
|
||||
Страница `/download/{id}` SHALL содержать блок «Информация о торренте» с полным
|
||||
исходным источником (magnet/URL/`.torrent`-ссылка) и infohash с кнопкой
|
||||
копирования. Сырой источник MUST выноситься в этот блок, а не в заголовок.
|
||||
|
||||
#### Scenario: Полный источник в отдельном блоке
|
||||
|
||||
- **WHEN** клиент открывает `GET /download/{id}`
|
||||
- **THEN** полный источник (magnet) и infohash показаны в блоке «Информация о
|
||||
торренте», а заголовок страницы остаётся человекочитаемым именем
|
||||
@@ -0,0 +1,48 @@
|
||||
## 1. Персистентность имени раздачи и времени добавления (БД + ingest + worker)
|
||||
|
||||
- [x] 1.1 Миграция `0005` (goose): `download.display_name TEXT NOT NULL DEFAULT ''` и `download.source_added_at` (nullable timestamp), обратная миграция (down)
|
||||
- [x] 1.2 Добавить поля `DisplayName` и `SourceAddedAt` в `store.Download`; писать `display_name` в `CreateDownload`; метод однократной записи `source_added_at` с SQL-гардом `WHERE source_added_at IS NULL`; экспортировать хелпер форматирования времени в формате `sqliteTimeLayout` (UTC)
|
||||
- [x] 1.3 В `internal/ingest` заполнять `DisplayName` выведенным именем `rename` при создании загрузки; в `discover.adopt()` — `display_name = t.Name` для усыновлённых торрентов
|
||||
- [x] 1.4 В `internal/worker` персистить `source_added_at` из `qbt.Torrent.AddedOn` в цикле `Poll` сразу после `byHash`-сопоставления (до `reconcile`, для любого класса состояния) и в `adopt()`; формат — `time.Unix(...).UTC().Format(sqliteTimeLayout)`; однократно (гард по `d.SourceAddedAt` + SQL IS NULL); под `w.mu`
|
||||
- [x] 1.5 Обновить ER-схему `docs/specs/database.md` (колонки `display_name`, `source_added_at`)
|
||||
- [x] 1.6 Тесты: ingest сохраняет `display_name`; adopt пишет `display_name` из имени торрента; worker пишет `source_added_at` однократно и в формате `created_at`; пустой контекст → пустое имя
|
||||
|
||||
## 2. Серверные фильтр, поиск, сортировка и пагинация списка (store)
|
||||
|
||||
- [x] 2.1 Вынести маппинг состояние↔группа фильтра в единый источник истины (используется и в UI, и в запросе); учесть `all` с исключением `deleted` и осознанно — группу `other` (cancelled/reverted/deleted) без своего чипа
|
||||
- [x] 2.2 Метод `store.ListDownloadsPage(ctx, ListFilter)` → страница + общий count: `WHERE` по группе состояний, `deleted` только при «показать всё», поиск `LIKE`/`COLLATE NOCASE` по `source_ref`/`display_name`/`context`/`infohash`, сортировка `ORDER BY COALESCE(source_added_at, created_at) DESC, id DESC`, `LIMIT`/`OFFSET`
|
||||
- [x] 2.3 Тесты `store`: фильтр по группе, поиск, скрытие `deleted` по умолчанию, порядок по времени добавления с фолбеком + тай-брейк по id при равных метках, границы пагинации и count
|
||||
|
||||
## 3. Список на главной (httpapi + шаблоны)
|
||||
|
||||
- [x] 3.1 Разбор GET-параметров `f`/`q`/`page`/`all` в `handleIndex` (клэмп `page`: `<1`/нечисловой → 1, за пределом → пустая страница), константа размера страницы (25), вызов `ListDownloadsPage`; расширить интерфейс `Reader` методом `ListDownloadsPage`
|
||||
- [x] 3.2 View-модель списка: элементы страницы + данные пагинации + текущие фильтр/поиск; удалить ставшие мёртвыми `SearchText` и (если больше не нужен) `Group` из `downloadView`
|
||||
- [x] 3.3 `index.html`: чипы-фильтр и «показать всё» как ссылки (сохраняют `q`), форма поиска (GET), навигация по страницам (сохраняет `f`/`q`); серверный рендер пустого состояния («ничего не найдено» при активных фильтре/поиске, иначе «пока пусто»)
|
||||
- [x] 3.4 Убрать клиентскую JS-фильтрацию из `app.js` (`applyFilters`/`setFilter` + `DOMContentLoaded`-хук), оставить `copyHash` и `<details>`; сохранить живой прогресс активных карточек
|
||||
- [x] 3.5 Заголовок карточки — фолбек-цепочка `display_name` → распознанное → усечённый источник (одна строка)
|
||||
- [x] 3.6 Тесты `httpapi`: параметры списка → корректная выборка/пагинация; заголовок из `display_name` и фолбеки
|
||||
|
||||
## 4. Страница загрузки: имя, матч-ссылка, блок торрента (httpapi + шаблоны)
|
||||
|
||||
- [x] 4.1 Заголовок/шапка `/download/{id}` — та же фолбек-цепочка заголовка
|
||||
- [x] 4.2 Хелпер `providerURL(provider, id, mediaType)` (tmdb: movie/tv; tvdb: медиазависимый путь; imdb: /title) + выбор URL матча: URL выбранного кандидата только если его provider+id == эффективным `rd.Provider`/`rd.ProviderID`, иначе построенный; нет надёжного URL → без ссылки
|
||||
- [x] 4.3 «Распознано как»: показать матч ссылкой на запись (провайдер, id, год); при отсутствии URL — текст без ссылки
|
||||
- [x] 4.4 Блок «Информация о торренте»: полный источник (magnet) + infohash с копированием
|
||||
- [x] 4.5 Тесты `httpapi`: матч-ссылка при наличии/отсутствии URL; расхождение выбранного кандидата и эффективного provider_id (ссылка ведёт на эффективную запись); блок торрента
|
||||
|
||||
## 5. Ревью: матч-ссылка (httpapi + шаблон)
|
||||
|
||||
- [x] 5.1 В блоке «Источник совпадения» строку «Выбрано» показывать ссылкой на запись (та же логика выбора URL, что в 4.2)
|
||||
|
||||
## 6. Полировка вёрстки (CSS/шаблоны)
|
||||
|
||||
- [x] 6.1 «Распознано как»: снять `max-width` у списка полей — блок на всю ширину
|
||||
- [x] 6.2 Layout-виджет: приклеить стрелку к строке файла-источника (источник и цель строго друг под другом)
|
||||
- [x] 6.3 Проверить отсутствие инлайн-`<style>` с хардкодом цветов в изменённых шаблонах (правки — в `jellybit.css`)
|
||||
|
||||
## 7. Проверка и ревью
|
||||
|
||||
- [x] 7.1 `task test` и `task lint` зелёные
|
||||
- [x] 7.2 `openspec validate web-ui-list-detail --strict`
|
||||
- [ ] 7.3 Ручная проверка: список с фильтром/поиском/пагинацией, живой прогресс не сбрасывается; заголовки; матч-ссылки; блок торрента; полировка
|
||||
- [x] 7.4 Ревью кода перед archive
|
||||
@@ -13,7 +13,9 @@ qBittorrent. Capability описывает вывод человекочитае
|
||||
При добавлении загрузки в qBittorrent система SHALL выводить из контекста
|
||||
загрузки человекочитаемое отображаемое имя и передавать его в qBittorrent
|
||||
(параметр `rename` API `/torrents/add`), чтобы задача в списке qBit не
|
||||
показывалась безликим `dn` magnet-ссылки.
|
||||
показывалась безликим `dn` magnet-ссылки. Это же имя система SHALL **сохранять
|
||||
у загрузки** (`download.display_name`) для последующего показа заголовком в
|
||||
веб-UI.
|
||||
|
||||
Имя SHALL быть коротким читаемым ярлыком (название, опционально режиссёр и
|
||||
год; для сериала — номер сезона, если он определён), а не куском сырого
|
||||
@@ -23,9 +25,9 @@ SHALL обрезаться по ограничению длины.
|
||||
Вывод имени SHALL выполняться синхронно перед отдачей источника в
|
||||
qBittorrent (параметр `rename` действует только в момент добавления).
|
||||
|
||||
Отображаемое имя SHALL влиять только на отображение в qBittorrent и SHALL
|
||||
NOT влиять на пути файлов на диске, распознавание или раскладку — реальные
|
||||
пути система по-прежнему читает из qBit API.
|
||||
Отображаемое имя SHALL влиять только на отображение (в qBittorrent и как
|
||||
заголовок в веб-UI) и SHALL NOT влиять на пути файлов на диске, распознавание
|
||||
или раскладку — реальные пути система по-прежнему читает из qBit API.
|
||||
|
||||
#### Scenario: Имя из контекста передаётся в qBittorrent
|
||||
|
||||
@@ -35,11 +37,20 @@ NOT влиять на пути файлов на диске, распознав
|
||||
- **AND** имя — короткий читаемый ярлык вида «название (режиссёр, год)»,
|
||||
где режиссёр и год опциональны
|
||||
|
||||
#### Scenario: Имя сохраняется у загрузки
|
||||
|
||||
- **WHEN** при приёме получено непустое отображаемое имя
|
||||
- **THEN** система сохраняет его в `download.display_name` вместе с созданием
|
||||
загрузки
|
||||
- **AND** веб-UI использует его заголовком карточки и страницы загрузки
|
||||
|
||||
#### Scenario: Контекст пуст или имя не получено
|
||||
|
||||
- **WHEN** контекста нет либо ни один способ вывода не дал непустого имени
|
||||
- **THEN** система добавляет загрузку без параметра `rename`
|
||||
- **AND** qBittorrent оставляет собственное имя (из `dn`/торрента)
|
||||
- **AND** `download.display_name` остаётся пустым, а веб-UI берёт заголовок из
|
||||
фолбека (распознанное название или усечённый источник)
|
||||
|
||||
### Requirement: Вывод имени через LLM со структурированным выводом
|
||||
|
||||
|
||||
@@ -88,13 +88,17 @@ self-hosted шрифтов, единая дизайн-система (`jellybit.
|
||||
### Requirement: Страницы веб-UI
|
||||
|
||||
Веб-UI SHALL предоставлять страницы: список загрузок с единым окном
|
||||
добавления и фильтром/поиском (`/`), экран ревью одной загрузки (`/review/{id}`)
|
||||
добавления, **серверными фильтром по группе состояний, поиском и постраничной
|
||||
выдачей (пагинацией)** (`/`), экран ревью одной загрузки (`/review/{id}`)
|
||||
и страницу просмотра одной загрузки (`/download/{id}`) с распознаванием,
|
||||
файлами→раскладкой, историей и — для сидирующих задач — секцией живой
|
||||
статистики раздачи. Карточки активных (downloading) загрузок в списке SHALL
|
||||
содержать индикатор прогресса. Состояние `deleted` SHALL быть скрыто в списке
|
||||
по умолчанию (с переключателем «показать всё»). Механика живого обновления
|
||||
прогресса и наполнение секции раздачи определяются capability `live-status`.
|
||||
файлами→раскладкой, историей, блоком информации о торренте и — для сидирующих
|
||||
задач — секцией живой статистики раздачи. Карточки активных (downloading)
|
||||
загрузок в списке SHALL содержать индикатор прогресса. Фильтр, поиск и номер
|
||||
страницы SHALL передаваться GET-параметрами запроса (например `f`, `q`, `page`)
|
||||
и SHALL работать без клиентского JavaScript. Состояние `deleted` SHALL быть
|
||||
скрыто в списке по умолчанию (с переключателем «показать всё»). Механика живого
|
||||
обновления прогресса и наполнение секции раздачи определяются capability
|
||||
`live-status`.
|
||||
|
||||
#### Scenario: Просмотр одной загрузки
|
||||
|
||||
@@ -114,6 +118,95 @@ self-hosted шрифтов, единая дизайн-система (`jellybit.
|
||||
всё» не включён
|
||||
- **THEN** они не отображаются, но доступны при включённом переключателе
|
||||
|
||||
#### Scenario: Пагинация списка
|
||||
|
||||
- **WHEN** загрузок под текущим фильтром больше, чем помещается на одну
|
||||
страницу, и клиент запрашивает `GET /?page=N`
|
||||
- **THEN** возвращается N-я страница результатов и элементы навигации по
|
||||
страницам, сохраняющие текущие фильтр и поисковый запрос
|
||||
|
||||
#### Scenario: Серверный фильтр и поиск
|
||||
|
||||
- **WHEN** клиент запрашивает список с параметрами фильтра по состоянию и/или
|
||||
строкой поиска (`GET /?f=review&q=дюна`)
|
||||
- **THEN** сервер возвращает только подходящие загрузки (по группе состояний и
|
||||
совпадению строки в названии/infohash/контексте), отфильтрованные на стороне
|
||||
БД, а не на клиенте
|
||||
|
||||
### Requirement: Порядок списка загрузок
|
||||
|
||||
Список загрузок SHALL сортироваться по времени добавления торрента в источник
|
||||
(qBittorrent `added_on`), от новых к старым. Если время добавления в источник
|
||||
неизвестно, для сортировки SHALL использоваться время создания загрузки в
|
||||
jellybit (`created_at`). Порядок MUST быть согласован между страницами
|
||||
пагинации.
|
||||
|
||||
#### Scenario: Сортировка по времени добавления в источник
|
||||
|
||||
- **WHEN** отрисовывается список загрузок, у которых известно время добавления
|
||||
в qBittorrent
|
||||
- **THEN** загрузки идут от недавно добавленных к более старым по этому времени
|
||||
|
||||
#### Scenario: Фолбек порядка без времени источника
|
||||
|
||||
- **WHEN** у загрузки нет сохранённого времени добавления в источник
|
||||
- **THEN** для её позиции в списке используется время создания загрузки в
|
||||
jellybit
|
||||
|
||||
### Requirement: Заголовок загрузки из имени раздачи
|
||||
|
||||
Веб-UI SHALL показывать заголовком загрузки (в карточке списка и в шапке
|
||||
страницы `/download/{id}`) сохранённое отображаемое имя раздачи (`display_name`,
|
||||
переданное в qBittorrent при приёме). Если имя пусто, заголовок SHALL брать
|
||||
распознанное название из плана; если и его нет — сырой источник (`source_ref`),
|
||||
усечённый до одной строки как обычный заголовок. Заголовок MUST NOT занимать
|
||||
несколько строк сырым magnet.
|
||||
|
||||
#### Scenario: Заголовок из имени раздачи
|
||||
|
||||
- **WHEN** у загрузки сохранено отображаемое имя раздачи
|
||||
- **THEN** карточка и страница показывают это имя заголовком
|
||||
|
||||
#### Scenario: Фолбек до распознавания и без имени
|
||||
|
||||
- **WHEN** отображаемого имени нет, но есть распознанное название
|
||||
- **THEN** заголовком служит распознанное название
|
||||
- **AND** если нет ни того, ни другого — заголовком служит усечённый до одной
|
||||
строки сырой источник, а не многострочный magnet
|
||||
|
||||
### Requirement: Матч с записью метабазы ссылкой
|
||||
|
||||
Веб-UI SHALL показывать подтверждённый матч с записью метабазы (TMDB/TVDB/IMDb)
|
||||
как ссылку на эту запись — на странице просмотра `/download/{id}` (блок
|
||||
распознавания) и в блоке «Источник совпадения» экрана ревью. Ссылка SHALL
|
||||
открываться в новой вкладке с `rel="noopener"`. Рядом со ссылкой SHALL быть
|
||||
видны провайдер, идентификатор записи и (при наличии) год.
|
||||
|
||||
#### Scenario: Матч виден ссылкой на странице просмотра
|
||||
|
||||
- **WHEN** у загрузки подтверждён матч с записью метабазы и известен URL записи
|
||||
- **THEN** в блоке распознавания на `/download/{id}` матч показан ссылкой на
|
||||
запись с провайдером и id
|
||||
|
||||
#### Scenario: URL записи неизвестен
|
||||
|
||||
- **WHEN** матч подтверждён (например, id задан вручную), но канонический URL
|
||||
записи построить нельзя
|
||||
- **THEN** матч показывается текстом (провайдер и id) без ссылки, страница не
|
||||
ломается
|
||||
|
||||
### Requirement: Блок информации о торренте
|
||||
|
||||
Страница `/download/{id}` SHALL содержать блок «Информация о торренте» с полным
|
||||
исходным источником (magnet/URL/`.torrent`-ссылка) и infohash с кнопкой
|
||||
копирования. Сырой источник MUST выноситься в этот блок, а не в заголовок.
|
||||
|
||||
#### Scenario: Полный источник в отдельном блоке
|
||||
|
||||
- **WHEN** клиент открывает `GET /download/{id}`
|
||||
- **THEN** полный источник (magnet) и infohash показаны в блоке «Информация о
|
||||
торренте», а заголовок страницы остаётся человекочитаемым именем
|
||||
|
||||
### Requirement: Действия соответствуют состоянию
|
||||
|
||||
Каждая страница SHALL показывать только действия, допустимые в текущем
|
||||
|
||||
Reference in New Issue
Block a user