From 71d0424b61de89d0a1f058645681868d80b4cbf1 Mon Sep 17 00:00:00 2001 From: voltron4lyfe <55123373+voltron4lyfe@users.noreply.github.com> Date: Fri, 14 Oct 2022 10:56:42 -0700 Subject: [PATCH] feat(clients): add Readarr support (#490) * Add initial Readarr support * Readarr working with MaM * feat(clients): readarr add tests --- README.md | 2 +- internal/action/readarr.go | 68 ++++++++ internal/action/run.go | 3 + internal/domain/action.go | 1 + internal/domain/client.go | 1 + internal/download_client/connection.go | 24 +++ .../indexer/definitions/myanonamouse.yaml | 1 + pkg/readarr/client.go | 136 +++++++++++++++ pkg/readarr/readarr.go | 149 +++++++++++++++++ pkg/readarr/readarr_test.go | 158 ++++++++++++++++++ .../testdata/release_push_ok_response.json | 37 ++++ .../testdata/system_status_response_ok.json | 31 ++++ web/src/domain/constants.ts | 14 +- .../forms/settings/DownloadClientForms.tsx | 3 +- web/src/screens/filters/action.tsx | 1 + web/src/types/Download.d.ts | 3 +- 16 files changed, 626 insertions(+), 6 deletions(-) create mode 100644 internal/action/readarr.go create mode 100644 pkg/readarr/client.go create mode 100644 pkg/readarr/readarr.go create mode 100644 pkg/readarr/readarr_test.go create mode 100644 pkg/readarr/testdata/release_push_ok_response.json create mode 100644 pkg/readarr/testdata/system_status_response_ok.json diff --git a/README.md b/README.md index 7098d1c..76da06d 100644 --- a/README.md +++ b/README.md @@ -34,7 +34,7 @@ Available download clients and actions - Deluge v1+ and v2+ - rTorrent - Transmission -- Sonarr, Radarr, Lidarr and Whisparr (pushes releases directly to them and gets in the early swarm, instead of getting them via RSS when it's already over) +- Sonarr, Radarr, Lidarr, Whisparr and Readarr (pushes releases directly to them and gets in the early swarm, instead of getting them via RSS when it's already over) - Watch folder - Exec custom scripts - Webhook diff --git a/internal/action/readarr.go b/internal/action/readarr.go new file mode 100644 index 0000000..2f031ec --- /dev/null +++ b/internal/action/readarr.go @@ -0,0 +1,68 @@ +package action + +import ( + "context" + "time" + + "github.com/autobrr/autobrr/internal/domain" + "github.com/autobrr/autobrr/pkg/errors" + "github.com/autobrr/autobrr/pkg/readarr" +) + +func (s *service) readarr(action domain.Action, release domain.Release) ([]string, error) { + s.log.Trace().Msg("action READARR") + + // TODO validate data + + // get client for action + client, err := s.clientSvc.FindByID(context.TODO(), action.ClientID) + if err != nil { + return nil, errors.Wrap(err, "readarr could not find client: %v", action.ClientID) + } + + // return early if no client found + if client == nil { + return nil, errors.New("no client found") + } + + // initial config + cfg := readarr.Config{ + Hostname: client.Host, + APIKey: client.Settings.APIKey, + Log: s.subLogger, + } + + // 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 := readarr.New(cfg) + + r := readarr.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 { + return nil, errors.Wrap(err, "readarr: failed to push release: %v", r) + } + + if rejections != nil { + s.log.Debug().Msgf("readarr: release push rejected: %v, indexer %v to %v reasons: '%v'", r.Title, r.Indexer, client.Host, rejections) + + return rejections, nil + } + + s.log.Debug().Msgf("readarr: successfully pushed release: %v, indexer %v to %v", r.Title, r.Indexer, client.Host) + + return nil, nil +} diff --git a/internal/action/run.go b/internal/action/run.go index a376ecd..22bf029 100644 --- a/internal/action/run.go +++ b/internal/action/run.go @@ -66,6 +66,9 @@ func (s *service) RunAction(action *domain.Action, release domain.Release) ([]st case domain.ActionTypeWhisparr: rejections, err = s.whisparr(*action, release) + case domain.ActionTypeReadarr: + rejections, err = s.readarr(*action, release) + default: s.log.Warn().Msgf("unsupported action type: %v", action.Type) return rejections, err diff --git a/internal/domain/action.go b/internal/domain/action.go index feb5762..4991877 100644 --- a/internal/domain/action.go +++ b/internal/domain/action.go @@ -62,6 +62,7 @@ const ( ActionTypeSonarr ActionType = "SONARR" ActionTypeLidarr ActionType = "LIDARR" ActionTypeWhisparr ActionType = "WHISPARR" + ActionTypeReadarr ActionType = "READARR" ) type ActionContentLayout string diff --git a/internal/domain/client.go b/internal/domain/client.go index 228dc01..014862b 100644 --- a/internal/domain/client.go +++ b/internal/domain/client.go @@ -55,4 +55,5 @@ const ( DownloadClientTypeSonarr DownloadClientType = "SONARR" DownloadClientTypeLidarr DownloadClientType = "LIDARR" DownloadClientTypeWhisparr DownloadClientType = "WHISPARR" + DownloadClientTypeReadarr DownloadClientType = "READARR" ) diff --git a/internal/download_client/connection.go b/internal/download_client/connection.go index 3df465d..cba8e3a 100644 --- a/internal/download_client/connection.go +++ b/internal/download_client/connection.go @@ -9,6 +9,7 @@ import ( "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" @@ -42,6 +43,9 @@ func (s *service) testConnection(client domain.DownloadClient) error { case domain.DownloadClientTypeWhisparr: return s.testWhisparrConnection(client) + + case domain.DownloadClientTypeReadarr: + return s.testReadarrConnection(client) default: return errors.New("unsupported client") } @@ -242,3 +246,23 @@ func (s *service) testWhisparrConnection(client domain.DownloadClient) error { return nil } + +func (s *service) testReadarrConnection(client domain.DownloadClient) error { + r := readarr.New(readarr.Config{ + Hostname: client.Host, + APIKey: client.Settings.APIKey, + BasicAuth: client.Settings.Basic.Auth, + Username: client.Settings.Basic.Username, + Password: client.Settings.Basic.Password, + Log: s.subLogger, + }) + + _, err := r.Test() + if err != nil { + return errors.Wrap(err, "readarr: connection test failed: %v", client.Host) + } + + s.log.Debug().Msgf("test client connection for readarr: success") + + return nil +} diff --git a/internal/indexer/definitions/myanonamouse.yaml b/internal/indexer/definitions/myanonamouse.yaml index aab807f..c73abf5 100644 --- a/internal/indexer/definitions/myanonamouse.yaml +++ b/internal/indexer/definitions/myanonamouse.yaml @@ -59,3 +59,4 @@ parse: match: torrenturl: "{{ .baseUrl }}tor/download.php?tid={{ .torrentId }}" + torrentname: "{{ .torrentName }} by {{ .author }} [{{ .language }} / {{ .tags }}]" diff --git a/pkg/readarr/client.go b/pkg/readarr/client.go new file mode 100644 index 0000000..e0b1045 --- /dev/null +++ b/pkg/readarr/client.go @@ -0,0 +1,136 @@ +package readarr + +import ( + "bytes" + "encoding/json" + "io" + "net/http" + "net/url" + "path" + + "github.com/autobrr/autobrr/pkg/errors" +) + +func (c *client) get(endpoint string) (int, []byte, error) { + u, err := url.Parse(c.config.Hostname) + u.Path = path.Join(u.Path, "/api/v1/", endpoint) + reqUrl := u.String() + + req, err := http.NewRequest(http.MethodGet, reqUrl, http.NoBody) + if err != nil { + return 0, nil, errors.Wrap(err, "could not build request") + } + + if c.config.BasicAuth { + req.SetBasicAuth(c.config.Username, c.config.Password) + } + + c.setHeaders(req) + + resp, err := c.http.Do(req) + if err != nil { + return 0, nil, errors.Wrap(err, "readarr.http.Do(req): %+v", req) + } + + defer resp.Body.Close() + + var buf bytes.Buffer + if _, err = io.Copy(&buf, resp.Body); err != nil { + return resp.StatusCode, nil, errors.Wrap(err, "readarr.io.Copy") + } + + return resp.StatusCode, buf.Bytes(), 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/v1/", endpoint) + reqUrl := u.String() + + jsonData, err := json.Marshal(data) + if err != nil { + return nil, errors.Wrap(err, "could not marshal data: %+v", data) + } + + req, err := http.NewRequest(http.MethodPost, reqUrl, bytes.NewBuffer(jsonData)) + if err != nil { + return nil, errors.Wrap(err, "could not build request") + } + + 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 { + return nil, errors.Wrap(err, "could not make request: %+v", req) + } + + // validate response + if res.StatusCode == http.StatusUnauthorized { + return nil, errors.New("unauthorized: bad credentials") + } else if res.StatusCode != http.StatusOK { + return nil, errors.New("readarr: bad request") + } + + // return raw response and let the caller handle json unmarshal of body + return res, nil +} + +func (c *client) postBody(endpoint string, data interface{}) (int, []byte, error) { + u, err := url.Parse(c.config.Hostname) + u.Path = path.Join(u.Path, "/api/v1/", endpoint) + reqUrl := u.String() + + jsonData, err := json.Marshal(data) + if err != nil { + return 0, nil, errors.Wrap(err, "could not marshal data: %+v", data) + } + + c.Log.Printf("readarr push JSON: %s\n", string(jsonData)) + + req, err := http.NewRequest(http.MethodPost, reqUrl, bytes.NewBuffer(jsonData)) + if err != nil { + return 0, nil, errors.Wrap(err, "could not build request") + } + + if c.config.BasicAuth { + req.SetBasicAuth(c.config.Username, c.config.Password) + } + + c.setHeaders(req) + + resp, err := c.http.Do(req) + if err != nil { + return 0, nil, errors.Wrap(err, "readarr.http.Do(req): %+v", req) + } + + defer resp.Body.Close() + + var buf bytes.Buffer + if _, err = io.Copy(&buf, resp.Body); err != nil { + return resp.StatusCode, nil, errors.Wrap(err, "readarr.io.Copy") + } + + if resp.StatusCode == http.StatusBadRequest { + return resp.StatusCode, buf.Bytes(), nil + } else if resp.StatusCode < 200 || resp.StatusCode > 401 { + return resp.StatusCode, buf.Bytes(), errors.New("readarr: bad request: %v (status: %s): %s", resp.Request.RequestURI, resp.Status, buf.String()) + } + + return resp.StatusCode, buf.Bytes(), nil +} + +func (c *client) setHeaders(req *http.Request) { + if req.Body != nil { + req.Header.Set("Content-Type", "application/json") + } + + req.Header.Set("User-Agent", "autobrr") + + req.Header.Set("X-Api-Key", c.config.APIKey) +} diff --git a/pkg/readarr/readarr.go b/pkg/readarr/readarr.go new file mode 100644 index 0000000..4979928 --- /dev/null +++ b/pkg/readarr/readarr.go @@ -0,0 +1,149 @@ +package readarr + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "strings" + "time" + + "log" + + "github.com/autobrr/autobrr/pkg/errors" +) + +type Config struct { + Hostname string + APIKey string + + // basic auth username and password + BasicAuth bool + Username string + Password string + + Log *log.Logger +} + +type Client interface { + Test() (*SystemStatusResponse, error) + Push(release Release) ([]string, error) +} + +type client struct { + config Config + http *http.Client + + Log *log.Logger +} + +// New create new readarr client +func New(config Config) Client { + + httpClient := &http.Client{ + Timeout: time.Second * 30, + } + + c := &client{ + config: config, + http: httpClient, + Log: config.Log, + } + + if config.Log == nil { + // if no provided logger then use io.Discard + c.Log = log.New(io.Discard, "", log.LstdFlags) + } + + 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 BadRequestResponse struct { + PropertyName string `json:"propertyName"` + ErrorMessage string `json:"errorMessage"` + AttemptedValue string `json:"attemptedValue"` + Severity string `json:"severity"` +} + +type SystemStatusResponse struct { + AppName string `json:"appName"` + Version string `json:"version"` +} + +func (c *client) Test() (*SystemStatusResponse, error) { + status, res, err := c.get("system/status") + if err != nil { + return nil, errors.Wrap(err, "could not make Test") + } + + if status == http.StatusUnauthorized { + return nil, errors.New("unauthorized: bad credentials") + } + + c.Log.Printf("readarr system/status status: (%v) response: %v\n", status, string(res)) + + response := SystemStatusResponse{} + err = json.Unmarshal(res, &response) + if err != nil { + return nil, errors.Wrap(err, "could not unmarshal data") + } + + return &response, nil +} + +func (c *client) Push(release Release) ([]string, error) { + status, res, err := c.postBody("release/push", release) + if err != nil { + return nil, errors.Wrap(err, "could not push release to readarr") + } + + c.Log.Printf("readarr release/push status: (%v) response: %v\n", status, string(res)) + + if status == http.StatusBadRequest { + badreqResponse := make([]*BadRequestResponse, 0) + err = json.Unmarshal(res, &badreqResponse) + if err != nil { + return nil, errors.Wrap(err, "could not unmarshal data") + } + + if badreqResponse[0] != nil && badreqResponse[0].PropertyName == "Title" && badreqResponse[0].ErrorMessage == "Unable to parse" { + rejections := []string{fmt.Sprintf("unable to parse: %v", badreqResponse[0].AttemptedValue)} + return rejections, err + } + } + + // pushResponse := make([]PushResponse, 0) + var pushResponse PushResponse + err = json.Unmarshal(res, &pushResponse) + if err != nil { + return nil, errors.Wrap(err, "could not unmarshal data") + } + + // log and return if rejected + if pushResponse.Rejected { + rejections := strings.Join(pushResponse.Rejections, ", ") + + c.Log.Printf("readarr release/push rejected %v reasons: %q\n", release.Title, rejections) + return pushResponse.Rejections, nil + } + + // successful push + return nil, nil +} diff --git a/pkg/readarr/readarr_test.go b/pkg/readarr/readarr_test.go new file mode 100644 index 0000000..9897b76 --- /dev/null +++ b/pkg/readarr/readarr_test.go @@ -0,0 +1,158 @@ +package readarr + +import ( + "net/http" + "net/http/httptest" + "os" + "testing" + + "github.com/rs/zerolog" + "github.com/stretchr/testify/assert" +) + +func Test_client_Push(t *testing.T) { + // disable logger + zerolog.SetGlobalLevel(zerolog.Disabled) + + mux := http.NewServeMux() + ts := httptest.NewServer(mux) + defer ts.Close() + + key := "mock-key" + + mux.HandleFunc("/api/v1/release/push", func(w http.ResponseWriter, r *http.Request) { + // request validation logic + apiKey := r.Header.Get("X-Api-Key") + if apiKey != "" { + if apiKey != key { + w.WriteHeader(http.StatusUnauthorized) + w.Write(nil) + return + } + } + + // read json response + jsonPayload, _ := os.ReadFile("testdata/release_push_ok_response.json") + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + w.Write(jsonPayload) + }) + + type fields struct { + config Config + } + type args struct { + release Release + } + tests := []struct { + name string + fields fields + args args + err error + rejections []string + wantErr bool + }{ + { + name: "push", + fields: fields{ + config: Config{ + Hostname: ts.URL, + APIKey: "", + BasicAuth: false, + Username: "", + Password: "", + }, + }, + args: args{release: Release{ + Title: "The Best Book by Famous Author [English / epub, mobi]", + DownloadUrl: "https://www.mock-indexer.test/tor/download.php?tid=000000", + Size: 1048576, + Indexer: "mock-indexer", + DownloadProtocol: "torrent", + Protocol: "torrent", + PublishDate: "2022-10-14T17:36:15Z", + }}, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + c := New(tt.fields.config) + + rejections, err := c.Push(tt.args.release) + assert.Equal(t, tt.rejections, rejections) + if tt.wantErr && assert.Error(t, err) { + assert.Equal(t, tt.err, err) + } + }) + } +} + +func Test_client_Test(t *testing.T) { + // disable logger + zerolog.SetGlobalLevel(zerolog.Disabled) + + key := "mock-key" + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + apiKey := r.Header.Get("X-Api-Key") + if apiKey != "" { + if apiKey != key { + w.WriteHeader(http.StatusUnauthorized) + w.Write(nil) + return + } + } + jsonPayload, _ := os.ReadFile("testdata/system_status_response_ok.json") + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + w.Write(jsonPayload) + })) + defer srv.Close() + + tests := []struct { + name string + cfg Config + want *SystemStatusResponse + expectedErr string + wantErr bool + }{ + { + name: "fetch", + cfg: Config{ + Hostname: srv.URL, + APIKey: key, + BasicAuth: false, + Username: "", + Password: "", + }, + want: &SystemStatusResponse{AppName: "Readarr", Version: "0.1.1.1320"}, + expectedErr: "", + wantErr: false, + }, + { + name: "fetch_unauthorized", + cfg: Config{ + Hostname: srv.URL, + APIKey: "bad-mock-key", + BasicAuth: false, + Username: "", + Password: "", + }, + want: nil, + wantErr: true, + expectedErr: "unauthorized: bad credentials", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + c := New(tt.cfg) + + got, err := c.Test() + if tt.wantErr && assert.Error(t, err) { + assert.EqualErrorf(t, err, tt.expectedErr, "Error should be: %v, got: %v", tt.wantErr, err) + } + + assert.Equal(t, tt.want, got) + }) + } +} diff --git a/pkg/readarr/testdata/release_push_ok_response.json b/pkg/readarr/testdata/release_push_ok_response.json new file mode 100644 index 0000000..54ba624 --- /dev/null +++ b/pkg/readarr/testdata/release_push_ok_response.json @@ -0,0 +1,37 @@ +{ + "guid": "PUSH-https://www.mock-indexer.test/tor/download.php?tid=000000", + "quality": { + "quality": { + "id": 3, + "name": "EPUB" + }, + "revision": { + "version": 1, + "real": 0, + "isRepack": false + } + }, + "qualityWeight": 301, + "age": 0, + "ageHours": 0.00138141025, + "ageMinutes": 0.08288463833333333, + "size": 1048576, + "indexerId": 0, + "indexer": "mock-indexer", + "releaseHash": "", + "title": "The Best Book by Famous Author [English / epub, mobi]", + "discography": false, + "sceneSource": false, + "authorName": "Famous Author", + "bookTitle": "The best book", + "approved": true, + "temporarilyRejected": false, + "rejected": false, + "rejections": [], + "publishDate": "2022-10-14T17:36:15Z", + "downloadUrl": "https://www.mock-indexer.test/tor/download.php?tid=000000", + "downloadAllowed": true, + "releaseWeight": 0, + "preferredWordScore": 0, + "protocol": "torrent" +} \ No newline at end of file diff --git a/pkg/readarr/testdata/system_status_response_ok.json b/pkg/readarr/testdata/system_status_response_ok.json new file mode 100644 index 0000000..b54ec24 --- /dev/null +++ b/pkg/readarr/testdata/system_status_response_ok.json @@ -0,0 +1,31 @@ +{ + "appName": "Readarr", + "version": "0.1.1.1320", + "buildTime": "2022-05-09T22:53:15Z", + "isDebug": false, + "isProduction": true, + "isAdmin": false, + "isUserInteractive": true, + "startupPath": "/app/bin", + "appData": "/config", + "osName": "alpine", + "osVersion": "3.16.2", + "isNetCore": true, + "isMono": false, + "isLinux": true, + "isOsx": false, + "isWindows": false, + "isDocker": false, + "mode": "console", + "branch": "develop", + "authentication": "none", + "sqliteVersion": "3.38.5", + "migrationVersion": 21, + "urlBase": "", + "runtimeVersion": "6.0.3", + "runtimeName": "netCore", + "startTime": "2022-10-14T17:26:21Z", + "packageVersion": "testing-e06fe51", + "packageAuthor": "[hotio](https://github.com/hotio)", + "packageUpdateMechanism": "docker" +} \ No newline at end of file diff --git a/web/src/domain/constants.ts b/web/src/domain/constants.ts index 792bdfa..aa50e9a 100644 --- a/web/src/domain/constants.ts +++ b/web/src/domain/constants.ts @@ -205,6 +205,11 @@ export const DownloadClientTypeOptions: RadioFieldsetOption[] = [ label: "Whisparr", description: "Send to Whisparr and let it decide", value: "WHISPARR" + }, + { + label: "Readarr", + description: "Send to Readarr and let it decide", + value: "READARR" } ]; @@ -217,7 +222,8 @@ export const DownloadClientTypeNameMap: Record[] = [ diff --git a/web/src/forms/settings/DownloadClientForms.tsx b/web/src/forms/settings/DownloadClientForms.tsx index 21f9d98..77221a0 100644 --- a/web/src/forms/settings/DownloadClientForms.tsx +++ b/web/src/forms/settings/DownloadClientForms.tsx @@ -211,7 +211,8 @@ export const componentMap: componentMapType = { RADARR: , SONARR: , LIDARR: , - WHISPARR: + WHISPARR: , + READARR: }; function FormFieldsRulesBasic() { diff --git a/web/src/screens/filters/action.tsx b/web/src/screens/filters/action.tsx index aad4bee..dd613cc 100644 --- a/web/src/screens/filters/action.tsx +++ b/web/src/screens/filters/action.tsx @@ -385,6 +385,7 @@ const TypeForm = ({ action, idx, clients }: TypeFormProps) => { case "SONARR": case "LIDARR": case "WHISPARR": + case "READARR": return (