## 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.
