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
@@ -0,0 +1,133 @@
## 1. Разведка формы ответа
- [x] 1.1 Записать `docs/research/tvdb-search-translations.md`: форма `SearchResult`
(`name`, `translations`, `name_translated`, `primary_language`), смысл
параметра `language` у `/search`, версия swagger, честный провенанс
«сверено по документации, живым прогоном не подтверждено»
- [x] 1.2 Добавить запись в перечень `docs/research/README.md` → «Записи»
## 2. Клиент TVDB
- [x] 2.1 `TVDBConfig.Language` — абстрактный код `ru`|`en`, комментарий по образцу
`TMDBConfig.Language`
- [x] 2.2 `tvdbLocale(lang string) string` — тотальный `switch`, `ru``rus`,
default → `eng`; имя ровно как у соседа `tmdbLocale`; комментарий называет
`config.validate` источником множества
- [x] 2.3 `NewTVDB` кладёт выведенный код в поле клиента
- [x] 2.4 `tvdbSearchResp` объявляет `translations` как `json.RawMessage` и
раскладывает в `map[string]string` отдельно, с гашением ошибки: негодная
форма косметического поля не должна ронять разбор всей выдачи
- [x] 2.5 `Search` заполняет `Title` из перевода (ключ ищется `strings.EqualFold`,
выбор среди совпавших детерминирован, значение — после `strings.TrimSpace`)
с фолбэком на `name`, и `OriginalTitle` из `name`
- [x] 2.6 Убедиться, что параметр `language` в строку запроса `/search` НЕ попал
- [x] 2.7 Сигнал на операцию, когда во всей выдаче не встретилось ни одного ключа
языка ожидаемого вида: подозрение на иную форму ответа отличается от
штатного «перевода нет». Уровень `WARN``DEBUG` в проде выключен
(правка по находке 1 ревью кода)
- [x] 2.8 Дописать третий маппер (`metadata.tvdbLocale`) в перечень потребителей
кода языка в комментарии `internal/config/config.go``validate`
## 3. Проброс из точки входа
- [x] 3.1 `cmd/jellybit/serve.go``metadataProviders` передаёт
`Language: cfg.ContentLanguage()` в `TVDBConfig`
## 4. Тесты
- [x] 4.1 Стенд `fakeTVDB` в `tvdb_test.go` отдаёт запись с блоком `translations`
- [x] 4.2 Табличный тест: перевод есть → `Title` локализован; перевода на нужный
язык нет; перевод из одних пробелов; ключ в другом регистре; блока нет;
блок пришёл не картой. Во всех случаях, кроме первого, `Title` = primary
name; во всех без исключения `OriginalTitle` = primary name
- [x] 4.3 Тест на язык по умолчанию (`Language` не задан → берётся `eng`)
- [x] 4.4 Тест: строка запроса `/search` не содержит `language` ни при `ru`, ни при
пустом языке, и совпадает в обоих случаях
- [x] 4.5 Тест: негодный блок переводов не проваливает `Search` — кандидаты
возвращаются
- [x] 4.6 Тест: на один `Search` уходит ровно один HTTP-запрос к `/search`
(счётчик на стенде) — расход лимита ключа не растёт
- [x] 4.8 Тест на сигнал о неожиданной форме ответа: буфер логгера, 8 случаев,
включая штатный «перевода нет» с ожиданием отсутствия следа
- [x] 4.9 Тест на детерминизм выбора ключа перевода (200 прогонов на случай)
- [x] 4.10 Тест `TestMatchMetadata_TranslationMakesTwoStrong` в
`internal/recognize/metadata_test.go`: два кандидата совпадают с планом
разными осями — подтверждённого матча нет, обе записи уходят в review
- [x] 4.7 Интеграционный тест в `integration_test.go` за `TVDB_API_KEY` печатает
`Title` и `OriginalTitle`. **Пишется, но не запускается** — живых обращений
к TVDB в этой задаче нет
## 5. Учёт нерешённого
- [x] 5.1 Записать вопрос человеку в `docs/tasks/items/tvdb-title-locale.md`
(раздел «Вопросы» + тег `question`) через скилл `av-dev-pm:tasks`: форма
ответа TVDB не сверена живым API, нужен ручной прогон под ключом; плюс
расхождение с критерием приёмки A1 про параметр языка в запросе; плюс
двустороннее движение границы авто/review. **Сделано иначе, чем
написано**: раздел «Вопросы» у задачи в спринте краснит `tasks.py check`
и через него шаг `canon` гейта, а закрытие задачи стёрло бы вопрос вместе
с файлом. Вопрос вынесен отдельной задачей-разведкой
`docs/tasks/items/tvdb-search-response-live-check.md`
## 6. Гейт
- [x] 6.1 `openspec validate --strict tvdb-title-locale`
- [x] 6.2 `task gate` зелёный
## Критерии приёмки задачи
Пришли из `docs/tasks/items/tvdb-title-locale.md`; переписывать и занижать их
нельзя, исход по каждому идёт в доклад.
- [ ] A1 Запрос поиска TVDB содержит параметр языка, выведенный из
`[general].language` (оракул: тест на `httptest`-сервере в `tvdb_test.go`).
**Отменён решением 1 design.md**: параметр — фильтр выдачи, а не селектор
перевода. Вместо него проверяется обратное — параметра в запросе нет
(задача 4.4). Расхождение вынесено вопросом человеку
- [x] A2 При наличии перевода `Candidate.Title` приходит на языке настройки, при
отсутствии — равен primary name (оракул: табличный тест 4.2)
- [x] A3 `Candidate.OriginalTitle` у TVDB непуст и равен primary name (оракул: тот
же тест 4.2; интеграционный прогон за `TVDB_API_KEY` печатает оба поля —
**написан, но не запущен**, живых обращений к метабазе в задаче нет)
- [x] A4 Дельта-спека `metadata-match` заказывает локаль TVDB и фолбэк на оригинал
(оракул: `openspec validate --strict`)
## Приёмочные критерии от рубрики (ревью дизайна, проход `rubric`)
Рубрика порождена до чтения кода и дизайна. Пункты 1, 5, 7 закрываются **условно**:
они опираются на форму ответа `/search`, взятую из публичной документации, — стенд
`httptest` проверит поведение кода на предполагаемой форме, а не истинность
предположения.
- [x] R1 Фолбэк `Title` тотален по всем формам отсутствия перевода: блока нет; блок
пуст; ключа нет; значение пустое. Пустого `Title` при непустом `name` нет ни
на одной ветви. Оракул — строка табличного теста на каждую форму *(условно)*
- [x] R2 Строка запроса `/search` не содержит параметра языка ни при каком значении
настройки: утверждение об **отсутствии** ключа плюс равенство URL при `ru` и `en`
- [x] R3 `OriginalTitle` = primary name всегда, включая случай найденного перевода;
значение настройки языка его не смещает
- [x] R4 Язык имеет один источник и один вывод диалекта: ни константы
`tvdbDefaultLanguage`, ни фолбэка в конструкторе, ни второго места перевода
`ru`/`en``rus`/`eng`
- [x] R5 Разбор устойчив к форме, а не только к содержимому: лишние поля,
отсутствующие поля, `translations: null`, `translations` не картой, усечённое
тело — не паникуют и не проваливают `Search`; худший исход — фолбэк по R1.
Негодный ответ поиска **целиком** по-прежнему остаётся ошибкой *(условно)*
- [x] R6 Пустота значения проверяется **после** `TrimSpace`, а не до
- [x] R7 Ключ в неожиданной форме (иной регистр) не даёт молчаливого фолбэка: поиск
ключа регистронезависимый, исход зафиксирован тестом *(условно)*
- [x] R8 Число обращений к TVDB на одну сверку не растёт: один `Search` — один
HTTP-запрос, проверяется счётчиком на стенде
- [x] R9 Результат — функция ответа провайдера, а не порядка проходов: исход не
зависит от того, каким проходом пришёл кандидат; заполнение `OriginalTitle`
не заставляет следующий заход перебора искать по подменённому значению; на
фолбэке `Title == OriginalTitle` дедупликация ключей перебора не схлопывает
перебор до нуля запросов
- [x] R10 Секрет и контекст ведут себя как у остальных клиентов: ключ TVDB не
попадает в лог, в текст ошибки разбора и в сообщение с URL; `context`
доходит до запроса и отменяет его; таймаут из конфига
- [x] R11 У ответа есть предел (`maxBody` в `rawGet`), у выдачи — прежний потолок
кандидатов; второго канала без предела не заводится
- [x] R12 Факт фолбэка наблюдаем, но не шумен: один чекпоинт на операцию,
отсутствие перевода — не `ERROR`, недоступность опциональной метабазы
состояние загрузки не двигает