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:
av
2026-08-07 15:17:05 +03:00
parent 0c83385098
commit fdbc781197
19 changed files with 1444 additions and 21 deletions
+186 -2
View File
@@ -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)