# 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