Единый источник полей отображаемого имени и один рендер полного ярлыка на всех путях (старт и «Обновить имя»/авто-перелив). Раньше старт давал полный «Название (режиссёр, год). Сезон 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>
12 KiB
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
- Миграция goose:
ALTER TABLE download ADD COLUMN parsed_context TEXT NOT NULL DEFAULT ''; обновить ER-схемуdocs/specs/database.md. - Существующие строки — с пустым
parsed_context; поведение имени для них не меняется (нижний слой пуст). Откат — колонка неиспользуемая, безопасно игнорируется; down-миграция дропает колонку. - Раскатка обычная (копия бинаря на umbar), без ручных шагов данных.
Open Questions
- Нет (развилки хранения/источника/сезона согласованы с пользователем до proposal).