package recognize import ( "context" "strings" "unicode" "git.vakhrushev.me/av/jellybit/internal/metadata" ) // matchMetadata сверяет план с включёнными базами: ищет по названию+году и, // если ровно один кандидат уверенно совпадает (название и год), возвращает // матч с официальным id и каноническим именем. Несколько кандидатов или их // отсутствие → nil (тогда авто-раскладки не будет). Ошибки провайдера не // валят распознавание — просто нет матча. func (r *Recognizer) matchMetadata(ctx context.Context, plan Plan) *Match { if len(r.providers) == 0 { return nil } mt := metadata.Movie if plan.Type == MediaSeries { mt = metadata.Series } searchTitle := plan.ProviderHint if strings.TrimSpace(searchTitle) == "" { searchTitle = plan.Title } matchTitles := normSet(plan.Title, plan.OriginalTitle) for _, p := range r.providers { cands, err := p.Search(ctx, metadata.Query{Type: mt, Title: searchTitle, Year: plan.Year}) if err != nil { r.log.Warn("recognize: metadata search failed", "provider", p.Name(), "err", err) continue } strong := strongMatches(cands, plan.Year, matchTitles) if len(strong) != 1 { continue } c := strong[0] match := &Match{Provider: c.Provider, ProviderID: c.ID, Title: c.Title, Year: c.Year} if mt == metadata.Series { if counts, cerr := p.SeasonEpisodeCounts(ctx, c.ID); cerr == nil { match.SeasonEpisodeCounts = counts } else { r.log.Warn("recognize: episode counts failed", "provider", p.Name(), "id", c.ID, "err", cerr) } } return match } return nil } // strongMatches оставляет кандидатов, чьё название совпадает с одним из // названий плана (после нормализации) и год бьётся (±1 год), дедуплицируя // по id. func strongMatches(cands []metadata.Candidate, year int, titles map[string]bool) []metadata.Candidate { seen := map[string]bool{} var out []metadata.Candidate for _, c := range cands { 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 приводит название к сравнимому виду: нижний регистр, только // буквы/цифры (юникод), одиночные пробелы. func normalize(s string) string { var b strings.Builder prevSpace := false for _, r := range strings.ToLower(s) { switch { case unicode.IsLetter(r) || unicode.IsDigit(r): b.WriteRune(r) prevSpace = false case !prevSpace: b.WriteByte(' ') prevSpace = true } } return strings.TrimSpace(b.String()) }