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:
@@ -55,12 +55,27 @@ func (f *fakeCommander) Retry(_ context.Context, id int64) error {
|
||||
}
|
||||
|
||||
type fakeReader struct {
|
||||
list []store.Download
|
||||
get *store.Download
|
||||
list []store.Download
|
||||
get *store.Download
|
||||
pageErr error
|
||||
total int // общее число под фильтром (для пагинации); 0 → len(list)
|
||||
lastFilter store.ListFilter // последний переданный фильтр
|
||||
}
|
||||
|
||||
func (f *fakeReader) ListDownloads(_ context.Context) ([]store.Download, error) { return f.list, nil }
|
||||
|
||||
func (f *fakeReader) ListDownloadsPage(_ context.Context, flt store.ListFilter) ([]store.Download, int, error) {
|
||||
f.lastFilter = flt
|
||||
if f.pageErr != nil {
|
||||
return nil, 0, f.pageErr
|
||||
}
|
||||
total := f.total
|
||||
if total == 0 {
|
||||
total = len(f.list)
|
||||
}
|
||||
return f.list, total, nil
|
||||
}
|
||||
|
||||
func (f *fakeReader) GetDownload(_ context.Context, id int64) (*store.Download, error) {
|
||||
if f.get != nil {
|
||||
return f.get, nil
|
||||
@@ -221,6 +236,149 @@ type ingestErr string
|
||||
|
||||
func (e ingestErr) Error() string { return string(e) }
|
||||
|
||||
func TestIndexTitleFromDisplayName(t *testing.T) {
|
||||
// Заголовок карточки — имя раздачи (display_name), а не сырой magnet.
|
||||
reader := &fakeReader{list: []store.Download{{
|
||||
ID: 1, SourceType: store.SourceMagnet, SourceRef: "magnet:?xt=urn:btih:abc",
|
||||
DisplayName: "Дюна: Часть вторая (2024)", State: store.StateDownloading,
|
||||
}}}
|
||||
srv := newServer(t, httpapi.Deps{Ingestor: &fakeIngestor{}, Commander: &fakeCommander{}, Reader: reader})
|
||||
|
||||
resp, err := http.Get(srv.URL + "/")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
if !strings.Contains(string(body), "Дюна: Часть вторая (2024)") {
|
||||
t.Error("заголовок карточки не из display_name")
|
||||
}
|
||||
if strings.Contains(string(body), "magnet:?xt=urn:btih:abc") {
|
||||
t.Error("сырой magnet просочился в список вместо имени раздачи")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIndexListFilterParams(t *testing.T) {
|
||||
// GET-параметры списка транслируются в store.ListFilter (серверная выборка).
|
||||
reader := &fakeReader{total: 100}
|
||||
srv := newServer(t, httpapi.Deps{Ingestor: &fakeIngestor{}, Commander: &fakeCommander{}, Reader: reader})
|
||||
|
||||
resp, err := http.Get(srv.URL + "/?f=review&q=дюна&page=2&all=1")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
f := reader.lastFilter
|
||||
if f.Group != store.GroupReview {
|
||||
t.Errorf("Group = %q, want review", f.Group)
|
||||
}
|
||||
if f.Query != "дюна" {
|
||||
t.Errorf("Query = %q", f.Query)
|
||||
}
|
||||
if !f.IncludeDeleted {
|
||||
t.Errorf("IncludeDeleted = false, want true (all=1)")
|
||||
}
|
||||
if f.Limit != 25 || f.Offset != 25 { // page 2, размер 25
|
||||
t.Errorf("Limit/Offset = %d/%d, want 25/25", f.Limit, f.Offset)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIndexPageClamp(t *testing.T) {
|
||||
reader := &fakeReader{}
|
||||
srv := newServer(t, httpapi.Deps{Ingestor: &fakeIngestor{}, Commander: &fakeCommander{}, Reader: reader})
|
||||
resp, err := http.Get(srv.URL + "/?page=0")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if reader.lastFilter.Offset != 0 {
|
||||
t.Errorf("page=0 → Offset %d, want 0 (клэмп на 1)", reader.lastFilter.Offset)
|
||||
}
|
||||
}
|
||||
|
||||
// detailReviewData — ReviewData для страницы /download/{id} с матчем метабазы.
|
||||
func detailReviewData(provider, providerID, chosenID, chosenURL string) *worker.ReviewData {
|
||||
rd := &worker.ReviewData{
|
||||
Download: store.Download{
|
||||
ID: 1, State: store.StateReview, SourceType: store.SourceMagnet,
|
||||
SourceRef: "magnet:?xt=urn:btih:deadbeef", DisplayName: "Дюна",
|
||||
Infohash: store.NullString("deadbeef"),
|
||||
},
|
||||
Recognition: &store.Recognition{ID: 1, DownloadID: 1, IsCurrent: true},
|
||||
Plan: recognize.Plan{
|
||||
Type: recognize.MediaMovie, Title: "Дюна", Year: 2024,
|
||||
Files: []recognize.PlanFile{{Src: "dune.mkv", Role: recognize.RoleMain}},
|
||||
},
|
||||
Preview: []layout.Link{{Src: "dune.mkv", Dst: "/srv/media/movies/Дюна (2024)/Дюна (2024).mkv"}},
|
||||
Provider: provider,
|
||||
ProviderID: providerID,
|
||||
}
|
||||
if chosenID != "" {
|
||||
rd.Candidates = []store.MetadataCandidate{{
|
||||
ID: 10, Provider: "tmdb", ProviderID: chosenID, Chosen: true,
|
||||
URL: store.NullString(chosenURL),
|
||||
}}
|
||||
}
|
||||
return rd
|
||||
}
|
||||
|
||||
func TestDownloadMatchLinkFromCandidate(t *testing.T) {
|
||||
// Выбранный кандидат совпадает с эффективным provider+id → ссылка кандидата.
|
||||
rv := &fakeReviewer{data: detailReviewData("tmdb", "693134", "693134", "https://custom.example/x")}
|
||||
srv := newServer(t, httpapi.Deps{Ingestor: &fakeIngestor{}, Commander: &fakeCommander{},
|
||||
Reader: &fakeReader{}, Reviewer: rv})
|
||||
|
||||
resp, err := http.Get(srv.URL + "/download/1")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
if !strings.Contains(string(body), "https://custom.example/x") {
|
||||
t.Error("ссылка на запись метабазы (URL кандидата) не показана")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDownloadMatchLinkMismatchUsesBuilt(t *testing.T) {
|
||||
// Эффективный id (999, ручной override) отличается от выбранного кандидата
|
||||
// (693134) → ссылка строится по эффективному id, а не берётся у кандидата.
|
||||
rv := &fakeReviewer{data: detailReviewData("tmdb", "999", "693134", "https://custom.example/x")}
|
||||
srv := newServer(t, httpapi.Deps{Ingestor: &fakeIngestor{}, Commander: &fakeCommander{},
|
||||
Reader: &fakeReader{}, Reviewer: rv})
|
||||
|
||||
resp, err := http.Get(srv.URL + "/download/1")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
if !strings.Contains(string(body), "https://www.themoviedb.org/movie/999") {
|
||||
t.Error("ссылка не построена по эффективному provider_id (movie/999)")
|
||||
}
|
||||
if strings.Contains(string(body), "https://custom.example/x") {
|
||||
t.Error("взят URL кандидата, не совпадающего с эффективным id")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDownloadTorrentInfoBlock(t *testing.T) {
|
||||
rv := &fakeReviewer{data: detailReviewData("tmdb", "693134", "693134", "https://x/y")}
|
||||
srv := newServer(t, httpapi.Deps{Ingestor: &fakeIngestor{}, Commander: &fakeCommander{},
|
||||
Reader: &fakeReader{}, Reviewer: rv})
|
||||
|
||||
resp, err := http.Get(srv.URL + "/download/1")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
for _, want := range []string{"Информация о торренте", "magnet:?xt=urn:btih:deadbeef", "Дюна"} {
|
||||
if !strings.Contains(string(body), want) {
|
||||
t.Errorf("страница загрузки не содержит %q", want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- Ревью ---
|
||||
|
||||
type fakeReviewer struct {
|
||||
@@ -360,6 +518,28 @@ func TestReviewRenders(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestReviewShowsMatchLink(t *testing.T) {
|
||||
// Подтверждённый матч в блоке «Источник совпадения» — ссылкой на запись.
|
||||
rd := seriesReviewData()
|
||||
rd.Provider = "tvdb"
|
||||
rd.ProviderID = "269613"
|
||||
rd.Candidates[0].Chosen = true // tvdb 269613 в seriesReviewData
|
||||
rd.Candidates[0].URL = store.NullString("https://www.thetvdb.com/dereferrer/series/269613")
|
||||
rv := &fakeReviewer{data: rd}
|
||||
srv := newServer(t, httpapi.Deps{Ingestor: &fakeIngestor{}, Commander: &fakeCommander{},
|
||||
Reader: &fakeReader{}, Reviewer: rv})
|
||||
|
||||
resp, err := http.Get(srv.URL + "/review/1")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
if !strings.Contains(string(body), "https://www.thetvdb.com/dereferrer/series/269613") {
|
||||
t.Error("строка «Выбрано» не содержит ссылку на запись метабазы")
|
||||
}
|
||||
}
|
||||
|
||||
func TestChooseCandidate(t *testing.T) {
|
||||
rv := &fakeReviewer{data: seriesReviewData()}
|
||||
srv := newServer(t, httpapi.Deps{Ingestor: &fakeIngestor{}, Commander: &fakeCommander{},
|
||||
|
||||
Reference in New Issue
Block a user