UI/UX списка и карточки загрузки: серверные фильтр/поиск/пагинация, матч-ссылка, имя раздачи (web-ui-list-detail)

- Список: серверные фильтр по группе состояний, поиск и пагинация (GET
  f/q/page/all, по 25), сортировка по времени добавления в qBittorrent
  (added_on) с фолбеком на created_at и tie-break по id.
- Заголовок загрузки = имя раздачи (display_name) → распознанное название →
  усечённый источник; сырой magnet вынесен в блок «Информация о торренте».
- Матч метабазы показан ссылкой на запись (страница загрузки и ревью);
  URL берётся у выбранного кандидата либо строится по provider+id и типу.
- Полировка вёрстки; клиентская JS-фильтрация убрана (всё серверное, без JS).
- Миграция 0005 (display_name, source_added_at); воркер однократно
  фиксирует source_added_at при поллинге/усыновлении; ER-схема обновлена.
- OpenSpec: дельты влиты в specs/{web-ui,ingest}, change заархивирован.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
av
2026-07-01 09:50:24 +03:00
co-authored by Claude Opus 4.8
parent 2a9bf7efb0
commit c0b5ab7295
33 changed files with 1679 additions and 110 deletions
+20 -3
View File
@@ -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
View File
@@ -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 {
+182 -2
View File
@@ -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{},
+58
View File
@@ -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)
}
}
-17
View File
@@ -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) {
+3
View File
@@ -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
View File
@@ -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 != "" {