Files
jellybit/internal/naming/naming.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

176 lines
7.9 KiB
Go

// Package naming выводит человекочитаемое отображаемое имя торрента из
// текстового контекста загрузки. Имя нужно лишь как ярлык в списке
// qBittorrent (вместо безликого dn вроде rutracker-topic-6514485) и не
// влияет на пути на диске или распознавание.
//
// Стратегия: сначала пробуем LLM (структурированный вывод названия/года/
// режиссёра/сезона), при неудаче — алгоритмический фолбек без сети. Любой
// сбой деградирует к пустой строке: приём загрузки никогда не падает из-за
// вывода имени.
//
// Извлечённая структура ([Fields]) — базовый (наименее доверенный) слой
// источника полей имени: её сохраняют у загрузки (parsed_context) и позже
// переиспользуют при обновлении display_name (поле, которого нет в
// распознавании/матче, — например режиссёр из контекста — не теряется).
package naming
import (
"context"
"log/slog"
"strconv"
"strings"
"unicode/utf8"
"git.vakhrushev.me/av/jellybit/internal/llm"
)
// maxNameLen — ограничение длины отображаемого имени (символов/рун).
const maxNameLen = 200
const (
typeMovie = "movie"
typeSeries = "series"
)
// Fields — скалярные поля имени, извлечённые из одного источника (контекст).
// Это же схема ответа LLM и схема хранения parsed_context. Year/Director/Season
// опциональны (пустые в ярлык не попадают).
type Fields struct {
Type string `json:"type"` // "movie"|"series"
Title string `json:"title"`
OriginalTitle string `json:"original_title"`
Year int `json:"year"`
Director string `json:"director"`
Season *int `json:"season"`
IsRussian bool `json:"is_russian"`
}
// SeasonLabel — сводка сезона из контекстного скаляра: «Сезон N» для сериала с
// заданным номером, иначе пусто. Для контекста сезон скалярный (в отличие от
// плана распознавания, где он per-file и сводится recognize.SeasonSummary).
// Используется как fallback-слой сводки сезонов, когда план распознавания её не
// даёт.
func (f Fields) SeasonLabel() string {
if f.Type == typeSeries && f.Season != nil && *f.Season > 0 {
return "Сезон " + strconv.Itoa(*f.Season)
}
return ""
}
// Namer выводит отображаемое имя. provider может быть nil — тогда работает
// только алгоритмический фолбек.
type Namer struct {
provider llm.Provider
// attempts — число попыток получить валидный ответ LLM ([llm].max_retries).
// Здесь это ровно столько вызовов модели (в отличие от recognize, где
// max_retries — это число ПЕРЕразборов, т.е. max_retries+1 вызовов).
attempts int
log *slog.Logger
}
// New собирает Namer. provider nil → только фолбек. attempts < 1 → 1.
// logger nil → slog.Default().
func New(provider llm.Provider, attempts int, logger *slog.Logger) *Namer {
if attempts < 1 {
attempts = 1
}
if logger == nil {
logger = slog.Default()
}
return &Namer{provider: provider, attempts: attempts, log: logger}
}
// Derive выводит отображаемое имя и извлечённую структуру. hint — подсказка из
// magnet (dn), используется только фолбеком, если контекст пуст. Возвращает имя
// ("" если вывести не удалось — вызывающий не задаёт rename) и извлечённую
// структуру с ok=true, если LLM дал валидные поля (тогда их JSON сохраняют как
// parsed_context). Фолбек полей не даёт (ok=false): его выход — только строка.
func (n *Namer) Derive(ctx context.Context, contextText, hint string) (name string, fields Fields, ok bool) {
// Нет ни контекста, ни подсказки — выводить имя не из чего. LLM на пустом
// входе способен лишь галлюцинировать (наблюдалось «Unknown»), поэтому его не
// зовём: имя считается не выведенным, вызывающий добавит загрузку без rename.
if strings.TrimSpace(contextText) == "" && strings.TrimSpace(hint) == "" {
return "", Fields{}, false
}
if n.provider != nil {
if ex, extracted := n.extractViaLLM(ctx, contextText, hint); extracted {
if label := render(ex); label != "" {
return label, ex, true
}
}
}
return fallbackName(contextText, hint), Fields{}, false
}
// DeriveName — тонкая обёртка над Derive, когда структура не нужна (возвращает
// только имя).
func (n *Namer) DeriveName(ctx context.Context, contextText, hint string) string {
name, _, _ := n.Derive(ctx, contextText, hint)
return name
}
// Label собирает полный детерминированный ярлык display_name из эффективных
// полей: «Название (режиссёр, год)», для сериала — хвост «. <сводка сезонов>».
// Все части, кроме названия, опциональны и выпадают, если пусты. seasonSummary —
// уже готовая строка сводки (для плана — recognize.SeasonSummary; для контекста —
// «Сезон N»). Имя очищается от управляющих символов и обрезается по длине.
// Пустое название → пустая строка. Единый рендер для шага добавления и перелива
// имени по распознаванию, чтобы формат ярлыка совпадал на всех путях.
func Label(title, director string, year int, seasonSummary string) string {
title = sanitize(title)
if title == "" {
return ""
}
var paren []string
if d := sanitize(director); d != "" {
paren = append(paren, d)
}
if year > 0 {
paren = append(paren, strconv.Itoa(year))
}
name := title
if len(paren) > 0 {
name += " (" + strings.Join(paren, ", ") + ")"
}
if s := sanitize(seasonSummary); s != "" {
name += ". " + s
}
return truncate(name, maxNameLen)
}
// render собирает ярлык из извлечённой структуры (контекст) через общий Label.
func render(f Fields) string {
return Label(f.Title, f.Director, f.Year, f.SeasonLabel())
}
// sanitize убирает управляющие символы и переводы строк, схлопывает пробелы.
func sanitize(s string) string {
s = strings.Map(func(r rune) rune {
if r == '\n' || r == '\t' || r == '\r' {
return ' '
}
if r < 0x20 {
return -1
}
return r
}, s)
return strings.Join(strings.Fields(s), " ")
}
// truncate обрезает строку до n рун (без разрыва символа), отбрасывая хвост.
func truncate(s string, n int) string {
if utf8.RuneCountInString(s) <= n {
return s
}
count := 0
for i := range s {
if count == n {
return strings.TrimRight(s[:i], " ")
}
count++
}
return s
}