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