From fce6c7149a8b7d12a4fc2948b90824851becf7d8 Mon Sep 17 00:00:00 2001 From: Ludvig Lundgren Date: Sun, 22 Aug 2021 00:55:00 +0200 Subject: [PATCH] Feature: Sonarr (#14) * feat: add sonarr download client * feat(web): add sonarr download client and actions * feat: add sonarr to filter actions --- internal/action/service.go | 8 + internal/action/sonarr.go | 65 +++++++ internal/domain/action.go | 1 + internal/domain/client.go | 1 + internal/download_client/connection.go | 22 +++ pkg/sonarr/client.go | 82 ++++++++ pkg/sonarr/sonarr.go | 131 +++++++++++++ pkg/sonarr/sonarr_test.go | 183 ++++++++++++++++++ .../testdata/release_push_response.json | 60 ++++++ .../testdata/system_status_response.json | 28 +++ web/src/components/FilterActionList.tsx | 1 + web/src/domain/constants.ts | 6 +- web/src/domain/interfaces.ts | 9 +- web/src/forms/filters/FilterActionAddForm.tsx | 1 + .../forms/settings/downloadclient/shared.tsx | 1 + 15 files changed, 594 insertions(+), 5 deletions(-) create mode 100644 internal/action/sonarr.go create mode 100644 pkg/sonarr/client.go create mode 100644 pkg/sonarr/sonarr.go create mode 100644 pkg/sonarr/sonarr_test.go create mode 100644 pkg/sonarr/testdata/release_push_response.json create mode 100644 pkg/sonarr/testdata/system_status_response.json diff --git a/internal/action/service.go b/internal/action/service.go index f83c6d6..0d99e16 100644 --- a/internal/action/service.go +++ b/internal/action/service.go @@ -73,6 +73,14 @@ func (s *service) RunActions(torrentFile string, hash string, filter domain.Filt } }() + case domain.ActionTypeSonarr: + go func() { + err := s.sonarr(announce, action) + if err != nil { + log.Error().Err(err).Msg("error sending torrent to sonarr") + } + }() + default: log.Warn().Msgf("unsupported action: %v type: %v", action.Name, action.Type) } diff --git a/internal/action/sonarr.go b/internal/action/sonarr.go new file mode 100644 index 0000000..fd45573 --- /dev/null +++ b/internal/action/sonarr.go @@ -0,0 +1,65 @@ +package action + +import ( + "time" + + "github.com/autobrr/autobrr/internal/domain" + "github.com/autobrr/autobrr/pkg/sonarr" + + "github.com/rs/zerolog/log" +) + +func (s *service) sonarr(announce domain.Announce, action domain.Action) error { + log.Trace().Msg("action SONARR") + + // TODO validate data + + // get client for action + client, err := s.clientSvc.FindByID(action.ClientID) + if err != nil { + log.Error().Err(err).Msgf("error finding client: %v", action.ClientID) + return err + } + + // return early if no client found + if client == nil { + return err + } + + // initial config + cfg := sonarr.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 + } + + r := sonarr.New(cfg) + + release := sonarr.Release{ + Title: announce.TorrentName, + DownloadUrl: announce.TorrentUrl, + Size: 0, + Indexer: announce.Site, + DownloadProtocol: "torrent", + Protocol: "torrent", + PublishDate: time.Now().String(), + } + + err = r.Push(release) + if err != nil { + log.Error().Err(err).Msgf("sonarr: failed to push release: %v", release) + return err + } + + // TODO save pushed release + + log.Debug().Msgf("sonarr: successfully pushed release: %v, indexer %v to %v", release.Title, release.Indexer, client.Host) + + return nil +} diff --git a/internal/domain/action.go b/internal/domain/action.go index 46ff12b..92c8779 100644 --- a/internal/domain/action.go +++ b/internal/domain/action.go @@ -38,4 +38,5 @@ const ( ActionTypeDelugeV2 ActionType = "DELUGE_V2" ActionTypeWatchFolder ActionType = "WATCH_FOLDER" ActionTypeRadarr ActionType = "RADARR" + ActionTypeSonarr ActionType = "SONARR" ) diff --git a/internal/domain/client.go b/internal/domain/client.go index d827ca2..0260e7b 100644 --- a/internal/domain/client.go +++ b/internal/domain/client.go @@ -39,4 +39,5 @@ const ( DownloadClientTypeDelugeV1 DownloadClientType = "DELUGE_V1" DownloadClientTypeDelugeV2 DownloadClientType = "DELUGE_V2" DownloadClientTypeRadarr DownloadClientType = "RADARR" + DownloadClientTypeSonarr DownloadClientType = "SONARR" ) diff --git a/internal/download_client/connection.go b/internal/download_client/connection.go index be2d7bc..5a0bbe4 100644 --- a/internal/download_client/connection.go +++ b/internal/download_client/connection.go @@ -6,6 +6,7 @@ import ( "github.com/autobrr/autobrr/internal/domain" "github.com/autobrr/autobrr/pkg/qbittorrent" "github.com/autobrr/autobrr/pkg/radarr" + "github.com/autobrr/autobrr/pkg/sonarr" delugeClient "github.com/gdm85/go-libdeluge" "github.com/rs/zerolog/log" @@ -21,6 +22,9 @@ func (s *service) testConnection(client domain.DownloadClient) error { case domain.DownloadClientTypeRadarr: return s.testRadarrConnection(client) + + case domain.DownloadClientTypeSonarr: + return s.testSonarrConnection(client) } return nil @@ -106,3 +110,21 @@ func (s *service) testRadarrConnection(client domain.DownloadClient) error { return nil } + +func (s *service) testSonarrConnection(client domain.DownloadClient) error { + r := sonarr.New(sonarr.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("sonarr: connection test failed: %v", client.Host) + return err + } + + return nil +} diff --git a/pkg/sonarr/client.go b/pkg/sonarr/client.go new file mode 100644 index 0000000..d7d2d17 --- /dev/null +++ b/pkg/sonarr/client.go @@ -0,0 +1,82 @@ +package sonarr + +import ( + "bytes" + "encoding/json" + "errors" + "fmt" + "net/http" + + "github.com/rs/zerolog/log" +) + +func (c *client) get(endpoint string) (*http.Response, error) { + reqUrl := fmt.Sprintf("%v/api/v3/%v", c.config.Hostname, endpoint) + + req, err := http.NewRequest(http.MethodGet, reqUrl, http.NoBody) + if err != nil { + log.Error().Err(err).Msgf("sonarr 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("sonarr 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) { + reqUrl := fmt.Sprintf("%v/api/v3/%v", c.config.Hostname, endpoint) + + jsonData, err := json.Marshal(data) + if err != nil { + log.Error().Err(err).Msgf("sonarr 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("sonarr 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("sonarr client request error: %v", reqUrl) + return nil, err + } + + // validate response + if res.StatusCode == http.StatusUnauthorized { + log.Error().Err(err).Msgf("sonarr client bad request: %v", reqUrl) + return nil, errors.New("unauthorized: bad credentials") + } else if res.StatusCode != http.StatusOK { + log.Error().Err(err).Msgf("sonarr client request error: %v", reqUrl) + return nil, nil + } + + // return raw response and let the caller handle json unmarshal of body + return res, nil +} diff --git a/pkg/sonarr/sonarr.go b/pkg/sonarr/sonarr.go new file mode 100644 index 0000000..dcf9ced --- /dev/null +++ b/pkg/sonarr/sonarr.go @@ -0,0 +1,131 @@ +package sonarr + +import ( + "encoding/json" + "errors" + "fmt" + "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) error +} + +type client struct { + config Config + http *http.Client +} + +// New create new sonarr 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().Err(err).Msg("sonarr client get error") + return nil, err + } + + defer res.Body.Close() + + body, err := io.ReadAll(res.Body) + if err != nil { + log.Error().Err(err).Msg("sonarr client error reading body") + return nil, err + } + + response := SystemStatusResponse{} + err = json.Unmarshal(body, &response) + if err != nil { + log.Error().Err(err).Msg("sonarr client error json unmarshal") + return nil, err + } + + log.Trace().Msgf("sonarr system/status response: %+v", response) + + return &response, nil +} + +func (c *client) Push(release Release) error { + res, err := c.post("release/push", release) + if err != nil { + log.Error().Err(err).Msg("sonarr client post error") + return err + } + + defer res.Body.Close() + + body, err := io.ReadAll(res.Body) + if err != nil { + log.Error().Err(err).Msg("sonarr client error reading body") + return err + } + + pushResponse := make([]PushResponse, 0) + err = json.Unmarshal(body, &pushResponse) + if err != nil { + log.Error().Err(err).Msg("sonarr client error json unmarshal") + return err + } + + log.Trace().Msgf("sonarr 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("sonarr push rejected: %s - reasons: %q", release.Title, rejections) + return errors.New(fmt.Errorf("sonarr push rejected %v", rejections).Error()) + } + + return nil +} diff --git a/pkg/sonarr/sonarr_test.go b/pkg/sonarr/sonarr_test.go new file mode 100644 index 0000000..bc36226 --- /dev/null +++ b/pkg/sonarr/sonarr_test.go @@ -0,0 +1,183 @@ +package sonarr + +import ( + "errors" + "io/ioutil" + "net/http" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/assert" + + "github.com/rs/zerolog" +) + +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/v3/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, _ := ioutil.ReadFile("testdata/release_push_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 + wantErr bool + }{ + { + name: "push", + fields: fields{ + config: Config{ + Hostname: ts.URL, + APIKey: "", + BasicAuth: false, + Username: "", + Password: "", + }, + }, + args: args{release: Release{ + Title: "That Show S01 2160p ATVP WEB-DL DDP 5.1 Atmos DV HEVC-NOGROUP", + DownloadUrl: "https://www.test.org/rss/download/0000001/00000000000000000000/That Show S01 2160p ATVP WEB-DL DDP 5.1 Atmos DV HEVC-NOGROUP.torrent", + Size: 0, + Indexer: "test", + DownloadProtocol: "torrent", + Protocol: "torrent", + PublishDate: "2021-08-21T15:36:00Z", + }}, + err: errors.New("sonarr push rejected Unknown Series"), + wantErr: true, + }, + { + name: "push_error", + fields: fields{ + config: Config{ + Hostname: ts.URL, + APIKey: key, + BasicAuth: false, + Username: "", + Password: "", + }, + }, + args: args{release: Release{ + Title: "That Show S01 2160p ATVP WEB-DL DDP 5.1 Atmos DV HEVC-NOGROUP", + DownloadUrl: "https://www.test.org/rss/download/0000001/00000000000000000000/That Show S01 2160p ATVP WEB-DL DDP 5.1 Atmos DV HEVC-NOGROUP.torrent", + Size: 0, + Indexer: "test", + DownloadProtocol: "torrent", + Protocol: "torrent", + PublishDate: "2021-08-21T15:36:00Z", + }}, + err: errors.New("sonarr push rejected Unknown Series"), + wantErr: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + c := New(tt.fields.config) + + err := c.Push(tt.args.release) + 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, _ := ioutil.ReadFile("testdata/system_status_response.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 + err error + wantErr bool + }{ + { + name: "fetch", + cfg: Config{ + Hostname: srv.URL, + APIKey: key, + BasicAuth: false, + Username: "", + Password: "", + }, + want: &SystemStatusResponse{Version: "3.0.6.1196"}, + err: nil, + wantErr: false, + }, + { + name: "fetch_unauthorized", + cfg: Config{ + Hostname: srv.URL, + APIKey: "bad-mock-key", + BasicAuth: false, + Username: "", + Password: "", + }, + want: nil, + wantErr: true, + err: errors.New("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.Equal(t, tt.err, err) + } + + assert.Equal(t, tt.want, got) + }) + } +} diff --git a/pkg/sonarr/testdata/release_push_response.json b/pkg/sonarr/testdata/release_push_response.json new file mode 100644 index 0000000..67cf7f9 --- /dev/null +++ b/pkg/sonarr/testdata/release_push_response.json @@ -0,0 +1,60 @@ +[ + { + "guid": "PUSH-https://www.test.org/rss/download/0000001/00000000000000000000/That Show S01 2160p ATVP WEB-DL DDP 5.1 Atmos DV HEVC-NOGROUP.torrent", + "quality": { + "quality": { + "id": 18, + "name": "WEBDL-2160p", + "source": "web", + "resolution": 2160 + }, + "revision": { + "version": 1, + "real": 0, + "isRepack": false + } + }, + "qualityWeight": 1401, + "age": 1301, + "ageHours": 31239.6100518075, + "ageMinutes": 1874376.60310845, + "size": 0, + "indexerId": 0, + "indexer": "test", + "releaseGroup": "NOGROUP", + "releaseHash": "", + "title": "That Show S01 2160p ATVP WEB-DL DDP 5.1 Atmos DV HEVC-NOGROUP", + "fullSeason": true, + "sceneSource": false, + "seasonNumber": 1, + "language": { + "id": 1, + "name": "English" + }, + "languageWeight": 2200, + "seriesTitle": "That Show", + "episodeNumbers": [], + "absoluteEpisodeNumbers": [], + "mappedEpisodeNumbers": [], + "mappedAbsoluteEpisodeNumbers": [], + "approved": false, + "temporarilyRejected": false, + "rejected": true, + "tvdbId": 0, + "tvRageId": 0, + "rejections": [ + "Unknown Series" + ], + "publishDate": "2018-01-28T07:00:00Z", + "downloadUrl": "https://www.test.org/rss/download/0000001/00000000000000000000/That Show S01 2160p ATVP WEB-DL DDP 5.1 Atmos DV HEVC-NOGROUP.torrent", + "episodeRequested": false, + "downloadAllowed": false, + "releaseWeight": 0, + "preferredWordScore": 0, + "protocol": "torrent", + "isDaily": false, + "isAbsoluteNumbering": false, + "isPossibleSpecialEpisode": false, + "special": false + } +] \ No newline at end of file diff --git a/pkg/sonarr/testdata/system_status_response.json b/pkg/sonarr/testdata/system_status_response.json new file mode 100644 index 0000000..a37b1d9 --- /dev/null +++ b/pkg/sonarr/testdata/system_status_response.json @@ -0,0 +1,28 @@ +{ + "version": "3.0.6.1196", + "buildTime": "2018-01-28T07:00:00Z", + "isDebug": false, + "isProduction": true, + "isAdmin": false, + "isUserInteractive": false, + "startupPath": "/usr/lib/sonarr/bin", + "appData": "/home/test/.config/sonarr", + "osName": "debian", + "osVersion": "10", + "isMonoRuntime": true, + "isMono": true, + "isLinux": true, + "isOsx": false, + "isWindows": false, + "mode": "console", + "branch": "main", + "authentication": "basic", + "sqliteVersion": "3.27.2", + "urlBase": "/sonarr", + "runtimeVersion": "6.8.0.123", + "runtimeName": "mono", + "startTime": "2021-08-20T21:04:34.409467Z", + "packageVersion": "3.0.6", + "packageAuthor": "[Team Sonarr](https://sonarr.tv)", + "packageUpdateMechanism": "builtIn" +} \ No newline at end of file diff --git a/web/src/components/FilterActionList.tsx b/web/src/components/FilterActionList.tsx index 34e0d75..a7c8e93 100644 --- a/web/src/components/FilterActionList.tsx +++ b/web/src/components/FilterActionList.tsx @@ -316,6 +316,7 @@ function ListItem({ action, clients, filterID, idx }: ListItemProps) { ); case "RADARR": + case "SONARR": return (
); case "RADARR": + case "SONARR": return (
, QBITTORRENT: , RADARR: , + SONARR: , };