diff --git a/internal/action/porla.go b/internal/action/porla.go index f20c69e..38c0c43 100644 --- a/internal/action/porla.go +++ b/internal/action/porla.go @@ -4,54 +4,66 @@ import ( "bufio" "context" "encoding/base64" - "io/ioutil" + "io" "os" "github.com/autobrr/autobrr/internal/domain" "github.com/autobrr/autobrr/pkg/errors" "github.com/autobrr/autobrr/pkg/porla" + "github.com/dcarbone/zadapters/zstdlog" "github.com/rs/zerolog" ) -func (s *service) porla(action domain.Action, release domain.Release) ([]string, error) { - s.log.Debug().Msgf("action Porla: %v", action.Name) +func (s *service) porla(ctx context.Context, action *domain.Action, release domain.Release) ([]string, error) { + s.log.Debug().Msgf("action Porla: %s", action.Name) - client, err := s.clientSvc.FindByID(context.TODO(), action.ClientID) + client, err := s.clientSvc.FindByID(ctx, action.ClientID) if err != nil { - return nil, errors.Wrap(err, "error finding client: %v", action.ClientID) + return nil, errors.Wrap(err, "error finding client: %d", action.ClientID) } if client == nil { - return nil, errors.New("could not find client by id: %v", action.ClientID) + return nil, errors.New("could not find client by id: %d", action.ClientID) } - porlaSettings := porla.Settings{ - Hostname: client.Host, - AuthToken: client.Settings.APIKey, + porlaSettings := porla.Config{ + Hostname: client.Host, + AuthToken: client.Settings.APIKey, + TLSSkipVerify: client.TLSSkipVerify, + BasicUser: client.Settings.Basic.Username, + BasicPass: client.Settings.Basic.Password, } porlaSettings.Log = zstdlog.NewStdLoggerWithLevel(s.log.With().Str("type", "Porla").Str("client", client.Name).Logger(), zerolog.TraceLevel) prl := porla.NewClient(porlaSettings) + rejections, err := s.porlaCheckRulesCanDownload(ctx, action, client, prl) + if err != nil { + return nil, errors.Wrap(err, "error checking Porla client rules: %s", action.Name) + } + + if len(rejections) > 0 { + return rejections, nil + } + if release.TorrentTmpFile == "" { if err := release.DownloadTorrentFile(); err != nil { - return nil, errors.Wrap(err, "error downloading torrent file for release: %v", release.TorrentName) + return nil, errors.Wrap(err, "error downloading torrent file for release: %s", release.TorrentName) } } file, err := os.Open(release.TorrentTmpFile) if err != nil { - return nil, errors.Wrap(err, "error opening file %v", release.TorrentTmpFile) + return nil, errors.Wrap(err, "error opening file %s", release.TorrentTmpFile) } defer file.Close() reader := bufio.NewReader(file) - content, err := ioutil.ReadAll(reader) - + content, err := io.ReadAll(reader) if err != nil { - return nil, errors.Wrap(err, "failed to read file: %v", release.TorrentTmpFile) + return nil, errors.Wrap(err, "failed to read file: %s", release.TorrentTmpFile) } opts := &porla.TorrentsAddReq{ @@ -69,7 +81,7 @@ func (s *service) porla(action domain.Action, release domain.Release) ([]string, opts.UploadLimit = action.LimitUploadSpeed * 1000 } - if err = prl.TorrentsAdd(opts); err != nil { + if err = prl.TorrentsAdd(ctx, opts); err != nil { return nil, errors.Wrap(err, "could not add torrent %v to client: %v", release.TorrentTmpFile, client.Name) } @@ -77,3 +89,27 @@ func (s *service) porla(action domain.Action, release domain.Release) ([]string, return nil, nil } + +func (s *service) porlaCheckRulesCanDownload(ctx context.Context, action *domain.Action, client *domain.DownloadClient, prla *porla.Client) ([]string, error) { + s.log.Trace().Msgf("action Porla: %s check rules", action.Name) + + // check for active downloads and other rules + if client.Settings.Rules.Enabled && !action.IgnoreRules { + torrents, err := prla.TorrentsList(ctx, &porla.TorrentsListFilters{Query: "is:downloading and not is:paused"}) + if err != nil { + return nil, errors.Wrap(err, "could not fetch active downloads") + } + + if client.Settings.Rules.MaxActiveDownloads > 0 { + if len(torrents.Torrents) >= client.Settings.Rules.MaxActiveDownloads { + rejection := "max active downloads reached, skipping" + + s.log.Debug().Msg(rejection) + + return []string{rejection}, nil + } + } + } + + return nil, nil +} diff --git a/internal/action/run.go b/internal/action/run.go index 97d23b1..455f9e0 100644 --- a/internal/action/run.go +++ b/internal/action/run.go @@ -61,7 +61,7 @@ func (s *service) RunAction(ctx context.Context, action *domain.Action, release rejections, err = s.transmission(ctx, action, release) case domain.ActionTypePorla: - rejections, err = s.porla(*action, release) + rejections, err = s.porla(ctx, action, release) case domain.ActionTypeRadarr: rejections, err = s.radarr(ctx, action, release) diff --git a/internal/download_client/connection.go b/internal/download_client/connection.go index d99e5bd..22648a4 100644 --- a/internal/download_client/connection.go +++ b/internal/download_client/connection.go @@ -264,7 +264,7 @@ func (s *service) testReadarrConnection(ctx context.Context, client domain.Downl } func (s *service) testPorlaConnection(client domain.DownloadClient) error { - p := porla.NewClient(porla.Settings{ + p := porla.NewClient(porla.Config{ Hostname: client.Host, AuthToken: client.Settings.APIKey, }) diff --git a/pkg/jsonrpc/jsonrpc.go b/pkg/jsonrpc/jsonrpc.go index fd8d19b..916b38c 100644 --- a/pkg/jsonrpc/jsonrpc.go +++ b/pkg/jsonrpc/jsonrpc.go @@ -2,6 +2,7 @@ package jsonrpc import ( "bytes" + "context" "encoding/json" "fmt" "net/http" @@ -13,6 +14,7 @@ import ( type Client interface { Call(method string, params ...interface{}) (*RPCResponse, error) + CallCtx(ctx context.Context, method string, params ...interface{}) (*RPCResponse, error) } type RPCRequest struct { @@ -61,11 +63,23 @@ type rpcClient struct { endpoint string httpClient *http.Client headers map[string]string + + // HTTP Basic auth username + basicUser string + + // HTTP Basic auth password + basicPass string } type ClientOpts struct { HTTPClient *http.Client Headers map[string]string + + // HTTP Basic auth username + BasicUser string + + // HTTP Basic auth password + BasicPass string } type RPCResponses []*RPCResponse @@ -95,6 +109,9 @@ func NewClientWithOpts(endpoint string, opts *ClientOpts) Client { } } + c.basicUser = opts.BasicUser + c.basicPass = opts.BasicPass + return c } @@ -106,22 +123,38 @@ func (c *rpcClient) Call(method string, params ...interface{}) (*RPCResponse, er Params: Params(params...), } - return c.doCall(request) + return c.doCall(context.TODO(), request) } -func (c *rpcClient) newRequest(req interface{}) (*http.Request, error) { +func (c *rpcClient) CallCtx(ctx context.Context, method string, params ...interface{}) (*RPCResponse, error) { + request := RPCRequest{ + ID: 1, + JsonRPC: "2.0", + Method: method, + Params: Params(params...), + } + + return c.doCall(ctx, request) +} + +func (c *rpcClient) newRequest(ctx context.Context, req interface{}) (*http.Request, error) { body, err := json.Marshal(req) if err != nil { return nil, errors.Wrap(err, "could not marshal request") } - request, err := http.NewRequest("POST", c.endpoint, bytes.NewReader(body)) + request, err := http.NewRequestWithContext(ctx, "POST", c.endpoint, bytes.NewReader(body)) if err != nil { return nil, errors.Wrap(err, "error creating request") } request.Header.Set("Content-Type", "application/json") request.Header.Set("Accept", "application/json") + // set basic auth + if c.basicUser != "" && c.basicPass != "" { + request.SetBasicAuth(c.basicUser, c.basicPass) + } + for k, v := range c.headers { request.Header.Set(k, v) } @@ -129,9 +162,9 @@ func (c *rpcClient) newRequest(req interface{}) (*http.Request, error) { return request, nil } -func (c *rpcClient) doCall(request RPCRequest) (*RPCResponse, error) { +func (c *rpcClient) doCall(ctx context.Context, request RPCRequest) (*RPCResponse, error) { - httpRequest, err := c.newRequest(request) + httpRequest, err := c.newRequest(ctx, request) if err != nil { return nil, errors.Wrap(err, "could not create rpc http request") } @@ -225,8 +258,7 @@ func (r *RPCResponse) GetObject(toType interface{}) error { return errors.Wrap(err, "could not marshal object") } - err = json.Unmarshal(js, toType) - if err != nil { + if err = json.Unmarshal(js, toType); err != nil { return errors.Wrap(err, "could not unmarshal object") } diff --git a/pkg/porla/client.go b/pkg/porla/client.go index 384a813..46c4ccb 100644 --- a/pkg/porla/client.go +++ b/pkg/porla/client.go @@ -1,30 +1,85 @@ package porla import ( + "crypto/tls" + "io" "log" + "net/http" + "time" "github.com/autobrr/autobrr/pkg/jsonrpc" ) +var ( + DefaultTimeout = 60 * time.Second +) + type Client struct { Name string Hostname string + cfg Config rpcClient jsonrpc.Client + http *http.Client + timeout time.Duration + + log *log.Logger } -type Settings struct { +type Config struct { Hostname string AuthToken string - Log *log.Logger + + // TLS skip cert validation + TLSSkipVerify bool + + // HTTP Basic auth username + BasicUser string + + // HTTP Basic auth password + BasicPass string + + Timeout int + Log *log.Logger } -func NewClient(settings Settings) *Client { +func NewClient(cfg Config) *Client { c := &Client{ - rpcClient: jsonrpc.NewClientWithOpts(settings.Hostname+"/api/v1/jsonrpc", &jsonrpc.ClientOpts{ - Headers: map[string]string{ - "Authorization": "Bearer " + settings.AuthToken, - }, - }), + cfg: cfg, + log: log.New(io.Discard, "", log.LstdFlags), + timeout: DefaultTimeout, } + + // override logger if we pass one + if cfg.Log != nil { + c.log = cfg.Log + } + + if cfg.Timeout > 0 { + c.timeout = time.Duration(cfg.Timeout) * time.Second + } + + c.http = &http.Client{ + Timeout: c.timeout, + } + + customTransport := http.DefaultTransport.(*http.Transport).Clone() + if cfg.TLSSkipVerify { + customTransport.TLSClientConfig = &tls.Config{InsecureSkipVerify: true} + } + + httpClient := &http.Client{ + Timeout: c.timeout, + Transport: customTransport, + } + + c.rpcClient = jsonrpc.NewClientWithOpts(cfg.Hostname+"/api/v1/jsonrpc", &jsonrpc.ClientOpts{ + Headers: map[string]string{ + "X-Porla-Token": cfg.AuthToken, + }, + HTTPClient: httpClient, + BasicUser: cfg.BasicUser, + BasicPass: cfg.BasicPass, + }) + return c } diff --git a/pkg/porla/domain.go b/pkg/porla/domain.go index db1a461..df3e446 100644 --- a/pkg/porla/domain.go +++ b/pkg/porla/domain.go @@ -18,3 +18,36 @@ type TorrentsAddReq struct { type TorrentsAddRes struct { } + +type TorrentsListReq struct { + Filters *TorrentsListFilters `json:"filters"` +} + +type TorrentsListFilters struct { + Query string `json:"query"` +} + +type TorrentsListRes struct { + Page int `json:"page"` + PageSize int `json:"page_size"` + TorrentsTotal int `json:"torrents_total"` + Torrents []Torrent `json:"torrents"` +} + +type Torrent struct { + DownloadRate int `json:"download_rate"` + UploadRate int `json:"upload_rate"` + Flags int `json:"flags"` + InfoHash []string `json:"info_hash"` + ListPeers int `json:"list_peers"` + ListSeeds int `json:"list_seeds"` + Name string `json:"name"` + NumPeers int `json:"num_peers"` + NumSeeds int `json:"num_seeds"` + Progress float64 `json:"progress"` + QueuePosition int `json:"queue_position"` + SavePath string `json:"save_path"` + Size int `json:"size"` + Total int `json:"total"` + TotalDone int `json:"total_done"` +} diff --git a/pkg/porla/methods.go b/pkg/porla/methods.go index 584e6a1..6cd3521 100644 --- a/pkg/porla/methods.go +++ b/pkg/porla/methods.go @@ -1,8 +1,9 @@ package porla +import "context" + func (c *Client) Version() (*SysVersionsPorla, error) { response, err := c.rpcClient.Call("sys.versions") - if err != nil { return nil, err } @@ -12,18 +13,15 @@ func (c *Client) Version() (*SysVersionsPorla, error) { } var versions *SysVersions - err = response.GetObject(&versions) - - if err != nil { + if err = response.GetObject(&versions); err != nil { return nil, err } return &versions.Porla, nil } -func (c *Client) TorrentsAdd(req *TorrentsAddReq) error { - response, err := c.rpcClient.Call("torrents.add", req) - +func (c *Client) TorrentsAdd(ctx context.Context, req *TorrentsAddReq) error { + response, err := c.rpcClient.CallCtx(ctx, "torrents.add", req) if err != nil { return err } @@ -33,11 +31,27 @@ func (c *Client) TorrentsAdd(req *TorrentsAddReq) error { } var res *TorrentsAddRes - err = response.GetObject(&res) - - if err != nil { + if err = response.GetObject(&res); err != nil { return err } return nil } + +func (c *Client) TorrentsList(ctx context.Context, filters *TorrentsListFilters) (*TorrentsListRes, error) { + response, err := c.rpcClient.CallCtx(ctx, "torrents.list", TorrentsListReq{Filters: filters}) + if err != nil { + return nil, err + } + + if response.Error != nil { + return nil, response.Error + } + + var res *TorrentsListRes + if err = response.GetObject(&res); err != nil { + return nil, err + } + + return res, nil +} diff --git a/web/src/forms/settings/DownloadClientForms.tsx b/web/src/forms/settings/DownloadClientForms.tsx index b1e042e..8857e68 100644 --- a/web/src/forms/settings/DownloadClientForms.tsx +++ b/web/src/forms/settings/DownloadClientForms.tsx @@ -162,7 +162,7 @@ function FormFieldsQbit() { function FormFieldsPorla() { const { - values: {} + values: { tls, settings } } = useFormikContext(); return ( @@ -173,7 +173,25 @@ function FormFieldsPorla() { help="Eg. http(s)://client.domain.ltd, http(s)://domain.ltd/porla, http://domain.ltd:port" /> + + + + {tls && ( + + )} + + + + {settings.basic?.auth === true && ( + <> + + + + )} ); } @@ -264,7 +282,7 @@ function FormFieldsRulesBasic() { ); } -function FormFieldsRules() { +function FormFieldsRulesQbit() { const { values: { settings } } = useFormikContext(); @@ -325,7 +343,8 @@ function FormFieldsRules() { export const rulesComponentMap: componentMapType = { DELUGE_V1: , DELUGE_V2: , - QBITTORRENT: + QBITTORRENT: , + PORLA: }; interface formButtonsProps { diff --git a/web/src/screens/filters/action.tsx b/web/src/screens/filters/action.tsx index f73bd4a..6bb8fc5 100644 --- a/web/src/screens/filters/action.tsx +++ b/web/src/screens/filters/action.tsx @@ -398,20 +398,6 @@ const TypeForm = ({ action, idx, clients }: TypeFormProps) => { ); - case "RADARR": - case "SONARR": - case "LIDARR": - case "WHISPARR": - case "READARR": - return ( -
- -
- ); case "PORLA": return (
@@ -448,6 +434,20 @@ const TypeForm = ({ action, idx, clients }: TypeFormProps) => {
); + case "RADARR": + case "SONARR": + case "LIDARR": + case "WHISPARR": + case "READARR": + return ( +
+ +
+ ); default: return null;