package downloader import ( "bytes" "context" "encoding/json" "errors" "fmt" "io" "mime/multipart" "net/http" "net/http/cookiejar" "net/url" "strconv" "strings" "sync" "time" ) // Client drives one qBittorrent through its WebUI API v2. type Client struct { Config Config mu sync.Mutex // http carries the SID cookie once logged in; nil until then. http *http.Client } // AddTorrent hands qBittorrent a magnet or .torrent bytes, filed under the // configured category with the save path already set. func (c *Client) AddTorrent(ctx context.Context, a Add) error { if a.Magnet == "" && len(a.Torrent) == 0 { return fmt.Errorf("add: neither a magnet nor torrent bytes") } if a.SavePath == "" { return fmt.Errorf("add: no save path, which would land the download outside the folder Minstrel imports from") } body := &bytes.Buffer{} form := multipart.NewWriter(body) fields := map[string]string{ "savepath": a.SavePath, "category": c.Config.Category, // Both, because qBittorrent 5 renamed paused to stopped and an older // one ignores the field it does not know. "paused": strconv.FormatBool(a.Paused), "stopped": strconv.FormatBool(a.Paused), } if len(a.Tags) > 0 { fields["tags"] = strings.Join(a.Tags, ",") } if a.StopAtMetadata && !a.Paused { fields["stopCondition"] = "MetadataReceived" } if a.Magnet != "" { fields["urls"] = a.Magnet } for k, v := range fields { if v == "" { continue } if err := form.WriteField(k, v); err != nil { return fmt.Errorf("add: build form: %w", err) } } if len(a.Torrent) > 0 { part, err := form.CreateFormFile("torrents", "release.torrent") if err != nil { return fmt.Errorf("add: build form: %w", err) } if _, err := part.Write(a.Torrent); err != nil { return fmt.Errorf("add: build form: %w", err) } } if err := form.Close(); err != nil { return fmt.Errorf("add: build form: %w", err) } resp, err := c.post(ctx, "/api/v2/torrents/add", form.FormDataContentType(), body.Bytes()) if err != nil { return err } // qBittorrent refuses with HTTP 200 and the body "Fails.". if strings.HasPrefix(strings.TrimSpace(string(resp)), "Fails") { return fmt.Errorf("%w: the client refused the torrent", ErrUnreachable) } return nil } // Torrents lists the configured category's torrents, or the given hashes. func (c *Client) Torrents(ctx context.Context, hashes ...string) ([]Torrent, error) { params := url.Values{} if c.Config.Category != "" { params.Set("category", c.Config.Category) } if len(hashes) > 0 { params.Set("hashes", strings.Join(hashes, "|")) } body, err := c.get(ctx, "/api/v2/torrents/info", params) if err != nil { return nil, err } var out []Torrent if err := json.Unmarshal(body, &out); err != nil { return nil, fmt.Errorf("%w: parse torrent list: %v", ErrUnreachable, err) } for i := range out { out[i].Hash = strings.ToLower(out[i].Hash) } return out, nil } // Connected reads qBittorrent's own connection status. func (c *Client) Connected(ctx context.Context) (bool, error) { body, err := c.get(ctx, "/api/v2/transfer/info", nil) if err != nil { return false, err } var info struct { ConnectionStatus string `json:"connection_status"` } if err := json.Unmarshal(body, &info); err != nil { return false, fmt.Errorf("%w: parse transfer info: %v", ErrUnreachable, err) } return info.ConnectionStatus == "connected", nil } // Stop and Start try qBittorrent 5's names first and fall back to 4's. func (c *Client) Stop(ctx context.Context, hashes ...string) error { return c.command(ctx, hashes, "/api/v2/torrents/stop", "/api/v2/torrents/pause") } func (c *Client) Start(ctx context.Context, hashes ...string) error { return c.command(ctx, hashes, "/api/v2/torrents/start", "/api/v2/torrents/resume") } func (c *Client) Reannounce(ctx context.Context, hashes ...string) error { return c.command(ctx, hashes, "/api/v2/torrents/reannounce", "/api/v2/torrents/reannounce") } func (c *Client) Recheck(ctx context.Context, hashes ...string) error { return c.command(ctx, hashes, "/api/v2/torrents/recheck", "/api/v2/torrents/recheck") } func (c *Client) Delete(ctx context.Context, deleteFiles bool, hashes ...string) error { if len(hashes) == 0 { return nil } params := url.Values{ "hashes": {strings.Join(hashes, "|")}, "deleteFiles": {strconv.FormatBool(deleteFiles)}, } _, err := c.post(ctx, "/api/v2/torrents/delete", "application/x-www-form-urlencoded", []byte(params.Encode())) return err } func (c *Client) SkipFiles(ctx context.Context, hash string, indices []int) error { return c.filePriority(ctx, hash, indices, "0") } func (c *Client) KeepFiles(ctx context.Context, hash string, indices []int) error { return c.filePriority(ctx, hash, indices, "1") } func (c *Client) filePriority(ctx context.Context, hash string, indices []int, priority string) error { if len(indices) == 0 { return nil } ids := make([]string, len(indices)) for i, n := range indices { ids[i] = strconv.Itoa(n) } params := url.Values{"hash": {hash}, "id": {strings.Join(ids, "|")}, "priority": {priority}} _, err := c.post(ctx, "/api/v2/torrents/filePrio", "application/x-www-form-urlencoded", []byte(params.Encode())) return err } func (c *Client) Files(ctx context.Context, hash string) ([]File, error) { raw, err := c.files(ctx, hash) if err != nil { return nil, err } out := make([]File, len(raw)) for i, f := range raw { out[i] = File{Index: f.Index, Path: f.Name, Size: f.Size} } return out, nil } func (c *Client) FileStates(ctx context.Context, hash string) ([]FileState, error) { raw, err := c.files(ctx, hash) if err != nil { return nil, err } out := make([]FileState, len(raw)) for i, f := range raw { done := f.Size if f.Progress < 1 { // Progress is a float; never round an unfinished file up to whole. done = min(int64(f.Progress*float64(f.Size)), f.Size-1) } out[i] = FileState{Path: f.Name, Size: f.Size, Done: max(done, 0), Skipped: f.Priority == 0} } return out, nil } type qbitFile struct { Index int `json:"index"` Name string `json:"name"` Size int64 `json:"size"` Progress float64 `json:"progress"` Priority int `json:"priority"` } func (c *Client) files(ctx context.Context, hash string) ([]qbitFile, error) { body, err := c.get(ctx, "/api/v2/torrents/files", url.Values{"hash": {hash}}) if err != nil { return nil, err } var raw []qbitFile if err := json.Unmarshal(body, &raw); err != nil { return nil, fmt.Errorf("%w: parse file list: %v", ErrUnreachable, err) } return raw, nil } func (c *Client) command(ctx context.Context, hashes []string, primary, fallback string) error { if len(hashes) == 0 { return nil } params := []byte(url.Values{"hashes": {strings.Join(hashes, "|")}}.Encode()) _, err := c.post(ctx, primary, "application/x-www-form-urlencoded", params) var status statusError if errors.As(err, &status) && (status.code == http.StatusNotFound || status.code == http.StatusMethodNotAllowed) { _, err = c.post(ctx, fallback, "application/x-www-form-urlencoded", params) } return err } type statusError struct { code int what string } func (e statusError) Error() string { return fmt.Sprintf("%s: HTTP %d", e.what, e.code) } func (e statusError) Unwrap() error { return ErrUnreachable } func (c *Client) get(ctx context.Context, path string, params url.Values) ([]byte, error) { endpoint := path if len(params) > 0 { endpoint += "?" + params.Encode() } return c.do(ctx, http.MethodGet, endpoint, "", nil) } func (c *Client) post(ctx context.Context, path, contentType string, body []byte) ([]byte, error) { return c.do(ctx, http.MethodPost, path, contentType, body) } // do logs in when there is no session, and once more on a 403, which is how // qBittorrent says a session expired. func (c *Client) do(ctx context.Context, method, path, contentType string, body []byte) ([]byte, error) { if c.Config.BaseURL == "" { return nil, ErrUnconfigured } if err := c.ensureSession(ctx, false); err != nil { return nil, err } out, err := c.call(ctx, method, path, contentType, body) var status statusError if errors.As(err, &status) && status.code == http.StatusForbidden { if err := c.ensureSession(ctx, true); err != nil { return nil, err } return c.call(ctx, method, path, contentType, body) } return out, err } func (c *Client) ensureSession(ctx context.Context, force bool) error { c.mu.Lock() defer c.mu.Unlock() if c.http != nil && !force { return nil } jar, err := cookiejar.New(nil) if err != nil { return fmt.Errorf("cookie jar: %w", err) } httpc := &http.Client{Jar: jar} params := url.Values{"username": {c.Config.Username}, "password": {c.Config.Password}} out, err := c.callWith(ctx, httpc, http.MethodPost, "/api/v2/auth/login", "application/x-www-form-urlencoded", []byte(params.Encode())) if err != nil { var status statusError if errors.As(err, &status) && status.code == http.StatusForbidden { return fmt.Errorf("%w: too many failed attempts, or this address is banned", ErrAuth) } return err } if strings.HasPrefix(strings.TrimSpace(string(out)), "Fails") { return fmt.Errorf("%w: wrong username or password", ErrAuth) } c.http = httpc return nil } func (c *Client) call(ctx context.Context, method, path, contentType string, body []byte) ([]byte, error) { c.mu.Lock() httpc := c.http c.mu.Unlock() if httpc == nil { httpc = &http.Client{} } return c.callWith(ctx, httpc, method, path, contentType, body) } func (c *Client) callWith(ctx context.Context, httpc *http.Client, method, path, contentType string, body []byte) ([]byte, error) { base := strings.TrimRight(c.Config.BaseURL, "/") status, out, err := send(ctx, httpc, c.Config.Timeout, method, base+path, contentType, body, func(h http.Header) { // qBittorrent's CSRF check refuses a request without a matching // Referer. h.Set("Referer", base) }) if err != nil { return nil, err } if status < 200 || status >= 300 { return nil, statusError{code: status, what: path} } return out, nil } // send makes one request to a download client under its timeout, lets the // caller add headers, and reads at most 16 MB. It returns the status for the // caller to read, because each client's refusals are its own. func send(ctx context.Context, httpc *http.Client, timeout time.Duration, method, target, contentType string, body []byte, set func(http.Header)) (int, []byte, error) { if timeout <= 0 { timeout = DefaultTimeout } ctx, cancel := context.WithTimeout(ctx, timeout) defer cancel() var reader io.Reader if body != nil { reader = bytes.NewReader(body) } req, err := http.NewRequestWithContext(ctx, method, target, reader) if err != nil { return 0, nil, fmt.Errorf("%w: build request: %v", ErrUnreachable, err) } req.Header.Set("User-Agent", UserAgent) if contentType != "" { req.Header.Set("Content-Type", contentType) } if set != nil { set(req.Header) } resp, err := httpc.Do(req) if err != nil { return 0, nil, fmt.Errorf("%w: %v", ErrUnreachable, err) } defer func() { _ = resp.Body.Close() }() out, err := io.ReadAll(io.LimitReader(resp.Body, 16<<20)) if err != nil { return 0, nil, fmt.Errorf("%w: read body: %v", ErrUnreachable, err) } return resp.StatusCode, out, nil }