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