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

239 lines
23 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
## 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.