From fdbc781197ed4f73145ff9260d0d4d7cec266885 Mon Sep 17 00:00:00 2001 From: Anton Vakhrushev Date: Fri, 7 Aug 2026 15:17:05 +0300 Subject: [PATCH] =?UTF-8?q?metadata:=20TVDB=20=D0=BE=D1=82=D0=B4=D0=B0?= =?UTF-8?q?=D1=91=D1=82=20=D0=BB=D0=BE=D0=BA=D0=B0=D0=BB=D0=B8=D0=B7=D0=BE?= =?UTF-8?q?=D0=B2=D0=B0=D0=BD=D0=BD=D0=BE=D0=B5=20=D0=BD=D0=B0=D0=B7=D0=B2?= =?UTF-8?q?=D0=B0=D0=BD=D0=B8=D0=B5=20=D0=B8=20=D0=BE=D1=80=D0=B8=D0=B3?= =?UTF-8?q?=D0=B8=D0=BD=D0=B0=D0=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - локаль из [general].language применяется при разборе ответа /search, а в запрос не уходит: параметр language у TVDB — фильтр выдачи, а не селектор перевода (ADR-2026-08-07) - Title берётся из блока translations с тотальным фолбэком на primary name, OriginalTitle — из primary name; форма ответа сверена по документации и живым прогоном не подтверждена (docs/research) - неожиданная форма ответа даёт WARN: признак — отсутствие во всей выдаче ключей языка ожидаемого вида, а не неудача разбора блока --- cmd/jellybit/serve.go | 7 +- config.example.toml | 2 +- ...R-2026-08-07-tvdb-locale-reads-response.md | 66 +++++ docs/adr/README.md | 1 + docs/research/README.md | 4 + docs/research/tvdb-search-translations.md | 97 +++++++ docs/review.md | 12 + internal/config/config.go | 4 +- internal/metadata/integration_test.go | 34 ++- internal/metadata/tvdb.go | 125 ++++++++- internal/metadata/tvdb_test.go | 188 +++++++++++++- internal/recognize/metadata_test.go | 20 ++ .../.openspec.yaml | 2 + .../2026-08-07-tvdb-title-locale/design.md | 238 ++++++++++++++++++ .../2026-08-07-tvdb-title-locale/proposal.md | 78 ++++++ .../review/report.md | 215 ++++++++++++++++ .../specs/metadata-match/spec.md | 120 +++++++++ .../2026-08-07-tvdb-title-locale/tasks.md | 133 ++++++++++ openspec/specs/metadata-match/spec.md | 119 +++++++++ 19 files changed, 1444 insertions(+), 21 deletions(-) create mode 100644 docs/adr/ADR-2026-08-07-tvdb-locale-reads-response.md create mode 100644 docs/research/tvdb-search-translations.md create mode 100644 openspec/changes/archive/2026-08-07-tvdb-title-locale/.openspec.yaml create mode 100644 openspec/changes/archive/2026-08-07-tvdb-title-locale/design.md create mode 100644 openspec/changes/archive/2026-08-07-tvdb-title-locale/proposal.md create mode 100644 openspec/changes/archive/2026-08-07-tvdb-title-locale/review/report.md create mode 100644 openspec/changes/archive/2026-08-07-tvdb-title-locale/specs/metadata-match/spec.md create mode 100644 openspec/changes/archive/2026-08-07-tvdb-title-locale/tasks.md diff --git a/cmd/jellybit/serve.go b/cmd/jellybit/serve.go index 0752791..58bf47c 100644 --- a/cmd/jellybit/serve.go +++ b/cmd/jellybit/serve.go @@ -275,9 +275,10 @@ func metadataProviders(cfg *config.Config, logger *slog.Logger) ([]metadata.Prov // пропускаем (сервис стартует), а не падаем. if cfg.Metadata.TVDB.Enabled && cfg.Metadata.TVDB.APIKey != "" { p, err := metadata.NewTVDB(metadata.TVDBConfig{ - APIKey: cfg.Metadata.TVDB.APIKey, - Proxy: cfg.Metadata.TVDB.Proxy, - Timeout: cfg.Metadata.TVDB.Timeout.Std(), + APIKey: cfg.Metadata.TVDB.APIKey, + Proxy: cfg.Metadata.TVDB.Proxy, + Timeout: cfg.Metadata.TVDB.Timeout.Std(), + Language: cfg.ContentLanguage(), }, logger) if err != nil { return nil, fmt.Errorf("tvdb provider: %w", err) diff --git a/config.example.toml b/config.example.toml index d4b0f01..031e663 100644 --- a/config.example.toml +++ b/config.example.toml @@ -49,7 +49,7 @@ timeout = "10s" # таймаут запроса к TMDB; Go enabled = false # включить провайдера TVDB api_key = "" # секрет: ключ TVDB; обязателен, если enabled (заполняет деплой) proxy = "" # опц. HTTP-прокси; пусто = без прокси -timeout = "10s" # таймаут запроса к TVDB; Go-duration (s/m/h) +timeout = "10s" # таймаут запроса к TVDB; Go-duration (s/m/h). Локаль названий задаёт [general].language: в запрос поиска не уходит, применяется при разборе ответа [metadata.tvmaze] enabled = false # включить провайдера TVMaze; без ключа, только сериалы (тег [tvdbid-…] из externals) diff --git a/docs/adr/ADR-2026-08-07-tvdb-locale-reads-response.md b/docs/adr/ADR-2026-08-07-tvdb-locale-reads-response.md new file mode 100644 index 0000000..774c795 --- /dev/null +++ b/docs/adr/ADR-2026-08-07-tvdb-locale-reads-response.md @@ -0,0 +1,66 @@ +# Локаль TVDB читается из ответа поиска, а не передаётся в запрос + +- **Дата:** 2026-08-07 +- **Источник:** [openspec/changes/archive/2026-08-07-tvdb-title-locale/design.md](../../openspec/changes/archive/2026-08-07-tvdb-title-locale/design.md), + решение 1 и решение 1a + +## Контекст + +Глобальная настройка `[general].language` правит промпт LLM и клиент TMDB +([ADR решения 2](../../openspec/changes/archive/2026-07-24-content-language-switch/design.md)), +но до клиента TVDB не доезжала. TVDB отдавал primary name — название на языке +оригинала, — и оно попадало в карточку ревью и в имя папки Jellyfin как есть. + +Очевидный подход, записанный прямо в постановке задачи и в её критерии приёмки: +добавить параметр языка в запрос `/search`, как это сделано для TMDB. От него +отказались. + +## Решение + +**Параметр языка в запрос поиска TVDB не передаётся. Локаль применяется только +при разборе ответа: `Candidate.Title` берётся из блока переводов, `OriginalTitle` +— из primary name.** + +Цитата решения 1 архивного `design.md`: + +> По [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`). Сужение +> выдачи — это изменение входа гейта матча, а задача такое явно запретила. + +Тем самым два провайдера намеренно устроены по-разному: у TMDB локаль едет в +запрос, у TVDB читается из ответа. Асимметрия оставлена в клиентах, а не поднята +в общий тип: у TMDB карты названий в ответе нет вовсе, и общий тип пришлось бы +заполнять единственным ключом (решение 1a, форма B). + +## Рассмотренные варианты + +- **Слать `language` и мириться с сужением выдачи.** Ломает основной сценарий: + иноязычные записи, ради которых задача заводилась, пропадут из поиска. +- **Отдельный запрос `/movies/{id}/translations/{lang}` на каждого кандидата.** + Цена в лимитах ключа не окупает косметическое поле. +- **Заголовок `Accept-Language`.** Для v4 не документирован — была бы догадка. +- **`Candidate` несёт карту названий, выбор делает потребитель** (форма B). Язык + у потребителя уже есть, плюмбинг не нужен, но у TMDB карты в ответе нет — + внутри одного доменного типа завелись бы две формы. +- **TVDB не локализуется вовсе, заполняется только `OriginalTitle`** (форма C). + Тогда наблюдаемый случай чинится только при включённом TMDB, давшем матч, — + поведение молча зависело бы от набора включённых провайдеров. + +## Последствия + +- Критерий приёмки задачи «запрос поиска содержит параметр языка» выполнен быть + не может и отменён этим решением. Расхождение вынесено вопросом человеку — + разведка [tvdb-search-response-live-check](../tasks/items/tvdb-search-response-live-check.md). +- **Решение опирается на документацию, а не на замер.** Семантика параметра и + форма блока переводов живым API не подтверждены — + [research/tvdb-search-translations.md](../research/tvdb-search-translations.md). + Если ручной прогон под ключом покажет иное, эта запись пересматривается новой, + а не правится. +- Заполнение `OriginalTitle` дало кандидату TVDB две оси сравнения вместо одной. + Логика гейта не менялась, но его вход изменился в обе стороны: запись, которую + отсекал иероглифический primary name, теперь может пройти по переводу, а две + разные записи могут совпасть с планом разными названиями и увести задачу в + review. Инвариант «авто-раскладка только при подтверждённом матче» не двигается. diff --git a/docs/adr/README.md b/docs/adr/README.md index 26062c0..4928734 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -42,6 +42,7 @@ | Дата | Запись | Статус | | --- | --- | --- | +| 2026-08-07 | [Локаль TVDB читается из ответа поиска, а не передаётся в запрос](ADR-2026-08-07-tvdb-locale-reads-response.md) | — | | 2026-08-06 | [Спека следует за кодом, когда гарантия недостижима, а окно узкое](ADR-2026-08-06-spec-follows-code-on-narrow-window.md) | — | | 2026-08-04 | [Конвейер ревью и пайплайн задачи переезжают в плагины](ADR-2026-08-04-review-pipeline-to-plugin.md) | — | | 2026-07-24 | [Локальная сборка образа + доставка docker save/load](ADR-2026-07-24-local-image-build.md) | — | diff --git a/docs/research/README.md b/docs/research/README.md index 66906d5..d98d413 100644 --- a/docs/research/README.md +++ b/docs/research/README.md @@ -35,3 +35,7 @@ LLM-эндпоинта на живых раздачах. Автоматичес - [torrent-bencode-limits.md](torrent-bencode-limits.md) — границы разбора `.torrent` в `anacrolix/torrent`: аллокация по объявленной длине строки, паники разбора, отсутствие «имени-заглушки». Проверено на `v1.61.0`. +- [tvdb-search-translations.md](tvdb-search-translations.md) — переводы в ответе + поиска TheTVDB v4: карта `translations`, параметр `language` как фильтр + выдачи, вырожденные значения. Сверено по swagger `4.7.10`, **живым прогоном + не подтверждено**. diff --git a/docs/research/tvdb-search-translations.md b/docs/research/tvdb-search-translations.md new file mode 100644 index 0000000..e58afd3 --- /dev/null +++ b/docs/research/tvdb-search-translations.md @@ -0,0 +1,97 @@ +# Переводы в ответе поиска TheTVDB v4 + +Клиент TVDB (`internal/metadata/tvdb.go`) берёт локализованное название кандидата +из ответа `/search`. Здесь записано, откуда взята форма этого ответа и чего в ней +не подтверждено. + +**Провенанс — и он слабый.** Всё ниже сверено **по публичной документации** +TheTVDB API v4, файл `docs/swagger.yml` репозитория `thetvdb/v4-api`, поле +`info.version` = `4.7.10` (прочитано 2026-08-07). **Живым прогоном не +подтверждено ни одно наблюдение**: `CLAUDE.md` → «Запреты» запрещает ходить в +боевые метабазы из отладочных прогонов и расходовать лимиты ключа. Всё +дальнейшее — **условие, а не замер**. Оракул, который это закроет, написан и ждёт +человека: + +``` +TVDB_API_KEY=… go test ./internal/metadata/ -run Integration -v +``` + +Он печатает `Title` и `OriginalTitle` первых кандидатов. + +## Параметр `language` у `/search` — фильтр, а не селектор перевода + +Дословно из swagger, параметр `language` эндпоинта `/search`: + +> Restrict results to a specific primary language. Should include the 3 character +> language code. + +То есть он **сужает выдачу** по основному языку записи, а не выбирает, на каком +языке вернуть название. Форум TheTVDB подтверждает направление: маршруты +`/search` не возвращают записи, которых нет на указанном языке. + +Следствие для нас: передача `language=rus` отсекла бы ровно те записи, ради +которых заводилась задача, — у `Ne Zha` основной язык `zho`. Поэтому запрос +поиска параметром языка **не параметризуется**, а локаль работает только на +стороне разбора ответа. Это заказано спекой (`openspec/specs/metadata-match/`, +требование «Локализованное название кандидата TVDB»). + +Расхождение с TMDB намеренное: у TMDB `language` — именно селектор +локализованного поля, и там он в запрос уходит. + +## Поля `SearchResult`, относящиеся к названию + +Из схемы `SearchResult` того же swagger: + +| Поле | Тип по схеме | Что берём | +|---|---|---| +| `name` | `string` | primary name записи — идёт в `Candidate.OriginalTitle` и служит фолбэком для `Title` | +| `translations` | `TranslationSimple` | карта «код языка → название»; из неё берём `Candidate.Title` | +| `name_translated` | `string` | **не используем** | +| `overviews`, `overview_translated` | описания | не используем | +| `primary_language` | `string` | не используем | +| `translationsWithLang` | массив строк | не используем | + +`TranslationSimple` в самом файле swagger описан как открытая карта (свободные +ключи со строковыми значениями); **полного текста этой схемы вычитать не +удалось** — документ в местах чтения обрывался. Форма «карта кода языка в +строку» принята по описанию поля и по обсуждениям в трекере `thetvdb/v4-api`, +где встречаются фрагменты вида `"translations": {"eng": "…"}`. Это самое слабое +место записки: если реальная форма иная (список объектов, двухбуквенные ключи), +разбор молча уйдёт в фолбэк. + +**Почему не `name_translated`.** Семантика поля в документации не описана +вовсе — не сказано ни на каком языке оно приходит, ни от чего зависит. +Правдоподобно, что заполняет его поисковый индекс при заданном фильтре +`language`, которого мы не шлём. Взять его в фолбэк значило бы получить название +на неизвестном языке молча; карта `translations` самодостаточна. + +## Что известно про вырожденные значения + +В трекере `thetvdb/v4-api` есть подтверждённый случай, когда `name` приезжает +**пустой строкой** при непустом блоке переводов (issue про `"name":"" must not be +empty`). Отсюда два следствия для разбора, оба заказаны спекой: + +- пустая строка в этих полях реальна, поэтому пустота значения перевода + проверяется после обрезки пробелов; +- блок переводов может нести ключ с пустым значением — это не «перевод есть». + +## Как мы защищаемся от того, что запись неверна + +Наблюдение не подтверждено, поэтому разбор устроен так, чтобы ошибка записки +стоила как можно меньше: + +- блок переводов разбирается **отдельно от остального ответа** и его негодная + форма гасится в фолбэк: косметическое поле не получает права уронить выдачу + поиска целиком; +- ключ ищется регистронезависимо; +- если в выдаче не разобрался **ни один** блок переводов, клиент пишет строку + DEBUG. Это единственный сигнал, отличающий «форма ответа не та, что здесь + записана» от штатного «перевода на этот язык нет»: без него неверное + предположение жило бы в бою неограниченно долго при зелёном гейте. + +## Условие пересмотра + +Записка протухает от смены версии API TheTVDB (сегодня v4, swagger 4.7.10) и от +любого ручного прогона интеграционного теста: первый же живой ответ обязан +заменить здесь предположения на наблюдения, а слова «живым прогоном не +подтверждено» — на дату и результат прогона. diff --git a/docs/review.md b/docs/review.md index b6683a0..2ffccb6 100644 --- a/docs/review.md +++ b/docs/review.md @@ -12,6 +12,18 @@ Go-сервиса и что здесь уже проскакивало. Устр [CLAUDE.md](../CLAUDE.md) → «Гейт». Вопрос про уже проверенное вытесняет вопрос про непроверенное — места в прогоне столько же. +**В worktree гейт краснеет ложно, и это не находка.** Ветки задач живут в +`tmp/wt-<задача>` — внутри самого репозитория. Два следствия, оба наблюдались: + +- кэш `golangci-lint` переживает смену каталога и отдаёт результаты прошлого + прогона из **основного** дерева. Признак — пути в `tmp/gate/lint.log` + начинаются с `../../internal/`, то есть указывают наружу worktree, и жалобы + приходят на файлы, которых дифф не касался. Лечится + `golangci-lint cache clean` перед прогоном; +- пробы проходов ревью, оставленные в `tmp/`, линтуются вместе с проектом: + `.go`-файл со `fmt.Printf` в `tmp/` краснит шаг `lint` через `forbidigo`. + Проход обязан за собой убирать, а оркестратор — сверять `tmp/` перед гейтом. + **Severity не выводится проходом заново.** Она стоит рядом с формулировкой инварианта в [CLAUDE.md](../CLAUDE.md) → «Инварианты», обратимость — там же в «Работа» → «Необратимое». Шкала ущерба берётся оттуда, а порядок ценностей — diff --git a/internal/config/config.go b/internal/config/config.go index e5c7885..f6ad91e 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -284,8 +284,8 @@ func (c *Config) validate() error { // Язык локализованного вывода: пусто (→ en) или один из кодов. Fail-fast, // как llm.type: мусорное значение не должно молча дефолтить. Множество // {ru, en} — канон; при добавлении кода синхронно расширь мапперы - // metadata.tmdbLocale и recognize.languageDirective, иначе новый язык молча - // даст английский вывод. + // metadata.tmdbLocale, metadata.tvdbLocale и recognize.languageDirective, + // иначе новый язык молча даст английский вывод. switch c.General.Language { case "", "ru", "en": default: diff --git a/internal/metadata/integration_test.go b/internal/metadata/integration_test.go index fb9f085..c87e2e8 100644 --- a/internal/metadata/integration_test.go +++ b/internal/metadata/integration_test.go @@ -52,12 +52,23 @@ func TestIntegration_TVMaze(t *testing.T) { // пропускается; включается ключом: // // TVDB_API_KEY=... go test ./internal/metadata/ -run Integration -v +// +// Он же — единственный оракул на форму блока переводов в выдаче поиска: +// docs/research/tvdb-search-translations.md записан по документации, живым +// прогоном не подтверждён. Прогон под русской локалью печатает Title и +// OriginalTitle: у movie 131155 «Нэчжа» ожидается в Title, а иероглифический +// primary name — в OriginalTitle. Совпадение Title с OriginalTitle у иноязычной +// записи означает, что перевод не доехал и предположение о форме неверно. func TestIntegration_TVDB(t *testing.T) { key := os.Getenv("TVDB_API_KEY") if key == "" { t.Skip("set TVDB_API_KEY to run") } - c, err := metadata.NewTVDB(metadata.TVDBConfig{APIKey: key, Timeout: 20 * time.Second}, nil) + c, err := metadata.NewTVDB(metadata.TVDBConfig{ + APIKey: key, + Timeout: 20 * time.Second, + Language: "ru", + }, nil) if err != nil { t.Fatalf("NewTVDB: %v", err) } @@ -73,11 +84,30 @@ func TestIntegration_TVDB(t *testing.T) { if i >= 5 { break } - t.Logf(" id=%s title=%q year=%d", cd.ID, cd.Title, cd.Year) + t.Logf(" id=%s title=%q original=%q year=%d", cd.ID, cd.Title, cd.OriginalTitle, cd.Year) } if len(cands) == 0 { t.Fatal("ожидался хотя бы один кандидат для Fargo") } + if cands[0].OriginalTitle == "" { + t.Error("OriginalTitle пуст: primary name в кандидат не доехал") + } + if cands[0].Title == "" { + t.Error("Title пуст: фолбэк на primary name не сработал") + } + + // Иноязычная запись — тот случай, ради которого задача заводилась. + // Расхождение Title и OriginalTitle подтверждает форму блока переводов. + film, err := c.Search(ctx, metadata.Query{Type: metadata.Movie, Title: "Ne Zha", Year: 2019}) + if err != nil { + t.Fatalf("Search(Ne Zha): %v", err) + } + for i, cd := range film { + if i >= 5 { + break + } + t.Logf(" ne zha: id=%s title=%q original=%q year=%d", cd.ID, cd.Title, cd.OriginalTitle, cd.Year) + } // Берём первого с непустым id и тянем число серий по сезонам. id := cands[0].ID diff --git a/internal/metadata/tvdb.go b/internal/metadata/tvdb.go index 6d2342f..ed44c89 100644 --- a/internal/metadata/tvdb.go +++ b/internal/metadata/tvdb.go @@ -25,16 +25,37 @@ type TVDBConfig struct { Proxy string Timeout time.Duration BaseURL string // пусто → api4.thetvdb.com; задаётся в тестах + // Language — абстрактный код языка вывода ("ru" | "en"); диалект локали TVDB + // (трёхбуквенный код) выводит сам клиент (tvdbLocale). Знание диалекта живёт + // здесь, у провайдера, который на нём говорит, а не в общем слое конфига. + Language string +} + +// tvdbLocale переводит абстрактный код языка вывода в код языка TVDB +// (трёхбуквенный, ISO 639-2). Тотальна: непокрытый вход (пусто, неизвестный код) +// → eng, чтобы поиск перевода никогда не шёл по пустому ключу (тогда фолбэк +// срабатывал бы всегда и молча). Множество кодов задаёт config.validate +// ({ru, en}); при добавлении кода — синхронно добавь ветку здесь. +func tvdbLocale(lang string) string { + switch lang { + case "ru": + return "rus" + default: + return "eng" + } } // TVDB — клиент TheTVDB (API v4). Токен получается логином по apikey и // кэшируется; при 401 выполняется повторный логин. Формы ответов сверены с -// живым API v4 (см. integration_test.go). +// живым API v4 (см. integration_test.go) — кроме блока переводов в выдаче +// поиска: он взят из публичной документации и живым прогоном не подтверждён +// (docs/research/tvdb-search-translations.md). type TVDB struct { - apiKey string - baseURL string - hc *http.Client - log *slog.Logger + apiKey string + baseURL string + language string + hc *http.Client + log *slog.Logger mu sync.Mutex token string @@ -56,7 +77,13 @@ func NewTVDB(cfg TVDBConfig, logger *slog.Logger) (*TVDB, error) { if logger == nil { logger = slog.Default() } - return &TVDB{apiKey: cfg.APIKey, baseURL: strings.TrimRight(base, "/"), hc: hc, log: logger}, nil + return &TVDB{ + apiKey: cfg.APIKey, + baseURL: strings.TrimRight(base, "/"), + language: tvdbLocale(cfg.Language), + hc: hc, + log: logger, + }, nil } func (t *TVDB) Name() string { return "tvdb" } @@ -148,10 +175,66 @@ type tvdbSearchResp struct { TVDBID string `json:"tvdb_id"` Name string `json:"name"` Year string `json:"year"` + // Translations — карта «код языка → название». Тип сырой намеренно: + // строгий тип дал бы косметическому полю право провалить json.Unmarshal + // всего ответа и убить кандидатов, которые сейчас приезжают нормально. + // Форма поля живым API не подтверждена (см. docs/research/). + Translations json.RawMessage `json:"translations"` } `json:"data"` } +// translatedName достаёт из сырого блока переводов название на языке lang. +// +// Второе значение — НЕ «перевода нет», а «форма ответа та, что мы предположили»: +// нёс ли блок хоть один ключ вида трёхбуквенного кода языка. Разбор блока таким +// признаком быть не может: json.Unmarshal успешно кладёт в карту и `null`, и +// `{}`, и словарь двухбуквенных кодов, а именно двухбуквенные коды — главный +// названный риск этого изменения (docs/research/tvdb-search-translations.md). +// Признак «разобралось» промолчал бы ровно там, где нужен сигнал. +// +// Негодная форма блока при этом не ошибка разбора ответа, а тотальный фолбэк на +// primary name: косметическое поле не получает права уронить выдачу поиска. +// +// Ключ ищется регистронезависимо — молчаливый фолбэк из-за регистра неотличим от +// «перевода нет». Выбор среди совпавших детерминирован: порядок обхода карты в Go +// случаен, а EqualFold совпадает и с `RUS`, и с юникод-эквивалентами простого +// case-folding, так что «первый попавшийся» давал бы разное имя папки от прогона +// к прогону на одном и том же ответе. +func translatedName(raw json.RawMessage, lang string) (name string, sawLangKeys bool) { + if len(raw) == 0 { + return "", false + } + var m map[string]string + if err := json.Unmarshal(raw, &m); err != nil || len(m) == 0 { + // `null` и `{}` разбираются без ошибки, но полезной нагрузки не несут — + // от отсутствия блока они неотличимы, и признаком формы быть не могут. + return "", false + } + best := "" + for k := range m { + if len(k) == 3 { + sawLangKeys = true + } + if !strings.EqualFold(k, lang) { + continue + } + switch { + case best == "", k == lang, best != lang && k < best: + best = k + } + } + if best == "" { + return "", sawLangKeys + } + return strings.TrimSpace(m[best]), sawLangKeys +} + // Search ищет сериал/фильм по названию и году. +// +// Параметр языка в запрос НЕ передаётся: у /search TVDB он фильтрует выдачу по +// основному языку записи, а не выбирает перевод, и сузил бы результат ровно на +// иноязычных записях. Локаль работает только на разборе ответа +// (openspec/specs/metadata-match, docs/research/tvdb-search-translations.md). func (t *TVDB) Search(ctx context.Context, q Query) ([]Candidate, error) { typ := "series" if q.Type == Movie { @@ -166,19 +249,39 @@ func (t *TVDB) Search(ctx context.Context, q Query) ([]Candidate, error) { return nil, fmt.Errorf("tvdb search: %w", err) } out := make([]Candidate, 0, len(resp.Data)) + sawLangKeys := false for _, r := range resp.Data { if r.TVDBID == "" { continue } year, _ := strconv.Atoi(r.Year) + title, sawKeys := translatedName(r.Translations, t.language) + sawLangKeys = sawLangKeys || sawKeys + if title == "" { + title = r.Name // фолбэк тотален: перевода нет, он пуст или блок негоден + } out = append(out, Candidate{ - Provider: "tvdb", - ID: r.TVDBID, - Title: r.Name, - Year: year, - URL: "https://www.thetvdb.com/dereferrer/" + typ + "/" + r.TVDBID, + Provider: "tvdb", + ID: r.TVDBID, + Title: title, + OriginalTitle: r.Name, + Year: year, + URL: "https://www.thetvdb.com/dereferrer/" + typ + "/" + r.TVDBID, }) } + // Во всей выдаче не встретилось ни одного трёхбуквенного кода языка — + // подозрение, что форма ответа не та, что записана в разведке. Штатное + // «перевода на этот язык нет» под условие не подпадает: там коды есть, просто + // нужного среди них нет. Один чекпоинт на операцию. + // + // Уровень WARN, а не DEBUG: это не рутина, а «наше предположение о внешнем + // контракте, возможно, неверно» (docs/conventions/logging.md — «команде, может + // стать проблемой»). DEBUG в проде выключен, а деградация здесь молчаливая: + // названия тихо уедут в фолбэк, и заметить это будет нечем. Ср. соседнее + // решение про refresh токена выше — там DEBUG осознан, случай ровно обратный. + if len(out) > 0 && !sawLangKeys { + logctx.FromOr(ctx, t.log).Warn("tvdb search returned no language-coded translations", "tvdb_locale", t.language) + } return out, nil } diff --git a/internal/metadata/tvdb_test.go b/internal/metadata/tvdb_test.go index a852bbd..e4c2f3a 100644 --- a/internal/metadata/tvdb_test.go +++ b/internal/metadata/tvdb_test.go @@ -1,10 +1,15 @@ package metadata import ( + "bytes" "context" "encoding/json" + "log/slog" "net/http" "net/http/httptest" + "net/url" + "strings" + "sync" "sync/atomic" "testing" ) @@ -38,7 +43,8 @@ func fakeTVDB(t *testing.T, logins *atomic.Int32) *httptest.Server { if r.URL.Query().Get("type") != "series" || r.URL.Query().Get("query") != "Fargo" { t.Errorf("query = %v", r.URL.Query()) } - _, _ = w.Write([]byte(`{"data":[{"tvdb_id":"269613","name":"Fargo","year":"2014"}]}`)) + _, _ = w.Write([]byte(`{"data":[{"tvdb_id":"269613","name":"Fargo","year":"2014", + "translations":{"rus":"Фарго","eng":"Fargo"}}]}`)) })) mux.HandleFunc("/series/269613/extended", authed(func(w http.ResponseWriter, _ *http.Request) { _, _ = w.Write([]byte(`{"data":{"episodes":[ @@ -52,13 +58,191 @@ func fakeTVDB(t *testing.T, logins *atomic.Int32) *httptest.Server { func newTVDB(t *testing.T, url string) *TVDB { t.Helper() - c, err := NewTVDB(TVDBConfig{APIKey: "k", BaseURL: url}, nil) + return newTVDBLang(t, url, "") +} + +func newTVDBLang(t *testing.T, url, lang string) *TVDB { + t.Helper() + c, err := NewTVDB(TVDBConfig{APIKey: "k", BaseURL: url, Language: lang}, nil) if err != nil { t.Fatalf("NewTVDB: %v", err) } return c } +// searchStand — стенд с одной записью поиска: тело ответа задаётся тестом, +// строка запроса и число обращений к /search записываются для проверок. +type searchStand struct { + srv *httptest.Server + mu sync.Mutex + queries []string + searches atomic.Int32 +} + +func (s *searchStand) recorded() []string { + s.mu.Lock() + defer s.mu.Unlock() + return append([]string(nil), s.queries...) +} + +func newSearchStand(t *testing.T, body string) *searchStand { + t.Helper() + s := &searchStand{} + mux := http.NewServeMux() + mux.HandleFunc("/login", func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write([]byte(`{"data":{"token":"tok"}}`)) + }) + mux.HandleFunc("/search", func(w http.ResponseWriter, r *http.Request) { + s.searches.Add(1) + s.mu.Lock() + s.queries = append(s.queries, r.URL.RawQuery) + s.mu.Unlock() + _, _ = w.Write([]byte(body)) + }) + s.srv = httptest.NewServer(mux) + t.Cleanup(s.srv.Close) + return s +} + +// Локализованное название кандидата: перевод, фолбэк во всех его формах и +// OriginalTitle, который равен primary name всегда. +func TestTVDB_SearchTranslations(t *testing.T) { + const primary = "哪吒之魔童降世" + cases := []struct { + name string + lang string + record string + wantTitle string + }{ + {"перевод есть", "ru", `"translations":{"rus":"Нэчжа","eng":"Ne Zha"}`, "Нэчжа"}, + {"перевода на язык нет", "ru", `"translations":{"eng":"Ne Zha"}`, primary}, + {"перевод из пробелов", "ru", `"translations":{"rus":" "}`, primary}, + {"ключ в другом регистре", "ru", `"translations":{"RUS":"Нэчжа"}`, "Нэчжа"}, + {"блока переводов нет", "ru", `"year":"2019"`, primary}, + {"блок пустой", "ru", `"translations":{}`, primary}, + {"блок не карта", "ru", `"translations":["Нэчжа"]`, primary}, + {"блок null", "ru", `"translations":null`, primary}, + {"язык по умолчанию — eng", "", `"translations":{"rus":"Нэчжа","eng":"Ne Zha"}`, "Ne Zha"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + body := `{"data":[{"tvdb_id":"131155","name":"` + primary + `","year":"2019",` + tc.record + `}]}` + stand := newSearchStand(t, body) + got, err := newTVDBLang(t, stand.srv.URL, tc.lang). + Search(context.Background(), Query{Type: Movie, Title: "Ne Zha"}) + if err != nil { + t.Fatalf("Search: %v", err) + } + if len(got) != 1 { + t.Fatalf("кандидатов = %d, want 1 (негодный перевод не должен ронять выдачу)", len(got)) + } + if got[0].Title != tc.wantTitle { + t.Errorf("Title = %q, want %q", got[0].Title, tc.wantTitle) + } + if got[0].OriginalTitle != primary { + t.Errorf("OriginalTitle = %q, want primary name %q", got[0].OriginalTitle, primary) + } + if n := stand.searches.Load(); n != 1 { + t.Errorf("обращений к /search = %d, want 1 (лимит ключа не растёт)", n) + } + }) + } +} + +// Сигнал «форма ответа не та, что записана в разведке». Он единственный, кто +// отличает неверное предположение о чужом API от штатного «перевода нет», — +// поэтому проверяется поимённо, а не через покрытие. +func TestTVDB_SignalOnUnexpectedTranslationForm(t *testing.T) { + cases := []struct { + name string + record string + wantSignal bool + }{ + {"двухбуквенные коды", `"translations":{"ru":"Дюна","en":"Dune"}`, true}, + {"блок null", `"translations":null`, true}, + {"блок пустой", `"translations":{}`, true}, + {"блока нет", `"year":"2021"`, true}, + {"блок не карта", `"translations":["Дюна"]`, true}, + {"вложенные объекты", `"translations":{"rus":{"name":"Дюна"}}`, true}, + // Штатный случай: коды трёхбуквенные, нужного среди них нет — не сигнал. + {"перевода на язык нет", `"translations":{"eng":"Dune"}`, false}, + {"перевод есть", `"translations":{"rus":"Дюна","eng":"Dune"}`, false}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + stand := newSearchStand(t, + `{"data":[{"tvdb_id":"1","name":"Dune","year":"2021",`+tc.record+`}]}`) + var buf bytes.Buffer + log := slog.New(slog.NewTextHandler(&buf, &slog.HandlerOptions{Level: slog.LevelWarn})) + c, err := NewTVDB(TVDBConfig{APIKey: "k", BaseURL: stand.srv.URL, Language: "ru"}, log) + if err != nil { + t.Fatalf("NewTVDB: %v", err) + } + got, err := c.Search(context.Background(), Query{Type: Movie, Title: "Dune"}) + if err != nil { + t.Fatalf("Search: %v", err) + } + if len(got) != 1 { + t.Fatalf("кандидатов = %d, want 1", len(got)) + } + signal := strings.Contains(buf.String(), "no language-coded translations") + if signal != tc.wantSignal { + t.Errorf("сигнал = %v, want %v; лог: %q", signal, tc.wantSignal, buf.String()) + } + }) + } +} + +// Выбор среди EqualFold-совпавших ключей детерминирован: иначе один и тот же +// ответ давал бы разное имя папки от прогона к прогону. +func TestTVDB_TranslationKeyPickIsDeterministic(t *testing.T) { + cases := []struct{ name, block, want string }{ + {"точное совпадение сильнее регистра", `{"rus":"Дюна","RUS":"HACK"}`, "Дюна"}, + {"без точного — лексикографически меньший", `{"RUS":"A","Rus":"B"}`, "A"}, + {"юникод-эквивалент case-folding", `{"rus":"Дюна","ruſ":"HACK"}`, "Дюна"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + for i := 0; i < 200; i++ { + got, _ := translatedName(json.RawMessage(tc.block), "rus") + if got != tc.want { + t.Fatalf("прогон %d: got %q, want %q", i, got, tc.want) + } + } + }) + } +} + +// Локаль работает только на разборе ответа: параметр языка в запрос не уходит, +// и строка запроса не зависит от настройки. +func TestTVDB_SearchQueryHasNoLanguage(t *testing.T) { + const body = `{"data":[{"tvdb_id":"1","name":"X","year":"2000"}]}` + var got []string + for _, lang := range []string{"ru", "en", ""} { + stand := newSearchStand(t, body) + if _, err := newTVDBLang(t, stand.srv.URL, lang). + Search(context.Background(), Query{Type: Movie, Title: "X", Year: 2000}); err != nil { + t.Fatalf("Search(%q): %v", lang, err) + } + recorded := stand.recorded() + if len(recorded) != 1 { + t.Fatalf("запросов = %d", len(recorded)) + } + q, err := url.ParseQuery(recorded[0]) + if err != nil { + t.Fatalf("ParseQuery: %v", err) + } + if _, ok := q["language"]; ok { + t.Errorf("language=%q в запросе при lang=%q: параметр сужает выдачу, слать его нельзя", + q.Get("language"), lang) + } + got = append(got, recorded[0]) + } + if got[0] != got[1] || got[1] != got[2] { + t.Errorf("строка запроса зависит от языка: %q", got) + } +} + func TestTVDB_SearchAndLoginCached(t *testing.T) { var logins atomic.Int32 srv := fakeTVDB(t, &logins) diff --git a/internal/recognize/metadata_test.go b/internal/recognize/metadata_test.go index 7802054..ad2b8ed 100644 --- a/internal/recognize/metadata_test.go +++ b/internal/recognize/metadata_test.go @@ -153,6 +153,26 @@ func TestMatchMetadata_OriginalTitle(t *testing.T) { } } +// Цена локализованного названия кандидата: множество осей сравнения выросло с +// одной до двух, и там, где сильный кандидат был один, их может стать двое — +// тогда подтверждённого матча нет и обе записи уходят в review. На этом счётчике +// держится инвариант «авто-раскладка только при подтверждённом матче». +func TestMatchMetadata_TranslationMakesTwoStrong(t *testing.T) { + p := &fakeProvider{candidates: []metadata.Candidate{ + {Provider: "tvdb", ID: "1", Title: "Нэчжа", OriginalTitle: "哪吒之魔童降世", Year: 2019}, + {Provider: "tvdb", ID: "2", Title: "Ne Zha", OriginalTitle: "Ne Zha", Year: 2019}, + }} + r := recognizerWith(p) + m, cands := r.matchMetadata(context.Background(), + Plan{Type: MediaMovie, Title: "Нэчжа", OriginalTitle: "Ne Zha", Year: 2019}) + if m != nil { + t.Errorf("двое сильных — подтверждённого матча быть не должно, got %+v", m) + } + if len(cands) != 2 { + t.Errorf("candidates = %d, want 2 (обе записи уходят в review)", len(cands)) + } +} + func TestMatchMetadata_MatchByOriginalFirst(t *testing.T) { // Реальный кейс: русское релиз-имя, матч по оригинальному названию. // При сильном матче по первому ключу дальнейшие запросы не делаются. diff --git a/openspec/changes/archive/2026-08-07-tvdb-title-locale/.openspec.yaml b/openspec/changes/archive/2026-08-07-tvdb-title-locale/.openspec.yaml new file mode 100644 index 0000000..878dc31 --- /dev/null +++ b/openspec/changes/archive/2026-08-07-tvdb-title-locale/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-08-07 diff --git a/openspec/changes/archive/2026-08-07-tvdb-title-locale/design.md b/openspec/changes/archive/2026-08-07-tvdb-title-locale/design.md new file mode 100644 index 0000000..5770cd2 --- /dev/null +++ b/openspec/changes/archive/2026-08-07-tvdb-title-locale/design.md @@ -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. diff --git a/openspec/changes/archive/2026-08-07-tvdb-title-locale/proposal.md b/openspec/changes/archive/2026-08-07-tvdb-title-locale/proposal.md new file mode 100644 index 0000000..36266b8 --- /dev/null +++ b/openspec/changes/archive/2026-08-07-tvdb-title-locale/proposal.md @@ -0,0 +1,78 @@ +## Why + +Глобальная настройка `[general].language` правит только промпт LLM и клиент TMDB; +до клиента TVDB она не доезжает вовсе. TVDB отдаёт primary name — название на +языке оригинала, — и оно попадает в карточку ревью и в имя папки Jellyfin как +есть: у [movie 131155](https://www.thetvdb.com/movies/131155-) это `哪吒之魔童降世` +вместо «Нэчжа», хотя русский перевод у записи есть. Плюс `Candidate.OriginalTitle` +у TVDB не заполняется вовсе, хотя это ось сравнения при матче: сегодня TVDB +сравнивается только по одному названию из двух возможных. + +## What Changes + +- `TVDBConfig` получает поле `Language` — абстрактный код (`ru`|`en`) из + `cfg.ContentLanguage()`, как уже сделано для TMDB. Диалект (у TVDB это + трёхбуквенный код ISO 639-2: `rus`/`eng`) выводит сам `internal/metadata/tvdb.go` + тотальным `switch` с default-веткой — по решению 2 архивного + [design content-language-switch](../archive/2026-07-24-content-language-switch/design.md). +- Разбор ответа `/search` дополняется картой `translations` (код языка → название). + `Candidate.Title` берётся из `translations[код]`, при отсутствии перевода — + из primary `name` (молчаливой пустоты быть не должно). +- `Candidate.OriginalTitle` заполняется primary `name`. +- **Параметр `language` в запрос `/search` НЕ добавляется.** По + [swagger TVDB v4 4.7.10](https://raw.githubusercontent.com/thetvdb/v4-api/main/docs/swagger.yml) + этот параметр — фильтр («Restrict results to a specific primary language»), а не + селектор перевода: он сузил бы выдачу и отрезал именно те записи, ради которых + задача заводилась. Разбор ведётся по ответу, а не по запросу. Расхождение с + критерием приёмки задачи вынесено вопросом человеку — см. `## Открытый вопрос`. +- **Форма ответа TVDB живым API не сверялась** — только по публичной документации + (см. `docs/research/tvdb-search-translations.md`). Интеграционный тест за + env-гейтом `TVDB_API_KEY` написан, но не запускался. + +Ломающих изменений нет: конфиг не меняется, новых настроек не появляется. + +## Capabilities + +### New Capabilities + +Новых нет. + +### Modified Capabilities + +- `metadata-match`: требование «Локаль запроса к TMDB» получает соседа — + «Локализованное название кандидата TVDB». Обобщать существующее требование на + всех провайдеров нельзя: механика у TMDB и TVDB разная (параметр запроса против + разбора ответа), и одно требование на двоих скрыло бы эту разницу. + +## Impact + +- `internal/metadata/tvdb.go` — `TVDBConfig.Language`, вывод кода языка, разбор + `translations` в `tvdbSearchResp`, заполнение `Title`/`OriginalTitle`. +- `internal/metadata/tvdb_test.go` — стенд `httptest` с блоком `translations`, + табличный тест на перевод/фолбэк/`OriginalTitle`. +- `internal/metadata/integration_test.go` — интеграционный тест за `TVDB_API_KEY` + печатает `Title` и `OriginalTitle`. **Пишется, но не запускается.** +- `cmd/jellybit/serve.go` — сборка провайдера TVDB получает `Language`. +- `internal/config/config.go` — в `validate` живёт единственный перечень + потребителей кода языка («при добавлении кода синхронно расширь мапперы + `metadata.tmdbLocale` и `recognize.languageDirective`»); третий маппер + дописывается в этот перечень. +- `docs/research/tvdb-search-translations.md` — новая записка о форме ответа + `/search` с честным провенансом. +- Поведение матча: `strongMatches` сравнивает план и с `Title`, и с + `OriginalTitle`. У TVDB сегодня `OriginalTitle` пуст, поэтому сравнение идёт по + одному названию; после изменения их станет два (локализованное + оригинальное). + Логика гейта не трогается, но его **вход** меняется, и следствие двустороннее: + запись, которую отсекал иероглифический primary name, теперь может пройти по + переводу (задача уйдёт в авто вместо review) — и наоборот, две разные записи + могут совпасть с планом разными названиями, тогда единичный сильный матч + станет двумя и задача уйдёт в review вместо авто. Второе направление — + в сторону человека, то есть безопасную. Инвариант «авто-раскладка только при + подтверждённом матче» не двигается. +- Внешний контракт: TheTVDB API v4 `/search`. Живых запросов в рамках задачи не + делалось (запрет `CLAUDE.md` → «Запреты»: лимиты метабаз не расходуем). + +## Открытый вопрос + +Форма ответа `/search` не сверена живым API. Вопрос человеку записан в +`docs/tasks/items/tvdb-title-locale.md` → раздел «Вопросы». diff --git a/openspec/changes/archive/2026-08-07-tvdb-title-locale/review/report.md b/openspec/changes/archive/2026-08-07-tvdb-title-locale/review/report.md new file mode 100644 index 0000000..21d0c62 --- /dev/null +++ b/openspec/changes/archive/2026-08-07-tvdb-title-locale/review/report.md @@ -0,0 +1,215 @@ +# Ревью изменения `tvdb-title-locale` — отчёт триажа + +Сформирован проходом `review-triage`; записан оркестратором (у прохода запись файлов +заблокирована). В конце добавлена секция «Что сделано по находкам». + +## Сводка + +**Размер:** малое (один содержательный узел `internal/metadata/tvdb.go` + тривиальная проводка в +`cmd/jellybit/serve.go`, комментарий в `internal/config/config.go`, два документных артефакта). +**Сложность:** незнакомое (дословно сработал проектный триггер `docs/review.md` → «Незнакомое +здесь»: новый источник в слиянии кандидата метабазы — `OriginalTitle` у TVDB становится непустым +и входит новой осью сравнения в `strongMatches`; внешний контракт живым прогоном не подтверждён). +**Метка:** `large` (малое × незнакомое), не спорный случай. +**Режим прогона:** по графу. **Гейт:** зелёный (`BASE=master task gate`, `-race` реально гонялся — +gcc есть; `gitleaks` no leaks; `govulncheck` 0 достижимых). + +**Сигнал о заниженной метке:** пришёл от `code` — «сигнала нет, метка `large` адекватна». +`basics` не запускался по плану, второго провенанса нет. + +### План разметки с исходом по каждой теме + +| тема | дом | глубина | кто закрывает | исход | +|---|---|---|---|---| +| requirements | `openspec/specs/metadata-match/spec.md` + дельта | разбор | `specs` | закрыта, 4 находки (S1–S4) | +| autotests | `CLAUDE.md` → «Гейт» | — | `autotests` | закрыта, 1 находка (A1), отработана до опиниативных проходов | +| conventions | `docs/conventions/README.md` + `config.md` + `logging.md` | разбор | `code` | закрыта, 3 находки (C1–C3) | +| architecture | `docs/architecture.md` → «Единые точки» (+ `docs/passport.md`) | доказательство | `architecture` | закрыта, 3 находки (R1–R3) + 4 пункта «дешевле переделать до мерджа» | +| security | `docs/security.md` | доказательство | `adversary` | закрыта, 4 находки (AD1–AD4); три вопроса темы — вне диффа, не разбирались | +| operations | `docs/architecture.md` → «Эксплуатация» (+ `docs/database.md`) | доказательство | `ops` | закрыта, 1 находка (O1) + 5 снятых замерами гипотез | +| тема проекта | своих тем проекта нет | — | `basics` не запускается | дома у темы нет; отсутствие отчёта согласовано планом | + +**Тем без отчёта нет.** + +Находок на входе — 17. Различных причин после дедупликации — 10. Крупнейшее слияние: «след не +срабатывает» — пять независимых обнаружений (S1, C1, R1, AD2, O1) плюс S2 как отсутствующий на +него тест. Это не подтверждение: под всеми проходами одна модель с одними априорными; согласие +подняло приоритет, `Confidence` — нет. Оракул добыт триажом отдельно. В основном списке 6 +(2 блокирующих + 4 «сейчас»), потолок 3+4 не выбран. + +**A1 (закрыта до опиниативных проходов):** гейт краснел по шагу `canon` — задача в спринте с +непустым разделом «Вопросы». Вопрос вынесен задачей-разведкой +`docs/tasks/items/tvdb-search-response-live-check.md`, раздел убран, гейт перегнан. + +## Блокирует мердж + +### 1. Единственный компенсирующий контроль изменения нем ровно на том риске, ради которого заведён + +- Файл: `internal/metadata/tvdb.go:191-205,247-252`; дельта, сценарий «Блок переводов отсутствует + или пришёл негодной формой» +- Severity: major · Confidence: high +- Оракул: падающий тест на настоящем `TVDB.Search` (httptest-стенд, буфер логгера): + `translations={"ru":…,"en":…}` → след false FAIL; `null` → false FAIL; `{}` → false FAIL; + `{"rus":{"name":…}}` → true PASS; `["Дюна"]` → true PASS; поля нет → true PASS. + Плюс `docs/conventions/logging.md` — «`DEBUG` … в проде выключен», и дефолт `[log].level = "info"`. +- Последствие: два независимых компаундирующих отказа одного механизма. **Предикат:** + `json.Unmarshal("null", &map[string]string{})` возвращает `err == nil` и `nil`-карту, поэтому для + `null`, `{}` и **двухбуквенных кодов** признак «разобралось» истинен — а двухбуквенные коды + `design.md` называет главным риском поимённо. **Уровень:** даже сработавший след при штатном + деплое не виден. Итог: все названия тихо уедут в фолбэк на английский primary name, и + единственный заведённый способ это заметить не сработает. +- Найдено проходами: `specs` (S1), `code` (C1), `architecture` (R1), `adversary` (AD2), `ops` (O1) +- Действие: **развилка**. (а) починить предикат и поднять уровень до `WARN`, закрепить тестом, в + спеке заказывать наблюдаемость, а не уровень; (б) признать детектор нерабочим и снять; + (в) оставить код, переписать дельту под фактическое поведение. Рекомендация — (а). + +### 2. Название из метабазы уезжает в имя каталога Jellyfin дословно, мимо санитайзинга, авто-раскладкой + +- Файлы: `internal/metadata/tvdb.go:233-245`; `internal/recognize/recognize.go:261-270`; + `design.md` → Risks +- Severity: major · Confidence: high +- Оракул: падающий тест на реальном `Recognizer`: `plan.Title` из трёх ZWSP — `auto=true`, тогда как + `sanitizeTitle` дал бы пустую строку; `"Dunegnp.mkv"`, `"Dune\nHACK"`, `"Dunа"` с кириллической + `а` — все с `auto=true` и все отличаются от того, что дал бы санитайзер. Плюс чтение кода: + `plan.Title = match.Title` стоит **после** `sanitizePlan`, а гейт сверяет + `normalize(c.Title) || normalize(c.OriginalTitle)` — проходит по `OriginalTitle`, и значение, + уезжающее в план, не сверяется ни с чем. +- Последствие: `layout.sanitizeComponent` снимает `/\:*?"<>|` и `< 0x20`, но не трогает категорию + Cf и не сворачивает гомоглифы. `adversary` показал два визуально неотличимых каталога. + Инвариант «целевой путь строго под библиотекой» **держится** (проверено на `Dune/../../etc`, + `" .. "`, `"..."`, 400 символов) — поэтому не `critical`. Класс **пре-существующий**: у TMDB тот + же путь. Дифф распространяет его на второго провайдера и впервые для TVDB делает поле, + уезжающее в путь, несверяемым. Отдельно: именно ложной фразой в `design.md` обоснован вывод + «новой недоверенной границы не появляется», и она архивируется вместе с change. +- Найдено проходами: `adversary` (AD1), независимо — `architecture` +- Действие: **развилка**. Прямой ответ на вопрос задания: **починка кода — урожай, а не дефект + этой задачи; починка `design.md` — дефект этой задачи и делается инлайн.** Вариант с + `sanitizeTitle` к `match.Title` меняет поведение уже работающего TMDB и выходит за рамку задачи. + Рекомендация — поправить документ, завести задачу. + +## Стоит исправить сейчас + +### 3. Один и тот же байт-в-байт ответ TVDB даёт разное имя каталога от прогона к прогону +- Файл: `internal/metadata/tvdb.go:199-203` · minor · medium (механизм доказан, достижимость нет) +- Оракул: 500 разборов одного тела — `{"rus":"Дюна","RUS":"HACK"}` дал `map[HACK:59 Дюна:441]`; + `{"rus":"Дюна","ruſ":"HACK"}` → `map[HACK:63 Дюна:437]`. +- Последствие: `for k := range m` по карте (порядок рандомизирован) + `EqualFold`, совпадающий с + несколькими ключами. Две задачи по одной раздаче в разное время получат разные имена каталогов. +- Сопутствующая правка спеки (провенанс `specs`/S3): регистронезависимый поиск дельтой не заказан + вовсе — живёт только в `design.md` и строке теста, а `design.md` архивируется. +- Действие: **инлайн** + +### 4. Сдвиг границы авто-review подтверждён только рассуждением +- Файл: дельта, сценарий про двух сильных кандидатов; механизм — `internal/recognize/metadata.go` + · minor · high +- Оракул: в `internal/recognize/metadata_test.go` нет теста, где два кандидата в пределах года и + один совпадает по `Title`, а другой по `OriginalTitle`. +- Последствие: на счётчике сильных кандидатов держится инвариант «авто-раскладка только при + подтверждённом матче» (`major`). Регрессия в `strongMatches` гейт не покраснит. +- Действие: **инлайн** + +### 5. Поле `language` в логах несёт два разных словаря +- Файлы: `internal/metadata/tvdb.go:251` в паре с `cmd/jellybit/serve.go:109` · nit · high +- Оракул: `docs/conventions/logging.md` → «Поля: словарь имён» — «одно поле — одно имя по всему + коду». `serve.go` кладёт абстрактный код (`ru`), новая строка — диалект (`rus`). +- Действие: **инлайн** + +### 6. Образец конфига после этого изменения говорит про локаль TVDB неправду +- Файл: `config.example.toml`, секция `[metadata.tvdb]` · nit · high +- Оракул: `docs/conventions/config.md` → самодокументируемый образец; у `[metadata.tmdb]` хвост + про `[general].language` есть, у `[metadata.tvdb]` нет — до change асимметрия была верна. +- Действие: **инлайн** + +## Гипотезы без доказательства + +1. Достижимость находки 3 (два EqualFold-равных ключа от живого TVDB) не проверена и в рамке + прогона проверена быть не может. +2. **Основание всего изменения — форма блока `translations` — живым прогоном не подтверждено.** + Все находки про перевод условны на этом. Единственный оракул — `TestIntegration_TVDB` за + `TVDB_API_KEY`, прогоняет человек. Отдельно от `specs`: **интеграционный тест печатает + результат по `Ne Zha`, но ничего не утверждает** — `PASS` с пустым выводом неотличим от + подтверждения. +3. AD4 (название длиннее ~237 байт уводит задачу в `failed` вместо review) понижено не по + доказательности (оракул есть: `n=250` → `Apply err=… file name too long`, `results=0`), а по + принадлежности к диффу: путь не тронут изменением. +4. Согласие пяти проходов по находке 1 — не подтверждение. `Confidence: high` стоит из-за + падающего теста, добытого триажом, и ни по какой другой причине. + +## Promote candidates + +1. **Правило `internal/archrules`, связывающее множество кодов языка с мапперами** (провенанс + `architecture`/R2). Язык вывода живёт в пяти местах четырёх пакетов; тестов, связывающих + множество с мапперами, нет — забытая ветка молча даст английский вывод. Дешёвый паллиатив: + строка «язык вывода» в `docs/architecture.md` → «Единые точки проекта». +2. **Один стенд чужого API на пакет** (провенанс `architecture`/R3). В `tvdb_test.go` два фейка + одного API; когда форма ответа поменяется по факту разведки, забытый `fakeTVDB` останется + зелёным и продолжит подтверждать опровергнутое предположение. +3. **Интеграционный тест обязан утверждать, а не печатать** (провенанс `specs`). + +**Отсеяно как вкусовщина:** переименование `translatedName` (поведения не меняет, записанной +конвенции нет); асимметрия «primary name едет без `TrimSpace`» (последствия нет); `searches` как +дубль `len(queries)` — часть promote-кандидата 2. Раздел «Типовые ложноположительные» +`docs/review.md` прочитан; ни одна находка под них не подпала. + +## Границы покрытия + +**План:** все семь строк имеют исход; тем без дома одна и она объявлена планом заранее; тем без +отчёта — ноль. + +**Запускались** шесть проходов. **Не запускался `basics`** — по плану разметки; пропуска темы это +не даёт, но означает, что второго провенанса у сигнала о заниженной метке нет. +**Не существует в конвейере** проход независимой реализации — снят по стоимости; на задаче, +целиком построенной на неподтверждённом внешнем контракте, это самый дорогой пробел прогона. + +**Чего не мог проверить каждый проход:** +- `autotests` — качество распознавания гейтом не проверяется вовсе (решение проекта); diff-coverage + 91%, 4 непокрытых строки — wiring в `cmd/jellybit/serve.go` внутри уже непокрытой функции. +- `specs` — границы спеки: пустой primary name при непустом переводе; локализация расширенных + данных (объявленный Non-Goal). +- `code` — правило про `ident` линтером не проверяется; форма кода вне механизированного перечня. +- `architecture` — завязка внешних потребителей на текущее поведение; «нужна ли функциональность». +- `adversary` — **три из четырёх вопросов темы `security` не разбирались** (крафт-магнет, снятие + последней копии, асимметрия признака владения): вне диффа, бюджет ушёл на построение пути. +- `ops` — реальный профиль нагрузки на umbar; замеры сняты в одиночку. +- Триаж — ничего нового не находит по определению. + +**Рамка соблюдена:** живых обращений к TheTVDB ноль; `TVDB_API_KEY` не задавался; +`TestIntegration_TVDB` не запускался. Оракулы триажа добыты на `httptest`-стенде в копии дерева +под `tmp/triage/repo`; рабочее дерево не менялось. + +**Целиком на человеке:** история инцидентов на umbar; поведение SQLite под реальным объёмом; +завязка внешних потребителей; «нужна ли функциональность»; качество распознавания. +**Перестали проверять сознательно:** идиоматичность Go (проход `idiom` упразднён 2026-08-04). + +**Четыре строки, которые не принесёт ни один проход:** решения проекта (`docs/adr/`) не +сверялись — процессный документ; записанные наблюдения (`docs/research/`) как оракул не +использовались, и оракул находки 1 заменён на исполненный тест именно поэтому; поимённая сверка с +руководствами по стилю Go не задавалась; альтернативной реализации для диффа решений нет. + +**Потолки:** `code` сообщил свой (2 из 4, срез не сработал); `adversary` объявил срез по бюджету; +`autotests`, `specs`, `architecture`, `ops` свой потолок не сообщили — это находка о прогоне. +Триаж: 6 из 7, потолок не выбран. + +## Что сделано по находкам (записано оркестратором после отработки) + +| № | Действие | Исход | +|---|---|---| +| 1 | развилка, выбран вариант (а) | Предикат разведён: признаком служит наличие хотя бы одного трёхбуквенного ключа во всей выдаче, а не удача разбора; `null` и `{}` считаются неразобранными. Уровень поднят до `WARN`. Закреплено тестом `TestTVDB_SignalOnUnexpectedTranslationForm` (8 случаев, включая штатный «перевода нет» с ожиданием **отсутствия** следа). В дельте заказана наблюдаемость, уровень отдан конвенциям; добавлены два сценария. | +| 2 | развилка, выбран вариант (а) | Утверждение в `design.md` → Risks переписано: сказано, что `plan.Title = match.Title` подставляется после `sanitizePlan`, санитайзинг не применяется, класс пре-существующий, инвариант целевого пути держится. Починка кода **не делалась** — отдана урожаем. | +| 3 | инлайн | Выбор ключа детерминирован: точное совпадение сильнее, иначе лексикографически меньший. Тест `TestTVDB_TranslationKeyPickIsDeterministic` (200 прогонов на случай). В дельту добавлено требование о регистронезависимости и детерминизме плюс сценарий «Ключ перевода в другом регистре». | +| 4 | инлайн | Добавлен `TestMatchMetadata_TranslationMakesTwoStrong` в `internal/recognize/metadata_test.go`. | +| 5 | инлайн | Поле лога переименовано в `tvdb_locale`. | +| 6 | инлайн | В `config.example.toml` у `[metadata.tvdb]` дописан хвост про `[general].language` с указанием отличия от TMDB. | + +**Урожай, переданный владельцу задач** (пайплайн задач из него не заводит): + +1. Санитайзинг `match.Title`/`match.Director` при подстановке в план либо категория Cf в + `layout.sanitizeComponent` — находка 2, класс общий для TMDB и TVDB, оракул в отчёте. +2. Название длиннее ~237 байт уводит задачу в `failed` с текстом системной ошибки вместо review + (AD4) — пре-существующее, оракул в отчёте. +3. Три promote-кандидата выше: правило синхронности кодов языка, один стенд чужого API на пакет, + интеграционный тест обязан утверждать. +4. Наблюдение о прогоне: четыре прохода не сообщили свой потолок. +5. Наблюдение об окружении: кэш `golangci-lint` в worktree, вложенном в основной репозиторий, + нёс результаты чужого прогона с путями `../../internal/…` и красил шаг `lint` находками вне + диффа. Лечится `golangci-lint cache clean`. На каждом worktree это ложный красный гейт. diff --git a/openspec/changes/archive/2026-08-07-tvdb-title-locale/specs/metadata-match/spec.md b/openspec/changes/archive/2026-08-07-tvdb-title-locale/specs/metadata-match/spec.md new file mode 100644 index 0000000..3c66f58 --- /dev/null +++ b/openspec/changes/archive/2026-08-07-tvdb-title-locale/specs/metadata-match/spec.md @@ -0,0 +1,120 @@ +## ADDED Requirements + +### Requirement: Локализованное название кандидата TVDB + +Кандидат TVDB SHALL нести локализованное название в поле `Title` и название на +языке оригинала в поле `OriginalTitle`. Язык локализации задаёт та же глобальная +настройка `language`, что и для TMDB, — правило единственного источника языка +живёт в требовании «Локаль запроса к TMDB» и здесь не переписывается. + +Источник локализованного названия — блок переводов в ответе поиска TVDB (карта +«код языка → название»). `OriginalTitle` SHALL брать primary name записи — это +название на языке оригинала и ось сравнения при матче. + +Фолбэк SHALL быть тотальным: если перевода на нужный язык нет, его значение +пусто после обрезки пробелов или блока переводов нет вовсе, `Title` SHALL быть +равен primary name. Пустого `Title` при непустом primary name быть SHALL NOT. +В `Title` SHALL попадать значение перевода после обрезки пробелов. + +Ключ языка SHALL искаться регистронезависимо: молчаливый фолбэк из-за регистра +ключа неотличим от отсутствия перевода и в эксплуатации не диагностируется. Если +условию отвечает несколько ключей, выбор SHALL быть детерминированным — один и +тот же ответ провайдера обязан давать один и тот же `Title`. + +Локаль TVDB SHALL влиять только на разбор ответа и SHALL NOT сужать выдачу +поиска: параметр языка в запрос поиска не передаётся. Причина — в +`docs/research/tvdb-search-translations.md`; здесь заказано поведение, а не +устройство чужого API. Тем самым разница с TMDB намеренна: у TMDB локаль едет в +запрос, у TVDB читается из ответа. + +Негодная форма блока переводов (блок пришёл не картой, значения не строки) SHALL +приводить к тому же тотальному фолбэку, а не проваливать разбор ответа поиска +целиком: ответ метабазы — недоверенный вход. + +Подозрение на иную форму ответа SHALL оставлять диагностический след. Признаком +служит **отсутствие во всей выдаче хотя бы одного ключа языка ожидаемого вида**, +а не неудача разбора блока: `null`, пустая карта и словарь кодов другого вида +разбираются без ошибки и потому признаком быть SHALL NOT. Штатное «перевода на +этот язык нет» (ключи ожидаемого вида есть, нужного среди них нет) следа +оставлять SHALL NOT — иначе сигнал неотличим от рутины. Уровень следа задают +конвенции логирования проекта и здесь не нормируются; требуется наблюдаемость, +а не конкретный уровень. + +Настоящее требование не изменяет условий подтверждения матча — они заданы +требованиями «Подтверждение матча и каноническое имя» и «Безгодовой второй +проход сверки как fallback». Заполнение `OriginalTitle` расширяет множество +названий кандидата, по которым идёт сравнение, с одного до двух. Исход гейта при +этом монотонным SHALL NOT считаться: там, где сильный кандидат был один, их +может стать двое, и тогда подтверждённого матча нет, а записи уходят кандидатами +в review — по действующему требованию, без исключений для TVDB. + +#### Scenario: Перевод на язык настройки есть + +- **GIVEN** TVDB включён, глобальный `language` = `ru` +- **WHEN** поиск возвращает запись с primary name `哪吒之魔童降世` и переводом `rus` = «Нэчжа» +- **THEN** `Candidate.Title` = «Нэчжа» +- **AND** `Candidate.OriginalTitle` = `哪吒之魔童降世` + +#### Scenario: Перевода на язык настройки нет — фолбэк на оригинал + +- **GIVEN** TVDB включён, глобальный `language` = `ru` +- **WHEN** поиск возвращает запись с primary name `Fargo` и переводами без ключа `rus` +- **THEN** `Candidate.Title` = `Fargo` +- **AND** `Candidate.OriginalTitle` = `Fargo` + +#### Scenario: Перевод есть, но пустой + +- **GIVEN** TVDB включён, глобальный `language` = `ru` +- **WHEN** поиск возвращает запись с primary name `Fargo` и переводом `rus` из одних пробелов +- **THEN** `Candidate.Title` = `Fargo` +- **AND** `Candidate.OriginalTitle` = `Fargo` + +#### Scenario: Язык по умолчанию — английский + +- **GIVEN** TVDB включён, глобальный `language` не задан в конфиге +- **WHEN** поиск возвращает запись с переводами `eng` и `rus` +- **THEN** `Candidate.Title` берётся из перевода `eng` + +#### Scenario: Ключ перевода в другом регистре + +- **GIVEN** TVDB включён, глобальный `language` = `ru` +- **WHEN** поиск возвращает запись с переводом под ключом `RUS` вместо `rus` +- **THEN** `Candidate.Title` берётся из этого перевода + +#### Scenario: Блок переводов отсутствует или пришёл негодной формой + +- **GIVEN** TVDB включён с любым значением `language` +- **WHEN** поиск возвращает записи без блока переводов либо с блоком, который не разбирается картой +- **THEN** `Candidate.Title` = primary name записи +- **AND** `Candidate.OriginalTitle` = primary name записи +- **AND** разбор ответа поиска не проваливается, кандидаты возвращаются +- **AND** остаётся диагностический след о подозрении на иную форму ответа + +#### Scenario: Коды языка не того вида — след остаётся + +- **GIVEN** TVDB включён, глобальный `language` = `ru` +- **WHEN** вся выдача поиска несёт блоки переводов с ключами другого вида (например, двухбуквенными), либо `null`, либо пустые +- **THEN** `Candidate.Title` = primary name у каждой записи +- **AND** остаётся диагностический след о подозрении на иную форму ответа + +#### Scenario: Перевода на язык нет — следа не остаётся + +- **GIVEN** TVDB включён, глобальный `language` = `ru` +- **WHEN** выдача несёт блоки переводов с ключами ожидаемого вида, но без нужного языка +- **THEN** `Candidate.Title` = primary name у каждой записи +- **AND** диагностического следа не остаётся: это штатный исход, а не подозрение + +#### Scenario: Запрос поиска не сужается языком + +- **GIVEN** TVDB включён, глобальный `language` = `ru` +- **WHEN** выполняется поиск +- **THEN** строка запроса поиска не содержит параметра языка +- **AND** строка запроса совпадает с той, что уходит при `language` = `en` + +#### Scenario: Перевод сделал сильных кандидатов двумя — матч не подтверждён + +- **GIVEN** план с `title` «Нэчжа» и `original_title` `Ne Zha`, год известен +- **WHEN** поиск TVDB возвращает две записи в пределах года ±1: одну с primary + name `哪吒之魔童降世` и переводом `rus` «Нэчжа», другую с primary name `Ne Zha` +- **THEN** гейт даёт двух сильных кандидатов вместо одного +- **AND** подтверждённого матча нет, обе записи уходят кандидатами в review diff --git a/openspec/changes/archive/2026-08-07-tvdb-title-locale/tasks.md b/openspec/changes/archive/2026-08-07-tvdb-title-locale/tasks.md new file mode 100644 index 0000000..90ca854 --- /dev/null +++ b/openspec/changes/archive/2026-08-07-tvdb-title-locale/tasks.md @@ -0,0 +1,133 @@ +## 1. Разведка формы ответа + +- [x] 1.1 Записать `docs/research/tvdb-search-translations.md`: форма `SearchResult` + (`name`, `translations`, `name_translated`, `primary_language`), смысл + параметра `language` у `/search`, версия swagger, честный провенанс + «сверено по документации, живым прогоном не подтверждено» +- [x] 1.2 Добавить запись в перечень `docs/research/README.md` → «Записи» + +## 2. Клиент TVDB + +- [x] 2.1 `TVDBConfig.Language` — абстрактный код `ru`|`en`, комментарий по образцу + `TMDBConfig.Language` +- [x] 2.2 `tvdbLocale(lang string) string` — тотальный `switch`, `ru` → `rus`, + default → `eng`; имя ровно как у соседа `tmdbLocale`; комментарий называет + `config.validate` источником множества +- [x] 2.3 `NewTVDB` кладёт выведенный код в поле клиента +- [x] 2.4 `tvdbSearchResp` объявляет `translations` как `json.RawMessage` и + раскладывает в `map[string]string` отдельно, с гашением ошибки: негодная + форма косметического поля не должна ронять разбор всей выдачи +- [x] 2.5 `Search` заполняет `Title` из перевода (ключ ищется `strings.EqualFold`, + выбор среди совпавших детерминирован, значение — после `strings.TrimSpace`) + с фолбэком на `name`, и `OriginalTitle` из `name` +- [x] 2.6 Убедиться, что параметр `language` в строку запроса `/search` НЕ попал +- [x] 2.7 Сигнал на операцию, когда во всей выдаче не встретилось ни одного ключа + языка ожидаемого вида: подозрение на иную форму ответа отличается от + штатного «перевода нет». Уровень `WARN` — `DEBUG` в проде выключен + (правка по находке 1 ревью кода) +- [x] 2.8 Дописать третий маппер (`metadata.tvdbLocale`) в перечень потребителей + кода языка в комментарии `internal/config/config.go` → `validate` + +## 3. Проброс из точки входа + +- [x] 3.1 `cmd/jellybit/serve.go` — `metadataProviders` передаёт + `Language: cfg.ContentLanguage()` в `TVDBConfig` + +## 4. Тесты + +- [x] 4.1 Стенд `fakeTVDB` в `tvdb_test.go` отдаёт запись с блоком `translations` +- [x] 4.2 Табличный тест: перевод есть → `Title` локализован; перевода на нужный + язык нет; перевод из одних пробелов; ключ в другом регистре; блока нет; + блок пришёл не картой. Во всех случаях, кроме первого, `Title` = primary + name; во всех без исключения `OriginalTitle` = primary name +- [x] 4.3 Тест на язык по умолчанию (`Language` не задан → берётся `eng`) +- [x] 4.4 Тест: строка запроса `/search` не содержит `language` ни при `ru`, ни при + пустом языке, и совпадает в обоих случаях +- [x] 4.5 Тест: негодный блок переводов не проваливает `Search` — кандидаты + возвращаются +- [x] 4.6 Тест: на один `Search` уходит ровно один HTTP-запрос к `/search` + (счётчик на стенде) — расход лимита ключа не растёт +- [x] 4.8 Тест на сигнал о неожиданной форме ответа: буфер логгера, 8 случаев, + включая штатный «перевода нет» с ожиданием отсутствия следа +- [x] 4.9 Тест на детерминизм выбора ключа перевода (200 прогонов на случай) +- [x] 4.10 Тест `TestMatchMetadata_TranslationMakesTwoStrong` в + `internal/recognize/metadata_test.go`: два кандидата совпадают с планом + разными осями — подтверждённого матча нет, обе записи уходят в review +- [x] 4.7 Интеграционный тест в `integration_test.go` за `TVDB_API_KEY` печатает + `Title` и `OriginalTitle`. **Пишется, но не запускается** — живых обращений + к TVDB в этой задаче нет + +## 5. Учёт нерешённого + +- [x] 5.1 Записать вопрос человеку в `docs/tasks/items/tvdb-title-locale.md` + (раздел «Вопросы» + тег `question`) через скилл `av-dev-pm:tasks`: форма + ответа TVDB не сверена живым API, нужен ручной прогон под ключом; плюс + расхождение с критерием приёмки A1 про параметр языка в запросе; плюс + двустороннее движение границы авто/review. **Сделано иначе, чем + написано**: раздел «Вопросы» у задачи в спринте краснит `tasks.py check` + и через него шаг `canon` гейта, а закрытие задачи стёрло бы вопрос вместе + с файлом. Вопрос вынесен отдельной задачей-разведкой + `docs/tasks/items/tvdb-search-response-live-check.md` + +## 6. Гейт + +- [x] 6.1 `openspec validate --strict tvdb-title-locale` +- [x] 6.2 `task gate` зелёный + +## Критерии приёмки задачи + +Пришли из `docs/tasks/items/tvdb-title-locale.md`; переписывать и занижать их +нельзя, исход по каждому идёт в доклад. + +- [ ] A1 Запрос поиска TVDB содержит параметр языка, выведенный из + `[general].language` (оракул: тест на `httptest`-сервере в `tvdb_test.go`). + **Отменён решением 1 design.md**: параметр — фильтр выдачи, а не селектор + перевода. Вместо него проверяется обратное — параметра в запросе нет + (задача 4.4). Расхождение вынесено вопросом человеку +- [x] A2 При наличии перевода `Candidate.Title` приходит на языке настройки, при + отсутствии — равен primary name (оракул: табличный тест 4.2) +- [x] A3 `Candidate.OriginalTitle` у TVDB непуст и равен primary name (оракул: тот + же тест 4.2; интеграционный прогон за `TVDB_API_KEY` печатает оба поля — + **написан, но не запущен**, живых обращений к метабазе в задаче нет) +- [x] A4 Дельта-спека `metadata-match` заказывает локаль TVDB и фолбэк на оригинал + (оракул: `openspec validate --strict`) + +## Приёмочные критерии от рубрики (ревью дизайна, проход `rubric`) + +Рубрика порождена до чтения кода и дизайна. Пункты 1, 5, 7 закрываются **условно**: +они опираются на форму ответа `/search`, взятую из публичной документации, — стенд +`httptest` проверит поведение кода на предполагаемой форме, а не истинность +предположения. + +- [x] R1 Фолбэк `Title` тотален по всем формам отсутствия перевода: блока нет; блок + пуст; ключа нет; значение пустое. Пустого `Title` при непустом `name` нет ни + на одной ветви. Оракул — строка табличного теста на каждую форму *(условно)* +- [x] R2 Строка запроса `/search` не содержит параметра языка ни при каком значении + настройки: утверждение об **отсутствии** ключа плюс равенство URL при `ru` и `en` +- [x] R3 `OriginalTitle` = primary name всегда, включая случай найденного перевода; + значение настройки языка его не смещает +- [x] R4 Язык имеет один источник и один вывод диалекта: ни константы + `tvdbDefaultLanguage`, ни фолбэка в конструкторе, ни второго места перевода + `ru`/`en` → `rus`/`eng` +- [x] R5 Разбор устойчив к форме, а не только к содержимому: лишние поля, + отсутствующие поля, `translations: null`, `translations` не картой, усечённое + тело — не паникуют и не проваливают `Search`; худший исход — фолбэк по R1. + Негодный ответ поиска **целиком** по-прежнему остаётся ошибкой *(условно)* +- [x] R6 Пустота значения проверяется **после** `TrimSpace`, а не до +- [x] R7 Ключ в неожиданной форме (иной регистр) не даёт молчаливого фолбэка: поиск + ключа регистронезависимый, исход зафиксирован тестом *(условно)* +- [x] R8 Число обращений к TVDB на одну сверку не растёт: один `Search` — один + HTTP-запрос, проверяется счётчиком на стенде +- [x] R9 Результат — функция ответа провайдера, а не порядка проходов: исход не + зависит от того, каким проходом пришёл кандидат; заполнение `OriginalTitle` + не заставляет следующий заход перебора искать по подменённому значению; на + фолбэке `Title == OriginalTitle` дедупликация ключей перебора не схлопывает + перебор до нуля запросов +- [x] R10 Секрет и контекст ведут себя как у остальных клиентов: ключ TVDB не + попадает в лог, в текст ошибки разбора и в сообщение с URL; `context` + доходит до запроса и отменяет его; таймаут из конфига +- [x] R11 У ответа есть предел (`maxBody` в `rawGet`), у выдачи — прежний потолок + кандидатов; второго канала без предела не заводится +- [x] R12 Факт фолбэка наблюдаем, но не шумен: один чекпоинт на операцию, + отсутствие перевода — не `ERROR`, недоступность опциональной метабазы + состояние загрузки не двигает diff --git a/openspec/specs/metadata-match/spec.md b/openspec/specs/metadata-match/spec.md index 1dfa173..ab11df4 100644 --- a/openspec/specs/metadata-match/spec.md +++ b/openspec/specs/metadata-match/spec.md @@ -267,3 +267,122 @@ fallback: если первый проход подтвердил матч ил - **WHEN** оценивается матч - **THEN** подтверждённого матча нет, кандидаты собираются для выбора в review +### Requirement: Локализованное название кандидата TVDB + +Кандидат TVDB SHALL нести локализованное название в поле `Title` и название на +языке оригинала в поле `OriginalTitle`. Язык локализации задаёт та же глобальная +настройка `language`, что и для TMDB, — правило единственного источника языка +живёт в требовании «Локаль запроса к TMDB» и здесь не переписывается. + +Источник локализованного названия — блок переводов в ответе поиска TVDB (карта +«код языка → название»). `OriginalTitle` SHALL брать primary name записи — это +название на языке оригинала и ось сравнения при матче. + +Фолбэк SHALL быть тотальным: если перевода на нужный язык нет, его значение +пусто после обрезки пробелов или блока переводов нет вовсе, `Title` SHALL быть +равен primary name. Пустого `Title` при непустом primary name быть SHALL NOT. +В `Title` SHALL попадать значение перевода после обрезки пробелов. + +Ключ языка SHALL искаться регистронезависимо: молчаливый фолбэк из-за регистра +ключа неотличим от отсутствия перевода и в эксплуатации не диагностируется. Если +условию отвечает несколько ключей, выбор SHALL быть детерминированным — один и +тот же ответ провайдера обязан давать один и тот же `Title`. + +Локаль TVDB SHALL влиять только на разбор ответа и SHALL NOT сужать выдачу +поиска: параметр языка в запрос поиска не передаётся. Причина — в +`docs/research/tvdb-search-translations.md`; здесь заказано поведение, а не +устройство чужого API. Тем самым разница с TMDB намеренна: у TMDB локаль едет в +запрос, у TVDB читается из ответа. + +Негодная форма блока переводов (блок пришёл не картой, значения не строки) SHALL +приводить к тому же тотальному фолбэку, а не проваливать разбор ответа поиска +целиком: ответ метабазы — недоверенный вход. + +Подозрение на иную форму ответа SHALL оставлять диагностический след. Признаком +служит **отсутствие во всей выдаче хотя бы одного ключа языка ожидаемого вида**, +а не неудача разбора блока: `null`, пустая карта и словарь кодов другого вида +разбираются без ошибки и потому признаком быть SHALL NOT. Штатное «перевода на +этот язык нет» (ключи ожидаемого вида есть, нужного среди них нет) следа +оставлять SHALL NOT — иначе сигнал неотличим от рутины. Уровень следа задают +конвенции логирования проекта и здесь не нормируются; требуется наблюдаемость, +а не конкретный уровень. + +Настоящее требование не изменяет условий подтверждения матча — они заданы +требованиями «Подтверждение матча и каноническое имя» и «Безгодовой второй +проход сверки как fallback». Заполнение `OriginalTitle` расширяет множество +названий кандидата, по которым идёт сравнение, с одного до двух. Исход гейта при +этом монотонным SHALL NOT считаться: там, где сильный кандидат был один, их +может стать двое, и тогда подтверждённого матча нет, а записи уходят кандидатами +в review — по действующему требованию, без исключений для TVDB. + +#### Scenario: Перевод на язык настройки есть + +- **GIVEN** TVDB включён, глобальный `language` = `ru` +- **WHEN** поиск возвращает запись с primary name `哪吒之魔童降世` и переводом `rus` = «Нэчжа» +- **THEN** `Candidate.Title` = «Нэчжа» +- **AND** `Candidate.OriginalTitle` = `哪吒之魔童降世` + +#### Scenario: Перевода на язык настройки нет — фолбэк на оригинал + +- **GIVEN** TVDB включён, глобальный `language` = `ru` +- **WHEN** поиск возвращает запись с primary name `Fargo` и переводами без ключа `rus` +- **THEN** `Candidate.Title` = `Fargo` +- **AND** `Candidate.OriginalTitle` = `Fargo` + +#### Scenario: Перевод есть, но пустой + +- **GIVEN** TVDB включён, глобальный `language` = `ru` +- **WHEN** поиск возвращает запись с primary name `Fargo` и переводом `rus` из одних пробелов +- **THEN** `Candidate.Title` = `Fargo` +- **AND** `Candidate.OriginalTitle` = `Fargo` + +#### Scenario: Язык по умолчанию — английский + +- **GIVEN** TVDB включён, глобальный `language` не задан в конфиге +- **WHEN** поиск возвращает запись с переводами `eng` и `rus` +- **THEN** `Candidate.Title` берётся из перевода `eng` + +#### Scenario: Ключ перевода в другом регистре + +- **GIVEN** TVDB включён, глобальный `language` = `ru` +- **WHEN** поиск возвращает запись с переводом под ключом `RUS` вместо `rus` +- **THEN** `Candidate.Title` берётся из этого перевода + +#### Scenario: Блок переводов отсутствует или пришёл негодной формой + +- **GIVEN** TVDB включён с любым значением `language` +- **WHEN** поиск возвращает записи без блока переводов либо с блоком, который не разбирается картой +- **THEN** `Candidate.Title` = primary name записи +- **AND** `Candidate.OriginalTitle` = primary name записи +- **AND** разбор ответа поиска не проваливается, кандидаты возвращаются +- **AND** остаётся диагностический след о подозрении на иную форму ответа + +#### Scenario: Коды языка не того вида — след остаётся + +- **GIVEN** TVDB включён, глобальный `language` = `ru` +- **WHEN** вся выдача поиска несёт блоки переводов с ключами другого вида (например, двухбуквенными), либо `null`, либо пустые +- **THEN** `Candidate.Title` = primary name у каждой записи +- **AND** остаётся диагностический след о подозрении на иную форму ответа + +#### Scenario: Перевода на язык нет — следа не остаётся + +- **GIVEN** TVDB включён, глобальный `language` = `ru` +- **WHEN** выдача несёт блоки переводов с ключами ожидаемого вида, но без нужного языка +- **THEN** `Candidate.Title` = primary name у каждой записи +- **AND** диагностического следа не остаётся: это штатный исход, а не подозрение + +#### Scenario: Запрос поиска не сужается языком + +- **GIVEN** TVDB включён, глобальный `language` = `ru` +- **WHEN** выполняется поиск +- **THEN** строка запроса поиска не содержит параметра языка +- **AND** строка запроса совпадает с той, что уходит при `language` = `en` + +#### Scenario: Перевод сделал сильных кандидатов двумя — матч не подтверждён + +- **GIVEN** план с `title` «Нэчжа» и `original_title` `Ne Zha`, год известен +- **WHEN** поиск TVDB возвращает две записи в пределах года ±1: одну с primary + name `哪吒之魔童降世` и переводом `rus` «Нэчжа», другую с primary name `Ne Zha` +- **THEN** гейт даёт двух сильных кандидатов вместо одного +- **AND** подтверждённого матча нет, обе записи уходят кандидатами в review +