metadata: TVDB отдаёт локализованное название и оригинал
- локаль из [general].language применяется при разборе ответа /search, а в запрос не уходит: параметр language у TVDB — фильтр выдачи, а не селектор перевода (ADR-2026-08-07) - Title берётся из блока translations с тотальным фолбэком на primary name, OriginalTitle — из primary name; форма ответа сверена по документации и живым прогоном не подтверждена (docs/research) - неожиданная форма ответа даёт WARN: признак — отсутствие во всей выдаче ключей языка ожидаемого вида, а не неудача разбора блока
This commit is contained in:
@@ -0,0 +1,238 @@
|
||||
## 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.
|
||||
Reference in New Issue
Block a user