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()) }