Сверка с метабазой промахивалась на иностранных фильмах с русским релиз-именем (кейс «Тёмный рыцарь»): поиск шёл по одной строке provider_hint||title и игнорировал original_title, а базы индексированы прежде всего по оригинальным названиям. - matchMetadata ищет по ключам original_title → title → provider_hint с ранним стопом на первом единичном сильном матче; пустые и нормализованно-дублирующие ключи пропускаются, кандидаты для review копятся из всех заходов. - Промпт требует всегда заполнять title и original_title (дублировать при отсутствии оригинала / российском контенте; при неуверенности дублировать, не выдумывать). Разбор остаётся мягким к пустому original_title. - TMDB-поиск передаёт language (по умолчанию ru-RU, настраивается [metadata.tmdb].language); original_title не зависит от локали. - Нормализация названий сводит ё→е. Инварианты не ослаблены: авто только при подтверждённом единичном матче + структурной валидации + согласованности сигналов. Capability recognition впервые перенесена в OpenSpec; change архивирован. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
197 lines
7.0 KiB
Go
197 lines
7.0 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): базы индексированы
|
||
// прежде всего по оригинальным названиям. Останавливаемся, как только очередной
|
||
// ключ дал единичный сильный матч (ранний стоп — дешевле по обращениям к базе).
|
||
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)
|
||
|
||
var match *Match
|
||
var candidates []metadata.Candidate
|
||
seen := map[string]bool{}
|
||
|
||
for _, key := range searchKeys(plan) {
|
||
for _, p := range r.providers {
|
||
cands, err := p.Search(ctx, metadata.Query{Type: mt, Title: key, Year: plan.Year})
|
||
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)
|
||
}
|
||
|
||
// Единичный сильный матч ищем у первого подходящего провайдера.
|
||
if match != nil {
|
||
continue
|
||
}
|
||
strong := strongMatches(cands, plan.Year, matchTitles)
|
||
if len(strong) != 1 {
|
||
continue
|
||
}
|
||
match = r.buildMatch(ctx, p, strong[0], mt)
|
||
}
|
||
if match != nil {
|
||
break
|
||
}
|
||
}
|
||
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.
|
||
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 приводит название к сравнимому виду: нижний регистр, только
|
||
// буквы/цифры (юникод), одиночные пробелы. Букву ё сводим к е (частое
|
||
// расхождение написания: «Тёмный» vs «Темный»).
|
||
func normalize(s string) string {
|
||
var b strings.Builder
|
||
prevSpace := false
|
||
for _, r := range strings.ToLower(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())
|
||
}
|