- локаль из [general].language применяется при разборе ответа /search, а в запрос не уходит: параметр language у TVDB — фильтр выдачи, а не селектор перевода (ADR-2026-08-07) - Title берётся из блока translations с тотальным фолбэком на primary name, OriginalTitle — из primary name; форма ответа сверена по документации и живым прогоном не подтверждена (docs/research) - неожиданная форма ответа даёт WARN: признак — отсутствие во всей выдаче ключей языка ожидаемого вида, а не неудача разбора блока
23 KiB
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
у /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.