Files
jellybit/openspec/changes/archive/2026-07-11-field-resolution-display-name/design.md
T
avandClaude Opus 4.8 02d4ecc2aa 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>
2026-07-11 11:50:08 +03:00

154 lines
12 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
## 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).