From 10a6348d39f670d6d8aecbb81e58fa4da76cdaa5 Mon Sep 17 00:00:00 2001 From: Anton Vakhrushev Date: Sat, 18 Jul 2026 15:41:28 +0300 Subject: [PATCH] =?UTF-8?q?tgbot:=20=D0=BF=D0=BE=D0=BA=D0=B0=D0=B7=D1=8B?= =?UTF-8?q?=D0=B2=D0=B0=D1=82=D1=8C=20=D0=B7=D0=B0=D0=BF=D0=B8=D1=81=D1=8C?= =?UTF-8?q?=20=D0=BC=D0=B0=D1=82=D1=87=D0=B0=20=D0=BC=D0=B5=D1=82=D0=B0?= =?UTF-8?q?=D0=B1=D0=B0=D0=B7=D1=8B=20(=D0=BF=D1=80=D0=BE=D0=B2=D0=B0?= =?UTF-8?q?=D0=B9=D0=B4=D0=B5=D1=80+id+=D1=81=D1=81=D1=8B=D0=BB=D0=BA?= =?UTF-8?q?=D0=B0)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit В карточке подтверждения и уведомлении о готовности бот теперь показывает запись матча метабазы — провайдер, 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) --- docs/backlog/README.md | 1 - docs/backlog/telegram-match-metabazy.md | 7 -- internal/httpapi/download.go | 2 +- internal/httpapi/providerurl_test.go | 53 --------- internal/httpapi/review.go | 49 +------- internal/tgbot/bot_test.go | 3 + internal/tgbot/render.go | 54 +++++++-- internal/tgbot/render_match_test.go | 109 ++++++++++++++++++ internal/worker/providerurl_test.go | 70 +++++++++++ internal/worker/review.go | 43 +++++++ .../design.md | 75 ++++++++++++ .../proposal.md | 58 ++++++++++ .../specs/notifications/spec.md | 95 +++++++++++++++ .../tasks.md | 42 +++++++ openspec/specs/notifications/spec.md | 76 +++++++++++- 15 files changed, 614 insertions(+), 123 deletions(-) delete mode 100644 docs/backlog/telegram-match-metabazy.md create mode 100644 internal/tgbot/render_match_test.go create mode 100644 internal/worker/providerurl_test.go create mode 100644 openspec/changes/archive/2026-07-18-telegram-metabase-match/design.md create mode 100644 openspec/changes/archive/2026-07-18-telegram-metabase-match/proposal.md create mode 100644 openspec/changes/archive/2026-07-18-telegram-metabase-match/specs/notifications/spec.md create mode 100644 openspec/changes/archive/2026-07-18-telegram-metabase-match/tasks.md diff --git a/docs/backlog/README.md b/docs/backlog/README.md index 19c3315..ddc071e 100644 --- a/docs/backlog/README.md +++ b/docs/backlog/README.md @@ -46,7 +46,6 @@ Tududi (проект `jellybit`) больше **не** держит беклог - [[идея] Многоступенчатая верификация привязки](mnogostupenchataya-verifikaciya.md) — ИДЕЯ (требует проработки) - [Согласование канона нумерации серий с провайдером тега](kanon-numeracii-vs-provajder.md) — Косметика/редкость: порядок просмотра ок, но у тайтлов со спорным порядком (Бибоп) Jellyfin подтягивает не те подписи серий, если канон файлов ≠ дефолтный порядок провайдера тега - [Выбор из нескольких находок метабазы в Telegram](telegram-vybor-nahodok.md) — Когда распознавание даёт несколько подходящих кандидатов в метабазе, предлагать их в… -- [Улучшения UI: показывать матч с записью метабазы в Telegram](telegram-match-metabazy.md) — Название/год/провайдер+id в боте уже выводятся; осталась кликабельная ссылка на запись - [Добавление торрентов файлом/ссылкой — «единое окно» (остаток: URL)](dobavlenie-edinoe-okno.md) — magnet и .torrent-файл приняты; остался фетч .torrent по URL (нужен SSRF-гард) - [Раздачи-копии диска (DVD/BluRay: VIDEO_TS/BDMV)](disk-kopii-video-ts-bdmv.md) — Иногда для очень редких фильмов скачивается не один видеофайл, а полная копия диска… - [Проверка свободного места перед copy-fallback](svobodnoe-mesto-copy-fallback.md) — Когда хардлинк невозможен (EXDEV/ENOTSUP/…), layout копирует файл, дублируя место на диске diff --git a/docs/backlog/telegram-match-metabazy.md b/docs/backlog/telegram-match-metabazy.md deleted file mode 100644 index 6067882..0000000 --- a/docs/backlog/telegram-match-metabazy.md +++ /dev/null @@ -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 → «Транспорты». diff --git a/internal/httpapi/download.go b/internal/httpapi/download.go index 399f4a2..e60daf9 100644 --- a/internal/httpapi/download.go +++ b/internal/httpapi/download.go @@ -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) diff --git a/internal/httpapi/providerurl_test.go b/internal/httpapi/providerurl_test.go index d15967f..5512272 100644 --- a/internal/httpapi/providerurl_test.go +++ b/internal/httpapi/providerurl_test.go @@ -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) - } -} diff --git a/internal/httpapi/review.go b/internal/httpapi/review.go index 1f9e315..ff58e6f 100644 --- a/internal/httpapi/review.go +++ b/internal/httpapi/review.go @@ -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 != "" { diff --git a/internal/tgbot/bot_test.go b/internal/tgbot/bot_test.go index 41809d7..76326ca 100644 --- a/internal/tgbot/bot_test.go +++ b/internal/tgbot/bot_test.go @@ -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}}, diff --git a/internal/tgbot/render.go b/internal/tgbot/render.go index 21d646c..3f63f07 100644 --- a/internal/tgbot/render.go +++ b/internal/tgbot/render.go @@ -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 в моноширинный — в клиентах 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(`%s ↗`, escHref(url), label) + } + return label } func contextOrSource(rd *worker.ReviewData) string { diff --git a/internal/tgbot/render_match_test.go b/internal/tgbot/render_match_test.go new file mode 100644 index 0000000..d9cef09 --- /dev/null +++ b/internal/tgbot/render_match_test.go @@ -0,0 +1,109 @@ +package tgbot + +import ( + "strings" + "testing" + + "git.vakhrushev.me/av/jellybit/internal/store" +) + +// Карточка ревью показывает запись матча ссылкой на страницу записи (тот же +// билдер URL, что и веб): provider+id внутри , стрелка ↗, экранировано. +func TestReviewCard_ShowsMatchLink(t *testing.T) { + b, _, _, _ := newTestBot(t, []int64{7}) + rd := reviewData(store.StateReview) // tvdb/269613, сериал + text, _ := b.renderCard(rd) + + want := `tvdb 269613 ↗` + 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, "tvdb 269613 ↗`) { + 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) + } +} diff --git a/internal/worker/providerurl_test.go b/internal/worker/providerurl_test.go new file mode 100644 index 0000000..42aa541 --- /dev/null +++ b/internal/worker/providerurl_test.go @@ -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) + } +} diff --git a/internal/worker/review.go b/internal/worker/review.go index 2032e05..471e9c3 100644 --- a/internal/worker/review.go +++ b/internal/worker/review.go @@ -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 diff --git a/openspec/changes/archive/2026-07-18-telegram-metabase-match/design.md b/openspec/changes/archive/2026-07-18-telegram-metabase-match/design.md new file mode 100644 index 0000000..c94378a --- /dev/null +++ b/openspec/changes/archive/2026-07-18-telegram-metabase-match/design.md @@ -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 → `provider id ↗` (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` +дополнительно заменяет `"` на `"` (порядок безопасен: `esc` уже перевёл `&` в +`&`, так что `&` в `"` не удвоится). Ссылка рисуется только при непустом +URL из `MatchURL()`. Миграции не нужны — данные матча уже в БД (`recognition`, +`metadata_candidate`). diff --git a/openspec/changes/archive/2026-07-18-telegram-metabase-match/proposal.md b/openspec/changes/archive/2026-07-18-telegram-metabase-match/proposal.md new file mode 100644 index 0000000..23ff551 --- /dev/null +++ b/openspec/changes/archive/2026-07-18-telegram-metabase-match/proposal.md @@ -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` — проверка ссылки и экранирования в строке матча. +- **Миграции БД:** нет (данные матча уже в БД). diff --git a/openspec/changes/archive/2026-07-18-telegram-metabase-match/specs/notifications/spec.md b/openspec/changes/archive/2026-07-18-telegram-metabase-match/specs/notifications/spec.md new file mode 100644 index 0000000..2f08aa8 --- /dev/null +++ b/openspec/changes/archive/2026-07-18-telegram-metabase-match/specs/notifications/spec.md @@ -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` экранируется, атрибут остаётся целым и + сообщение доставляется diff --git a/openspec/changes/archive/2026-07-18-telegram-metabase-match/tasks.md b/openspec/changes/archive/2026-07-18-telegram-metabase-match/tasks.md new file mode 100644 index 0000000..afa2185 --- /dev/null +++ b/openspec/changes/archive/2026-07-18-telegram-metabase-match/tasks.md @@ -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` заменяет `"` → `"`). +- [x] 3.2 `baseLine` принимает `*worker.ReviewData`, использует эффективные + `rd.Provider`/`rd.ProviderID` и `rd.MatchURL()`: при непустом URL — + `provider id ↗` (provider/id через `esc`, URL через `escHref`), + иначе текст `provider id`; при отсутствии матча — пусто. +- [x] 3.3 `reviewCard` зовёт `baseLine(rd)`, при пустом результате показывает + «База: нет матча» (сохранение поведения); `renderDone` добавляет строку + «База: …» только при непустом `baseLine(rd)` (без матча — опускает). + +## 4. Тесты + +- [x] 4.1 `internal/tgbot`: карточка ревью с матчем содержит `