Files
jellybit/openspec/changes/archive/2026-08-07-tvdb-title-locale/design.md
T
av fdbc781197 metadata: TVDB отдаёт локализованное название и оригинал
- локаль из [general].language применяется при разборе ответа /search, а в
  запрос не уходит: параметр language у TVDB — фильтр выдачи, а не селектор
  перевода (ADR-2026-08-07)
- Title берётся из блока translations с тотальным фолбэком на primary name,
  OriginalTitle — из primary name; форма ответа сверена по документации и
  живым прогоном не подтверждена (docs/research)
- неожиданная форма ответа даёт WARN: признак — отсутствие во всей выдаче
  ключей языка ожидаемого вида, а не неудача разбора блока
2026-08-07 15:17:05 +03:00

23 KiB
Raw Blame History

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: rurus, 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.