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:
av
2026-08-07 15:17:05 +03:00
parent 0c83385098
commit fdbc781197
19 changed files with 1444 additions and 21 deletions
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-08-07
@@ -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.
@@ -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` → раздел «Вопросы».
@@ -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` дал бы пустую строку; `"Dune<RLO>gnp.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 это ложный красный гейт.
@@ -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
@@ -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`, недоступность опциональной метабазы
состояние загрузки не двигает