diff --git a/go.mod b/go.mod index 07d4d67..c0dba84 100644 --- a/go.mod +++ b/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 diff --git a/go.sum b/go.sum index f3233a1..3bebe35 100644 --- a/go.sum +++ b/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= diff --git a/internal/action/qbittorrent.go b/internal/action/qbittorrent.go index a530601..0a7ba9e 100644 --- a/internal/action/qbittorrent.go +++ b/internal/action/qbittorrent.go @@ -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 -} diff --git a/internal/action/run.go b/internal/action/run.go index e147c7e..66bef17 100644 --- a/internal/action/run.go +++ b/internal/action/run.go @@ -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") } diff --git a/internal/action/service.go b/internal/action/service.go index b0b8ed4..8eb1251 100644 --- a/internal/action/service.go +++ b/internal/action/service.go @@ -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) diff --git a/internal/domain/client.go b/internal/domain/client.go index 014862b..9c6fa24 100644 --- a/internal/domain/client.go +++ b/internal/domain/client.go @@ -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() +} diff --git a/internal/domain/client_test.go b/internal/domain/client_test.go new file mode 100644 index 0000000..d772501 --- /dev/null +++ b/internal/domain/client_test.go @@ -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()") + }) + } +} diff --git a/internal/domain/release.go b/internal/domain/release.go index 4b2137e..0d3d49d 100644 --- a/internal/domain/release.go +++ b/internal/domain/release.go @@ -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") } diff --git a/internal/download_client/connection.go b/internal/download_client/connection.go index cba8e3a..7f6ea31 100644 --- a/internal/download_client/connection.go +++ b/internal/download_client/connection.go @@ -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) } diff --git a/internal/download_client/service.go b/internal/download_client/service.go index 96d64a2..e9c7ed3 100644 --- a/internal/download_client/service.go +++ b/internal/download_client/service.go @@ -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 +} diff --git a/internal/http/download_client.go b/internal/http/download_client.go index 8e37ce3..a7250e9 100644 --- a/internal/http/download_client.go +++ b/internal/http/download_client.go @@ -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 } diff --git a/internal/release/service.go b/internal/release/service.go index 80e114e..3c60a31 100644 --- a/internal/release/service.go +++ b/internal/release/service.go @@ -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 diff --git a/pkg/qbittorrent/client.go b/pkg/qbittorrent/client.go deleted file mode 100644 index 9404dcc..0000000 --- a/pkg/qbittorrent/client.go +++ /dev/null @@ -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() -} diff --git a/pkg/qbittorrent/client_test.go b/pkg/qbittorrent/client_test.go deleted file mode 100644 index 3bdc464..0000000 --- a/pkg/qbittorrent/client_test.go +++ /dev/null @@ -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) - } - }) - } -} diff --git a/pkg/qbittorrent/domain.go b/pkg/qbittorrent/domain.go deleted file mode 100644 index 4a2bda2..0000000 --- a/pkg/qbittorrent/domain.go +++ /dev/null @@ -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 -} diff --git a/pkg/qbittorrent/domain_test.go b/pkg/qbittorrent/domain_test.go deleted file mode 100644 index e9ffc2b..0000000 --- a/pkg/qbittorrent/domain_test.go +++ /dev/null @@ -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) - }) - } -} diff --git a/pkg/qbittorrent/methods.go b/pkg/qbittorrent/methods.go deleted file mode 100644 index 6309730..0000000 --- a/pkg/qbittorrent/methods.go +++ /dev/null @@ -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 -}