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:
@@ -6,6 +6,7 @@ import (
|
||||
"strconv"
|
||||
|
||||
"git.vakhrushev.me/av/jellybit/internal/store"
|
||||
"git.vakhrushev.me/av/jellybit/internal/worker"
|
||||
)
|
||||
|
||||
// --- Страница просмотра одной загрузки ---
|
||||
@@ -13,7 +14,8 @@ import (
|
||||
type downloadDetailView struct {
|
||||
ID int64
|
||||
Title string
|
||||
Source string
|
||||
SourceType string // тип источника (magnet/torrent/url) — блок «Информация о торренте»
|
||||
SourceFull string // полный источник (magnet) — блок «Информация о торренте»
|
||||
Infohash string
|
||||
InfohashShort string
|
||||
Context string
|
||||
@@ -32,6 +34,7 @@ type downloadDetailView struct {
|
||||
Year int
|
||||
Provider string
|
||||
ProviderID string
|
||||
MatchURL string // ссылка на запись метабазы (пусто — показываем текстом)
|
||||
NoBase bool
|
||||
Confidence string
|
||||
Files []fileRow
|
||||
@@ -47,6 +50,18 @@ type downloadDetailView struct {
|
||||
Retriable bool
|
||||
}
|
||||
|
||||
// detailTitle — заголовок страницы просмотра: имя раздачи (display_name) →
|
||||
// распознанное название (план) → усечённый до одной строки сырой источник.
|
||||
func detailTitle(d store.Download, rd *worker.ReviewData) string {
|
||||
if d.DisplayName != "" {
|
||||
return d.DisplayName
|
||||
}
|
||||
if rd.Plan.Title != "" {
|
||||
return rd.Plan.Title
|
||||
}
|
||||
return shorten(oneLine(d.SourceRef), 120)
|
||||
}
|
||||
|
||||
func (s *server) handleDownload(w http.ResponseWriter, r *http.Request) {
|
||||
id, err := pathID(r)
|
||||
if err != nil {
|
||||
@@ -67,8 +82,9 @@ func (s *server) handleDownload(w http.ResponseWriter, r *http.Request) {
|
||||
d := rd.Download
|
||||
view := downloadDetailView{
|
||||
ID: id,
|
||||
Title: d.SourceRef,
|
||||
Source: shorten(d.SourceRef, 120),
|
||||
Title: detailTitle(d, rd),
|
||||
SourceType: string(d.SourceType),
|
||||
SourceFull: d.SourceRef,
|
||||
Infohash: d.Infohash.String,
|
||||
InfohashShort: shortenHash(d.Infohash.String),
|
||||
Context: d.Context,
|
||||
@@ -98,6 +114,7 @@ func (s *server) handleDownload(w http.ResponseWriter, r *http.Request) {
|
||||
default:
|
||||
view.Provider = rd.Provider
|
||||
view.ProviderID = rd.ProviderID
|
||||
view.MatchURL = matchURL(rd, view.MediaType)
|
||||
}
|
||||
if rd.Recognition.Confidence.Valid {
|
||||
view.Confidence = strconv.FormatFloat(rd.Recognition.Confidence.Float64, 'f', 2, 64)
|
||||
|
||||
+189
-11
@@ -42,6 +42,7 @@ type Commander interface {
|
||||
// Reader читает задачи (store.Store).
|
||||
type Reader interface {
|
||||
ListDownloads(ctx context.Context) ([]store.Download, error)
|
||||
ListDownloadsPage(ctx context.Context, f store.ListFilter) ([]store.Download, int, error)
|
||||
GetDownload(ctx context.Context, id int64) (*store.Download, error)
|
||||
}
|
||||
|
||||
@@ -139,26 +140,54 @@ func handleHealthz(w http.ResponseWriter, _ *http.Request) {
|
||||
|
||||
// --- Веб-UI ---
|
||||
|
||||
// pageSize — размер страницы списка загрузок (серверная пагинация).
|
||||
const pageSize = 25
|
||||
|
||||
type indexView struct {
|
||||
Error string
|
||||
Downloads []downloadView
|
||||
|
||||
// Фильтр/поиск (серверные, в query).
|
||||
Filter string // активная группа (all/review/active/done/problem)
|
||||
Query string // текст поиска
|
||||
ShowAll bool // показывать удалённые
|
||||
Chips []filterChip // чипы фильтра со ссылками
|
||||
ShowURL string // ссылка тумблера «показать всё»
|
||||
|
||||
// Пагинация.
|
||||
Page int
|
||||
Pages int // всего страниц (>=1)
|
||||
Total int // всего строк под фильтром
|
||||
Searching bool // активны фильтр/поиск — влияет на текст пустого состояния
|
||||
PrevURL string // пусто — на первой странице
|
||||
NextURL string // пусто — на последней
|
||||
PageLinks []pageLink // пронумерованные страницы (окно)
|
||||
}
|
||||
|
||||
type filterChip struct {
|
||||
Key string
|
||||
Label string
|
||||
URL string
|
||||
Active bool
|
||||
}
|
||||
|
||||
type pageLink struct {
|
||||
Num int
|
||||
URL string
|
||||
Active bool
|
||||
}
|
||||
|
||||
type downloadView struct {
|
||||
ID int64
|
||||
Title string // отображаемый заголовок карточки
|
||||
Source string
|
||||
Infohash string // полный (для копирования)
|
||||
InfohashShort string // усечённый (для показа)
|
||||
Context string
|
||||
State string
|
||||
Group string // группа фильтра (review/active/done/problem/other)
|
||||
SearchText string // haystack для клиентского поиска (lowercase)
|
||||
Error string
|
||||
Terminal bool
|
||||
IsDownloading bool // активная загрузка → живой прогресс-бар + поллинг
|
||||
Progress progressView // живой прогресс (заполняется в handleIndex из снимка)
|
||||
Deleted bool // скрыт по умолчанию на главной
|
||||
Reviewable bool // review/deferred — есть экран ревью
|
||||
Undoable bool // done — можно откатить раскладку
|
||||
Relinkable bool // reverted/cancelled/target_missing — можно перепривязать заново
|
||||
@@ -166,14 +195,75 @@ type downloadView struct {
|
||||
Note string // пояснение рассинхрона (target_missing/orphaned/deleted)
|
||||
}
|
||||
|
||||
// listChips — определения чипов фильтра списка (порядок = порядок показа).
|
||||
var listChips = []struct {
|
||||
Key string
|
||||
Label string
|
||||
Group store.StateGroup
|
||||
}{
|
||||
{"all", "Все", store.GroupAll},
|
||||
{"review", "Ждут меня", store.GroupReview},
|
||||
{"active", "В работе", store.GroupActive},
|
||||
{"done", "Готово", store.GroupDone},
|
||||
{"problem", "Проблемы", store.GroupProblem},
|
||||
}
|
||||
|
||||
func (s *server) handleIndex(w http.ResponseWriter, r *http.Request) {
|
||||
downloads, err := s.deps.Reader.ListDownloads(r.Context())
|
||||
q := r.URL.Query()
|
||||
group := parseGroup(q.Get("f"))
|
||||
query := strings.TrimSpace(q.Get("q"))
|
||||
showAll := q.Get("all") == "1"
|
||||
page := parsePage(q.Get("page"))
|
||||
|
||||
downloads, total, err := s.deps.Reader.ListDownloadsPage(r.Context(), store.ListFilter{
|
||||
Group: group,
|
||||
Query: query,
|
||||
IncludeDeleted: showAll,
|
||||
Limit: pageSize,
|
||||
Offset: (page - 1) * pageSize,
|
||||
})
|
||||
if err != nil {
|
||||
s.deps.Logger.Error("list downloads", "error", err)
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
view := indexView{Error: r.URL.Query().Get("err")}
|
||||
|
||||
pages := max((total+pageSize-1)/pageSize, 1)
|
||||
|
||||
view := indexView{
|
||||
Error: q.Get("err"),
|
||||
Filter: string(group),
|
||||
Query: query,
|
||||
ShowAll: showAll,
|
||||
Page: page,
|
||||
Pages: pages,
|
||||
Total: total,
|
||||
Searching: query != "" || group != store.GroupAll || showAll || page > 1,
|
||||
}
|
||||
// Чипы: сохраняют q/all, сбрасывают страницу.
|
||||
for _, c := range listChips {
|
||||
view.Chips = append(view.Chips, filterChip{
|
||||
Key: c.Key,
|
||||
Label: c.Label,
|
||||
URL: listURL(c.Group, query, showAll, 1),
|
||||
Active: c.Group == group,
|
||||
})
|
||||
}
|
||||
// Тумблер «показать всё» переключает all, сохраняя фильтр/поиск.
|
||||
view.ShowURL = listURL(group, query, !showAll, 1)
|
||||
// Пагинация: сохраняет f/q/all.
|
||||
if page > 1 {
|
||||
view.PrevURL = listURL(group, query, showAll, page-1)
|
||||
}
|
||||
if page < pages {
|
||||
view.NextURL = listURL(group, query, showAll, page+1)
|
||||
}
|
||||
for _, n := range pageWindow(page, pages) {
|
||||
view.PageLinks = append(view.PageLinks, pageLink{
|
||||
Num: n, URL: listURL(group, query, showAll, n), Active: n == page,
|
||||
})
|
||||
}
|
||||
|
||||
for _, d := range downloads {
|
||||
v := toView(d)
|
||||
// Живой прогресс активных загрузок — со значениями уже в первом кадре
|
||||
@@ -187,6 +277,78 @@ func (s *server) handleIndex(w http.ResponseWriter, r *http.Request) {
|
||||
s.render(w, "index.html", view)
|
||||
}
|
||||
|
||||
// parseGroup разбирает параметр фильтра `f`; неизвестное → all.
|
||||
func parseGroup(s string) store.StateGroup {
|
||||
switch store.StateGroup(s) {
|
||||
case store.GroupReview:
|
||||
return store.GroupReview
|
||||
case store.GroupActive:
|
||||
return store.GroupActive
|
||||
case store.GroupDone:
|
||||
return store.GroupDone
|
||||
case store.GroupProblem:
|
||||
return store.GroupProblem
|
||||
default:
|
||||
return store.GroupAll
|
||||
}
|
||||
}
|
||||
|
||||
// parsePage разбирает номер страницы (1-based); мусор/<1 → 1. За последней
|
||||
// страницей отдаём как есть — запрос вернёт пустую страницу (не ошибка).
|
||||
func parsePage(s string) int {
|
||||
n, err := strconv.Atoi(s)
|
||||
if err != nil || n < 1 {
|
||||
return 1
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// listURL строит ссылку списка с сохранением состояния фильтра/поиска/страницы.
|
||||
// Дефолты (all, пустой поиск, page 1) в query не пишем — URL чистый.
|
||||
func listURL(group store.StateGroup, query string, showAll bool, page int) string {
|
||||
v := url.Values{}
|
||||
if group != store.GroupAll {
|
||||
v.Set("f", string(group))
|
||||
}
|
||||
if query != "" {
|
||||
v.Set("q", query)
|
||||
}
|
||||
if showAll {
|
||||
v.Set("all", "1")
|
||||
}
|
||||
if page > 1 {
|
||||
v.Set("page", strconv.Itoa(page))
|
||||
}
|
||||
if len(v) == 0 {
|
||||
return "/"
|
||||
}
|
||||
return "/?" + v.Encode()
|
||||
}
|
||||
|
||||
// pageWindow возвращает номера страниц вокруг текущей (окно до 7), чтобы пагинация
|
||||
// не разрасталась на больших списках.
|
||||
func pageWindow(page, pages int) []int {
|
||||
const win = 7
|
||||
if pages <= win {
|
||||
out := make([]int, pages)
|
||||
for i := range out {
|
||||
out[i] = i + 1
|
||||
}
|
||||
return out
|
||||
}
|
||||
start := max(page-win/2, 1)
|
||||
end := start + win - 1
|
||||
if end > pages {
|
||||
end = pages
|
||||
start = end - win + 1
|
||||
}
|
||||
out := make([]int, 0, win)
|
||||
for n := start; n <= end; n++ {
|
||||
out = append(out, n)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (s *server) handleUIAdd(w http.ResponseWriter, r *http.Request) {
|
||||
if err := r.ParseForm(); err != nil {
|
||||
redirectErr(w, r, "не удалось разобрать форму")
|
||||
@@ -358,18 +520,14 @@ func toView(d store.Download) downloadView {
|
||||
state := string(d.State)
|
||||
return downloadView{
|
||||
ID: d.ID,
|
||||
Title: d.SourceRef,
|
||||
Source: shorten(d.SourceRef, 64),
|
||||
Title: downloadTitle(d),
|
||||
Infohash: d.Infohash.String,
|
||||
InfohashShort: shortenHash(d.Infohash.String),
|
||||
Context: d.Context,
|
||||
State: state,
|
||||
Group: stateGroup(state),
|
||||
SearchText: strings.ToLower(d.SourceRef + " " + d.Infohash.String + " " + d.Context),
|
||||
Error: d.ErrorMsg.String,
|
||||
Terminal: d.State.IsTerminal(),
|
||||
IsDownloading: d.State == store.StateDownloading,
|
||||
Deleted: d.State == store.StateDeleted,
|
||||
Reviewable: d.State == store.StateReview || d.State == store.StateDeferred,
|
||||
Undoable: d.State == store.StateDone,
|
||||
Relinkable: d.State == store.StateReverted || d.State == store.StateCancelled ||
|
||||
@@ -379,6 +537,26 @@ func toView(d store.Download) downloadView {
|
||||
}
|
||||
}
|
||||
|
||||
// downloadTitle — заголовок загрузки для списка: имя раздачи (display_name,
|
||||
// то, что ушло в qBittorrent) → распознанное название (RecTitle из листинга) →
|
||||
// усечённый до одной строки сырой источник. Сырой magnet не должен занимать
|
||||
// несколько строк заголовка.
|
||||
func downloadTitle(d store.Download) string {
|
||||
if d.DisplayName != "" {
|
||||
return d.DisplayName
|
||||
}
|
||||
if d.RecTitle.Valid && d.RecTitle.String != "" {
|
||||
return d.RecTitle.String
|
||||
}
|
||||
return shorten(oneLine(d.SourceRef), 80)
|
||||
}
|
||||
|
||||
// oneLine схлопывает переводы строк и лишние пробелы — сырой источник в
|
||||
// заголовок кладём одной строкой.
|
||||
func oneLine(s string) string {
|
||||
return strings.Join(strings.Fields(s), " ")
|
||||
}
|
||||
|
||||
// shortenHash усекает infohash до вида "a1b2c3d4e5…f0" для компактного показа.
|
||||
func shortenHash(h string) string {
|
||||
if len(h) <= 12 {
|
||||
|
||||
@@ -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{},
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"git.vakhrushev.me/av/jellybit/internal/store"
|
||||
"git.vakhrushev.me/av/jellybit/internal/worker"
|
||||
)
|
||||
|
||||
func TestProviderURL(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
provider string
|
||||
id string
|
||||
mtype string
|
||||
want string
|
||||
}{
|
||||
{"tmdb movie", "tmdb", "693134", "movie", "https://www.themoviedb.org/movie/693134"},
|
||||
{"tmdb series", "tmdb", "60622", "series", "https://www.themoviedb.org/tv/60622"},
|
||||
{"tvdb series", "tvdb", "269613", "series", "https://www.thetvdb.com/dereferrer/series/269613"},
|
||||
{"tvdb movie", "tvdb", "12345", "movie", "https://www.thetvdb.com/dereferrer/movie/12345"},
|
||||
{"imdb", "imdb", "tt0111161", "movie", "https://www.imdb.com/title/tt0111161"},
|
||||
{"unknown provider → пусто", "kinopoisk", "42", "movie", ""},
|
||||
{"пустой id → пусто", "tmdb", "", "movie", ""},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
if got := providerURL(c.provider, c.id, c.mtype); got != c.want {
|
||||
t.Errorf("providerURL(%q,%q,%q) = %q, want %q", c.provider, c.id, c.mtype, got, c.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestMatchURLNoLinkWhenUnbuildable — эффективный провайдер, для которого URL не
|
||||
// строится и совпадающего кандидата нет, даёт пустую ссылку (транспорт покажет
|
||||
// матч текстом — сценарий «URL записи неизвестен»).
|
||||
func TestMatchURLNoLinkWhenUnbuildable(t *testing.T) {
|
||||
rd := &worker.ReviewData{Provider: "kinopoisk", ProviderID: "42"}
|
||||
if got := matchURL(rd, "movie"); got != "" {
|
||||
t.Errorf("matchURL = %q, want пусто (URL не строится, кандидата нет)", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestMatchURLPrefersMatchingCandidate — URL берётся у выбранного кандидата,
|
||||
// когда его provider+id совпадают с эффективными.
|
||||
func TestMatchURLPrefersMatchingCandidate(t *testing.T) {
|
||||
rd := &worker.ReviewData{
|
||||
Provider: "tmdb", ProviderID: "693134",
|
||||
Candidates: []store.MetadataCandidate{{
|
||||
Provider: "tmdb", ProviderID: "693134", Chosen: true,
|
||||
URL: store.NullString("https://custom.example/x"),
|
||||
}},
|
||||
}
|
||||
if got := matchURL(rd, "movie"); got != "https://custom.example/x" {
|
||||
t.Errorf("matchURL = %q, want URL выбранного кандидата", got)
|
||||
}
|
||||
}
|
||||
@@ -73,23 +73,6 @@ func badgeLabel(state string) string {
|
||||
}
|
||||
}
|
||||
|
||||
// stateGroup — группа фильтра на главной (чипы): ждут меня / в работе / готово
|
||||
// / проблемы / прочее. Завязано на смысл состояния, не на цвет.
|
||||
func stateGroup(state string) string {
|
||||
switch state {
|
||||
case "review", "deferred":
|
||||
return "review"
|
||||
case "downloading", "completed", "recognizing", "linking":
|
||||
return "active"
|
||||
case "done":
|
||||
return "done"
|
||||
case "stuck", "failed", "target_missing", "orphaned":
|
||||
return "problem"
|
||||
default:
|
||||
return "other"
|
||||
}
|
||||
}
|
||||
|
||||
// render отрисовывает именованный шаблон в буфер и только затем пишет ответ —
|
||||
// при ошибке шаблона клиент не получит «полустраницу».
|
||||
func (s *server) render(w http.ResponseWriter, name string, data any) {
|
||||
|
||||
@@ -20,6 +20,9 @@ type stubReader struct {
|
||||
}
|
||||
|
||||
func (s stubReader) ListDownloads(context.Context) ([]store.Download, error) { return s.list, nil }
|
||||
func (s stubReader) ListDownloadsPage(context.Context, store.ListFilter) ([]store.Download, int, error) {
|
||||
return s.list, len(s.list), nil
|
||||
}
|
||||
func (s stubReader) GetDownload(context.Context, int64) (*store.Download, error) {
|
||||
if s.one == nil {
|
||||
return nil, store.ErrNotFound
|
||||
|
||||
@@ -43,6 +43,7 @@ type reviewView struct {
|
||||
Year int
|
||||
Provider string
|
||||
ProviderID string
|
||||
MatchURL string // ссылка на подтверждённую запись метабазы (пусто — текстом)
|
||||
Confidence string
|
||||
Reasons []string
|
||||
Hints []string
|
||||
@@ -102,6 +103,7 @@ func (s *server) handleReview(w http.ResponseWriter, r *http.Request) {
|
||||
default:
|
||||
view.Provider = rd.Provider
|
||||
view.ProviderID = rd.ProviderID
|
||||
view.MatchURL = matchURL(rd, string(rd.Plan.Type))
|
||||
}
|
||||
if rec.Confidence.Valid {
|
||||
view.Confidence = strconv.FormatFloat(rec.Confidence.Float64, 'f', 2, 64)
|
||||
@@ -253,6 +255,47 @@ func (s *server) reviewAction(w http.ResponseWriter, r *http.Request, fn func(co
|
||||
redirectReview(w, r, id, "")
|
||||
}
|
||||
|
||||
// matchURL выбирает ссылку на подтверждённую запись метабазы. Приоритет — URL
|
||||
// выбранного кандидата, но только если его provider+id совпадают с эффективными
|
||||
// (человек мог выбрать кандидата, затем вручную переопределить id — тогда
|
||||
// кандидат указывает на другую запись). Иначе строим канонический URL; если не
|
||||
// удаётся — возвращаем пусто (транспорт покажет матч текстом).
|
||||
func matchURL(rd *worker.ReviewData, mediaType string) string {
|
||||
for _, c := range rd.Candidates {
|
||||
if c.Chosen && c.Provider == rd.Provider && c.ProviderID == rd.ProviderID &&
|
||||
c.URL.Valid && c.URL.String != "" {
|
||||
return c.URL.String
|
||||
}
|
||||
}
|
||||
return providerURL(rd.Provider, rd.ProviderID, mediaType)
|
||||
}
|
||||
|
||||
// providerURL строит канонический URL записи метабазы с учётом типа медиа.
|
||||
// Пустой id или неизвестный провайдер → пусто.
|
||||
func providerURL(provider, id, mediaType string) string {
|
||||
if id == "" {
|
||||
return ""
|
||||
}
|
||||
switch provider {
|
||||
case "tmdb":
|
||||
kind := "movie"
|
||||
if mediaType == "series" {
|
||||
kind = "tv"
|
||||
}
|
||||
return "https://www.themoviedb.org/" + kind + "/" + id
|
||||
case "tvdb":
|
||||
kind := "series"
|
||||
if mediaType == "movie" {
|
||||
kind = "movie"
|
||||
}
|
||||
return "https://www.thetvdb.com/dereferrer/" + kind + "/" + id
|
||||
case "imdb":
|
||||
return "https://www.imdb.com/title/" + id
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
func redirectReview(w http.ResponseWriter, r *http.Request, id int64, msg string) {
|
||||
u := "/review/" + strconv.FormatInt(id, 10)
|
||||
if msg != "" {
|
||||
|
||||
@@ -124,6 +124,7 @@ func (s *Service) Ingest(ctx context.Context, req Request) (Result, error) {
|
||||
d := &store.Download{
|
||||
SourceType: store.SourceMagnet,
|
||||
SourceRef: source,
|
||||
DisplayName: rename, // то же имя, что уходит в qBittorrent (rename); заголовок в веб-UI
|
||||
Context: req.Context,
|
||||
Infohash: store.NullString(info.Infohash),
|
||||
IdempotencyKey: store.NullString(info.Infohash),
|
||||
|
||||
@@ -129,6 +129,10 @@ func TestIngestSetsDisplayName(t *testing.T) {
|
||||
if len(fq.added) != 1 || fq.added[0].Rename != "Дюна: Часть вторая (2024)" {
|
||||
t.Errorf("rename = %q, want %q", fq.added[0].Rename, "Дюна: Часть вторая (2024)")
|
||||
}
|
||||
// То же имя сохраняется у загрузки — заголовок в веб-UI.
|
||||
if len(fs.created) != 1 || fs.created[0].DisplayName != "Дюна: Часть вторая (2024)" {
|
||||
t.Errorf("display_name = %q, want %q", fs.created[0].DisplayName, "Дюна: Часть вторая (2024)")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIngestEmptyNameOmitsRename(t *testing.T) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// StateGroup — группа состояний для фильтра списка загрузок. Единый источник
|
||||
// истины «группа → состояния» для транспорта (чипы) и запроса (WHERE).
|
||||
type StateGroup string
|
||||
|
||||
const (
|
||||
GroupAll StateGroup = "all" // все, кроме deleted (если не IncludeDeleted)
|
||||
GroupReview StateGroup = "review" // ждут человека
|
||||
GroupActive StateGroup = "active" // в работе
|
||||
GroupDone StateGroup = "done" // готово
|
||||
GroupProblem StateGroup = "problem" // проблемы
|
||||
)
|
||||
|
||||
// statesInGroup возвращает состояния группы фильтра. nil — группа не сужает
|
||||
// выборку по состоянию (all/неизвестная): состояния перечисляются здесь и
|
||||
// нигде больше, чтобы UI и запрос не разъезжались. Терминальные cancelled/
|
||||
// reverted/deleted своей группы (чипа) не имеют — под all они видны (deleted —
|
||||
// только при IncludeDeleted), точечно к ним не фильтруем (см. tasks 2.1).
|
||||
func statesInGroup(g StateGroup) []State {
|
||||
switch g {
|
||||
case GroupReview:
|
||||
return []State{StateReview, StateDeferred}
|
||||
case GroupActive:
|
||||
return []State{StateDownloading, StateCompleted, StateRecognizing, StateLinking}
|
||||
case GroupDone:
|
||||
return []State{StateDone}
|
||||
case GroupProblem:
|
||||
return []State{StateStuck, StateFailed, StateTargetMissing, StateOrphaned}
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// ListFilter — параметры страницы списка загрузок.
|
||||
type ListFilter struct {
|
||||
Group StateGroup // группа состояний ("" / all — без сужения)
|
||||
Query string // поиск по названию/infohash/контексту (подстрока)
|
||||
IncludeDeleted bool // показывать состояние deleted (только под all)
|
||||
Limit int // размер страницы
|
||||
Offset int // смещение страницы
|
||||
}
|
||||
|
||||
// listWhere строит условие WHERE и его аргументы по фильтру.
|
||||
func listWhere(f ListFilter) (string, []any) {
|
||||
var conds []string
|
||||
var args []any
|
||||
|
||||
if states := statesInGroup(f.Group); states != nil {
|
||||
ph := make([]string, len(states))
|
||||
for i, st := range states {
|
||||
ph[i] = "?"
|
||||
args = append(args, string(st))
|
||||
}
|
||||
conds = append(conds, "state IN ("+strings.Join(ph, ",")+")")
|
||||
} else if !f.IncludeDeleted {
|
||||
// Группа all: скрываем удалённые по умолчанию (шум в ленте).
|
||||
conds = append(conds, "state <> ?")
|
||||
args = append(args, string(StateDeleted))
|
||||
}
|
||||
|
||||
if q := strings.TrimSpace(f.Query); q != "" {
|
||||
// LIKE даёт регистронезависимость только для ASCII (встроенное
|
||||
// case-folding SQLite), для кириллицы поиск чувствителен к регистру —
|
||||
// ICU в чистом modernc-драйвере нет. Достаточно для инфохэша/латиницы;
|
||||
// кириллические названия ищем как введены.
|
||||
like := "%" + q + "%"
|
||||
conds = append(conds, "(source_ref LIKE ? COLLATE NOCASE "+
|
||||
"OR display_name LIKE ? COLLATE NOCASE "+
|
||||
"OR context LIKE ? COLLATE NOCASE "+
|
||||
"OR IFNULL(infohash,'') LIKE ? COLLATE NOCASE)")
|
||||
args = append(args, like, like, like, like)
|
||||
}
|
||||
|
||||
if len(conds) == 0 {
|
||||
return "", args
|
||||
}
|
||||
return " WHERE " + strings.Join(conds, " AND "), args
|
||||
}
|
||||
|
||||
// ListDownloadsPage возвращает страницу загрузок под фильтром и общее число
|
||||
// строк, удовлетворяющих фильтру (для пагинации). Сортировка — по времени
|
||||
// добавления в источник (source_added_at) с фолбеком на created_at и tie-break
|
||||
// по id: без вторичного ключа при равных метках порядок неустойчив, и строки
|
||||
// задваивались бы/пропадали между страницами. RecTitle подтягивается LEFT JOIN
|
||||
// текущей попытки распознавания — для фолбека заголовка карточки.
|
||||
func (s *Store) ListDownloadsPage(ctx context.Context, f ListFilter) ([]Download, int, error) {
|
||||
where, args := listWhere(f)
|
||||
|
||||
var total int
|
||||
if err := s.DB.GetContext(ctx, &total, `SELECT COUNT(*) FROM download`+where, args...); err != nil {
|
||||
return nil, 0, fmt.Errorf("list downloads page count: %w", err)
|
||||
}
|
||||
|
||||
q := `SELECT download.*, r.title AS rec_title
|
||||
FROM download
|
||||
LEFT JOIN recognition r ON r.download_id = download.id AND r.is_current = 1` + where +
|
||||
` ORDER BY COALESCE(download.source_added_at, download.created_at) DESC, download.id DESC
|
||||
LIMIT ? OFFSET ?`
|
||||
pageArgs := append(append([]any{}, args...), f.Limit, f.Offset)
|
||||
|
||||
var out []Download
|
||||
if err := s.DB.SelectContext(ctx, &out, q, pageArgs...); err != nil {
|
||||
return nil, 0, fmt.Errorf("list downloads page: %w", err)
|
||||
}
|
||||
return out, total, nil
|
||||
}
|
||||
@@ -0,0 +1,264 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// hashN — детерминированный валидный 40-hex infohash по номеру.
|
||||
func hashN(n int) string { return fmt.Sprintf("%040x", n) }
|
||||
|
||||
// mkDownload заводит загрузку в заданном состоянии с display_name.
|
||||
func mkDownload(t *testing.T, st *Store, n int, state State, display string) int64 {
|
||||
t.Helper()
|
||||
ctx := context.Background()
|
||||
d := newDownloading(hashN(n))
|
||||
d.DisplayName = display
|
||||
id, err := st.CreateDownload(ctx, d)
|
||||
if err != nil {
|
||||
t.Fatalf("create #%d: %v", n, err)
|
||||
}
|
||||
if state != StateDownloading {
|
||||
if err := st.SetDownloadState(ctx, id, state, "", ""); err != nil {
|
||||
t.Fatalf("set state #%d: %v", n, err)
|
||||
}
|
||||
}
|
||||
return id
|
||||
}
|
||||
|
||||
func ids(ds []Download) []int64 {
|
||||
out := make([]int64, len(ds))
|
||||
for i, d := range ds {
|
||||
out[i] = d.ID
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func TestListDownloadsPageFilterAndDeleted(t *testing.T) {
|
||||
st := newTestStore(t)
|
||||
ctx := context.Background()
|
||||
|
||||
review := mkDownload(t, st, 1, StateReview, "Дюна")
|
||||
active := mkDownload(t, st, 2, StateDownloading, "Fargo")
|
||||
done := mkDownload(t, st, 3, StateDone, "Оно")
|
||||
deleted := mkDownload(t, st, 4, StateDeleted, "Старьё")
|
||||
|
||||
// Группа review — только review/deferred.
|
||||
page, total, err := st.ListDownloadsPage(ctx, ListFilter{Group: GroupReview, Limit: 50})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if total != 1 || len(page) != 1 || page[0].ID != review {
|
||||
t.Fatalf("review group = %v (total %d), want [%d]", ids(page), total, review)
|
||||
}
|
||||
|
||||
// all: deleted скрыт по умолчанию.
|
||||
page, total, err = st.ListDownloadsPage(ctx, ListFilter{Group: GroupAll, Limit: 50})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if total != 3 {
|
||||
t.Fatalf("all (без deleted) total = %d, want 3; ids=%v", total, ids(page))
|
||||
}
|
||||
for _, d := range page {
|
||||
if d.ID == deleted {
|
||||
t.Fatalf("deleted не должен быть виден по умолчанию")
|
||||
}
|
||||
}
|
||||
_ = active
|
||||
_ = done
|
||||
|
||||
// all + IncludeDeleted: показываем всё.
|
||||
_, total, err = st.ListDownloadsPage(ctx, ListFilter{Group: GroupAll, IncludeDeleted: true, Limit: 50})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if total != 4 {
|
||||
t.Fatalf("all+deleted total = %d, want 4", total)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListDownloadsPageSearch(t *testing.T) {
|
||||
st := newTestStore(t)
|
||||
ctx := context.Background()
|
||||
|
||||
mkDownload(t, st, 1, StateReview, "Дюна: Часть вторая")
|
||||
mkDownload(t, st, 2, StateReview, "Fargo")
|
||||
|
||||
// Поиск по display_name (кириллица — как введено, с учётом регистра).
|
||||
page, total, err := st.ListDownloadsPage(ctx, ListFilter{Query: "Дюна", Limit: 50})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if total != 1 || len(page) != 1 || page[0].DisplayName != "Дюна: Часть вторая" {
|
||||
t.Fatalf("search 'Дюна' = %v (total %d)", ids(page), total)
|
||||
}
|
||||
|
||||
// Латиница ищется регистронезависимо (ASCII case-folding LIKE).
|
||||
page, _, err = st.ListDownloadsPage(ctx, ListFilter{Query: "fargo", Limit: 50})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(page) != 1 || page[0].DisplayName != "Fargo" {
|
||||
t.Fatalf("search 'fargo' = %v", ids(page))
|
||||
}
|
||||
|
||||
// Поиск по infohash.
|
||||
page, _, err = st.ListDownloadsPage(ctx, ListFilter{Query: hashN(2), Limit: 50})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(page) != 1 || page[0].Infohash.String != hashN(2) {
|
||||
t.Fatalf("search by infohash = %v", ids(page))
|
||||
}
|
||||
}
|
||||
|
||||
func TestListDownloadsPageOrderAndPagination(t *testing.T) {
|
||||
st := newTestStore(t)
|
||||
ctx := context.Background()
|
||||
|
||||
// Три задачи; у #1 и #2 задано source_added_at (разное), у #3 — нет (фолбек
|
||||
// на created_at). Проверяем порядок DESC по времени добавления и tie-break.
|
||||
id1 := mkDownload(t, st, 1, StateDownloading, "one")
|
||||
id2 := mkDownload(t, st, 2, StateDownloading, "two")
|
||||
id3 := mkDownload(t, st, 3, StateDownloading, "three")
|
||||
|
||||
base := time.Date(2026, 6, 1, 12, 0, 0, 0, time.UTC)
|
||||
// id1 добавлен раньше, id2 позже → id2 должен идти выше id1.
|
||||
if err := st.SetSourceAddedAt(ctx, id1, base); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := st.SetSourceAddedAt(ctx, id2, base.Add(time.Hour)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
page, total, err := st.ListDownloadsPage(ctx, ListFilter{Group: GroupAll, Limit: 50})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if total != 3 {
|
||||
t.Fatalf("total = %d, want 3", total)
|
||||
}
|
||||
// id3 фолбечит на created_at (~сейчас, 2026-07-01) — свежее, чем добавления
|
||||
// id1/id2 в июне → id3 первым; затем id2 (позже добавлен), затем id1.
|
||||
got := ids(page)
|
||||
want := []int64{id3, id2, id1}
|
||||
for i := range want {
|
||||
if got[i] != want[i] {
|
||||
t.Fatalf("порядок = %v, want %v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
// Пагинация: по 2 на страницу.
|
||||
p1, total, err := st.ListDownloadsPage(ctx, ListFilter{Group: GroupAll, Limit: 2, Offset: 0})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if total != 3 || len(p1) != 2 {
|
||||
t.Fatalf("page1 len=%d total=%d, want 2/3", len(p1), total)
|
||||
}
|
||||
p2, _, err := st.ListDownloadsPage(ctx, ListFilter{Group: GroupAll, Limit: 2, Offset: 2})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(p2) != 1 {
|
||||
t.Fatalf("page2 len=%d, want 1", len(p2))
|
||||
}
|
||||
// Страницы не пересекаются и покрывают весь список.
|
||||
if p1[0].ID != id3 || p1[1].ID != id2 || p2[0].ID != id1 {
|
||||
t.Fatalf("страницы разъехались: p1=%v p2=%v", ids(p1), ids(p2))
|
||||
}
|
||||
}
|
||||
|
||||
func TestListDownloadsPageTieBreakByID(t *testing.T) {
|
||||
st := newTestStore(t)
|
||||
ctx := context.Background()
|
||||
|
||||
// Одинаковое время добавления у всех → устойчивый порядок по id DESC.
|
||||
same := time.Date(2026, 6, 1, 12, 0, 0, 0, time.UTC)
|
||||
var idList []int64
|
||||
for i := 1; i <= 3; i++ {
|
||||
id := mkDownload(t, st, i, StateDownloading, fmt.Sprintf("d%d", i))
|
||||
if err := st.SetSourceAddedAt(ctx, id, same); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
idList = append(idList, id)
|
||||
}
|
||||
page, _, err := st.ListDownloadsPage(ctx, ListFilter{Group: GroupAll, Limit: 50})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got := ids(page)
|
||||
want := []int64{idList[2], idList[1], idList[0]} // id DESC
|
||||
for i := range want {
|
||||
if got[i] != want[i] {
|
||||
t.Fatalf("tie-break порядок = %v, want %v", got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetSourceAddedAtOnce(t *testing.T) {
|
||||
st := newTestStore(t)
|
||||
ctx := context.Background()
|
||||
|
||||
id := mkDownload(t, st, 1, StateDownloading, "x")
|
||||
first := time.Date(2026, 6, 1, 10, 0, 0, 0, time.UTC)
|
||||
second := time.Date(2026, 6, 2, 10, 0, 0, 0, time.UTC)
|
||||
|
||||
if err := st.SetSourceAddedAt(ctx, id, first); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Повторная запись не должна перезаписать (гард IS NULL).
|
||||
if err := st.SetSourceAddedAt(ctx, id, second); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
d, err := st.GetDownload(ctx, id)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !d.SourceAddedAt.Valid || d.SourceAddedAt.String != FormatTime(first) {
|
||||
t.Fatalf("source_added_at = %q, want %q (первое значение неизменно)",
|
||||
d.SourceAddedAt.String, FormatTime(first))
|
||||
}
|
||||
}
|
||||
|
||||
func TestListDownloadsPageRecTitleFallback(t *testing.T) {
|
||||
st := newTestStore(t)
|
||||
ctx := context.Background()
|
||||
|
||||
// Загрузка без display_name, но с распознанным названием — RecTitle из join.
|
||||
id := mkDownload(t, st, 1, StateReview, "")
|
||||
if _, err := st.CreateRecognition(ctx, &Recognition{
|
||||
DownloadID: id,
|
||||
Title: NullString("Настоящее название"),
|
||||
}, nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
page, _, err := st.ListDownloadsPage(ctx, ListFilter{Group: GroupAll, Limit: 50})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(page) != 1 {
|
||||
t.Fatalf("len = %d", len(page))
|
||||
}
|
||||
if !page[0].RecTitle.Valid || page[0].RecTitle.String != "Настоящее название" {
|
||||
t.Fatalf("RecTitle = %q, want распознанное название", page[0].RecTitle.String)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateDownloadPersistsDisplayName(t *testing.T) {
|
||||
st := newTestStore(t)
|
||||
ctx := context.Background()
|
||||
|
||||
id := mkDownload(t, st, 1, StateDownloading, "Дюна: Часть вторая (2024)")
|
||||
d, err := st.GetDownload(ctx, id)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if d.DisplayName != "Дюна: Часть вторая (2024)" {
|
||||
t.Fatalf("display_name = %q", d.DisplayName)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
-- +goose Up
|
||||
-- Отображаемое имя раздачи (то, что уходит в qBittorrent как rename) — для
|
||||
-- заголовка карточки/страницы в веб-UI. source_added_at — время добавления
|
||||
-- торрента в qBittorrent (added_on), базис сортировки списка; nullable, пока
|
||||
-- воркер не наблюдал раздачу.
|
||||
ALTER TABLE download ADD COLUMN display_name TEXT NOT NULL DEFAULT '';
|
||||
ALTER TABLE download ADD COLUMN source_added_at TEXT;
|
||||
|
||||
-- +goose Down
|
||||
ALTER TABLE download DROP COLUMN source_added_at;
|
||||
ALTER TABLE download DROP COLUMN display_name;
|
||||
@@ -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