Files
jellybit/internal/recognize/metadata.go
T
avandClaude Opus 4.8 e2ea1840c9 Распознавание: санитайзинг названий от LLM + безгодовой фолбэк сверки
Кейс «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>
2026-07-10 15:45:19 +03:00

234 lines
10 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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())
}