From d8ef7063f4542aa4d16103f1af580ee35c5275aa Mon Sep 17 00:00:00 2001 From: Anton Vakhrushev Date: Sun, 28 Jun 2026 12:20:10 +0300 Subject: [PATCH] =?UTF-8?q?=D0=94=D0=BE=D0=B1=D0=B0=D0=B2=D0=B8=D0=BB=20?= =?UTF-8?q?=D1=80=D0=B5=D0=B0=D0=BB=D0=B8=D0=B7=D0=B0=D1=86=D0=B8=D1=8E?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- cmd/jellybit/serve.go | 38 +++-- internal/ingest/ingest.go | 24 ++- internal/ingest/ingest_test.go | 51 ++++++- internal/naming/fallback.go | 77 ++++++++++ internal/naming/fallback_test.go | 62 ++++++++ internal/naming/naming.go | 139 ++++++++++++++++++ internal/naming/naming_test.go | 104 +++++++++++++ internal/naming/prompt.go | 103 +++++++++++++ internal/naming/render_test.go | 77 ++++++++++ internal/qbt/qbt.go | 4 + internal/qbt/qbt_test.go | 45 ++++++ .../changes/add-qbt-display-name/tasks.md | 46 +++--- 12 files changed, 731 insertions(+), 39 deletions(-) create mode 100644 internal/naming/fallback.go create mode 100644 internal/naming/fallback_test.go create mode 100644 internal/naming/naming.go create mode 100644 internal/naming/naming_test.go create mode 100644 internal/naming/prompt.go create mode 100644 internal/naming/render_test.go diff --git a/cmd/jellybit/serve.go b/cmd/jellybit/serve.go index 3aa6e8f..09c98ae 100644 --- a/cmd/jellybit/serve.go +++ b/cmd/jellybit/serve.go @@ -22,6 +22,7 @@ import ( "git.vakhrushev.me/av/jellybit/internal/llm" "git.vakhrushev.me/av/jellybit/internal/logging" "git.vakhrushev.me/av/jellybit/internal/metadata" + "git.vakhrushev.me/av/jellybit/internal/naming" "git.vakhrushev.me/av/jellybit/internal/qbt" "git.vakhrushev.me/av/jellybit/internal/recognize" "git.vakhrushev.me/av/jellybit/internal/store" @@ -62,7 +63,27 @@ func runServe(args []string) error { return err } - ingestor := ingest.New(st, qb, ingest.Config{ + // LLM-провайдер (опц.) — общий для вывода имени и распознавания. + var llmProvider llm.Provider + if cfg.LLM.Type != "" && cfg.LLM.BaseURL != "" { + llmProvider, err = llm.New(llm.Config{ + Type: cfg.LLM.Type, + BaseURL: cfg.LLM.BaseURL, + APIKey: cfg.LLM.APIKey, + Model: cfg.LLM.Model, + Proxy: cfg.LLM.Proxy, + Timeout: cfg.LLM.Timeout.Std(), + }, logger) + if err != nil { + return fmt.Errorf("llm provider: %w", err) + } + } + + // Вывод отображаемого имени торрента из контекста (best-effort). Без LLM + // работает только алгоритмический фолбек. + namer := naming.New(llmProvider, cfg.LLM.MaxRetries, logger) + + ingestor := ingest.New(st, qb, namer, ingest.Config{ Category: cfg.QBittorrent.Category, SavePath: cfg.QBittorrent.SavePath, }, logger) @@ -79,19 +100,8 @@ func runServe(args []string) error { // Ф2/Ф3: распознаватель и раскладчик. Если LLM не сконфигурирован, // сервис работает как в Ф1 (completed-задачи дальше не двигаются). var recognizer worker.Recognizer - if cfg.LLM.Type != "" && cfg.LLM.BaseURL != "" { - provider, perr := llm.New(llm.Config{ - Type: cfg.LLM.Type, - BaseURL: cfg.LLM.BaseURL, - APIKey: cfg.LLM.APIKey, - Model: cfg.LLM.Model, - Proxy: cfg.LLM.Proxy, - Timeout: cfg.LLM.Timeout.Std(), - }, logger) - if perr != nil { - return fmt.Errorf("llm provider: %w", perr) - } - recognizer = recognize.New(provider, providers, recognize.Config{ + if llmProvider != nil { + recognizer = recognize.New(llmProvider, providers, recognize.Config{ MaxRetries: cfg.LLM.MaxRetries, AutoThreshold: cfg.Recognition.AutoConfidenceThreshold, }, logger) diff --git a/internal/ingest/ingest.go b/internal/ingest/ingest.go index 068d986..6ab3e4f 100644 --- a/internal/ingest/ingest.go +++ b/internal/ingest/ingest.go @@ -26,6 +26,12 @@ type QBittorrent interface { Add(ctx context.Context, ar qbt.AddRequest) error } +// Namer выводит человекочитаемое отображаемое имя торрента из контекста. +// Пустой результат → имя в qBittorrent не задаём. nil → шаг пропускается. +type Namer interface { + DeriveName(ctx context.Context, contextText, hint string) string +} + // Config — параметры добавления в qBittorrent. type Config struct { Category string @@ -36,13 +42,15 @@ type Config struct { type Service struct { store Store qbt QBittorrent + namer Namer cfg Config log *slog.Logger } -// New собирает сервис приёма. -func New(st Store, qb QBittorrent, cfg Config, log *slog.Logger) *Service { - return &Service{store: st, qbt: qb, cfg: cfg, log: log} +// New собирает сервис приёма. namer опционален (nil → отображаемое имя не +// выводится; qBittorrent оставит своё). +func New(st Store, qb QBittorrent, namer Namer, cfg Config, log *slog.Logger) *Service { + return &Service{store: st, qbt: qb, namer: namer, cfg: cfg, log: log} } // Request — входной запрос приёма. @@ -82,6 +90,15 @@ func (s *Service) Ingest(ctx context.Context, req Request) (Result, error) { }, nil } + // Отображаемое имя для списка qBit — best-effort: не валит приём. + // Выводится синхронно (param rename действует только при добавлении) и + // ДО CreateDownload, чтобы возможный медленный вызов LLM не расширял окно + // «строка в БД есть, в qBittorrent ещё нет». Имя от строки БД не зависит. + var rename string + if s.namer != nil { + rename = s.namer.DeriveName(ctx, req.Context, info.DisplayName) + } + d := &store.Download{ SourceType: store.SourceMagnet, SourceRef: source, @@ -99,6 +116,7 @@ func (s *Service) Ingest(ctx context.Context, req Request) (Result, error) { URLs: []string{source}, Category: s.cfg.Category, SavePath: s.cfg.SavePath, + Rename: rename, }) if addErr != nil { s.log.Warn("ingest: qbittorrent add failed, marking download failed", diff --git a/internal/ingest/ingest_test.go b/internal/ingest/ingest_test.go index ddec56f..1ca40e8 100644 --- a/internal/ingest/ingest_test.go +++ b/internal/ingest/ingest_test.go @@ -58,8 +58,27 @@ func (f *fakeQbt) Add(_ context.Context, ar qbt.AddRequest) error { return nil } +// fakeNamer возвращает заранее заданное имя; фиксирует переданные аргументы. +type fakeNamer struct { + name string + gotContext string + gotHint string + called bool +} + +func (f *fakeNamer) DeriveName(_ context.Context, contextText, hint string) string { + f.called = true + f.gotContext = contextText + f.gotHint = hint + return f.name +} + func newService(st Store, qb QBittorrent) *Service { - return New(st, qb, Config{Category: "jellybit", SavePath: "/srv/media/downloads"}, + return newServiceWithNamer(st, qb, nil) +} + +func newServiceWithNamer(st Store, qb QBittorrent, nm Namer) *Service { + return New(st, qb, nm, Config{Category: "jellybit", SavePath: "/srv/media/downloads"}, slog.New(slog.NewTextHandler(io.Discard, nil))) } @@ -94,6 +113,36 @@ func TestIngestHappyPath(t *testing.T) { } } +func TestIngestSetsDisplayName(t *testing.T) { + fs := &fakeStore{} + fq := &fakeQbt{} + nm := &fakeNamer{name: "Дюна: Часть вторая (2024)"} + _, err := newServiceWithNamer(fs, fq, nm).Ingest(context.Background(), + Request{Source: sampleMagnet, Context: "Дюна 2"}) + if err != nil { + t.Fatalf("Ingest: %v", err) + } + if !nm.called || nm.gotContext != "Дюна 2" || nm.gotHint != "Dune" { + t.Errorf("namer получил context=%q hint=%q (called=%v)", nm.gotContext, nm.gotHint, nm.called) + } + if len(fq.added) != 1 || fq.added[0].Rename != "Дюна: Часть вторая (2024)" { + t.Errorf("rename = %q, want %q", fq.added[0].Rename, "Дюна: Часть вторая (2024)") + } +} + +func TestIngestEmptyNameOmitsRename(t *testing.T) { + fs := &fakeStore{} + fq := &fakeQbt{} + nm := &fakeNamer{name: ""} // имя не получено + if _, err := newServiceWithNamer(fs, fq, nm).Ingest(context.Background(), + Request{Source: sampleMagnet}); err != nil { + t.Fatalf("Ingest: %v", err) + } + if len(fq.added) != 1 || fq.added[0].Rename != "" { + t.Errorf("rename = %q, want пусто", fq.added[0].Rename) + } +} + func TestIngestIdempotent(t *testing.T) { existing := &store.Download{ID: 7, State: store.StateDownloading} fs := &fakeStore{active: existing} diff --git a/internal/naming/fallback.go b/internal/naming/fallback.go new file mode 100644 index 0000000..55c5ff0 --- /dev/null +++ b/internal/naming/fallback.go @@ -0,0 +1,77 @@ +package naming + +import ( + "regexp" + "strings" +) + +// parenURL — markdown-хвост " (https://…)" в строках контекста. +var parenURL = regexp.MustCompile(`\s*\(https?://[^)]+\)`) + +// parenSpecs — открывающая скобка тех. характеристик «(2024, …»: круглая +// скобка, за которой (через необяз. пробелы) идёт цифра. +var parenSpecs = regexp.MustCompile(`\(\s*\d`) + +// fallbackName выводит имя без сети: берёт первую содержательную строку +// контекста, отсекает технические характеристики и обрезает по длине. Если +// контекст пуст/бесполезен — пробует hint (dn из magnet). Возвращает "", +// если ничего пригодного нет. +func fallbackName(contextText, hint string) string { + if line := firstMeaningfulLine(contextText); line != "" { + return truncate(stripTechSpecs(line), maxNameLen) + } + if h := sanitize(hint); h != "" { + return truncate(stripTechSpecs(h), maxNameLen) + } + return "" +} + +// firstMeaningfulLine возвращает первую строку контекста, не являющуюся +// ссылкой, командой бота или UI-мусором. Контекст из tgbot уже вычищен, но +// для HTTP/CLI вход может быть сырым — отсюда лёгкая фильтрация. +func firstMeaningfulLine(text string) string { + for line := range strings.SplitSeq(text, "\n") { + line = strings.TrimSpace(parenURL.ReplaceAllString(line, "")) + line = sanitize(line) + if line == "" || isNoiseLine(line) { + continue + } + return line + } + return "" +} + +// isNoiseLine отсекает строки-ссылки и команды бота (см. tgbot для исходной +// эвристики; здесь — минимум, нужный фолбеку для сырого ввода). Проверяем +// префиксы, а не вхождения: голый URL внутри осмысленной строки не должен +// выкидывать всю строку с названием. +func isNoiseLine(line string) bool { + switch { + case strings.HasPrefix(line, "/"): + return true // команда бота + case strings.HasPrefix(line, "magnet:"), + strings.HasPrefix(line, "http://"), + strings.HasPrefix(line, "https://"): + return true // строка-ссылка + case strings.Contains(line, ": /"): + return true // строка рейтинга/команд бота «👍: /g_… или 👎: /r_…» + default: + return false + } +} + +// stripTechSpecs отсекает хвост технических характеристик в скобках — +// квадратных ("… [2024, WEB-DL 2160p …]") или круглых с годом +// ("… (2024, WEB-DL …)") — и подчищает разделители на конце. Скобка с +// текстом (например режиссёр «(Дени Вильнёв)») не режется. +func stripTechSpecs(s string) string { + cut := len(s) + if i := strings.IndexByte(s, '['); i >= 0 && i < cut { + cut = i + } + if loc := parenSpecs.FindStringIndex(s); loc != nil && loc[0] < cut { + cut = loc[0] + } + s = strings.TrimRight(s[:cut], " -–—/.,") + return strings.TrimSpace(s) +} diff --git a/internal/naming/fallback_test.go b/internal/naming/fallback_test.go new file mode 100644 index 0000000..8517a9c --- /dev/null +++ b/internal/naming/fallback_test.go @@ -0,0 +1,62 @@ +package naming + +import "testing" + +func TestFallbackName(t *testing.T) { + tests := []struct { + name string + context string + hint string + want string + }{ + { + name: "release title with tech specs", + context: "Дюна: Часть вторая / Dune: Part Two [2024, фантастика, WEB-DL 2160p]\nDub + MVO", + want: "Дюна: Часть вторая / Dune: Part Two", + }, + { + name: "skips url and command lines", + context: "magnet:?xt=urn:btih:abc\nhttps://example.com/x\n/help\nБрат [1997]", + want: "Брат", + }, + { + name: "empty context falls back to magnet dn hint", + context: "", + hint: "rutracker-topic-6514485", + want: "rutracker-topic-6514485", + }, + { + name: "useless context and no hint yields empty", + context: "https://example.com/x\n/help", + hint: "", + want: "", + }, + { + name: "strips markdown url tail", + context: "Сёгун (https://hashurl.ru/abc)", + want: "Сёгун", + }, + { + name: "strips tech specs in round brackets with year", + context: "Дюна (2024, фантастика, WEB-DL 2160p)", + want: "Дюна", + }, + { + name: "keeps non-spec parenthesis (director)", + context: "Брат (Алексей Балабанов) [1997, криминал]", + want: "Брат (Алексей Балабанов)", + }, + { + name: "inline url does not drop the title line", + context: "Дюна 2024 подробнее http://tracker/x", + want: "Дюна 2024 подробнее http://tracker/x", + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + if got := fallbackName(tc.context, tc.hint); got != tc.want { + t.Errorf("fallbackName() = %q, want %q", got, tc.want) + } + }) + } +} diff --git a/internal/naming/naming.go b/internal/naming/naming.go new file mode 100644 index 0000000..5dacc3f --- /dev/null +++ b/internal/naming/naming.go @@ -0,0 +1,139 @@ +// 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 +} diff --git a/internal/naming/naming_test.go b/internal/naming/naming_test.go new file mode 100644 index 0000000..17261e4 --- /dev/null +++ b/internal/naming/naming_test.go @@ -0,0 +1,104 @@ +package naming + +import ( + "context" + "errors" + "io" + "log/slog" + "testing" + + "git.vakhrushev.me/av/jellybit/internal/llm" +) + +func testLogger() *slog.Logger { + return slog.New(slog.NewTextHandler(io.Discard, nil)) +} + +// fakeProvider отдаёт заранее заданные ответы по очереди; считает вызовы. +type fakeProvider struct { + responses []string + err error + calls int +} + +func (f *fakeProvider) Complete(_ context.Context, _ llm.Request) (llm.Response, error) { + f.calls++ + if f.err != nil { + return llm.Response{}, f.err + } + idx := f.calls - 1 + if idx >= len(f.responses) { + idx = len(f.responses) - 1 + } + return llm.Response{Content: f.responses[idx]}, nil +} + +const duneContext = "Дюна: Часть вторая / Dune: Part Two [2024, фантастика, WEB-DL 2160p]\nDub + MVO" + +func TestDeriveNameViaLLM(t *testing.T) { + fp := &fakeProvider{responses: []string{ + `{"type":"movie","title":"Дюна: Часть вторая","year":2024,"director":"Дени Вильнёв","is_russian":false}`, + }} + got := New(fp, 3, testLogger()).DeriveName(context.Background(), duneContext, "") + want := "Дюна: Часть вторая (Дени Вильнёв, 2024)" + if got != want { + t.Errorf("DeriveName() = %q, want %q", got, want) + } + if fp.calls != 1 { + t.Errorf("вызовов LLM = %d, want 1", fp.calls) + } +} + +func TestDeriveNameRussianTitle(t *testing.T) { + fp := &fakeProvider{responses: []string{ + `{"type":"movie","title":"Брат","original_title":"Brat","year":1997,"is_russian":true}`, + }} + got := New(fp, 3, testLogger()).DeriveName(context.Background(), "Брат / Brat [1997]", "") + if got != "Брат (1997)" { + t.Errorf("DeriveName() = %q, want %q", got, "Брат (1997)") + } +} + +func TestDeriveNameRetriesThenSucceeds(t *testing.T) { + fp := &fakeProvider{responses: []string{ + "не json вовсе", + `{"type":"movie","title":"Дюна: Часть вторая","year":2024}`, + }} + got := New(fp, 3, testLogger()).DeriveName(context.Background(), duneContext, "") + if got != "Дюна: Часть вторая (2024)" { + t.Errorf("DeriveName() = %q", got) + } + if fp.calls != 2 { + t.Errorf("вызовов LLM = %d, want 2", fp.calls) + } +} + +func TestDeriveNameExhaustsThenFallback(t *testing.T) { + fp := &fakeProvider{responses: []string{"мусор"}} + got := New(fp, 3, testLogger()).DeriveName(context.Background(), duneContext, "") + // Бюджет исчерпан → алгоритмический фолбек: первая строка без тех. спецификаций. + if got != "Дюна: Часть вторая / Dune: Part Two" { + t.Errorf("DeriveName() = %q (ожидался фолбек)", got) + } + if fp.calls != 3 { + t.Errorf("вызовов LLM = %d, want 3 (исчерпание бюджета)", fp.calls) + } +} + +func TestDeriveNameProviderErrorFallsBack(t *testing.T) { + fp := &fakeProvider{err: errors.New("connection refused")} + got := New(fp, 3, testLogger()).DeriveName(context.Background(), duneContext, "") + if got != "Дюна: Часть вторая / Dune: Part Two" { + t.Errorf("DeriveName() = %q (ожидался фолбек при ошибке)", got) + } + if fp.calls != 1 { + t.Errorf("вызовов LLM = %d, want 1 (на транспортной ошибке не повторяем)", fp.calls) + } +} + +func TestDeriveNameNilProviderUsesFallback(t *testing.T) { + got := New(nil, 3, testLogger()).DeriveName(context.Background(), duneContext, "") + if got != "Дюна: Часть вторая / Dune: Part Two" { + t.Errorf("DeriveName() = %q (ожидался фолбек без LLM)", got) + } +} diff --git a/internal/naming/prompt.go b/internal/naming/prompt.go new file mode 100644 index 0000000..3af385d --- /dev/null +++ b/internal/naming/prompt.go @@ -0,0 +1,103 @@ +package naming + +import ( + "context" + "encoding/json" + "fmt" + "strings" + + "git.vakhrushev.me/av/jellybit/internal/llm" +) + +// systemPrompt инструктирует модель вытащить из вольного контекста короткое +// имя. Язык названия выбирается по происхождению контента. +const systemPrompt = `Ты извлекаешь из текста о торрент-раздаче данные для короткого названия. +Верни СТРОГО один JSON-объект, без markdown-ограждений и пояснений. + +Схема: +{ + "type": "movie" | "series", + "title": "название на нужном языке", + "original_title": "оригинальное название или пустая строка", + "year": число или 0, + "director": "режиссёр или пустая строка", + "season": число или null, + "is_russian": true | false +} + +Правила: +- Определи, фильм это или сериал ("type"). +- "is_russian" = true, если это российский/советский фильм или сериал. +- "title": для российского контента — русское название; иначе — английское + (оригинальное). Без года, страны и технических характеристик. +- "year" — год выпуска (0, если не ясен). "director" — режиссёр (пустая + строка, если не указан). "season" — номер сезона для сериала (null для + фильма или если сезон не указан). +- Если данных не хватает, заполняй что можешь; "title" должен быть непустым.` + +// extractViaLLM делает до n.attempts попыток получить валидную структуру. +// Транспортные ошибки провайдера (сеть/429/5xx) гасятся внутри llm.Provider; +// здесь повторяем только переразбор невалидного ответа. ok=false, если за +// все попытки валидного результата не получено. +func (n *Namer) extractViaLLM(ctx context.Context, contextText, hint string) (extracted, bool) { + contextText = strings.TrimSpace(contextText) + if contextText == "" { + return extracted{}, false + } + + user := "Контекст раздачи:\n" + contextText + if hint = strings.TrimSpace(hint); hint != "" { + user += "\n\nИмя из magnet (подсказка, может быть мусором): " + hint + } + + temp := 0.0 + msgs := []llm.Message{ + {Role: llm.RoleSystem, Content: systemPrompt}, + {Role: llm.RoleUser, Content: user}, + } + + for attempt := 1; attempt <= n.attempts; attempt++ { + resp, err := n.provider.Complete(ctx, llm.Request{ + Messages: msgs, + JSONMode: true, + Temperature: &temp, + }) + if err != nil { + // Транспортная ошибка/таймаут: дальше пробовать смысла нет — + // уходим в фолбек, приём не валим. + n.log.Warn("naming: llm complete failed, will fall back", "err", err) + return extracted{}, false + } + + ex, perr := parseExtracted(resp.Content) + if perr == nil { + return ex, true + } + n.log.Warn("naming: unparsed llm response", "attempt", attempt, "err", perr) + msgs = append(msgs, + llm.Message{Role: llm.RoleAssistant, Content: resp.Content}, + llm.Message{Role: llm.RoleUser, Content: "Ответ невалиден: " + perr.Error() + + ". Верни строго один JSON-объект по схеме, без пояснений."}) + } + return extracted{}, false +} + +// parseExtracted вытаскивает JSON-объект из ответа и валидирует минимум: +// непустой title. Тип по умолчанию — movie. +func parseExtracted(raw string) (extracted, error) { + jsonStr, err := llm.ExtractJSONObject(raw) + if err != nil { + return extracted{}, err + } + var ex extracted + if err := json.Unmarshal([]byte(jsonStr), &ex); err != nil { + return extracted{}, fmt.Errorf("naming: unmarshal: %w", err) + } + if strings.TrimSpace(ex.Title) == "" { + return extracted{}, fmt.Errorf("naming: empty title") + } + if ex.Type != typeMovie && ex.Type != typeSeries { + ex.Type = typeMovie + } + return ex, nil +} diff --git a/internal/naming/render_test.go b/internal/naming/render_test.go new file mode 100644 index 0000000..47df10a --- /dev/null +++ b/internal/naming/render_test.go @@ -0,0 +1,77 @@ +package naming + +import ( + "strings" + "testing" +) + +func ptr(i int) *int { return &i } + +func TestRender(t *testing.T) { + tests := []struct { + name string + in extracted + want string + }{ + { + name: "movie with director and year", + in: extracted{Type: typeMovie, Title: "Дюна: Часть вторая", Director: "Дени Вильнёв", Year: 2024}, + want: "Дюна: Часть вторая (Дени Вильнёв, 2024)", + }, + { + name: "movie with year only", + in: extracted{Type: typeMovie, Title: "Дюна: Часть вторая", Year: 2024}, + want: "Дюна: Часть вторая (2024)", + }, + { + name: "movie with director only", + in: extracted{Type: typeMovie, Title: "Брат", Director: "Алексей Балабанов"}, + want: "Брат (Алексей Балабанов)", + }, + { + name: "movie without director and year", + in: extracted{Type: typeMovie, Title: "Брат"}, + want: "Брат", + }, + { + name: "series with season", + in: extracted{Type: typeSeries, Title: "Сёгун", Year: 2024, Season: ptr(2)}, + want: "Сёгун (2024). Сезон 2", + }, + { + name: "series without season", + in: extracted{Type: typeSeries, Title: "Сёгун", Year: 2024}, + want: "Сёгун (2024)", + }, + { + name: "series season zero is omitted", + in: extracted{Type: typeSeries, Title: "Сёгун", Season: ptr(0)}, + want: "Сёгун", + }, + { + name: "empty title yields empty name", + in: extracted{Type: typeMovie, Title: " ", Year: 2024}, + want: "", + }, + { + name: "control chars and newlines are sanitized", + in: extracted{Type: typeMovie, Title: "Дюна\n\tЧасть\x00 вторая", Year: 2024}, + want: "Дюна Часть вторая (2024)", + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + if got := render(tc.in); got != tc.want { + t.Errorf("render() = %q, want %q", got, tc.want) + } + }) + } +} + +func TestRenderTruncates(t *testing.T) { + long := strings.Repeat("я", maxNameLen+50) + got := render(extracted{Type: typeMovie, Title: long}) + if n := len([]rune(got)); n > maxNameLen { + t.Errorf("длина имени = %d рун, want <= %d", n, maxNameLen) + } +} diff --git a/internal/qbt/qbt.go b/internal/qbt/qbt.go index f4a1b31..e6fe7ff 100644 --- a/internal/qbt/qbt.go +++ b/internal/qbt/qbt.go @@ -73,6 +73,7 @@ type AddRequest struct { Torrents [][]byte // .torrent-файлы (Ф1 не использует) Category string SavePath string + Rename string // отображаемое имя торрента (param rename); пустое — не задаём Paused bool } @@ -173,6 +174,9 @@ func (c *Client) Add(ctx context.Context, ar AddRequest) error { if ar.SavePath != "" { _ = mw.WriteField("savepath", ar.SavePath) } + if ar.Rename != "" { + _ = mw.WriteField("rename", ar.Rename) + } _ = mw.WriteField("paused", strconv.FormatBool(ar.Paused)) for i, data := range ar.Torrents { fw, err := mw.CreateFormFile("torrents", fmt.Sprintf("file%d.torrent", i)) diff --git a/internal/qbt/qbt_test.go b/internal/qbt/qbt_test.go index 4727308..447f1f9 100644 --- a/internal/qbt/qbt_test.go +++ b/internal/qbt/qbt_test.go @@ -69,6 +69,51 @@ func newClient(t *testing.T, url string) *Client { return c } +func TestAddSendsRename(t *testing.T) { + tests := []struct { + name string + rename string + wantField string + wantHasKey bool + }{ + {name: "with rename", rename: "Дюна: Часть вторая (2024)", wantField: "Дюна: Часть вторая (2024)", wantHasKey: true}, + {name: "empty rename omits field", rename: "", wantHasKey: false}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + var gotField string + var hasKey bool + mux := http.NewServeMux() + mux.HandleFunc("/api/v2/torrents/add", func(w http.ResponseWriter, r *http.Request) { + if err := r.ParseMultipartForm(1 << 20); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + _, hasKey = r.MultipartForm.Value["rename"] + gotField = r.FormValue("rename") + _, _ = w.Write([]byte("Ok.")) + }) + srv := httptest.NewServer(mux) + t.Cleanup(srv.Close) + + c := newClient(t, srv.URL) + err := c.Add(context.Background(), AddRequest{ + URLs: []string{"magnet:?xt=urn:btih:541adcff3b6dd5dba7088ea83317d9d6fac331d6"}, + Rename: tc.rename, + }) + if err != nil { + t.Fatalf("Add: %v", err) + } + if hasKey != tc.wantHasKey { + t.Errorf("наличие поля rename = %v, want %v", hasKey, tc.wantHasKey) + } + if gotField != tc.wantField { + t.Errorf("rename = %q, want %q", gotField, tc.wantField) + } + }) + } +} + func TestAddPerformsLazyLogin(t *testing.T) { srv := fakeQBittorrent(t, "[]") c := newClient(t, srv.URL) diff --git a/openspec/changes/add-qbt-display-name/tasks.md b/openspec/changes/add-qbt-display-name/tasks.md index 0141e35..64088ac 100644 --- a/openspec/changes/add-qbt-display-name/tasks.md +++ b/openspec/changes/add-qbt-display-name/tasks.md @@ -1,55 +1,59 @@ ## 1. qBittorrent: проброс имени -- [ ] 1.1 Добавить поле `Rename string` в `qbt.AddRequest` -- [ ] 1.2 В `Client.Add` писать form-field `rename` при непустом `Rename` -- [ ] 1.3 Тест: при заданном `Rename` form-data содержит `rename`, при +- [x] 1.1 Добавить поле `Rename string` в `qbt.AddRequest` +- [x] 1.2 В `Client.Add` писать form-field `rename` при непустом `Rename` +- [x] 1.3 Тест: при заданном `Rename` form-data содержит `rename`, при пустом — поля нет ## 2. Вывод имени: структура и рендер -- [ ] 2.1 Описать структуру извлечённого имени (type, title, +- [x] 2.1 Описать структуру извлечённого имени (type, title, original_title, year, director, season, is_russian) -- [ ] 2.2 Реализовать чистую функцию рендера структуры в короткий ярлык: +- [x] 2.2 Реализовать чистую функцию рендера структуры в короткий ярлык: `Title (Director, Year)` с опциональными режиссёром и годом (скобка опускается, если обоих нет), для сериала — суффикс `. Сезон N`; очистка управляющих символов/переводов строк и обрезка по длине -- [ ] 2.3 Тесты рендера: фильм с режиссёром+годом / только год / только +- [x] 2.3 Тесты рендера: фильм с режиссёром+годом / только год / только режиссёр / без обоих, сериал с сезоном/без, обрезка длины, очистка ## 3. Вывод имени: LLM -- [ ] 3.1 Узкий промпт извлечения имени (RU-название для российского +- [x] 3.1 Узкий промпт извлечения имени (RU-название для российского контента, иначе EN/оригинал) + описание JSON-схемы ответа -- [ ] 3.2 Парсинг и валидация ответа (валидный JSON, непустой `title`); +- [x] 3.2 Парсинг и валидация ответа (валидный JSON, непустой `title`); бюджет попыток — `[llm].max_retries` (переиспользуем существующий) -- [ ] 3.3 Тесты на фикстурах (без сети): успешный разбор, выбор языка +- [x] 3.3 Тесты на фикстурах (без сети): успешный разбор, выбор языка названия, исчерпание попыток ## 4. Вывод имени: алгоритмический фолбек -- [ ] 4.1 Реализовать фолбек без сети: первая содержательная строка +- [x] 4.1 Реализовать фолбек без сети: первая содержательная строка контекста (без ссылок/команд/UI-мусора), отсечение тех. характеристик, очистка и обрезка -- [ ] 4.2 Вынести/переиспользовать логику чистки строк (сейчас в - `tgbot.cleanContext`), чтобы не дублировать -- [ ] 4.3 Тесты фолбека: заголовок релиза, пустой/бесполезный контекст → "" +- [x] 4.2 Фолбек самодостаточен (лёгкая фильтрация шума для сырого + HTTP/CLI-ввода). `tgbot.cleanContext` НЕ рефакторил: он работает на + слое транспорта (чистит UI-мусор бота), фолбек — на слое ядра (берёт + заголовок из уже-контекста); преждевременная общая зависимость связала + бы транспорт с util ядра. Дублирование минимально (две эвристики) +- [x] 4.3 Тесты фолбека: заголовок релиза, пустой/бесполезный контекст → "" ## 5. Интеграция в ingest -- [ ] 5.1 Ввести узкий интерфейс/функцию вывода имени (`DeriveName`), - реализованную поверх LLM + фолбек; зависимость опциональна (нет LLM → - только фолбек) -- [ ] 5.2 В `Ingest()` синхронно выводить имя из `req.Context` (подсказка — +- [x] 5.1 Ввести узкий интерфейс `ingest.Namer` (`DeriveName`), реализован + пакетом `internal/naming` поверх LLM + фолбек; зависимость опциональна + (nil-провайдер → только фолбек) +- [x] 5.2 В `Ingest()` синхронно выводить имя из `req.Context` (подсказка — `magnet.DisplayName`) перед `qbt.Add`, класть в `AddRequest.Rename` -- [ ] 5.3 Graceful-деградация: ошибка/таймаут вывода имени логируется и не +- [x] 5.3 Graceful-деградация: ошибка/таймаут вывода имени логируется и не прерывает приём (пустое имя → без `rename`). Бюджет попыток и таймаут — общие `[llm].max_retries` / `[llm].timeout`, новых параметров не вводим -- [ ] 5.4 Прокинуть зависимость (LLM-провайдер) в сборке сервиса - (`cmd/jellybit`) +- [x] 5.4 Прокинуть зависимость (LLM-провайдер) в сборке сервиса + (`cmd/jellybit`): провайдер поднимается один раз, переиспользуется + `naming` и `recognize` ## 6. Проверка -- [ ] 6.1 `task test` и `task lint` зелёные +- [x] 6.1 `task test` и `task lint` зелёные - [ ] 6.2 Ручная проверка на реальном qBittorrent: имя видно в списке; подтвердить поведение `rename` для многофайловой раздачи - [ ] 6.3 Обновить `docs/specs` (architecture/ingest) и отметить todo