Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/).
Expand Down
66 changes: 63 additions & 3 deletions qbit.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import (
"net/http/cookiejar"
"net/url"
"strings"
"sync"
"time"

"golang.org/x/net/publicsuffix"
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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())
Expand Down
131 changes: 131 additions & 0 deletions qbit_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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()

Expand Down
Loading