tgbot: показывать запись матча метабазы (провайдер+id+ссылка)
В карточке подтверждения и уведомлении о готовности бот теперь показывает
запись матча метабазы — провайдер, id и кликабельную ссылку на страницу
записи, — как веб-страница /download/{id} и экран ревью. Так ошибочную
привязку видно и из Telegram.
Билдер URL записи (providerURL/matchURL) вынесен из internal/httpapi в ядро
internal/worker (worker.ProviderURL + метод (*ReviewData).MatchURL()), чтобы
оба транспорта строили ссылку одинаково; httpapi делегирует туда. baseLine
переведён на эффективные provider/id (с учётом ручных правок), URL в href
экранируется escHref (сверх esc закрывает кавычку — иначе изготовленный id
разорвал бы атрибут и Telegram отклонил бы сообщение). При отсутствии матча
карточка ревью показывает «нет матча», уведомление о готовности строку
опускает.
Capability notifications: ADDED «Показ записи матча метабазы» + MODIFIED
требования об экранировании (id матча и URL, контекст href).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -160,7 +160,7 @@ func (s *server) buildDownloadView(id string, rd *worker.ReviewData) downloadDet
|
||||
default:
|
||||
view.Provider = rd.Provider
|
||||
view.ProviderID = rd.ProviderID
|
||||
view.MatchURL = matchURL(rd, view.MediaType)
|
||||
view.MatchURL = rd.MatchURL()
|
||||
}
|
||||
if rd.Recognition.Confidence.Valid {
|
||||
view.Confidence = strconv.FormatFloat(rd.Recognition.Confidence.Float64, 'f', 2, 64)
|
||||
|
||||
@@ -2,36 +2,8 @@ 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseManualSource(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
@@ -70,28 +42,3 @@ func TestParseManualSource(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -135,7 +135,7 @@ func buildReviewView(id string, rd *worker.ReviewData, errMsg string) reviewView
|
||||
default:
|
||||
view.Provider = rd.Provider
|
||||
view.ProviderID = rd.ProviderID
|
||||
view.MatchURL = matchURL(rd, string(rd.Plan.Type))
|
||||
view.MatchURL = rd.MatchURL()
|
||||
}
|
||||
if rec.Confidence.Valid {
|
||||
view.Confidence = strconv.FormatFloat(rec.Confidence.Float64, 'f', 2, 64)
|
||||
@@ -294,7 +294,7 @@ func looksLikeURL(s string) bool {
|
||||
strings.Contains(s, ".org/") || strings.Contains(s, ".com/")
|
||||
}
|
||||
|
||||
// parseProviderURL — обратная к providerURL: URL записи → (provider, id).
|
||||
// parseProviderURL — обратная к worker.ProviderURL: URL записи → (provider, id).
|
||||
// TMDB/IMDb извлекаются из URL; TVDB — только dereferrer с числовым id (URL
|
||||
// сайта thetvdb.com/series/{slug} числового id не содержит → не распознаём).
|
||||
func parseProviderURL(raw string) (provider, id string, ok bool) {
|
||||
@@ -336,12 +336,12 @@ func leadingDigits(s string) string {
|
||||
}
|
||||
|
||||
// sourceMatchURL — ссылка на запись источника-кандидата: URL кандидата, если
|
||||
// есть, иначе канонический URL из provider/id (обратный порядок к matchURL).
|
||||
// есть, иначе канонический URL из provider/id (обратный порядок к MatchURL).
|
||||
func sourceMatchURL(src worker.SourceOption) string {
|
||||
if src.URL != "" {
|
||||
return src.URL
|
||||
}
|
||||
return providerURL(src.Provider, src.ProviderID, src.Type)
|
||||
return worker.ProviderURL(src.Provider, src.ProviderID, src.Type)
|
||||
}
|
||||
|
||||
func (s *server) handleDefer(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -503,47 +503,6 @@ func (s *server) reviewBlockAction(w http.ResponseWriter, r *http.Request, fn fu
|
||||
s.render(w, "review_source_swap", view)
|
||||
}
|
||||
|
||||
// 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 string, msg string) {
|
||||
u := "/review/" + id
|
||||
if msg != "" {
|
||||
|
||||
@@ -127,6 +127,9 @@ func reviewData(state store.State) *worker.ReviewData {
|
||||
Provider: store.NullString("tvdb"), ProviderID: store.NullString("269613"),
|
||||
Reasons: `["неполный пак"]`,
|
||||
},
|
||||
// Эффективные provider/id (как заполняет effectiveProvider) — их и
|
||||
// показывают уведомления/карточка, консистентно с веб-страницей.
|
||||
Provider: "tvdb", ProviderID: "269613",
|
||||
Plan: recognize.Plan{
|
||||
Type: recognize.MediaSeries, Title: "Фарго", Year: 2015,
|
||||
Files: []recognize.PlanFile{{Src: "e1.mkv", Role: recognize.RoleEpisode, Season: &s, Episode: &e}},
|
||||
|
||||
+42
-12
@@ -20,6 +20,15 @@ import (
|
||||
// `<` на битую разметку.
|
||||
func esc(s string) string { return tgbotapi.EscapeText(tgbotapi.ModeHTML, s) }
|
||||
|
||||
// escHref экранирует URL для вставки в значение атрибута `href`. Контекст
|
||||
// атрибута строже текстового: esc (EscapeText) закрывает `<`/`>`/`&`, но НЕ
|
||||
// трогает `"`, а недоверенный id уходит в URL сырым (см. worker.ProviderURL) —
|
||||
// кавычка в id разорвала бы атрибут и Telegram отклонил бы сообщение (parse
|
||||
// error → уведомление не доставится). Поэтому поверх esc заменяем `"` на
|
||||
// `"`; порядок безопасен: esc уже перевёл `&` в `&`, повторно `&` в
|
||||
// `"` не удвоится.
|
||||
func escHref(url string) string { return strings.ReplaceAll(esc(url), `"`, """) }
|
||||
|
||||
// idCode оборачивает download id в моноширинный <code> — в клиентах Telegram по
|
||||
// нему работает tap-to-copy (скопировать id для /download/{id} или диагностики).
|
||||
// Визуальный префикс (`#` / `download_id=`) держим ВНЕ code, чтобы копировался
|
||||
@@ -69,8 +78,11 @@ func (b *Bot) reviewCard(rd *worker.ReviewData) (string, *tgbotapi.InlineKeyboar
|
||||
// guessLine/baseLine возвращают уже экранированный текст (внешние title/provider
|
||||
// внутри) — повторно не экранируем.
|
||||
fmt.Fprintf(&sb, "Похоже на: %s\n", guessLine(rd))
|
||||
if base := baseLine(rd.Recognition); base != "" {
|
||||
// В ревью показываем «нет матча» явно — полезно видеть, что база не выбрана.
|
||||
if base := baseLine(rd); base != "" {
|
||||
fmt.Fprintf(&sb, "База: %s\n", base)
|
||||
} else {
|
||||
sb.WriteString("База: нет матча\n")
|
||||
}
|
||||
if reasons := rd.Recognition.ReasonList(); len(reasons) > 0 {
|
||||
fmt.Fprintf(&sb, "Причины: %s\n", esc(strings.Join(reasons, " · ")))
|
||||
@@ -127,14 +139,22 @@ func titleLabel(rd *worker.ReviewData) string {
|
||||
return "#" + idCode(rd.Download.ID)
|
||||
}
|
||||
|
||||
// renderDone — короткое сообщение о готовности.
|
||||
// renderDone — короткое сообщение о готовности. Дополняем строкой матча (база +
|
||||
// ссылка), чтобы ошибочную привязку было видно и в финальном пинге; без матча
|
||||
// строку опускаем — в готовности «нет матча» лишний шум.
|
||||
func (b *Bot) renderDone(rd *worker.ReviewData) string {
|
||||
label := titleLabel(rd)
|
||||
var sb strings.Builder
|
||||
n := len(rd.Preview)
|
||||
if n == 0 {
|
||||
return fmt.Sprintf("✅ Готово: %s разложен.", label)
|
||||
fmt.Fprintf(&sb, "✅ Готово: %s разложен.", label)
|
||||
} else {
|
||||
fmt.Fprintf(&sb, "✅ Готово: %s — разложено файлов: %d.", label, n)
|
||||
}
|
||||
return fmt.Sprintf("✅ Готово: %s — разложено файлов: %d.", label, n)
|
||||
if base := baseLine(rd); base != "" {
|
||||
fmt.Fprintf(&sb, "\nБаза: %s", base)
|
||||
}
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
// renderDesync — уведомление о рассинхроне (источник/цель удалены вручную).
|
||||
@@ -267,16 +287,26 @@ func guessLine(rd *worker.ReviewData) string {
|
||||
return s
|
||||
}
|
||||
|
||||
// baseLine возвращает уже экранированный текст (provider/provider_id — внешние,
|
||||
// из метабазы/LLM): вызывающий вставляет как есть, без повторного esc.
|
||||
func baseLine(rec *store.Recognition) string {
|
||||
if rec == nil || !rec.Provider.Valid || rec.Provider.String == "" || rec.Provider.String == "none" {
|
||||
return "нет матча"
|
||||
// baseLine — запись матча метабазы для уведомлений: provider и id (эффективные,
|
||||
// с учётом ручных правок — как на веб-странице загрузки), при возможности
|
||||
// построить URL — ссылкой на страницу записи (тот же билдер, что и веб:
|
||||
// worker.ReviewData.MatchURL). Возвращает уже экранированный HTML (provider/id —
|
||||
// внешние, URL — в контексте href): вызывающий вставляет как есть. Пусто, если
|
||||
// матча нет (провайдер пуст/none) — поверхность сама решает, показывать ли
|
||||
// индикатор «нет матча».
|
||||
func baseLine(rd *worker.ReviewData) string {
|
||||
prov := rd.Provider
|
||||
if prov == "" || prov == "none" {
|
||||
return ""
|
||||
}
|
||||
if rec.ProviderID.Valid && rec.ProviderID.String != "" {
|
||||
return esc(rec.Provider.String) + " " + esc(rec.ProviderID.String)
|
||||
label := esc(prov)
|
||||
if rd.ProviderID != "" {
|
||||
label += " " + esc(rd.ProviderID)
|
||||
}
|
||||
return esc(rec.Provider.String)
|
||||
if url := rd.MatchURL(); url != "" {
|
||||
return fmt.Sprintf(`<a href="%s">%s ↗</a>`, escHref(url), label)
|
||||
}
|
||||
return label
|
||||
}
|
||||
|
||||
func contextOrSource(rd *worker.ReviewData) string {
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
package tgbot
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.vakhrushev.me/av/jellybit/internal/store"
|
||||
)
|
||||
|
||||
// Карточка ревью показывает запись матча ссылкой на страницу записи (тот же
|
||||
// билдер URL, что и веб): provider+id внутри <a href>, стрелка ↗, экранировано.
|
||||
func TestReviewCard_ShowsMatchLink(t *testing.T) {
|
||||
b, _, _, _ := newTestBot(t, []int64{7})
|
||||
rd := reviewData(store.StateReview) // tvdb/269613, сериал
|
||||
text, _ := b.renderCard(rd)
|
||||
|
||||
want := `<a href="https://www.thetvdb.com/dereferrer/series/269613">tvdb 269613 ↗</a>`
|
||||
if !strings.Contains(text, "База: "+want) {
|
||||
t.Errorf("нет ссылки матча в карточке ревью:\n%s", text)
|
||||
}
|
||||
}
|
||||
|
||||
// Запись матча берёт эффективные provider/id (с учётом ручных правок), а не
|
||||
// сырое распознавание — консистентно с веб-страницей загрузки.
|
||||
func TestReviewCard_UsesEffectiveProvider(t *testing.T) {
|
||||
b, _, _, _ := newTestBot(t, []int64{7})
|
||||
rd := reviewData(store.StateReview)
|
||||
// Сырое распознавание — одно, эффективный выбор (ручная правка) — другое.
|
||||
rd.Recognition.Provider = store.NullString("tvdb")
|
||||
rd.Recognition.ProviderID = store.NullString("269613")
|
||||
rd.Provider, rd.ProviderID = "imdb", "tt0111161"
|
||||
text, _ := b.renderCard(rd)
|
||||
|
||||
if !strings.Contains(text, "imdb tt0111161") {
|
||||
t.Errorf("должен показываться эффективный провайдер:\n%s", text)
|
||||
}
|
||||
if strings.Contains(text, "tvdb") || strings.Contains(text, "269613") {
|
||||
t.Errorf("сырое распознавание не должно просачиваться:\n%s", text)
|
||||
}
|
||||
}
|
||||
|
||||
// Матч без строящегося URL (неизвестный провайдер) — текстом provider id, без
|
||||
// ссылки.
|
||||
func TestReviewCard_MatchWithoutURLAsText(t *testing.T) {
|
||||
b, _, _, _ := newTestBot(t, []int64{7})
|
||||
rd := reviewData(store.StateReview)
|
||||
rd.Provider, rd.ProviderID = "kinopoisk", "42"
|
||||
text, _ := b.renderCard(rd)
|
||||
|
||||
if !strings.Contains(text, "База: kinopoisk 42") {
|
||||
t.Errorf("матч без URL должен быть текстом:\n%s", text)
|
||||
}
|
||||
if strings.Contains(text, "<a href") {
|
||||
t.Errorf("для несобираемого URL ссылки быть не должно:\n%s", text)
|
||||
}
|
||||
}
|
||||
|
||||
// Нет матча: карточка ревью показывает индикатор «нет матча».
|
||||
func TestReviewCard_NoMatchIndicator(t *testing.T) {
|
||||
b, _, _, _ := newTestBot(t, []int64{7})
|
||||
rd := reviewData(store.StateReview)
|
||||
rd.Provider, rd.ProviderID = "none", ""
|
||||
text, _ := b.renderCard(rd)
|
||||
|
||||
if !strings.Contains(text, "База: нет матча") {
|
||||
t.Errorf("нет индикатора «нет матча»:\n%s", text)
|
||||
}
|
||||
}
|
||||
|
||||
// Уведомление о готовности содержит запись матча (база + ссылка) — чтобы
|
||||
// ошибочную привязку было видно после раскладки.
|
||||
func TestRenderDone_ShowsMatch(t *testing.T) {
|
||||
b, _, _, _ := newTestBot(t, []int64{7})
|
||||
rd := reviewData(store.StateDone)
|
||||
text := b.renderDone(rd)
|
||||
|
||||
if !strings.Contains(text, "Готово") {
|
||||
t.Fatalf("не сообщение о готовности:\n%s", text)
|
||||
}
|
||||
if !strings.Contains(text, `База: <a href="https://www.thetvdb.com/dereferrer/series/269613">tvdb 269613 ↗</a>`) {
|
||||
t.Errorf("нет строки матча в готовности:\n%s", text)
|
||||
}
|
||||
}
|
||||
|
||||
// Без матча уведомление о готовности строку матча опускает (в финальном пинге
|
||||
// «нет матча» — шум).
|
||||
func TestRenderDone_OmitsBaseWithoutMatch(t *testing.T) {
|
||||
b, _, _, _ := newTestBot(t, []int64{7})
|
||||
rd := reviewData(store.StateDone)
|
||||
rd.Provider, rd.ProviderID = "none", ""
|
||||
text := b.renderDone(rd)
|
||||
|
||||
if strings.Contains(text, "База") {
|
||||
t.Errorf("без матча строки «База» в готовности быть не должно:\n%s", text)
|
||||
}
|
||||
}
|
||||
|
||||
// Кавычка в id (а значит в URL) экранируется в значении href ("), атрибут
|
||||
// остаётся целым — иначе Telegram отклонил бы сообщение (parse error).
|
||||
func TestReviewCard_QuoteInURLEscaped(t *testing.T) {
|
||||
b, _, _, _ := newTestBot(t, []int64{7})
|
||||
rd := reviewData(store.StateReview)
|
||||
rd.Provider, rd.ProviderID = "imdb", `tt1"onmouseover=x`
|
||||
text, _ := b.renderCard(rd)
|
||||
|
||||
if strings.Contains(text, `="x`) || !strings.Contains(text, """) {
|
||||
t.Errorf("кавычка в href не экранирована:\n%s", text)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
package worker
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"git.vakhrushev.me/av/jellybit/internal/recognize"
|
||||
"git.vakhrushev.me/av/jellybit/internal/store"
|
||||
)
|
||||
|
||||
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 := &ReviewData{Provider: "kinopoisk", ProviderID: "42"}
|
||||
if got := rd.MatchURL(); got != "" {
|
||||
t.Errorf("MatchURL = %q, want пусто (URL не строится, кандидата нет)", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestMatchURLCanonicalFromEffective — без кандидатов ссылка строится по
|
||||
// эффективным provider/id и типу медиа плана.
|
||||
func TestMatchURLCanonicalFromEffective(t *testing.T) {
|
||||
rd := &ReviewData{
|
||||
Provider: "tmdb", ProviderID: "60622",
|
||||
Plan: recognize.Plan{Type: recognize.MediaSeries},
|
||||
}
|
||||
if got := rd.MatchURL(); got != "https://www.themoviedb.org/tv/60622" {
|
||||
t.Errorf("MatchURL = %q, want канонический URL по типу плана", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestMatchURLPrefersMatchingCandidate — URL берётся у выбранного кандидата,
|
||||
// когда его provider+id совпадают с эффективными.
|
||||
func TestMatchURLPrefersMatchingCandidate(t *testing.T) {
|
||||
rd := &ReviewData{
|
||||
Provider: "tmdb", ProviderID: "693134",
|
||||
Candidates: []store.MetadataCandidate{{
|
||||
Provider: "tmdb", ProviderID: "693134", Chosen: true,
|
||||
URL: store.NullString("https://custom.example/x"),
|
||||
}},
|
||||
}
|
||||
if got := rd.MatchURL(); got != "https://custom.example/x" {
|
||||
t.Errorf("MatchURL = %q, want URL выбранного кандидата", got)
|
||||
}
|
||||
}
|
||||
@@ -957,6 +957,49 @@ type ReviewData struct {
|
||||
Overrides map[string]string
|
||||
}
|
||||
|
||||
// MatchURL — ссылка на подтверждённую запись метабазы для этой загрузки. Общий
|
||||
// билдер для всех транспортов (веб и Telegram строят ссылку одинаково). Приоритет
|
||||
// — URL выбранного кандидата, но только если его provider+id совпадают с
|
||||
// эффективными (человек мог выбрать кандидата, затем вручную переопределить id —
|
||||
// тогда кандидат указывает на другую запись). Иначе строим канонический URL по
|
||||
// эффективным provider/id и типу медиа плана; если не удаётся — пусто (транспорт
|
||||
// покажет матч текстом).
|
||||
func (rd *ReviewData) MatchURL() 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, string(rd.Plan.Type))
|
||||
}
|
||||
|
||||
// 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 ""
|
||||
}
|
||||
}
|
||||
|
||||
// SourceKind — вид источника в едином списке ревью.
|
||||
type SourceKind string
|
||||
|
||||
|
||||
Reference in New Issue
Block a user