From c0b5ab7295fc0cfc617d5f4bacc8b621bc4dc366 Mon Sep 17 00:00:00 2001 From: Anton Vakhrushev Date: Wed, 1 Jul 2026 09:50:24 +0300 Subject: [PATCH] =?UTF-8?q?UI/UX=20=D1=81=D0=BF=D0=B8=D1=81=D0=BA=D0=B0=20?= =?UTF-8?q?=D0=B8=20=D0=BA=D0=B0=D1=80=D1=82=D0=BE=D1=87=D0=BA=D0=B8=20?= =?UTF-8?q?=D0=B7=D0=B0=D0=B3=D1=80=D1=83=D0=B7=D0=BA=D0=B8:=20=D1=81?= =?UTF-8?q?=D0=B5=D1=80=D0=B2=D0=B5=D1=80=D0=BD=D1=8B=D0=B5=20=D1=84=D0=B8?= =?UTF-8?q?=D0=BB=D1=8C=D1=82=D1=80/=D0=BF=D0=BE=D0=B8=D1=81=D0=BA/=D0=BF?= =?UTF-8?q?=D0=B0=D0=B3=D0=B8=D0=BD=D0=B0=D1=86=D0=B8=D1=8F,=20=D0=BC?= =?UTF-8?q?=D0=B0=D1=82=D1=87-=D1=81=D1=81=D1=8B=D0=BB=D0=BA=D0=B0,=20?= =?UTF-8?q?=D0=B8=D0=BC=D1=8F=20=D1=80=D0=B0=D0=B7=D0=B4=D0=B0=D1=87=D0=B8?= =?UTF-8?q?=20(web-ui-list-detail)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Список: серверные фильтр по группе состояний, поиск и пагинация (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) --- docs/specs/database.md | 4 +- internal/httpapi/download.go | 23 +- internal/httpapi/httpapi.go | 200 ++++++++++++- internal/httpapi/httpapi_test.go | 184 +++++++++++- internal/httpapi/providerurl_test.go | 58 ++++ internal/httpapi/render.go | 17 -- internal/httpapi/render_test.go | 3 + internal/httpapi/review.go | 43 +++ internal/ingest/ingest.go | 1 + internal/ingest/ingest_test.go | 4 + internal/store/download.go | 41 ++- internal/store/list.go | 113 ++++++++ internal/store/list_test.go | 264 ++++++++++++++++++ .../store/migrations/0005_display_name.sql | 11 + internal/worker/discover.go | 10 + internal/worker/discover_test.go | 29 +- internal/worker/review_test.go | 7 + internal/worker/worker.go | 17 ++ internal/worker/worker_test.go | 11 + .../.openspec.yaml | 2 + .../2026-07-01-web-ui-list-detail/design.md | 218 +++++++++++++++ .../2026-07-01-web-ui-list-detail/proposal.md | 72 +++++ .../specs/ingest/spec.md | 45 +++ .../specs/web-ui/spec.md | 125 +++++++++ .../2026-07-01-web-ui-list-detail/tasks.md | 48 ++++ openspec/specs/ingest/spec.md | 19 +- openspec/specs/web-ui/spec.md | 105 ++++++- web/static/css/jellybit.css | 15 +- web/static/js/app.js | 35 +-- web/templates/download.html | 21 +- web/templates/index.html | 39 +-- web/templates/partials/layout_widget.html | 3 +- web/templates/review.html | 2 +- 33 files changed, 1679 insertions(+), 110 deletions(-) create mode 100644 internal/httpapi/providerurl_test.go create mode 100644 internal/store/list.go create mode 100644 internal/store/list_test.go create mode 100644 internal/store/migrations/0005_display_name.sql create mode 100644 openspec/changes/archive/2026-07-01-web-ui-list-detail/.openspec.yaml create mode 100644 openspec/changes/archive/2026-07-01-web-ui-list-detail/design.md create mode 100644 openspec/changes/archive/2026-07-01-web-ui-list-detail/proposal.md create mode 100644 openspec/changes/archive/2026-07-01-web-ui-list-detail/specs/ingest/spec.md create mode 100644 openspec/changes/archive/2026-07-01-web-ui-list-detail/specs/web-ui/spec.md create mode 100644 openspec/changes/archive/2026-07-01-web-ui-list-detail/tasks.md diff --git a/docs/specs/database.md b/docs/specs/database.md index 8c78576..6d8832d 100644 --- a/docs/specs/database.md +++ b/docs/specs/database.md @@ -10,7 +10,7 @@ > документации. > > Состояние на: миграции `0001_init`, `0002_recognition_plan`, -> `0003_source_miss_count`. +> `0003_source_miss_count`, `0004_candidate_url`, `0005_display_name`. Назначение таблиц и почему так — [architecture.md](architecture.md) → «Хранилище». Значения `state` и переходы — [workflow.md](workflow.md). @@ -29,6 +29,7 @@ erDiagram INTEGER id PK "AUTOINCREMENT" TEXT source_type "NOT NULL; magnet|torrent|url" TEXT source_ref "NOT NULL; magnet/url/путь" + TEXT display_name "NOT NULL DEFAULT ''; имя раздачи (rename qBittorrent), заголовок в UI (миграция 0005)" TEXT context "NOT NULL DEFAULT ''" TEXT infohash "nullable; может появиться позже приёма" TEXT idempotency_key "nullable; UNIQUE если NOT NULL" @@ -36,6 +37,7 @@ erDiagram TEXT error_code "nullable" TEXT error_msg "nullable" INTEGER source_miss_count "NOT NULL DEFAULT 0; дебаунс пропажи источника (миграция 0003)" + TEXT source_added_at "nullable; время добавления в qBittorrent (added_on), базис сортировки (миграция 0005)" TEXT created_at "NOT NULL DEFAULT datetime('now')" TEXT updated_at "NOT NULL DEFAULT datetime('now')" } diff --git a/internal/httpapi/download.go b/internal/httpapi/download.go index 72b1bf6..c0b8436 100644 --- a/internal/httpapi/download.go +++ b/internal/httpapi/download.go @@ -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) diff --git a/internal/httpapi/httpapi.go b/internal/httpapi/httpapi.go index b40c006..6271916 100644 --- a/internal/httpapi/httpapi.go +++ b/internal/httpapi/httpapi.go @@ -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 { diff --git a/internal/httpapi/httpapi_test.go b/internal/httpapi/httpapi_test.go index 501cff7..a77fd1a 100644 --- a/internal/httpapi/httpapi_test.go +++ b/internal/httpapi/httpapi_test.go @@ -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{}, diff --git a/internal/httpapi/providerurl_test.go b/internal/httpapi/providerurl_test.go new file mode 100644 index 0000000..136a519 --- /dev/null +++ b/internal/httpapi/providerurl_test.go @@ -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) + } +} diff --git a/internal/httpapi/render.go b/internal/httpapi/render.go index 5f0028c..1e76c32 100644 --- a/internal/httpapi/render.go +++ b/internal/httpapi/render.go @@ -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) { diff --git a/internal/httpapi/render_test.go b/internal/httpapi/render_test.go index ffc5b52..f36287c 100644 --- a/internal/httpapi/render_test.go +++ b/internal/httpapi/render_test.go @@ -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 diff --git a/internal/httpapi/review.go b/internal/httpapi/review.go index 712601f..f202d8b 100644 --- a/internal/httpapi/review.go +++ b/internal/httpapi/review.go @@ -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 != "" { diff --git a/internal/ingest/ingest.go b/internal/ingest/ingest.go index f4898be..d656746 100644 --- a/internal/ingest/ingest.go +++ b/internal/ingest/ingest.go @@ -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), diff --git a/internal/ingest/ingest_test.go b/internal/ingest/ingest_test.go index 03ea537..f7ed784 100644 --- a/internal/ingest/ingest_test.go +++ b/internal/ingest/ingest_test.go @@ -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) { diff --git a/internal/store/download.go b/internal/store/download.go index 1906ceb..1712a8c 100644 --- a/internal/store/download.go +++ b/internal/store/download.go @@ -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 diff --git a/internal/store/list.go b/internal/store/list.go new file mode 100644 index 0000000..8f00742 --- /dev/null +++ b/internal/store/list.go @@ -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 +} diff --git a/internal/store/list_test.go b/internal/store/list_test.go new file mode 100644 index 0000000..55a7a2d --- /dev/null +++ b/internal/store/list_test.go @@ -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) + } +} diff --git a/internal/store/migrations/0005_display_name.sql b/internal/store/migrations/0005_display_name.sql new file mode 100644 index 0000000..ee5fa50 --- /dev/null +++ b/internal/store/migrations/0005_display_name.sql @@ -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; diff --git a/internal/worker/discover.go b/internal/worker/discover.go index 1504aa6..ac47fe3 100644 --- a/internal/worker/discover.go +++ b/internal/worker/discover.go @@ -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) diff --git a/internal/worker/discover_test.go b/internal/worker/discover_test.go index 60a8c16..6082195 100644 --- a/internal/worker/discover_test.go +++ b/internal/worker/discover_test.go @@ -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{ diff --git a/internal/worker/review_test.go b/internal/worker/review_test.go index beeded2..3e12325 100644 --- a/internal/worker/review_test.go +++ b/internal/worker/review_test.go @@ -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 { diff --git a/internal/worker/worker.go b/internal/worker/worker.go index 26776a3..c825b04 100644 --- a/internal/worker/worker.go +++ b/internal/worker/worker.go @@ -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. diff --git a/internal/worker/worker_test.go b/internal/worker/worker_test.go index d8a2c00..15e0931 100644 --- a/internal/worker/worker_test.go +++ b/internal/worker/worker_test.go @@ -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) { diff --git a/openspec/changes/archive/2026-07-01-web-ui-list-detail/.openspec.yaml b/openspec/changes/archive/2026-07-01-web-ui-list-detail/.openspec.yaml new file mode 100644 index 0000000..e7cc357 --- /dev/null +++ b/openspec/changes/archive/2026-07-01-web-ui-list-detail/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-07-01 diff --git a/openspec/changes/archive/2026-07-01-web-ui-list-detail/design.md b/openspec/changes/archive/2026-07-01-web-ui-list-detail/design.md new file mode 100644 index 0000000..b43207a --- /dev/null +++ b/openspec/changes/archive/2026-07-01-web-ui-list-detail/design.md @@ -0,0 +1,218 @@ +## Context + +Современный server-rendered веб-UI уже даёт список-карточки, страницу просмотра +`/download/{id}`, ревью и живой прогресс (htmx-поллинг снимка воркера). Осталось +закрыть хвосты беклога по UI/UX. Текущее состояние по пунктам: + +- **Список** (`handleIndex` + `index.html`): грузит `ListDownloads` целиком, + фильтр-чипы и поиск — клиентский JS по `data-group`/`data-text` уже + отрендеренных карточек, `deleted` прячется чекбоксом `showAll`. Пагинации нет. +- **Заголовок** карточки и шапки страницы — `d.SourceRef` (для magnet это сырая + ссылка). Красивое имя есть только в блоке «Распознано как» и только после + распознавания (`rd.Plan.Title`). Имя `rename`, уходящее в qBittorrent при + приёме (`internal/ingest`, `Namer.DeriveName`), нигде не сохраняется. +- **Матч метабазы**: на ревью кандидаты уже показываются со ссылкой + (`candidate_url`, миграция 0004); но подтверждённый выбор («Выбрано: provider + id») и блок «Распознано как» на `/download/{id}` ссылки не имеют. +- **Полировка**: в «Распознано как» список полей ограничен `max-width:360px`; в + layout-виджете стрелка не приклеена к строке файла-источника. + +Ограничения: один статический бинарь, без сборки фронта и реактивных фреймворков; +чтение — через `store`, тонкий транспорт `httpapi`; SQLite (одиночное соединение, +сериализация записи). Целевой масштаб — сотни–тысячи строк `download`. + +## Goals / Non-Goals + +**Goals:** + +- Список масштабируется на рост БД: серверные фильтр по состоянию, поиск и + пагинация через GET-параметры, работающие без JS и шарящиеся ссылкой. +- Заголовок загрузки — человекочитаемое имя раздачи qBittorrent, доступное с + первого кадра (до распознавания), с внятным фолбеком. +- Подтверждённый матч метабазы виден ссылкой на запись во всех веб-местах + (карточка просмотра, «Источник совпадения» ревью). +- Сырой источник/magnet не мозолит глаза в заголовке — вынесен в отдельный блок. +- Мелкие дефекты вёрстки устранены. + +**Non-Goals:** + +- Полный лог переходов состояний (нужна таблица истории — отдельная задача). +- Собственный идентификатор загрузки, слияние по infohash. +- Полноценный «выбор источника матча с предпросмотром полей» (отдельная задача + беклога); здесь — только ссылка на уже подтверждённый матч. +- Показ матча в Telegram, перевод живых обновлений на SSE. + +## Decisions + +### 1. Список: серверные фильтр/поиск/пагинация через GET-параметры + +Параметры: `f` (группа состояний: `all|review|active|done|problem`, по умолчанию +`all` без `deleted`), `q` (строка поиска), `page` (1-based), плюс `all=1` +(показать в т.ч. `deleted`). Размер страницы — константа в `httpapi` (**25**). + +Новый метод `store` возвращает страницу + общее число строк под фильтром для +рендера пагинации: например `ListDownloadsPage(ctx, ListFilter) ([]Download, +int, error)`, где `ListFilter{Group, Query, Deleted, Limit, Offset}`. В SQL: +`WHERE` по множеству состояний группы (маппинг из существующего `stateGroup`, +вынести в общий источник истины состояние↔группа), `deleted` включается только +при `all`; поиск — `LIKE '%'||?||'%'` по `source_ref`, `display_name`, `context`, +`infohash` (регистронезависимо, `COLLATE NOCASE`); сортировка +`ORDER BY COALESCE(source_added_at, created_at) DESC, id DESC` (см. решение 5); +`LIMIT/OFFSET`. Итог count — отдельный `COUNT(*)` с тем же `WHERE`. + +`page` клэмпится: `< 1`/нечисловой → `1`; за последней страницей → пустая +страница (не ошибка). Метасимволы `%`/`_` в `q` не экранируем (параметризованный +`LIKE`, не security-issue; при необходимости добавим `ESCAPE` позже). +Пустое состояние (`len(page)==0`) рендерит сервер: «ничего не найдено», если +активны фильтр/поиск, иначе «пока пусто» — клиентского JS для этого больше нет. + +**Почему GET, а не htmx/JSON:** закладки/шаринг, работа без JS, минимум кода; +согласуется с «клиентская логика без сборки». Пагинация и чипы — обычные ссылки, +сохраняющие текущие `f`/`q`. Клиентская JS-фильтрация удаляется (заменена +серверной), копирование infohash и `
` остаются. + +**Живой прогресс:** карточки текущей страницы по-прежнему сами поллят свой +фрагмент прогресса — механика `live-status` не затрагивается. Форма поиска — +обычный GET-submit; фильтр-чипы — ссылки. + +Альтернатива (клиентская пагинация или «Только пагинация» с клиентским +фильтром) отвергнута: фильтрует лишь текущую страницу и вводит в заблуждение. + +### 2. Персистентность отображаемого имени (`display_name`) + +Миграция `0005`: `ALTER TABLE download ADD COLUMN display_name TEXT NOT NULL +DEFAULT ''`. При приёме (`internal/ingest`) выведенное имя `rename` пишется и в +`download.display_name` (та же строка, что уходит в qBittorrent). Инвариант +ingest-спеки сохраняется: имя не влияет на пути/распознавание/раскладку. + +Для **усыновлённых** торрентов (`discover.adopt()`) приёма через `ingest` нет и +`rename` не выводится; там `SourceRef` — искусственный `magnet:?xt=urn:btih:…`. +Чтобы заголовок таких задач не был голым btih, при усыновлении пишем +`display_name = t.Name` (имя торрента из qBittorrent). + +Заголовок в веб-UI (карточка списка и шапка `/download/{id}`) — фолбек-цепочка: + +1. `display_name` (если непустой); +2. распознанное `rd.Plan.Title` (если есть план); +3. сырой источник (`source_ref`), **усечённый в одну строку** как обычный + заголовок. + +Старые строки БД получат `display_name=''` → отработает п.2/п.3, регресса нет. + +**Почему колонка, а не вывод на лету:** `rename` выводится единожды при приёме +(в т.ч. через LLM) и в БД сейчас не сохраняется; пересчитывать на каждый рендер +дорого и недетерминированно. Хранение — дёшево и даёт стабильный заголовок с +первого кадра. + +### 3. Ссылка на запись метабазы + +Приоритет — URL уже сохранённого выбранного кандидата (`candidate_url`, +миграция 0004): в `ReviewData` есть `Candidates` с флагом `Chosen` и `URL` +(детальная страница их грузит — `handleDownload` → `ReviewData`, проверено). + +**Важно:** URL выбранного кандидата берём только если его `provider`+`id` +совпадают с **эффективными** `rd.Provider`/`rd.ProviderID` (пользователь мог +выбрать кандидата, а затем вручную переопределить `provider_id` — тогда +`Chosen`-кандидат указывает на другую запись). Если не совпадают или URL у +кандидата нет — строим канонический URL функцией `providerURL(provider, id, +mediaType)`. + +`providerURL` учитывает тип медиа: для `tmdb` — `/movie/{id}` vs `/tv/{id}` +(см. `internal/metadata/tmdb.go`), для `tvdb` — медиазависимый путь (см. +`internal/metadata/tvdb.go`), для `imdb` — `/title/{id}`. Тип на детальной +странице известен (`view.IsSeries`/`rd.Plan.Type`). Если для провайдера/типа +надёжный URL не построить — показываем матч текстом (провайдер, id) без ссылки +(предусмотрено сценарием «URL записи неизвестен»). + +Ссылка показывается в «Распознано как» на `/download/{id}` и в строке «Выбрано» +блока «Источник совпадения» ревью. Ссылки — `target=_blank rel=noopener` (как у +кандидатов). + +### 4. Блок «Информация о торренте» на `/download/{id}` + +Отдельная секция: тип источника, полный `source_ref`/magnet и `infohash` с +кнопкой копирования. Разгружает заголовок (см. решение 2) и собирает «сырьё» в +одном месте. + +### 5. Сортировка списка по времени добавления в источник + +Список сортируется по времени добавления торрента в qBittorrent (`added_on`), с +фолбеком на время создания загрузки в jellybit (`created_at`). Причина: jellybit +может **захватывать уже добавленные** (усыновлённые) торренты, поэтому +`created_at` не отражает реальный порядок появления раздачи; `added_on` — более +верный базис (тем же соображением воркер уже считает возраст задачи от +`added_on`, см. `worker.torrentAge`). + +`added_on` не хранится в БД — воркер берёт его из живого `qbt.Torrent`. Добавляем +колонку `download.source_added_at` (nullable) и **один раз** персистим её из +воркера. Значение неизменно (время добавления не меняется), пишем только при +первом наблюдении. + +**Формат (критично для сортировки):** `source_added_at` хранится байт-в-байт в +том же формате, что `created_at` — `YYYY-MM-DD HH:MM:SS` в UTC +(`store.sqliteTimeLayout`). Воркер форматирует +`time.Unix(t.AddedOn,0).UTC().Format(...)` через хелпер `store` (не RFC3339 и не +число), иначе лексикографическое сравнение TEXT в `COALESCE(...)` даст неверный +порядок при смешивании заполненных и фолбек-строк. + +**Точка записи:** в цикле `Poll` сразу после успешного сопоставления активной +задачи с торрентом по `byHash` (до `reconcile`), для любого класса состояния +торрента — иначе задача, увиденная тиком уже готовой (`classReady` → сразу +`transition`), запись пропустит. Для усыновлённых торрентов `added_on` доступен +уже в `adopt()` (`discover.go`) — пишем и там. Всё под `w.mu`. Идемпотентность: +проверка `d.SourceAddedAt` в Go + SQL-гард `WHERE source_added_at IS NULL` в +методе стора (не писать на каждом тике). + +**Сортировка** в запросе списка — `ORDER BY COALESCE(source_added_at, +created_at) DESC, id DESC`. Вторичный ключ `id DESC` обязателен: без него при +равных метках времени (пакетное добавление в одну секунду) SQLite даёт +неустойчивый порядок, и при `LIMIT/OFFSET` строки задваиваются/пропадают между +страницами — это нарушило бы требование «порядок согласован между страницами». + +Альтернатива (сортировка по `id DESC` или только `created_at`) отвергнута: +порядок разъедется для усыновлённых торрентов. + +### 6. Полировка вёрстки (CSS/шаблоны) + +- «Распознано как»: снять `max-width:360px` у `dl.kv` — блок на всю ширину. +- Layout-виджет: приклеить стрелку к строке файла-источника (источник и цель + строго друг под другом) — правка `layout_widget.html`/CSS. + +Инвариант дизайн-системы соблюдаем: без инлайн-`