Единый источник полей отображаемого имени и один рендер полного ярлыка на всех путях (старт и «Обновить имя»/авто-перелив). Раньше старт давал полный «Название (режиссёр, год). Сезон N» но выбрасывал структуру, а перелив по распознаванию — усечённый «Title (Year)». - Слоистое разрешение скаляров имени: override → recognition(+match) → новый базовый слой «контекст» (download.parsed_context, JSON naming.Fields). - naming: публичные Fields/Label/Derive, вынесен единый рендер; удалён FormatTitleYear. Сводка сезонов вынесена в recognize.SeasonSummary. - Режиссёр из метабазы (решение A2): TMDB/TVDB credits через опциональный metadata.DirectorProvider; авто-матч кладёт в plan.Director, ручной выбор кандидата тянет credits и пиннит ovrDirector. Метабаза бьёт контекст. - refreshDisplayNameLocked строит полный ярлык из эффективных полей; инфо-панель ревью показывает загруженного режиссёра. - Миграция 0011_parsed_context + ER-схема. Всё косметика: на пути/раскладку не влияет, приём/вывод имени не валятся (best-effort). Закрывает беклог-задачу «Кнопка „Обновить имя“: полный формат ярлыка». OpenSpec: archive/2026-07-11-field-resolution-display-name (ingest, recognition, metadata-match, review). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
250 lines
19 KiB
Markdown
250 lines
19 KiB
Markdown
# 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-RU`, со значением, настраиваемым конфигом `[metadata.tmdb].language`.
|
||
Это влияет только на локализованное поле `Title`/`Name`; поле
|
||
`original_title`/`original_name` остаётся на языке оригинала, поэтому
|
||
оригинальная сторона сравнения не затрагивается.
|
||
|
||
#### Scenario: Локализованный заголовок приходит по-русски
|
||
|
||
- **GIVEN** TMDB включён, `language` не задан в конфиге
|
||
- **WHEN** выполняется поиск фильма с русской локализацией
|
||
- **THEN** запрос содержит `language=ru-RU`
|
||
- **AND** в кандидате `Title` приходит на русском, а `OriginalTitle` — на языке оригинала
|
||
|
||
### 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
|
||
|