recognize: название из метабазы санитизируется перед попаданием в план
- чистка стоит на каждой точке входа значения метабазы в план — сборка матча, копия кандидата для ревью, набор закреплённых значений источника и его чтение: гарантия, поставленная только на запись, обходится данными, сохранёнными прежними версиями - название, непригодное как имя каталога (пустое или без единой буквы и цифры), не подставляется — раздача уходит в review с названной причиной - гейт подтверждения матча не сдвинут: сравнение с планом идёт по значениям провайдера, чистится только копия, уходящая дальше
This commit is contained in:
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user