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
+35
View File
@@ -2,6 +2,7 @@ package recognize
import (
"context"
"log/slog"
"strings"
"unicode"
@@ -143,10 +144,44 @@ func (r *Recognizer) buildMatch(ctx context.Context, p metadata.Provider, c meta
ProviderID: pid,
Title: c.Title,
Year: c.Year,
Director: directorOf(ctx, r.log, p, mt, c.ID),
SeasonEpisodeCounts: counts,
}
}
// directorOf тянет режиссёра из провайдера по нативному id, если он умеет
// (DirectorProvider). Best-effort: ошибка/неподдержка → пусто, распознавание не
// валится. Режиссёр — недоверенное косметическое поле.
func directorOf(ctx context.Context, log *slog.Logger, p metadata.Provider, mt metadata.MediaType, id string) string {
dp, ok := p.(metadata.DirectorProvider)
if !ok {
return ""
}
d, err := dp.Director(ctx, mt, id)
if err != nil {
logctx.FromOr(ctx, log).Debug("metadata director skipped", "provider", p.Name(), "id", id)
return ""
}
return d
}
// Director тянет режиссёра выбранного источника по (provider, id) — точка входа
// для ручного выбора кандидата в ревью (см. worker). Ищет провайдера поиска по
// имени; best-effort — нет провайдера/не умеет/ошибка → пусто. mt — тип из плана.
func (r *Recognizer) Director(ctx context.Context, mt MediaType, provider, providerID string) string {
metaType := metadata.Movie
if mt == MediaSeries {
metaType = metadata.Series
}
for _, p := range r.providers {
if p.Name() != provider {
continue
}
return directorOf(ctx, r.log, p, metaType, providerID)
}
return ""
}
// CandidateTag — провайдер и id для тега папки Jellyfin: внешний (из
// TagProvider/TagID, напр. TVMaze → tvdb/imdb), если есть, иначе сам провайдер
// поиска. Используется и в матче, и при сохранении кандидатов.
+17 -8
View File
@@ -80,14 +80,19 @@ type PlanFile struct {
// Plan — структурированный результат распознавания (схема ответа LLM).
type Plan struct {
Type MediaType `json:"type"`
Title string `json:"title"`
OriginalTitle string `json:"original_title,omitempty"`
Year int `json:"year,omitempty"`
ProviderHint string `json:"provider_hint,omitempty"`
Files []PlanFile `json:"files"`
Confidence float64 `json:"confidence"`
Notes string `json:"notes,omitempty"`
Type MediaType `json:"type"`
Title string `json:"title"`
OriginalTitle string `json:"original_title,omitempty"`
Year int `json:"year,omitempty"`
// Director — режиссёр (опц.). LLM его НЕ заполняет и не валидируется по нему;
// его вкладывает подтверждённый матч метабазы (buildMatch) или закреплённый в
// ревью источник (override). Недоверенное косметическое поле для вывода
// отображаемого имени; в plan-санитайзинг не входит (чистится на рендере).
Director string `json:"director,omitempty"`
ProviderHint string `json:"provider_hint,omitempty"`
Files []PlanFile `json:"files"`
Confidence float64 `json:"confidence"`
Notes string `json:"notes,omitempty"`
}
// PreParse — черновой разбор имени релиза (go-ptn).
@@ -111,6 +116,7 @@ type Match struct {
ProviderID string // официальный id
Title string // каноническое название
Year int // каноничный год
Director string // режиссёр (best-effort из credits; пусто — нет)
SeasonEpisodeCounts map[int]int // число серий по сезонам (для сериала)
}
@@ -245,6 +251,9 @@ func (r *Recognizer) Recognize(ctx context.Context, in Input) (Result, error) {
if match.Year != 0 {
plan.Year = match.Year
}
if match.Director != "" {
plan.Director = match.Director
}
}
dec := decide(plan, pre, match, len(r.providers) > 0, r.threshold)
+72
View File
@@ -0,0 +1,72 @@
package recognize
import (
"sort"
"strconv"
"strings"
)
// SeasonSummary собирает верхнеуровневую сводку сезонов сериальной раздачи по
// эпизодным файлам плана. Сезон задан на файле (мультисезонные паки), поэтому
// сводим множество различных сезонов; Season nil/0 — спецвыпуски. Примеры:
// «Сезон 2», «Сезоны 1–3», «Сезоны 1, 3–4», «Спецвыпуски», «Сезоны 1–2, спецвыпуски».
// Пустая строка — эпизодных файлов нет (нечего сводить). Общий хелпер для веб-UI
// (сводка сезонов на карточке/ревью) и для ярлыка display_name (naming).
func SeasonSummary(plan Plan) string {
seen := map[int]bool{}
specials := false
for _, f := range plan.Files {
if f.Role != RoleEpisode {
continue
}
n := 0
if f.Season != nil {
n = *f.Season
}
if n <= 0 {
specials = true
continue
}
seen[n] = true
}
nums := make([]int, 0, len(seen))
for n := range seen {
nums = append(nums, n)
}
sort.Ints(nums)
var parts []string
switch {
case len(nums) == 1:
parts = append(parts, "Сезон "+strconv.Itoa(nums[0]))
case len(nums) > 1:
parts = append(parts, "Сезоны "+seasonRanges(nums))
}
if specials {
if len(parts) == 0 {
parts = append(parts, "Спецвыпуски")
} else {
parts = append(parts, "спецвыпуски")
}
}
return strings.Join(parts, ", ")
}
// seasonRanges схлопывает возрастающие номера сезонов в диапазоны:
// [1,2,3] → «13», [1,3,4] → «1, 34».
func seasonRanges(nums []int) string {
var out []string
for i := 0; i < len(nums); {
j := i
for j+1 < len(nums) && nums[j+1] == nums[j]+1 {
j++
}
if j == i {
out = append(out, strconv.Itoa(nums[i]))
} else {
out = append(out, strconv.Itoa(nums[i])+""+strconv.Itoa(nums[j]))
}
i = j + 1
}
return strings.Join(out, ", ")
}
+49
View File
@@ -0,0 +1,49 @@
package recognize
import "testing"
func TestSeasonSummary(t *testing.T) {
// ep — эпизодный файл с заданным (или nil) сезоном.
ep := func(season *int) PlanFile {
return PlanFile{Role: RoleEpisode, Season: season}
}
n := func(v int) *int { return &v }
cases := []struct {
name string
files []PlanFile
want string
}{
{"пусто", nil, ""},
{"один сезон", []PlanFile{ep(n(2)), ep(n(2))}, "Сезон 2"},
{"диапазон", []PlanFile{ep(n(1)), ep(n(2)), ep(n(3))}, "Сезоны 13"},
{"разрыв", []PlanFile{ep(n(1)), ep(n(3)), ep(n(4))}, "Сезоны 1, 34"},
{"несортированный вход", []PlanFile{ep(n(3)), ep(n(1)), ep(n(2))}, "Сезоны 13"},
{"только спецвыпуски (nil)", []PlanFile{ep(nil)}, "Спецвыпуски"},
{"только спецвыпуски (0)", []PlanFile{ep(n(0))}, "Спецвыпуски"},
{"сезоны и спецвыпуски", []PlanFile{ep(n(1)), ep(n(2)), ep(nil)}, "Сезоны 1–2, спецвыпуски"},
{"один сезон и спецвыпуски", []PlanFile{ep(n(1)), ep(nil)}, "Сезон 1, спецвыпуски"},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
got := SeasonSummary(Plan{Files: c.files})
if got != c.want {
t.Errorf("SeasonSummary = %q, want %q", got, c.want)
}
})
}
}
// Не-эпизодные файлы (main/subtitle/…) не влияют на сводку сезонов.
func TestSeasonSummary_IgnoresNonEpisodes(t *testing.T) {
s := 2
plan := Plan{Files: []PlanFile{
{Role: RoleEpisode, Season: &s},
{Role: RoleMain},
{Role: RoleSubtitle},
{Role: RoleIgnore},
}}
if got := SeasonSummary(plan); got != "Сезон 2" {
t.Errorf("SeasonSummary = %q, want «Сезон 2»", got)
}
}