## 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).