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
+1
View File
@@ -278,6 +278,7 @@ func metadataProviders(cfg *config.Config, logger *slog.Logger) ([]metadata.Prov
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)
+1 -1
View File
@@ -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)
@@ -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. Инвариант «авто-раскладка только при подтверждённом матче» не двигается.
+1
View File
@@ -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) | — |
+4
View File
@@ -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`, **живым прогоном
не подтверждено**.
+97
View File
@@ -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) и от
любого ручного прогона интеграционного теста: первый же живой ответ обязан
заменить здесь предположения на наблюдения, а слова «живым прогоном не
подтверждено» — на дату и результат прогона.
+12
View File
@@ -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) → «Инварианты», обратимость — там же в
«Работа» → «Необратимое». Шкала ущерба берётся оттуда, а порядок ценностей —
+2 -2
View File
@@ -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:
+32 -2
View File
@@ -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
+106 -3
View File
@@ -25,14 +25,35 @@ 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
language string
hc *http.Client
log *slog.Logger
@@ -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,
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
}
+186 -2
View File
@@ -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)
+20
View File
@@ -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) {
// Реальный кейс: русское релиз-имя, матч по оригинальному названию.
// При сильном матче по первому ключу дальнейшие запросы не делаются.
@@ -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`, недоступность опциональной метабазы
состояние загрузки не двигает
+119
View File
@@ -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