## 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
  с таймаутом клиента; провал не валит матч; кэш метабаз — отдельная задача
  беклога (`metadata-cache`).
- **Рассинхрон формата ярлыка и сводки сезонов между стартом, обновлением и
  карточкой** → устраняется единой функцией рендера и общим `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).
