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
+24 -3
View File
@@ -13,6 +13,7 @@ package worker
import (
"context"
"encoding/json"
"errors"
"fmt"
"log/slog"
@@ -25,6 +26,7 @@ import (
"git.vakhrushev.me/av/jellybit/internal/layout"
"git.vakhrushev.me/av/jellybit/internal/logctx"
"git.vakhrushev.me/av/jellybit/internal/magnet"
"git.vakhrushev.me/av/jellybit/internal/naming"
"git.vakhrushev.me/av/jellybit/internal/qbt"
"git.vakhrushev.me/av/jellybit/internal/recognize"
"git.vakhrushev.me/av/jellybit/internal/store"
@@ -52,6 +54,7 @@ type Store interface {
// SetDisplayName обновляет отображаемое имя постфактум (перелив канонического
// имени после распознавания) — без гарда состояния, FSM не двигает.
SetDisplayName(ctx context.Context, id, name string) error
SetParsedContext(ctx context.Context, id, jsonStr string) error
SetSourceMissCount(ctx context.Context, id string, n int) error
SetSourceAddedAt(ctx context.Context, id string, t time.Time) error
// SetRetriedAt проставляет время ручного retry — сброс базиса отсчёта
@@ -104,12 +107,18 @@ type QBittorrent interface {
// Recognizer — распознаватель (recognize.Recognizer).
type Recognizer interface {
Recognize(ctx context.Context, in recognize.Input) (recognize.Result, error)
// Director тянет режиссёра выбранного источника по (provider, id) из
// метабазы (credits) — для ручного выбора кандидата в ревью. Best-effort:
// нет провайдера/не умеет/ошибка → пустая строка.
Director(ctx context.Context, mt recognize.MediaType, provider, providerID string) string
}
// Namer выводит человекочитаемое отображаемое имя из контекста (naming.Namer).
// Пустой результат → rename в qBittorrent не задаём. nil → имя не выводим.
// Derive возвращает имя (пустое → rename в qBittorrent не задаём) и извлечённую
// структуру (её JSON сохраняется как parsed_context — базовый слой полей имени).
// nil-namer → имя не выводим.
type Namer interface {
DeriveName(ctx context.Context, contextText, hint string) string
Derive(ctx context.Context, contextText, hint string) (name string, fields naming.Fields, ok bool)
}
// Layouter — раскладчик хардлинками (layout.Layouter).
@@ -452,7 +461,19 @@ func (w *Worker) processCatched(ctx context.Context) {
}
var rename string
if w.namer != nil {
rename = w.namer.DeriveName(cctx, cur.Context, hint)
name, fields, extracted := w.namer.Derive(cctx, cur.Context, hint)
rename = name
// Сохраняем извлечённую структуру как базовый слой полей имени
// (parsed_context) — режиссёр/год из контекста не теряются и
// переиспользуются при обновлении display_name. Best-effort: сбой не
// валит добавление (косметика). Пустая структура → нечего сохранять.
if extracted {
if blob, mErr := json.Marshal(fields); mErr == nil {
if sErr := w.store.SetParsedContext(cctx, cur.ID, string(blob)); sErr != nil {
logctx.From(cctx).Warn("catched save parsed context failed", "error", sErr)
}
}
}
}
addReq.Rename = rename
addErr := w.qbt.Add(cctx, addReq)