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

23 KiB
Raw Blame History

Ревью изменения 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=250Apply 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 это ложный красный гейт.