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
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>
368 lines
11 KiB
Go
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
|
|
}
|