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:
av
2026-07-01 09:50:24 +03:00
co-authored by Claude Opus 4.8
parent 2a9bf7efb0
commit c0b5ab7295
33 changed files with 1679 additions and 110 deletions
+35 -6
View File
@@ -74,6 +74,7 @@ type Download struct {
ID int64 `db:"id"`
SourceType SourceType `db:"source_type"`
SourceRef string `db:"source_ref"`
DisplayName string `db:"display_name"` // имя раздачи (rename в qBittorrent), заголовок в веб-UI
Context string `db:"context"`
Infohash sql.NullString `db:"infohash"`
IdempotencyKey sql.NullString `db:"idempotency_key"`
@@ -82,9 +83,18 @@ type Download struct {
ErrorMsg sql.NullString `db:"error_msg"`
// SourceMissCount — счётчик подряд идущих тиков сверки без раздачи в
// qBittorrent (дебаунс пропажи источника, см. state-reconciliation).
SourceMissCount int `db:"source_miss_count"`
CreatedAt string `db:"created_at"`
UpdatedAt string `db:"updated_at"`
SourceMissCount int `db:"source_miss_count"`
// SourceAddedAt — время добавления торрента в qBittorrent (added_on), базис
// сортировки списка. NULL, пока воркер не наблюдал раздачу. Хранится в
// формате sqliteTimeLayout (UTC), как created_at.
SourceAddedAt sql.NullString `db:"source_added_at"`
CreatedAt string `db:"created_at"`
UpdatedAt string `db:"updated_at"`
// RecTitle — распознанное название текущей попытки (LEFT JOIN recognition).
// Заполняется только листингом ListDownloadsPage для фолбека заголовка; в
// прочих выборках остаётся пустым.
RecTitle sql.NullString `db:"rec_title"`
}
// sqliteTimeLayout — формат меток datetime('now') в SQLite (UTC).
@@ -95,6 +105,14 @@ func ParseTime(s string) (time.Time, error) {
return time.ParseInLocation(sqliteTimeLayout, s, time.UTC)
}
// FormatTime форматирует время в формат меток SQLite (datetime('now'), UTC) —
// байт-в-байт тот же, что у created_at/updated_at. Нужен, чтобы лексикографи-
// ческое сравнение строк времени (COALESCE(source_added_at, created_at) в
// сортировке списка) давало верный хронологический порядок.
func FormatTime(t time.Time) string {
return t.UTC().Format(sqliteTimeLayout)
}
// CreatedTime возвращает время создания загрузки как time.Time (UTC).
func (d Download) CreatedTime() (time.Time, error) { return ParseTime(d.CreatedAt) }
@@ -106,10 +124,10 @@ func NullString(s string) sql.NullString {
// CreateDownload вставляет загрузку и возвращает её id.
func (s *Store) CreateDownload(ctx context.Context, d *Download) (int64, error) {
const q = `
INSERT INTO download (source_type, source_ref, context, infohash, idempotency_key, state)
VALUES (?, ?, ?, ?, ?, ?)`
INSERT INTO download (source_type, source_ref, display_name, context, infohash, idempotency_key, state)
VALUES (?, ?, ?, ?, ?, ?, ?)`
res, err := s.DB.ExecContext(ctx, q,
d.SourceType, d.SourceRef, d.Context, d.Infohash, d.IdempotencyKey, d.State)
d.SourceType, d.SourceRef, d.DisplayName, d.Context, d.Infohash, d.IdempotencyKey, d.State)
if err != nil {
return 0, fmt.Errorf("insert download: %w", err)
}
@@ -132,6 +150,17 @@ func (s *Store) GetDownload(ctx context.Context, id int64) (*Download, error) {
return &d, nil
}
// SetSourceAddedAt однократно фиксирует время добавления торрента в источник
// (qBittorrent added_on). SQL-гард `source_added_at IS NULL` не даёт перезапи-
// сать значение на повторных наблюдениях: время добавления неизменно.
func (s *Store) SetSourceAddedAt(ctx context.Context, id int64, t time.Time) error {
const q = `UPDATE download SET source_added_at = ? WHERE id = ? AND source_added_at IS NULL`
if _, err := s.DB.ExecContext(ctx, q, FormatTime(t), id); err != nil {
return fmt.Errorf("set source added at %d: %w", id, err)
}
return nil
}
// ListDownloads возвращает все загрузки, новые сверху.
func (s *Store) ListDownloads(ctx context.Context) ([]Download, error) {
var out []Download