Добавил реализацию

This commit is contained in:
av
2026-06-28 12:20:10 +03:00
parent 4f2fb5998a
commit d8ef7063f4
12 changed files with 731 additions and 39 deletions
+21 -3
View File
@@ -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",
+50 -1
View File
@@ -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}
+77
View File
@@ -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)
}
+62
View File
@@ -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)
}
})
}
}
+139
View File
@@ -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
}
+104
View File
@@ -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)
}
}
+103
View File
@@ -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
}
+77
View File
@@ -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)
}
}
+4
View File
@@ -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))
+45
View File
@@ -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)