From c59969966c6e6c6330877d64fa8919369f7bb6f8 Mon Sep 17 00:00:00 2001 From: David Newhall II Date: Thu, 20 Aug 2026 00:03:30 -0700 Subject: [PATCH 1/4] Refresh qbit for current Go, CI, and lint, and cover the small API with tests. Co-authored-by: Cursor --- .github/renovate.json | 11 +- .github/workflows/codetests.yml | 51 ++++++---- .golangci.yml | 62 ++++++++--- LICENSE | 2 +- README.md | 49 ++++++--- go.mod | 10 +- go.sum | 33 ++---- qbit.go | 98 +++++++++++++----- qbit_test.go | 175 ++++++++++++++++++++++++++++++++ 9 files changed, 371 insertions(+), 120 deletions(-) create mode 100644 qbit_test.go 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..0c9cfd9 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -1,19 +1,51 @@ +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 + - 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..a9f4f33 100644 --- a/README.md +++ b/README.md @@ -1,44 +1,61 @@ -# 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/). + +## 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..7eae834 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,12 @@ import ( // Package defaults. const ( - DefaultTimeout = 1 * time.Minute + DefaultTimeout = time.Minute ) // 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 +105,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 +135,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,33 +150,49 @@ 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 } -// login is called once from New(). +func (q *Qbit) setAuth(req *http.Request) { + if q.auth != "" { + req.Header.Set("Authorization", q.auth) + } +} + +// 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() }() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return fmt.Errorf("reading login response: %w", err) + } if resp.StatusCode != http.StatusOK || !strings.Contains(string(body), "Ok.") { return fmt.Errorf("%w: %s: %s: %s", ErrLoginFailed, resp.Status, req.URL, string(body)) @@ -192,12 +212,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 +245,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,29 +266,40 @@ 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() }() + + respBody, err := io.ReadAll(resp.Body) + if err != nil { + return fmt.Errorf("reading '%s' response: %w", method, err) + } + + if isUnauthorized(resp.StatusCode) { if err := q.login(ctx); err != nil { return err } @@ -281,8 +307,26 @@ func (q *Qbit) req(ctx context.Context, method, uri string, val url.Values, into if loop { // try again after logging in. return q.req(ctx, method, uri, val, into, false) } + } + + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("%s: %s: %s", method, resp.Status, string(respBody)) //nolint:err113 + } + + return decodeBody(resp.Status, respBody, into) +} + +func isUnauthorized(status int) bool { + return status == http.StatusForbidden || status == http.StatusUnauthorized +} + +func decodeBody(status string, body []byte, into any) error { + if into == nil || len(bytes.TrimSpace(body)) == 0 { + return nil + } - return fmt.Errorf("%s: %w", resp.Status, err) + if err := json.Unmarshal(body, into); err != nil { + return fmt.Errorf("%s: %w", status, err) } return nil diff --git a/qbit_test.go b/qbit_test.go new file mode 100644 index 0000000..0ba0dd4 --- /dev/null +++ b/qbit_test.go @@ -0,0 +1,175 @@ +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 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 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.Write([]byte("Ok.")) + 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 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 returns an empty body on success. + 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")) +} From 2febe304d6fe995c9df443b81fdc0e1cbbba98ea Mon Sep 17 00:00:00 2001 From: David Newhall II Date: Thu, 20 Aug 2026 00:13:03 -0700 Subject: [PATCH 2/4] Accept qBittorrent 5.2 empty 204 login and API responses. Co-authored-by: Cursor --- README.md | 3 +++ qbit.go | 17 +++++++++++++---- qbit_test.go | 39 +++++++++++++++++++++++++++++++++++++-- 3 files changed, 53 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index a9f4f33..a9b4418 100644 --- a/README.md +++ b/README.md @@ -12,6 +12,9 @@ listing categories, and setting a torrent category. If you need more of the API, 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 diff --git a/qbit.go b/qbit.go index 7eae834..46f8a88 100644 --- a/qbit.go +++ b/qbit.go @@ -194,11 +194,16 @@ func (q *Qbit) login(ctx context.Context) error { return fmt.Errorf("reading login response: %w", err) } - if resp.StatusCode != http.StatusOK || !strings.Contains(string(body), "Ok.") { - return fmt.Errorf("%w: %s: %s: %s", ErrLoginFailed, resp.Status, req.URL, string(body)) + // qBittorrent 5.2+ returns 204 No Content on success. Older versions return 200 with "Ok.". + if resp.StatusCode == http.StatusNoContent { + return nil } - 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. @@ -309,13 +314,17 @@ func (q *Qbit) req(ctx context.Context, method, uri string, val url.Values, into } } - if resp.StatusCode != http.StatusOK { + if !isSuccessStatus(resp.StatusCode) { return fmt.Errorf("%s: %s: %s", method, resp.Status, string(respBody)) //nolint:err113 } return decodeBody(resp.Status, respBody, into) } +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 index 0ba0dd4..c47af49 100644 --- a/qbit_test.go +++ b/qbit_test.go @@ -48,6 +48,24 @@ func TestNewLogin(t *testing.T) { 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() @@ -65,6 +83,22 @@ func TestNewLoginFailed(t *testing.T) { 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() @@ -101,7 +135,7 @@ func TestGetXfersRelogin(t *testing.T) { switch req.URL.Path { case "/api/v2/auth/login": loggedIn = true - _, _ = writer.Write([]byte("Ok.")) + writer.WriteHeader(http.StatusNoContent) case "/api/v2/torrents/info": if !loggedIn { writer.WriteHeader(http.StatusForbidden) @@ -163,7 +197,8 @@ func TestSetTorrentCategory(t *testing.T) { require.NoError(t, err) assert.Contains(t, string(body), "category=movies") assert.Contains(t, string(body), "hashes=aaa%7Cbbb") - // qBittorrent returns an empty body on success. + // qBittorrent 5.2+ returns 204 with an empty body on success. + writer.WriteHeader(http.StatusNoContent) default: t.Errorf("unexpected path: %s", req.URL.Path) } From da2fa0bec793d4625559cd71d24f4b80713dfe71 Mon Sep 17 00:00:00 2001 From: David Newhall II Date: Thu, 20 Aug 2026 00:21:36 -0700 Subject: [PATCH 3/4] Only re-login once on 401/403, and disable wsl_v5. Co-authored-by: Cursor --- .golangci.yml | 1 + qbit.go | 6 ++---- qbit_test.go | 26 ++++++++++++++++++++++++++ 3 files changed, 29 insertions(+), 4 deletions(-) diff --git a/.golangci.yml b/.golangci.yml index 0c9cfd9..276fd15 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -10,6 +10,7 @@ linters: - tagliatelle - noinlineerr - wsl + - wsl_v5 - funcorder - gomodguard settings: diff --git a/qbit.go b/qbit.go index 46f8a88..1224fa8 100644 --- a/qbit.go +++ b/qbit.go @@ -304,14 +304,12 @@ func (q *Qbit) req(ctx context.Context, method, uri string, val url.Values, into return fmt.Errorf("reading '%s' response: %w", method, err) } - if isUnauthorized(resp.StatusCode) { + if isUnauthorized(resp.StatusCode) && loop { 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) } if !isSuccessStatus(resp.StatusCode) { diff --git a/qbit_test.go b/qbit_test.go index c47af49..96dd54a 100644 --- a/qbit_test.go +++ b/qbit_test.go @@ -159,6 +159,32 @@ func TestGetXfersRelogin(t *testing.T) { 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() From 950f8d9a712325a8e2f1ab96af839bf294bfaf8a Mon Sep 17 00:00:00 2001 From: David Newhall II Date: Thu, 20 Aug 2026 00:26:34 -0700 Subject: [PATCH 4/4] Stream JSON for successful API responses instead of buffering the whole body. Co-authored-by: Cursor --- qbit.go | 47 ++++++++++++++++++++++++++++------------------- 1 file changed, 28 insertions(+), 19 deletions(-) diff --git a/qbit.go b/qbit.go index 1224fa8..50cab79 100644 --- a/qbit.go +++ b/qbit.go @@ -23,6 +23,7 @@ import ( // Package defaults. const ( DefaultTimeout = time.Minute + maxErrBody = 4096 ) // Custom errors returned by this package. @@ -299,12 +300,9 @@ func (q *Qbit) req(ctx context.Context, method, uri string, val url.Values, into defer func() { _ = resp.Body.Close() }() - respBody, err := io.ReadAll(resp.Body) - if err != nil { - return fmt.Errorf("reading '%s' response: %w", method, err) - } - if isUnauthorized(resp.StatusCode) && loop { + _, _ = io.Copy(io.Discard, resp.Body) + if err := q.login(ctx); err != nil { return err } @@ -312,11 +310,34 @@ func (q *Qbit) req(ctx context.Context, method, uri string, val url.Values, into 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, string(respBody)) //nolint:err113 + 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 decodeBody(resp.Status, respBody, into) + return string(buf) } func isSuccessStatus(status int) bool { @@ -326,15 +347,3 @@ func isSuccessStatus(status int) bool { func isUnauthorized(status int) bool { return status == http.StatusForbidden || status == http.StatusUnauthorized } - -func decodeBody(status string, body []byte, into any) error { - if into == nil || len(bytes.TrimSpace(body)) == 0 { - return nil - } - - if err := json.Unmarshal(body, into); err != nil { - return fmt.Errorf("%s: %w", status, err) - } - - return nil -}