recognize: название из метабазы санитизируется перед попаданием в план

- чистка стоит на каждой точке входа значения метабазы в план — сборка матча,
  копия кандидата для ревью, набор закреплённых значений источника и его
  чтение: гарантия, поставленная только на запись, обходится данными,
  сохранёнными прежними версиями
- название, непригодное как имя каталога (пустое или без единой буквы и
  цифры), не подставляется — раздача уходит в review с названной причиной
- гейт подтверждения матча не сдвинут: сравнение с планом идёт по значениям
  провайдера, чистится только копия, уходящая дальше
This commit is contained in:
av
2026-08-10 10:41:16 +03:00
parent bb278e8744
commit 9aecf757e0
26 changed files with 1653 additions and 53 deletions
+8 -2
View File
@@ -20,6 +20,7 @@ import (
"log/slog"
"strconv"
"strings"
"unicode"
"unicode/utf8"
"git.vakhrushev.me/av/jellybit/internal/llm"
@@ -202,13 +203,18 @@ func render(f Fields) string {
return Label(f.Title, f.Director, f.Year, f.SeasonLabel())
}
// sanitize убирает управляющие символы и переводы строк, схлопывает пробелы.
// sanitize убирает управляющие и невидимые символы, переводы строк, схлопывает
// пробелы. Категорию Cf (zero-width, BOM, RLO) снимаем наравне с C0: значения
// приходят из метабазы и из сохранённого контекста, то есть снаружи, а ярлык
// уезжает в карточку Telegram, в шапку веб-UI и в имя раздачи qBittorrent —
// RLO там переворачивает отображение. Это же обещает требование metadata-match:
// режиссёр в плане не чистится именно потому, что его чистит рендер.
func sanitize(s string) string {
s = strings.Map(func(r rune) rune {
if r == '\n' || r == '\t' || r == '\r' {
return ' '
}
if r < 0x20 {
if r < 0x20 || unicode.Is(unicode.Cf, r) {
return -1
}
return r
+22
View File
@@ -158,3 +158,25 @@ func TestDeriveNameNilProviderUsesFallback(t *testing.T) {
t.Errorf("DeriveName() = %q (ожидался фолбек без LLM)", got)
}
}
// Значения ярлыка приходят снаружи — из credits метабазы и из сохранённого
// контекста. Категория Cf (zero-width, BOM, RLO) обязана сниматься здесь:
// требование metadata-match не чистит режиссёра в плане именно потому, что его
// чистит рендер. RLO в карточке Telegram переворачивает отображение имени.
func TestSanitize_StripsFormatChars(t *testing.T) {
cases := []struct{ in, want string }{
{"Denis\u202eVilleneuve", "DenisVilleneuve"},
{"Denis\u200bVilleneuve", "DenisVilleneuve"},
{"Denis\ufeffVilleneuve", "DenisVilleneuve"},
{"Denis\nVilleneuve", "Denis Villeneuve"},
{"Дени Вильнёв", "Дени Вильнёв"},
}
for _, c := range cases {
if got := sanitize(c.in); got != c.want {
t.Errorf("sanitize(%q) = %q, want %q", c.in, got, c.want)
}
}
if got := Label("Dune", "Denis\u202eVilleneuve", 2021, ""); got != "Dune (DenisVilleneuve, 2021)" {
t.Errorf("Label = %q", got)
}
}
+17 -2
View File
@@ -72,13 +72,17 @@ func (r *Recognizer) matchMetadata(ctx context.Context, plan Plan) (*Match, []me
}
// Копим кандидатов для выбора (дедуп по провайдеру+id, потолок).
// Названия чистим на КОПИИ: выбор кандидата человеком — такой же
// путь подтверждения матча, как авто, и закреплённое название так
// же уезжает в имя каталога. Исходный cands не трогаем — по нему
// ниже ищется сильный матч, и сдвигать его гейт задача не заказывала.
for _, c := range cands {
ck := c.Provider + ":" + c.ID
if seen[ck] || len(candidates) >= maxCandidates {
continue
}
seen[ck] = true
candidates = append(candidates, c)
candidates = append(candidates, sanitizeCandidate(c))
}
// Единичный сильный матч ищем у первого подходящего провайдера.
@@ -139,10 +143,21 @@ func (r *Recognizer) buildMatch(ctx context.Context, p metadata.Provider, c meta
}
}
prov, pid := CandidateTag(c)
// Каноническое название чистим здесь — в единственной точке сборки Match, и
// уже ПОСЛЕ гейта сильного матча (strongMatches сравнивает по значениям
// провайдера). Дальше по потоку значение считается чистым: и подстановка в
// план, и решение auto/review, и диагностика сухого прогона берут его как
// есть, а не чистят каждый заново.
title := c.Title
if clean := SanitizeTitle(title); clean != title {
logctx.FromOr(ctx, r.log).Debug("metadata match title sanitized",
"before", title, "after", clean)
title = clean
}
return &Match{
Provider: prov,
ProviderID: pid,
Title: c.Title,
Title: title,
Year: c.Year,
Director: directorOf(ctx, r.log, p, mt, c.ID),
SeasonEpisodeCounts: counts,
@@ -0,0 +1,220 @@
package recognize
import (
"context"
"testing"
"git.vakhrushev.me/av/jellybit/internal/metadata"
)
// Провенанс набора входов — отчёт триажа ревью tvdb-title-locale (находка 2) и
// раздел «Воспроизведение» задачи metadata-title-sanitize. До правки каждый из
// них давал auto=true и уезжал в имя каталога библиотеки дословно.
const dunePlanResp = `{"type":"movie","title":"Dune","original_title":"Dune","year":2021,
"confidence":0.9,"files":[
{"src":"Dune.2021/movie.mkv","role":"main","season":null,"episode":null}
]}`
func duneInput() Input {
return Input{
Name: "Dune.2021.2160p.BluRay.x265",
Files: []File{{Path: "Dune.2021/movie.mkv", Size: 20 << 30}},
}
}
// recognizeWithMatchTitle прогоняет полный Recognize против базы, единственная
// запись которой названа dbTitle. Год и оригинальное название совпадают с планом,
// поэтому матч подтверждается и все прочие условия авто чисты — судим ровно
// подстановку названия.
func recognizeWithMatchTitle(t *testing.T, dbTitle string) Result {
t.Helper()
p := &fakeProvider{candidates: []metadata.Candidate{
{Provider: "tmdb", ID: "438631", Title: dbTitle, OriginalTitle: "Dune", Year: 2021},
}}
r := New(&fakeLLM{responses: []string{dunePlanResp}}, []metadata.Provider{p},
Config{MaxRetries: 2}, testLogger())
res, err := r.Recognize(context.Background(), duneInput())
if err != nil {
t.Fatalf("Recognize: %v", err)
}
return res
}
// Четыре входа из «Воспроизведения»: значение в плане обязано совпадать с тем,
// что дал бы санитайзер, а не с тем, что отдала база.
func TestRecognize_MatchTitleSanitized(t *testing.T) {
cases := []struct {
name string
db string
want string
auto bool
about string
}{
{
name: "zero-width внутри слова", db: "Du\u200bne", want: "Dune", auto: true,
about: "невидимка снимается, авто остаётся",
},
{
name: "RLO переворачивает отображение", db: "Dune\u202egnp.mkv",
want: "Dunegnp.mkv", auto: true,
about: "управляющий символ снимается",
},
{
name: "перевод строки", db: "Dune\nHACK", want: "Dune HACK", auto: true,
about: "перевод строки сводится к пробелу",
},
{
name: "кириллический двойник", db: "Dunа", want: "Duna", auto: true,
about: "двойник сворачивается в доминирующий скрипт",
},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
res := recognizeWithMatchTitle(t, c.db)
if res.Match == nil {
t.Fatalf("матч должен быть подтверждён (%s)", c.about)
}
if res.Plan.Title != c.want {
t.Errorf("plan.Title = %q, want %q", res.Plan.Title, c.want)
}
if res.Plan.Title == c.db {
t.Errorf("значение базы уехало в план дословно: %q", res.Plan.Title)
}
if res.Decision.Auto != c.auto {
t.Errorf("auto = %v, want %v; reasons=%v",
res.Decision.Auto, c.auto, res.Decision.Reasons)
}
})
}
}
// Название, непригодное как имя каталога: пустое после чистки либо голая
// пунктуация. Подстановки не происходит, авто заблокирована причиной, но матч
// остаётся подтверждённым — id и год верны, распознавание не падает.
func TestRecognize_MatchTitleUnusable(t *testing.T) {
for _, db := range []string{"\u200b\u200b\u200b", ".", "...", "-", " . "} {
t.Run(db, func(t *testing.T) {
res := recognizeWithMatchTitle(t, db)
if res.Plan.Title != "Dune" {
t.Errorf("plan.Title = %q, want %q (название распознавания)",
res.Plan.Title, "Dune")
}
if res.Decision.Auto {
t.Error("авто-раскладка должна быть заблокирована")
}
if !hasReason(res.Decision.Reasons, "непригодно как имя каталога") {
t.Errorf("причина не названа: %v", res.Decision.Reasons)
}
if res.Match == nil || res.Match.ProviderID != "438631" {
t.Errorf("матч обязан остаться подтверждённым, got %+v", res.Match)
}
if res.Plan.Year != 2021 {
t.Errorf("год должен быть подставлен, got %d", res.Plan.Year)
}
})
}
}
// Нормальное название: значение базы доезжает как есть, авто разрешена. Это
// защита от регресса на TMDB — санитайзинг на чистом значении не делает ничего.
func TestRecognize_MatchTitleNormalUnchanged(t *testing.T) {
res := recognizeWithMatchTitle(t, "Dune: Part One")
if res.Plan.Title != "Dune: Part One" {
t.Errorf("plan.Title = %q, want %q", res.Plan.Title, "Dune: Part One")
}
if !res.Decision.Auto {
t.Errorf("auto = false, reasons=%v", res.Decision.Reasons)
}
}
// Кандидаты, уходящие в review, очищены: их названия закрепляет человек, и они
// становятся именем каталога так же, как каноническое.
func TestMatchMetadata_CandidatesSanitized(t *testing.T) {
p := &fakeProvider{candidates: []metadata.Candidate{
{Provider: "tmdb", ID: "1", Title: "Du\u200bne", OriginalTitle: "Dun\u200be", Year: 2021},
{Provider: "tmdb", ID: "2", Title: "Dunа Part Two", Year: 2024},
}}
r := recognizerWith(p)
_, cands := r.matchMetadata(context.Background(),
Plan{Type: MediaMovie, Title: "Nothing Matches Here", Year: 1900})
if len(cands) != 2 {
t.Fatalf("candidates = %d, want 2", len(cands))
}
if cands[0].Title != "Dune" || cands[0].OriginalTitle != "Dune" {
t.Errorf("кандидат не очищен: %+v", cands[0])
}
if cands[1].Title != "Duna Part Two" {
t.Errorf("двойник у кандидата не свёрнут: %q", cands[1].Title)
}
}
// Гейт матча не сдвинулся: кандидат, отличающийся от плана только невидимым
// символом внутри слова, сильным не считается. Сравнение идёт по значению
// провайдера — санитизируется только копия, уходящая в review.
func TestMatchMetadata_SanitizeDoesNotMoveGate(t *testing.T) {
p := &fakeProvider{candidates: []metadata.Candidate{
{Provider: "tmdb", ID: "1", Title: "Du\u200bne", Year: 2021},
}}
r := recognizerWith(p)
m, cands := r.matchMetadata(context.Background(),
Plan{Type: MediaMovie, Title: "Dune", Year: 2021})
if m != nil {
t.Errorf("невидимка внутри слова не должна давать сильный матч, got %+v", m)
}
if len(cands) != 1 || cands[0].Title != "Dune" {
t.Errorf("кандидат обязан уйти в review очищенным, got %+v", cands)
}
}
// Список кандидатов не обеднел: после подтверждённого матча у первого провайдера
// остальные продолжают пополнять список для review.
func TestMatchMetadata_CandidatesFromAllProvidersKept(t *testing.T) {
a := &fakeProvider{name: "tmdb", candidates: []metadata.Candidate{
{Provider: "tmdb", ID: "1", Title: "Dune", Year: 2021},
}}
b := &fakeProvider{name: "tvdb", candidates: []metadata.Candidate{
{Provider: "tvdb", ID: "9", Title: "Dune Other", Year: 2021},
}}
r := New(&fakeLLM{}, []metadata.Provider{a, b}, Config{}, testLogger())
m, cands := r.matchMetadata(context.Background(),
Plan{Type: MediaMovie, Title: "Dune", Year: 2021})
if m == nil {
t.Fatal("матч у первого провайдера должен подтвердиться")
}
if len(cands) != 2 {
t.Errorf("candidates = %d, want 2 (кандидаты второго провайдера не теряются)",
len(cands))
}
}
func TestUsableTitle(t *testing.T) {
usable := []string{"Dune", "2001", "Ne Zha", "Брат", "«Дюна»"}
unusable := []string{"", ".", "..", "...", "-", " . ", "—", "!?"}
for _, s := range usable {
if !UsableTitle(s) {
t.Errorf("UsableTitle(%q) = false, want true", s)
}
}
for _, s := range unusable {
if UsableTitle(s) {
t.Errorf("UsableTitle(%q) = true, want false", s)
}
}
}
// Идемпотентность несёт два утверждения сразу: что правка не двигает путей у
// раздач с нормальным названием и что «в плане нет несанитизированных полей»
// вообще проверяемо. Без теста она подразумевалась.
func TestSanitizeTitle_Idempotent(t *testing.T) {
inputs := []string{
"Du\u200bne", "Dune\u202egnp.mkv", "Dune\nHACK", "Dunа",
"Dune: Part One", " spaced out ", "\u200b\u200b\u200b", ".", "-",
"Тёмный рыцарь", "哪吒之魔童降世",
}
for _, in := range inputs {
once := SanitizeTitle(in)
if twice := SanitizeTitle(once); twice != once {
t.Errorf("sanitizeTitle не идемпотентен на %q: %q → %q", in, once, twice)
}
}
}
+7 -1
View File
@@ -260,7 +260,13 @@ func (r *Recognizer) Recognize(ctx context.Context, in Input) (Result, error) {
// review, когда единичного сильного матча нет.
match, candidates := r.matchMetadata(ctx, plan)
if match != nil {
plan.Title = match.Title
// Match.Title пришёл санитизированным (buildMatch чистит его в единственной
// точке сборки матча). Здесь остаётся только вопрос пригодности: название,
// не годное как имя каталога, не подставляем вовсе — в плане остаётся
// название распознавания, а decide уводит раздачу в review.
if UsableTitle(match.Title) {
plan.Title = match.Title
}
if match.Year != 0 {
plan.Year = match.Year
}
+37 -3
View File
@@ -4,6 +4,8 @@ import (
"log/slog"
"strings"
"unicode"
"git.vakhrushev.me/av/jellybit/internal/metadata"
)
// homoglyphPairs — курируемая таблица визуально неотличимых кирилло-латинских
@@ -34,17 +36,49 @@ func init() {
}
}
// sanitizeTitle чистит человекочитаемое поле плана как недоверенный вывод LLM:
// SanitizeTitle чистит человекочитаемое поле плана как недоверенный вывод LLM:
// (1) убирает управляющие и zero-width символы; (2) сводит пробелы к одиночным и
// обрезает края; (3) сворачивает homoglyph-двойники. Порядок важен: strip делаем
// до collapse, чтобы удаление zero-width не оставляло сдвоенных пробелов.
func sanitizeTitle(s string) string {
func SanitizeTitle(s string) string {
s = stripControl(s)
s = collapseSpaces(s)
s = foldHomoglyphs(s)
return s
}
// UsableTitle отвечает, годится ли название как компонент пути к файлу. Годится
// то, в чём после санитайзинга осталась хотя бы одна буква или цифра: layout
// снимает разделители и обрезает края от точек и пробелов, поэтому «.», «...»,
// «-» и прочая голая пунктуация схлопываются там в пустое имя каталога, а
// ведущая точка вдобавок делает каталог скрытым для Jellyfin.
//
// Проверять этим предикатом надо УЖЕ санитизированное значение: невидимые
// символы буквами не являются, но и не мешают — их снимает SanitizeTitle.
// Экспортирована вместе с SanitizeTitle, потому что тот же вопрос задаёт worker
// при закреплении выбранного человеком источника: правило одно, домов у него
// быть не должно.
func UsableTitle(s string) bool {
for _, r := range s {
if unicode.IsLetter(r) || unicode.IsDigit(r) {
return true
}
}
return false
}
// sanitizeCandidate чистит человекочитаемые названия кандидата метабазы. Зовётся
// на КОПИИ кандидата в момент, когда он уходит из сверки дальше — в список для
// review, в хранилище, на экран и в закрепляемое человеком значение. Значения,
// по которым ищется сильный матч, при этом не меняются: сравнение обязано идти
// по тому, что отдал провайдер (см. metadata-match), иначе кандидат, отличающийся
// от плана невидимым символом, начал бы совпадать там, где прежде уходил в review.
func sanitizeCandidate(c metadata.Candidate) metadata.Candidate {
c.Title = SanitizeTitle(c.Title)
c.OriginalTitle = SanitizeTitle(c.OriginalTitle)
return c
}
// sanitizePlan применяет санитайзинг к человекочитаемым полям плана. files[].src
// НЕ трогаем: они обязаны байт-в-байт совпадать с реальными файлами торрента, и
// homoglyph там — настоящий mismatch (отклоняется валидацией, уходит в review),
@@ -57,7 +91,7 @@ func sanitizePlan(p *Plan, log *slog.Logger) {
}
func sanitizeField(v, field string, log *slog.Logger) string {
clean := sanitizeTitle(v)
clean := SanitizeTitle(v)
if clean != v && log != nil {
log.Debug("recognition plan field sanitized", "field", field, "before", v, "after", clean)
}
+2 -2
View File
@@ -49,8 +49,8 @@ func TestSanitizeTitle(t *testing.T) {
"The Matrix": "The Matrix",
}
for in, want := range cases {
if got := sanitizeTitle(in); got != want {
t.Errorf("sanitizeTitle(%q) = %q, want %q", in, got, want)
if got := SanitizeTitle(in); got != want {
t.Errorf("SanitizeTitle(%q) = %q, want %q", in, got, want)
}
}
}
+14 -4
View File
@@ -84,10 +84,10 @@ func validateSchema(p *Plan, in Input) error {
}
// decide считает решение модели уверенности (см. recognition.md). Авто —
// только если выполнено всё: подтверждённый единичный матч в базе; чистая
// структурная валидация (для сериала — число серий бьётся с базой);
// согласованность с пред-парсом; самооценка LLM не ниже порога. Любая
// невыполненная — причина ухода в review.
// только если выполнено всё: подтверждённый единичный матч в базе; каноническое
// название матча пригодно как имя каталога; чистая структурная валидация (для
// сериала — число серий бьётся с базой); согласованность с пред-парсом;
// самооценка LLM не ниже порога. Любая невыполненная — причина ухода в review.
func decide(p Plan, pre PreParse, match *Match, metadataEnabled bool, threshold float64) Decision {
var reasons []string
@@ -98,6 +98,16 @@ func decide(p Plan, pre PreParse, match *Match, metadataEnabled bool, threshold
reasons = append(reasons, "не найдено в базе или несколько кандидатов")
}
// Каноническое название базы, непригодное как имя каталога (пустое или без
// единой буквы и цифры), в план не подставлено — там осталось название
// распознавания. Матч при этом верен: id, год и режиссёр на месте, поэтому
// отклоняем не матч, а авто-раскладку. Match.Title уже санитизирован
// (buildMatch), второй раз не чистим: два независимых пересчёта одного
// условия разъедутся на первой же правке одного из них.
if match != nil && !UsableTitle(match.Title) {
reasons = append(reasons, "название из базы непригодно как имя каталога")
}
reasons = append(reasons, structuralWarnings(p)...)
if match != nil && p.Type == MediaSeries {
+28 -3
View File
@@ -812,7 +812,8 @@ func (w *Worker) chooseCandidateLocked(ctx context.Context, id string, d *store.
if w.recognizer != nil {
director = w.recognizer.Director(ctx, candMediaType(rec), cand.Provider, cand.ProviderID)
}
for field, value := range sourcePins(cand.Provider, cand.ProviderID, title, year, director) {
pins := sourcePins(cand.Provider, cand.ProviderID, title, year, director)
for field, value := range pins {
if err := w.store.SetOverride(ctx, id, field, value); err != nil {
return fmt.Errorf("choose candidate: %w", err)
}
@@ -820,8 +821,13 @@ func (w *Worker) chooseCandidateLocked(ctx context.Context, id string, d *store.
if err := w.store.SetCandidateChosen(ctx, rec.ID, cand.ID); err != nil {
return fmt.Errorf("choose candidate: %w", err)
}
// title_pinned=false означает, что название кандидата не годится как имя
// каталога и в плане осталось название распознавания. Без этого атрибута
// вопрос «почему папка названа догадкой, а не как в базе» по логам не
// разбирается: на авто-пути такой отказ несёт причина решения, здесь её нет.
logctx.From(w.scoped(ctx, capReview, id, d.PrimaryInfohash())).Info("review candidate chosen",
"provider", cand.Provider, "provider_id", cand.ProviderID)
"provider", cand.Provider, "provider_id", cand.ProviderID,
"title_pinned", pins[ovrTitle] != "")
// Подтверждённый матч — переливаем каноническое имя в display_name и в ярлык
// раздачи (best-effort, косметика). Сбой обновления имени не должен ронять
// выбор кандидата: логируем и продолжаем.
@@ -911,6 +917,18 @@ func sourcePins(provider, providerID, title string, year int, director string) m
if year > 0 {
yr = strconv.Itoa(year)
}
// Название источника — недоверенное значение метабазы, и чистится оно здесь,
// на единственном общем доме набора пинов: через sourcePins идут и превью
// источника, и его закрепление, поэтому «превью = применение» держится
// конструкцией, а не памятью. Чистка идемпотентна — на кандидате, записанном
// уже с чисткой, она ничего не меняет, а строку из БД, сохранённую прежней
// версией, приводит в порядок. Непригодное как имя каталога название пином не
// становится: пустое значение очищает пин, и в плане остаётся название
// распознавания.
title = recognize.SanitizeTitle(title)
if !recognize.UsableTitle(title) {
title = ""
}
return map[string]string{
ovrProvider: provider,
ovrProviderID: providerID,
@@ -1297,8 +1315,15 @@ func (w *Worker) resolveFolderBase(ctx context.Context, downloadID, provider, pr
// applyOverrides применяет ручные правки к плану: каноническое имя/год (из
// выбранного кандидата базы) и помечает игнорируемые файлы ролью ignore (их
// раскладка пропустит).
//
// Название чистится здесь, НА ЧТЕНИИ, и это не дубль чистки в sourcePins: та
// держит хранилище чистым, а эта защищает от значений, записанных прежними
// версиями. Пин, закреплённый до появления санитайзинга, иначе доезжает до имени
// каталога дословно при обычном «Применить» — без повторного выбора источника
// запись в хранилище никто не перепишет. Чистка идемпотентна, так что на пинах,
// записанных уже с ней, обе не делают ничего.
func applyOverrides(plan recognize.Plan, overrides map[string]string) recognize.Plan {
if t := overrides[ovrTitle]; t != "" {
if t := recognize.SanitizeTitle(overrides[ovrTitle]); recognize.UsableTitle(t) {
plan.Title = t
}
if y := overrides[ovrYear]; y != "" {
+121
View File
@@ -2285,3 +2285,124 @@ func TestSweepLinking_LeavesOtherStates(t *testing.T) {
t.Errorf("review task moved to %q", st.downloads["2"].State)
}
}
// Кандидат с непригодным названием (голая пунктуация — TVDB правится
// сообществом, мусорные записи там штатны): пин названия не ставится, и в
// эффективном плане остаётся название распознавания. Иначе в библиотеке
// появился бы каталог «- (2000)», а на «.» раскладка упала бы ошибкой слоя
// layout — тот же исход, что и у канонического названия на авто-пути.
func TestChooseCandidate_UnusableTitleNotPinned(t *testing.T) {
for _, title := range []string{"-", ".", "..."} {
t.Run(title, func(t *testing.T) {
w, st := reviewWithCandidate(t, store.MetadataCandidate{
Provider: "tvdb", ProviderID: "269613",
Title: store.NullString(title),
Year: sql.NullInt64{Int64: 2014, Valid: true},
})
if err := w.ChooseCandidate(context.Background(), "1", st.candidates[0].ID); err != nil {
t.Fatalf("ChooseCandidate: %v", err)
}
if ov := st.overrides["1"]; ov[ovrTitle] != "" {
t.Errorf("непригодное название закреплено: %q", ov[ovrTitle])
}
// Провайдер, id и год закрепляются как обычно — матч верен.
if ov := st.overrides["1"]; ov[ovrProviderID] != "269613" || ov[ovrYear] != "2014" {
t.Errorf("overrides = %v", st.overrides["1"])
}
plan, _, _, err := w.effectivePlan(context.Background(), "1")
if err != nil {
t.Fatalf("effectivePlan: %v", err)
}
if plan.Title != "Догадка" {
t.Errorf("plan.Title = %q, want название распознавания", plan.Title)
}
})
}
}
// Кандидат, сохранённый ПРЕЖНЕЙ версией (до чистки на входе в список), лежит в
// БД грязным. Гарантия чистоты не может держаться на времени записи: чистка
// стоит на закреплении, в sourcePins, и идемпотентна — на новых строках это
// no-op. Без неё невидимка доезжает до имени каталога, потому что
// layout.sanitizeComponent категорию Cf не трогает.
func TestChooseCandidate_DirtyLegacyTitleSanitized(t *testing.T) {
w, st := reviewWithCandidate(t, store.MetadataCandidate{
Provider: "tvdb", ProviderID: "269613",
Title: store.NullString("Far\u200bgo"),
Year: sql.NullInt64{Int64: 2014, Valid: true},
})
if err := w.ChooseCandidate(context.Background(), "1", st.candidates[0].ID); err != nil {
t.Fatalf("ChooseCandidate: %v", err)
}
if got := st.overrides["1"][ovrTitle]; got != "Fargo" {
t.Errorf("закреплено %q, want %q — грязная строка из БД не очищена", got, "Fargo")
}
plan, _, _, err := w.effectivePlan(context.Background(), "1")
if err != nil {
t.Fatalf("effectivePlan: %v", err)
}
if plan.Title != "Fargo" {
t.Errorf("plan.Title = %q", plan.Title)
}
}
// Превью = применение: строка источника показывает ровно то название, которое
// закрепится по клику. Прежде превью считалось из сырого названия кандидата, а
// гейт пригодности стоял только на закреплении — экран обещал одно, раскладка
// делала другое.
func TestBuildSources_PreviewMatchesApply(t *testing.T) {
for _, c := range []struct{ name, dbTitle, want string }{
{"непригодное название", "-", "Догадка"},
{"грязное название", "Far\u200bgo", "Fargo"},
} {
t.Run(c.name, func(t *testing.T) {
w, st := reviewWithCandidate(t, store.MetadataCandidate{
Provider: "tvdb", ProviderID: "269613",
Title: store.NullString(c.dbTitle),
Year: sql.NullInt64{Int64: 2014, Valid: true},
})
rd, err := w.ReviewData(context.Background(), "1")
if err != nil {
t.Fatalf("ReviewData: %v", err)
}
var src *SourceOption
for i := range rd.Sources {
if rd.Sources[i].Kind == SourceCandidate {
src = &rd.Sources[i]
}
}
if src == nil {
t.Fatal("кандидат не попал в источники")
}
if src.Title != c.want {
t.Errorf("превью показывает %q, want %q", src.Title, c.want)
}
if err := w.ChooseCandidate(context.Background(), "1", st.candidates[0].ID); err != nil {
t.Fatalf("ChooseCandidate: %v", err)
}
plan, _, _, err := w.effectivePlan(context.Background(), "1")
if err != nil {
t.Fatalf("effectivePlan: %v", err)
}
if plan.Title != src.Title {
t.Errorf("превью %q, применилось %q — расхождение", src.Title, plan.Title)
}
})
}
}
// Пин, закреплённый ПРЕЖНЕЙ версией, лежит в overrides грязным, и обычное
// «Применить» его не переписывает — источник заново не выбирают. Поэтому чистка
// стоит и на чтении: иначе раздача, стоящая в ревью на момент выката, создаёт
// ровно тот каталог, ради которого затевалась правка.
func TestApplyOverrides_LegacyDirtyPinSanitized(t *testing.T) {
plan := recognize.Plan{Type: recognize.MediaMovie, Title: "Догадка", Year: 2000}
got := applyOverrides(plan, map[string]string{ovrTitle: "Fa\u200brgo"})
if got.Title != "Fargo" {
t.Errorf("plan.Title = %q, want %q — грязный пин доехал до раскладки", got.Title, "Fargo")
}
// Непригодный пин названием не становится: остаётся название распознавания.
if got := applyOverrides(plan, map[string]string{ovrTitle: "-"}); got.Title != "Догадка" {
t.Errorf("plan.Title = %q, want название распознавания", got.Title)
}
}