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:
av
2026-07-18 15:41:28 +03:00
co-authored by Claude Opus 4.8
parent 4a58b0bda0
commit 10a6348d39
15 changed files with 614 additions and 123 deletions
+1 -1
View File
@@ -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)
-53
View File
@@ -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)
}
}
+4 -45
View File
@@ -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 != "" {