From 21e1eacd3d117d1411aa707a2bd174d338c74d77 Mon Sep 17 00:00:00 2001 From: David Newhall II Date: Wed, 2 Sep 2026 00:37:07 -0700 Subject: [PATCH 1/2] Add qBittorrent alternative speed-limit get/toggle/set APIs. Notifiarr needs turtle-mode on/off when someone is watching remotely; qBit only exposes get+toggle. Co-authored-by: Cursor --- README.md | 3 ++- qbit.go | 46 +++++++++++++++++++++++++++++++ qbit_test.go | 76 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 124 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index a9b4418..08ecb54 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,8 @@ Go library to interact with the [qBittorrent](https://github.com/qbittorrent/qBittorrent) Web API. 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 +listing categories, setting a torrent category, and toggling alternative (turtle) speed +limits. If you need more of the API, please [open an issue](https://github.com/golift/qbit/issues/new) or a pull request. This library is used by [Notifiarr](https://github.com/Notifiarr/notifiarr/). diff --git a/qbit.go b/qbit.go index 50cab79..f8db51f 100644 --- a/qbit.go +++ b/qbit.go @@ -236,6 +236,52 @@ func (q *Qbit) GetCategoriesContext(ctx context.Context) (map[string]*Category, return cats, nil } +// SpeedLimitsMode returns true when alternative (turtle) speed limits are enabled. +func (q *Qbit) SpeedLimitsMode() (bool, error) { + return q.SpeedLimitsModeContext(context.Background()) +} + +// SpeedLimitsModeContext returns true when alternative (turtle) speed limits are enabled. +// qBittorrent returns a JSON number: 0 (regular limits) or 1 (alternative limits). +func (q *Qbit) SpeedLimitsModeContext(ctx context.Context) (bool, error) { + var mode int + if err := q.getReq(ctx, "api/v2/transfer/speedLimitsMode", &mode); err != nil { + return false, err + } + + return mode != 0, nil +} + +// ToggleSpeedLimitsMode toggles alternative (turtle) speed limits on or off. +func (q *Qbit) ToggleSpeedLimitsMode() error { + return q.ToggleSpeedLimitsModeContext(context.Background()) +} + +// ToggleSpeedLimitsModeContext toggles alternative (turtle) speed limits on or off. +func (q *Qbit) ToggleSpeedLimitsModeContext(ctx context.Context) error { + return q.postReq(ctx, "api/v2/transfer/toggleSpeedLimitsMode", nil, nil) +} + +// SetSpeedLimitsMode enables or disables alternative (turtle) speed limits. +// qBittorrent has no set-mode endpoint, so this gets the current mode and toggles if needed. +func (q *Qbit) SetSpeedLimitsMode(enabled bool) error { + return q.SetSpeedLimitsModeContext(context.Background(), enabled) +} + +// SetSpeedLimitsModeContext enables or disables alternative (turtle) speed limits. +func (q *Qbit) SetSpeedLimitsModeContext(ctx context.Context, enabled bool) error { + current, err := q.SpeedLimitsModeContext(ctx) + if err != nil { + return err + } + + if current == enabled { + return nil + } + + return q.ToggleSpeedLimitsModeContext(ctx) +} + // GetXfers returns data about all transfers/downloads in the Qbit client. func (q *Qbit) GetXfers() ([]*Xfer, error) { return q.GetXfersContext(context.Background()) diff --git a/qbit_test.go b/qbit_test.go index 96dd54a..85f22fb 100644 --- a/qbit_test.go +++ b/qbit_test.go @@ -6,6 +6,7 @@ import ( "io" "net/http" "net/http/httptest" + "strconv" "testing" "github.com/stretchr/testify/assert" @@ -210,6 +211,81 @@ func TestGetCategories(t *testing.T) { assert.Equal(t, "/data/tv", cats["tv"].SavePath) } +func TestSpeedLimitsMode(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/transfer/speedLimitsMode": + assert.Equal(t, http.MethodGet, req.Method) + _, _ = writer.Write([]byte(`1`)) + default: + t.Errorf("unexpected path: %s", req.URL.Path) + } + }) + + client, err := qbit.New(context.Background(), &qbit.Config{URL: server.URL}) + require.NoError(t, err) + + on, err := client.SpeedLimitsMode() + require.NoError(t, err) + assert.True(t, on) +} + +func TestToggleSpeedLimitsMode(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/transfer/toggleSpeedLimitsMode": + assert.Equal(t, http.MethodPost, req.Method) + 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.ToggleSpeedLimitsMode()) +} + +func TestSetSpeedLimitsMode(t *testing.T) { + t.Parallel() + + toggles := 0 + mode := 0 + 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/transfer/speedLimitsMode": + assert.Equal(t, http.MethodGet, req.Method) + _, _ = writer.Write([]byte(strconv.Itoa(mode))) + case "/api/v2/transfer/toggleSpeedLimitsMode": + assert.Equal(t, http.MethodPost, req.Method) + toggles++ + mode = 1 - mode + 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.SetSpeedLimitsMode(false)) + assert.Equal(t, 0, toggles) + require.NoError(t, client.SetSpeedLimitsMode(true)) + assert.Equal(t, 1, toggles) + require.NoError(t, client.SetSpeedLimitsModeContext(context.Background(), true)) + assert.Equal(t, 1, toggles) +} + func TestSetTorrentCategory(t *testing.T) { t.Parallel() From e9ed35073734f9525546ae745142ba446ba2fa5f Mon Sep 17 00:00:00 2001 From: David Newhall II Date: Wed, 2 Sep 2026 00:59:16 -0700 Subject: [PATCH 2/2] Serialize turtle-mode get/toggle so concurrent setters cannot double-flip. qBittorrent only exposes toggle; two SetSpeedLimitsMode calls that both read the same mode would both toggle and leave the opposite state. Co-authored-by: Cursor --- qbit.go | 22 +++++++++++++++++---- qbit_test.go | 55 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 73 insertions(+), 4 deletions(-) diff --git a/qbit.go b/qbit.go index f8db51f..2fbec0e 100644 --- a/qbit.go +++ b/qbit.go @@ -15,6 +15,7 @@ import ( "net/http/cookiejar" "net/url" "strings" + "sync" "time" "golang.org/x/net/publicsuffix" @@ -44,9 +45,10 @@ type Config struct { // Qbit is what you get in return for passing in a valid Config to New(). type Qbit struct { - config *Config - auth string - client *http.Client + speedLimits sync.Mutex + config *Config + auth string + client *http.Client } // Xfer is a transfer from the torrents/info endpoint. @@ -259,6 +261,13 @@ func (q *Qbit) ToggleSpeedLimitsMode() error { // ToggleSpeedLimitsModeContext toggles alternative (turtle) speed limits on or off. func (q *Qbit) ToggleSpeedLimitsModeContext(ctx context.Context) error { + q.speedLimits.Lock() + defer q.speedLimits.Unlock() + + return q.toggleSpeedLimitsMode(ctx) +} + +func (q *Qbit) toggleSpeedLimitsMode(ctx context.Context) error { return q.postReq(ctx, "api/v2/transfer/toggleSpeedLimitsMode", nil, nil) } @@ -269,7 +278,12 @@ func (q *Qbit) SetSpeedLimitsMode(enabled bool) error { } // SetSpeedLimitsModeContext enables or disables alternative (turtle) speed limits. +// Get-then-toggle is serialized with ToggleSpeedLimitsMode so concurrent setters cannot both +// read the same mode and flip it twice, leaving qBittorrent in the opposite state. func (q *Qbit) SetSpeedLimitsModeContext(ctx context.Context, enabled bool) error { + q.speedLimits.Lock() + defer q.speedLimits.Unlock() + current, err := q.SpeedLimitsModeContext(ctx) if err != nil { return err @@ -279,7 +293,7 @@ func (q *Qbit) SetSpeedLimitsModeContext(ctx context.Context, enabled bool) erro return nil } - return q.ToggleSpeedLimitsModeContext(ctx) + return q.toggleSpeedLimitsMode(ctx) } // GetXfers returns data about all transfers/downloads in the Qbit client. diff --git a/qbit_test.go b/qbit_test.go index 85f22fb..62cc880 100644 --- a/qbit_test.go +++ b/qbit_test.go @@ -7,7 +7,9 @@ import ( "net/http" "net/http/httptest" "strconv" + "sync" "testing" + "time" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -286,6 +288,59 @@ func TestSetSpeedLimitsMode(t *testing.T) { assert.Equal(t, 1, toggles) } +func TestSetSpeedLimitsModeConcurrent(t *testing.T) { + t.Parallel() + + var ( + mu sync.Mutex + mode int + toggles int + ) + + 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/transfer/speedLimitsMode": + mu.Lock() + cur := mode + mu.Unlock() + time.Sleep(time.Millisecond) + _, _ = writer.Write([]byte(strconv.Itoa(cur))) + case "/api/v2/transfer/toggleSpeedLimitsMode": + mu.Lock() + toggles++ + mode = 1 - mode + mu.Unlock() + 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) + + const callers = 20 + + var wg sync.WaitGroup + wg.Add(callers) + + for range callers { + go func() { + defer wg.Done() + assert.NoError(t, client.SetSpeedLimitsMode(true)) + }() + } + + wg.Wait() + + mu.Lock() + defer mu.Unlock() + assert.Equal(t, 1, toggles) + assert.Equal(t, 1, mode) +} + func TestSetTorrentCategory(t *testing.T) { t.Parallel()