Реализация, фаза 1: добавление данных в qbittorrent
This commit is contained in:
@@ -1,4 +0,0 @@
|
||||
// Package qbt — клиент qBittorrent WebUI API (сессия, добавление, опрос).
|
||||
//
|
||||
// Заглушка: реализация в фазе Ф1 (см. docs/specs/architecture.md).
|
||||
package qbt
|
||||
@@ -0,0 +1,219 @@
|
||||
// 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
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
package qbt
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// fakeQBittorrent — минимальный стенд WebUI API: требует cookie SID, выдаёт
|
||||
// его на /auth/login. Так проверяется и ленивый логин по 403.
|
||||
func fakeQBittorrent(t *testing.T, info string) *httptest.Server {
|
||||
t.Helper()
|
||||
mux := http.NewServeMux()
|
||||
|
||||
mux.HandleFunc("/api/v2/auth/login", func(w http.ResponseWriter, r *http.Request) {
|
||||
_ = r.ParseForm()
|
||||
if r.PostForm.Get("username") != "admin" || r.PostForm.Get("password") != "secret" {
|
||||
w.WriteHeader(http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
http.SetCookie(w, &http.Cookie{Name: "SID", Value: "token", Path: "/"})
|
||||
_, _ = w.Write([]byte("Ok."))
|
||||
})
|
||||
|
||||
authed := func(next http.HandlerFunc) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
if c, err := r.Cookie("SID"); err != nil || c.Value != "token" {
|
||||
w.WriteHeader(http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
next(w, r)
|
||||
}
|
||||
}
|
||||
|
||||
mux.HandleFunc("/api/v2/torrents/add", authed(func(w http.ResponseWriter, r *http.Request) {
|
||||
if err := r.ParseMultipartForm(1 << 20); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if r.FormValue("category") != "jellybit" || !strings.Contains(r.FormValue("urls"), "magnet:") {
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
_, _ = w.Write([]byte("Ok."))
|
||||
}))
|
||||
|
||||
mux.HandleFunc("/api/v2/torrents/info", authed(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Query().Get("category") != "jellybit" {
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(info))
|
||||
}))
|
||||
|
||||
srv := httptest.NewServer(mux)
|
||||
t.Cleanup(srv.Close)
|
||||
return srv
|
||||
}
|
||||
|
||||
func newClient(t *testing.T, url string) *Client {
|
||||
t.Helper()
|
||||
c, err := New(Config{URL: url, Username: "admin", Password: "secret"})
|
||||
if err != nil {
|
||||
t.Fatalf("New: %v", err)
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
func TestAddPerformsLazyLogin(t *testing.T) {
|
||||
srv := fakeQBittorrent(t, "[]")
|
||||
c := newClient(t, srv.URL)
|
||||
|
||||
// Первый вызов без cookie → сервер вернёт 403 → клиент логинится и повторяет.
|
||||
err := c.Add(context.Background(), AddRequest{
|
||||
URLs: []string{"magnet:?xt=urn:btih:541adcff3b6dd5dba7088ea83317d9d6fac331d6"},
|
||||
Category: "jellybit",
|
||||
SavePath: "/srv/media/downloads",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Add: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTorrents(t *testing.T) {
|
||||
const body = `[{"hash":"541adcff3b6dd5dba7088ea83317d9d6fac331d6","name":"Dune","state":"uploading","save_path":"/srv/media/downloads","content_path":"/srv/media/downloads/Dune","progress":1.0,"amount_left":0}]`
|
||||
srv := fakeQBittorrent(t, body)
|
||||
c := newClient(t, srv.URL)
|
||||
|
||||
ts, err := c.Torrents(context.Background(), "jellybit")
|
||||
if err != nil {
|
||||
t.Fatalf("Torrents: %v", err)
|
||||
}
|
||||
if len(ts) != 1 {
|
||||
t.Fatalf("torrents = %d, want 1", len(ts))
|
||||
}
|
||||
got := ts[0]
|
||||
if got.Hash != "541adcff3b6dd5dba7088ea83317d9d6fac331d6" || got.State != "uploading" {
|
||||
t.Errorf("torrent = %+v", got)
|
||||
}
|
||||
if got.ContentPath != "/srv/media/downloads/Dune" {
|
||||
t.Errorf("content_path = %q", got.ContentPath)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoginFailure(t *testing.T) {
|
||||
srv := fakeQBittorrent(t, "[]")
|
||||
c, err := New(Config{URL: srv.URL, Username: "admin", Password: "wrong"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// 403 → попытка логина с неверным паролем → снова 403 → ошибка.
|
||||
if _, err := c.Torrents(context.Background(), "jellybit"); err == nil {
|
||||
t.Error("ожидалась ошибка логина")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user