Files
jellybit/internal/recognize/metadata_test.go
T
av fdbc781197 metadata: TVDB отдаёт локализованное название и оригинал
- локаль из [general].language применяется при разборе ответа /search, а в
  запрос не уходит: параметр language у TVDB — фильтр выдачи, а не селектор
  перевода (ADR-2026-08-07)
- Title берётся из блока translations с тотальным фолбэком на primary name,
  OriginalTitle — из primary name; форма ответа сверена по документации и
  живым прогоном не подтверждена (docs/research)
- неожиданная форма ответа даёт WARN: признак — отсутствие во всей выдаче
  ключей языка ожидаемого вида, а не неудача разбора блока
2026-08-07 15:17:05 +03:00

446 lines
18 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"
"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
exactYear bool // имитирует жёсткий year-фильтр базы: q.Year>0 отсекает по точному году
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
}
res := f.candidates
if f.byTitle != nil {
res = f.byTitle[q.Title]
}
if f.exactYear && q.Year > 0 {
var filtered []metadata.Candidate
for _, c := range res {
if c.Year == q.Year {
filtered = append(filtered, c)
}
}
return filtered, nil
}
return res, 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)
}
}
// Цена локализованного названия кандидата: множество осей сравнения выросло с
// одной до двух, и там, где сильный кандидат был один, их может стать двое —
// тогда подтверждённого матча нет и обе записи уходят в review. На этом счётчике
// держится инвариант «авто-раскладка только при подтверждённом матче».
func TestMatchMetadata_TranslationMakesTwoStrong(t *testing.T) {
p := &fakeProvider{candidates: []metadata.Candidate{
{Provider: "tvdb", ID: "1", Title: "Нэчжа", OriginalTitle: "哪吒之魔童降世", Year: 2019},
{Provider: "tvdb", ID: "2", Title: "Ne Zha", OriginalTitle: "Ne Zha", Year: 2019},
}}
r := recognizerWith(p)
m, cands := r.matchMetadata(context.Background(),
Plan{Type: MediaMovie, Title: "Нэчжа", OriginalTitle: "Ne Zha", Year: 2019})
if m != nil {
t.Errorf("двое сильных — подтверждённого матча быть не должно, got %+v", m)
}
if len(cands) != 2 {
t.Errorf("candidates = %d, want 2 (обе записи уходят в review)", len(cands))
}
}
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 TestMatchMetadata_YearlessFallbackOffByOne(t *testing.T) {
// Год плана off-by-one: exact-year фильтр запроса отсекает запись в pass 1,
// безгодовой pass 2 её возвращает, гейт ±1 принимает → авто-матч.
p := &fakeProvider{exactYear: true, candidates: []metadata.Candidate{
{Provider: "tmdb", ID: "42", Title: "Harold and the Purple Crayon", Year: 2024},
}}
r := recognizerWith(p)
m, _ := r.matchMetadata(context.Background(),
Plan{Type: MediaMovie, Title: "Harold and the Purple Crayon", Year: 2023})
if m == nil || m.ProviderID != "42" {
t.Fatalf("off-by-one год: ожидался матч через безгодовой проход, got %+v", m)
}
if p.searched != 2 {
t.Errorf("searched = %d, want 2 (pass1 с годом + pass2 без года)", p.searched)
}
}
func TestMatchMetadata_YearlessFallbackBigGapReviewOnly(t *testing.T) {
// Год расходится больше чем на 1: pass 2 вернёт запись, но гейт по году её
// отклонит — подтверждённого матча нет, кандидат уходит в review.
p := &fakeProvider{exactYear: true, candidates: []metadata.Candidate{
{Provider: "tmdb", ID: "7", Title: "X", Year: 2005},
}}
r := recognizerWith(p)
m, cands := r.matchMetadata(context.Background(),
Plan{Type: MediaMovie, Title: "X", Year: 2010})
if m != nil {
t.Errorf("расхождение года >1: авто-матч не ожидался, got %+v", m)
}
if len(cands) != 1 || cands[0].ID != "7" {
t.Errorf("кандидат должен собраться для review: %+v", cands)
}
}
func TestMatchMetadata_YearlessUnknownYearReviewOnly(t *testing.T) {
// Год плана известен, но у записи в базе год неизвестен: pass 1 (с годом) её
// не находит, pass 2 (без года) находит, но в fallback требуется известный
// год → авто-матча нет, кандидат уходит в review.
p := &fakeProvider{exactYear: true, candidates: []metadata.Candidate{
{Provider: "tmdb", ID: "9", Title: "X", Year: 0},
}}
r := recognizerWith(p)
m, cands := r.matchMetadata(context.Background(),
Plan{Type: MediaMovie, Title: "X", Year: 2020})
if m != nil {
t.Errorf("год кандидата неизвестен: авто-матч не ожидался, got %+v", m)
}
if len(cands) != 1 || cands[0].ID != "9" {
t.Errorf("кандидат должен собраться для review: %+v", cands)
}
}
func TestMatchMetadata_NoSecondPassWhenPass1Matches(t *testing.T) {
// pass 1 подтвердил матч — безгодовой проход не выполняется.
p := &fakeProvider{exactYear: true, candidates: []metadata.Candidate{
{Provider: "tmdb", ID: "1", Title: "X", Year: 2000},
}}
r := recognizerWith(p)
m, _ := r.matchMetadata(context.Background(),
Plan{Type: MediaMovie, Title: "X", Year: 2000})
if m == nil {
t.Fatal("ожидался матч в pass 1")
}
if p.searched != 1 {
t.Errorf("searched = %d, want 1 (второго прохода быть не должно)", p.searched)
}
}
func TestMatchMetadata_NoSecondPassWhenYearUnknown(t *testing.T) {
// Год неизвестен (0): второй проход был бы идентичен первому — не делаем.
p := &fakeProvider{candidates: []metadata.Candidate{
{Provider: "tmdb", ID: "1", Title: "Y", Year: 2000},
}}
r := recognizerWith(p)
// Название кандидата совпадает, но нет матча по названию плана — гейт не пройдёт;
// проверяем именно число запросов.
r.matchMetadata(context.Background(), Plan{Type: MediaMovie, Title: "Совсем другое"})
if p.searched != 1 {
t.Errorf("searched = %d, want 1 (год неизвестен → одного прохода достаточно)", p.searched)
}
}
func TestMatchMetadata_YearlessAmbiguousNoMatch(t *testing.T) {
// Несколько кандидатов из безгодового прохода, проходящих гейт → не подтверждён.
p := &fakeProvider{exactYear: true, candidates: []metadata.Candidate{
{Provider: "tmdb", ID: "1", Title: "Twin", Year: 2001},
{Provider: "tmdb", ID: "2", Title: "Twin", Year: 2001},
}}
r := recognizerWith(p)
m, cands := r.matchMetadata(context.Background(),
Plan{Type: MediaMovie, Title: "Twin", Year: 2000})
if m != nil {
t.Errorf("неоднозначность из pass 2: матч не ожидался, got %+v", m)
}
if len(cands) != 2 {
t.Errorf("оба кандидата должны собраться для review: %+v", cands)
}
}
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)
}
}
}
func TestNormalize_FoldsHomoglyph(t *testing.T) {
// «Harold» с кириллической буквой-двойником 'а' (U+0430) в первом слове
// нормализуется к тому же виду, что и чистая латиница (defense-in-depth на
// стороне кандидата базы).
dirty := "Hаrold and the Purple Crayon" // 'а' — кириллица
clean := "Harold and the Purple Crayon"
if normalize(dirty) != normalize(clean) {
t.Errorf("normalize(%q)=%q != normalize(%q)=%q", dirty, normalize(dirty), clean, normalize(clean))
}
}
// Сквозной авто: 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)
}
}