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