Сверка с метабазой промахивалась на иностранных фильмах с русским релиз-именем (кейс «Тёмный рыцарь»): поиск шёл по одной строке 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>
305 lines
11 KiB
Go
305 lines
11 KiB
Go
package recognize
|
||
|
||
import (
|
||
"context"
|
||
"errors"
|
||
"slices"
|
||
"testing"
|
||
|
||
"git.vakhrushev.me/av/jellybit/internal/metadata"
|
||
)
|
||
|
||
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 {
|
||
if f.name == "" {
|
||
return "tmdb"
|
||
}
|
||
return f.name
|
||
}
|
||
func (f *fakeProvider) Search(_ context.Context, q metadata.Query) ([]metadata.Candidate, error) {
|
||
f.searched++
|
||
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
|
||
}
|
||
|
||
func recognizerWith(p metadata.Provider) *Recognizer {
|
||
var providers []metadata.Provider
|
||
if p != nil {
|
||
providers = []metadata.Provider{p}
|
||
}
|
||
return New(&fakeLLM{}, providers, Config{}, testLogger())
|
||
}
|
||
|
||
func TestMatchMetadata_SingleStrong(t *testing.T) {
|
||
p := &fakeProvider{candidates: []metadata.Candidate{
|
||
{Provider: "tmdb", ID: "603", Title: "The Matrix", Year: 1999},
|
||
{Provider: "tmdb", ID: "604", Title: "The Matrix Reloaded", Year: 2003},
|
||
}}
|
||
r := recognizerWith(p)
|
||
m, _ := r.matchMetadata(context.Background(),
|
||
Plan{Type: MediaMovie, Title: "The Matrix", Year: 1999})
|
||
if m == nil {
|
||
t.Fatal("expected match")
|
||
}
|
||
if m.ProviderID != "603" || m.Provider != "tmdb" {
|
||
t.Errorf("match = %+v", m)
|
||
}
|
||
}
|
||
|
||
func TestMatchMetadata_AmbiguousNoMatch(t *testing.T) {
|
||
// Два кандидата с тем же названием и годом — неоднозначно.
|
||
p := &fakeProvider{candidates: []metadata.Candidate{
|
||
{ID: "1", Title: "Fargo", Year: 2014},
|
||
{ID: "2", Title: "Fargo", Year: 2014},
|
||
}}
|
||
r := recognizerWith(p)
|
||
if m, _ := r.matchMetadata(context.Background(),
|
||
Plan{Type: MediaSeries, Title: "Fargo", Year: 2014}); m != nil {
|
||
t.Errorf("ambiguous must not match, got %+v", m)
|
||
}
|
||
}
|
||
|
||
func TestMatchMetadata_ReturnsCandidates(t *testing.T) {
|
||
// Нет сильного матча (разные названия), но кандидаты собраны для выбора.
|
||
p := &fakeProvider{name: "tvmaze", candidates: []metadata.Candidate{
|
||
{Provider: "tvmaze", ID: "1", Title: "Fargo", Year: 2014, TagProvider: "tvdb", TagID: "269613"},
|
||
{Provider: "tvmaze", ID: "2", Title: "Fargo Idaho", Year: 2010},
|
||
{Provider: "tvmaze", ID: "1", Title: "Fargo", Year: 2014}, // дубль по id
|
||
}}
|
||
r := recognizerWith(p)
|
||
m, cands := r.matchMetadata(context.Background(),
|
||
Plan{Type: MediaSeries, Title: "Совсем другое", Year: 2014})
|
||
if m != nil {
|
||
t.Errorf("strong match не ожидался: %+v", m)
|
||
}
|
||
if len(cands) != 2 { // дубль отброшен
|
||
t.Fatalf("candidates = %d, want 2: %+v", len(cands), cands)
|
||
}
|
||
// CandidateTag даёт внешний TVDB-id для первого.
|
||
prov, id := CandidateTag(cands[0])
|
||
if prov != "tvdb" || id != "269613" {
|
||
t.Errorf("tag = %s/%s", prov, id)
|
||
}
|
||
}
|
||
|
||
func TestRecognize_PopulatesCandidates(t *testing.T) {
|
||
in := Input{Name: "Show.S01", Files: []File{{Path: "e1.mkv", Size: 1}}}
|
||
resp := `{"type":"series","title":"Show","year":2020,"confidence":0.9,
|
||
"files":[{"src":"e1.mkv","role":"episode","season":1,"episode":1}]}`
|
||
p := &fakeProvider{name: "tvmaze", candidates: []metadata.Candidate{
|
||
{Provider: "tvmaze", ID: "1", Title: "Show One", Year: 2020},
|
||
{Provider: "tvmaze", ID: "2", Title: "Show Two", Year: 2019},
|
||
}}
|
||
r := New(&fakeLLM{responses: []string{resp}}, []metadata.Provider{p}, Config{}, testLogger())
|
||
res, err := r.Recognize(context.Background(), in)
|
||
if err != nil {
|
||
t.Fatalf("Recognize: %v", err)
|
||
}
|
||
if res.Match != nil {
|
||
t.Errorf("strong match не ожидался")
|
||
}
|
||
if len(res.Candidates) != 2 {
|
||
t.Errorf("Result.Candidates = %d, want 2", len(res.Candidates))
|
||
}
|
||
}
|
||
|
||
func TestMatchMetadata_YearMismatch(t *testing.T) {
|
||
p := &fakeProvider{candidates: []metadata.Candidate{{ID: "1", Title: "X", Year: 1990}}}
|
||
r := recognizerWith(p)
|
||
if m, _ := r.matchMetadata(context.Background(),
|
||
Plan{Type: MediaMovie, Title: "X", Year: 2020}); m != nil {
|
||
t.Errorf("year mismatch must not match, got %+v", m)
|
||
}
|
||
}
|
||
|
||
func TestMatchMetadata_OriginalTitle(t *testing.T) {
|
||
p := &fakeProvider{candidates: []metadata.Candidate{
|
||
{ID: "1", Title: "Leon", OriginalTitle: "Léon", Year: 1994},
|
||
}}
|
||
r := recognizerWith(p)
|
||
m, _ := r.matchMetadata(context.Background(),
|
||
Plan{Type: MediaMovie, Title: "Léon", Year: 1994})
|
||
if m == nil || m.ProviderID != "1" {
|
||
t.Errorf("should match by original title, got %+v", m)
|
||
}
|
||
}
|
||
|
||
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{
|
||
name: "tvmaze",
|
||
candidates: []metadata.Candidate{
|
||
{Provider: "tvmaze", ID: "1", Title: "Fargo", Year: 2014, TagProvider: "tvdb", TagID: "269613"},
|
||
},
|
||
counts: map[int]int{1: 10},
|
||
}
|
||
r := recognizerWith(p)
|
||
m, _ := r.matchMetadata(context.Background(),
|
||
Plan{Type: MediaSeries, Title: "Fargo", Year: 2014})
|
||
if m == nil {
|
||
t.Fatal("expected match")
|
||
}
|
||
// Провенанс/тег — внешний TVDB-id, а не нативный tvmaze.
|
||
if m.Provider != "tvdb" || m.ProviderID != "269613" {
|
||
t.Errorf("match provider = %s/%s, want tvdb/269613", m.Provider, m.ProviderID)
|
||
}
|
||
if m.SeasonEpisodeCounts[1] != 10 {
|
||
t.Errorf("counts not fetched by native id: %+v", m.SeasonEpisodeCounts)
|
||
}
|
||
}
|
||
|
||
func TestMatchMetadata_SeriesFetchesCounts(t *testing.T) {
|
||
p := &fakeProvider{
|
||
candidates: []metadata.Candidate{{ID: "60622", Title: "Fargo", Year: 2014}},
|
||
counts: map[int]int{1: 10, 2: 10},
|
||
}
|
||
r := recognizerWith(p)
|
||
m, _ := r.matchMetadata(context.Background(),
|
||
Plan{Type: MediaSeries, Title: "Fargo", Year: 2014})
|
||
if m == nil || m.SeasonEpisodeCounts[1] != 10 {
|
||
t.Errorf("counts not fetched: %+v", m)
|
||
}
|
||
}
|
||
|
||
func TestMatchMetadata_ProviderErrorNoMatch(t *testing.T) {
|
||
p := &fakeProvider{searchErr: errors.New("upstream down")}
|
||
r := recognizerWith(p)
|
||
if m, _ := r.matchMetadata(context.Background(),
|
||
Plan{Type: MediaMovie, Title: "X", Year: 2000}); m != nil {
|
||
t.Errorf("provider error must yield no match, got %+v", m)
|
||
}
|
||
}
|
||
|
||
func TestMatchMetadata_Disabled(t *testing.T) {
|
||
r := recognizerWith(nil)
|
||
if m, _ := r.matchMetadata(context.Background(), Plan{Type: MediaMovie, Title: "X"}); m != nil {
|
||
t.Errorf("no providers → no match, got %+v", m)
|
||
}
|
||
}
|
||
|
||
func TestNormalize(t *testing.T) {
|
||
cases := map[string]string{
|
||
"The Matrix": "the matrix",
|
||
"Léon: The Pro!": "léon the pro",
|
||
" A B ": "a b",
|
||
"Привет, Мир": "привет мир",
|
||
"Тёмный рыцарь": "темный рыцарь", // ё → е
|
||
"ЁЖ": "еж",
|
||
}
|
||
for in, want := range cases {
|
||
if got := normalize(in); got != want {
|
||
t.Errorf("normalize(%q) = %q, want %q", in, got, want)
|
||
}
|
||
}
|
||
}
|
||
|
||
// Сквозной авто: LLM-план + матч в базе + чистая валидация → Decision.Auto.
|
||
func TestRecognize_AutoWithMatch(t *testing.T) {
|
||
in := Input{Name: "The.Matrix.1999", Files: []File{{Path: "m/film.mkv", Size: 1}}}
|
||
resp := `{"type":"movie","title":"The Matrix","year":1999,"confidence":0.95,
|
||
"provider_hint":"The Matrix","files":[{"src":"m/film.mkv","role":"main"}]}`
|
||
llmFake := &fakeLLM{responses: []string{resp}}
|
||
p := &fakeProvider{candidates: []metadata.Candidate{
|
||
{Provider: "tmdb", ID: "603", Title: "The Matrix", Year: 1999},
|
||
}}
|
||
r := New(llmFake, []metadata.Provider{p}, Config{}, testLogger())
|
||
|
||
res, err := r.Recognize(context.Background(), in)
|
||
if err != nil {
|
||
t.Fatalf("Recognize: %v", err)
|
||
}
|
||
if !res.Decision.Auto {
|
||
t.Errorf("expected auto, reasons: %v", res.Decision.Reasons)
|
||
}
|
||
if res.Match == nil || res.Match.ProviderID != "603" {
|
||
t.Errorf("match = %+v", res.Match)
|
||
}
|
||
}
|