metadata: TVDB отдаёт локализованное название и оригинал
- локаль из [general].language применяется при разборе ответа /search, а в запрос не уходит: параметр language у TVDB — фильтр выдачи, а не селектор перевода (ADR-2026-08-07) - Title берётся из блока translations с тотальным фолбэком на primary name, OriginalTitle — из primary name; форма ответа сверена по документации и живым прогоном не подтверждена (docs/research) - неожиданная форма ответа даёт WARN: признак — отсутствие во всей выдаче ключей языка ожидаемого вида, а не неудача разбора блока
This commit is contained in:
@@ -284,8 +284,8 @@ func (c *Config) validate() error {
|
||||
// Язык локализованного вывода: пусто (→ en) или один из кодов. Fail-fast,
|
||||
// как llm.type: мусорное значение не должно молча дефолтить. Множество
|
||||
// {ru, en} — канон; при добавлении кода синхронно расширь мапперы
|
||||
// metadata.tmdbLocale и recognize.languageDirective, иначе новый язык молча
|
||||
// даст английский вывод.
|
||||
// metadata.tmdbLocale, metadata.tvdbLocale и recognize.languageDirective,
|
||||
// иначе новый язык молча даст английский вывод.
|
||||
switch c.General.Language {
|
||||
case "", "ru", "en":
|
||||
default:
|
||||
|
||||
@@ -52,12 +52,23 @@ func TestIntegration_TVMaze(t *testing.T) {
|
||||
// пропускается; включается ключом:
|
||||
//
|
||||
// TVDB_API_KEY=... go test ./internal/metadata/ -run Integration -v
|
||||
//
|
||||
// Он же — единственный оракул на форму блока переводов в выдаче поиска:
|
||||
// docs/research/tvdb-search-translations.md записан по документации, живым
|
||||
// прогоном не подтверждён. Прогон под русской локалью печатает Title и
|
||||
// OriginalTitle: у movie 131155 «Нэчжа» ожидается в Title, а иероглифический
|
||||
// primary name — в OriginalTitle. Совпадение Title с OriginalTitle у иноязычной
|
||||
// записи означает, что перевод не доехал и предположение о форме неверно.
|
||||
func TestIntegration_TVDB(t *testing.T) {
|
||||
key := os.Getenv("TVDB_API_KEY")
|
||||
if key == "" {
|
||||
t.Skip("set TVDB_API_KEY to run")
|
||||
}
|
||||
c, err := metadata.NewTVDB(metadata.TVDBConfig{APIKey: key, Timeout: 20 * time.Second}, nil)
|
||||
c, err := metadata.NewTVDB(metadata.TVDBConfig{
|
||||
APIKey: key,
|
||||
Timeout: 20 * time.Second,
|
||||
Language: "ru",
|
||||
}, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("NewTVDB: %v", err)
|
||||
}
|
||||
@@ -73,11 +84,30 @@ func TestIntegration_TVDB(t *testing.T) {
|
||||
if i >= 5 {
|
||||
break
|
||||
}
|
||||
t.Logf(" id=%s title=%q year=%d", cd.ID, cd.Title, cd.Year)
|
||||
t.Logf(" id=%s title=%q original=%q year=%d", cd.ID, cd.Title, cd.OriginalTitle, cd.Year)
|
||||
}
|
||||
if len(cands) == 0 {
|
||||
t.Fatal("ожидался хотя бы один кандидат для Fargo")
|
||||
}
|
||||
if cands[0].OriginalTitle == "" {
|
||||
t.Error("OriginalTitle пуст: primary name в кандидат не доехал")
|
||||
}
|
||||
if cands[0].Title == "" {
|
||||
t.Error("Title пуст: фолбэк на primary name не сработал")
|
||||
}
|
||||
|
||||
// Иноязычная запись — тот случай, ради которого задача заводилась.
|
||||
// Расхождение Title и OriginalTitle подтверждает форму блока переводов.
|
||||
film, err := c.Search(ctx, metadata.Query{Type: metadata.Movie, Title: "Ne Zha", Year: 2019})
|
||||
if err != nil {
|
||||
t.Fatalf("Search(Ne Zha): %v", err)
|
||||
}
|
||||
for i, cd := range film {
|
||||
if i >= 5 {
|
||||
break
|
||||
}
|
||||
t.Logf(" ne zha: id=%s title=%q original=%q year=%d", cd.ID, cd.Title, cd.OriginalTitle, cd.Year)
|
||||
}
|
||||
|
||||
// Берём первого с непустым id и тянем число серий по сезонам.
|
||||
id := cands[0].ID
|
||||
|
||||
+114
-11
@@ -25,16 +25,37 @@ type TVDBConfig struct {
|
||||
Proxy string
|
||||
Timeout time.Duration
|
||||
BaseURL string // пусто → api4.thetvdb.com; задаётся в тестах
|
||||
// Language — абстрактный код языка вывода ("ru" | "en"); диалект локали TVDB
|
||||
// (трёхбуквенный код) выводит сам клиент (tvdbLocale). Знание диалекта живёт
|
||||
// здесь, у провайдера, который на нём говорит, а не в общем слое конфига.
|
||||
Language string
|
||||
}
|
||||
|
||||
// tvdbLocale переводит абстрактный код языка вывода в код языка TVDB
|
||||
// (трёхбуквенный, ISO 639-2). Тотальна: непокрытый вход (пусто, неизвестный код)
|
||||
// → eng, чтобы поиск перевода никогда не шёл по пустому ключу (тогда фолбэк
|
||||
// срабатывал бы всегда и молча). Множество кодов задаёт config.validate
|
||||
// ({ru, en}); при добавлении кода — синхронно добавь ветку здесь.
|
||||
func tvdbLocale(lang string) string {
|
||||
switch lang {
|
||||
case "ru":
|
||||
return "rus"
|
||||
default:
|
||||
return "eng"
|
||||
}
|
||||
}
|
||||
|
||||
// TVDB — клиент TheTVDB (API v4). Токен получается логином по apikey и
|
||||
// кэшируется; при 401 выполняется повторный логин. Формы ответов сверены с
|
||||
// живым API v4 (см. integration_test.go).
|
||||
// живым API v4 (см. integration_test.go) — кроме блока переводов в выдаче
|
||||
// поиска: он взят из публичной документации и живым прогоном не подтверждён
|
||||
// (docs/research/tvdb-search-translations.md).
|
||||
type TVDB struct {
|
||||
apiKey string
|
||||
baseURL string
|
||||
hc *http.Client
|
||||
log *slog.Logger
|
||||
apiKey string
|
||||
baseURL string
|
||||
language string
|
||||
hc *http.Client
|
||||
log *slog.Logger
|
||||
|
||||
mu sync.Mutex
|
||||
token string
|
||||
@@ -56,7 +77,13 @@ func NewTVDB(cfg TVDBConfig, logger *slog.Logger) (*TVDB, error) {
|
||||
if logger == nil {
|
||||
logger = slog.Default()
|
||||
}
|
||||
return &TVDB{apiKey: cfg.APIKey, baseURL: strings.TrimRight(base, "/"), hc: hc, log: logger}, nil
|
||||
return &TVDB{
|
||||
apiKey: cfg.APIKey,
|
||||
baseURL: strings.TrimRight(base, "/"),
|
||||
language: tvdbLocale(cfg.Language),
|
||||
hc: hc,
|
||||
log: logger,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (t *TVDB) Name() string { return "tvdb" }
|
||||
@@ -148,10 +175,66 @@ type tvdbSearchResp struct {
|
||||
TVDBID string `json:"tvdb_id"`
|
||||
Name string `json:"name"`
|
||||
Year string `json:"year"`
|
||||
// Translations — карта «код языка → название». Тип сырой намеренно:
|
||||
// строгий тип дал бы косметическому полю право провалить json.Unmarshal
|
||||
// всего ответа и убить кандидатов, которые сейчас приезжают нормально.
|
||||
// Форма поля живым API не подтверждена (см. docs/research/).
|
||||
Translations json.RawMessage `json:"translations"`
|
||||
} `json:"data"`
|
||||
}
|
||||
|
||||
// translatedName достаёт из сырого блока переводов название на языке lang.
|
||||
//
|
||||
// Второе значение — НЕ «перевода нет», а «форма ответа та, что мы предположили»:
|
||||
// нёс ли блок хоть один ключ вида трёхбуквенного кода языка. Разбор блока таким
|
||||
// признаком быть не может: json.Unmarshal успешно кладёт в карту и `null`, и
|
||||
// `{}`, и словарь двухбуквенных кодов, а именно двухбуквенные коды — главный
|
||||
// названный риск этого изменения (docs/research/tvdb-search-translations.md).
|
||||
// Признак «разобралось» промолчал бы ровно там, где нужен сигнал.
|
||||
//
|
||||
// Негодная форма блока при этом не ошибка разбора ответа, а тотальный фолбэк на
|
||||
// primary name: косметическое поле не получает права уронить выдачу поиска.
|
||||
//
|
||||
// Ключ ищется регистронезависимо — молчаливый фолбэк из-за регистра неотличим от
|
||||
// «перевода нет». Выбор среди совпавших детерминирован: порядок обхода карты в Go
|
||||
// случаен, а EqualFold совпадает и с `RUS`, и с юникод-эквивалентами простого
|
||||
// case-folding, так что «первый попавшийся» давал бы разное имя папки от прогона
|
||||
// к прогону на одном и том же ответе.
|
||||
func translatedName(raw json.RawMessage, lang string) (name string, sawLangKeys bool) {
|
||||
if len(raw) == 0 {
|
||||
return "", false
|
||||
}
|
||||
var m map[string]string
|
||||
if err := json.Unmarshal(raw, &m); err != nil || len(m) == 0 {
|
||||
// `null` и `{}` разбираются без ошибки, но полезной нагрузки не несут —
|
||||
// от отсутствия блока они неотличимы, и признаком формы быть не могут.
|
||||
return "", false
|
||||
}
|
||||
best := ""
|
||||
for k := range m {
|
||||
if len(k) == 3 {
|
||||
sawLangKeys = true
|
||||
}
|
||||
if !strings.EqualFold(k, lang) {
|
||||
continue
|
||||
}
|
||||
switch {
|
||||
case best == "", k == lang, best != lang && k < best:
|
||||
best = k
|
||||
}
|
||||
}
|
||||
if best == "" {
|
||||
return "", sawLangKeys
|
||||
}
|
||||
return strings.TrimSpace(m[best]), sawLangKeys
|
||||
}
|
||||
|
||||
// Search ищет сериал/фильм по названию и году.
|
||||
//
|
||||
// Параметр языка в запрос НЕ передаётся: у /search TVDB он фильтрует выдачу по
|
||||
// основному языку записи, а не выбирает перевод, и сузил бы результат ровно на
|
||||
// иноязычных записях. Локаль работает только на разборе ответа
|
||||
// (openspec/specs/metadata-match, docs/research/tvdb-search-translations.md).
|
||||
func (t *TVDB) Search(ctx context.Context, q Query) ([]Candidate, error) {
|
||||
typ := "series"
|
||||
if q.Type == Movie {
|
||||
@@ -166,19 +249,39 @@ func (t *TVDB) Search(ctx context.Context, q Query) ([]Candidate, error) {
|
||||
return nil, fmt.Errorf("tvdb search: %w", err)
|
||||
}
|
||||
out := make([]Candidate, 0, len(resp.Data))
|
||||
sawLangKeys := false
|
||||
for _, r := range resp.Data {
|
||||
if r.TVDBID == "" {
|
||||
continue
|
||||
}
|
||||
year, _ := strconv.Atoi(r.Year)
|
||||
title, sawKeys := translatedName(r.Translations, t.language)
|
||||
sawLangKeys = sawLangKeys || sawKeys
|
||||
if title == "" {
|
||||
title = r.Name // фолбэк тотален: перевода нет, он пуст или блок негоден
|
||||
}
|
||||
out = append(out, Candidate{
|
||||
Provider: "tvdb",
|
||||
ID: r.TVDBID,
|
||||
Title: r.Name,
|
||||
Year: year,
|
||||
URL: "https://www.thetvdb.com/dereferrer/" + typ + "/" + r.TVDBID,
|
||||
Provider: "tvdb",
|
||||
ID: r.TVDBID,
|
||||
Title: title,
|
||||
OriginalTitle: r.Name,
|
||||
Year: year,
|
||||
URL: "https://www.thetvdb.com/dereferrer/" + typ + "/" + r.TVDBID,
|
||||
})
|
||||
}
|
||||
// Во всей выдаче не встретилось ни одного трёхбуквенного кода языка —
|
||||
// подозрение, что форма ответа не та, что записана в разведке. Штатное
|
||||
// «перевода на этот язык нет» под условие не подпадает: там коды есть, просто
|
||||
// нужного среди них нет. Один чекпоинт на операцию.
|
||||
//
|
||||
// Уровень WARN, а не DEBUG: это не рутина, а «наше предположение о внешнем
|
||||
// контракте, возможно, неверно» (docs/conventions/logging.md — «команде, может
|
||||
// стать проблемой»). DEBUG в проде выключен, а деградация здесь молчаливая:
|
||||
// названия тихо уедут в фолбэк, и заметить это будет нечем. Ср. соседнее
|
||||
// решение про refresh токена выше — там DEBUG осознан, случай ровно обратный.
|
||||
if len(out) > 0 && !sawLangKeys {
|
||||
logctx.FromOr(ctx, t.log).Warn("tvdb search returned no language-coded translations", "tvdb_locale", t.language)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -1,10 +1,15 @@
|
||||
package metadata
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
)
|
||||
@@ -38,7 +43,8 @@ func fakeTVDB(t *testing.T, logins *atomic.Int32) *httptest.Server {
|
||||
if r.URL.Query().Get("type") != "series" || r.URL.Query().Get("query") != "Fargo" {
|
||||
t.Errorf("query = %v", r.URL.Query())
|
||||
}
|
||||
_, _ = w.Write([]byte(`{"data":[{"tvdb_id":"269613","name":"Fargo","year":"2014"}]}`))
|
||||
_, _ = w.Write([]byte(`{"data":[{"tvdb_id":"269613","name":"Fargo","year":"2014",
|
||||
"translations":{"rus":"Фарго","eng":"Fargo"}}]}`))
|
||||
}))
|
||||
mux.HandleFunc("/series/269613/extended", authed(func(w http.ResponseWriter, _ *http.Request) {
|
||||
_, _ = w.Write([]byte(`{"data":{"episodes":[
|
||||
@@ -52,13 +58,191 @@ func fakeTVDB(t *testing.T, logins *atomic.Int32) *httptest.Server {
|
||||
|
||||
func newTVDB(t *testing.T, url string) *TVDB {
|
||||
t.Helper()
|
||||
c, err := NewTVDB(TVDBConfig{APIKey: "k", BaseURL: url}, nil)
|
||||
return newTVDBLang(t, url, "")
|
||||
}
|
||||
|
||||
func newTVDBLang(t *testing.T, url, lang string) *TVDB {
|
||||
t.Helper()
|
||||
c, err := NewTVDB(TVDBConfig{APIKey: "k", BaseURL: url, Language: lang}, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("NewTVDB: %v", err)
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
// searchStand — стенд с одной записью поиска: тело ответа задаётся тестом,
|
||||
// строка запроса и число обращений к /search записываются для проверок.
|
||||
type searchStand struct {
|
||||
srv *httptest.Server
|
||||
mu sync.Mutex
|
||||
queries []string
|
||||
searches atomic.Int32
|
||||
}
|
||||
|
||||
func (s *searchStand) recorded() []string {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return append([]string(nil), s.queries...)
|
||||
}
|
||||
|
||||
func newSearchStand(t *testing.T, body string) *searchStand {
|
||||
t.Helper()
|
||||
s := &searchStand{}
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/login", func(w http.ResponseWriter, _ *http.Request) {
|
||||
_, _ = w.Write([]byte(`{"data":{"token":"tok"}}`))
|
||||
})
|
||||
mux.HandleFunc("/search", func(w http.ResponseWriter, r *http.Request) {
|
||||
s.searches.Add(1)
|
||||
s.mu.Lock()
|
||||
s.queries = append(s.queries, r.URL.RawQuery)
|
||||
s.mu.Unlock()
|
||||
_, _ = w.Write([]byte(body))
|
||||
})
|
||||
s.srv = httptest.NewServer(mux)
|
||||
t.Cleanup(s.srv.Close)
|
||||
return s
|
||||
}
|
||||
|
||||
// Локализованное название кандидата: перевод, фолбэк во всех его формах и
|
||||
// OriginalTitle, который равен primary name всегда.
|
||||
func TestTVDB_SearchTranslations(t *testing.T) {
|
||||
const primary = "哪吒之魔童降世"
|
||||
cases := []struct {
|
||||
name string
|
||||
lang string
|
||||
record string
|
||||
wantTitle string
|
||||
}{
|
||||
{"перевод есть", "ru", `"translations":{"rus":"Нэчжа","eng":"Ne Zha"}`, "Нэчжа"},
|
||||
{"перевода на язык нет", "ru", `"translations":{"eng":"Ne Zha"}`, primary},
|
||||
{"перевод из пробелов", "ru", `"translations":{"rus":" "}`, primary},
|
||||
{"ключ в другом регистре", "ru", `"translations":{"RUS":"Нэчжа"}`, "Нэчжа"},
|
||||
{"блока переводов нет", "ru", `"year":"2019"`, primary},
|
||||
{"блок пустой", "ru", `"translations":{}`, primary},
|
||||
{"блок не карта", "ru", `"translations":["Нэчжа"]`, primary},
|
||||
{"блок null", "ru", `"translations":null`, primary},
|
||||
{"язык по умолчанию — eng", "", `"translations":{"rus":"Нэчжа","eng":"Ne Zha"}`, "Ne Zha"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
body := `{"data":[{"tvdb_id":"131155","name":"` + primary + `","year":"2019",` + tc.record + `}]}`
|
||||
stand := newSearchStand(t, body)
|
||||
got, err := newTVDBLang(t, stand.srv.URL, tc.lang).
|
||||
Search(context.Background(), Query{Type: Movie, Title: "Ne Zha"})
|
||||
if err != nil {
|
||||
t.Fatalf("Search: %v", err)
|
||||
}
|
||||
if len(got) != 1 {
|
||||
t.Fatalf("кандидатов = %d, want 1 (негодный перевод не должен ронять выдачу)", len(got))
|
||||
}
|
||||
if got[0].Title != tc.wantTitle {
|
||||
t.Errorf("Title = %q, want %q", got[0].Title, tc.wantTitle)
|
||||
}
|
||||
if got[0].OriginalTitle != primary {
|
||||
t.Errorf("OriginalTitle = %q, want primary name %q", got[0].OriginalTitle, primary)
|
||||
}
|
||||
if n := stand.searches.Load(); n != 1 {
|
||||
t.Errorf("обращений к /search = %d, want 1 (лимит ключа не растёт)", n)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Сигнал «форма ответа не та, что записана в разведке». Он единственный, кто
|
||||
// отличает неверное предположение о чужом API от штатного «перевода нет», —
|
||||
// поэтому проверяется поимённо, а не через покрытие.
|
||||
func TestTVDB_SignalOnUnexpectedTranslationForm(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
record string
|
||||
wantSignal bool
|
||||
}{
|
||||
{"двухбуквенные коды", `"translations":{"ru":"Дюна","en":"Dune"}`, true},
|
||||
{"блок null", `"translations":null`, true},
|
||||
{"блок пустой", `"translations":{}`, true},
|
||||
{"блока нет", `"year":"2021"`, true},
|
||||
{"блок не карта", `"translations":["Дюна"]`, true},
|
||||
{"вложенные объекты", `"translations":{"rus":{"name":"Дюна"}}`, true},
|
||||
// Штатный случай: коды трёхбуквенные, нужного среди них нет — не сигнал.
|
||||
{"перевода на язык нет", `"translations":{"eng":"Dune"}`, false},
|
||||
{"перевод есть", `"translations":{"rus":"Дюна","eng":"Dune"}`, false},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
stand := newSearchStand(t,
|
||||
`{"data":[{"tvdb_id":"1","name":"Dune","year":"2021",`+tc.record+`}]}`)
|
||||
var buf bytes.Buffer
|
||||
log := slog.New(slog.NewTextHandler(&buf, &slog.HandlerOptions{Level: slog.LevelWarn}))
|
||||
c, err := NewTVDB(TVDBConfig{APIKey: "k", BaseURL: stand.srv.URL, Language: "ru"}, log)
|
||||
if err != nil {
|
||||
t.Fatalf("NewTVDB: %v", err)
|
||||
}
|
||||
got, err := c.Search(context.Background(), Query{Type: Movie, Title: "Dune"})
|
||||
if err != nil {
|
||||
t.Fatalf("Search: %v", err)
|
||||
}
|
||||
if len(got) != 1 {
|
||||
t.Fatalf("кандидатов = %d, want 1", len(got))
|
||||
}
|
||||
signal := strings.Contains(buf.String(), "no language-coded translations")
|
||||
if signal != tc.wantSignal {
|
||||
t.Errorf("сигнал = %v, want %v; лог: %q", signal, tc.wantSignal, buf.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Выбор среди EqualFold-совпавших ключей детерминирован: иначе один и тот же
|
||||
// ответ давал бы разное имя папки от прогона к прогону.
|
||||
func TestTVDB_TranslationKeyPickIsDeterministic(t *testing.T) {
|
||||
cases := []struct{ name, block, want string }{
|
||||
{"точное совпадение сильнее регистра", `{"rus":"Дюна","RUS":"HACK"}`, "Дюна"},
|
||||
{"без точного — лексикографически меньший", `{"RUS":"A","Rus":"B"}`, "A"},
|
||||
{"юникод-эквивалент case-folding", `{"rus":"Дюна","ruſ":"HACK"}`, "Дюна"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
for i := 0; i < 200; i++ {
|
||||
got, _ := translatedName(json.RawMessage(tc.block), "rus")
|
||||
if got != tc.want {
|
||||
t.Fatalf("прогон %d: got %q, want %q", i, got, tc.want)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Локаль работает только на разборе ответа: параметр языка в запрос не уходит,
|
||||
// и строка запроса не зависит от настройки.
|
||||
func TestTVDB_SearchQueryHasNoLanguage(t *testing.T) {
|
||||
const body = `{"data":[{"tvdb_id":"1","name":"X","year":"2000"}]}`
|
||||
var got []string
|
||||
for _, lang := range []string{"ru", "en", ""} {
|
||||
stand := newSearchStand(t, body)
|
||||
if _, err := newTVDBLang(t, stand.srv.URL, lang).
|
||||
Search(context.Background(), Query{Type: Movie, Title: "X", Year: 2000}); err != nil {
|
||||
t.Fatalf("Search(%q): %v", lang, err)
|
||||
}
|
||||
recorded := stand.recorded()
|
||||
if len(recorded) != 1 {
|
||||
t.Fatalf("запросов = %d", len(recorded))
|
||||
}
|
||||
q, err := url.ParseQuery(recorded[0])
|
||||
if err != nil {
|
||||
t.Fatalf("ParseQuery: %v", err)
|
||||
}
|
||||
if _, ok := q["language"]; ok {
|
||||
t.Errorf("language=%q в запросе при lang=%q: параметр сужает выдачу, слать его нельзя",
|
||||
q.Get("language"), lang)
|
||||
}
|
||||
got = append(got, recorded[0])
|
||||
}
|
||||
if got[0] != got[1] || got[1] != got[2] {
|
||||
t.Errorf("строка запроса зависит от языка: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTVDB_SearchAndLoginCached(t *testing.T) {
|
||||
var logins atomic.Int32
|
||||
srv := fakeTVDB(t, &logins)
|
||||
|
||||
@@ -153,6 +153,26 @@ func TestMatchMetadata_OriginalTitle(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// Цена локализованного названия кандидата: множество осей сравнения выросло с
|
||||
// одной до двух, и там, где сильный кандидат был один, их может стать двое —
|
||||
// тогда подтверждённого матча нет и обе записи уходят в 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) {
|
||||
// Реальный кейс: русское релиз-имя, матч по оригинальному названию.
|
||||
// При сильном матче по первому ключу дальнейшие запросы не делаются.
|
||||
|
||||
Reference in New Issue
Block a user