140 lines
5.0 KiB
Go
140 lines
5.0 KiB
Go
// 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
|
|
}
|