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:
+66
-52
@@ -7,6 +7,11 @@
|
||||
// режиссёра/сезона), при неудаче — алгоритмический фолбек без сети. Любой
|
||||
// сбой деградирует к пустой строке: приём загрузки никогда не падает из-за
|
||||
// вывода имени.
|
||||
//
|
||||
// Извлечённая структура ([Fields]) — базовый (наименее доверенный) слой
|
||||
// источника полей имени: её сохраняют у загрузки (parsed_context) и позже
|
||||
// переиспользуют при обновлении display_name (поле, которого нет в
|
||||
// распознавании/матче, — например режиссёр из контекста — не теряется).
|
||||
package naming
|
||||
|
||||
import (
|
||||
@@ -22,24 +27,34 @@ import (
|
||||
// maxNameLen — ограничение длины отображаемого имени (символов/рун).
|
||||
const maxNameLen = 200
|
||||
|
||||
// mediaType — вид контента в извлечённой структуре.
|
||||
type mediaType string
|
||||
|
||||
const (
|
||||
typeMovie mediaType = "movie"
|
||||
typeSeries mediaType = "series"
|
||||
typeMovie = "movie"
|
||||
typeSeries = "series"
|
||||
)
|
||||
|
||||
// extracted — структура имени, извлечённая из контекста (схема ответа LLM).
|
||||
// Year и Director опциональны (пустое значение → в ярлык не попадают).
|
||||
type extracted struct {
|
||||
Type mediaType `json:"type"`
|
||||
Title string `json:"title"`
|
||||
OriginalTitle string `json:"original_title"`
|
||||
Year int `json:"year"`
|
||||
Director string `json:"director"`
|
||||
Season *int `json:"season"`
|
||||
IsRussian bool `json:"is_russian"`
|
||||
// Fields — скалярные поля имени, извлечённые из одного источника (контекст).
|
||||
// Это же схема ответа LLM и схема хранения parsed_context. Year/Director/Season
|
||||
// опциональны (пустые в ярлык не попадают).
|
||||
type Fields struct {
|
||||
Type string `json:"type"` // "movie"|"series"
|
||||
Title string `json:"title"`
|
||||
OriginalTitle string `json:"original_title"`
|
||||
Year int `json:"year"`
|
||||
Director string `json:"director"`
|
||||
Season *int `json:"season"`
|
||||
IsRussian bool `json:"is_russian"`
|
||||
}
|
||||
|
||||
// SeasonLabel — сводка сезона из контекстного скаляра: «Сезон N» для сериала с
|
||||
// заданным номером, иначе пусто. Для контекста сезон скалярный (в отличие от
|
||||
// плана распознавания, где он per-file и сводится recognize.SeasonSummary).
|
||||
// Используется как fallback-слой сводки сезонов, когда план распознавания её не
|
||||
// даёт.
|
||||
func (f Fields) SeasonLabel() string {
|
||||
if f.Type == typeSeries && f.Season != nil && *f.Season > 0 {
|
||||
return "Сезон " + strconv.Itoa(*f.Season)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// Namer выводит отображаемое имя. provider может быть nil — тогда работает
|
||||
@@ -65,72 +80,71 @@ func New(provider llm.Provider, attempts int, logger *slog.Logger) *Namer {
|
||||
return &Namer{provider: provider, attempts: attempts, log: logger}
|
||||
}
|
||||
|
||||
// DeriveName выводит отображаемое имя из контекста. hint — подсказка из
|
||||
// magnet (dn), используется только фолбеком, если контекст пуст. Возвращает
|
||||
// "" если имя получить не удалось (тогда вызывающий не задаёт rename).
|
||||
func (n *Namer) DeriveName(ctx context.Context, contextText, hint string) string {
|
||||
// Derive выводит отображаемое имя и извлечённую структуру. hint — подсказка из
|
||||
// magnet (dn), используется только фолбеком, если контекст пуст. Возвращает имя
|
||||
// ("" если вывести не удалось — вызывающий не задаёт rename) и извлечённую
|
||||
// структуру с ok=true, если LLM дал валидные поля (тогда их JSON сохраняют как
|
||||
// parsed_context). Фолбек полей не даёт (ok=false): его выход — только строка.
|
||||
func (n *Namer) Derive(ctx context.Context, contextText, hint string) (name string, fields Fields, ok bool) {
|
||||
// Нет ни контекста, ни подсказки — выводить имя не из чего. LLM на пустом
|
||||
// входе способен лишь галлюцинировать (наблюдалось «Unknown»), поэтому его не
|
||||
// зовём: имя считается не выведенным, вызывающий добавит загрузку без rename.
|
||||
if strings.TrimSpace(contextText) == "" && strings.TrimSpace(hint) == "" {
|
||||
return ""
|
||||
return "", Fields{}, false
|
||||
}
|
||||
if n.provider != nil {
|
||||
if ex, ok := n.extractViaLLM(ctx, contextText, hint); ok {
|
||||
if name := render(ex); name != "" {
|
||||
return name
|
||||
if ex, extracted := n.extractViaLLM(ctx, contextText, hint); extracted {
|
||||
if label := render(ex); label != "" {
|
||||
return label, ex, true
|
||||
}
|
||||
}
|
||||
}
|
||||
return fallbackName(contextText, hint)
|
||||
return fallbackName(contextText, hint), Fields{}, false
|
||||
}
|
||||
|
||||
// FormatTitleYear собирает детерминированный ярлык «Title (Year)» из уже
|
||||
// распознанного названия — без сети и LLM (перелив канонического имени после
|
||||
// распознавания). Год опционален; имя очищается от управляющих символов и
|
||||
// обрезается по длине. Пустой title → пустая строка. В отличие от render это
|
||||
// НЕ формат add-шага (там ещё режиссёр и сезон): у перелива их нет.
|
||||
func FormatTitleYear(title string, year int) string {
|
||||
// DeriveName — тонкая обёртка над Derive, когда структура не нужна (возвращает
|
||||
// только имя).
|
||||
func (n *Namer) DeriveName(ctx context.Context, contextText, hint string) string {
|
||||
name, _, _ := n.Derive(ctx, contextText, hint)
|
||||
return name
|
||||
}
|
||||
|
||||
// Label собирает полный детерминированный ярлык display_name из эффективных
|
||||
// полей: «Название (режиссёр, год)», для сериала — хвост «. <сводка сезонов>».
|
||||
// Все части, кроме названия, опциональны и выпадают, если пусты. seasonSummary —
|
||||
// уже готовая строка сводки (для плана — recognize.SeasonSummary; для контекста —
|
||||
// «Сезон N»). Имя очищается от управляющих символов и обрезается по длине.
|
||||
// Пустое название → пустая строка. Единый рендер для шага добавления и перелива
|
||||
// имени по распознаванию, чтобы формат ярлыка совпадал на всех путях.
|
||||
func Label(title, director string, year int, seasonSummary string) string {
|
||||
title = sanitize(title)
|
||||
if title == "" {
|
||||
return ""
|
||||
}
|
||||
if year > 0 {
|
||||
title += " (" + strconv.Itoa(year) + ")"
|
||||
}
|
||||
return truncate(title, maxNameLen)
|
||||
}
|
||||
|
||||
// render собирает из структуры короткий ярлык:
|
||||
// - movie: "Title (Director, Year)" — режиссёр и год опциональны;
|
||||
// - series: то же + ". Сезон N", если сезон задан.
|
||||
//
|
||||
// Имя очищается от управляющих символов/переводов строк и обрезается по
|
||||
// длине. Пустой Title → пустая строка.
|
||||
func render(ex extracted) string {
|
||||
title := sanitize(ex.Title)
|
||||
if title == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
var paren []string
|
||||
if d := sanitize(ex.Director); d != "" {
|
||||
if d := sanitize(director); d != "" {
|
||||
paren = append(paren, d)
|
||||
}
|
||||
if ex.Year > 0 {
|
||||
paren = append(paren, strconv.Itoa(ex.Year))
|
||||
if year > 0 {
|
||||
paren = append(paren, strconv.Itoa(year))
|
||||
}
|
||||
|
||||
name := title
|
||||
if len(paren) > 0 {
|
||||
name += " (" + strings.Join(paren, ", ") + ")"
|
||||
}
|
||||
if ex.Type == typeSeries && ex.Season != nil && *ex.Season > 0 {
|
||||
name += ". Сезон " + strconv.Itoa(*ex.Season)
|
||||
if s := sanitize(seasonSummary); s != "" {
|
||||
name += ". " + s
|
||||
}
|
||||
return truncate(name, maxNameLen)
|
||||
}
|
||||
|
||||
// render собирает ярлык из извлечённой структуры (контекст) через общий Label.
|
||||
func render(f Fields) string {
|
||||
return Label(f.Title, f.Director, f.Year, f.SeasonLabel())
|
||||
}
|
||||
|
||||
// sanitize убирает управляющие символы и переводы строк, схлопывает пробелы.
|
||||
func sanitize(s string) string {
|
||||
s = strings.Map(func(r rune) rune {
|
||||
|
||||
Reference in New Issue
Block a user