Files
jellybit/internal/recognize/metadata.go
T
avandClaude Opus 4.8 4fc8c41b3a Поиск по нескольким названиям при сверке с базой (recognition)
Сверка с метабазой промахивалась на иностранных фильмах с русским
релиз-именем (кейс «Тёмный рыцарь»): поиск шёл по одной строке
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>
2026-06-29 12:16:47 +03:00

197 lines
7.0 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): базы индексированы
// прежде всего по оригинальным названиям. Останавливаемся, как только очередной
// ключ дал единичный сильный матч (ранний стоп — дешевле по обращениям к базе).
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())
}