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
View File
@@ -46,7 +46,6 @@ Tududi (проект `jellybit`) больше **не** держит беклог
- [[идея] Многоступенчатая верификация привязки](mnogostupenchataya-verifikaciya.md) — ИДЕЯ (требует проработки) - [[идея] Многоступенчатая верификация привязки](mnogostupenchataya-verifikaciya.md) — ИДЕЯ (требует проработки)
- [Согласование канона нумерации серий с провайдером тега](kanon-numeracii-vs-provajder.md) — Косметика/редкость: порядок просмотра ок, но у тайтлов со спорным порядком (Бибоп) Jellyfin подтягивает не те подписи серий, если канон файлов ≠ дефолтный порядок провайдера тега - [Согласование канона нумерации серий с провайдером тега](kanon-numeracii-vs-provajder.md) — Косметика/редкость: порядок просмотра ок, но у тайтлов со спорным порядком (Бибоп) Jellyfin подтягивает не те подписи серий, если канон файлов ≠ дефолтный порядок провайдера тега
- [Выбор из нескольких находок метабазы в Telegram](telegram-vybor-nahodok.md) — Когда распознавание даёт несколько подходящих кандидатов в метабазе, предлагать их в… - [Выбор из нескольких находок метабазы в Telegram](telegram-vybor-nahodok.md) — Когда распознавание даёт несколько подходящих кандидатов в метабазе, предлагать их в…
- [Улучшения UI: показывать матч с записью метабазы в Telegram](telegram-match-metabazy.md) — Название/год/провайдер+id в боте уже выводятся; осталась кликабельная ссылка на запись
- [Добавление торрентов файлом/ссылкой — «единое окно» (остаток: URL)](dobavlenie-edinoe-okno.md) — magnet и .torrent-файл приняты; остался фетч .torrent по URL (нужен SSRF-гард) - [Добавление торрентов файлом/ссылкой — «единое окно» (остаток: URL)](dobavlenie-edinoe-okno.md) — magnet и .torrent-файл приняты; остался фетч .torrent по URL (нужен SSRF-гард)
- [Раздачи-копии диска (DVD/BluRay: VIDEO_TS/BDMV)](disk-kopii-video-ts-bdmv.md) — Иногда для очень редких фильмов скачивается не один видеофайл, а полная копия диска… - [Раздачи-копии диска (DVD/BluRay: VIDEO_TS/BDMV)](disk-kopii-video-ts-bdmv.md) — Иногда для очень редких фильмов скачивается не один видеофайл, а полная копия диска…
- [Проверка свободного места перед copy-fallback](svobodnoe-mesto-copy-fallback.md) — Когда хардлинк невозможен (EXDEV/ENOTSUP/…), layout копирует файл, дублируя место на диске - [Проверка свободного места перед copy-fallback](svobodnoe-mesto-copy-fallback.md) — Когда хардлинк невозможен (EXDEV/ENOTSUP/…), layout копирует файл, дублируя место на диске
-7
View File
@@ -1,7 +0,0 @@
# Улучшения UI: показывать матч с записью метабазы в Telegram
**Приоритет:** низкий
Web-сторона реализована: страница загрузки /download/{id} и экран ревью показывают, с какой именно записью метабазы (TMDB/TVDB/IMDb) сматчилась загрузка — провайдер, id и ссылку. Осталось довести то же в Telegram: в уведомлениях/подтверждениях показывать запись матча (название, год, провайдер-id, ссылку), чтобы ошибочную привязку было видно и из бота. Полный выбор источника в вебе уже реализован.
Связано: specs/review-ux.md, specs/recognition.md (матч в базе), specs/architecture.md → «Транспорты».
+1 -1
View File
@@ -160,7 +160,7 @@ func (s *server) buildDownloadView(id string, rd *worker.ReviewData) downloadDet
default: default:
view.Provider = rd.Provider view.Provider = rd.Provider
view.ProviderID = rd.ProviderID view.ProviderID = rd.ProviderID
view.MatchURL = matchURL(rd, view.MediaType) view.MatchURL = rd.MatchURL()
} }
if rd.Recognition.Confidence.Valid { if rd.Recognition.Confidence.Valid {
view.Confidence = strconv.FormatFloat(rd.Recognition.Confidence.Float64, 'f', 2, 64) view.Confidence = strconv.FormatFloat(rd.Recognition.Confidence.Float64, 'f', 2, 64)
-53
View File
@@ -2,36 +2,8 @@ package httpapi
import ( import (
"testing" "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) { func TestParseManualSource(t *testing.T) {
cases := []struct { cases := []struct {
name string 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: default:
view.Provider = rd.Provider view.Provider = rd.Provider
view.ProviderID = rd.ProviderID view.ProviderID = rd.ProviderID
view.MatchURL = matchURL(rd, string(rd.Plan.Type)) view.MatchURL = rd.MatchURL()
} }
if rec.Confidence.Valid { if rec.Confidence.Valid {
view.Confidence = strconv.FormatFloat(rec.Confidence.Float64, 'f', 2, 64) 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/") 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 // TMDB/IMDb извлекаются из URL; TVDB — только dereferrer с числовым id (URL
// сайта thetvdb.com/series/{slug} числового id не содержит → не распознаём). // сайта thetvdb.com/series/{slug} числового id не содержит → не распознаём).
func parseProviderURL(raw string) (provider, id string, ok bool) { func parseProviderURL(raw string) (provider, id string, ok bool) {
@@ -336,12 +336,12 @@ func leadingDigits(s string) string {
} }
// sourceMatchURL — ссылка на запись источника-кандидата: URL кандидата, если // sourceMatchURL — ссылка на запись источника-кандидата: URL кандидата, если
// есть, иначе канонический URL из provider/id (обратный порядок к matchURL). // есть, иначе канонический URL из provider/id (обратный порядок к MatchURL).
func sourceMatchURL(src worker.SourceOption) string { func sourceMatchURL(src worker.SourceOption) string {
if src.URL != "" { if src.URL != "" {
return 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) { 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) 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) { func redirectReview(w http.ResponseWriter, r *http.Request, id string, msg string) {
u := "/review/" + id u := "/review/" + id
if msg != "" { if msg != "" {
+3
View File
@@ -127,6 +127,9 @@ func reviewData(state store.State) *worker.ReviewData {
Provider: store.NullString("tvdb"), ProviderID: store.NullString("269613"), Provider: store.NullString("tvdb"), ProviderID: store.NullString("269613"),
Reasons: `["неполный пак"]`, Reasons: `["неполный пак"]`,
}, },
// Эффективные provider/id (как заполняет effectiveProvider) — их и
// показывают уведомления/карточка, консистентно с веб-страницей.
Provider: "tvdb", ProviderID: "269613",
Plan: recognize.Plan{ Plan: recognize.Plan{
Type: recognize.MediaSeries, Title: "Фарго", Year: 2015, Type: recognize.MediaSeries, Title: "Фарго", Year: 2015,
Files: []recognize.PlanFile{{Src: "e1.mkv", Role: recognize.RoleEpisode, Season: &s, Episode: &e}}, Files: []recognize.PlanFile{{Src: "e1.mkv", Role: recognize.RoleEpisode, Season: &s, Episode: &e}},
+42 -12
View File
@@ -20,6 +20,15 @@ import (
// `&lt;` на битую разметку. // `&lt;` на битую разметку.
func esc(s string) string { return tgbotapi.EscapeText(tgbotapi.ModeHTML, s) } 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 заменяем `"` на
// `&quot;`; порядок безопасен: esc уже перевёл `&` в `&amp;`, повторно `&` в
// `&quot;` не удвоится.
func escHref(url string) string { return strings.ReplaceAll(esc(url), `"`, "&quot;") }
// idCode оборачивает download id в моноширинный <code> — в клиентах Telegram по // idCode оборачивает download id в моноширинный <code> — в клиентах Telegram по
// нему работает tap-to-copy (скопировать id для /download/{id} или диагностики). // нему работает tap-to-copy (скопировать id для /download/{id} или диагностики).
// Визуальный префикс (`#` / `download_id=`) держим ВНЕ code, чтобы копировался // Визуальный префикс (`#` / `download_id=`) держим ВНЕ code, чтобы копировался
@@ -69,8 +78,11 @@ func (b *Bot) reviewCard(rd *worker.ReviewData) (string, *tgbotapi.InlineKeyboar
// guessLine/baseLine возвращают уже экранированный текст (внешние title/provider // guessLine/baseLine возвращают уже экранированный текст (внешние title/provider
// внутри) — повторно не экранируем. // внутри) — повторно не экранируем.
fmt.Fprintf(&sb, "Похоже на: %s\n", guessLine(rd)) fmt.Fprintf(&sb, "Похоже на: %s\n", guessLine(rd))
if base := baseLine(rd.Recognition); base != "" { // В ревью показываем «нет матча» явно — полезно видеть, что база не выбрана.
if base := baseLine(rd); base != "" {
fmt.Fprintf(&sb, "База: %s\n", base) fmt.Fprintf(&sb, "База: %s\n", base)
} else {
sb.WriteString("База: нет матча\n")
} }
if reasons := rd.Recognition.ReasonList(); len(reasons) > 0 { if reasons := rd.Recognition.ReasonList(); len(reasons) > 0 {
fmt.Fprintf(&sb, "Причины: %s\n", esc(strings.Join(reasons, " · "))) fmt.Fprintf(&sb, "Причины: %s\n", esc(strings.Join(reasons, " · ")))
@@ -127,14 +139,22 @@ func titleLabel(rd *worker.ReviewData) string {
return "#" + idCode(rd.Download.ID) return "#" + idCode(rd.Download.ID)
} }
// renderDone — короткое сообщение о готовности. // renderDone — короткое сообщение о готовности. Дополняем строкой матча (база +
// ссылка), чтобы ошибочную привязку было видно и в финальном пинге; без матча
// строку опускаем — в готовности «нет матча» лишний шум.
func (b *Bot) renderDone(rd *worker.ReviewData) string { func (b *Bot) renderDone(rd *worker.ReviewData) string {
label := titleLabel(rd) label := titleLabel(rd)
var sb strings.Builder
n := len(rd.Preview) n := len(rd.Preview)
if n == 0 { 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 — уведомление о рассинхроне (источник/цель удалены вручную). // renderDesync — уведомление о рассинхроне (источник/цель удалены вручную).
@@ -267,16 +287,26 @@ func guessLine(rd *worker.ReviewData) string {
return s return s
} }
// baseLine возвращает уже экранированный текст (provider/provider_id — внешние, // baseLine — запись матча метабазы для уведомлений: provider и id (эффективные,
// из метабазы/LLM): вызывающий вставляет как есть, без повторного esc. // с учётом ручных правок — как на веб-странице загрузки), при возможности
func baseLine(rec *store.Recognition) string { // построить URL — ссылкой на страницу записи (тот же билдер, что и веб:
if rec == nil || !rec.Provider.Valid || rec.Provider.String == "" || rec.Provider.String == "none" { // worker.ReviewData.MatchURL). Возвращает уже экранированный HTML (provider/id —
return "нет матча" // внешние, URL — в контексте href): вызывающий вставляет как есть. Пусто, если
// матча нет (провайдер пуст/none) — поверхность сама решает, показывать ли
// индикатор «нет матча».
func baseLine(rd *worker.ReviewData) string {
prov := rd.Provider
if prov == "" || prov == "none" {
return ""
} }
if rec.ProviderID.Valid && rec.ProviderID.String != "" { label := esc(prov)
return esc(rec.Provider.String) + " " + esc(rec.ProviderID.String) 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 { func contextOrSource(rd *worker.ReviewData) string {
+109
View File
@@ -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 (&quot;), атрибут
// остаётся целым — иначе 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, "&quot;") {
t.Errorf("кавычка в href не экранирована:\n%s", text)
}
}
+70
View File
@@ -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)
}
}
+43
View File
@@ -957,6 +957,49 @@ type ReviewData struct {
Overrides map[string]string 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 — вид источника в едином списке ревью. // SourceKind — вид источника в едином списке ревью.
type SourceKind string type SourceKind string
@@ -0,0 +1,75 @@
# Design
## Контекст
Билдер URL записи метабазы сейчас живёт в транспорте `internal/httpapi`
(`providerURL` — канонический URL по provider/id/type; `matchURL` — выбор ссылки
матча: URL выбранного кандидата, если его provider+id совпадают с эффективными,
иначе канонический). Telegram-транспорт (`internal/tgbot`) не может переиспользовать
эти функции: транспорт не должен зависеть от другого транспорта, да и незачем
дублировать логику. Оба транспорта уже зависят от ядра `internal/worker` и его
`ReviewData`.
## Решение
### Билдер URL — в ядро worker
Переносим в `internal/worker`:
- `func ProviderURL(provider, id, mediaType string) string` — экспортируемая
чистая функция (канонический URL; пустой id или неизвестный провайдер → пусто).
- `func (rd *ReviewData) MatchURL() string` — метод: приоритет URL выбранного
кандидата (при совпадении provider+id с эффективными), иначе
`ProviderURL(rd.Provider, rd.ProviderID, string(rd.Plan.Type))`. Тип медиа для
URL всегда `rd.Plan.Type` (так и звали оба вызова в httpapi), поэтому метод
берёт его сам — вызывающему не нужно передавать.
httpapi делегирует: `view.MatchURL = rd.MatchURL()`; `sourceMatchURL` зовёт
`worker.ProviderURL(...)`. Обратный разбор `parseProviderURL` (URL → provider/id)
и парсинг ручного ввода остаются в httpapi — это транспортный ввод, не общий
билдер. Тесты `providerURL`/`matchURL` переезжают в `internal/worker`.
### Что показываем в боте
`baseLine` меняем: принимает `*worker.ReviewData` (а не сырой `*store.Recognition`),
использует **эффективные** `rd.Provider`/`rd.ProviderID` (как веб — с учётом
ручных правок) и `rd.MatchURL()`:
- есть URL → `<a href="URL">provider id ↗</a>` (provider, id экранированы через
`esc`; URL — через `escHref`, см. «Безопасность»);
- нет URL → `provider id` текстом (как раньше);
- матча нет (`""`/`none`) → возвращает пусто (вызывающий решает, показывать ли
индикатор).
Строку матча показываем в двух местах:
- **карточка ревью** (`reviewCard`) — точка подтверждения, где привязку ещё можно
поправить; строка «База: …» уже была, добавляем в неё ссылку и переводим на
эффективный провайдер. Когда матча нет — сохраняем текущее поведение: «База:
нет матча» (в ревью полезно видеть, что база не выбрана).
- **уведомление о готовности** (`renderDone`) — добавляем строку «База: …**только
при наличии матча**, чтобы ошибочную привязку было видно и в финальном пинге
(файлы уже разложены, но расхождение заметно сразу). Без матча строку опускаем
— в готовности «нет матча» лишний шум.
Асимметрия «нет матча» между поверхностями осознанная: индикатор в ревью помогает
(можно добавить базу), в финальном пинге — нет. `baseLine` поэтому отдаёт пусто на
«нет матча», а текст «нет матча» подставляет `reviewCard` (единственная
поверхность, где он нужен).
Прочие уведомления (падение/рассинхрон) матч не показывают: там нет
подтверждённого результата раскладки, релевантна причина сбоя, а не запись базы.
### Безопасность
`provider`, `id`, `URL` — недоверенные (метабаза/LLM/ручной ввод). `provider` и id
экранируются через `esc` (текстовый контекст). Для **URL контекст другой —
значение атрибута `href`**, а `esc` (`tgbotapi.EscapeText(ModeHTML)`) заменяет
только `<`, `>`, `&` и **не трогает `"`**. Между тем `ProviderURL` подставляет id
в URL сырым (`"…/title/" + id`), а id недоверенный: id с `"` разорвал бы атрибут
`href` и Telegram отклонил бы сообщение (parse error) → уведомление о матче тихо
не доставилось бы. Поэтому URL экранируем хелпером `escHref`, который поверх `esc`
дополнительно заменяет `"` на `&quot;` (порядок безопасен: `esc` уже перевёл `&` в
`&amp;`, так что `&` в `&quot;` не удвоится). Ссылка рисуется только при непустом
URL из `MatchURL()`. Миграции не нужны — данные матча уже в БД (`recognition`,
`metadata_candidate`).
@@ -0,0 +1,58 @@
## Why
Веб уже показывает, с какой именно записью метабазы сматчилась загрузка:
страница `/download/{id}` и экран ревью выводят provider, id и — если URL
строится — ссылку на страницу записи (`internal/httpapi`: `matchURL`/
`providerURL`, шаблон `download_main.html`). В Telegram матч показан беднее:
карточка ревью (`internal/tgbot/render.go`, `baseLine`) выводит только
`provider id` из **сырого** распознавания и **без ссылки**, а уведомление о
готовности матч не показывает вовсе. Из-за этого ошибочную привязку (не тот
фильм/сезон) из бота не видно — приходится открывать веб.
## What Changes
- **Карточка подтверждения (review)** в боте показывает матч со **ссылкой** на
запись метабазы (когда URL строится) — как веб: `provider id ↗`. Без URL —
тем же текстом, что и раньше.
- **Уведомление о готовности** (`renderDone`) показывает строку матча (provider,
id, ссылка) — чтобы ошибочную привязку было видно и в финальном пинге.
- **Provider/id берутся эффективные** (`ReviewData.Provider`/`ProviderID`, с
учётом ручных правок), консистентно с веб-страницей и экраном ревью, а не из
сырого распознавания.
- **Единый билдер URL:** канонический `providerURL` и выбор ссылки матча
`matchURL` переезжают из `internal/httpapi` в ядро `internal/worker`
(`worker.ProviderURL` + метод `(*ReviewData).MatchURL()`), чтобы оба
транспорта (веб и Telegram) строили ссылку одинаково. httpapi делегирует туда.
- **Экранирование:** provider, id и URL — недоверенные, экранируются перед
вставкой в HTML-сообщение (инвариант «выход LLM недоверенный»); ссылка
рисуется только при непустом URL. URL — в контексте атрибута `href`: помимо
`<`/`>`/`&` экранируется и кавычка (иначе изготовленный id разорвёт атрибут и
Telegram отклонит сообщение).
## Capabilities
### New Capabilities
Нет.
### Modified Capabilities
- `notifications`: добавляется требование к **содержанию** уведомлений/
подтверждений — показ записи матча метабазы (provider, id, ссылка) в карточке
ревью и уведомлении о готовности, эффективным провайдером, с экранированием.
Условия и события доставки (падение, review, готовность, рассинхрон) без
изменений.
## Impact
- **Спеки:** дельта `notifications` — ADDED «Показ записи матча метабазы» +
MODIFIED «Экранирование внешнего текста» (в перечень добавлены id матча и URL,
экранирование учитывает контекст `href`/кавычку).
- **Код:** `internal/worker/review.go` (новые `ProviderURL` + метод `MatchURL`),
`internal/httpapi/review.go`/`download.go` (делегируют в worker; локальные
`providerURL`/`matchURL` удаляются, `sourceMatchURL` зовёт `worker.ProviderURL`),
`internal/tgbot/render.go` (`baseLine` по `ReviewData` со ссылкой; строка матча
в `renderDone`).
- **Тесты:** тесты `providerURL`/`matchURL` переезжают в `internal/worker`;
`internal/tgbot` — проверка ссылки и экранирования в строке матча.
- **Миграции БД:** нет (данные матча уже в БД).
@@ -0,0 +1,95 @@
## ADDED Requirements
### Requirement: Показ записи матча метабазы в уведомлениях
Уведомления и подтверждения бота по загрузке с матчем метабазы система SHALL
сопровождать записью матча: provider и id, а при возможности построить URL
записи — ссылкой на страницу записи (тот же канонический билдер URL, что и веб).
Это SHALL применяться в карточке подтверждения (`review`) и в уведомлении о
готовности. Provider и id SHALL отражать эффективный выбор (с учётом ручных
правок), консистентно с веб-страницей загрузки и экраном ревью. Когда URL не
строится, матч SHALL показываться текстом (provider и id без ссылки), чтобы
ошибочную привязку было видно из бота.
Отсутствие матча (`none`/пусто) поверхности отражают по-разному: карточка
подтверждения SHALL показывать явный индикатор «нет матча» (в ревью полезно
видеть, что база не выбрана), а уведомление о готовности строку матча в этом
случае SHALL опускать (в финальном пинге «нет матча» — шум).
Provider, id и URL — недоверенные (метабаза/LLM/ручной ввод), поэтому система
MUST экранировать их перед вставкой в размеченное сообщение так, чтобы значение
не могло разорвать разметку в своём контексте (для URL в атрибуте `href`с
учётом кавычки), иначе изготовленный id способен сломать сообщение и подавить
доставку уведомления (инвариант «выход LLM недоверенный»).
#### Scenario: Матч со ссылкой в карточке review
- **GIVEN** загрузка в `review` с подтверждённым матчем метабазы, для которого
строится URL записи
- **WHEN** бот рендерит карточку подтверждения
- **THEN** матч выводится ссылкой на страницу записи с provider и id, а provider,
id и URL экранированы (в т.ч. кавычка в значении `href`)
#### Scenario: Матч без строящегося URL показывается текстом
- **GIVEN** загрузка с матчем, для провайдера которого URL записи не строится
- **WHEN** бот рендерит карточку подтверждения
- **THEN** матч выводится текстом (provider и id) без ссылки
#### Scenario: Показ матча в уведомлении о готовности
- **GIVEN** загрузка с матчем метабазы, перешедшая в готовность
- **WHEN** бот рендерит уведомление о готовности
- **THEN** уведомление содержит запись матча (provider, id, при возможности —
ссылку), чтобы ошибочную привязку было видно после раскладки
#### Scenario: Нет матча — индикатор в review, пропуск в готовности
- **GIVEN** загрузка без матча метабазы (`none`/пусто)
- **WHEN** бот рендерит карточку подтверждения, а затем уведомление о готовности
- **THEN** карточка подтверждения показывает индикатор «нет матча», а уведомление
о готовности строку матча не содержит
#### Scenario: Эффективный провайдер после ручной правки
- **GIVEN** загрузка, где провайдер/id матча переопределены вручную
- **WHEN** бот рендерит запись матча
- **THEN** показываются эффективные provider и id (как на веб-странице загрузки),
а не значения сырого распознавания
## MODIFIED Requirements
### Requirement: Экранирование внешнего текста при форматированных уведомлениях
При включённом форматировании исходящих сообщений (parse mode) система MUST
экранировать все внешние/недоверенные фрагменты перед вставкой в размеченное
сообщение: display name, распознанное название, источник/контекст, целевой путь,
причины распознавания, provider, id матча, ссылку на запись метабазы (URL), код и
текст ошибки. Экранирование MUST учитывать контекст вставки: для значения в
атрибуте (URL в `href`) — в том числе кавычку, чтобы недоверенное значение не
разорвало атрибут. Это защищает от того, что спецсимволы разметки сломают
сообщение или что разметка будет инъектирована из недоверенного источника
(инвариант «выход LLM недоверенный»). Секреты (токены/ключи/пароли) MUST NOT
попадать в текст уведомлений и логи.
#### Scenario: Спецсимволы в названии не ломают разметку
- **GIVEN** уведомление, где display name или распознанное название содержит
символы разметки (`<`, `>`, `&`)
- **WHEN** бот рендерит форматированное сообщение
- **THEN** эти символы экранируются, сообщение доставляется корректно, а разметка
из недоверенного текста не интерпретируется
#### Scenario: Внешний путь и причины экранируются
- **GIVEN** уведомление с целевым путём плана и причинами распознавания
- **WHEN** бот рендерит форматированное сообщение
- **THEN** символы разметки в пути и причинах экранируются перед вставкой
#### Scenario: Кавычка в URL записи не разрывает атрибут href
- **GIVEN** уведомление со ссылкой на запись метабазы, где id (а значит URL)
содержит кавычку
- **WHEN** бот рендерит ссылку матча
- **THEN** кавычка в значении `href` экранируется, атрибут остаётся целым и
сообщение доставляется
@@ -0,0 +1,42 @@
## 1. Ядро: общий билдер URL
- [x] 1.1 `internal/worker/review.go`: добавить экспортируемую
`func ProviderURL(provider, id, mediaType string) string` (канонический URL;
пустой id / неизвестный провайдер → пусто) и метод
`func (rd *ReviewData) MatchURL() string` (приоритет URL выбранного кандидата
при совпадении provider+id с эффективными, иначе `ProviderURL` по
`rd.Provider`/`rd.ProviderID`/`rd.Plan.Type`).
- [x] 1.2 Перенести тесты `providerURL`/`matchURL` в `internal/worker`
(из `internal/httpapi/providerurl_test.go`), поправив на новые имена/сигнатуры.
## 2. httpapi: делегирование
- [x] 2.1 `internal/httpapi/review.go`/`download.go`: удалить локальные
`providerURL`/`matchURL`; `view.MatchURL = rd.MatchURL()`; `sourceMatchURL`
зовёт `worker.ProviderURL(src.Provider, src.ProviderID, src.Type)`.
`parseProviderURL`/`parseManualSource` остаются в httpapi.
## 3. tgbot: показ матча
- [x] 3.1 `internal/tgbot/render.go`: `escHref` — экранирование URL для значения
атрибута `href` (поверх `esc` заменяет `"``&quot;`).
- [x] 3.2 `baseLine` принимает `*worker.ReviewData`, использует эффективные
`rd.Provider`/`rd.ProviderID` и `rd.MatchURL()`: при непустом URL —
`<a href="...">provider id ↗</a>` (provider/id через `esc`, URL через `escHref`),
иначе текст `provider id`; при отсутствии матча — пусто.
- [x] 3.3 `reviewCard` зовёт `baseLine(rd)`, при пустом результате показывает
«База: нет матча» (сохранение поведения); `renderDone` добавляет строку
«База: …» только при непустом `baseLine(rd)` (без матча — опускает).
## 4. Тесты
- [x] 4.1 `internal/tgbot`: карточка ревью с матчем содержит `<a href=...↗`;
провайдер/id экранированы; матч без URL — текстом; `renderDone` содержит строку
матча при матче и опускает её без матча; карточка ревью без матча — «нет матча».
- [x] 4.2 `internal/tgbot`: id с `"` — кавычка в `href` экранируется (`&quot;`),
атрибут остаётся целым.
## 5. Спека и проверки
- [x] 5.1 `openspec validate --strict telegram-metabase-match`.
- [x] 5.2 `task test` и `task lint` — зелёные.
+72 -4
View File
@@ -80,10 +80,13 @@ Telegram / бейдж в вебе) — пользователя зовут, а
При включённом форматировании исходящих сообщений (parse mode) система MUST При включённом форматировании исходящих сообщений (parse mode) система MUST
экранировать все внешние/недоверенные фрагменты перед вставкой в размеченное экранировать все внешние/недоверенные фрагменты перед вставкой в размеченное
сообщение: display name, распознанное название, источник/контекст, целевой путь, сообщение: display name, распознанное название, источник/контекст, целевой путь,
причины распознавания, provider, код и текст ошибки. Это защищает от того, что причины распознавания, provider, id матча, ссылку на запись метабазы (URL), код и
спецсимволы разметки сломают сообщение или что разметка будет инъектирована из текст ошибки. Экранирование MUST учитывать контекст вставки: для значения в
недоверенного источника (инвариант «выход LLM недоверенный»). Секреты атрибуте (URL в `href`) — в том числе кавычку, чтобы недоверенное значение не
(токены/ключи/пароли) MUST NOT попадать в текст уведомлений и логи. разорвало атрибут. Это защищает от того, что спецсимволы разметки сломают
сообщение или что разметка будет инъектирована из недоверенного источника
(инвариант «выход LLM недоверенный»). Секреты (токены/ключи/пароли) MUST NOT
попадать в текст уведомлений и логи.
#### Scenario: Спецсимволы в названии не ломают разметку #### Scenario: Спецсимволы в названии не ломают разметку
@@ -99,3 +102,68 @@ Telegram / бейдж в вебе) — пользователя зовут, а
- **WHEN** бот рендерит форматированное сообщение - **WHEN** бот рендерит форматированное сообщение
- **THEN** символы разметки в пути и причинах экранируются перед вставкой - **THEN** символы разметки в пути и причинах экранируются перед вставкой
#### Scenario: Кавычка в URL записи не разрывает атрибут href
- **GIVEN** уведомление со ссылкой на запись метабазы, где id (а значит URL)
содержит кавычку
- **WHEN** бот рендерит ссылку матча
- **THEN** кавычка в значении `href` экранируется, атрибут остаётся целым и
сообщение доставляется
### Requirement: Показ записи матча метабазы в уведомлениях
Уведомления и подтверждения бота по загрузке с матчем метабазы система SHALL
сопровождать записью матча: provider и id, а при возможности построить URL
записи — ссылкой на страницу записи (тот же канонический билдер URL, что и веб).
Это SHALL применяться в карточке подтверждения (`review`) и в уведомлении о
готовности. Provider и id SHALL отражать эффективный выбор (с учётом ручных
правок), консистентно с веб-страницей загрузки и экраном ревью. Когда URL не
строится, матч SHALL показываться текстом (provider и id без ссылки), чтобы
ошибочную привязку было видно из бота.
Отсутствие матча (`none`/пусто) поверхности отражают по-разному: карточка
подтверждения SHALL показывать явный индикатор «нет матча» (в ревью полезно
видеть, что база не выбрана), а уведомление о готовности строку матча в этом
случае SHALL опускать (в финальном пинге «нет матча» — шум).
Provider, id и URL — недоверенные (метабаза/LLM/ручной ввод), поэтому система
MUST экранировать их перед вставкой в размеченное сообщение так, чтобы значение
не могло разорвать разметку в своём контексте (для URL в атрибуте `href`с
учётом кавычки), иначе изготовленный id способен сломать сообщение и подавить
доставку уведомления (инвариант «выход LLM недоверенный»).
#### Scenario: Матч со ссылкой в карточке review
- **GIVEN** загрузка в `review` с подтверждённым матчем метабазы, для которого
строится URL записи
- **WHEN** бот рендерит карточку подтверждения
- **THEN** матч выводится ссылкой на страницу записи с provider и id, а provider,
id и URL экранированы (в т.ч. кавычка в значении `href`)
#### Scenario: Матч без строящегося URL показывается текстом
- **GIVEN** загрузка с матчем, для провайдера которого URL записи не строится
- **WHEN** бот рендерит карточку подтверждения
- **THEN** матч выводится текстом (provider и id) без ссылки
#### Scenario: Показ матча в уведомлении о готовности
- **GIVEN** загрузка с матчем метабазы, перешедшая в готовность
- **WHEN** бот рендерит уведомление о готовности
- **THEN** уведомление содержит запись матча (provider, id, при возможности —
ссылку), чтобы ошибочную привязку было видно после раскладки
#### Scenario: Нет матча — индикатор в review, пропуск в готовности
- **GIVEN** загрузка без матча метабазы (`none`/пусто)
- **WHEN** бот рендерит карточку подтверждения, а затем уведомление о готовности
- **THEN** карточка подтверждения показывает индикатор «нет матча», а уведомление
о готовности строку матча не содержит
#### Scenario: Эффективный провайдер после ручной правки
- **GIVEN** загрузка, где провайдер/id матча переопределены вручную
- **WHEN** бот рендерит запись матча
- **THEN** показываются эффективные provider и id (как на веб-странице загрузки),
а не значения сырого распознавания