Кейс «Harold and the Purple Crayon»: LLM отдал title с кириллической буквой-двойником, сырое название ушло в запрос TVDB дословно (не нашлось), а гейт нормализации кир/лат двойники не сворачивал — двойной промах, пустой список кандидатов, ручной ввод id. - recognition: санитайзинг человекочитаемых полей плана (title/original_title/ provider_hint) на границе разбора, до валидации: strip control/zero-width, collapse пробелов, потокенная свёртка homoglyph-двойников по курируемой кир↔лат таблице. files[].src не трогаем (обязаны биться с торрентом). - metadata-match: тот же fold в normalize (гейт) как defense-in-depth; безгодовой второй проход сверки как fallback при известном годе и промахе первого — восстанавливает off-by-one авто-матчи и пополняет кандидатов review. В fallback требуем известный год кандидата (год-unknown → review, не авто); гейт год ±1 и инвариант авто-матча не двигаются. Спеки recognition/metadata-match обновлены, change заархивирован. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
234 lines
10 KiB
Go
234 lines
10 KiB
Go
package recognize
|
||
|
||
import (
|
||
"context"
|
||
"strings"
|
||
"unicode"
|
||
|
||
"git.vakhrushev.me/av/jellybit/internal/logctx"
|
||
"git.vakhrushev.me/av/jellybit/internal/metadata"
|
||
)
|
||
|
||
// maxCandidates — потолок на число сохраняемых кандидатов для ручного выбора.
|
||
const maxCandidates = 8
|
||
|
||
// matchMetadata сверяет план с включёнными базами. Возвращает (а) единичный
|
||
// сильный матч — ровно один кандидат с совпадением названия и года (для него
|
||
// тянем число серий и используем для авто), либо nil; (б) список кандидатов
|
||
// из всех выполненных заходов (топ-N, дедуп) — чтобы человек мог выбрать в
|
||
// review, когда сильного матча нет. Ошибки провайдера не валят распознавание.
|
||
//
|
||
// Поиск идёт по нескольким названиям в порядке убывания силы ключа
|
||
// (original_title → title → provider_hint, см. searchKeys): базы индексированы
|
||
// прежде всего по оригинальным названиям. Останавливаемся, как только очередной
|
||
// ключ дал единичный сильный матч (ранний стоп — дешевле по обращениям к базе).
|
||
//
|
||
// Проходов сверки два: pass 1 — с годом плана (дешёвое сужение при верном годе),
|
||
// pass 2 — fallback без года, только если год известен и pass 1 не подтвердил
|
||
// матч. Exact-year фильтр запроса строже гейта strongMatches (год ±1): безгодовой
|
||
// проход восстанавливает off-by-one авто-матчи (запись, которую точный фильтр
|
||
// отсёк, а гейт принял бы) и пополняет кандидатов для review при бо́льших ошибках
|
||
// года. Гейт при этом не меняется (год ±1 по plan.Year), инвариант авто-матча не
|
||
// двигается. Кандидаты копятся через оба прохода (дедуп по provider:id, потолок).
|
||
func (r *Recognizer) matchMetadata(ctx context.Context, plan Plan) (*Match, []metadata.Candidate) {
|
||
if len(r.providers) == 0 {
|
||
return nil, nil
|
||
}
|
||
mt := metadata.Movie
|
||
if plan.Type == MediaSeries {
|
||
mt = metadata.Series
|
||
}
|
||
|
||
matchTitles := normSet(plan.Title, plan.OriginalTitle)
|
||
keys := searchKeys(plan)
|
||
|
||
// Год запроса по проходам: сначала год плана, затем 0 (без года) как fallback.
|
||
// При неизвестном годе второй проход был бы идентичен первому — не делаем.
|
||
queryYears := []int{plan.Year}
|
||
if plan.Year > 0 {
|
||
queryYears = append(queryYears, 0)
|
||
}
|
||
|
||
var match *Match
|
||
var candidates []metadata.Candidate
|
||
seen := map[string]bool{}
|
||
|
||
for _, qYear := range queryYears {
|
||
// Безгодовой fallback-проход (есть только при известном годе плана).
|
||
// В нём требуем известный год кандидата: off-by-one даёт авто-матч, а
|
||
// запись с неизвестным годом — только кандидат в review (год подтвердить
|
||
// нечем, авто было бы недо-подтверждённым). В pass 1 leniency yearMatches
|
||
// к unknown-году сохраняется как прежде.
|
||
fallback := qYear == 0 && plan.Year > 0
|
||
for _, key := range keys {
|
||
for _, p := range r.providers {
|
||
cands, err := p.Search(ctx, metadata.Query{Type: mt, Title: key, Year: qYear})
|
||
if err != nil {
|
||
// Сам вызов провайдера залогирован клиентом (ext.*-ERROR); здесь —
|
||
// доменное решение «пропускаем провайдера, пробуем следующий».
|
||
logctx.FromOr(ctx, r.log).Debug("metadata provider skipped", "provider", p.Name())
|
||
continue
|
||
}
|
||
|
||
// Копим кандидатов для выбора (дедуп по провайдеру+id, потолок).
|
||
for _, c := range cands {
|
||
ck := c.Provider + ":" + c.ID
|
||
if seen[ck] || len(candidates) >= maxCandidates {
|
||
continue
|
||
}
|
||
seen[ck] = true
|
||
candidates = append(candidates, c)
|
||
}
|
||
|
||
// Единичный сильный матч ищем у первого подходящего провайдера.
|
||
// Гейт по plan.Year (не qYear): безгодовой проход расширяет только
|
||
// выдачу запроса, но требует год кандидата ±1 (и известный — в
|
||
// fallback), поэтому условие подтверждения не ослабляется.
|
||
if match != nil {
|
||
continue
|
||
}
|
||
strong := strongMatches(cands, plan.Year, matchTitles, fallback)
|
||
if len(strong) != 1 {
|
||
continue
|
||
}
|
||
match = r.buildMatch(ctx, p, strong[0], mt)
|
||
}
|
||
if match != nil {
|
||
break
|
||
}
|
||
}
|
||
if match != nil {
|
||
break // pass 1 подтвердил матч — безгодовой проход не нужен
|
||
}
|
||
}
|
||
return match, candidates
|
||
}
|
||
|
||
// searchKeys строит ключи поиска по базе в порядке убывания силы:
|
||
// original_title → title → provider_hint. Пустые и нормализованно совпадающие
|
||
// с уже добавленным ключом пропускаем, чтобы не обращаться к базе дважды с тем
|
||
// же запросом (частый случай — российский фильм, где original_title дублирует
|
||
// title).
|
||
func searchKeys(plan Plan) []string {
|
||
var keys []string
|
||
seen := map[string]bool{}
|
||
for _, t := range []string{plan.OriginalTitle, plan.Title, plan.ProviderHint} {
|
||
if strings.TrimSpace(t) == "" {
|
||
continue
|
||
}
|
||
n := normalize(t)
|
||
if n == "" || seen[n] {
|
||
continue
|
||
}
|
||
seen[n] = true
|
||
keys = append(keys, t)
|
||
}
|
||
return keys
|
||
}
|
||
|
||
// buildMatch тянет число серий (по нативному id) и собирает Match с
|
||
// тег-предпочтительным провенансом.
|
||
func (r *Recognizer) buildMatch(ctx context.Context, p metadata.Provider, c metadata.Candidate, mt metadata.MediaType) *Match {
|
||
var counts map[int]int
|
||
if mt == metadata.Series {
|
||
if got, err := p.SeasonEpisodeCounts(ctx, c.ID); err == nil {
|
||
counts = got
|
||
} else {
|
||
logctx.FromOr(ctx, r.log).Debug("metadata episode counts skipped", "provider", p.Name(), "id", c.ID)
|
||
}
|
||
}
|
||
prov, pid := CandidateTag(c)
|
||
return &Match{
|
||
Provider: prov,
|
||
ProviderID: pid,
|
||
Title: c.Title,
|
||
Year: c.Year,
|
||
SeasonEpisodeCounts: counts,
|
||
}
|
||
}
|
||
|
||
// CandidateTag — провайдер и id для тега папки Jellyfin: внешний (из
|
||
// TagProvider/TagID, напр. TVMaze → tvdb/imdb), если есть, иначе сам провайдер
|
||
// поиска. Используется и в матче, и при сохранении кандидатов.
|
||
func CandidateTag(c metadata.Candidate) (provider, id string) {
|
||
if c.TagProvider != "" {
|
||
return c.TagProvider, c.TagID
|
||
}
|
||
return c.Provider, c.ID
|
||
}
|
||
|
||
// strongMatches оставляет кандидатов, чьё название совпадает с одним из
|
||
// названий плана (после нормализации) и год бьётся (±1 год), дедуплицируя
|
||
// по id. requireKnownYear (безгодовой fallback-проход) дополнительно отсекает
|
||
// кандидатов с неизвестным годом: авто-матч там требует подтверждённого года.
|
||
func strongMatches(cands []metadata.Candidate, year int, titles map[string]bool, requireKnownYear bool) []metadata.Candidate {
|
||
seen := map[string]bool{}
|
||
var out []metadata.Candidate
|
||
for _, c := range cands {
|
||
if requireKnownYear && c.Year == 0 {
|
||
continue
|
||
}
|
||
if !yearMatches(year, c.Year) {
|
||
continue
|
||
}
|
||
if !titles[normalize(c.Title)] && !titles[normalize(c.OriginalTitle)] {
|
||
continue
|
||
}
|
||
if seen[c.ID] {
|
||
continue
|
||
}
|
||
seen[c.ID] = true
|
||
out = append(out, c)
|
||
}
|
||
return out
|
||
}
|
||
|
||
// yearMatches: год известен у обоих и расходится не больше чем на 1 (разные
|
||
// базы по-разному датируют релиз), либо где-то год неизвестен.
|
||
func yearMatches(a, b int) bool {
|
||
if a == 0 || b == 0 {
|
||
return true
|
||
}
|
||
d := a - b
|
||
if d < 0 {
|
||
d = -d
|
||
}
|
||
return d <= 1
|
||
}
|
||
|
||
// normSet — множество нормализованных непустых названий.
|
||
func normSet(titles ...string) map[string]bool {
|
||
out := map[string]bool{}
|
||
for _, t := range titles {
|
||
if n := normalize(t); n != "" {
|
||
out[n] = true
|
||
}
|
||
}
|
||
return out
|
||
}
|
||
|
||
// normalize приводит название к сравнимому виду: нижний регистр, только
|
||
// буквы/цифры (юникод), одиночные пробелы. Букву ё сводим к е (частое
|
||
// расхождение написания: «Тёмный» vs «Темный»). Перед этим сворачиваем
|
||
// кирилло-латинские homoglyph-двойники (foldHomoglyphs) — defense-in-depth на
|
||
// случай двойников со стороны кандидата базы: гейт сильного матча должен быть
|
||
// устойчив к ним независимо от санитайзинга плана.
|
||
func normalize(s string) string {
|
||
var b strings.Builder
|
||
prevSpace := false
|
||
for _, r := range strings.ToLower(foldHomoglyphs(s)) {
|
||
if r == 'ё' {
|
||
r = 'е'
|
||
}
|
||
switch {
|
||
case unicode.IsLetter(r) || unicode.IsDigit(r):
|
||
b.WriteRune(r)
|
||
prevSpace = false
|
||
case !prevSpace:
|
||
b.WriteByte(' ')
|
||
prevSpace = true
|
||
}
|
||
}
|
||
return strings.TrimSpace(b.String())
|
||
}
|