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
+10
View File
@@ -3,6 +3,7 @@ package worker
import (
"context"
"strings"
"time"
"git.vakhrushev.me/av/jellybit/internal/qbt"
"git.vakhrushev.me/av/jellybit/internal/store"
@@ -50,6 +51,7 @@ func (w *Worker) adopt(ctx context.Context, t qbt.Torrent) {
d := &store.Download{
SourceType: store.SourceMagnet,
SourceRef: "magnet:?xt=urn:btih:" + infohash,
DisplayName: t.Name, // усыновление: приёма/rename нет, берём имя торрента из qBittorrent
Infohash: store.NullString(infohash),
IdempotencyKey: store.NullString(infohash),
State: store.StateDownloading,
@@ -64,6 +66,14 @@ func (w *Worker) adopt(ctx context.Context, t qbt.Torrent) {
w.log.Error("discover adopt failed", "capability", capIngest, "infohash", infohash, "error", err)
return
}
// Базис сортировки — время добавления в источник; у усыновлённого оно уже
// известно (created_at задачи было бы моментом усыновления, не добавления).
if t.AddedOn > 0 {
if err := w.store.SetSourceAddedAt(ctx, id, time.Unix(t.AddedOn, 0)); err != nil {
w.log.Warn("adopt set source_added_at failed",
"capability", capIngest, "download_id", id, "error", err)
}
}
w.log.Info("discover adopted torrent",
"capability", capIngest, "download_id", id, "infohash", infohash, "name", t.Name,
"category", t.Category, "tags", t.Tags)
+28 -1
View File
@@ -28,7 +28,7 @@ func TestDiscover_AdoptsByCategory(t *testing.T) {
st := emptyStore()
w := newTestWorker(st, &fakeQbt{})
w.discover(context.Background(), []qbt.Torrent{
{Hash: ihDisc, Name: "Avatar", Category: "jellybit", State: "stalledUP"},
{Hash: ihDisc, Name: "Avatar", Category: "jellybit", State: "stalledUP", AddedOn: 1_700_000_000},
})
d := findByInfohash(st, ihDisc)
@@ -41,6 +41,14 @@ func TestDiscover_AdoptsByCategory(t *testing.T) {
if d.IdempotencyKey.String != ihDisc {
t.Errorf("idempotency_key = %q", d.IdempotencyKey.String)
}
// Усыновление берёт заголовок из имени торрента qBittorrent и фиксирует
// время добавления (added_on) как базис сортировки.
if d.DisplayName != "Avatar" {
t.Errorf("display_name = %q, want Avatar", d.DisplayName)
}
if !d.SourceAddedAt.Valid {
t.Errorf("source_added_at не зафиксирован при усыновлении")
}
}
func TestDiscover_AdoptsByTag(t *testing.T) {
@@ -94,6 +102,25 @@ func TestDiscover_SkipsNoInfohash(t *testing.T) {
// TestPoll_AdoptsAndCompletes — сценарий пользователя целиком: помеченная и
// уже скачанная раздача за один тик усыновляется и доходит до completed.
func TestPoll_CapturesSourceAddedAt(t *testing.T) {
ih := "a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0"
st := &fakeStore{downloads: map[int64]*store.Download{
1: {ID: 1, State: store.StateDownloading, SourceType: store.SourceMagnet,
Infohash: store.NullString(ih), IdempotencyKey: store.NullString(ih)},
}}
qb := &fakeQbt{torrents: []qbt.Torrent{
{Hash: ih, Name: "X", Category: "jellybit", State: "downloading", AddedOn: 1_700_000_000},
}}
w := newTestWorker(st, qb)
if err := w.Poll(context.Background()); err != nil {
t.Fatalf("Poll: %v", err)
}
if d := st.downloads[1]; !d.SourceAddedAt.Valid {
t.Fatalf("source_added_at не захвачен при поллинге активной задачи")
}
}
func TestPoll_AdoptsAndCompletes(t *testing.T) {
st := emptyStore()
qb := &fakeQbt{torrents: []qbt.Torrent{
+7
View File
@@ -317,6 +317,13 @@ func (m *memStore) SetSourceMissCount(_ context.Context, id int64, n int) error
return nil
}
func (m *memStore) SetSourceAddedAt(_ context.Context, id int64, t time.Time) error {
if d, ok := m.downloads[id]; ok && !d.SourceAddedAt.Valid {
d.SourceAddedAt = store.NullString(store.FormatTime(t))
}
return nil
}
func (m *memStore) CreateRecognition(_ context.Context, r *store.Recognition, reasons []string) (int64, error) {
for _, e := range m.recs {
if e.DownloadID == r.DownloadID {
+17
View File
@@ -42,6 +42,7 @@ type Store interface {
GetDownload(ctx context.Context, id int64) (*store.Download, error)
SetDownloadState(ctx context.Context, id int64, state store.State, errCode, errMsg string) error
SetSourceMissCount(ctx context.Context, id int64, n int) error
SetSourceAddedAt(ctx context.Context, id int64, t time.Time) error
// Discovery (усыновление раздач по категории/тегу).
ExistsByInfohash(ctx context.Context, infohash string) (bool, error)
@@ -333,6 +334,7 @@ func (w *Worker) Poll(ctx context.Context) error {
"capability", capIngest, "download_id", d.ID, "infohash", d.Infohash.String)
continue
}
w.captureSourceAddedAt(ctx, d, t)
w.reconcile(ctx, d, t)
}
@@ -380,6 +382,21 @@ func (w *Worker) checkTimeouts(ctx context.Context, d store.Download, t qbt.Torr
}
}
// captureSourceAddedAt однократно сохраняет время добавления торрента в
// qBittorrent (added_on) у задачи — базис сортировки списка. Пишем только при
// первом наблюдении (в БД source_added_at ещё пуст, SQL-гард в store); значение
// неизменно, поэтому повторные тики его не трогают. Учётная операция: её сбой не
// двигает задачу, лишь логируем WARN. Вызывается под w.mu.
func (w *Worker) captureSourceAddedAt(ctx context.Context, d store.Download, t qbt.Torrent) {
if d.SourceAddedAt.Valid || t.AddedOn <= 0 {
return
}
if err := w.store.SetSourceAddedAt(ctx, d.ID, time.Unix(t.AddedOn, 0)); err != nil {
w.log.Warn("capture source_added_at failed",
"capability", capIngest, "download_id", d.ID, "error", err)
}
}
// torrentAge — возраст торрента: от added_on в qBittorrent (надёжный базис,
// переживает retry/усыновление), с фолбэком на created_at задачи, если qBit не
// отдал added_on.
+11
View File
@@ -115,6 +115,17 @@ func (f *fakeStore) SetSourceMissCount(_ context.Context, id int64, n int) error
return nil
}
func (f *fakeStore) SetSourceAddedAt(_ context.Context, id int64, t time.Time) error {
d, ok := f.downloads[id]
if !ok {
return fmt.Errorf("download %d not found", id)
}
if !d.SourceAddedAt.Valid { // гард как в store: пишем однократно
d.SourceAddedAt = store.NullString(store.FormatTime(t))
}
return nil
}
// --- Ф3-методы Store (заглушки; переопределяются в review_test.go) ---
func (f *fakeStore) CreateRecognition(_ context.Context, _ *store.Recognition, _ []string) (int64, error) {