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
@@ -0,0 +1,153 @@
## Context
`download.display_name` — косметический ярлык (список qBittorrent + заголовок в
веб-UI), не влияющий на пути/раскладку. Сейчас его выводят два расходящихся пути:
- **Старт** (`worker.go:455`, `naming.DeriveName`): LLM извлекает `extracted`
(type/title/original_title/year/**director**/season), приватная `render`
собирает полный ярлык «Название (режиссёр, год). Сезон N». Структура после
рендера выбрасывается.
- **Обновление по распознаванию** (`review.go:1089`, `refreshDisplayNameLocked`):
ручная кнопка «Обновить имя» и авто-перелив при матче зовут
`naming.FormatTitleYear(plan.Title, plan.Year)` → усечённый `Title (Year)`.
В системе уже есть слоистое разрешение полей плана: `effectivePlan` читает
`recognition.Plan` (в него `Recognize` вкладывает каноничные title/year матча) и
накладывает `override` (ручные пины) через `applyOverrides`. Не хватает **нижнего
слоя «контекст»** и **поля режиссёра**.
Constraints (инварианты): вывод имени НИКОГДА не валит приём/добавление
(деградация к пустому); выход LLM и метабаз недоверенный; санитайзинг + лимит
`maxNameLen`; секреты не в логах; время UTC; ULID-идентификаторы; при изменении
схемы — миграция goose + ER-схема `docs/specs/database.md`.
## Goals / Non-Goals
**Goals:**
- Единый слоистый источник скалярных полей имени и **одна** функция рендера
полного ярлыка, используемая и на старте, и при обновлении.
- Режиссёр из контекста сохраняется (`parsed_context`) и не теряется; режиссёр из
метабазы (TMDB/TVDB credits) его перекрывает.
- Кнопка «Обновить имя»/авто-перелив дают полный формат (закрытие беклог-задачи
`knopka-obnovit-imya-polnyj-format`).
**Non-Goals:**
- Не вводим EAV-таблицу «поле+источник» и не переносим title/year из
Plan/override в новое хранилище (Plan структурен — `files[]`; дубль исказит
«где правда»).
- Не храним провенанс поля (источник выводится при разрешении, если понадобится
в UI).
- Не добавляем режиссёра в промпт распознавания (его уже извлекает контекстный
`naming`; в план он приходит из матча).
- Не трогаем логику раскладки/путей/безопасности.
## Decisions
### 1. Хранение контекста — JSON-колонка `download.parsed_context`
Извлечённую на старте структуру (`naming.extracted`) сериализуем JSON-ом в новую
колонку `download.parsed_context TEXT NOT NULL DEFAULT ''`. Это единственный
недостающий источник; он 1:1 с загрузкой, ставится один раз, читается точечно.
*Почему не таблица-спутник:* join ради 1:1 без выгоды. *Почему не колонки-на-поле:*
миграция на каждое под-поле; JSON эволюционирует свободно, как уже хранится Plan.
*Почему вообще persist, а не пере-извлечение из `context` при обновлении:* лишний
вызов LLM; пользователь явно просил «сохраняем».
### 2. Слоистое разрешение — хелпер в коде, не хранимый провенанс
Вводим структуру эффективных полей имени и хелпер, собирающий её из слоёв
override → recognition(+match) → parsed_context (первый непустой на поле).
Источник каждого поля выводится позицией слоя; хранить его не нужно. Хелпер живёт
рядом с `effectivePlan`/`refreshDisplayNameLocked` (worker), т.к. только он имеет
доступ ко всем трём слоям под `w.mu`.
### 3. Режиссёр: два входа из метабазы + слой override (решение A2)
`recognize.Plan` получает опциональное `Director string \`json:"director,omitempty"\``.
LLM его не заполняет и не валидирует. Режиссёр из метабазы приходит **двумя**
путями, оба best-effort (ошибка/пусто/провайдер-без-режиссёра — напр. TVMaze — не
валят матч):
- **Авто-матч** (`Recognize`/`matchMetadata`): при подтверждённом единичном матче
вкладываем режиссёра в `plan.Director` — ровно как уже вкладываются title/year.
- **Ручной выбор кандидата в ревью** (основной путь): `chooseCandidateLocked`/
`AddManualSource` при закреплении кандидата тянут режиссёра выбранного `provider:id`
из credits и пишут его как **director-override** (новое поле `ovr` в наборе пинов
источника рядом с provider/id/title/year). `applyOverrides` кладёт значение в
`plan.Director`. Так режиссёр выбранного кандидата переживает перезагрузку
страницы (override персистентен) без колонки на `metadata_candidate`.
Выборку credits по `provider:id` даёт новый метод интерфейса метабазы, проброшенный
в worker через интерфейс `Recognizer` (worker уже зависит от него; прямой зависимости
worker→metadata не заводим). Credits тянем **только** для подтверждённого/выбранного
источника, а не для каждого кандидата поиска — экономим внешние вызовы.
*Альтернатива A1 (отклонена пользователем):* режиссёр только из авто-матча —
на основном (ручном) пути подтверждения матча не проявлялся бы. *Альтернатива
(колонка `metadata_candidate.director` + фетч на поиске):* вторая миграция и фетч
для всех кандидатов — дороже, отклонена.
### 3a. Режиссёр — недоверенное косметическое поле
`director` (из контекста, из авто-матча или из override) — недоверенный вход. Он
НЕ входит в санитайзинг плана (`recognition` «Санитайзинг человекочитаемых полей»
чистит `title`/`original_title`/`provider_hint`) и НЕ участвует в структурной
валидации/гейте. Очистка (управляющие символы, пробелы, лимит) применяется к нему
на **рендере ярлыка** (`render`/`sanitize` уже это делают). На пути/раскладку
режиссёр не влияет.
### 4. Единый рендер полного ярлыка
Экспортируем из `internal/naming` функцию, строящую ярлык из эффективных полей
(та же логика, что приватная `render`): «Название (режиссёр, год)» + для сериала
хвост сезона. `FormatTitleYear` удаляем (или переводим на новый рендер).
`refreshDisplayNameLocked` вместо `FormatTitleYear(plan.Title, plan.Year)` зовёт
новый рендер по эффективным полям. Старт (`DeriveName`) использует тот же рендер.
### 5. Сводка сезонов — общая с UI, отдельная форма слоя
Сезон не разрешается как плоский скаляр: у слоя `recognition` он выражен
**per-file** (`plan.Files[].Season`) и сворачивается в строку через `seasonSummary`
(`httpapi/files.go:47`: один → «Сезон N», диапазон → «Сезоны 1–3», спецвыпуски), а
у слоя `parsed_context` это **скаляр** `Season *int` (даёт лишь «Сезон N»). Поэтому:
- Публичный рендер ярлыка принимает **готовую строку сводки сезонов**, а не сырое
число; хвост ярлыка — «. <сводка>» (пусто → хвоста нет).
- `effectiveNameFields` вычисляет эту строку по источнику: если есть план
распознавания с episode-ролями — `seasonSummary(plan)`; иначе (плана нет —
например ярлык на старте — или у сериала нет episode-ролей) fallback на
контекстный скаляр `parsed_context.season` → «Сезон N». Для фильма сезона нет.
- Логику `seasonSummary` выносим из `httpapi` в переиспользуемое место (`recognize`
или `naming`); `httpapi` и рендер зовут один хелпер — карточка страницы и ярлык
дают одинаковую сводку. Тесты `seasonSummary` переезжают вместе с кодом.
## Risks / Trade-offs
- **Доп. вызов credits к TMDB/TVDB при каждом подтверждённом матче** → best-effort
с таймаутом клиента; провал не валит матч; кэш метабаз — отдельная задача
беклога (`kesh-metabaz`).
- **Рассинхрон формата ярлыка и сводки сезонов между стартом, обновлением и
карточкой** → устраняется единой функцией рендера и общим `seasonSummary`
(ревью проверит, что все три пути зовут одно).
- **Миграция добавляет колонку существующим строкам** → `DEFAULT ''`, старые
загрузки просто без `parsed_context` (нижний слой пуст) — деградация штатная,
имя выводится из распознавания как и раньше.
- **`parsed_context` — недоверенный вход** (LLM/фолбек) → к его полям применяется
тот же санитайзинг/лимит на рендере; на пути/раскладку не влияет.
## Migration Plan
1. Миграция goose: `ALTER TABLE download ADD COLUMN parsed_context TEXT NOT NULL
DEFAULT ''`; обновить ER-схему `docs/specs/database.md`.
2. Существующие строки — с пустым `parsed_context`; поведение имени для них не
меняется (нижний слой пуст). Откат — колонка неиспользуемая, безопасно
игнорируется; down-миграция дропает колонку.
3. Раскатка обычная (копия бинаря на umbar), без ручных шагов данных.
## Open Questions
- Нет (развилки хранения/источника/сезона согласованы с пользователем до
proposal).