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:
@@ -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), если есть, иначе сам провайдер
|
||||
// поиска. Используется и в матче, и при сохранении кандидатов.
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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] → «1–3», [1,3,4] → «1, 3–4».
|
||||
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, ", ")
|
||||
}
|
||||
@@ -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))}, "Сезоны 1–3"},
|
||||
{"разрыв", []PlanFile{ep(n(1)), ep(n(3)), ep(n(4))}, "Сезоны 1, 3–4"},
|
||||
{"несортированный вход", []PlanFile{ep(n(3)), ep(n(1)), ep(n(2))}, "Сезоны 1–3"},
|
||||
{"только спецвыпуски (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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user