Files
jellybit/internal/recognize/metadata.go
T
avandClaude Opus 4.8 02d4ecc2aa 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>
2026-07-11 11:50:08 +03:00

269 lines
12 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package recognize
import (
"context"
"log/slog"
"strings"
"unicode"
"git.vakhrushev.me/av/jellybit/internal/logctx"
"git.vakhrushev.me/av/jellybit/internal/metadata"
)
// maxCandidates — потолок на число сохраняемых кандидатов для ручного выбора.
const maxCandidates = 8
// matchMetadata сверяет план с включёнными базами. Возвращает (а) единичный
// сильный матч — ровно один кандидат с совпадением названия и года (для него
// тянем число серий и используем для авто), либо nil; (б) список кандидатов
// из всех выполненных заходов (топ-N, дедуп) — чтобы человек мог выбрать в
// review, когда сильного матча нет. Ошибки провайдера не валят распознавание.
//
// Поиск идёт по нескольким названиям в порядке убывания силы ключа
// (original_title → title → provider_hint, см. searchKeys): базы индексированы
// прежде всего по оригинальным названиям. Останавливаемся, как только очередной
// ключ дал единичный сильный матч (ранний стоп — дешевле по обращениям к базе).
//
// Проходов сверки два: pass 1 — с годом плана (дешёвое сужение при верном годе),
// pass 2 — fallback без года, только если год известен и pass 1 не подтвердил
// матч. Exact-year фильтр запроса строже гейта strongMatches (год ±1): безгодовой
// проход восстанавливает off-by-one авто-матчи (запись, которую точный фильтр
// отсёк, а гейт принял бы) и пополняет кандидатов для review при бо́льших ошибках
// года. Гейт при этом не меняется (год ±1 по plan.Year), инвариант авто-матча не
// двигается. Кандидаты копятся через оба прохода (дедуп по provider:id, потолок).
func (r *Recognizer) matchMetadata(ctx context.Context, plan Plan) (*Match, []metadata.Candidate) {
if len(r.providers) == 0 {
return nil, nil
}
mt := metadata.Movie
if plan.Type == MediaSeries {
mt = metadata.Series
}
matchTitles := normSet(plan.Title, plan.OriginalTitle)
keys := searchKeys(plan)
// Год запроса по проходам: сначала год плана, затем 0 (без года) как fallback.
// При неизвестном годе второй проход был бы идентичен первому — не делаем.
queryYears := []int{plan.Year}
if plan.Year > 0 {
queryYears = append(queryYears, 0)
}
var match *Match
var candidates []metadata.Candidate
seen := map[string]bool{}
for _, qYear := range queryYears {
// Безгодовой fallback-проход (есть только при известном годе плана).
// В нём требуем известный год кандидата: off-by-one даёт авто-матч, а
// запись с неизвестным годом — только кандидат в review (год подтвердить
// нечем, авто было бы недо-подтверждённым). В pass 1 leniency yearMatches
// к unknown-году сохраняется как прежде.
fallback := qYear == 0 && plan.Year > 0
for _, key := range keys {
for _, p := range r.providers {
cands, err := p.Search(ctx, metadata.Query{Type: mt, Title: key, Year: qYear})
if err != nil {
// Сам вызов провайдера залогирован клиентом (ext.*-ERROR); здесь —
// доменное решение «пропускаем провайдера, пробуем следующий».
logctx.FromOr(ctx, r.log).Debug("metadata provider skipped", "provider", p.Name())
continue
}
// Копим кандидатов для выбора (дедуп по провайдеру+id, потолок).
for _, c := range cands {
ck := c.Provider + ":" + c.ID
if seen[ck] || len(candidates) >= maxCandidates {
continue
}
seen[ck] = true
candidates = append(candidates, c)
}
// Единичный сильный матч ищем у первого подходящего провайдера.
// Гейт по plan.Year (не qYear): безгодовой проход расширяет только
// выдачу запроса, но требует год кандидата ±1 (и известный — в
// fallback), поэтому условие подтверждения не ослабляется.
if match != nil {
continue
}
strong := strongMatches(cands, plan.Year, matchTitles, fallback)
if len(strong) != 1 {
continue
}
match = r.buildMatch(ctx, p, strong[0], mt)
}
if match != nil {
break
}
}
if match != nil {
break // pass 1 подтвердил матч — безгодовой проход не нужен
}
}
return match, candidates
}
// searchKeys строит ключи поиска по базе в порядке убывания силы:
// original_title → title → provider_hint. Пустые и нормализованно совпадающие
// с уже добавленным ключом пропускаем, чтобы не обращаться к базе дважды с тем
// же запросом (частый случай — российский фильм, где original_title дублирует
// title).
func searchKeys(plan Plan) []string {
var keys []string
seen := map[string]bool{}
for _, t := range []string{plan.OriginalTitle, plan.Title, plan.ProviderHint} {
if strings.TrimSpace(t) == "" {
continue
}
n := normalize(t)
if n == "" || seen[n] {
continue
}
seen[n] = true
keys = append(keys, t)
}
return keys
}
// buildMatch тянет число серий (по нативному id) и собирает Match с
// тег-предпочтительным провенансом.
func (r *Recognizer) buildMatch(ctx context.Context, p metadata.Provider, c metadata.Candidate, mt metadata.MediaType) *Match {
var counts map[int]int
if mt == metadata.Series {
if got, err := p.SeasonEpisodeCounts(ctx, c.ID); err == nil {
counts = got
} else {
logctx.FromOr(ctx, r.log).Debug("metadata episode counts skipped", "provider", p.Name(), "id", c.ID)
}
}
prov, pid := CandidateTag(c)
return &Match{
Provider: prov,
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), если есть, иначе сам провайдер
// поиска. Используется и в матче, и при сохранении кандидатов.
func CandidateTag(c metadata.Candidate) (provider, id string) {
if c.TagProvider != "" {
return c.TagProvider, c.TagID
}
return c.Provider, c.ID
}
// strongMatches оставляет кандидатов, чьё название совпадает с одним из
// названий плана (после нормализации) и год бьётся (±1 год), дедуплицируя
// по id. requireKnownYear (безгодовой fallback-проход) дополнительно отсекает
// кандидатов с неизвестным годом: авто-матч там требует подтверждённого года.
func strongMatches(cands []metadata.Candidate, year int, titles map[string]bool, requireKnownYear bool) []metadata.Candidate {
seen := map[string]bool{}
var out []metadata.Candidate
for _, c := range cands {
if requireKnownYear && c.Year == 0 {
continue
}
if !yearMatches(year, c.Year) {
continue
}
if !titles[normalize(c.Title)] && !titles[normalize(c.OriginalTitle)] {
continue
}
if seen[c.ID] {
continue
}
seen[c.ID] = true
out = append(out, c)
}
return out
}
// yearMatches: год известен у обоих и расходится не больше чем на 1 (разные
// базы по-разному датируют релиз), либо где-то год неизвестен.
func yearMatches(a, b int) bool {
if a == 0 || b == 0 {
return true
}
d := a - b
if d < 0 {
d = -d
}
return d <= 1
}
// normSet — множество нормализованных непустых названий.
func normSet(titles ...string) map[string]bool {
out := map[string]bool{}
for _, t := range titles {
if n := normalize(t); n != "" {
out[n] = true
}
}
return out
}
// normalize приводит название к сравнимому виду: нижний регистр, только
// буквы/цифры (юникод), одиночные пробелы. Букву ё сводим к е (частое
// расхождение написания: «Тёмный» vs «Темный»). Перед этим сворачиваем
// кирилло-латинские homoglyph-двойники (foldHomoglyphs) — defense-in-depth на
// случай двойников со стороны кандидата базы: гейт сильного матча должен быть
// устойчив к ним независимо от санитайзинга плана.
func normalize(s string) string {
var b strings.Builder
prevSpace := false
for _, r := range strings.ToLower(foldHomoglyphs(s)) {
if r == 'ё' {
r = 'е'
}
switch {
case unicode.IsLetter(r) || unicode.IsDigit(r):
b.WriteRune(r)
prevSpace = false
case !prevSpace:
b.WriteByte(' ')
prevSpace = true
}
}
return strings.TrimSpace(b.String())
}