// Package naming выводит человекочитаемое отображаемое имя торрента из // текстового контекста загрузки. Имя нужно лишь как ярлык в списке // qBittorrent (вместо безликого dn вроде rutracker-topic-6514485) и не // влияет на пути на диске или распознавание. // // Стратегия: сначала пробуем LLM (структурированный вывод названия/года/ // режиссёра/сезона), при неудаче — алгоритмический фолбек без сети. Любой // сбой деградирует к пустой строке: приём загрузки никогда не падает из-за // вывода имени. package naming import ( "context" "log/slog" "strconv" "strings" "unicode/utf8" "git.vakhrushev.me/av/jellybit/internal/llm" ) // maxNameLen — ограничение длины отображаемого имени (символов/рун). const maxNameLen = 200 // mediaType — вид контента в извлечённой структуре. type mediaType string const ( typeMovie mediaType = "movie" typeSeries mediaType = "series" ) // extracted — структура имени, извлечённая из контекста (схема ответа LLM). // Year и Director опциональны (пустое значение → в ярлык не попадают). type extracted struct { Type mediaType `json:"type"` 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"` } // 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} } // DeriveName выводит отображаемое имя из контекста. hint — подсказка из // magnet (dn), используется только фолбеком, если контекст пуст. Возвращает // "" если имя получить не удалось (тогда вызывающий не задаёт rename). func (n *Namer) DeriveName(ctx context.Context, contextText, hint string) string { if n.provider != nil { if ex, ok := n.extractViaLLM(ctx, contextText, hint); ok { if name := render(ex); name != "" { return name } } } return fallbackName(contextText, hint) } // render собирает из структуры короткий ярлык: // - movie: "Title (Director, Year)" — режиссёр и год опциональны; // - series: то же + ". Сезон N", если сезон задан. // // Имя очищается от управляющих символов/переводов строк и обрезается по // длине. Пустой Title → пустая строка. func render(ex extracted) string { title := sanitize(ex.Title) if title == "" { return "" } var paren []string if d := sanitize(ex.Director); d != "" { paren = append(paren, d) } if ex.Year > 0 { paren = append(paren, strconv.Itoa(ex.Year)) } name := title if len(paren) > 0 { name += " (" + strings.Join(paren, ", ") + ")" } if ex.Type == typeSeries && ex.Season != nil && *ex.Season > 0 { name += ". Сезон " + strconv.Itoa(*ex.Season) } return truncate(name, maxNameLen) } // 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 }