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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user