Files
jellybit/internal/qbt/qbt.go
T

220 lines
6.9 KiB
Go

// Package qbt — клиент qBittorrent WebUI API (v2): сессия, добавление
// торрента, опрос задач по категории.
//
// Логин ленивый: cookie-сессия устанавливается при первом 403 и повторно
// при её протухании. Источник (magnet/.torrent) отдаём qBittorrent — он сам
// качает, jellybit не делает исходящих запросов на пользовательский URL.
package qbt
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"mime/multipart"
"net/http"
"net/http/cookiejar"
"net/url"
"strconv"
"strings"
"sync"
"time"
)
// 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
mu sync.Mutex // сериализует логин
}
// Torrent — подмножество полей /torrents/info, нужное jellybit.
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"`
Progress float64 `json:"progress"`
AmountLeft int64 `json:"amount_left"`
AddedOn int64 `json:"added_on"`
InfohashV1 string `json:"infohash_v1"`
InfohashV2 string `json:"infohash_v2"`
}
// AddRequest — параметры добавления торрента.
type AddRequest struct {
URLs []string // magnet/URL-ссылки
Torrents [][]byte // .torrent-файлы (Ф1 не использует)
Category string
SavePath string
Paused bool
}
// New создаёт клиент с собственным cookie-jar.
func New(cfg Config) (*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
}
return &Client{
base: base,
hc: &http.Client{Jar: jar, Timeout: timeout},
user: cfg.Username,
pass: cfg.Password,
}, 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
resp, err := c.hc.Do(req)
if err != nil {
return fmt.Errorf("qbittorrent login: %w", err)
}
defer func() { _ = resp.Body.Close() }()
body, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<10))
if resp.StatusCode != http.StatusOK || strings.TrimSpace(string(body)) != "Ok." {
return fmt.Errorf("qbittorrent login failed: status %d body %q",
resp.StatusCode, strings.TrimSpace(string(body)))
}
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()
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)
}
_ = 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()
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 {
return fmt.Errorf("qbittorrent add: %w", err)
}
defer func() { _ = resp.Body.Close() }()
body, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<10))
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("qbittorrent add: status %d body %q",
resp.StatusCode, strings.TrimSpace(string(body)))
}
if strings.TrimSpace(string(body)) == "Fails." {
return fmt.Errorf("qbittorrent add: rejected (Fails.)")
}
return nil
}
// Torrents возвращает задачи указанной категории (пустая — все).
func (c *Client) Torrents(ctx context.Context, category string) ([]Torrent, error) {
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 {
return nil, fmt.Errorf("qbittorrent info: %w", err)
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<10))
return nil, fmt.Errorf("qbittorrent info: status %d body %q",
resp.StatusCode, strings.TrimSpace(string(body)))
}
var ts []Torrent
if err := json.NewDecoder(resp.Body).Decode(&ts); err != nil {
return nil, fmt.Errorf("decode qbittorrent info: %w", err)
}
return ts, nil
}