На /download/{id} поле «Режиссёр» было захардкожено прочерком, хотя экран
ревью режиссёра уже выводит: слоистое разрешение полей ярлыка было заперто в
неэкспортируемом worker.effectiveDisplayName. Из-за этого билдеры вью видели
только слой распознавания+матч (rd.Plan.Director) без слоя контекста — то же
на экране ревью.
Вынес разрешение в экспортируемую naming.EffectiveFields(parsedContext, plan)
LabelFields с методом Label(): выбор слоя по сырым значениям (как прежде),
выбранные скаляры возвращаются очищенными (sanitize идемпотентен, display_name
побайтно тот же). effectiveDisplayName стал тонкой обёрткой; страница загрузки
и экран ревью берут режиссёра из той же функции — согласованно с заголовком.
OpenSpec: web-ui (ADDED «Режиссёр в блоке распознавания страницы загрузки»),
review (MODIFIED «Инфо и предпросмотр выбранного источника» — слоистое
разрешение с фолбэком на контекст). Change заархивирован. Беклог: закрыта
rezhisser-v-kartochke-zagruzki.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
233 lines
11 KiB
Go
233 lines
11 KiB
Go
// Package naming выводит человекочитаемое отображаемое имя торрента из
|
||
// текстового контекста загрузки. Имя нужно лишь как ярлык в списке
|
||
// qBittorrent (вместо безликого dn вроде rutracker-topic-6514485) и не
|
||
// влияет на пути на диске или распознавание.
|
||
//
|
||
// Стратегия: сначала пробуем LLM (структурированный вывод названия/года/
|
||
// режиссёра/сезона), при неудаче — алгоритмический фолбек без сети. Любой
|
||
// сбой деградирует к пустой строке: приём загрузки никогда не падает из-за
|
||
// вывода имени.
|
||
//
|
||
// Извлечённая структура ([Fields]) — базовый (наименее доверенный) слой
|
||
// источника полей имени: её сохраняют у загрузки (parsed_context) и позже
|
||
// переиспользуют при обновлении display_name (поле, которого нет в
|
||
// распознавании/матче, — например режиссёр из контекста — не теряется).
|
||
package naming
|
||
|
||
import (
|
||
"context"
|
||
"encoding/json"
|
||
"log/slog"
|
||
"strconv"
|
||
"strings"
|
||
"unicode/utf8"
|
||
|
||
"git.vakhrushev.me/av/jellybit/internal/llm"
|
||
"git.vakhrushev.me/av/jellybit/internal/recognize"
|
||
)
|
||
|
||
// 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"`
|
||
}
|
||
|
||
// LabelFields — эффективные скалярные поля отображаемого ярлыка после слоистого
|
||
// разрешения (план → контекст). Значения уже очищены (sanitize): управляющие
|
||
// символы вырезаны, пробелы схлопнуты — те же, что попадут внутрь Label, поэтому
|
||
// присутствие/отсутствие поля определяется одинаково при прямом выводе (шаблон)
|
||
// и внутри Label. Season — готовая строка-сводка сезонов (для фильма пусто).
|
||
type LabelFields struct {
|
||
Title string
|
||
Director string
|
||
Year int
|
||
Season string
|
||
}
|
||
|
||
// Label собирает полный ярлык display_name из эффективных полей. sanitize внутри
|
||
// идемпотентен, поэтому на уже очищенных полях результат тот же.
|
||
func (f LabelFields) Label() string {
|
||
return Label(f.Title, f.Director, f.Year, f.Season)
|
||
}
|
||
|
||
// EffectiveFields разрешает поля отображаемого ярлыка слоями: план (override →
|
||
// распознавание+матч, уже свёрнут вызывающим) с фолбэком на извлечённый из
|
||
// контекста слой (parsedContext, схема Fields) для полей, которых план не дал
|
||
// (например режиссёр из контекста при распознавании без матча). Сводка сезонов —
|
||
// recognize.SeasonSummary(plan) с фолбэком на контекстный скаляр. parsedContext
|
||
// недоверен: битый JSON → пустой слой (best-effort). Выбор слоя идёт по сырым
|
||
// значениям (как прежде), выбранные скаляры возвращаются очищенными (sanitize),
|
||
// чтобы прямой вывод поля во вью совпадал с тем, что даёт Label в шапке.
|
||
func EffectiveFields(parsedContext string, plan recognize.Plan) LabelFields {
|
||
var ctxf Fields
|
||
if s := strings.TrimSpace(parsedContext); s != "" {
|
||
_ = json.Unmarshal([]byte(s), &ctxf)
|
||
}
|
||
title := plan.Title
|
||
if title == "" {
|
||
title = ctxf.Title
|
||
}
|
||
director := plan.Director
|
||
if director == "" {
|
||
director = ctxf.Director
|
||
}
|
||
year := plan.Year
|
||
if year == 0 {
|
||
year = ctxf.Year
|
||
}
|
||
season := recognize.SeasonSummary(plan)
|
||
if season == "" && plan.Type == recognize.MediaSeries {
|
||
season = ctxf.SeasonLabel()
|
||
}
|
||
return LabelFields{
|
||
Title: sanitize(title),
|
||
Director: sanitize(director),
|
||
Year: year,
|
||
Season: sanitize(season),
|
||
}
|
||
}
|
||
|
||
// 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
|
||
}
|