From 9d0ab6ea5284faf8ed38f54517943ee0cd510c82 Mon Sep 17 00:00:00 2001 From: Ludvig Lundgren Date: Wed, 6 Apr 2022 10:40:44 +0200 Subject: [PATCH] feat(clients): add whisparr (#218) * feat(clients): add whisparr * feat: add client connection test --- internal/action/run.go | 7 + internal/action/whisparr.go | 70 +++++++++ internal/domain/action.go | 1 + internal/domain/client.go | 1 + internal/download_client/connection.go | 29 +++- pkg/whisparr/client.go | 87 ++++++++++++ pkg/whisparr/whisparr.go | 133 ++++++++++++++++++ web/src/domain/constants.ts | 8 ++ .../forms/settings/DownloadClientForms.tsx | 1 + web/src/screens/filters/details.tsx | 1 + web/src/types/Download.d.ts | 3 +- 11 files changed, 338 insertions(+), 3 deletions(-) create mode 100644 internal/action/whisparr.go create mode 100644 pkg/whisparr/client.go create mode 100644 pkg/whisparr/whisparr.go diff --git a/internal/action/run.go b/internal/action/run.go index 0dc17db..4895879 100644 --- a/internal/action/run.go +++ b/internal/action/run.go @@ -167,6 +167,13 @@ func (s *service) runAction(action domain.Action, release domain.Release) error return err } + case domain.ActionTypeWhisparr: + rejections, err = s.whisparr(release, action) + if err != nil { + log.Error().Stack().Err(err).Msg("error sending torrent to whisparr") + return err + } + default: log.Warn().Msgf("unsupported action: %v type: %v", action.Name, action.Type) return nil diff --git a/internal/action/whisparr.go b/internal/action/whisparr.go new file mode 100644 index 0000000..22451e3 --- /dev/null +++ b/internal/action/whisparr.go @@ -0,0 +1,70 @@ +package action + +import ( + "context" + "time" + + "github.com/autobrr/autobrr/internal/domain" + "github.com/autobrr/autobrr/pkg/whisparr" + + "github.com/rs/zerolog/log" +) + +func (s *service) whisparr(release domain.Release, action domain.Action) ([]string, error) { + log.Trace().Msg("action WHISPARR") + + // TODO validate data + + // get client for action + client, err := s.clientSvc.FindByID(context.TODO(), action.ClientID) + if err != nil { + log.Error().Err(err).Msgf("whisparr: error finding client: %v", action.ClientID) + return nil, err + } + + // return early if no client found + if client == nil { + return nil, err + } + + // initial config + cfg := whisparr.Config{ + Hostname: client.Host, + APIKey: client.Settings.APIKey, + } + + // only set basic auth if enabled + if client.Settings.Basic.Auth { + cfg.BasicAuth = client.Settings.Basic.Auth + cfg.Username = client.Settings.Basic.Username + cfg.Password = client.Settings.Basic.Password + } + + arr := whisparr.New(cfg) + + r := whisparr.Release{ + Title: release.TorrentName, + DownloadUrl: release.TorrentURL, + Size: int64(release.Size), + Indexer: release.Indexer, + DownloadProtocol: "torrent", + Protocol: "torrent", + PublishDate: time.Now().Format(time.RFC3339), + } + + rejections, err := arr.Push(r) + if err != nil { + log.Error().Stack().Err(err).Msgf("whisparr: failed to push release: %v", r) + return nil, err + } + + if rejections != nil { + log.Debug().Msgf("whisparr: release push rejected: %v, indexer %v to %v reasons: '%v'", r.Title, r.Indexer, client.Host, rejections) + + return rejections, nil + } + + log.Debug().Msgf("whisparr: successfully pushed release: %v, indexer %v to %v", r.Title, r.Indexer, client.Host) + + return nil, nil +} diff --git a/internal/domain/action.go b/internal/domain/action.go index 8282a46..ee0d890 100644 --- a/internal/domain/action.go +++ b/internal/domain/action.go @@ -50,4 +50,5 @@ const ( ActionTypeRadarr ActionType = "RADARR" ActionTypeSonarr ActionType = "SONARR" ActionTypeLidarr ActionType = "LIDARR" + ActionTypeWhisparr ActionType = "WHISPARR" ) diff --git a/internal/domain/client.go b/internal/domain/client.go index ad224f4..4f266e0 100644 --- a/internal/domain/client.go +++ b/internal/domain/client.go @@ -53,4 +53,5 @@ const ( DownloadClientTypeRadarr DownloadClientType = "RADARR" DownloadClientTypeSonarr DownloadClientType = "SONARR" DownloadClientTypeLidarr DownloadClientType = "LIDARR" + DownloadClientTypeWhisparr DownloadClientType = "WHISPARR" ) diff --git a/internal/download_client/connection.go b/internal/download_client/connection.go index c11380e..4265f33 100644 --- a/internal/download_client/connection.go +++ b/internal/download_client/connection.go @@ -1,6 +1,8 @@ package download_client import ( + "github.com/autobrr/autobrr/pkg/whisparr" + "github.com/pkg/errors" "time" "github.com/autobrr/autobrr/internal/domain" @@ -29,9 +31,12 @@ func (s *service) testConnection(client domain.DownloadClient) error { case domain.DownloadClientTypeLidarr: return s.testLidarrConnection(client) - } - return nil + case domain.DownloadClientTypeWhisparr: + return s.testWhisparrConnection(client) + default: + return errors.New("unsupported client") + } } func (s *service) testQbittorrentConnection(client domain.DownloadClient) error { @@ -159,3 +164,23 @@ func (s *service) testLidarrConnection(client domain.DownloadClient) error { return nil } + +func (s *service) testWhisparrConnection(client domain.DownloadClient) error { + r := whisparr.New(whisparr.Config{ + Hostname: client.Host, + APIKey: client.Settings.APIKey, + BasicAuth: client.Settings.Basic.Auth, + Username: client.Settings.Basic.Username, + Password: client.Settings.Basic.Password, + }) + + _, err := r.Test() + if err != nil { + log.Error().Err(err).Msgf("whisparr: connection test failed: %v", client.Host) + return err + } + + log.Debug().Msgf("test client connection for whisparr: success") + + return nil +} diff --git a/pkg/whisparr/client.go b/pkg/whisparr/client.go new file mode 100644 index 0000000..104abfa --- /dev/null +++ b/pkg/whisparr/client.go @@ -0,0 +1,87 @@ +package whisparr + +import ( + "bytes" + "encoding/json" + "errors" + "net/http" + "net/url" + "path" + + "github.com/rs/zerolog/log" +) + +func (c *client) get(endpoint string) (*http.Response, error) { + u, err := url.Parse(c.config.Hostname) + u.Path = path.Join(u.Path, "/api/v3/", endpoint) + reqUrl := u.String() + + req, err := http.NewRequest(http.MethodGet, reqUrl, http.NoBody) + if err != nil { + log.Error().Err(err).Msgf("whisparr client request error : %v", reqUrl) + return nil, err + } + + if c.config.BasicAuth { + req.SetBasicAuth(c.config.Username, c.config.Password) + } + + req.Header.Add("X-Api-Key", c.config.APIKey) + req.Header.Set("User-Agent", "autobrr") + + res, err := c.http.Do(req) + if err != nil { + log.Error().Err(err).Msgf("whisparr client request error : %v", reqUrl) + return nil, err + } + + if res.StatusCode == http.StatusUnauthorized { + return nil, errors.New("unauthorized: bad credentials") + } + + return res, nil +} + +func (c *client) post(endpoint string, data interface{}) (*http.Response, error) { + u, err := url.Parse(c.config.Hostname) + u.Path = path.Join(u.Path, "/api/v3/", endpoint) + reqUrl := u.String() + + jsonData, err := json.Marshal(data) + if err != nil { + log.Error().Err(err).Msgf("whisparr client could not marshal data: %v", reqUrl) + return nil, err + } + + req, err := http.NewRequest(http.MethodPost, reqUrl, bytes.NewBuffer(jsonData)) + if err != nil { + log.Error().Err(err).Msgf("whisparr client request error: %v", reqUrl) + return nil, err + } + + if c.config.BasicAuth { + req.SetBasicAuth(c.config.Username, c.config.Password) + } + + req.Header.Add("X-Api-Key", c.config.APIKey) + req.Header.Set("Content-Type", "application/json; charset=UTF-8") + req.Header.Set("User-Agent", "autobrr") + + res, err := c.http.Do(req) + if err != nil { + log.Error().Err(err).Msgf("whisparr client request error: %v", reqUrl) + return nil, err + } + + // validate response + if res.StatusCode == http.StatusUnauthorized { + log.Error().Err(err).Msgf("whisparr client bad request: %v", reqUrl) + return nil, errors.New("unauthorized: bad credentials") + } else if res.StatusCode != http.StatusOK { + log.Error().Err(err).Msgf("whisparr client request error: %v", reqUrl) + return nil, errors.New("whisparr: bad request") + } + + // return raw response and let the caller handle json unmarshal of body + return res, nil +} diff --git a/pkg/whisparr/whisparr.go b/pkg/whisparr/whisparr.go new file mode 100644 index 0000000..f76d71c --- /dev/null +++ b/pkg/whisparr/whisparr.go @@ -0,0 +1,133 @@ +package whisparr + +import ( + "encoding/json" + "io" + "net/http" + "strings" + "time" + + "github.com/rs/zerolog/log" +) + +type Config struct { + Hostname string + APIKey string + + // basic auth username and password + BasicAuth bool + Username string + Password string +} + +type Client interface { + Test() (*SystemStatusResponse, error) + Push(release Release) ([]string, error) +} + +type client struct { + config Config + http *http.Client +} + +func New(config Config) Client { + + httpClient := &http.Client{ + Timeout: time.Second * 10, + } + + c := &client{ + config: config, + http: httpClient, + } + + return c +} + +type Release struct { + Title string `json:"title"` + DownloadUrl string `json:"downloadUrl"` + Size int64 `json:"size"` + Indexer string `json:"indexer"` + DownloadProtocol string `json:"downloadProtocol"` + Protocol string `json:"protocol"` + PublishDate string `json:"publishDate"` +} + +type PushResponse struct { + Approved bool `json:"approved"` + Rejected bool `json:"rejected"` + TempRejected bool `json:"temporarilyRejected"` + Rejections []string `json:"rejections"` +} + +type SystemStatusResponse struct { + Version string `json:"version"` +} + +func (c *client) Test() (*SystemStatusResponse, error) { + res, err := c.get("system/status") + if err != nil { + log.Error().Stack().Err(err).Msg("whisparr client get error") + return nil, err + } + + defer res.Body.Close() + + body, err := io.ReadAll(res.Body) + if err != nil { + log.Error().Stack().Err(err).Msg("whisparr client error reading body") + return nil, err + } + + response := SystemStatusResponse{} + err = json.Unmarshal(body, &response) + if err != nil { + log.Error().Stack().Err(err).Msg("whisparr client error json unmarshal") + return nil, err + } + + log.Trace().Msgf("whisparr system/status response: %+v", response) + + return &response, nil +} + +func (c *client) Push(release Release) ([]string, error) { + res, err := c.post("release/push", release) + if err != nil { + log.Error().Stack().Err(err).Msg("whisparr client post error") + return nil, err + } + + if res == nil { + return nil, nil + } + + defer res.Body.Close() + + body, err := io.ReadAll(res.Body) + if err != nil { + log.Error().Stack().Err(err).Msg("whisparr client error reading body") + return nil, err + } + + pushResponse := make([]PushResponse, 0) + err = json.Unmarshal(body, &pushResponse) + if err != nil { + log.Error().Stack().Err(err).Msg("whisparr client error json unmarshal") + return nil, err + } + + log.Trace().Msgf("whisparr release/push response body: %+v", string(body)) + + // log and return if rejected + if pushResponse[0].Rejected { + rejections := strings.Join(pushResponse[0].Rejections, ", ") + + log.Trace().Msgf("whisparr push rejected: %s - reasons: %q", release.Title, rejections) + return pushResponse[0].Rejections, nil + } + + // success true + return nil, nil +} diff --git a/web/src/domain/constants.ts b/web/src/domain/constants.ts index 1a34917..1ec003f 100644 --- a/web/src/domain/constants.ts +++ b/web/src/domain/constants.ts @@ -177,6 +177,11 @@ export const DownloadClientTypeOptions: RadioFieldsetOption[] = [ description: "Send to Lidarr and let it decide", value: "LIDARR" }, + { + label: "Whisparr", + description: "Send to Whisparr and let it decide", + value: "WHISPARR" + }, ]; export const DownloadClientTypeNameMap: Record = { @@ -186,6 +191,7 @@ export const DownloadClientTypeNameMap: Record, SONARR: , LIDARR: , + WHISPARR: , }; diff --git a/web/src/screens/filters/details.tsx b/web/src/screens/filters/details.tsx index 1adc522..5523e13 100644 --- a/web/src/screens/filters/details.tsx +++ b/web/src/screens/filters/details.tsx @@ -849,6 +849,7 @@ function FilterActionsItem({ action, clients, idx, remove }: FilterActionsItemPr case "RADARR": case "SONARR": case "LIDARR": + case "WHISPARR": return (