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) } }