Files
jellybit/openspec/changes/archive/2026-08-07-tvdb-title-locale/review/report.md
T
av fdbc781197 metadata: TVDB отдаёт локализованное название и оригинал
- локаль из [general].language применяется при разборе ответа /search, а в
  запрос не уходит: параметр language у TVDB — фильтр выдачи, а не селектор
  перевода (ADR-2026-08-07)
- Title берётся из блока translations с тотальным фолбэком на primary name,
  OriginalTitle — из primary name; форма ответа сверена по документации и
  живым прогоном не подтверждена (docs/research)
- неожиданная форма ответа даёт WARN: признак — отсутствие во всей выдаче
  ключей языка ожидаемого вида, а не неудача разбора блока
2026-08-07 15:17:05 +03:00

216 lines
23 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Ревью изменения `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 это ложный красный гейт.