display_name: слоистое разрешение полей + сохранение режиссёра из контекста

Единый источник полей отображаемого имени и один рендер полного ярлыка на
всех путях (старт и «Обновить имя»/авто-перелив). Раньше старт давал полный
«Название (режиссёр, год). Сезон 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>
This commit is contained in:
av
2026-07-11 11:50:08 +03:00
co-authored by Claude Opus 4.8
parent 9472bfdd83
commit 02d4ecc2aa
40 changed files with 1536 additions and 311 deletions
+79 -11
View File
@@ -132,13 +132,24 @@ JSON-вывод), извлекая из контекста тип (movie/series)
распознавание дало каноническое название, — переливая уже вычисленное имя (без
нового вызова LLM) в `download.display_name` и в имя раздачи qBittorrent.
Источником имени SHALL быть **эффективное** распознанное название: пины
`title`/`year` (если матч подтверждён), иначе `recognition.title`/`year` — то
же название, что использует раскладка. Формат SHALL быть коротким детерминированным
ярлыком `Title (Year)` (год опционален), с той же очисткой от управляющих
символов и обрезкой по длине, что и вывод имени на шаге добавления. Пустой
источник (нет распознавания или пустое название) SHALL приводить к отсутствию
изменений (no-op).
Источником имени SHALL быть **эффективные поля имени**, разрешённые по слоям
сверху вниз (берётся первый непустой слой): (1) ручные правки `override`;
(2) распознавание с вложенным подтверждённым матчем (`recognition`, куда матч
метабазы уже вложил каноничные название/год/режиссёра); (3) сохранённая
структура из контекста (`download.parsed_context`). Так каждое поле берётся из
самого доверенного доступного источника, а данные из контекста (например
режиссёр) не теряются, если распознавание/матч их не дали. Название из слоя
распознавания SHALL совпадать с тем, что использует раскладка (эффективный
`title`), чтобы отображаемое имя не расходилось с целевыми путями.
Формат SHALL быть тем же полным детерминированным ярлыком, что и на шаге
добавления: «Название (режиссёр, год)», где режиссёр и год опциональны, а для
сериала добавляется сводка сезонов («. Сезон N» для одного сезона; «. Сезоны …»
для многосезонного пака; отметка спецвыпусков) — согласованная со сводкой сезонов
на экране просмотра. Применяются та же очистка от управляющих символов и обрезка
по ограничению длины. Пустой источник (нет ни распознавания, ни сохранённой
структуры, дающих непустое название) SHALL приводить к отсутствию изменений
(no-op).
Переименование раздачи в qBittorrent SHALL адресоваться по infohash своей
раздачи и SHALL быть best-effort: сбой (раздача удалена, qBittorrent недоступен)
@@ -154,12 +165,28 @@ SHALL NOT проваливать обновление — `download.display_name
#### Scenario: Перелив имени в загрузку и раздачу
- **GIVEN** загрузка с распознанным непустым каноническим названием
- **GIVEN** загрузка с распознанным непустым каноническим названием и известным
режиссёром (из матча или из сохранённого контекста)
- **WHEN** запускается обновление отображаемого имени
- **THEN** `download.display_name` устанавливается в `Title (Year)`
- **THEN** `download.display_name` устанавливается в полный ярлык
«Название (режиссёр, год)» (для сериала — со сводкой сезонов)
- **AND** раздача в qBittorrent переименовывается в то же имя (по infohash своей
раздачи)
#### Scenario: Режиссёр из контекста переживает распознавание без матча
- **GIVEN** загрузка, где режиссёр был извлечён из контекста, а распознавание
прошло без подтверждённого матча (режиссёр из метабазы недоступен)
- **WHEN** запускается обновление отображаемого имени
- **THEN** в ярлыке используется режиссёр из сохранённого контекста
- **AND** название/год берутся из распознавания
#### Scenario: Режиссёр из матча бьёт контекстного
- **GIVEN** загрузка, где режиссёр есть и в контексте, и в подтверждённом матче
- **WHEN** формируется ярлык
- **THEN** используется режиссёр из матча (более доверенный слой)
#### Scenario: qBittorrent недоступен — имя у загрузки всё равно обновлено
- **GIVEN** обновление отображаемого имени с выведенным непустым именем
@@ -168,9 +195,10 @@ SHALL NOT проваливать обновление — `download.display_name
- **THEN** `download.display_name` всё равно обновлён
- **AND** ошибка внешнего вызова qBittorrent логируется, операция не проваливается
#### Scenario: Нет распознавания — обновление ничего не делает
#### Scenario: Нет источника имени — обновление ничего не делает
- **GIVEN** загрузка без распознанного названия (пустой источник имени)
- **GIVEN** загрузка без распознанного названия и без сохранённой структуры
(пустой источник имени)
- **WHEN** запускается обновление отображаемого имени
- **THEN** ни `download.display_name`, ни имя раздачи не меняются (no-op)
@@ -561,3 +589,43 @@ NOT.
- **WHEN** принимаемый `.torrent`-файл превышает ограничение размера
- **THEN** приём отклоняется с ошибкой, загрузка не создаётся
### Requirement: Сохранение извлечённой из контекста структуры имени
Система SHALL сохранять структуру имени, извлечённую LLM со структурированным
выводом на шаге добавления (тип, название, оригинальное название, год, режиссёр,
сезон), у загрузки (`download.parsed_context`, JSON), чтобы её поля могли
переиспользоваться при последующем выводе имени без повторного вызова LLM.
Алгоритмический фолбек структуры не даёт (его выход — только строка имени), тогда
`parsed_context` остаётся пустым — это штатно (нижний слой отсутствует). Сохранённая структура SHALL
быть **базовым (наименее доверенным) слоем** источника полей имени: её значения
берутся, только если более доверенный слой (распознавание/матч, ручные правки)
соответствующего поля не дал.
Сохранение SHALL быть best-effort и косметическим: неудача записи `parsed_context`
SHALL NOT проваливать добавление загрузки, а сама структура SHALL влиять только на
отображаемое имя и SHALL NOT влиять на пути файлов, распознавание или раскладку.
Пустая/невыведенная структура (нет контекста и подсказки) SHALL приводить к
пустому `parsed_context` (нечего сохранять).
#### Scenario: Извлечённый режиссёр сохраняется у загрузки
- **GIVEN** контекст загрузки, из которого LLM извлёк режиссёра и год
- **WHEN** система выводит имя на шаге добавления
- **THEN** извлечённая структура (в т.ч. режиссёр) сохраняется в
`download.parsed_context`
- **AND** отображаемое имя формируется как и прежде (полный ярлык)
#### Scenario: Сбой сохранения структуры не валит добавление
- **GIVEN** запись `parsed_context` завершается ошибкой
- **WHEN** идёт шаг добавления загрузки
- **THEN** загрузка всё равно добавляется в qBittorrent (с `rename`, если имя
выведено)
- **AND** ошибка логируется, приём/добавление не проваливается
#### Scenario: Пустой вход — пустая структура
- **GIVEN** пойманная загрузка без контекста и без подсказки из полей источника
- **WHEN** выполняется шаг добавления
- **THEN** структура не выводится, `download.parsed_context` пуст