Поиск по нескольким названиям при сверке с базой (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>
This commit is contained in:
@@ -3,6 +3,7 @@ package recognize
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"slices"
|
||||
"testing"
|
||||
|
||||
"git.vakhrushev.me/av/jellybit/internal/metadata"
|
||||
@@ -11,9 +12,11 @@ import (
|
||||
type fakeProvider struct {
|
||||
name string
|
||||
candidates []metadata.Candidate
|
||||
byTitle map[string][]metadata.Candidate // если задано — результат зависит от запроса
|
||||
counts map[int]int
|
||||
searchErr error
|
||||
searched int
|
||||
queries []string // строки запросов в порядке вызова
|
||||
}
|
||||
|
||||
func (f *fakeProvider) Name() string {
|
||||
@@ -22,9 +25,16 @@ func (f *fakeProvider) Name() string {
|
||||
}
|
||||
return f.name
|
||||
}
|
||||
func (f *fakeProvider) Search(_ context.Context, _ metadata.Query) ([]metadata.Candidate, error) {
|
||||
func (f *fakeProvider) Search(_ context.Context, q metadata.Query) ([]metadata.Candidate, error) {
|
||||
f.searched++
|
||||
return f.candidates, f.searchErr
|
||||
f.queries = append(f.queries, q.Title)
|
||||
if f.searchErr != nil {
|
||||
return nil, f.searchErr
|
||||
}
|
||||
if f.byTitle != nil {
|
||||
return f.byTitle[q.Title], nil
|
||||
}
|
||||
return f.candidates, nil
|
||||
}
|
||||
func (f *fakeProvider) SeasonEpisodeCounts(_ context.Context, _ string) (map[int]int, error) {
|
||||
return f.counts, nil
|
||||
@@ -132,6 +142,75 @@ func TestMatchMetadata_OriginalTitle(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestMatchMetadata_MatchByOriginalFirst(t *testing.T) {
|
||||
// Реальный кейс: русское релиз-имя, матч по оригинальному названию.
|
||||
// При сильном матче по первому ключу дальнейшие запросы не делаются.
|
||||
p := &fakeProvider{byTitle: map[string][]metadata.Candidate{
|
||||
"The Dark Knight": {{Provider: "tmdb", ID: "155", Title: "The Dark Knight", Year: 2008}},
|
||||
}}
|
||||
r := recognizerWith(p)
|
||||
m, _ := r.matchMetadata(context.Background(),
|
||||
Plan{Type: MediaMovie, Title: "Тёмный рыцарь", OriginalTitle: "The Dark Knight", Year: 2008})
|
||||
if m == nil || m.ProviderID != "155" {
|
||||
t.Fatalf("should match by original title, got %+v", m)
|
||||
}
|
||||
if want := []string{"The Dark Knight"}; !slices.Equal(p.queries, want) {
|
||||
t.Errorf("queries = %v, want %v (ранний стоп на оригинале)", p.queries, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMatchMetadata_FallbackToTitle(t *testing.T) {
|
||||
// Запрос по original ничего не дал — фолбэк на локализованный title.
|
||||
p := &fakeProvider{byTitle: map[string][]metadata.Candidate{
|
||||
"Wrong Original": {},
|
||||
"Fargo": {{Provider: "tmdb", ID: "1", Title: "Fargo", Year: 2014}},
|
||||
}}
|
||||
r := recognizerWith(p)
|
||||
m, _ := r.matchMetadata(context.Background(),
|
||||
Plan{Type: MediaSeries, Title: "Fargo", OriginalTitle: "Wrong Original", Year: 2014})
|
||||
if m == nil || m.ProviderID != "1" {
|
||||
t.Fatalf("should fall back to title, got %+v", m)
|
||||
}
|
||||
if want := []string{"Wrong Original", "Fargo"}; !slices.Equal(p.queries, want) {
|
||||
t.Errorf("queries = %v, want %v", p.queries, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMatchMetadata_FallbackToProviderHint(t *testing.T) {
|
||||
// Ни original, ни title не нашли — третий фолбэк по provider_hint.
|
||||
// Гейт по-прежнему требует совпадения названия кандидата с title/original.
|
||||
p := &fakeProvider{byTitle: map[string][]metadata.Candidate{
|
||||
"Orig": {},
|
||||
"Loc": {},
|
||||
"Hint": {{Provider: "tmdb", ID: "7", Title: "Loc", Year: 2000}},
|
||||
}}
|
||||
r := recognizerWith(p)
|
||||
m, _ := r.matchMetadata(context.Background(),
|
||||
Plan{Type: MediaMovie, Title: "Loc", OriginalTitle: "Orig", ProviderHint: "Hint", Year: 2000})
|
||||
if m == nil || m.ProviderID != "7" {
|
||||
t.Fatalf("should fall back to provider_hint, got %+v", m)
|
||||
}
|
||||
if want := []string{"Orig", "Loc", "Hint"}; !slices.Equal(p.queries, want) {
|
||||
t.Errorf("queries = %v, want %v", p.queries, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMatchMetadata_SkipsDuplicateKey(t *testing.T) {
|
||||
// Российский фильм: original_title дублирует title — база дёргается раз.
|
||||
p := &fakeProvider{byTitle: map[string][]metadata.Candidate{
|
||||
"Брат": {{Provider: "tmdb", ID: "1", Title: "Брат", Year: 1997}},
|
||||
}}
|
||||
r := recognizerWith(p)
|
||||
m, _ := r.matchMetadata(context.Background(),
|
||||
Plan{Type: MediaMovie, Title: "Брат", OriginalTitle: "Брат", Year: 1997})
|
||||
if m == nil || m.ProviderID != "1" {
|
||||
t.Fatalf("expected match, got %+v", m)
|
||||
}
|
||||
if p.searched != 1 {
|
||||
t.Errorf("searched = %d, want 1 (дубль-ключ пропущен)", p.searched)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMatchMetadata_TagFromExternal(t *testing.T) {
|
||||
// TVMaze-стиль: нативный id для счёта серий, внешний TVDB-id для тега.
|
||||
p := &fakeProvider{
|
||||
@@ -191,6 +270,8 @@ func TestNormalize(t *testing.T) {
|
||||
"Léon: The Pro!": "léon the pro",
|
||||
" A B ": "a b",
|
||||
"Привет, Мир": "привет мир",
|
||||
"Тёмный рыцарь": "темный рыцарь", // ё → е
|
||||
"ЁЖ": "еж",
|
||||
}
|
||||
for in, want := range cases {
|
||||
if got := normalize(in); got != want {
|
||||
|
||||
Reference in New Issue
Block a user