Files
jellybit/openspec/specs/metadata-match/spec.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

389 lines
30 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.
# metadata-match Specification
## Purpose
Сверка распознанного плана с внешними базами метаданных (TMDB/TVDB/TVMaze):
поиск записи по нескольким названиям с нормализацией, локаль запроса,
подтверждение единичного сильного матча (официальный `provider_id` +
каноническое имя/год) и сбор кандидатов с URL для ручного выбора в `review`.
Разбор сигналов моделью — в `recognition`.
## Requirements
### Requirement: Сверка с базой по нескольким названиям
При сверке плана с включёнными базами метаданных система SHALL искать по
нескольким названиям в порядке убывания силы ключа: сначала по
`original_title`, затем по локализованному `title`, затем по `provider_hint`.
Этот перебор названий SHALL выполняться в рамках каждого прохода сверки — проходы
(с годом и безгодовой fallback) определяет требование «Безгодовой второй проход
сверки как fallback». Поиск SHALL останавливаться, как только очередной запрос
дал единичный сильный матч (ровно один кандидат с совпадением названия и года);
ранняя остановка действует в пределах прохода. Запрос с названием, нормализованно
совпадающим с уже выполненным, система SHALL пропускать, чтобы не обращаться к
базе повторно с тем же ключом.
Кандидаты для ручного выбора в review система SHALL собирать из всех выполненных
заходов обоих проходов с дедупликацией по `provider:id` и общим потолком.
#### Scenario: Иностранный фильм находится по оригинальному названию
- **GIVEN** план с `title` «Тёмный рыцарь», `original_title` «The Dark Knight», год 2008
- **WHEN** выполняется сверка с базой
- **THEN** первый запрос идёт по «The Dark Knight»
- **AND** при единичном сильном матче дальнейшие запросы (по `title`, `provider_hint`) не выполняются
#### Scenario: Фолбэк на локализованное название
- **GIVEN** план, для которого запрос по `original_title` не дал единичного сильного матча
- **WHEN** продолжается сверка
- **THEN** выполняется запрос по локализованному `title`
- **AND** при отсутствии матча и там — запрос по `provider_hint`
#### Scenario: Дублирующий запрос пропускается
- **GIVEN** план, у которого `original_title` нормализованно совпадает с `title`
- **WHEN** выполняется сверка
- **THEN** база запрашивается этим названием один раз, повторный заход по `title` не делается
### Requirement: Подтверждение матча и каноническое имя
При единичном сильном матче система SHALL брать из записи базы официальный
`provider` (`tmdb`|`tvdb`|`tvmaze`) и `provider_id`, а также каноническое название
и год, и подменять ими соответствующие поля плана (для сериала — с учётом внешнего
тега TVDB/IMDb из `externals`, идущего в имя папки). Матч SHALL считаться
подтверждённым только при ровно одном сильном кандидате; при нуле или нескольких
кандидатах подтверждённого матча быть SHALL NOT (авто-раскладка не разрешается,
кандидаты уходят в review). Работа с базами опциональна: при выключенных базах
сверка не выполняется и подтверждённого матча нет.
При подтверждённом матче система SHALL дополнительно попытаться получить из базы
**режиссёра** (TMDB/TVDB credits) и вложить его в план (`director`) как
недоверенное косметическое значение для вывода отображаемого имени. Тот же способ
выборки режиссёра по `provider:id` SHALL быть доступен при закреплении вручную
выбранного в ревью кандидата (см. `review`), т.к. основной путь подтверждения
матча — ручной выбор, а не авто. Выборка режиссёра SHALL быть best-effort: её
недоступность, отсутствие в базе или провайдер без режиссёра (напр. TVMaze) SHALL
NOT проваливать распознавание/матч/выбор — `director` остаётся пустым, а имя
выводится без режиссёра или из более низкого слоя (сохранённый контекст). Режиссёр
из метабазы SHALL иметь приоритет над режиссёром из контекста (более проверенный
источник).
Режиссёр — недоверенное человекочитаемое поле: он SHALL NOT участвовать в
структурной валидации/гейте авто-раскладки, а его очистка (управляющие символы,
пробелы, лимит длины) применяется при рендере отображаемого имени, а не в
plan-санитайзинге.
#### Scenario: Единичный матч даёт id и каноническое имя
- **GIVEN** поиск вернул ровно одного сильного кандидата TMDB для фильма
- **WHEN** матч подтверждается
- **THEN** план получает `provider`=`tmdb`, `provider_id`, каноническое название и год
#### Scenario: Матч подтягивает режиссёра
- **GIVEN** подтверждённый единичный матч TMDB для фильма, у которого в credits
указан режиссёр
- **WHEN** матч подтверждается
- **THEN** в план вкладывается `director` из credits
- **AND** отображаемое имя может использовать этого режиссёра
#### Scenario: Режиссёр недоступен — матч не ломается
- **GIVEN** подтверждённый матч, для которого выборка режиссёра недоступна или
провайдер режиссёра не отдаёт
- **WHEN** матч подтверждается
- **THEN** `director` остаётся пустым
- **AND** матч подтверждён, распознавание не проваливается
#### Scenario: Несколько кандидатов — матч не подтверждён
- **GIVEN** поиск вернул более одного подходящего кандидата
- **WHEN** оценивается матч
- **THEN** подтверждённого матча нет, кандидаты собираются для выбора в review
### Requirement: Локаль запроса к TMDB
Локаль запросов к TMDB SHALL выводиться из глобальной настройки `language`
(`ru`|`en`, дефолт `en`): `ru``ru-RU`, `en``en-US`. Отдельной настройки
локали у TMDB быть SHALL NOT — глобальный `language` единственный источник.
Эту локаль система SHALL передавать параметром `language` как в запрос поиска,
так и в запрос credits (режиссёр). На стороне поиска локаль влияет ТОЛЬКО на
локализованное поле `Title`/`Name`; поле `original_title`/`original_name`
остаётся на языке оригинала, поэтому оригинальная сторона сравнения не
затрагивается. На стороне credits передача локали — best-effort: имена людей
провайдер локализует не всегда, при отсутствии перевода имя остаётся на языке
оригинала, и это не проваливает выборку режиссёра.
#### Scenario: Локаль по умолчанию — английская
- **GIVEN** TMDB включён, глобальный `language` не задан в конфиге
- **WHEN** выполняется поиск фильма
- **THEN** запрос содержит `language=en-US`
- **AND** в кандидате `Title` приходит на английском, а `OriginalTitle` — на языке оригинала
#### Scenario: language=ru даёт русскую локаль
- **GIVEN** TMDB включён, глобальный `language` = `ru`
- **WHEN** выполняется поиск фильма с русской локализацией
- **THEN** запрос содержит `language=ru-RU`
- **AND** в кандидате `Title` приходит на русском, а `OriginalTitle` — на языке оригинала
#### Scenario: Локаль передаётся и в запрос режиссёра
- **GIVEN** подтверждённый матч TMDB и глобальный `language` = `ru`
- **WHEN** выполняется запрос credits за режиссёром
- **THEN** запрос содержит `language=ru-RU`
- **AND** при отсутствии локализованного имени режиссёр остаётся на языке оригинала, выборка не проваливается
### Requirement: Нормализация названий при сравнении
Нормализация названий для гейта сильного матча SHALL сводить букву `ё` к `е`,
чтобы написания, различающиеся только `ё`/`е`, считались одним названием.
Нормализация SHALL дополнительно сворачивать кирилло-латинские homoglyph-двойники
по той же курируемой таблице, что применяется при санитайзинге плана
(capability `recognition`), чтобы визуально совпадающие название плана и кандидата
базы, различающиеся лишь скриптом отдельных букв, считались одним названием. Это
defense-in-depth на случай двойников со стороны кандидата: гейт — точка
безопасно-критичного решения об авто-матче и SHALL быть устойчив к двойникам
независимо от предшествующего санитайзинга.
#### Scenario: «Тёмный» и «Темный» совпадают
- **GIVEN** план с названием «Тёмный рыцарь» и кандидат базы «Темный рыцарь»
- **WHEN** сравниваются нормализованные названия
- **THEN** они считаются совпадающими
#### Scenario: Двойник у кандидата не мешает матчу
- **GIVEN** план с латинским названием `Harold` и кандидат базы, где то же слово
содержит кириллический символ-двойник
- **WHEN** сравниваются нормализованные названия
- **THEN** они считаются совпадающими
### Requirement: Кандидат несёт URL для внешней проверки
Каждый кандидат внешней базы метаданных (`metadata.Candidate`) SHALL нести
поле `URL` — ссылку на страницу элемента (фильма/сериала) на сайте
провайдера. URL SHALL формироваться клиентом провайдера при поиске
(`Search`) и сохраняться в таблице `metadata_candidate`. Отображение этой
ссылки на экране ревью — забота `review`/`web-ui`, не данного требования.
Формат URL для каждого провайдера:
- **TMDB**: `https://www.themoviedb.org/movie/{id}` (фильм) или
`https://www.themoviedb.org/tv/{id}` (сериал) — тип контента известен из
запроса `Query.Type`
- **TVDB**: `https://www.thetvdb.com/dereferrer/movie/{id}` (фильм) или
`https://www.thetvdb.com/dereferrer/series/{id}` (сериал) — тип контента
известен из запроса `Query.Type`
- **TVMaze**: `https://www.tvmaze.com/shows/{id}` — URL SHALL использовать
нативный id TVMaze, а не внешний тег (TVDB/IMDb), чтобы ссылка вела на
TVMaze-страницу
#### Scenario: Кандидат TMDB с корректной ссылкой
- **GIVEN** TMDB найден кандидат-фильм с id `603` («Матрица»)
- **WHEN** клиент TMDB формирует Candidate
- **THEN** `URL` = `https://www.themoviedb.org/movie/603`
#### Scenario: Кандидат TVMaze с нативной ссылкой
- **GIVEN** TVMaze найден сериал с id `169` («Фарго»), внешний тег — TVDB id `269613`
- **WHEN** клиент TVMaze формирует Candidate
- **THEN** `URL` = `https://www.tvmaze.com/shows/169`
- **AND** `TagProvider`/`TagID` остаются `tvdb`/`269613` (тег папки Jellyfin не меняется)
#### Scenario: URL сохраняется в БД
- **GIVEN** результат поиска с кандидатами
- **WHEN** кандидаты сохраняются в таблицу `metadata_candidate`
- **THEN** значение `url` SHALL быть записано в колонку `url`
- **AND** при последующей загрузке данных ревью url доступен без повторной
генерации
### Requirement: Безгодовой второй проход сверки как fallback
Система SHALL выполнять второй проход сверки без года как fallback: когда поиск с
годом не дал подтверждённого единичного сильного матча, а год плана известен
(`year > 0`), выполняется тот же перебор названий (в порядке `original_title`
`title``provider_hint`) и провайдеров, но с запросом БЕЗ года. Второй проход
SHALL выполняться только как
fallback: если первый проход подтвердил матч или год плана неизвестен, второго
прохода быть SHALL NOT.
Гейт сильного матча (нормализованное совпадение названия и год кандидата в пределах
±1, ровно один кандидат) при этом SHALL оставаться неизменным — безгодовой проход
расширяет только выдачу запроса, но не ослабляет условие подтверждения, поэтому
инвариант «авто-раскладка только при подтверждённом единичном сильном матче» не
затрагивается. Дополнительно в безгодовом проходе система SHALL требовать
известный год кандидата: запись с неизвестным годом (год подтвердить нечем) во
втором проходе подтверждённым матчем быть SHALL NOT и уходит кандидатом в review
(в первом, с-годом, проходе прежняя leniency к неизвестному году сохраняется).
Кандидаты для ручного выбора в review система SHALL собирать из обоих проходов с
той же дедупликацией по `provider:id` и общим потолком.
#### Scenario: Год плана off-by-one — авто-матч восстанавливается без года
- **GIVEN** запись есть в базе, но год плана отличается от её года ровно на 1
(жёсткий exact-year фильтр запроса отсёк её в первом проходе)
- **WHEN** первый проход (с годом) не дал сильного матча
- **THEN** выполняется второй проход без года
- **AND** единичный кандидат проходит гейт (название совпадает, год бьётся ±1) —
матч подтверждается
#### Scenario: Год плана расходится больше чем на 1 — кандидат только в review
- **GIVEN** запись есть в базе, но год плана отличается от её года больше чем на 1
- **WHEN** второй проход без года возвращает эту запись единственным кандидатом
- **THEN** гейт отклоняет её по году (расхождение больше ±1) — подтверждённого
матча нет
- **AND** кандидат собирается для выбора в review (человек получает кандидата
вместо пустого списка)
#### Scenario: Первый проход подтвердил матч — второго нет
- **GIVEN** первый проход (с годом) дал единичный сильный матч
- **WHEN** завершается сверка
- **THEN** второй (безгодовой) проход не выполняется
#### Scenario: Год неизвестен — второго прохода нет
- **GIVEN** план без года (`year` = 0)
- **WHEN** первый проход не дал матча
- **THEN** второй проход не выполняется (он был бы идентичен первому)
#### Scenario: Кандидат с неизвестным годом в безгодовом проходе — только review
- **GIVEN** год плана известен, а pass 1 (с годом) не дал матча
- **WHEN** безгодовой проход возвращает единственного кандидата с совпадающим
названием, но неизвестным годом
- **THEN** подтверждённого матча нет (год кандидата не подтверждён) — авто-раскладка
не делается
- **AND** кандидат собирается для выбора в review
#### Scenario: Несколько кандидатов из безгодового прохода — матч не подтверждён
- **GIVEN** безгодовой проход вернул более одного кандидата, проходящего гейт
- **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