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:
av
2026-07-11 11:50:08 +03:00
co-authored by Claude Opus 4.8
parent 9472bfdd83
commit 02d4ecc2aa
40 changed files with 1536 additions and 311 deletions
+80 -10
View File
@@ -29,6 +29,7 @@ const (
ovrProviderID = "provider_id" // id в выбранной базе
ovrTitle = "title" // запиненное каноническое название
ovrYear = "year" // запиненный год
ovrDirector = "director" // режиссёр выбранного источника (credits метабазы)
ovrForceReview = "force_review" // ручная перепривязка: не авто-раскладывать
)
@@ -785,7 +786,15 @@ func (w *Worker) chooseCandidateLocked(ctx context.Context, id string, d *store.
if cand.Year.Valid {
year = int(cand.Year.Int64)
}
for field, value := range sourcePins(cand.Provider, cand.ProviderID, title, year) {
// Режиссёр выбранного источника из метабазы (credits) — best-effort: пустой
// (нет провайдера/не умеет/ошибка) очищает возможный унаследованный пин; тогда
// в ярлыке режиссёр берётся из распознавания/авто-матча (plan.Director), а если
// и там пусто — из сохранённого контекста (нижний слой).
director := ""
if w.recognizer != nil {
director = w.recognizer.Director(ctx, candMediaType(rec), cand.Provider, cand.ProviderID)
}
for field, value := range sourcePins(cand.Provider, cand.ProviderID, title, year, director) {
if err := w.store.SetOverride(ctx, id, field, value); err != nil {
return fmt.Errorf("choose candidate: %w", err)
}
@@ -825,9 +834,22 @@ func (w *Worker) SetProviderID(ctx context.Context, id string, provider, provide
if err != nil {
return err
}
// Режиссёр вручную заданного источника из метабазы (credits) — best-effort
// косметика: сбой чтения рекогниции не валит смену источника (тип по умолчанию
// movie, как трактует candMediaType(nil)).
director := ""
if w.recognizer != nil {
rec, rerr := w.store.GetCurrentRecognition(ctx, id)
if rerr != nil {
logctx.From(w.scoped(ctx, capReview, id, d.PrimaryInfohash())).
Warn("set provider: recognition lookup for director failed", "error", rerr)
rec = nil
}
director = w.recognizer.Director(ctx, candMediaType(rec), provider, providerID)
}
// Полный набор пинов: id задан вручную, название/год берём из плана
// (очищаем возможный унаследованный пин прежнего источника).
for field, value := range sourcePins(provider, providerID, "", 0) {
for field, value := range sourcePins(provider, providerID, "", 0, director) {
if err := w.store.SetOverride(ctx, id, field, value); err != nil {
return fmt.Errorf("set provider: %w", err)
}
@@ -848,7 +870,7 @@ func (w *Worker) ClearProvider(ctx context.Context, id string) (err error) {
if err != nil {
return err
}
for field, value := range sourcePins("none", "", "", 0) {
for field, value := range sourcePins("none", "", "", 0, "") {
if err := w.store.SetOverride(ctx, id, field, value); err != nil {
return fmt.Errorf("clear provider: %w", err)
}
@@ -862,7 +884,7 @@ func (w *Worker) ClearProvider(ctx context.Context, id string) (err error) {
// пустая строка трактуется как «нет override» → берётся значение плана. Так
// выбор любого источника даёт детерминированный эффективный план, а превью
// совпадает с применением. Используется и в коммите (SetOverride), и в превью.
func sourcePins(provider, providerID, title string, year int) map[string]string {
func sourcePins(provider, providerID, title string, year int, director string) map[string]string {
yr := ""
if year > 0 {
yr = strconv.Itoa(year)
@@ -872,9 +894,19 @@ func sourcePins(provider, providerID, title string, year int) map[string]string
ovrProviderID: providerID,
ovrTitle: title,
ovrYear: yr,
ovrDirector: director,
}
}
// candMediaType — тип из текущей рекогниции (для выборки режиссёра нужным
// эндпоинтом провайдера). Неизвестный/пустой → movie (дефолт схемы).
func candMediaType(rec *store.Recognition) recognize.MediaType {
if rec != nil && rec.MediaType.String == string(recognize.MediaSeries) {
return recognize.MediaSeries
}
return recognize.MediaMovie
}
// --- Данные для экрана ревью ---
// ReviewData — всё, что нужно транспорту для отрисовки ревью.
@@ -1011,7 +1043,10 @@ func (w *Worker) buildSources(ctx context.Context, downloadID string, rawPlan re
// overrides, считает эффективный план и предпросмотр путей — эфемерно, без
// записи. Гарантия preview == apply: тот же набор пинов запишет выбор.
func (w *Worker) sourceOption(ctx context.Context, downloadID string, kind SourceKind, rawPlan recognize.Plan, base map[string]string, candID, provider, providerID, url, title string, year int, active bool) SourceOption {
eff := applyOverrides(rawPlan, mergeSourceOverrides(base, sourcePins(provider, providerID, title, year)))
// Режиссёр в эфемерном превью не тянем (пусто): он не влияет на пути раскладки,
// а per-candidate выборка credits дорога. Режиссёр появляется в инфо-панели
// после закрепления выбора (ovrDirector персистится в chooseCandidateLocked).
eff := applyOverrides(rawPlan, mergeSourceOverrides(base, sourcePins(provider, providerID, title, year, "")))
opt := SourceOption{
Kind: kind,
CandidateID: candID,
@@ -1035,13 +1070,14 @@ func (w *Worker) sourceOption(ctx context.Context, downloadID string, kind Sourc
return opt
}
// mergeSourceOverrides накладывает пины источника (provider/id/title/year) на
// неисточниковые overrides (media_type, ignored_files, force_review, ...).
// mergeSourceOverrides накладывает пины источника (provider/id/title/year/
// director) на неисточниковые overrides (media_type, ignored_files, force_review,
// ...).
func mergeSourceOverrides(base, pins map[string]string) map[string]string {
m := make(map[string]string, len(base)+len(pins))
for k, v := range base {
switch k {
case ovrProvider, ovrProviderID, ovrTitle, ovrYear:
case ovrProvider, ovrProviderID, ovrTitle, ovrYear, ovrDirector:
continue
default:
m[k] = v
@@ -1072,6 +1108,37 @@ func (w *Worker) effectivePlan(ctx context.Context, id string) (plan recognize.P
return applyOverrides(plan, overrides), prov, pid, nil
}
// effectiveDisplayName собирает полный ярлык display_name из эффективных полей:
// плана (override → распознавание+матч, уже свёрнуто effectivePlan) с fallback на
// сохранённый контекст (parsed_context) для полей, которых план не дал (например
// режиссёр из контекста при распознавании без матча). Сводка сезонов —
// recognize.SeasonSummary(plan) с fallback на контекстный скаляр. Единый формат с
// шагом добавления (общий naming.Label). parsed_context недоверен: битый JSON →
// пустой слой (best-effort), очистка полей — на рендере.
func effectiveDisplayName(d store.Download, plan recognize.Plan) string {
var ctxf naming.Fields
if s := strings.TrimSpace(d.ParsedContext); s != "" {
_ = json.Unmarshal([]byte(s), &ctxf)
}
title := plan.Title
if title == "" {
title = ctxf.Title
}
director := plan.Director
if director == "" {
director = ctxf.Director
}
year := plan.Year
if year == 0 {
year = ctxf.Year
}
season := recognize.SeasonSummary(plan)
if season == "" && plan.Type == recognize.MediaSeries {
season = ctxf.SeasonLabel()
}
return naming.Label(title, director, year, season)
}
// RefreshDisplayName — внешняя точка входа обновления отображаемого имени
// (ручная кнопка): берёт w.mu и делегирует refreshDisplayNameLocked.
func (w *Worker) RefreshDisplayName(ctx context.Context, id string) (err error) {
@@ -1107,9 +1174,9 @@ func (w *Worker) refreshDisplayNameLocked(ctx context.Context, id string) error
if err != nil {
return fmt.Errorf("refresh display name: %w", err)
}
name := naming.FormatTitleYear(plan.Title, plan.Year)
name := effectiveDisplayName(*d, plan)
if name == "" {
return nil // пустое распознанное название — no-op
return nil // пустое эффективное название — no-op
}
if err := w.store.SetDisplayName(cctx, id, name); err != nil {
return fmt.Errorf("refresh display name: %w", err)
@@ -1201,6 +1268,9 @@ func applyOverrides(plan recognize.Plan, overrides map[string]string) recognize.
plan.Year = year
}
}
if d := overrides[ovrDirector]; d != "" {
plan.Director = d
}
ignored := parseIgnored(overrides[ovrIgnoredFiles])
if len(ignored) > 0 {
for i := range plan.Files {