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:
@@ -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).
|
||||
Reference in New Issue
Block a user