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,238 @@
## Context
`[general].language` (`ru`|`en`, дефолт `en`) правит промпт LLM и клиент TMDB
(архивный change `2026-07-24-content-language-switch`). Клиент TVDB
(`internal/metadata/tvdb.go`) о ней не знает: `TVDBConfig` поля языка не имеет,
`serve.go` собирает провайдер без него, `Search` шлёт `query`/`type`/`year`, а из
ответа разбирает единственное поле `name` — primary name записи. Итог: при
`language = ru` в карточку ревью и имя папки едет `哪吒之魔童降世`.
`Candidate.OriginalTitle` TVDB не заполняет вовсе, хотя `strongMatches`
(`internal/recognize/metadata.go`) сравнивает план и с `Title`, и с
`OriginalTitle`.
Архивный design прямо оставил TVDB вне охвата и предсказал эту задачу:
«второй локализуемый провайдер (TVDB с иным синтаксисом локали) добавит свой
маппинг у себя, а не расширит общий слой» (решение 2).
**Ограничение прогона.** Живых запросов к TVDB не делалось: `CLAUDE.md`
«Запреты» запрещает ходить в боевые метабазы из отладочных прогонов и расходовать
лимиты ключа. Форма ответа взята из публичной документации; провенанс —
`docs/research/tvdb-search-translations.md`.
## Goals / Non-Goals
**Goals:**
- `Candidate.Title` у TVDB — на языке `[general].language`, с тотальным фолбэком
на primary name.
- `Candidate.OriginalTitle` у TVDB — primary name.
- Знание диалекта локали TVDB живёт в `tvdb.go`, а не в общем слое.
**Non-Goals:**
- Локаль TVMaze — провайдер переводов не отдаёт, вне охвата.
- Локализация расширенных данных TVDB (`/series/{id}/extended`, `Director`):
разбор там языком не параметризован, задача его не трогает.
- Дополнительные запросы за переводом (`/movies/{id}/translations/{lang}`) —
это +1 обращение на кандидата при действующем лимите ключа.
- Изменение гейта авто-раскладки и правил матча.
- Языки помимо `ru`/`en` — множество задаёт `config.validate`.
## Decisions
**1. Параметр `language` в запрос `/search` не передаётся.** По
[swagger TVDB v4, версия 4.7.10](https://raw.githubusercontent.com/thetvdb/v4-api/main/docs/swagger.yml)
у `/search` есть параметр `language` с описанием «Restrict results to a specific
primary language. Should include the 3 character language code» — это **фильтр
выдачи**, а не селектор перевода. Передача `language=rus` отсекла бы записи,
основной язык которых не русский, то есть ровно наблюдаемый случай (`Ne Zha`,
основной язык `zho`). Сужение выдачи — это изменение входа гейта матча, а задача
такое явно запретила («матч и гейт авто-раскладки не трогаются»). Поэтому локаль
работает только на стороне разбора ответа.
Отклонённые альтернативы: (а) слать `language` и мириться с сужением — ломает
основной сценарий; (б) отдельный запрос `/movies/{id}/translations/{lang}` на
каждого кандидата — цена в лимитах ключа не окупает косметическое поле;
(в) заголовок `Accept-Language` — для v4 не документирован, была бы догадка.
**Цена решения названа:** критерий приёмки задачи «запрос поиска TVDB содержит
параметр языка» из-за этого не выполняется. Это дефект критерия, а не пропуск
работы — критерий писался под предположение о смысле параметра, которое
документация не подтверждает. Вопрос человеку записан.
**1a. Форма решения: где живёт выбор названия.** Разобраны три формы, не одна.
- **A — выбранная: клиент провайдера разрешает название при разборе ответа.**
Контракт `Candidate` не меняется, симметрия с TMDB держится на уровне типа,
правка локальна. Цена: код языка едет третьим питателем (`TVDBConfig.Language`
плюс строка в `serve.go`), прочие переводы выбрасываются на границе разбора,
правило фолбэка становится приватным знанием клиента — у второго
локализуемого провайдера это второй экземпляр правила.
- **B — `Candidate` несёт карту названий, выбор делает потребитель.** Язык у
потребителя уже есть (`recognize.Config.Language` заполняется тем же
`cfg.ContentLanguage()`), плюмбинг в клиент не нужен, а гейт уже сравнивает по
множеству названий (`normSet`). Отклонена по одной причине, и она решающая: у
TMDB локаль разрешается **запросом**, карты названий в ответе нет вовсе —
поле пришлось бы заполнять единственным ключом, и внутри одного доменного типа
завелись бы две формы. Асимметрия провайдеров переехала бы из клиента в общий
тип, где она дороже.
- **C — TVDB не локализуется вовсе: заполняется только `OriginalTitle`.** Ноль
нового знания и ноль плюмбинга, а `OriginalTitle` — реальная ось сравнения —
чинится всё равно. Отклонена: наблюдаемый случай `哪吒之魔童降世` чинился бы
только при включённом TMDB, давшем матч, то есть поведение стало бы молча
зависеть от набора включённых провайдеров.
**2. Источник перевода — карта `translations` ответа, не `name_translated`.**
В схеме `SearchResult` есть оба поля: `name_translated` (строка) и `translations`
(`TranslationSimple` — открытая карта «код языка → строка»). Семантика
`name_translated` в документации не описана вовсе, и заполняется оно, судя по
всему, поисковым индексом при заданном фильтре языка — которого мы не шлём
(решение 1). Карта `translations` самодостаточна: ключ известен, значение
однозначно. Читаем её, `name_translated` не трогаем. Взять непонятное поле в
фолбэк хуже, чем не взять: молча приехало бы название на неизвестном языке.
**3. Код языка выводит `tvdb.go` тотальным `switch`, по образцу `tmdbLocale`.**
`tvdbLocale(lang string) string`: `ru``rus`, default → `eng`. Имя ровно как у
соседа (`tmdbLocale`) — две функции одной роли обязаны собираться одним грепом.
Тотальность — defense in depth: непокрытый вход (пусто, неизвестный код) даёт
`eng`, а не пустой ключ, по которому фолбэк сработал бы всегда. Комментарий у
функции называет `config.validate` источником множества кодов — как у
`tmdbLocale`, и **обратная ссылка в `config.validate` дописывается тем же
change**: там перечислены потребители кода языка (`metadata.tmdbLocale`,
`recognize.languageDirective`), и третий обязан появиться в перечне, иначе
следующий язык молча даст английские названия у TVDB.
Отклонено: карта `map[string]string` в общем слое — вернуло бы знание диалекта
туда, откуда решение 2 архивного change его унесло. Отклонён и
`golang.org/x/text/language` (`Base.ISO3()` умеет ровно этот маппинг): в `go.mod`
его нет ни прямо, ни транзитивно, и заводить зависимость ради двухветочного
`switch` над множеством, которое валидирует `config.validate`, дороже.
**4. Фолбэк лестницей `translations[код]``name`, с обрезкой пробелов и
регистронезависимым ключом.** Значение перевода проверяется на пустоту после
`strings.TrimSpace` — карта может нести ключ с пустой строкой (в трекере v4-api
есть подтверждённый случай пустого `name` при непустых переводах, то есть пустые
строки в этих полях реальны), и в `Title` едет обрезанное значение. Ключ ищется
`strings.EqualFold`: карта переводов мала, а молчаливый фолбэк из-за регистра
ключа неотличим от «перевода нет» и в эксплуатации не диагностируется.
`OriginalTitle` всегда `name`, без фолбэка на перевод: пустой `OriginalTitle`
честнее подставного.
Выбор среди совпавших ключей детерминирован: порядок обхода карты в Go случаен, а
`EqualFold` совпадает и с `RUS`, и с юникод-эквивалентами простого case-folding
(`ſ` складывается в `s`). «Первый попавшийся» давал бы разное имя папки от
прогона к прогону на одном и том же ответе — точное совпадение сильнее, при его
отсутствии берётся лексикографически меньший ключ.
**4a. Блок переводов разбирается терпимо, а иная форма ответа видна в логе.**
`translations` объявляется `json.RawMessage` и раскладывается в
`map[string]string` отдельно, с гашением ошибки. Причина в том, что сегодня этого
поля в структуре нет вовсе: объявить его строгим типом значило бы завести новый
способ **уронить весь разбор ответа поиска** — негодная форма одного
косметического поля провалила бы `json.Unmarshal` целиком и убила бы кандидатов,
которые сейчас приезжают нормально. Ответ метабазы — недоверенный вход
(`docs/security.md`), и регрессия устойчивости ради косметики не окупается.
Обратная сторона решения — тихая деградация, и она гасится следом. Признак следа
**отсутствие во всей выдаче хотя бы одного трёхбуквенного ключа**, а не неудача
разбора блока: `json.Unmarshal` успешно кладёт в карту и `null`, и `{}`, и
словарь двухбуквенных кодов, то есть признак «разобралось» промолчал бы ровно на
главном названном риске. Штатное «перевода на этот язык нет» под условие не
подпадает — там ключи есть, нужного среди них нет.
Уровень — `WARN`, а не `DEBUG`. `DEBUG` в проде выключен (`[log].level = "info"`
по умолчанию, `docs/conventions/logging.md`), а деградация здесь молчаливая:
названия тихо уедут в фолбэк, гейт останется зелёным, карточка ревью не
изменится. По той же таблице уровней это «команде, может стать проблемой»:
предположение о внешнем контракте, возможно, неверно. Соседнее решение в том же
файле — `DEBUG` на обновление протухшего токена — осознанно и описывает
противоположный случай, рутину.
Спека при этом заказывает **наблюдаемость, а не уровень**: уровень принадлежит
конвенциям логирования, и норма, прибитая к `DEBUG`, потребовала бы нового change
на всякую его смену.
**5. Расширение множества названий кандидата названо, а не спрятано, — и оно
двунаправленное.** `strongMatches` сравнивает план с `{Title, OriginalTitle}`.
Сегодня у TVDB это `{primary name, ""}` — одно название; после изменения
`{перевод, primary name}` — два, и primary name из множества не исчезает.
Множество названий монотонно растёт — **но исход гейта монотонным не является**,
и это важнее: гейт требует **ровно одного** сильного кандидата. Отсюда два
направления, и оба реальны.
- Вверх: запись, которую раньше отсекал иероглифический primary name, теперь
проходит по переводу — задача, уходившая в review, пойдёт в авто. Это польза
задачи.
- Вниз: две разные записи могут совпасть с планом — одна переводом, другая
primary name (франшиза или ремейк с одним русским названием и годами в
пределах ±1). Единичный сильный матч становится двумя, и запись, шедшая в
авто, уйдёт в review.
Сам гейт (нормализация + условия требований «Подтверждение матча» и «Безгодовой
второй проход») не трогается, инвариант «авто-раскладка только при подтверждённом
матче» не двигается: движение вниз — в сторону человека, то есть безопасную.
Утверждение «всё, что матчилось раньше, матчится и теперь» здесь стояло и было
неверным; оно снято.
**6. Тестовая фикстура — константами в тесте пакета, без `testdata/`.**
`CLAUDE.md` → «Запреты»: каталог `testdata` в проекте не заводился. Стенд
`fakeTVDB` в `tvdb_test.go` расширяется блоком `translations`; случаи
перевод/фолбэк/отсутствие блока идут табличным тестом.
## Risks / Trade-offs
- [Форма ответа взята из документации, живым API не сверена] → интеграционный
тест за `TVDB_API_KEY` написан и печатает `Title`/`OriginalTitle`; человек
гоняет его вручную. Записка разведки помечает наблюдение как условие, а не
замер. Если живой ответ отдаёт двухбуквенные коды вместо трёхбуквенных, фолбэк
сработает тотально — исход побайтно совпадёт с сегодняшним (`Title` = primary
name), и тем он и опасен: гейт зелёный, карточка ревью прежняя, фича не
работает. Гасится DEBUG-следом из решения 4a — он отличает «переводов в выдаче
не оказалось вовсе» от «перевода на этот язык нет».
- [Расширение множества названий двигает границу авто/review в обе стороны] →
названо решением 5, гейт не ослаблен. Вверх: авто-раскладка по записи, чей
перевод совпал с названием плана при годе ±1 и единственном кандидате —
обратимо через `Undo`. Вниз: франшиза с одним русским названием даёт двух
сильных кандидатов, и задача уходит в review — потеря автоматизации, не потеря
данных. Рамка задачи «матч и гейт не трогаются» соблюдена буквально (логика
гейта та же), но вход гейта изменился, и это материал для уже открытого
вопроса человеку, а не повод менять решение.
- [Негодная форма блока переводов роняет весь разбор поиска] → снято решением 4a:
поле разбирается отдельно, ошибка гасится в тотальный фолбэк. Без этого
косметическое поле получило бы право убивать выдачу целиком.
- [`name_translated` игнорируется] → если он окажется полезнее карты, это правка
на пару строк; пока брать его — догадка.
- [Перевод может нести управляющие символы или иной скрипт] → **санитайзинг к
нему не применяется, и это надо знать точно**: `plan.Title = match.Title`
(`internal/recognize/recognize.go`) подставляется **после** `sanitizePlan`, то
есть название из метабазы входит в план вторым путём, мимо единственной точки
санитайзинга, а `layout.sanitizeComponent` снимает разделители пути и
управляющие символы ниже `0x20`, но не трогает категорию Cf (zero-width,
BOM, RLO) и не сворачивает гомоглифы. Ревью построило путь: значение перевода
попадает в имя каталога библиотеки дословно, при `auto = true`.
Инвариант «целевой путь строго под библиотекой» при этом **держится**
проверено на `Dune/../../etc`, `" .. "`, `"..."` и на имени в 400 символов,
выхода из-под корня нет. Новой недоверенной **границы** действительно не
появляется, но по другой причине, чем здесь стояло: класс уже существует у
TMDB, где `Title` и `OriginalTitle` разведены давно, и тот же обход работает
там. Что добавляет это изменение — распространение класса на второго
провайдера и то, что у TVDB поле, уезжающее в путь, впервые перестало
совпадать с полем, по которому прошёл гейт. Починка (`sanitizeTitle` к
`match.Title`/`match.Director` либо категория Cf в `layout`) выходит за рамку
задачи, меняет поведение уже работающего TMDB и отдана урожаем ревью.
## Migration Plan
Изменений конфига и схемы БД нет; миграция не заводится. Выкатка — обычный
бинарь. Откат — прежний бинарь, состояние совместимо в обе стороны (меняется
только содержимое текстовых полей вновь создаваемых кандидатов). Уже сохранённые
`metadata_candidate` не переписываются: старые записи остаются с прежним `title`.
## Open Questions
- **Форма ответа `/search` не сверена живым API.** Нужен ручной прогон
`TVDB_API_KEY=… go test ./internal/metadata/ -run Integration -v` под ключом
человека, чтобы подтвердить: ключи `translations` трёхбуквенные, блок
приезжает в выдаче поиска без дополнительных параметров, `name` — именно
primary name. Вопрос записан в `docs/tasks/items/tvdb-title-locale.md`.
- **Критерий приёмки про параметр языка в запросе.** Решение 1 его отменяет;
человеку решать, переписать критерий или отвергнуть решение 1.