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
+114 -11
View File
@@ -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
}