// Package qbt — клиент qBittorrent WebUI API (v2): сессия, добавление // торрента, опрос задач по категории. // // Логин ленивый: cookie-сессия устанавливается при первом 403 и повторно // при её протухании. Источник (magnet/.torrent) отдаём qBittorrent — он сам // качает, jellybit не делает исходящих запросов на пользовательский URL. package qbt import ( "bytes" "context" "encoding/json" "fmt" "io" "log/slog" "mime/multipart" "net/http" "net/http/cookiejar" "net/url" "strconv" "strings" "sync" "time" "git.vakhrushev.me/av/jellybit/internal/logctx" "git.vakhrushev.me/av/jellybit/internal/logging" ) // Config — параметры подключения к qBittorrent WebUI. type Config struct { URL string Username string Password string Timeout time.Duration } // Client — клиент qBittorrent WebUI API. type Client struct { base *url.URL hc *http.Client user string pass string log *slog.Logger mu sync.Mutex // сериализует логин } // Torrent — подмножество полей /torrents/info, нужное jellybit. Помимо полей // для машины состояний и сопоставления несёт живую телеметрию (скорости, ETA, // статистика раздачи) — её собирает снимок воркера для веб-UI; все эти поля // приходят в том же ответе, отдельного вызова не нужно. type Torrent struct { Hash string `json:"hash"` Name string `json:"name"` State string `json:"state"` SavePath string `json:"save_path"` ContentPath string `json:"content_path"` Category string `json:"category"` Tags string `json:"tags"` // через запятую Progress float64 `json:"progress"` AmountLeft int64 `json:"amount_left"` TotalSize int64 `json:"total_size"` // полный размер раздачи, байт AddedOn int64 `json:"added_on"` // LastActivity — Unix-время последнего движения данных по торренту (скачан/ // отдан кусок). Базис измерения простоя для stuck_after: простой = now − // last_activity (а не возраст от added_on), поэтому долго качавшийся торрент, // на миг зашедший в stalledDL, не помечается «зависшим». LastActivity int64 `json:"last_activity"` InfohashV1 string `json:"infohash_v1"` InfohashV2 string `json:"infohash_v2"` // Живая телеметрия (для снимка воркера и веб-UI). Dlspeed int64 `json:"dlspeed"` // скорость загрузки, байт/с Upspeed int64 `json:"upspeed"` // скорость отдачи, байт/с Eta int64 `json:"eta"` // оценка до завершения, с (8640000 ≈ ∞) Ratio float64 `json:"ratio"` // рейтинг отдачи (может быть -1 = ∞/н/д) NumSeeds int `json:"num_seeds"` // подключённые сиды NumLeechs int `json:"num_leechs"` // подключённые личи (пиры) Uploaded int64 `json:"uploaded"` // отдано всего, байт } // File — элемент /torrents/files: путь файла относительно save_path // (включая корневую папку торрента для многофайловых раздач) и его размер. // Абсолютный путь на диске = filepath.Join(save_path, Name) — НЕ content_path: // для многофайловой раздачи это удвоило бы корневую папку, для однофайловой // дало бы путь под самим файлом. type File struct { Name string `json:"name"` Size int64 `json:"size"` } // AddRequest — параметры добавления торрента. type AddRequest struct { URLs []string // magnet/URL-ссылки Torrents [][]byte // .torrent-файлы (Ф1 не использует) Category string SavePath string Rename string // отображаемое имя торрента (param rename); пустое — не задаём Paused bool } // New создаёт клиент с собственным cookie-jar. logger nil → slog.Default(). func New(cfg Config, logger *slog.Logger) (*Client, error) { base, err := url.Parse(strings.TrimRight(cfg.URL, "/")) if err != nil { return nil, fmt.Errorf("parse qbittorrent url %q: %w", cfg.URL, err) } jar, err := cookiejar.New(nil) if err != nil { return nil, fmt.Errorf("cookie jar: %w", err) } timeout := cfg.Timeout if timeout == 0 { timeout = 30 * time.Second } if logger == nil { logger = slog.Default() } return &Client{ base: base, hc: &http.Client{Jar: jar, Timeout: timeout}, user: cfg.Username, pass: cfg.Password, log: logger, }, nil } func (c *Client) endpoint(path string) string { return c.base.String() + path } // login устанавливает cookie-сессию. Сериализован, чтобы параллельные // вызовы (поллинг + приём) не логинились наперегонки. func (c *Client) login(ctx context.Context) error { c.mu.Lock() defer c.mu.Unlock() form := url.Values{"username": {c.user}, "password": {c.pass}} req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.endpoint("/api/v2/auth/login"), strings.NewReader(form.Encode())) if err != nil { return err } req.Header.Set("Content-Type", "application/x-www-form-urlencoded") req.Header.Set("Referer", c.base.String()) // qBit проверяет Referer/Host log := logctx.FromOr(ctx, c.log) call := logging.ExtCall{Service: logging.ServiceQBittorrent, Operation: "auth/login", Start: time.Now()} resp, err := c.hc.Do(req) if err != nil { call.Failure(log, err) return fmt.Errorf("qbittorrent login: %w", err) } defer func() { _ = resp.Body.Close() }() call.Status = resp.StatusCode body, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<10)) if resp.StatusCode != http.StatusOK || strings.TrimSpace(string(body)) != "Ok." { // Тело логина не содержит секретов (qBit отвечает "Ok."/"Fails."), но // учётные данные (логин/пароль) в лог не идут — только факт неуспеха. err := fmt.Errorf("qbittorrent login failed: status %d body %q", resp.StatusCode, strings.TrimSpace(string(body))) call.Failure(log, err) return err } call.Success(log, "authenticated", true) return nil } // do выполняет запрос; при 403 один раз перелогинивается и повторяет. // build вызывается заново для повтора, т.к. тело запроса одноразовое. func (c *Client) do(ctx context.Context, build func() (*http.Request, error)) (*http.Response, error) { req, err := build() if err != nil { return nil, err } resp, err := c.hc.Do(req) if err != nil { return nil, err } if resp.StatusCode == http.StatusForbidden { _ = resp.Body.Close() logctx.FromOr(ctx, c.log).Debug("qbittorrent session expired, re-login") if err := c.login(ctx); err != nil { return nil, err } req2, err := build() if err != nil { return nil, err } return c.hc.Do(req2) } return resp, nil } // Add добавляет торрент(ы) в qBittorrent. func (c *Client) Add(ctx context.Context, ar AddRequest) error { var buf bytes.Buffer mw := multipart.NewWriter(&buf) if len(ar.URLs) > 0 { _ = mw.WriteField("urls", strings.Join(ar.URLs, "\n")) } if ar.Category != "" { _ = mw.WriteField("category", ar.Category) } 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)) if err != nil { return fmt.Errorf("qbittorrent add: form file: %w", err) } if _, err := fw.Write(data); err != nil { return fmt.Errorf("qbittorrent add: write file: %w", err) } } if err := mw.Close(); err != nil { return fmt.Errorf("qbittorrent add: close multipart: %w", err) } contentType := mw.FormDataContentType() payload := buf.Bytes() log := logctx.FromOr(ctx, c.log) call := logging.ExtCall{Service: logging.ServiceQBittorrent, Operation: "torrents/add", Start: time.Now()} resp, err := c.do(ctx, func() (*http.Request, error) { req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.endpoint("/api/v2/torrents/add"), bytes.NewReader(payload)) if err != nil { return nil, err } req.Header.Set("Content-Type", contentType) req.Header.Set("Referer", c.base.String()) return req, nil }) if err != nil { call.Failure(log, err, "category", ar.Category) return fmt.Errorf("qbittorrent add: %w", err) } defer func() { _ = resp.Body.Close() }() call.Status = resp.StatusCode body, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<10)) if resp.StatusCode != http.StatusOK { err := fmt.Errorf("qbittorrent add: status %d body %q", resp.StatusCode, strings.TrimSpace(string(body))) call.Failure(log, err, "category", ar.Category) return err } if strings.TrimSpace(string(body)) == "Fails." { err := fmt.Errorf("qbittorrent add: rejected (Fails.)") call.Failure(log, err, "category", ar.Category, "urls", len(ar.URLs), "torrents", len(ar.Torrents)) return err } call.Success(log, "category", ar.Category, "save_path", ar.SavePath, "urls", len(ar.URLs), "torrents", len(ar.Torrents), "paused", ar.Paused) return nil } // Torrents возвращает задачи указанной категории (пустая — все). func (c *Client) Torrents(ctx context.Context, category string) ([]Torrent, error) { log := logctx.FromOr(ctx, c.log) call := logging.ExtCall{Service: logging.ServiceQBittorrent, Operation: "torrents/info", Start: time.Now()} resp, err := c.do(ctx, func() (*http.Request, error) { u := c.endpoint("/api/v2/torrents/info") if category != "" { u += "?category=" + url.QueryEscape(category) } return http.NewRequestWithContext(ctx, http.MethodGet, u, nil) }) if err != nil { call.Failure(log, err) return nil, fmt.Errorf("qbittorrent info: %w", err) } defer func() { _ = resp.Body.Close() }() call.Status = resp.StatusCode if resp.StatusCode != http.StatusOK { body, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<10)) err := fmt.Errorf("qbittorrent info: status %d body %q", resp.StatusCode, strings.TrimSpace(string(body))) call.Failure(log, err) return nil, err } var ts []Torrent if err := json.NewDecoder(resp.Body).Decode(&ts); err != nil { call.Failure(log, err) return nil, fmt.Errorf("decode qbittorrent info: %w", err) } // Поллинг частый — на DEBUG, чтобы не зашумлять INFO (как healthcheck). call.SuccessDebug(log, "category", category, "count", len(ts)) return ts, nil } // Files возвращает список файлов торрента (имена относительно save_path, // включая корневую папку для многофайловых раздач, и размеры). Нужен // распознаванию как один из сигналов; абсолютный путь — join(save_path, Name). func (c *Client) Files(ctx context.Context, hash string) ([]File, error) { log := logctx.FromOr(ctx, c.log) call := logging.ExtCall{Service: logging.ServiceQBittorrent, Operation: "torrents/files", Start: time.Now()} resp, err := c.do(ctx, func() (*http.Request, error) { u := c.endpoint("/api/v2/torrents/files?hash=" + url.QueryEscape(hash)) return http.NewRequestWithContext(ctx, http.MethodGet, u, nil) }) if err != nil { call.Failure(log, err) return nil, fmt.Errorf("qbittorrent files: %w", err) } defer func() { _ = resp.Body.Close() }() call.Status = resp.StatusCode if resp.StatusCode != http.StatusOK { body, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<10)) err := fmt.Errorf("qbittorrent files: status %d body %q", resp.StatusCode, strings.TrimSpace(string(body))) call.Failure(log, err) return nil, err } var fs []File if err := json.NewDecoder(resp.Body).Decode(&fs); err != nil { call.Failure(log, err) return nil, fmt.Errorf("decode qbittorrent files: %w", err) } call.SuccessDebug(log, "count", len(fs)) return fs, nil }