diff --git a/.github/renovate.json b/.github/renovate.json index 0fc55f7..5db72dd 100644 --- a/.github/renovate.json +++ b/.github/renovate.json @@ -1,15 +1,6 @@ { "$schema": "https://docs.renovatebot.com/renovate-schema.json", "extends": [ - "config:base", - "default:automergeDigest" - ], - "packageRules": [ - { - "matchDatasources": ["go", "github-releases", "github-tags"], - "matchUpdateTypes": ["minor", "patch", "pin", "digest"], - "automerge": true - } + "config:recommended" ] } - diff --git a/.github/workflows/codetests.yml b/.github/workflows/codetests.yml index 9fa0936..72b5c7c 100644 --- a/.github/workflows/codetests.yml +++ b/.github/workflows/codetests.yml @@ -2,24 +2,31 @@ name: test-and-lint on: pull_request: branches: - - main + - main permissions: contents: read jobs: + codespell: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + - uses: codespell-project/actions-codespell@v2 + with: + skip: .git,go.sum + ignore_words_list: SuperSeeding # Runs `go test` against 3 operating systems. - # gotest: - # strategy: - # matrix: - # os: [macos, windows, ubuntu] - # runs-on: ${{ matrix.os }}-latest - # steps: - # - uses: actions/checkout@v3.2.0 - # - uses: actions/setup-go@v3 - # with: - # go-version: 1.19 - # - name: go-test - # run: go test -race -covermode=atomic ./... - + gotest: + strategy: + matrix: + os: [macos, windows, ubuntu] + runs-on: ${{ matrix.os }}-latest + steps: + - uses: actions/checkout@v7 + - uses: actions/setup-go@v7 + with: + go-version: stable + - name: go-test + run: go test -race -v -covermode=atomic ./... # Runs golangci-lint on macos against freebsd and macos. golangci-darwin: strategy: @@ -30,14 +37,14 @@ jobs: env: GOOS: ${{ matrix.os }} steps: - - uses: actions/setup-go@v5 + - uses: actions/setup-go@v7 with: go-version: stable - - uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4 + - uses: actions/checkout@v7 - name: golangci-lint - uses: golangci/golangci-lint-action@v6 + uses: golangci/golangci-lint-action@v9 with: - version: 'v1.55' + version: v2.13.0 # Runs golangci-lint on linux against linux and windows. golangci-linux: strategy: @@ -48,11 +55,11 @@ jobs: env: GOOS: ${{ matrix.os }} steps: - - uses: actions/setup-go@v5 + - uses: actions/setup-go@v7 with: go-version: stable - - uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4 + - uses: actions/checkout@v7 - name: golangci-lint - uses: golangci/golangci-lint-action@v6 + uses: golangci/golangci-lint-action@v9 with: - version: 'v1.55' \ No newline at end of file + version: v2.13.0 diff --git a/.golangci.yml b/.golangci.yml index 8a96580..276fd15 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -1,19 +1,52 @@ +version: '2' linters: - enable-all: true + default: all disable: - # deprecated - - maligned - - scopelint - - interfacer - - golint - - exhaustivestruct - - nosnakecase - - structcheck - - deadcode - - varcheck - - ifshort - # unused + - dupl - exhaustruct + - exhaustruct_v5 + - musttag + - nlreturn - tagliatelle -run: - timeout: 3m \ No newline at end of file + - noinlineerr + - wsl + - wsl_v5 + - funcorder + - gomodguard + settings: + depguard: + rules: + main: + files: + - $all + allow: + - $gostd + - github.com/stretchr/testify + - golift.io/qbit + - golang.org/x/net + exclusions: + generated: lax + presets: + - std-error-handling + rules: + - linters: + - err113 + - forcetypeassert + - funlen + - gochecknoglobals + - lll + - goconst + path: (.+)_test\.go + paths: + - test + - queue + +issues: + max-issues-per-linter: 0 + max-same-issues: 0 +formatters: + enable: + - gci + - gofmt + - gofumpt + - goimports diff --git a/LICENSE b/LICENSE index 6a94503..74934b5 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ MIT License -Copyright (c) 2021-2023 Go Lift - Building Strong Go Tools +Copyright (c) 2021-2026 Go Lift - Building Strong Go Tools Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/README.md b/README.md index f989fcc..a9b4418 100644 --- a/README.md +++ b/README.md @@ -1,44 +1,64 @@ -# Go Library: qbit +# qbit -Go Library used to interact with Qbittorrent. This library is not full-featured, -and probably only provides read-only methods to view the download list. +[![GoDoc](https://godoc.org/golift.io/qbit?status.svg)](https://pkg.go.dev/golift.io/qbit) +[![Go Report Card](https://goreportcard.com/badge/golift.io/qbit)](https://goreportcard.com/report/golift.io/qbit) +[![MIT License](http://img.shields.io/:license-mit-blue.svg)](https://github.com/golift/qbit/blob/main/LICENSE) -At the time of this writing (creation), the library only supports logging in, -and collecting transfer info. +Go library to interact with the [qBittorrent](https://github.com/qbittorrent/qBittorrent) Web API. -If you'd like new features, please open a GitHub issue or pull request. +This is not a full client. It covers login (including HTTP basic auth), listing transfers, +listing categories, and setting a torrent category. If you need more of the API, please +[open an issue](https://github.com/golift/qbit/issues/new) or a pull request. -Example: +This library is used by [Notifiarr](https://github.com/Notifiarr/notifiarr/). + +qBittorrent 5.2+ returns HTTP 204 on login and other empty WebAPI responses. +Older 200/`Ok.` logins still work. + +## Install + +```shell +go get golift.io/qbit +``` + +## Example ```go package main import ( + "context" "log" + "net/http" "time" "golift.io/qbit" ) func main() { - config := &qbit.Config{ - URL: "http://localhost:8080", - User: "admin", - Pass: "qbitpassword", - } - - qbit, err := qbit.New(config) + q, err := qbit.New(context.Background(), &qbit.Config{ + URL: "http://localhost:8080", + User: "admin", + Pass: "qbitpassword", + // Optional HTTP basic auth in front of the Web UI: + // HTTPUser: "nginx", + // HTTPPass: "secret", + Client: &http.Client{Timeout: time.Minute}, + }) if err != nil { log.Fatalln("[ERROR]", err) } - xfers, err := qbit.GetXfers() + xfers, err := q.GetXfers() if err != nil { log.Fatalln("[ERROR]", err) } for _, xfer := range xfers { - log.Println(xfer.Name, xfer.Progress) + log.Println(xfer.Name, xfer.Progress, xfer.SavePath) } } ``` + +`New` logs in immediately. `NewNoAuth` skips that and logs in on the first API call +(useful when you only need the client constructed at startup). diff --git a/go.mod b/go.mod index 4e84e8e..60327bd 100644 --- a/go.mod +++ b/go.mod @@ -1,5 +1,11 @@ module golift.io/qbit -go 1.17 +go 1.25.0 -require golang.org/x/net v0.27.0 +require golang.org/x/net v0.58.0 // publicsuffix, cookiejar. + +// All of this is for the tests. +require ( + github.com/stretchr/testify v1.12.1 // assert! + go.yaml.in/yaml/v3 v3.0.5 // indirect +) diff --git a/go.sum b/go.sum index fa73826..05c7bb5 100644 --- a/go.sum +++ b/go.sum @@ -1,27 +1,6 @@ -golang.org/x/net v0.0.0-20210716203947-853a461950ff h1:j2EK/QoxYNBsXI4R7fQkkRUk8y6wnOBI+6hgPdP/6Ds= -golang.org/x/net v0.0.0-20210716203947-853a461950ff/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= -golang.org/x/net v0.4.0 h1:Q5QPcMlvfxFTAPV0+07Xz/MpK9NTXu2VDUuy0FeMfaU= -golang.org/x/net v0.4.0/go.mod h1:MBQ8lrhLObU/6UmLb4fmbmk5OcyYmqtbGd/9yIeKjEE= -golang.org/x/net v0.5.0 h1:GyT4nK/YDHSqa1c4753ouYCDajOYKTja9Xb/OHtgvSw= -golang.org/x/net v0.5.0/go.mod h1:DivGGAXEgPSlEBzxGzZI+ZLohi+xUj054jfeKui00ws= -golang.org/x/net v0.6.0 h1:L4ZwwTvKW9gr0ZMS1yrHD9GZhIuVjOBBnaKH+SPQK0Q= -golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= -golang.org/x/net v0.8.0 h1:Zrh2ngAOFYneWTAIAPethzeaQLuHwhuBkuV6ZiRnUaQ= -golang.org/x/net v0.8.0/go.mod h1:QVkue5JL9kW//ek3r6jTKnTFis1tRmNAW2P1shuFdJc= -golang.org/x/net v0.9.0 h1:aWJ/m6xSmxWBx+V0XRHTlrYrPG56jKsLdTFmsSsCzOM= -golang.org/x/net v0.9.0/go.mod h1:d48xBJpPfHeWQsugry2m+kC02ZBRGRgulfHnEXEuWns= -golang.org/x/net v0.10.0 h1:X2//UzNDwYmtCLn7To6G58Wr6f5ahEAQgKNzv9Y951M= -golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= -golang.org/x/net v0.17.0 h1:pVaXccu2ozPjCXewfr1S7xza/zcXTity9cCdXQYSjIM= -golang.org/x/net v0.17.0/go.mod h1:NxSsAGuq816PNPmqtQdLE42eU2Fs7NoRIZrHJAlaCOE= -golang.org/x/net v0.20.0 h1:aCL9BSgETF1k+blQaYUBx9hJ9LOGP3gAVemcZlf1Kpo= -golang.org/x/net v0.20.0/go.mod h1:z8BVo6PvndSri0LbOE3hAn0apkU+1YvI6E70E9jsnvY= -golang.org/x/net v0.24.0 h1:1PcaxkF854Fu3+lvBIx5SYn9wRlBzzcnHZSiaFFAb0w= -golang.org/x/net v0.24.0/go.mod h1:2Q7sJY5mzlzWjKtYUEXSlBWCdyaioyXzRB2RtU8KVE8= -golang.org/x/net v0.27.0 h1:5K3Njcw06/l2y9vpGCSdcxWOYHOUk3dVNGDXN+FvAys= -golang.org/x/net v0.27.0/go.mod h1:dDi0PyhWNoiUOrAS8uXv/vnScO4wnHQO4mj9fn/RytE= -golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= -golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= -golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +github.com/stretchr/testify v1.12.1 h1:EuwCh5fleGS7H32xRwO3wRGT7DxrDhLAT6FF8MpWDWE= +github.com/stretchr/testify v1.12.1/go.mod h1:MDEgiDPPsNp5cuIrHPPCyornHKgEVbtFUmoNlxoYthg= +go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw= +go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg= +golang.org/x/net v0.58.0 h1:ynWG7rqYi4ccpTEuPZ2QGWHktVEM9DMCj9yzDE0Q7To= +golang.org/x/net v0.58.0/go.mod h1:YwCddHnFlT7eLQqVprV19OnhLGtc5xOKgE0RyqgfWAU= diff --git a/qbit.go b/qbit.go index f0f9100..50cab79 100644 --- a/qbit.go +++ b/qbit.go @@ -8,6 +8,7 @@ import ( "context" "encoding/base64" "encoding/json" + "errors" "fmt" "io" "net/http" @@ -21,12 +22,13 @@ import ( // Package defaults. const ( - DefaultTimeout = 1 * time.Minute + DefaultTimeout = time.Minute + maxErrBody = 4096 ) // Custom errors returned by this package. var ( - ErrLoginFailed = fmt.Errorf("authentication failed") + ErrLoginFailed = errors.New("authentication failed") ) // Config is the input data needed to return a Qbit struct. @@ -104,10 +106,13 @@ type Category struct { SavePath string `json:"savePath"` } +// NewNoAuth returns a Qbit client without logging in. +// The client logs in automatically on the first request that requires it. func NewNoAuth(config *Config) (*Qbit, error) { - return newConfig(context.TODO(), config, false) + return newConfig(context.Background(), config, false) } +// New returns a Qbit client and logs in immediately. func New(ctx context.Context, config *Config) (*Qbit, error) { return newConfig(ctx, config, true) } @@ -131,7 +136,7 @@ func newConfig(ctx context.Context, config *Config, login bool) (*Qbit, error) { httpClient := config.Client if httpClient == nil { - httpClient = &http.Client{} + httpClient = &http.Client{Timeout: DefaultTimeout} } httpClient.Jar = jar @@ -146,39 +151,60 @@ func newConfig(ctx context.Context, config *Config, login bool) (*Qbit, error) { return qbit, nil } - ctx, cancel := context.WithTimeout(ctx, time.Minute) + ctx, cancel := context.WithTimeout(ctx, DefaultTimeout) defer cancel() - return qbit, qbit.login(ctx) + if err := qbit.login(ctx); err != nil { + return nil, err + } + + return qbit, nil +} + +func (q *Qbit) setAuth(req *http.Request) { + if q.auth != "" { + req.Header.Set("Authorization", q.auth) + } } -// login is called once from New(). +// login is called from New() and again if a request is rejected. func (q *Qbit) login(ctx context.Context) error { params := make(url.Values) params.Add("username", q.config.User) params.Add("password", q.config.Pass) - post := strings.NewReader(params.Encode()) - req, err := http.NewRequestWithContext(ctx, http.MethodPost, q.config.URL+"api/v2/auth/login", post) + loginURL := q.config.URL + "api/v2/auth/login" + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, loginURL, strings.NewReader(params.Encode())) if err != nil { return fmt.Errorf("creating login request: %w", err) } req.Header.Add("Content-Type", "application/x-www-form-urlencoded") + q.setAuth(req) resp, err := q.client.Do(req) if err != nil { return fmt.Errorf("login failed: %w", err) } - defer resp.Body.Close() - body, _ := io.ReadAll(resp.Body) + defer func() { _ = resp.Body.Close() }() - if resp.StatusCode != http.StatusOK || !strings.Contains(string(body), "Ok.") { - return fmt.Errorf("%w: %s: %s: %s", ErrLoginFailed, resp.Status, req.URL, string(body)) + body, err := io.ReadAll(resp.Body) + if err != nil { + return fmt.Errorf("reading login response: %w", err) } - return nil + // qBittorrent 5.2+ returns 204 No Content on success. Older versions return 200 with "Ok.". + if resp.StatusCode == http.StatusNoContent { + return nil + } + + if resp.StatusCode == http.StatusOK && strings.Contains(string(body), "Ok.") { + return nil + } + + return fmt.Errorf("%w: %s: %s: %s", ErrLoginFailed, resp.Status, req.URL, string(body)) } // SetTorrentCategory updates the category for 1 or more torrents. @@ -192,12 +218,7 @@ func (q *Qbit) SetTorrentCategoryContext(ctx context.Context, category string, t values.Set("category", category) values.Set("hashes", strings.Join(torrentHashes, "|")) - var into map[string]interface{} - if err := q.postReq(ctx, "api/v2/torrents/setCategory", values, into); err != nil { - return err - } - - return nil + return q.postReq(ctx, "api/v2/torrents/setCategory", values, nil) } // GetCategories returns all the categories in Qbit. @@ -230,15 +251,15 @@ func (q *Qbit) GetXfersContext(ctx context.Context) ([]*Xfer, error) { return xfers, nil } -func (q *Qbit) getReq(ctx context.Context, path string, into interface{}) error { +func (q *Qbit) getReq(ctx context.Context, path string, into any) error { return q.req(ctx, http.MethodGet, q.config.URL+path, nil, into, true) } -func (q *Qbit) postReq(ctx context.Context, path string, values url.Values, into interface{}) error { +func (q *Qbit) postReq(ctx context.Context, path string, values url.Values, into any) error { return q.req(ctx, http.MethodPost, q.config.URL+path, values, into, true) } -func (q *Qbit) req(ctx context.Context, method, uri string, val url.Values, into interface{}, loop bool) error { +func (q *Qbit) newRequest(ctx context.Context, method, uri string, val url.Values) (*http.Request, error) { var body io.Reader if val == nil { @@ -251,39 +272,78 @@ func (q *Qbit) req(ctx context.Context, method, uri string, val url.Values, into req, err := http.NewRequestWithContext(ctx, method, uri, body) if err != nil { - return fmt.Errorf("creating '%s' request: %w", method, err) + return nil, fmt.Errorf("creating '%s' request: %w", method, err) } if method == http.MethodPost { req.Header.Set("Content-Type", "application/x-www-form-urlencoded") } else { - val.Set("filter", "all") req.URL.RawQuery = val.Encode() } req.Header.Set("Accept", "application/json") + q.setAuth(req) - if q.auth != "" { - req.Header.Set("Authorization", q.auth) + return req, nil +} + +func (q *Qbit) req(ctx context.Context, method, uri string, val url.Values, into any, loop bool) error { + req, err := q.newRequest(ctx, method, uri, val) + if err != nil { + return err } resp, err := q.client.Do(req) if err != nil { return fmt.Errorf("%s failed: %w", method, err) } - defer resp.Body.Close() - if err := json.NewDecoder(resp.Body).Decode(into); err != nil { + defer func() { _ = resp.Body.Close() }() + + if isUnauthorized(resp.StatusCode) && loop { + _, _ = io.Copy(io.Discard, resp.Body) + if err := q.login(ctx); err != nil { return err } - if loop { // try again after logging in. - return q.req(ctx, method, uri, val, into, false) - } + return q.req(ctx, method, uri, val, into, false) + } + + return readResponse(method, resp, into) +} + +func readResponse(method string, resp *http.Response, into any) error { + if !isSuccessStatus(resp.StatusCode) { + return fmt.Errorf("%s: %s: %s", method, resp.Status, readErrBody(resp.Body)) //nolint:err113 + } + + if into == nil || resp.StatusCode == http.StatusNoContent { + _, _ = io.Copy(io.Discard, resp.Body) + return nil + } + + if err := json.NewDecoder(resp.Body).Decode(into); err != nil && !errors.Is(err, io.EOF) { return fmt.Errorf("%s: %w", resp.Status, err) } return nil } + +func readErrBody(body io.Reader) string { + buf, err := io.ReadAll(io.LimitReader(body, maxErrBody)) + if err != nil { + return err.Error() + } + + return string(buf) +} + +func isSuccessStatus(status int) bool { + return status == http.StatusOK || status == http.StatusNoContent +} + +func isUnauthorized(status int) bool { + return status == http.StatusForbidden || status == http.StatusUnauthorized +} diff --git a/qbit_test.go b/qbit_test.go new file mode 100644 index 0000000..96dd54a --- /dev/null +++ b/qbit_test.go @@ -0,0 +1,236 @@ +package qbit_test + +import ( + "context" + "encoding/base64" + "io" + "net/http" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "golift.io/qbit" +) + +func newServer(t *testing.T, handler http.HandlerFunc) *httptest.Server { + t.Helper() + + server := httptest.NewServer(handler) + t.Cleanup(server.Close) + + return server +} + +func TestNewLogin(t *testing.T) { + t.Parallel() + + server := newServer(t, func(writer http.ResponseWriter, req *http.Request) { + assert.Equal(t, http.MethodPost, req.Method) + assert.Equal(t, "/api/v2/auth/login", req.URL.Path) + require.NoError(t, req.ParseForm()) + assert.Equal(t, "admin", req.Form.Get("username")) + assert.Equal(t, "secret", req.Form.Get("password")) + assert.Equal(t, "Basic "+base64.StdEncoding.EncodeToString([]byte("httpuser:httppass")), req.Header.Get("Authorization")) + + http.SetCookie(writer, &http.Cookie{Name: "SID", Value: "cookie"}) + _, _ = writer.Write([]byte("Ok.")) + }) + + client, err := qbit.New(context.Background(), &qbit.Config{ + URL: server.URL + "/", + User: "admin", + Pass: "secret", + HTTPUser: "httpuser", + HTTPPass: "httppass", + }) + require.NoError(t, err) + require.NotNil(t, client) +} + +func TestNewLoginNoContent(t *testing.T) { + t.Parallel() + + server := newServer(t, func(writer http.ResponseWriter, req *http.Request) { + assert.Equal(t, "/api/v2/auth/login", req.URL.Path) + http.SetCookie(writer, &http.Cookie{Name: "QBT_SID_8080", Value: "cookie"}) + writer.WriteHeader(http.StatusNoContent) + }) + + client, err := qbit.New(context.Background(), &qbit.Config{ + URL: server.URL, + User: "admin", + Pass: "secret", + }) + require.NoError(t, err) + require.NotNil(t, client) +} + +func TestNewLoginFailed(t *testing.T) { + t.Parallel() + + server := newServer(t, func(writer http.ResponseWriter, _ *http.Request) { + writer.WriteHeader(http.StatusForbidden) + _, _ = writer.Write([]byte("Fails.")) + }) + + client, err := qbit.New(context.Background(), &qbit.Config{ + URL: server.URL, + User: "admin", + Pass: "wrong", + }) + require.ErrorIs(t, err, qbit.ErrLoginFailed) + assert.Nil(t, client) +} + +func TestNewLoginFailsBody(t *testing.T) { + t.Parallel() + + server := newServer(t, func(writer http.ResponseWriter, _ *http.Request) { + _, _ = writer.Write([]byte("Fails.")) + }) + + client, err := qbit.New(context.Background(), &qbit.Config{ + URL: server.URL, + User: "admin", + Pass: "wrong", + }) + require.ErrorIs(t, err, qbit.ErrLoginFailed) + assert.Nil(t, client) +} + +func TestGetXfers(t *testing.T) { + t.Parallel() + + server := newServer(t, func(writer http.ResponseWriter, req *http.Request) { + switch req.URL.Path { + case "/api/v2/auth/login": + _, _ = writer.Write([]byte("Ok.")) + case "/api/v2/torrents/info": + assert.Equal(t, http.MethodGet, req.Method) + assert.Empty(t, req.URL.RawQuery) + _, _ = writer.Write([]byte(`[{"name":"ubuntu.iso","progress":1,"hash":"abc","root_path":"/downloads/ubuntu"}]`)) + default: + t.Errorf("unexpected path: %s", req.URL.Path) + } + }) + + client, err := qbit.New(context.Background(), &qbit.Config{URL: server.URL, User: "admin", Pass: "secret"}) + require.NoError(t, err) + + xfers, err := client.GetXfers() + require.NoError(t, err) + require.Len(t, xfers, 1) + assert.Equal(t, "ubuntu.iso", xfers[0].Name) + assert.Equal(t, 1.0, xfers[0].Progress) + assert.Equal(t, "abc", xfers[0].Hash) + assert.Equal(t, "/downloads/ubuntu", xfers[0].RootPath) +} + +func TestGetXfersRelogin(t *testing.T) { + t.Parallel() + + loggedIn := false + server := newServer(t, func(writer http.ResponseWriter, req *http.Request) { + switch req.URL.Path { + case "/api/v2/auth/login": + loggedIn = true + writer.WriteHeader(http.StatusNoContent) + case "/api/v2/torrents/info": + if !loggedIn { + writer.WriteHeader(http.StatusForbidden) + _, _ = writer.Write([]byte("Forbidden")) + + return + } + + _, _ = writer.Write([]byte(`[]`)) + default: + t.Errorf("unexpected path: %s", req.URL.Path) + } + }) + + client, err := qbit.NewNoAuth(&qbit.Config{URL: server.URL, User: "admin", Pass: "secret"}) + require.NoError(t, err) + + xfers, err := client.GetXfersContext(context.Background()) + require.NoError(t, err) + assert.Empty(t, xfers) + assert.True(t, loggedIn) +} + +func TestGetXfersStillForbidden(t *testing.T) { + t.Parallel() + + logins := 0 + server := newServer(t, func(writer http.ResponseWriter, req *http.Request) { + switch req.URL.Path { + case "/api/v2/auth/login": + logins++ + writer.WriteHeader(http.StatusNoContent) + case "/api/v2/torrents/info": + writer.WriteHeader(http.StatusForbidden) + _, _ = writer.Write([]byte("Forbidden")) + default: + t.Errorf("unexpected path: %s", req.URL.Path) + } + }) + + client, err := qbit.NewNoAuth(&qbit.Config{URL: server.URL, User: "admin", Pass: "secret"}) + require.NoError(t, err) + + xfers, err := client.GetXfers() + require.Error(t, err) + assert.Nil(t, xfers) + assert.Equal(t, 1, logins) +} + +func TestGetCategories(t *testing.T) { + t.Parallel() + + server := newServer(t, func(writer http.ResponseWriter, req *http.Request) { + switch req.URL.Path { + case "/api/v2/auth/login": + _, _ = writer.Write([]byte("Ok.")) + case "/api/v2/torrents/categories": + assert.Equal(t, http.MethodGet, req.Method) + _, _ = writer.Write([]byte(`{"tv":{"name":"tv","savePath":"/data/tv"}}`)) + default: + t.Errorf("unexpected path: %s", req.URL.Path) + } + }) + + client, err := qbit.New(context.Background(), &qbit.Config{URL: server.URL}) + require.NoError(t, err) + + cats, err := client.GetCategories() + require.NoError(t, err) + require.Contains(t, cats, "tv") + assert.Equal(t, "tv", cats["tv"].Name) + assert.Equal(t, "/data/tv", cats["tv"].SavePath) +} + +func TestSetTorrentCategory(t *testing.T) { + t.Parallel() + + server := newServer(t, func(writer http.ResponseWriter, req *http.Request) { + switch req.URL.Path { + case "/api/v2/auth/login": + _, _ = writer.Write([]byte("Ok.")) + case "/api/v2/torrents/setCategory": + assert.Equal(t, http.MethodPost, req.Method) + body, err := io.ReadAll(req.Body) + require.NoError(t, err) + assert.Contains(t, string(body), "category=movies") + assert.Contains(t, string(body), "hashes=aaa%7Cbbb") + // qBittorrent 5.2+ returns 204 with an empty body on success. + writer.WriteHeader(http.StatusNoContent) + default: + t.Errorf("unexpected path: %s", req.URL.Path) + } + }) + + client, err := qbit.New(context.Background(), &qbit.Config{URL: server.URL}) + require.NoError(t, err) + require.NoError(t, client.SetTorrentCategory("movies", "aaa", "bbb")) +}