- чистка стоит на каждой точке входа значения метабазы в план — сборка матча, копия кандидата для ревью, набор закреплённых значений источника и его чтение: гарантия, поставленная только на запись, обходится данными, сохранёнными прежними версиями - название, непригодное как имя каталога (пустое или без единой буквы и цифры), не подставляется — раздача уходит в review с названной причиной - гейт подтверждения матча не сдвинут: сравнение с планом идёт по значениям провайдера, чистится только копия, уходящая дальше
196 lines
8.2 KiB
Go
196 lines
8.2 KiB
Go
package recognize
|
||
|
||
import (
|
||
"log/slog"
|
||
"strings"
|
||
"unicode"
|
||
|
||
"git.vakhrushev.me/av/jellybit/internal/metadata"
|
||
)
|
||
|
||
// homoglyphPairs — курируемая таблица визуально неотличимых кирилло-латинских
|
||
// пар (двойников). Реальная боль — русскоклавиатурные двойники в англоязычных
|
||
// названиях (кейс «Hаrold» с кир. `а`). Таблица используется в обе стороны:
|
||
// направление свёртки выбирает foldHomoglyphs по доминирующему скрипту токена.
|
||
// Единственный источник правды и для санитайзинга плана, и для нормализации в
|
||
// гейте матча (normalize).
|
||
var homoglyphPairs = []struct{ cyr, lat rune }{
|
||
// строчные
|
||
{'а', 'a'}, {'е', 'e'}, {'о', 'o'}, {'р', 'p'}, {'с', 'c'},
|
||
{'у', 'y'}, {'х', 'x'}, {'к', 'k'},
|
||
// заглавные
|
||
{'А', 'A'}, {'В', 'B'}, {'Е', 'E'}, {'К', 'K'}, {'М', 'M'},
|
||
{'Н', 'H'}, {'О', 'O'}, {'Р', 'P'}, {'С', 'C'}, {'Т', 'T'},
|
||
{'Х', 'X'},
|
||
}
|
||
|
||
var (
|
||
cyr2lat = map[rune]rune{}
|
||
lat2cyr = map[rune]rune{}
|
||
)
|
||
|
||
func init() {
|
||
for _, p := range homoglyphPairs {
|
||
cyr2lat[p.cyr] = p.lat
|
||
lat2cyr[p.lat] = p.cyr
|
||
}
|
||
}
|
||
|
||
// SanitizeTitle чистит человекочитаемое поле плана как недоверенный вывод LLM:
|
||
// (1) убирает управляющие и zero-width символы; (2) сводит пробелы к одиночным и
|
||
// обрезает края; (3) сворачивает homoglyph-двойники. Порядок важен: strip делаем
|
||
// до collapse, чтобы удаление zero-width не оставляло сдвоенных пробелов.
|
||
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),
|
||
// а не повод «чинить» путь. Логируем на Debug, когда значение реально изменилось
|
||
// (названия не относятся к секретам).
|
||
func sanitizePlan(p *Plan, log *slog.Logger) {
|
||
p.Title = sanitizeField(p.Title, "title", log)
|
||
p.OriginalTitle = sanitizeField(p.OriginalTitle, "original_title", log)
|
||
p.ProviderHint = sanitizeField(p.ProviderHint, "provider_hint", log)
|
||
}
|
||
|
||
func sanitizeField(v, field string, log *slog.Logger) string {
|
||
clean := SanitizeTitle(v)
|
||
if clean != v && log != nil {
|
||
log.Debug("recognition plan field sanitized", "field", field, "before", v, "after", clean)
|
||
}
|
||
return clean
|
||
}
|
||
|
||
// stripControl удаляет format-символы (zero-width, BOM, soft-hyphen — категория
|
||
// Cf) и управляющие C0/C1, кроме пробельных (\t\n\r и пр. остаются — их сведёт
|
||
// collapseSpaces).
|
||
func stripControl(s string) string {
|
||
return strings.Map(func(r rune) rune {
|
||
switch {
|
||
case unicode.Is(unicode.Cf, r):
|
||
return -1
|
||
case unicode.IsControl(r) && !unicode.IsSpace(r):
|
||
return -1
|
||
default:
|
||
return r
|
||
}
|
||
}, s)
|
||
}
|
||
|
||
// collapseSpaces сводит последовательности пробельных к одиночному пробелу и
|
||
// обрезает края.
|
||
func collapseSpaces(s string) string {
|
||
var b strings.Builder
|
||
b.Grow(len(s))
|
||
pendingSpace := false
|
||
for _, r := range s {
|
||
if unicode.IsSpace(r) {
|
||
pendingSpace = true
|
||
continue
|
||
}
|
||
if pendingSpace && b.Len() > 0 {
|
||
b.WriteByte(' ')
|
||
}
|
||
pendingSpace = false
|
||
b.WriteRune(r)
|
||
}
|
||
return b.String()
|
||
}
|
||
|
||
// foldHomoglyphs сворачивает кирилло-латинские homoglyph-двойники потокенно.
|
||
// Токен (максимальная последовательность букв) из одного скрипта не трогаем —
|
||
// билингвальность реальна, честная кириллица неприкосновенна. В смешанном токене
|
||
// определяем доминирующий скрипт по числу буквенных рун и заменяем
|
||
// буквы-меньшинство их двойниками из доминирующего скрипта; при равенстве
|
||
// скриптов токен не меняем. Не-буквенные символы (пробелы, цифры, пунктуация)
|
||
// разделяют токены и копируются как есть.
|
||
func foldHomoglyphs(s string) string {
|
||
runes := []rune(s)
|
||
var b strings.Builder
|
||
b.Grow(len(s))
|
||
for i := 0; i < len(runes); {
|
||
if !unicode.IsLetter(runes[i]) {
|
||
b.WriteRune(runes[i])
|
||
i++
|
||
continue
|
||
}
|
||
j := i
|
||
for j < len(runes) && unicode.IsLetter(runes[j]) {
|
||
j++
|
||
}
|
||
b.WriteString(foldToken(runes[i:j]))
|
||
i = j
|
||
}
|
||
return b.String()
|
||
}
|
||
|
||
func foldToken(tok []rune) string {
|
||
var cyr, lat int
|
||
for _, r := range tok {
|
||
switch {
|
||
case unicode.Is(unicode.Cyrillic, r):
|
||
cyr++
|
||
case unicode.Is(unicode.Latin, r):
|
||
lat++
|
||
}
|
||
}
|
||
if cyr == 0 || lat == 0 {
|
||
return string(tok) // одно-скриптовый токен — не трогаем
|
||
}
|
||
var m map[rune]rune
|
||
switch {
|
||
case lat > cyr:
|
||
m = cyr2lat // доминирует латиница — сворачиваем кириллические двойники
|
||
case cyr > lat:
|
||
m = lat2cyr
|
||
default:
|
||
return string(tok) // нет доминирующего скрипта — не трогаем
|
||
}
|
||
out := make([]rune, len(tok))
|
||
for i, r := range tok {
|
||
if repl, ok := m[r]; ok {
|
||
out[i] = repl
|
||
} else {
|
||
out[i] = r
|
||
}
|
||
}
|
||
return string(out)
|
||
}
|