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..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. @@ -236,6 +238,64 @@ 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 { + 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) +} + +// 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. +// 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 + } + + if current == enabled { + return nil + } + + return q.toggleSpeedLimitsMode(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..62cc880 100644 --- a/qbit_test.go +++ b/qbit_test.go @@ -6,7 +6,10 @@ import ( "io" "net/http" "net/http/httptest" + "strconv" + "sync" "testing" + "time" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -210,6 +213,134 @@ 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 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()