// 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 }