Files
minstrel/internal/downloader/qbittorrent.go
T
bvandeusenandClaude Opus 5.5 a2e1890d4c
release / govulncheck (push) Successful in 34s
release / web (push) Successful in 1m13s
release / go (push) Successful in 2m12s
release / integration (push) Successful in 4m6s
release / Build signed APK (releases and dev) (push) Successful in 8m41s
release / android (push) Successful in 9m47s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 59s
release / Verify release artifacts (tag releases only) (push) Skipped
feat: indexers and download clients you add and configure (M502 #5518)
Admin → Integrations gains "Indexers and download clients": two lists of
connections the operator adds, Fabled Librarian's model. Nothing is seeded
or copied from Lidarr.

- Indexers: Prowlarr (its native /api/v1/search, which asks every indexer
  at once; Prowlarr has no aggregate Torznab feed) and any Torznab feed.
- Download clients: qBittorrent (WebUI v2, with re-login on an expired
  session) and FabledExchange, one entry with a switch per job:
  downloads (/api/downloads), search (/api/search) and release details
  (/api/torrents/{infohash}). All three start on; its key is optional.
- Each entry: enable, name, address, a write-only key or password, Test.
  A download client's Test lists its torrents and checks the downloads
  folder is visible and writable from Minstrel; an Exchange's Test
  reports each job switched on.
- Download clients carry a path mapping (Minstrel's /downloads vs the
  client's own name for it); search sources carry searches per hour.

Migration 0079 adds acquisition_services with its CHECKs. Ported from
Fabled Librarian: internal/indexer (Torznab, 429 Retry-After, magnet
redirects), internal/prowlarr, internal/exchange, internal/downloader,
and internal/acquire's one builder per kind, which Test already uses
so a passing Test vouches for the path a grab will take.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 20:34:51 -04:00

368 lines
11 KiB
Go

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
}