mirror of
https://github.com/idanoo/autobrr
synced 2025-07-23 00:39:13 +00:00
feat(clients): add support for qBittorrent 4.4.0+ (#558)
* refactor: move client to go-qbittorrent * refactor: move client to go-qbittorrent * feat(downloadclient): cache qbittorrent client * feat(downloadclient): update qbit * feat(downloadclient): client test and remove pkg qbit * feat(downloadclient): update pkg qbit * fix(release): method * feat(release): make GetCachedClient concurrent safe * feat(release): add additional tests for buildLegacyHost * feat(release): remove branching * chore: update pkg autobrr/go-qbittorrent to v.1.2.0
This commit is contained in:
parent
6ad4abe296
commit
29da2416ec
17 changed files with 379 additions and 1764 deletions
9
go.mod
9
go.mod
|
@ -7,6 +7,7 @@ require (
|
|||
github.com/Masterminds/squirrel v1.5.3
|
||||
github.com/anacrolix/torrent v1.46.0
|
||||
github.com/asaskevich/EventBus v0.0.0-20200907212545-49d423059eef
|
||||
github.com/autobrr/go-qbittorrent v1.2.0
|
||||
github.com/avast/retry-go v3.0.0+incompatible
|
||||
github.com/dcarbone/zadapters/zstdlog v0.3.1
|
||||
github.com/dustin/go-humanize v1.0.0
|
||||
|
@ -34,7 +35,7 @@ require (
|
|||
github.com/spf13/viper v1.13.0
|
||||
github.com/stretchr/testify v1.8.0
|
||||
golang.org/x/crypto v0.0.0-20220829220503-c86fa9a7ed90
|
||||
golang.org/x/net v0.0.0-20220909164309-bea034e7d591
|
||||
golang.org/x/net v0.2.0
|
||||
golang.org/x/sync v0.0.0-20220929204114-8fcdb60fdcc0
|
||||
golang.org/x/time v0.0.0-20220722155302-e5dcc9cfc0b9
|
||||
gopkg.in/natefinch/lumberjack.v2 v2.0.0
|
||||
|
@ -85,9 +86,9 @@ require (
|
|||
github.com/spf13/jwalterweatherman v1.1.0 // indirect
|
||||
github.com/subosito/gotenv v1.4.1 // indirect
|
||||
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4 // indirect
|
||||
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab // indirect
|
||||
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211 // indirect
|
||||
golang.org/x/text v0.3.8 // indirect
|
||||
golang.org/x/sys v0.2.0 // indirect
|
||||
golang.org/x/term v0.2.0 // indirect
|
||||
golang.org/x/text v0.4.0 // indirect
|
||||
golang.org/x/tools v0.1.12 // indirect
|
||||
gopkg.in/cenkalti/backoff.v1 v1.1.0 // indirect
|
||||
gopkg.in/ini.v1 v1.67.0 // indirect
|
||||
|
|
17
go.sum
17
go.sum
|
@ -89,6 +89,8 @@ github.com/andybalholm/cascadia v1.1.0/go.mod h1:GsXiBklL0woXo1j/WYWtSYYC4ouU9Pq
|
|||
github.com/apache/thrift v0.12.0/go.mod h1:cp2SuWMxlEZw2r+iP2GNCdIi4C1qmUzdZFSVb+bacwQ=
|
||||
github.com/asaskevich/EventBus v0.0.0-20200907212545-49d423059eef h1:2JGTg6JapxP9/R33ZaagQtAM4EkkSYnIAlOG5EI8gkM=
|
||||
github.com/asaskevich/EventBus v0.0.0-20200907212545-49d423059eef/go.mod h1:JS7hed4L1fj0hXcyEejnW57/7LCetXggd+vwrRnYeII=
|
||||
github.com/autobrr/go-qbittorrent v1.2.0 h1:hF9SNPrgaGxaKru9MQOlov4TH7f81dBnX/vkqH7d6Ls=
|
||||
github.com/autobrr/go-qbittorrent v1.2.0/go.mod h1:z88B3+O/1/3doQABErvIOOxE4hjpmIpulu6XzDG/q78=
|
||||
github.com/avast/retry-go v3.0.0+incompatible h1:4SOWQ7Qs+oroOTQOYnAHqelpCO0biHSxpiH9JdtuBj0=
|
||||
github.com/avast/retry-go v3.0.0+incompatible/go.mod h1:XtSnn+n/sHqQIpZ10K1qAevBhOOCWBLXXy3hyiqqBrY=
|
||||
github.com/benbjohnson/immutable v0.2.0/go.mod h1:uc6OHo6PN2++n98KHLxW8ef4W42ylHiQSENghE1ezxI=
|
||||
|
@ -517,8 +519,8 @@ golang.org/x/net v0.0.0-20201031054903-ff519b6c9102/go.mod h1:sp8m0HH+o8qH0wwXwY
|
|||
golang.org/x/net v0.0.0-20201209123823-ac852fbbde11/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||
golang.org/x/net v0.0.0-20201224014010-6772e930b67b/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||
golang.org/x/net v0.0.0-20220909164309-bea034e7d591 h1:D0B/7al0LLrVC8aWF4+oxpv/m8bc7ViFfVS8/gXGdqI=
|
||||
golang.org/x/net v0.0.0-20220909164309-bea034e7d591/go.mod h1:YDH+HFinaLZZlnHAfSS6ZXJJ9M9t4Dl22yv3iI2vPwk=
|
||||
golang.org/x/net v0.2.0 h1:sZfSu1wtKLGlWI4ZZayP0ck9Y73K1ynO6gqzTdBVdPU=
|
||||
golang.org/x/net v0.2.0/go.mod h1:KqCZLdyyvdV855qA2rE3GC2aiw5xGR5TEjj8smXukLY=
|
||||
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
|
||||
golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
|
||||
golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
|
||||
|
@ -586,19 +588,20 @@ golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBc
|
|||
golang.org/x/sys v0.0.0-20210927094055-39ccf1dd6fa6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20211007075335-d3039528d8ac/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220412211240-33da011f77ad/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab h1:2QkjZIsXupsJbJIdSjjUOgWK3aEtzyuh2mPt3l/CkeU=
|
||||
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.2.0 h1:ljd4t30dBnAvMZaQCevtY0xLLD0A+bRZXbgLMLU1F/A=
|
||||
golang.org/x/sys v0.2.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211 h1:JGgROgKl9N8DuW20oFS5gxc+lE67/N3FcwmBPMe7ArY=
|
||||
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||
golang.org/x/term v0.2.0 h1:z85xZCsEl7bi/KwbNADeBYoOP0++7W1ipu+aGnpwzRM=
|
||||
golang.org/x/term v0.2.0/go.mod h1:TVmDHMZPmdnySmBfhjOoOdhjzdE1h4u1VwSiw2l1Nuc=
|
||||
golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
|
||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.4/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.8 h1:nAL+RVCQ9uMn3vJZbV+MRnydTJFPf8qqY42YiA6MrqY=
|
||||
golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
|
||||
golang.org/x/text v0.4.0 h1:BrVqGRd7+k1DiOgtnFvAkoQEWQvBc25ouMJM6429SFg=
|
||||
golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
|
||||
golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
|
||||
golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
|
||||
golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
|
||||
|
|
|
@ -2,73 +2,29 @@ package action
|
|||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/dcarbone/zadapters/zstdlog"
|
||||
"github.com/rs/zerolog"
|
||||
|
||||
"github.com/autobrr/autobrr/internal/domain"
|
||||
"github.com/autobrr/autobrr/pkg/errors"
|
||||
"github.com/autobrr/autobrr/pkg/qbittorrent"
|
||||
|
||||
"github.com/autobrr/go-qbittorrent"
|
||||
)
|
||||
|
||||
const ReannounceMaxAttempts = 50
|
||||
const ReannounceInterval = 7000
|
||||
|
||||
func (s *service) qbittorrent(action domain.Action, release domain.Release) ([]string, error) {
|
||||
func (s *service) qbittorrent(ctx context.Context, action domain.Action, release domain.Release) ([]string, error) {
|
||||
s.log.Debug().Msgf("action qBittorrent: %v", action.Name)
|
||||
|
||||
// get client for action
|
||||
client, err := s.clientSvc.FindByID(context.TODO(), action.ClientID)
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "error finding client: %v", action.ClientID)
|
||||
}
|
||||
c := s.clientSvc.GetCachedClient(ctx, action.ClientID)
|
||||
|
||||
if client == nil {
|
||||
return nil, errors.New("could not find client by id: %v", action.ClientID)
|
||||
}
|
||||
|
||||
qbtSettings := qbittorrent.Settings{
|
||||
Name: client.Name,
|
||||
Hostname: client.Host,
|
||||
Port: uint(client.Port),
|
||||
Username: client.Username,
|
||||
Password: client.Password,
|
||||
TLS: client.TLS,
|
||||
TLSSkipVerify: client.TLSSkipVerify,
|
||||
}
|
||||
|
||||
// setup sub logger adapter which is compatible with *log.Logger
|
||||
qbtSettings.Log = zstdlog.NewStdLoggerWithLevel(s.log.With().Str("type", "qBittorrent").Str("client", client.Name).Logger(), zerolog.TraceLevel)
|
||||
|
||||
// only set basic auth if enabled
|
||||
if client.Settings.Basic.Auth {
|
||||
qbtSettings.BasicAuth = client.Settings.Basic.Auth
|
||||
qbtSettings.Basic.Username = client.Settings.Basic.Username
|
||||
qbtSettings.Basic.Password = client.Settings.Basic.Password
|
||||
}
|
||||
|
||||
qbt := qbittorrent.NewClient(qbtSettings)
|
||||
|
||||
// only login if we have a password
|
||||
if qbtSettings.Password != "" {
|
||||
if err = qbt.Login(); err != nil {
|
||||
return nil, errors.Wrap(err, "could not log into client: %v at %v", client.Name, client.Host)
|
||||
}
|
||||
}
|
||||
|
||||
rejections, err := s.qbittorrentCheckRulesCanDownload(action, client, qbt)
|
||||
rejections, err := s.qbittorrentCheckRulesCanDownload(ctx, action, c.Dc, c.Qbt)
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "error checking client rules: %v", action.Name)
|
||||
}
|
||||
|
||||
if rejections != nil {
|
||||
if len(rejections) > 0 {
|
||||
return rejections, nil
|
||||
}
|
||||
|
||||
if release.TorrentTmpFile == "" {
|
||||
if err := release.DownloadTorrentFile(); err != nil {
|
||||
if err := release.DownloadTorrentFileCtx(ctx); err != nil {
|
||||
return nil, errors.Wrap(err, "error downloading torrent file for release: %v", release.TorrentName)
|
||||
}
|
||||
}
|
||||
|
@ -83,38 +39,41 @@ func (s *service) qbittorrent(action domain.Action, release domain.Release) ([]s
|
|||
|
||||
s.log.Trace().Msgf("action qBittorrent options: %+v", options)
|
||||
|
||||
if err = qbt.AddTorrentFromFile(release.TorrentTmpFile, options); err != nil {
|
||||
return nil, errors.Wrap(err, "could not add torrent %v to client: %v", release.TorrentTmpFile, client.Name)
|
||||
if err = c.Qbt.AddTorrentFromFileCtx(ctx, release.TorrentTmpFile, options); err != nil {
|
||||
return nil, errors.Wrap(err, "could not add torrent %v to client: %v", release.TorrentTmpFile, c.Dc.Name)
|
||||
}
|
||||
|
||||
if !action.Paused && !action.ReAnnounceSkip && release.TorrentHash != "" {
|
||||
if err := s.reannounceTorrent(qbt, action, release.TorrentHash); err != nil {
|
||||
opts := qbittorrent.ReannounceOptions{
|
||||
Interval: int(action.ReAnnounceInterval),
|
||||
MaxAttempts: int(action.ReAnnounceMaxAttempts),
|
||||
DeleteOnFailure: action.ReAnnounceDelete,
|
||||
}
|
||||
if err := c.Qbt.ReannounceTorrentWithRetry(ctx, opts, release.TorrentHash); err != nil {
|
||||
return nil, errors.Wrap(err, "could not reannounce torrent: %v", release.TorrentHash)
|
||||
}
|
||||
}
|
||||
|
||||
s.log.Info().Msgf("torrent with hash %v successfully added to client: '%v'", release.TorrentHash, client.Name)
|
||||
s.log.Info().Msgf("torrent with hash %v successfully added to client: '%v'", release.TorrentHash, c.Dc.Name)
|
||||
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (s *service) prepareQbitOptions(action domain.Action, m domain.Macro) (map[string]string, error) {
|
||||
|
||||
opts := &qbittorrent.TorrentAddOptions{}
|
||||
|
||||
opts.Paused = false
|
||||
if action.Paused {
|
||||
opts.Paused = BoolPointer(true)
|
||||
opts.Paused = true
|
||||
}
|
||||
if action.SkipHashCheck {
|
||||
opts.SkipHashCheck = BoolPointer(true)
|
||||
opts.SkipHashCheck = true
|
||||
}
|
||||
if action.ContentLayout != "" {
|
||||
if action.ContentLayout == domain.ActionContentLayoutSubfolderCreate {
|
||||
layout := qbittorrent.ContentLayoutSubfolderCreate
|
||||
opts.ContentLayout = &layout
|
||||
opts.ContentLayout = qbittorrent.ContentLayoutSubfolderCreate
|
||||
} else if action.ContentLayout == domain.ActionContentLayoutSubfolderNone {
|
||||
layout := qbittorrent.ContentLayoutSubfolderNone
|
||||
opts.ContentLayout = &layout
|
||||
opts.ContentLayout = qbittorrent.ContentLayoutSubfolderNone
|
||||
}
|
||||
// if ORIGINAL then leave empty
|
||||
}
|
||||
|
@ -125,8 +84,8 @@ func (s *service) prepareQbitOptions(action domain.Action, m domain.Macro) (map[
|
|||
return nil, errors.Wrap(err, "could not parse savepath macro: %v", action.SavePath)
|
||||
}
|
||||
|
||||
opts.SavePath = &actionArgs
|
||||
opts.AutoTMM = BoolPointer(false)
|
||||
opts.SavePath = actionArgs
|
||||
opts.AutoTMM = false
|
||||
}
|
||||
if action.Category != "" {
|
||||
// parse and replace values in argument string before continuing
|
||||
|
@ -135,7 +94,7 @@ func (s *service) prepareQbitOptions(action domain.Action, m domain.Macro) (map[
|
|||
return nil, errors.Wrap(err, "could not parse category macro: %v", action.Category)
|
||||
}
|
||||
|
||||
opts.Category = &categoryArgs
|
||||
opts.Category = categoryArgs
|
||||
}
|
||||
if action.Tags != "" {
|
||||
// parse and replace values in argument string before continuing
|
||||
|
@ -144,34 +103,30 @@ func (s *service) prepareQbitOptions(action domain.Action, m domain.Macro) (map[
|
|||
return nil, errors.Wrap(err, "could not parse tags macro: %v", action.Tags)
|
||||
}
|
||||
|
||||
opts.Tags = &tagsArgs
|
||||
opts.Tags = tagsArgs
|
||||
}
|
||||
if action.LimitUploadSpeed > 0 {
|
||||
opts.LimitUploadSpeed = &action.LimitUploadSpeed
|
||||
opts.LimitUploadSpeed = action.LimitUploadSpeed
|
||||
}
|
||||
if action.LimitDownloadSpeed > 0 {
|
||||
opts.LimitDownloadSpeed = &action.LimitDownloadSpeed
|
||||
opts.LimitDownloadSpeed = action.LimitDownloadSpeed
|
||||
}
|
||||
if action.LimitRatio > 0 {
|
||||
opts.LimitRatio = &action.LimitRatio
|
||||
opts.LimitRatio = action.LimitRatio
|
||||
}
|
||||
if action.LimitSeedTime > 0 {
|
||||
opts.LimitSeedTime = &action.LimitSeedTime
|
||||
opts.LimitSeedTime = action.LimitSeedTime
|
||||
}
|
||||
|
||||
return opts.Prepare(), nil
|
||||
}
|
||||
|
||||
func BoolPointer(b bool) *bool {
|
||||
return &b
|
||||
}
|
||||
|
||||
func (s *service) qbittorrentCheckRulesCanDownload(action domain.Action, client *domain.DownloadClient, qbt *qbittorrent.Client) ([]string, error) {
|
||||
func (s *service) qbittorrentCheckRulesCanDownload(ctx context.Context, action domain.Action, client *domain.DownloadClient, qbt *qbittorrent.Client) ([]string, error) {
|
||||
s.log.Trace().Msgf("action qBittorrent: %v check rules", action.Name)
|
||||
|
||||
// check for active downloads and other rules
|
||||
if client.Settings.Rules.Enabled && !action.IgnoreRules {
|
||||
activeDownloads, err := qbt.GetTorrentsActiveDownloads()
|
||||
activeDownloads, err := qbt.GetTorrentsActiveDownloadsCtx(ctx)
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "could not fetch active downloads")
|
||||
}
|
||||
|
@ -179,11 +134,11 @@ func (s *service) qbittorrentCheckRulesCanDownload(action domain.Action, client
|
|||
// make sure it's not set to 0 by default
|
||||
if client.Settings.Rules.MaxActiveDownloads > 0 {
|
||||
|
||||
// if max active downloads reached, check speed and if lower than threshold add anyways
|
||||
// if max active downloads reached, check speed and if lower than threshold add anyway
|
||||
if len(activeDownloads) >= client.Settings.Rules.MaxActiveDownloads {
|
||||
if client.Settings.Rules.IgnoreSlowTorrents {
|
||||
// check speeds of downloads
|
||||
info, err := qbt.GetTransferInfo()
|
||||
info, err := qbt.GetTransferInfoCtx(ctx)
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "could not get transfer info")
|
||||
}
|
||||
|
@ -210,109 +165,3 @@ func (s *service) qbittorrentCheckRulesCanDownload(action domain.Action, client
|
|||
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (s *service) reannounceTorrent(qb *qbittorrent.Client, action domain.Action, hash string) error {
|
||||
announceOK := false
|
||||
attempts := 0
|
||||
|
||||
interval := ReannounceInterval
|
||||
if action.ReAnnounceInterval > 0 {
|
||||
interval = int(action.ReAnnounceInterval)
|
||||
}
|
||||
|
||||
maxAttempts := ReannounceMaxAttempts
|
||||
if action.ReAnnounceMaxAttempts > 0 {
|
||||
maxAttempts = int(action.ReAnnounceMaxAttempts)
|
||||
}
|
||||
|
||||
for attempts < maxAttempts {
|
||||
s.log.Debug().Msgf("qBittorrent - run re-announce %v attempt: %v", hash, attempts)
|
||||
|
||||
// add delay for next run
|
||||
time.Sleep(time.Duration(interval) * time.Second)
|
||||
|
||||
trackers, err := qb.GetTorrentTrackers(hash)
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "could not get trackers for torrent with hash: %v", hash)
|
||||
}
|
||||
|
||||
if trackers == nil {
|
||||
attempts++
|
||||
continue
|
||||
}
|
||||
|
||||
s.log.Trace().Msgf("qBittorrent - run re-announce %v attempt: %v trackers (%+v)", hash, attempts, trackers)
|
||||
|
||||
// check if status not working or something else
|
||||
working := isTrackerStatusOK(trackers)
|
||||
if working {
|
||||
s.log.Debug().Msgf("qBittorrent - re-announce for %v OK", hash)
|
||||
|
||||
announceOK = true
|
||||
|
||||
// if working lets return
|
||||
return nil
|
||||
}
|
||||
|
||||
s.log.Trace().Msgf("qBittorrent - not working yet, lets re-announce %v attempt: %v", hash, attempts)
|
||||
err = qb.ReAnnounceTorrents([]string{hash})
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "could not re-announce torrent with hash: %v", hash)
|
||||
}
|
||||
|
||||
attempts++
|
||||
}
|
||||
|
||||
// delete on failure to reannounce
|
||||
if !announceOK && action.ReAnnounceDelete {
|
||||
s.log.Debug().Msgf("qBittorrent - re-announce for %v took too long, deleting torrent", hash)
|
||||
|
||||
err := qb.DeleteTorrents([]string{hash}, false)
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "could not delete torrent with hash: %v", hash)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Check if status not working or something else
|
||||
// https://github.com/qbittorrent/qBittorrent/wiki/WebUI-API-(qBittorrent-4.1)#get-torrent-trackers
|
||||
//
|
||||
// 0 Tracker is disabled (used for DHT, PeX, and LSD)
|
||||
// 1 Tracker has not been contacted yet
|
||||
// 2 Tracker has been contacted and is working
|
||||
// 3 Tracker is updating
|
||||
// 4 Tracker has been contacted, but it is not working (or doesn't send proper replies)
|
||||
func isTrackerStatusOK(trackers []qbittorrent.TorrentTracker) bool {
|
||||
for _, tracker := range trackers {
|
||||
if tracker.Status == qbittorrent.TrackerStatusDisabled {
|
||||
continue
|
||||
}
|
||||
|
||||
// check for certain messages before the tracker status to catch ok status with unreg msg
|
||||
if isUnregistered(tracker.Message) {
|
||||
return false
|
||||
}
|
||||
|
||||
if tracker.Status == qbittorrent.TrackerStatusOK {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
func isUnregistered(msg string) bool {
|
||||
words := []string{"unregistered", "not registered", "not found", "not exist"}
|
||||
|
||||
msg = strings.ToLower(msg)
|
||||
|
||||
for _, v := range words {
|
||||
if strings.Contains(msg, v) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
|
|
@ -2,6 +2,7 @@ package action
|
|||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"io"
|
||||
"net/http"
|
||||
|
@ -14,7 +15,7 @@ import (
|
|||
"github.com/autobrr/autobrr/pkg/errors"
|
||||
)
|
||||
|
||||
func (s *service) RunAction(action *domain.Action, release domain.Release) ([]string, error) {
|
||||
func (s *service) RunAction(ctx context.Context, action *domain.Action, release domain.Release) ([]string, error) {
|
||||
|
||||
var (
|
||||
err error
|
||||
|
@ -40,13 +41,13 @@ func (s *service) RunAction(action *domain.Action, release domain.Release) ([]st
|
|||
err = s.watchFolder(*action, release)
|
||||
|
||||
case domain.ActionTypeWebhook:
|
||||
err = s.webhook(*action, release)
|
||||
err = s.webhook(ctx, *action, release)
|
||||
|
||||
case domain.ActionTypeDelugeV1, domain.ActionTypeDelugeV2:
|
||||
rejections, err = s.deluge(*action, release)
|
||||
|
||||
case domain.ActionTypeQbittorrent:
|
||||
rejections, err = s.qbittorrent(*action, release)
|
||||
rejections, err = s.qbittorrent(ctx, *action, release)
|
||||
|
||||
case domain.ActionTypeRTorrent:
|
||||
rejections, err = s.rtorrent(*action, release)
|
||||
|
@ -86,12 +87,12 @@ func (s *service) RunAction(action *domain.Action, release domain.Release) ([]st
|
|||
}
|
||||
|
||||
payload := &domain.NotificationPayload{
|
||||
Event: domain.NotificationEventPushApproved,
|
||||
ReleaseName: release.TorrentName,
|
||||
Filter: release.Filter.Name,
|
||||
Indexer: release.Indexer,
|
||||
InfoHash: release.TorrentHash,
|
||||
|
||||
Event: domain.NotificationEventPushApproved,
|
||||
ReleaseName: release.TorrentName,
|
||||
Filter: release.Filter.Name,
|
||||
Indexer: release.Indexer,
|
||||
InfoHash: release.TorrentHash,
|
||||
|
||||
Size: release.Size,
|
||||
Status: domain.ReleasePushStatusApproved,
|
||||
Action: action.Name,
|
||||
|
@ -208,10 +209,10 @@ func (s *service) watchFolder(action domain.Action, release domain.Release) erro
|
|||
return nil
|
||||
}
|
||||
|
||||
func (s *service) webhook(action domain.Action, release domain.Release) error {
|
||||
func (s *service) webhook(ctx context.Context, action domain.Action, release domain.Release) error {
|
||||
// if webhook data contains TorrentPathName or TorrentDataRawBytes, lets download the torrent file
|
||||
if release.TorrentTmpFile == "" && (strings.Contains(action.WebhookData, "TorrentPathName") || strings.Contains(action.WebhookData, "TorrentDataRawBytes")) {
|
||||
if err := release.DownloadTorrentFile(); err != nil {
|
||||
if err := release.DownloadTorrentFileCtx(ctx); err != nil {
|
||||
return errors.Wrap(err, "webhook: could not download torrent file for release: %v", release.TorrentName)
|
||||
}
|
||||
}
|
||||
|
@ -245,7 +246,7 @@ func (s *service) webhook(action domain.Action, release domain.Release) error {
|
|||
|
||||
client := http.Client{Transport: t, Timeout: 15 * time.Second}
|
||||
|
||||
req, err := http.NewRequest(http.MethodPost, action.WebhookHost, bytes.NewBufferString(dataArgs))
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, action.WebhookHost, bytes.NewBufferString(dataArgs))
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "could not build request for webhook")
|
||||
}
|
||||
|
|
|
@ -7,7 +7,6 @@ import (
|
|||
"github.com/autobrr/autobrr/internal/domain"
|
||||
"github.com/autobrr/autobrr/internal/download_client"
|
||||
"github.com/autobrr/autobrr/internal/logger"
|
||||
"github.com/autobrr/autobrr/pkg/qbittorrent"
|
||||
|
||||
"github.com/asaskevich/EventBus"
|
||||
"github.com/dcarbone/zadapters/zstdlog"
|
||||
|
@ -21,12 +20,7 @@ type Service interface {
|
|||
DeleteByFilterID(ctx context.Context, filterID int) error
|
||||
ToggleEnabled(actionID int) error
|
||||
|
||||
RunAction(action *domain.Action, release domain.Release) ([]string, error)
|
||||
}
|
||||
|
||||
type qbitKey struct {
|
||||
I int // type
|
||||
N string // name
|
||||
RunAction(ctx context.Context, action *domain.Action, release domain.Release) ([]string, error)
|
||||
}
|
||||
|
||||
type service struct {
|
||||
|
@ -35,17 +29,14 @@ type service struct {
|
|||
repo domain.ActionRepo
|
||||
clientSvc download_client.Service
|
||||
bus EventBus.Bus
|
||||
|
||||
qbitClients map[qbitKey]qbittorrent.Client
|
||||
}
|
||||
|
||||
func NewService(log logger.Logger, repo domain.ActionRepo, clientSvc download_client.Service, bus EventBus.Bus) Service {
|
||||
s := &service{
|
||||
log: log.With().Str("module", "action").Logger(),
|
||||
repo: repo,
|
||||
clientSvc: clientSvc,
|
||||
bus: bus,
|
||||
qbitClients: map[qbitKey]qbittorrent.Client{},
|
||||
log: log.With().Str("module", "action").Logger(),
|
||||
repo: repo,
|
||||
clientSvc: clientSvc,
|
||||
bus: bus,
|
||||
}
|
||||
|
||||
s.subLogger = zstdlog.NewStdLoggerWithLevel(s.log.With().Logger(), zerolog.TraceLevel)
|
||||
|
|
|
@ -1,6 +1,12 @@
|
|||
package domain
|
||||
|
||||
import "context"
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/url"
|
||||
|
||||
"github.com/autobrr/go-qbittorrent"
|
||||
)
|
||||
|
||||
type DownloadClientRepo interface {
|
||||
List(ctx context.Context) ([]DownloadClient, error)
|
||||
|
@ -24,6 +30,11 @@ type DownloadClient struct {
|
|||
Settings DownloadClientSettings `json:"settings,omitempty"`
|
||||
}
|
||||
|
||||
type DownloadClientCached struct {
|
||||
Dc *DownloadClient
|
||||
Qbt *qbittorrent.Client
|
||||
}
|
||||
|
||||
type DownloadClientSettings struct {
|
||||
APIKey string `json:"apikey,omitempty"`
|
||||
Basic BasicAuth `json:"basic,omitempty"`
|
||||
|
@ -57,3 +68,48 @@ const (
|
|||
DownloadClientTypeWhisparr DownloadClientType = "WHISPARR"
|
||||
DownloadClientTypeReadarr DownloadClientType = "READARR"
|
||||
)
|
||||
|
||||
func (c DownloadClient) BuildLegacyHost() string {
|
||||
if c.Type == DownloadClientTypeQbittorrent {
|
||||
return c.qbitBuildLegacyHost()
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// qbitBuildLegacyHost exists to support older configs
|
||||
func (c DownloadClient) qbitBuildLegacyHost() string {
|
||||
// parse url
|
||||
u, _ := url.Parse(c.Host)
|
||||
|
||||
// reset Opaque
|
||||
u.Opaque = ""
|
||||
|
||||
// set scheme
|
||||
scheme := "http"
|
||||
if c.TLS {
|
||||
scheme = "https"
|
||||
}
|
||||
u.Scheme = scheme
|
||||
|
||||
// if host is empty lets use one from settings
|
||||
if u.Host == "" {
|
||||
u.Host = c.Host
|
||||
}
|
||||
|
||||
// reset Path
|
||||
if u.Host == u.Path {
|
||||
u.Path = ""
|
||||
}
|
||||
|
||||
// handle ports
|
||||
if c.Port > 0 {
|
||||
if c.Port == 80 || c.Port == 443 {
|
||||
// skip for regular http and https
|
||||
} else {
|
||||
u.Host = fmt.Sprintf("%v:%v", u.Host, c.Port)
|
||||
}
|
||||
}
|
||||
|
||||
// make into new string and return
|
||||
return u.String()
|
||||
}
|
||||
|
|
155
internal/domain/client_test.go
Normal file
155
internal/domain/client_test.go
Normal file
|
@ -0,0 +1,155 @@
|
|||
package domain
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestDownloadClient_qbitBuildLegacyHost(t *testing.T) {
|
||||
type fields struct {
|
||||
ID int
|
||||
Name string
|
||||
Type DownloadClientType
|
||||
Enabled bool
|
||||
Host string
|
||||
Port int
|
||||
TLS bool
|
||||
TLSSkipVerify bool
|
||||
Username string
|
||||
Password string
|
||||
Settings DownloadClientSettings
|
||||
}
|
||||
tests := []struct {
|
||||
name string
|
||||
fields fields
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "build_url_1",
|
||||
fields: fields{
|
||||
Host: "https://qbit.domain.ltd",
|
||||
Port: 0,
|
||||
Username: "",
|
||||
Password: "",
|
||||
TLS: true,
|
||||
TLSSkipVerify: false,
|
||||
},
|
||||
want: "https://qbit.domain.ltd",
|
||||
},
|
||||
{
|
||||
name: "build_url_2",
|
||||
fields: fields{
|
||||
Host: "http://qbit.domain.ltd",
|
||||
Port: 0,
|
||||
Username: "",
|
||||
Password: "",
|
||||
TLS: false,
|
||||
TLSSkipVerify: false,
|
||||
},
|
||||
want: "http://qbit.domain.ltd",
|
||||
},
|
||||
{
|
||||
name: "build_url_3",
|
||||
fields: fields{
|
||||
Host: "https://qbit.domain.ltd:8080",
|
||||
Port: 0,
|
||||
Username: "",
|
||||
Password: "",
|
||||
TLS: true,
|
||||
TLSSkipVerify: false,
|
||||
},
|
||||
want: "https://qbit.domain.ltd:8080",
|
||||
},
|
||||
{
|
||||
name: "build_url_4",
|
||||
fields: fields{
|
||||
Host: "qbit.domain.ltd:8080",
|
||||
Port: 0,
|
||||
Username: "",
|
||||
Password: "",
|
||||
TLS: false,
|
||||
TLSSkipVerify: false,
|
||||
},
|
||||
want: "http://qbit.domain.ltd:8080",
|
||||
},
|
||||
{
|
||||
name: "build_url_5",
|
||||
fields: fields{
|
||||
Host: "qbit.domain.ltd",
|
||||
Port: 8080,
|
||||
Username: "",
|
||||
Password: "",
|
||||
TLS: false,
|
||||
TLSSkipVerify: false,
|
||||
},
|
||||
want: "http://qbit.domain.ltd:8080",
|
||||
},
|
||||
{
|
||||
name: "build_url_6",
|
||||
fields: fields{
|
||||
Host: "qbit.domain.ltd",
|
||||
Port: 443,
|
||||
Username: "",
|
||||
Password: "",
|
||||
TLS: true,
|
||||
TLSSkipVerify: false,
|
||||
},
|
||||
want: "https://qbit.domain.ltd",
|
||||
},
|
||||
{
|
||||
name: "build_url_7",
|
||||
fields: fields{
|
||||
Host: "qbit.domain.ltd",
|
||||
Port: 10200,
|
||||
Username: "",
|
||||
Password: "",
|
||||
TLS: false,
|
||||
TLSSkipVerify: false,
|
||||
},
|
||||
want: "http://qbit.domain.ltd:10200",
|
||||
},
|
||||
{
|
||||
name: "build_url_8",
|
||||
fields: fields{
|
||||
Host: "https://domain.ltd/qbittorrent",
|
||||
Port: 0,
|
||||
Username: "",
|
||||
Password: "",
|
||||
TLS: true,
|
||||
TLSSkipVerify: false,
|
||||
},
|
||||
want: "https://domain.ltd/qbittorrent",
|
||||
},
|
||||
{
|
||||
name: "build_url_9",
|
||||
fields: fields{
|
||||
Host: "127.0.0.1",
|
||||
Port: 8080,
|
||||
Username: "",
|
||||
Password: "",
|
||||
TLS: false,
|
||||
TLSSkipVerify: false,
|
||||
},
|
||||
want: "http://127.0.0.1:8080",
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
c := DownloadClient{
|
||||
ID: tt.fields.ID,
|
||||
Name: tt.fields.Name,
|
||||
Type: tt.fields.Type,
|
||||
Enabled: tt.fields.Enabled,
|
||||
Host: tt.fields.Host,
|
||||
Port: tt.fields.Port,
|
||||
TLS: tt.fields.TLS,
|
||||
TLSSkipVerify: tt.fields.TLSSkipVerify,
|
||||
Username: tt.fields.Username,
|
||||
Password: tt.fields.Password,
|
||||
Settings: tt.fields.Settings,
|
||||
}
|
||||
assert.Equalf(t, tt.want, c.qbitBuildLegacyHost(), "qbitBuildLegacyHost()")
|
||||
})
|
||||
}
|
||||
}
|
|
@ -273,7 +273,15 @@ func (r *Release) ParseSizeBytesString(size string) {
|
|||
r.Size = s
|
||||
}
|
||||
|
||||
func (r *Release) DownloadTorrentFileCtx(ctx context.Context) error {
|
||||
return r.downloadTorrentFile(ctx)
|
||||
}
|
||||
|
||||
func (r *Release) DownloadTorrentFile() error {
|
||||
return r.downloadTorrentFile(context.Background())
|
||||
}
|
||||
|
||||
func (r *Release) downloadTorrentFile(ctx context.Context) error {
|
||||
if r.TorrentURL == "" {
|
||||
return errors.New("download_file: url can't be empty")
|
||||
} else if r.TorrentTmpFile != "" {
|
||||
|
@ -294,7 +302,7 @@ func (r *Release) DownloadTorrentFile() error {
|
|||
Timeout: time.Second * 45,
|
||||
}
|
||||
|
||||
req, err := http.NewRequest(http.MethodGet, r.TorrentURL, nil)
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, r.TorrentURL, nil)
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "error downloading file")
|
||||
}
|
||||
|
|
|
@ -7,21 +7,21 @@ import (
|
|||
"github.com/autobrr/autobrr/internal/domain"
|
||||
"github.com/autobrr/autobrr/pkg/errors"
|
||||
"github.com/autobrr/autobrr/pkg/lidarr"
|
||||
"github.com/autobrr/autobrr/pkg/qbittorrent"
|
||||
"github.com/autobrr/autobrr/pkg/radarr"
|
||||
"github.com/autobrr/autobrr/pkg/readarr"
|
||||
"github.com/autobrr/autobrr/pkg/sonarr"
|
||||
"github.com/autobrr/autobrr/pkg/whisparr"
|
||||
"github.com/autobrr/go-qbittorrent"
|
||||
|
||||
delugeClient "github.com/gdm85/go-libdeluge"
|
||||
"github.com/hekmon/transmissionrpc/v2"
|
||||
"github.com/mrobinsn/go-rtorrent/rtorrent"
|
||||
)
|
||||
|
||||
func (s *service) testConnection(client domain.DownloadClient) error {
|
||||
func (s *service) testConnection(ctx context.Context, client domain.DownloadClient) error {
|
||||
switch client.Type {
|
||||
case domain.DownloadClientTypeQbittorrent:
|
||||
return s.testQbittorrentConnection(client)
|
||||
return s.testQbittorrentConnection(ctx, client)
|
||||
|
||||
case domain.DownloadClientTypeDelugeV1, domain.DownloadClientTypeDelugeV2:
|
||||
return s.testDelugeConnection(client)
|
||||
|
@ -51,32 +51,28 @@ func (s *service) testConnection(client domain.DownloadClient) error {
|
|||
}
|
||||
}
|
||||
|
||||
func (s *service) testQbittorrentConnection(client domain.DownloadClient) error {
|
||||
qbtSettings := qbittorrent.Settings{
|
||||
Hostname: client.Host,
|
||||
Port: uint(client.Port),
|
||||
func (s *service) testQbittorrentConnection(ctx context.Context, client domain.DownloadClient) error {
|
||||
qbtSettings := qbittorrent.Config{
|
||||
Host: client.BuildLegacyHost(),
|
||||
Username: client.Username,
|
||||
Password: client.Password,
|
||||
TLS: client.TLS,
|
||||
TLSSkipVerify: client.TLSSkipVerify,
|
||||
Log: s.subLogger,
|
||||
}
|
||||
|
||||
// only set basic auth if enabled
|
||||
if client.Settings.Basic.Auth {
|
||||
qbtSettings.BasicAuth = client.Settings.Basic.Auth
|
||||
qbtSettings.Basic.Username = client.Settings.Basic.Username
|
||||
qbtSettings.Basic.Password = client.Settings.Basic.Password
|
||||
qbtSettings.BasicUser = client.Settings.Basic.Username
|
||||
qbtSettings.BasicPass = client.Settings.Basic.Password
|
||||
}
|
||||
|
||||
qbt := qbittorrent.NewClient(qbtSettings)
|
||||
|
||||
if err := qbt.Login(); err != nil {
|
||||
if err := qbt.LoginCtx(ctx); err != nil {
|
||||
return errors.Wrap(err, "error logging into client: %v", client.Host)
|
||||
}
|
||||
|
||||
_, err := qbt.GetTorrents()
|
||||
if err != nil {
|
||||
if _, err := qbt.GetTorrentsCtx(ctx, qbittorrent.TorrentFilterOptions{Filter: qbittorrent.TorrentFilterAll}); err != nil {
|
||||
return errors.Wrap(err, "error getting torrents: %v", client.Host)
|
||||
}
|
||||
|
||||
|
|
|
@ -4,10 +4,12 @@ import (
|
|||
"context"
|
||||
"errors"
|
||||
"log"
|
||||
"sync"
|
||||
|
||||
"github.com/autobrr/autobrr/internal/domain"
|
||||
"github.com/autobrr/autobrr/internal/logger"
|
||||
|
||||
"github.com/autobrr/go-qbittorrent"
|
||||
"github.com/dcarbone/zadapters/zstdlog"
|
||||
"github.com/rs/zerolog"
|
||||
)
|
||||
|
@ -18,19 +20,27 @@ type Service interface {
|
|||
Store(ctx context.Context, client domain.DownloadClient) (*domain.DownloadClient, error)
|
||||
Update(ctx context.Context, client domain.DownloadClient) (*domain.DownloadClient, error)
|
||||
Delete(ctx context.Context, clientID int) error
|
||||
Test(client domain.DownloadClient) error
|
||||
Test(ctx context.Context, client domain.DownloadClient) error
|
||||
|
||||
GetCachedClient(ctx context.Context, clientId int32) *domain.DownloadClientCached
|
||||
}
|
||||
|
||||
type service struct {
|
||||
log zerolog.Logger
|
||||
repo domain.DownloadClientRepo
|
||||
subLogger *log.Logger
|
||||
|
||||
qbitClients map[int32]*domain.DownloadClientCached
|
||||
m sync.RWMutex
|
||||
}
|
||||
|
||||
func NewService(log logger.Logger, repo domain.DownloadClientRepo) Service {
|
||||
s := &service{
|
||||
log: log.With().Str("module", "download_client").Logger(),
|
||||
repo: repo,
|
||||
|
||||
qbitClients: map[int32]*domain.DownloadClientCached{},
|
||||
m: sync.RWMutex{},
|
||||
}
|
||||
|
||||
s.subLogger = zstdlog.NewStdLoggerWithLevel(s.log.With().Logger(), zerolog.TraceLevel)
|
||||
|
@ -91,19 +101,29 @@ func (s *service) Update(ctx context.Context, client domain.DownloadClient) (*do
|
|||
return nil, err
|
||||
}
|
||||
|
||||
if client.Type == domain.DownloadClientTypeQbittorrent {
|
||||
s.m.Lock()
|
||||
delete(s.qbitClients, int32(client.ID))
|
||||
s.m.Unlock()
|
||||
}
|
||||
|
||||
return c, err
|
||||
}
|
||||
|
||||
func (s *service) Delete(ctx context.Context, clientID int) error {
|
||||
err := s.repo.Delete(ctx, clientID)
|
||||
if err != nil {
|
||||
if err := s.repo.Delete(ctx, clientID); err != nil {
|
||||
s.log.Error().Err(err).Msgf("could not delete download client: %v", clientID)
|
||||
return err
|
||||
}
|
||||
|
||||
s.m.Lock()
|
||||
delete(s.qbitClients, int32(clientID))
|
||||
s.m.Unlock()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *service) Test(client domain.DownloadClient) error {
|
||||
func (s *service) Test(ctx context.Context, client domain.DownloadClient) error {
|
||||
// basic validation of client
|
||||
if client.Host == "" {
|
||||
return errors.New("validation error: no host")
|
||||
|
@ -112,10 +132,61 @@ func (s *service) Test(client domain.DownloadClient) error {
|
|||
}
|
||||
|
||||
// test
|
||||
if err := s.testConnection(client); err != nil {
|
||||
if err := s.testConnection(ctx, client); err != nil {
|
||||
s.log.Error().Err(err).Msg("client connection test error")
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *service) GetCachedClient(ctx context.Context, clientId int32) *domain.DownloadClientCached {
|
||||
|
||||
// check if client exists in cache
|
||||
s.m.RLock()
|
||||
cached, ok := s.qbitClients[clientId]
|
||||
s.m.RUnlock()
|
||||
|
||||
if ok {
|
||||
return cached
|
||||
}
|
||||
|
||||
// get client for action
|
||||
client, err := s.FindByID(ctx, clientId)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
if client == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
qbtSettings := qbittorrent.Config{
|
||||
Host: client.BuildLegacyHost(),
|
||||
Username: client.Username,
|
||||
Password: client.Password,
|
||||
TLSSkipVerify: client.TLSSkipVerify,
|
||||
}
|
||||
|
||||
// setup sub logger adapter which is compatible with *log.Logger
|
||||
qbtSettings.Log = zstdlog.NewStdLoggerWithLevel(s.log.With().Str("type", "qBittorrent").Str("client", client.Name).Logger(), zerolog.TraceLevel)
|
||||
|
||||
// only set basic auth if enabled
|
||||
if client.Settings.Basic.Auth {
|
||||
qbtSettings.BasicUser = client.Settings.Basic.Username
|
||||
qbtSettings.BasicPass = client.Settings.Basic.Password
|
||||
}
|
||||
|
||||
qc := &domain.DownloadClientCached{
|
||||
Dc: client,
|
||||
Qbt: qbittorrent.NewClient(qbtSettings),
|
||||
}
|
||||
|
||||
cached = qc
|
||||
|
||||
s.m.Lock()
|
||||
s.qbitClients[clientId] = cached
|
||||
s.m.Unlock()
|
||||
|
||||
return cached
|
||||
}
|
||||
|
|
|
@ -17,7 +17,7 @@ type downloadClientService interface {
|
|||
Store(ctx context.Context, client domain.DownloadClient) (*domain.DownloadClient, error)
|
||||
Update(ctx context.Context, client domain.DownloadClient) (*domain.DownloadClient, error)
|
||||
Delete(ctx context.Context, clientID int) error
|
||||
Test(client domain.DownloadClient) error
|
||||
Test(ctx context.Context, client domain.DownloadClient) error
|
||||
}
|
||||
|
||||
type downloadClientHandler struct {
|
||||
|
@ -77,8 +77,7 @@ func (h downloadClientHandler) test(w http.ResponseWriter, r *http.Request) {
|
|||
return
|
||||
}
|
||||
|
||||
err := h.service.Test(data)
|
||||
if err != nil {
|
||||
if err := h.service.Test(r.Context(), data); err != nil {
|
||||
h.encoder.Error(w, err)
|
||||
return
|
||||
}
|
||||
|
|
|
@ -86,6 +86,8 @@ func (s *service) Process(release *domain.Release) {
|
|||
return
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
// TODO check in config for "Save all releases"
|
||||
// TODO cross-seed check
|
||||
// TODO dupe checks
|
||||
|
@ -133,8 +135,7 @@ func (s *service) Process(release *domain.Release) {
|
|||
// save release here to only save those with rejections from actions instead of all releases
|
||||
if release.ID == 0 {
|
||||
release.FilterStatus = domain.ReleaseStatusFilterApproved
|
||||
err = s.Store(context.Background(), release)
|
||||
if err != nil {
|
||||
if err = s.Store(ctx, release); err != nil {
|
||||
l.Error().Err(err).Msgf("release.Process: error writing release to database: %+v", release)
|
||||
return
|
||||
}
|
||||
|
@ -166,7 +167,7 @@ func (s *service) Process(release *domain.Release) {
|
|||
continue
|
||||
}
|
||||
|
||||
rejections, err = s.actionSvc.RunAction(a, *release)
|
||||
rejections, err = s.actionSvc.RunAction(ctx, a, *release)
|
||||
if err != nil {
|
||||
l.Error().Stack().Err(err).Msgf("release.Process: error running actions for filter: %v", release.Filter.Name)
|
||||
continue
|
||||
|
|
|
@ -1,404 +0,0 @@
|
|||
package qbittorrent
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/tls"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"net/http/cookiejar"
|
||||
"net/url"
|
||||
"os"
|
||||
"path"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"golang.org/x/net/publicsuffix"
|
||||
|
||||
"github.com/autobrr/autobrr/pkg/errors"
|
||||
)
|
||||
|
||||
var (
|
||||
backoffSchedule = []time.Duration{
|
||||
5 * time.Second,
|
||||
10 * time.Second,
|
||||
20 * time.Second,
|
||||
}
|
||||
timeout = 60 * time.Second
|
||||
)
|
||||
|
||||
type Client struct {
|
||||
Name string
|
||||
settings Settings
|
||||
http *http.Client
|
||||
|
||||
log *log.Logger
|
||||
}
|
||||
|
||||
type Settings struct {
|
||||
Name string
|
||||
Hostname string
|
||||
Port uint
|
||||
Username string
|
||||
Password string
|
||||
TLS bool
|
||||
TLSSkipVerify bool
|
||||
protocol string
|
||||
BasicAuth bool
|
||||
Basic Basic
|
||||
Log *log.Logger
|
||||
}
|
||||
|
||||
type Basic struct {
|
||||
Username string
|
||||
Password string
|
||||
}
|
||||
|
||||
func NewClient(settings Settings) *Client {
|
||||
c := &Client{
|
||||
settings: settings,
|
||||
Name: settings.Name,
|
||||
log: log.New(io.Discard, "", log.LstdFlags),
|
||||
}
|
||||
|
||||
// override logger if we pass one
|
||||
if settings.Log != nil {
|
||||
c.log = settings.Log
|
||||
}
|
||||
|
||||
//store cookies in jar
|
||||
jarOptions := &cookiejar.Options{PublicSuffixList: publicsuffix.List}
|
||||
jar, err := cookiejar.New(jarOptions)
|
||||
if err != nil {
|
||||
c.log.Println("new client cookie error")
|
||||
}
|
||||
|
||||
c.http = &http.Client{
|
||||
Timeout: timeout,
|
||||
Jar: jar,
|
||||
}
|
||||
|
||||
c.settings.protocol = "http"
|
||||
if c.settings.TLS {
|
||||
c.settings.protocol = "https"
|
||||
}
|
||||
|
||||
if c.settings.TLSSkipVerify {
|
||||
//skip TLS verification
|
||||
tr := &http.Transport{
|
||||
TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
|
||||
}
|
||||
|
||||
c.http.Transport = tr
|
||||
}
|
||||
|
||||
return c
|
||||
}
|
||||
|
||||
func (c *Client) get(endpoint string, opts map[string]string) (*http.Response, error) {
|
||||
var err error
|
||||
var resp *http.Response
|
||||
|
||||
reqUrl := buildUrlOpts(c.settings, endpoint, opts)
|
||||
|
||||
req, err := http.NewRequest("GET", reqUrl, nil)
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "could not build request")
|
||||
}
|
||||
|
||||
if c.settings.BasicAuth {
|
||||
req.SetBasicAuth(c.settings.Basic.Username, c.settings.Basic.Password)
|
||||
}
|
||||
|
||||
// try request and if fail run 3 retries
|
||||
for i, backoff := range backoffSchedule {
|
||||
resp, err = c.http.Do(req)
|
||||
|
||||
// request ok, lets break out of the loop
|
||||
if err == nil {
|
||||
break
|
||||
}
|
||||
|
||||
c.log.Printf("qbit GET failed: retrying attempt %d - %v\n", i, reqUrl)
|
||||
|
||||
time.Sleep(backoff)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "error making get request: %v", reqUrl)
|
||||
}
|
||||
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
func (c *Client) post(endpoint string, opts map[string]string) (*http.Response, error) {
|
||||
// add optional parameters that the user wants
|
||||
form := url.Values{}
|
||||
if opts != nil {
|
||||
for k, v := range opts {
|
||||
form.Add(k, v)
|
||||
}
|
||||
}
|
||||
|
||||
var err error
|
||||
var resp *http.Response
|
||||
|
||||
reqUrl := buildUrl(c.settings, endpoint)
|
||||
|
||||
req, err := http.NewRequest("POST", reqUrl, strings.NewReader(form.Encode()))
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "could not build request")
|
||||
}
|
||||
|
||||
if c.settings.BasicAuth {
|
||||
req.SetBasicAuth(c.settings.Basic.Username, c.settings.Basic.Password)
|
||||
}
|
||||
|
||||
// add the content-type so qbittorrent knows what to expect
|
||||
req.Header.Add("Content-Type", "application/x-www-form-urlencoded")
|
||||
|
||||
// try request and if fail run 3 retries
|
||||
for i, backoff := range backoffSchedule {
|
||||
resp, err = c.http.Do(req)
|
||||
|
||||
// request ok, lets break out of the loop
|
||||
if err == nil {
|
||||
break
|
||||
}
|
||||
|
||||
c.log.Printf("qbit POST failed: retrying attempt %d - %v\n", i, reqUrl)
|
||||
|
||||
time.Sleep(backoff)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "error making post request: %v", reqUrl)
|
||||
}
|
||||
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
func (c *Client) postBasic(endpoint string, opts map[string]string) (*http.Response, error) {
|
||||
// add optional parameters that the user wants
|
||||
form := url.Values{}
|
||||
if opts != nil {
|
||||
for k, v := range opts {
|
||||
form.Add(k, v)
|
||||
}
|
||||
}
|
||||
|
||||
var err error
|
||||
var resp *http.Response
|
||||
|
||||
reqUrl := buildUrl(c.settings, endpoint)
|
||||
|
||||
req, err := http.NewRequest("POST", reqUrl, strings.NewReader(form.Encode()))
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "could not build request")
|
||||
}
|
||||
|
||||
if c.settings.BasicAuth {
|
||||
req.SetBasicAuth(c.settings.Basic.Username, c.settings.Basic.Password)
|
||||
}
|
||||
|
||||
// add the content-type so qbittorrent knows what to expect
|
||||
req.Header.Add("Content-Type", "application/x-www-form-urlencoded")
|
||||
|
||||
resp, err = c.http.Do(req)
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "error making post request: %v", reqUrl)
|
||||
}
|
||||
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
func (c *Client) postFile(endpoint string, fileName string, opts map[string]string) (*http.Response, error) {
|
||||
var err error
|
||||
var resp *http.Response
|
||||
|
||||
file, err := os.Open(fileName)
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "error opening file %v", fileName)
|
||||
}
|
||||
// Close the file later
|
||||
defer file.Close()
|
||||
|
||||
// Buffer to store our request body as bytes
|
||||
var requestBody bytes.Buffer
|
||||
|
||||
// Store a multipart writer
|
||||
multiPartWriter := multipart.NewWriter(&requestBody)
|
||||
|
||||
// Initialize file field
|
||||
fileWriter, err := multiPartWriter.CreateFormFile("torrents", fileName)
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "error initializing file field %v", fileName)
|
||||
}
|
||||
|
||||
// Copy the actual file content to the fields writer
|
||||
_, err = io.Copy(fileWriter, file)
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "error copy file contents to writer %v", fileName)
|
||||
}
|
||||
|
||||
// Populate other fields
|
||||
if opts != nil {
|
||||
for key, val := range opts {
|
||||
fieldWriter, err := multiPartWriter.CreateFormField(key)
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "error creating form field %v with value %v", key, val)
|
||||
}
|
||||
|
||||
_, err = fieldWriter.Write([]byte(val))
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "error writing field %v with value %v", key, val)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Close multipart writer
|
||||
multiPartWriter.Close()
|
||||
|
||||
reqUrl := buildUrl(c.settings, endpoint)
|
||||
req, err := http.NewRequest("POST", reqUrl, &requestBody)
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "error creating request %v", fileName)
|
||||
}
|
||||
|
||||
if c.settings.BasicAuth {
|
||||
req.SetBasicAuth(c.settings.Basic.Username, c.settings.Basic.Password)
|
||||
}
|
||||
|
||||
// Set correct content type
|
||||
req.Header.Set("Content-Type", multiPartWriter.FormDataContentType())
|
||||
|
||||
// try request and if fail run 3 retries
|
||||
for i, backoff := range backoffSchedule {
|
||||
resp, err = c.http.Do(req)
|
||||
|
||||
// request ok, lets break out of the loop
|
||||
if err == nil {
|
||||
break
|
||||
}
|
||||
|
||||
c.log.Printf("qbit POST file failed: retrying attempt %d - %v\n", i, reqUrl)
|
||||
|
||||
time.Sleep(backoff)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "error making post file request %v", fileName)
|
||||
}
|
||||
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
func (c *Client) setCookies(cookies []*http.Cookie) {
|
||||
cookieURL, _ := url.Parse(buildUrl(c.settings, ""))
|
||||
|
||||
c.http.Jar.SetCookies(cookieURL, cookies)
|
||||
}
|
||||
|
||||
func buildUrl(settings Settings, endpoint string) string {
|
||||
// parse url
|
||||
u, _ := url.Parse(settings.Hostname)
|
||||
|
||||
// reset Opaque
|
||||
u.Opaque = ""
|
||||
|
||||
// set scheme
|
||||
scheme := "http"
|
||||
if u.Scheme == "http" || u.Scheme == "https" {
|
||||
if settings.TLS {
|
||||
scheme = "https"
|
||||
}
|
||||
u.Scheme = scheme
|
||||
} else {
|
||||
if settings.TLS {
|
||||
scheme = "https"
|
||||
}
|
||||
u.Scheme = scheme
|
||||
}
|
||||
|
||||
// if host is empty lets use one from settings
|
||||
if u.Host == "" {
|
||||
u.Host = settings.Hostname
|
||||
}
|
||||
|
||||
// reset Path
|
||||
if u.Host == u.Path {
|
||||
u.Path = ""
|
||||
}
|
||||
|
||||
// handle ports
|
||||
if settings.Port > 0 {
|
||||
if settings.Port == 80 || settings.Port == 443 {
|
||||
// skip for regular http and https
|
||||
} else {
|
||||
u.Host = fmt.Sprintf("%v:%v", u.Host, settings.Port)
|
||||
}
|
||||
}
|
||||
|
||||
// join path
|
||||
u.Path = path.Join(u.Path, "/api/v2/", endpoint)
|
||||
|
||||
// make into new string and return
|
||||
return u.String()
|
||||
}
|
||||
|
||||
func buildUrlOpts(settings Settings, endpoint string, opts map[string]string) string {
|
||||
// parse url
|
||||
u, _ := url.Parse(settings.Hostname)
|
||||
|
||||
// reset Opaque
|
||||
u.Opaque = ""
|
||||
|
||||
// set scheme
|
||||
scheme := "http"
|
||||
if u.Scheme == "http" || u.Scheme == "https" {
|
||||
if settings.TLS {
|
||||
scheme = "https"
|
||||
}
|
||||
u.Scheme = scheme
|
||||
} else {
|
||||
if settings.TLS {
|
||||
scheme = "https"
|
||||
}
|
||||
u.Scheme = scheme
|
||||
}
|
||||
|
||||
// if host is empty lets use one from settings
|
||||
if u.Host == "" {
|
||||
u.Host = settings.Hostname
|
||||
}
|
||||
|
||||
// reset Path
|
||||
if u.Host == u.Path {
|
||||
u.Path = ""
|
||||
}
|
||||
|
||||
// handle ports
|
||||
if settings.Port > 0 {
|
||||
if settings.Port == 80 || settings.Port == 443 {
|
||||
// skip for regular http and https
|
||||
} else {
|
||||
u.Host = fmt.Sprintf("%v:%v", u.Host, settings.Port)
|
||||
}
|
||||
}
|
||||
|
||||
// add query params
|
||||
q := u.Query()
|
||||
for k, v := range opts {
|
||||
q.Set(k, v)
|
||||
}
|
||||
|
||||
u.RawQuery = q.Encode()
|
||||
|
||||
// join path
|
||||
u.Path = path.Join(u.Path, "/api/v2/", endpoint)
|
||||
|
||||
// make into new string and return
|
||||
return u.String()
|
||||
}
|
|
@ -1,135 +0,0 @@
|
|||
package qbittorrent
|
||||
|
||||
import "testing"
|
||||
|
||||
func Test_buildUrl(t *testing.T) {
|
||||
type args struct {
|
||||
settings Settings
|
||||
endpoint string
|
||||
}
|
||||
tests := []struct {
|
||||
name string
|
||||
args args
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "build_url_1",
|
||||
args: args{
|
||||
settings: Settings{
|
||||
Hostname: "https://qbit.domain.ltd",
|
||||
Port: 0,
|
||||
Username: "",
|
||||
Password: "",
|
||||
TLS: true,
|
||||
TLSSkipVerify: false,
|
||||
protocol: "",
|
||||
},
|
||||
endpoint: "auth/login",
|
||||
},
|
||||
want: "https://qbit.domain.ltd/api/v2/auth/login",
|
||||
},
|
||||
{
|
||||
name: "build_url_2",
|
||||
args: args{
|
||||
settings: Settings{
|
||||
Hostname: "http://qbit.domain.ltd",
|
||||
Port: 0,
|
||||
Username: "",
|
||||
Password: "",
|
||||
TLS: false,
|
||||
TLSSkipVerify: false,
|
||||
protocol: "",
|
||||
},
|
||||
endpoint: "/auth/login",
|
||||
},
|
||||
want: "http://qbit.domain.ltd/api/v2/auth/login",
|
||||
},
|
||||
{
|
||||
name: "build_url_3",
|
||||
args: args{
|
||||
settings: Settings{
|
||||
Hostname: "https://qbit.domain.ltd:8080",
|
||||
Port: 0,
|
||||
Username: "",
|
||||
Password: "",
|
||||
TLS: true,
|
||||
TLSSkipVerify: false,
|
||||
protocol: "",
|
||||
},
|
||||
endpoint: "/auth/login",
|
||||
},
|
||||
want: "https://qbit.domain.ltd:8080/api/v2/auth/login",
|
||||
},
|
||||
{
|
||||
name: "build_url_4",
|
||||
args: args{
|
||||
settings: Settings{
|
||||
Hostname: "qbit.domain.ltd:8080",
|
||||
Port: 0,
|
||||
Username: "",
|
||||
Password: "",
|
||||
TLS: false,
|
||||
TLSSkipVerify: false,
|
||||
protocol: "",
|
||||
},
|
||||
endpoint: "/auth/login",
|
||||
},
|
||||
want: "http://qbit.domain.ltd:8080/api/v2/auth/login",
|
||||
},
|
||||
{
|
||||
name: "build_url_5",
|
||||
args: args{
|
||||
settings: Settings{
|
||||
Hostname: "qbit.domain.ltd",
|
||||
Port: 8080,
|
||||
Username: "",
|
||||
Password: "",
|
||||
TLS: false,
|
||||
TLSSkipVerify: false,
|
||||
protocol: "",
|
||||
},
|
||||
endpoint: "/auth/login",
|
||||
},
|
||||
want: "http://qbit.domain.ltd:8080/api/v2/auth/login",
|
||||
},
|
||||
{
|
||||
name: "build_url_6",
|
||||
args: args{
|
||||
settings: Settings{
|
||||
Hostname: "qbit.domain.ltd",
|
||||
Port: 443,
|
||||
Username: "",
|
||||
Password: "",
|
||||
TLS: true,
|
||||
TLSSkipVerify: false,
|
||||
protocol: "",
|
||||
},
|
||||
endpoint: "/auth/login",
|
||||
},
|
||||
want: "https://qbit.domain.ltd/api/v2/auth/login",
|
||||
},
|
||||
{
|
||||
name: "build_url_6",
|
||||
args: args{
|
||||
settings: Settings{
|
||||
Hostname: "qbit.domain.ltd",
|
||||
Port: 10200,
|
||||
Username: "",
|
||||
Password: "",
|
||||
TLS: false,
|
||||
TLSSkipVerify: false,
|
||||
protocol: "",
|
||||
},
|
||||
endpoint: "/auth/login",
|
||||
},
|
||||
want: "http://qbit.domain.ltd:10200/api/v2/auth/login",
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := buildUrl(tt.args.settings, tt.args.endpoint); got != tt.want {
|
||||
t.Errorf("buildUrl() = %v, want %v", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
|
@ -1,316 +0,0 @@
|
|||
package qbittorrent
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
)
|
||||
|
||||
type Torrent struct {
|
||||
AddedOn int `json:"added_on"`
|
||||
AmountLeft int `json:"amount_left"`
|
||||
AutoManaged bool `json:"auto_tmm"`
|
||||
Availability float32 `json:"availability"`
|
||||
Category string `json:"category"`
|
||||
Completed int `json:"completed"`
|
||||
CompletionOn int `json:"completion_on"`
|
||||
DlLimit int `json:"dl_limit"`
|
||||
DlSpeed int `json:"dl_speed"`
|
||||
Downloaded int `json:"downloaded"`
|
||||
DownloadedSession int `json:"downloaded_session"`
|
||||
ETA int `json:"eta"`
|
||||
FirstLastPiecePrio bool `json:"f_l_piece_prio"`
|
||||
ForceStart bool `json:"force_start"`
|
||||
Hash string `json:"hash"`
|
||||
LastActivity int `json:"last_activity"`
|
||||
MagnetURI string `json:"magnet_uri"`
|
||||
MaxRatio float32 `json:"max_ratio"`
|
||||
MaxSeedingTime int `json:"max_seeding_time"`
|
||||
Name string `json:"name"`
|
||||
NumComplete int `json:"num_complete"`
|
||||
NumIncomplete int `json:"num_incomplete"`
|
||||
NumSeeds int `json:"num_seeds"`
|
||||
Priority int `json:"priority"`
|
||||
Progress float32 `json:"progress"`
|
||||
Ratio float32 `json:"ratio"`
|
||||
RatioLimit float32 `json:"ratio_limit"`
|
||||
SavePath string `json:"save_path"`
|
||||
SeedingTimeLimit int `json:"seeding_time_limit"`
|
||||
SeenComplete int `json:"seen_complete"`
|
||||
SequentialDownload bool `json:"seq_dl"`
|
||||
Size int `json:"size"`
|
||||
State TorrentState `json:"state"`
|
||||
SuperSeeding bool `json:"super_seeding"`
|
||||
Tags string `json:"tags"`
|
||||
TimeActive int `json:"time_active"`
|
||||
TotalSize int `json:"total_size"`
|
||||
Tracker *string `json:"tracker"`
|
||||
UpLimit int `json:"up_limit"`
|
||||
Uploaded int `json:"uploaded"`
|
||||
UploadedSession int `json:"uploaded_session"`
|
||||
UpSpeed int `json:"upspeed"`
|
||||
}
|
||||
|
||||
type TorrentTrackersResponse struct {
|
||||
Trackers []TorrentTracker `json:"trackers"`
|
||||
}
|
||||
|
||||
type TorrentTracker struct {
|
||||
//Tier int `json:"tier"` // can be both empty "" and int
|
||||
Url string `json:"url"`
|
||||
Status TrackerStatus `json:"status"`
|
||||
NumPeers int `json:"num_peers"`
|
||||
NumSeeds int `json:"num_seeds"`
|
||||
NumLeechers int `json:"num_leechers"`
|
||||
NumDownloaded int `json:"num_downloaded"`
|
||||
Message string `json:"msg"`
|
||||
}
|
||||
|
||||
type TorrentFiles []struct {
|
||||
Availability int `json:"availability"`
|
||||
Index int `json:"index"`
|
||||
IsSeed bool `json:"is_seed,omitempty"`
|
||||
Name string `json:"name"`
|
||||
PieceRange []int `json:"piece_range"`
|
||||
Priority int `json:"priority"`
|
||||
Progress int `json:"progress"`
|
||||
Size int `json:"size"`
|
||||
}
|
||||
|
||||
type Category struct {
|
||||
Name string `json:"name"`
|
||||
SavePath string `json:"savePath"`
|
||||
}
|
||||
|
||||
type TorrentState string
|
||||
|
||||
const (
|
||||
// Some error occurred, applies to paused torrents
|
||||
TorrentStateError TorrentState = "error"
|
||||
|
||||
// Torrent data files is missing
|
||||
TorrentStateMissingFiles TorrentState = "missingFiles"
|
||||
|
||||
// Torrent is being seeded and data is being transferred
|
||||
TorrentStateUploading TorrentState = "uploading"
|
||||
|
||||
// Torrent is paused and has finished downloading
|
||||
TorrentStatePausedUp TorrentState = "pausedUP"
|
||||
|
||||
// Queuing is enabled and torrent is queued for upload
|
||||
TorrentStateQueuedUp TorrentState = "queuedUP"
|
||||
|
||||
// Torrent is being seeded, but no connection were made
|
||||
TorrentStateStalledUp TorrentState = "stalledUP"
|
||||
|
||||
// Torrent has finished downloading and is being checked
|
||||
TorrentStateCheckingUp TorrentState = "checkingUP"
|
||||
|
||||
// Torrent is forced to uploading and ignore queue limit
|
||||
TorrentStateForcedUp TorrentState = "forcedUP"
|
||||
|
||||
// Torrent is allocating disk space for download
|
||||
TorrentStateAllocating TorrentState = "allocating"
|
||||
|
||||
// Torrent is being downloaded and data is being transferred
|
||||
TorrentStateDownloading TorrentState = "downloading"
|
||||
|
||||
// Torrent has just started downloading and is fetching metadata
|
||||
TorrentStateMetaDl TorrentState = "metaDL"
|
||||
|
||||
// Torrent is paused and has NOT finished downloading
|
||||
TorrentStatePausedDl TorrentState = "pausedDL"
|
||||
|
||||
// Queuing is enabled and torrent is queued for download
|
||||
TorrentStateQueuedDl TorrentState = "queuedDL"
|
||||
|
||||
// Torrent is being downloaded, but no connection were made
|
||||
TorrentStateStalledDl TorrentState = "stalledDL"
|
||||
|
||||
// Same as checkingUP, but torrent has NOT finished downloading
|
||||
TorrentStateCheckingDl TorrentState = "checkingDL"
|
||||
|
||||
// Torrent is forced to downloading to ignore queue limit
|
||||
TorrentStateForcedDl TorrentState = "forcedDL"
|
||||
|
||||
// Checking resume data on qBt startup
|
||||
TorrentStateCheckingResumeData TorrentState = "checkingResumeData"
|
||||
|
||||
// Torrent is moving to another location
|
||||
TorrentStateMoving TorrentState = "moving"
|
||||
|
||||
// Unknown status
|
||||
TorrentStateUnknown TorrentState = "unknown"
|
||||
)
|
||||
|
||||
type TorrentFilter string
|
||||
|
||||
const (
|
||||
// Torrent is paused
|
||||
TorrentFilterAll TorrentFilter = "all"
|
||||
|
||||
// Torrent is active
|
||||
TorrentFilterActive TorrentFilter = "active"
|
||||
|
||||
// Torrent is inactive
|
||||
TorrentFilterInactive TorrentFilter = "inactive"
|
||||
|
||||
// Torrent is completed
|
||||
TorrentFilterCompleted TorrentFilter = "completed"
|
||||
|
||||
// Torrent is resumed
|
||||
TorrentFilterResumed TorrentFilter = "resumed"
|
||||
|
||||
// Torrent is paused
|
||||
TorrentFilterPaused TorrentFilter = "paused"
|
||||
|
||||
// Torrent is stalled
|
||||
TorrentFilterStalled TorrentFilter = "stalled"
|
||||
|
||||
// Torrent is being seeded and data is being transferred
|
||||
TorrentFilterUploading TorrentFilter = "uploading"
|
||||
|
||||
// Torrent is being seeded, but no connection were made
|
||||
TorrentFilterStalledUploading TorrentFilter = "stalled_uploading"
|
||||
|
||||
// Torrent is being downloaded and data is being transferred
|
||||
TorrentFilterDownloading TorrentFilter = "downloading"
|
||||
|
||||
// Torrent is being downloaded, but no connection were made
|
||||
TorrentFilterStalledDownloading TorrentFilter = "stalled_downloading"
|
||||
|
||||
// Torrent is errored
|
||||
TorrentFilterError TorrentFilter = "errored"
|
||||
)
|
||||
|
||||
// TrackerStatus https://github.com/qbittorrent/qBittorrent/wiki/WebUI-API-(qBittorrent-4.1)#get-torrent-trackers
|
||||
type TrackerStatus int
|
||||
|
||||
const (
|
||||
// 0 Tracker is disabled (used for DHT, PeX, and LSD)
|
||||
TrackerStatusDisabled TrackerStatus = 0
|
||||
|
||||
// 1 Tracker has not been contacted yet
|
||||
TrackerStatusNotContacted TrackerStatus = 1
|
||||
|
||||
// 2 Tracker has been contacted and is working
|
||||
TrackerStatusOK TrackerStatus = 2
|
||||
|
||||
// 3 Tracker is updating
|
||||
TrackerStatusUpdating TrackerStatus = 3
|
||||
|
||||
// 4 Tracker has been contacted, but it is not working (or doesn't send proper replies)
|
||||
TrackerStatusNotWorking TrackerStatus = 4
|
||||
)
|
||||
|
||||
type ConnectionStatus string
|
||||
|
||||
const (
|
||||
ConnectionStatusConnected = "connected"
|
||||
ConnectionStatusFirewalled = "firewalled"
|
||||
ConnectionStatusDisconnected = "disconnected"
|
||||
)
|
||||
|
||||
// TransferInfo
|
||||
//
|
||||
// https://github.com/qbittorrent/qBittorrent/wiki/WebUI-API-(qBittorrent-4.1)#get-global-transfer-info
|
||||
//
|
||||
// dl_info_speed integer Global download rate (bytes/s)
|
||||
//
|
||||
// dl_info_data integer Data downloaded this session (bytes)
|
||||
//
|
||||
// up_info_speed integer Global upload rate (bytes/s)
|
||||
//
|
||||
// up_info_data integer Data uploaded this session (bytes)
|
||||
//
|
||||
// dl_rate_limit integer Download rate limit (bytes/s)
|
||||
//
|
||||
// up_rate_limit integer Upload rate limit (bytes/s)
|
||||
//
|
||||
// dht_nodes integer DHT nodes connected to
|
||||
//
|
||||
// connection_status string Connection status. See possible values here below
|
||||
//
|
||||
type TransferInfo struct {
|
||||
ConnectionStatus ConnectionStatus `json:"connection_status"`
|
||||
DHTNodes int64 `json:"dht_nodes"`
|
||||
DlInfoData int64 `json:"dl_info_data"`
|
||||
DlInfoSpeed int64 `json:"dl_info_speed"`
|
||||
DlRateLimit int64 `json:"dl_rate_limit"`
|
||||
UpInfoData int64 `json:"up_info_data"`
|
||||
UpInfoSpeed int64 `json:"up_info_speed"`
|
||||
UpRateLimit int64 `json:"up_rate_limit"`
|
||||
}
|
||||
|
||||
type ContentLayout string
|
||||
|
||||
// https://www.youtube.com/watch?v=4N1iwQxiHrs
|
||||
const (
|
||||
ContentLayoutOriginal ContentLayout = "Original"
|
||||
ContentLayoutSubfolderNone ContentLayout = "NoSubfolder"
|
||||
ContentLayoutSubfolderCreate ContentLayout = "Subfolder"
|
||||
)
|
||||
|
||||
type TorrentAddOptions struct {
|
||||
Paused *bool
|
||||
SkipHashCheck *bool
|
||||
ContentLayout *ContentLayout
|
||||
SavePath *string
|
||||
AutoTMM *bool
|
||||
Category *string
|
||||
Tags *string
|
||||
LimitUploadSpeed *int64
|
||||
LimitDownloadSpeed *int64
|
||||
LimitRatio *float64
|
||||
LimitSeedTime *int64
|
||||
}
|
||||
|
||||
func (o *TorrentAddOptions) Prepare() map[string]string {
|
||||
options := map[string]string{}
|
||||
|
||||
if o.Paused != nil {
|
||||
options["paused"] = "true"
|
||||
}
|
||||
if o.SkipHashCheck != nil {
|
||||
options["skip_checking"] = "true"
|
||||
}
|
||||
if o.ContentLayout != nil {
|
||||
if *o.ContentLayout == ContentLayoutSubfolderCreate {
|
||||
// pre qBittorrent version 4.3.2
|
||||
options["root_folder"] = "true"
|
||||
|
||||
// post version 4.3.2
|
||||
options["contentLayout"] = string(ContentLayoutSubfolderCreate)
|
||||
|
||||
} else if *o.ContentLayout == ContentLayoutSubfolderNone {
|
||||
// pre qBittorrent version 4.3.2
|
||||
options["root_folder"] = "false"
|
||||
|
||||
// post version 4.3.2
|
||||
options["contentLayout"] = string(ContentLayoutSubfolderNone)
|
||||
}
|
||||
// if ORIGINAL then leave empty
|
||||
}
|
||||
if o.SavePath != nil && *o.SavePath != "" {
|
||||
options["savepath"] = *o.SavePath
|
||||
options["autoTMM"] = "false"
|
||||
}
|
||||
if o.Category != nil && *o.Category != "" {
|
||||
options["category"] = *o.Category
|
||||
}
|
||||
if o.Tags != nil && *o.Tags != "" {
|
||||
options["tags"] = *o.Tags
|
||||
}
|
||||
if o.LimitUploadSpeed != nil && *o.LimitUploadSpeed > 0 {
|
||||
options["upLimit"] = strconv.FormatInt(*o.LimitUploadSpeed*1000, 10)
|
||||
}
|
||||
if o.LimitDownloadSpeed != nil && *o.LimitDownloadSpeed > 0 {
|
||||
options["dlLimit"] = strconv.FormatInt(*o.LimitDownloadSpeed*1000, 10)
|
||||
}
|
||||
if o.LimitRatio != nil && *o.LimitRatio > 0 {
|
||||
options["ratioLimit"] = strconv.FormatFloat(*o.LimitRatio, 'f', 2, 64)
|
||||
}
|
||||
if o.LimitSeedTime != nil && *o.LimitSeedTime > 0 {
|
||||
options["seedingTimeLimit"] = strconv.FormatInt(*o.LimitSeedTime, 10)
|
||||
}
|
||||
|
||||
return options
|
||||
}
|
|
@ -1,179 +0,0 @@
|
|||
package qbittorrent
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func PtrBool(b bool) *bool {
|
||||
return &b
|
||||
}
|
||||
|
||||
func PtrStr(s string) *string {
|
||||
return &s
|
||||
}
|
||||
|
||||
func PtrInt64(i int64) *int64 {
|
||||
return &i
|
||||
}
|
||||
func PtrFloat64(f float64) *float64 {
|
||||
return &f
|
||||
}
|
||||
|
||||
func TestTorrentAddOptions_Prepare(t *testing.T) {
|
||||
layoutNone := ContentLayoutSubfolderNone
|
||||
layoutCreate := ContentLayoutSubfolderCreate
|
||||
layoutOriginal := ContentLayoutOriginal
|
||||
type fields struct {
|
||||
Paused *bool
|
||||
SkipHashCheck *bool
|
||||
ContentLayout *ContentLayout
|
||||
SavePath *string
|
||||
AutoTMM *bool
|
||||
Category *string
|
||||
Tags *string
|
||||
LimitUploadSpeed *int64
|
||||
LimitDownloadSpeed *int64
|
||||
LimitRatio *float64
|
||||
LimitSeedTime *int64
|
||||
}
|
||||
tests := []struct {
|
||||
name string
|
||||
fields fields
|
||||
want map[string]string
|
||||
}{
|
||||
{
|
||||
name: "test_01",
|
||||
fields: fields{
|
||||
Paused: nil,
|
||||
SkipHashCheck: PtrBool(true),
|
||||
ContentLayout: nil,
|
||||
SavePath: PtrStr("/home/test/torrents"),
|
||||
AutoTMM: nil,
|
||||
Category: PtrStr("test"),
|
||||
Tags: PtrStr("limited,slow"),
|
||||
LimitUploadSpeed: PtrInt64(100000),
|
||||
LimitDownloadSpeed: PtrInt64(100000),
|
||||
LimitRatio: PtrFloat64(2.0),
|
||||
LimitSeedTime: PtrInt64(100),
|
||||
},
|
||||
want: map[string]string{
|
||||
"skip_checking": "true",
|
||||
"autoTMM": "false",
|
||||
"ratioLimit": "2.00",
|
||||
"savepath": "/home/test/torrents",
|
||||
"seedingTimeLimit": "100",
|
||||
"category": "test",
|
||||
"tags": "limited,slow",
|
||||
"upLimit": "100000000",
|
||||
"dlLimit": "100000000",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "test_02",
|
||||
fields: fields{
|
||||
Paused: nil,
|
||||
SkipHashCheck: PtrBool(true),
|
||||
ContentLayout: &layoutCreate,
|
||||
SavePath: PtrStr("/home/test/torrents"),
|
||||
AutoTMM: nil,
|
||||
Category: PtrStr("test"),
|
||||
Tags: PtrStr("limited,slow"),
|
||||
LimitUploadSpeed: PtrInt64(100000),
|
||||
LimitDownloadSpeed: PtrInt64(100000),
|
||||
LimitRatio: PtrFloat64(2.0),
|
||||
LimitSeedTime: PtrInt64(100),
|
||||
},
|
||||
want: map[string]string{
|
||||
"skip_checking": "true",
|
||||
"root_folder": "true",
|
||||
"contentLayout": "Subfolder",
|
||||
"autoTMM": "false",
|
||||
"ratioLimit": "2.00",
|
||||
"savepath": "/home/test/torrents",
|
||||
"seedingTimeLimit": "100",
|
||||
"category": "test",
|
||||
"tags": "limited,slow",
|
||||
"upLimit": "100000000",
|
||||
"dlLimit": "100000000",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "test_03",
|
||||
fields: fields{
|
||||
Paused: nil,
|
||||
SkipHashCheck: PtrBool(true),
|
||||
ContentLayout: &layoutNone,
|
||||
SavePath: PtrStr("/home/test/torrents"),
|
||||
AutoTMM: nil,
|
||||
Category: PtrStr("test"),
|
||||
Tags: PtrStr("limited,slow"),
|
||||
LimitUploadSpeed: PtrInt64(100000),
|
||||
LimitDownloadSpeed: PtrInt64(100000),
|
||||
LimitRatio: PtrFloat64(2.0),
|
||||
LimitSeedTime: PtrInt64(100),
|
||||
},
|
||||
want: map[string]string{
|
||||
"skip_checking": "true",
|
||||
"root_folder": "false",
|
||||
"contentLayout": "NoSubfolder",
|
||||
"autoTMM": "false",
|
||||
"ratioLimit": "2.00",
|
||||
"savepath": "/home/test/torrents",
|
||||
"seedingTimeLimit": "100",
|
||||
"category": "test",
|
||||
"tags": "limited,slow",
|
||||
"upLimit": "100000000",
|
||||
"dlLimit": "100000000",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "test_04",
|
||||
fields: fields{
|
||||
Paused: nil,
|
||||
SkipHashCheck: PtrBool(true),
|
||||
ContentLayout: &layoutOriginal,
|
||||
SavePath: PtrStr("/home/test/torrents"),
|
||||
AutoTMM: nil,
|
||||
Category: PtrStr("test"),
|
||||
Tags: PtrStr("limited,slow"),
|
||||
LimitUploadSpeed: PtrInt64(100000),
|
||||
LimitDownloadSpeed: PtrInt64(100000),
|
||||
LimitRatio: PtrFloat64(2.0),
|
||||
LimitSeedTime: PtrInt64(100),
|
||||
},
|
||||
want: map[string]string{
|
||||
"skip_checking": "true",
|
||||
"autoTMM": "false",
|
||||
"ratioLimit": "2.00",
|
||||
"savepath": "/home/test/torrents",
|
||||
"seedingTimeLimit": "100",
|
||||
"category": "test",
|
||||
"tags": "limited,slow",
|
||||
"upLimit": "100000000",
|
||||
"dlLimit": "100000000",
|
||||
},
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
o := &TorrentAddOptions{
|
||||
Paused: tt.fields.Paused,
|
||||
SkipHashCheck: tt.fields.SkipHashCheck,
|
||||
ContentLayout: tt.fields.ContentLayout,
|
||||
SavePath: tt.fields.SavePath,
|
||||
AutoTMM: tt.fields.AutoTMM,
|
||||
Category: tt.fields.Category,
|
||||
Tags: tt.fields.Tags,
|
||||
LimitUploadSpeed: tt.fields.LimitUploadSpeed,
|
||||
LimitDownloadSpeed: tt.fields.LimitDownloadSpeed,
|
||||
LimitRatio: tt.fields.LimitRatio,
|
||||
LimitSeedTime: tt.fields.LimitSeedTime,
|
||||
}
|
||||
|
||||
got := o.Prepare()
|
||||
assert.Equal(t, tt.want, got)
|
||||
})
|
||||
}
|
||||
}
|
|
@ -1,482 +0,0 @@
|
|||
package qbittorrent
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httputil"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/autobrr/autobrr/pkg/errors"
|
||||
)
|
||||
|
||||
// Login https://github.com/qbittorrent/qBittorrent/wiki/WebUI-API-(qBittorrent-4.1)#authentication
|
||||
func (c *Client) Login() error {
|
||||
opts := map[string]string{
|
||||
"username": c.settings.Username,
|
||||
"password": c.settings.Password,
|
||||
}
|
||||
|
||||
resp, err := c.postBasic("auth/login", opts)
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "login error")
|
||||
} else if resp.StatusCode == http.StatusForbidden {
|
||||
return errors.New("User's IP is banned for too many failed login attempts")
|
||||
|
||||
} else if resp.StatusCode != http.StatusOK { // check for correct status code
|
||||
return errors.New("qbittorrent login bad status %v", resp.StatusCode)
|
||||
}
|
||||
|
||||
defer resp.Body.Close()
|
||||
|
||||
bodyBytes, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
bodyString := string(bodyBytes)
|
||||
|
||||
// read output
|
||||
if bodyString == "Fails." {
|
||||
return errors.New("bad credentials")
|
||||
}
|
||||
|
||||
// good response == "Ok."
|
||||
|
||||
// place cookies in jar for future requests
|
||||
if cookies := resp.Cookies(); len(cookies) > 0 {
|
||||
c.setCookies(cookies)
|
||||
} else {
|
||||
return errors.New("bad credentials")
|
||||
}
|
||||
|
||||
c.log.Printf("logged into client: %v", c.Name)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Client) GetTorrents() ([]Torrent, error) {
|
||||
|
||||
resp, err := c.get("torrents/info", nil)
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "get torrents error")
|
||||
}
|
||||
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "could not read body")
|
||||
}
|
||||
|
||||
var torrents []Torrent
|
||||
if err := json.Unmarshal(body, &torrents); err != nil {
|
||||
return nil, errors.Wrap(err, "could not unmarshal body")
|
||||
}
|
||||
|
||||
return torrents, nil
|
||||
}
|
||||
|
||||
func (c *Client) GetTorrentsFilter(filter TorrentFilter) ([]Torrent, error) {
|
||||
opts := map[string]string{
|
||||
"filter": string(filter),
|
||||
}
|
||||
|
||||
resp, err := c.get("torrents/info", opts)
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "could not get filtered torrents with filter: %v", filter)
|
||||
}
|
||||
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "could not read body")
|
||||
}
|
||||
|
||||
var torrents []Torrent
|
||||
if err := json.Unmarshal(body, &torrents); err != nil {
|
||||
return nil, errors.Wrap(err, "could not unmarshal body")
|
||||
}
|
||||
|
||||
return torrents, nil
|
||||
}
|
||||
|
||||
func (c *Client) GetTorrentsActiveDownloads() ([]Torrent, error) {
|
||||
var filter = TorrentFilterDownloading
|
||||
|
||||
opts := map[string]string{
|
||||
"filter": string(filter),
|
||||
}
|
||||
|
||||
resp, err := c.get("torrents/info", opts)
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "could not get active torrents")
|
||||
}
|
||||
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "could not read body")
|
||||
}
|
||||
|
||||
var torrents []Torrent
|
||||
if err := json.Unmarshal(body, &torrents); err != nil {
|
||||
return nil, errors.Wrap(err, "could not unmarshal body")
|
||||
}
|
||||
|
||||
res := make([]Torrent, 0)
|
||||
for _, torrent := range torrents {
|
||||
// qbit counts paused torrents as downloading as well by default
|
||||
// so only add torrents with state downloading, and not pausedDl, stalledDl etc
|
||||
if torrent.State == TorrentStateDownloading || torrent.State == TorrentStateStalledDl {
|
||||
res = append(res, torrent)
|
||||
}
|
||||
}
|
||||
|
||||
return res, nil
|
||||
}
|
||||
|
||||
func (c *Client) GetTorrentsRaw() (string, error) {
|
||||
resp, err := c.get("torrents/info", nil)
|
||||
if err != nil {
|
||||
return "", errors.Wrap(err, "could not get torrents raw")
|
||||
}
|
||||
|
||||
defer resp.Body.Close()
|
||||
|
||||
data, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return "", errors.Wrap(err, "could not get read body torrents raw")
|
||||
}
|
||||
|
||||
return string(data), nil
|
||||
}
|
||||
|
||||
func (c *Client) GetTorrentTrackers(hash string) ([]TorrentTracker, error) {
|
||||
opts := map[string]string{
|
||||
"hash": hash,
|
||||
}
|
||||
|
||||
resp, err := c.get("torrents/trackers", opts)
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "could not get torrent trackers for hash: %v", hash)
|
||||
}
|
||||
|
||||
defer resp.Body.Close()
|
||||
|
||||
dump, err := httputil.DumpResponse(resp, true)
|
||||
if err != nil {
|
||||
//c.log.Printf("get torrent trackers error dump response: %v\n", string(dump))
|
||||
return nil, errors.Wrap(err, "could not dump response for hash: %v", hash)
|
||||
}
|
||||
|
||||
c.log.Printf("get torrent trackers response dump: %q", dump)
|
||||
|
||||
if resp.StatusCode == http.StatusNotFound {
|
||||
return nil, nil
|
||||
} else if resp.StatusCode == http.StatusForbidden {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "could not read body")
|
||||
}
|
||||
|
||||
c.log.Printf("get torrent trackers body: %v\n", string(body))
|
||||
|
||||
var trackers []TorrentTracker
|
||||
if err := json.Unmarshal(body, &trackers); err != nil {
|
||||
return nil, errors.Wrap(err, "could not unmarshal body")
|
||||
}
|
||||
|
||||
return trackers, nil
|
||||
}
|
||||
|
||||
// AddTorrentFromFile add new torrent from torrent file
|
||||
func (c *Client) AddTorrentFromFile(file string, options map[string]string) error {
|
||||
|
||||
res, err := c.postFile("torrents/add", file, options)
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "could not add torrent %v", file)
|
||||
} else if res.StatusCode != http.StatusOK {
|
||||
return errors.Wrap(err, "could not add torrent %v unexpected status: %v", file, res.StatusCode)
|
||||
}
|
||||
|
||||
defer res.Body.Close()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Client) DeleteTorrents(hashes []string, deleteFiles bool) error {
|
||||
// Add hashes together with | separator
|
||||
hv := strings.Join(hashes, "|")
|
||||
|
||||
opts := map[string]string{
|
||||
"hashes": hv,
|
||||
"deleteFiles": strconv.FormatBool(deleteFiles),
|
||||
}
|
||||
|
||||
resp, err := c.get("torrents/delete", opts)
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "could not delete torrents: %+v", hashes)
|
||||
} else if resp.StatusCode != http.StatusOK {
|
||||
return errors.Wrap(err, "could not delete torrents %v unexpected status: %v", hashes, resp.StatusCode)
|
||||
}
|
||||
|
||||
defer resp.Body.Close()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Client) ReAnnounceTorrents(hashes []string) error {
|
||||
// Add hashes together with | separator
|
||||
hv := strings.Join(hashes, "|")
|
||||
opts := map[string]string{
|
||||
"hashes": hv,
|
||||
}
|
||||
|
||||
resp, err := c.get("torrents/reannounce", opts)
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "could not re-announce torrents: %v", hashes)
|
||||
} else if resp.StatusCode != http.StatusOK {
|
||||
return errors.Wrap(err, "could not re-announce torrents: %v unexpected status: %v", hashes, resp.StatusCode)
|
||||
}
|
||||
|
||||
defer resp.Body.Close()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Client) GetTransferInfo() (*TransferInfo, error) {
|
||||
resp, err := c.get("transfer/info", nil)
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "could not get transfer info")
|
||||
}
|
||||
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "could not read body")
|
||||
}
|
||||
|
||||
var info TransferInfo
|
||||
if err := json.Unmarshal(body, &info); err != nil {
|
||||
return nil, errors.Wrap(err, "could not unmarshal body")
|
||||
}
|
||||
|
||||
return &info, nil
|
||||
}
|
||||
|
||||
func (c *Client) Resume(hashes []string) error {
|
||||
// Add hashes together with | separator
|
||||
hv := strings.Join(hashes, "|")
|
||||
opts := map[string]string{
|
||||
"hashes": hv,
|
||||
}
|
||||
|
||||
resp, err := c.get("torrents/resume", opts)
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "could not resume torrents: %v", hashes)
|
||||
} else if resp.StatusCode != http.StatusOK {
|
||||
return errors.Wrap(err, "could not resume torrents: %v unexpected status: %v", hashes, resp.StatusCode)
|
||||
}
|
||||
|
||||
defer resp.Body.Close()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Client) SetForceStart(hashes []string, value bool) error {
|
||||
// Add hashes together with | separator
|
||||
hv := strings.Join(hashes, "|")
|
||||
opts := map[string]string{
|
||||
"hashes": hv,
|
||||
"value": strconv.FormatBool(value),
|
||||
}
|
||||
|
||||
resp, err := c.get("torrents/setForceStart", opts)
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "could not setForceStart torrents: %v", hashes)
|
||||
} else if resp.StatusCode != http.StatusOK {
|
||||
return errors.Wrap(err, "could not setForceStart torrents: %v unexpected status: %v", hashes, resp.StatusCode)
|
||||
}
|
||||
|
||||
defer resp.Body.Close()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Client) Recheck(hashes []string) error {
|
||||
// Add hashes together with | separator
|
||||
hv := strings.Join(hashes, "|")
|
||||
opts := map[string]string{
|
||||
"hashes": hv,
|
||||
}
|
||||
|
||||
resp, err := c.get("torrents/recheck", opts)
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "could not recheck torrents: %v", hashes)
|
||||
} else if resp.StatusCode != http.StatusOK {
|
||||
return errors.Wrap(err, "could not recheck torrents: %v unexpected status: %v", hashes, resp.StatusCode)
|
||||
}
|
||||
|
||||
defer resp.Body.Close()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Client) Pause(hashes []string) error {
|
||||
// Add hashes together with | separator
|
||||
hv := strings.Join(hashes, "|")
|
||||
opts := map[string]string{
|
||||
"hashes": hv,
|
||||
}
|
||||
|
||||
resp, err := c.get("torrents/pause", opts)
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "could not pause torrents: %v", hashes)
|
||||
} else if resp.StatusCode != http.StatusOK {
|
||||
return errors.Wrap(err, "could not pause torrents: %v unexpected status: %v", hashes, resp.StatusCode)
|
||||
}
|
||||
|
||||
defer resp.Body.Close()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Client) SetAutoManagement(hashes []string, enable bool) error {
|
||||
// Add hashes together with | separator
|
||||
hv := strings.Join(hashes, "|")
|
||||
opts := map[string]string{
|
||||
"hashes": hv,
|
||||
"enable": strconv.FormatBool(enable),
|
||||
}
|
||||
|
||||
resp, err := c.get("torrents/setAutoManagement", opts)
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "could not setAutoManagement torrents: %v", hashes)
|
||||
} else if resp.StatusCode != http.StatusOK {
|
||||
return errors.Wrap(err, "could not setAutoManagement torrents: %v unexpected status: %v", hashes, resp.StatusCode)
|
||||
}
|
||||
|
||||
defer resp.Body.Close()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Client) CreateCategory(category string, path string) error {
|
||||
opts := map[string]string{
|
||||
"category": category,
|
||||
"savePath": path,
|
||||
}
|
||||
|
||||
resp, err := c.get("torrents/createCategory", opts)
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "could not createCategory torrents: %v", category)
|
||||
} else if resp.StatusCode != http.StatusOK {
|
||||
return errors.Wrap(err, "could not createCategory torrents: %v unexpected status: %v", category, resp.StatusCode)
|
||||
}
|
||||
|
||||
defer resp.Body.Close()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Client) EditCategory(category string, path string) error {
|
||||
opts := map[string]string{
|
||||
"category": category,
|
||||
"savePath": path,
|
||||
}
|
||||
|
||||
resp, err := c.get("torrents/editCategory", opts)
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "could not editCategory torrents: %v", category)
|
||||
} else if resp.StatusCode != http.StatusOK {
|
||||
return errors.Wrap(err, "could not editCategory torrents: %v unexpected status: %v", category, resp.StatusCode)
|
||||
}
|
||||
|
||||
defer resp.Body.Close()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Client) RemoveCategories(categories []string) error {
|
||||
opts := map[string]string{
|
||||
"categories": strings.Join(categories, "\n"),
|
||||
}
|
||||
|
||||
resp, err := c.get("torrents/removeCategories", opts)
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "could not removeCategories torrents: %v", opts["categories"])
|
||||
} else if resp.StatusCode != http.StatusOK {
|
||||
return errors.Wrap(err, "could not removeCategories torrents: %v unexpected status: %v", opts["categories"], resp.StatusCode)
|
||||
}
|
||||
|
||||
defer resp.Body.Close()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Client) SetCategory(hashes []string, category string) error {
|
||||
// Add hashes together with | separator
|
||||
hv := strings.Join(hashes, "|")
|
||||
opts := map[string]string{
|
||||
"hashes": hv,
|
||||
"category": category,
|
||||
}
|
||||
|
||||
resp, err := c.get("torrents/setCategory", opts)
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "could not setCategory torrents: %v", hashes)
|
||||
} else if resp.StatusCode != http.StatusOK {
|
||||
return errors.Wrap(err, "could not setCategory torrents: %v unexpected status: %v", hashes, resp.StatusCode)
|
||||
}
|
||||
|
||||
defer resp.Body.Close()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Client) GetFilesInformation(hash string) (*TorrentFiles, error) {
|
||||
opts := map[string]string{
|
||||
"hash": hash,
|
||||
}
|
||||
|
||||
resp, err := c.get("torrents/files", opts)
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "could not get files info")
|
||||
}
|
||||
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "could not read body")
|
||||
}
|
||||
|
||||
var info TorrentFiles
|
||||
if err := json.Unmarshal(body, &info); err != nil {
|
||||
return nil, errors.Wrap(err, "could not unmarshal body")
|
||||
}
|
||||
|
||||
return &info, nil
|
||||
}
|
||||
|
||||
func (c *Client) GetCategories() (map[string]Category, error) {
|
||||
resp, err := c.get("torrents/categories", nil)
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "could not get files info")
|
||||
}
|
||||
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "could not read body")
|
||||
}
|
||||
|
||||
m := make(map[string]Category)
|
||||
if err := json.Unmarshal(body, &m); err != nil {
|
||||
return nil, errors.Wrap(err, "could not unmarshal body")
|
||||
}
|
||||
|
||||
return m, nil
|
||||
}
|
Loading…
Add table
Add a link
Reference in a new issue