From e6cfc77e8523c24b4f0d18a9c6a5caa09921fab5 Mon Sep 17 00:00:00 2001 From: Ludvig Lundgren Date: Sun, 22 Aug 2021 02:17:13 +0200 Subject: [PATCH] Feature: Lidarr (#15) * feat(web): add lidarr download client * feat: add lidarr download client --- internal/action/lidarr.go | 65 +++++++ internal/action/service.go | 7 + internal/domain/action.go | 1 + internal/domain/client.go | 1 + internal/download_client/connection.go | 22 +++ pkg/lidarr/client.go | 82 ++++++++ pkg/lidarr/lidarr.go | 131 +++++++++++++ pkg/lidarr/lidarr_test.go | 182 ++++++++++++++++++ .../testdata/release_push_response.json | 39 ++++ .../testdata/system_status_response.json | 28 +++ web/src/components/FilterActionList.tsx | 1 + web/src/domain/constants.ts | 8 +- web/src/domain/interfaces.ts | 9 +- web/src/forms/filters/FilterActionAddForm.tsx | 1 + .../forms/filters/FilterActionUpdateForm.tsx | 2 + .../forms/settings/downloadclient/shared.tsx | 18 +- 16 files changed, 582 insertions(+), 15 deletions(-) create mode 100644 internal/action/lidarr.go create mode 100644 pkg/lidarr/client.go create mode 100644 pkg/lidarr/lidarr.go create mode 100644 pkg/lidarr/lidarr_test.go create mode 100644 pkg/lidarr/testdata/release_push_response.json create mode 100644 pkg/lidarr/testdata/system_status_response.json diff --git a/internal/action/lidarr.go b/internal/action/lidarr.go new file mode 100644 index 0000000..8737945 --- /dev/null +++ b/internal/action/lidarr.go @@ -0,0 +1,65 @@ +package action + +import ( + "time" + + "github.com/autobrr/autobrr/internal/domain" + "github.com/autobrr/autobrr/pkg/lidarr" + + "github.com/rs/zerolog/log" +) + +func (s *service) lidarr(announce domain.Announce, action domain.Action) error { + log.Trace().Msg("action LIDARR") + + // 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 := lidarr.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 := lidarr.New(cfg) + + release := lidarr.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("lidarr: failed to push release: %v", release) + return err + } + + // TODO save pushed release + + log.Debug().Msgf("lidarr: successfully pushed release: %v, indexer %v to %v", release.Title, release.Indexer, client.Host) + + return nil +} diff --git a/internal/action/service.go b/internal/action/service.go index 0d99e16..20074e0 100644 --- a/internal/action/service.go +++ b/internal/action/service.go @@ -80,6 +80,13 @@ func (s *service) RunActions(torrentFile string, hash string, filter domain.Filt log.Error().Err(err).Msg("error sending torrent to sonarr") } }() + case domain.ActionTypeLidarr: + go func() { + err := s.lidarr(announce, action) + if err != nil { + log.Error().Err(err).Msg("error sending torrent to lidarr") + } + }() default: log.Warn().Msgf("unsupported action: %v type: %v", action.Name, action.Type) diff --git a/internal/domain/action.go b/internal/domain/action.go index 92c8779..f317405 100644 --- a/internal/domain/action.go +++ b/internal/domain/action.go @@ -39,4 +39,5 @@ const ( ActionTypeWatchFolder ActionType = "WATCH_FOLDER" ActionTypeRadarr ActionType = "RADARR" ActionTypeSonarr ActionType = "SONARR" + ActionTypeLidarr ActionType = "LIDARR" ) diff --git a/internal/domain/client.go b/internal/domain/client.go index 0260e7b..f4f3908 100644 --- a/internal/domain/client.go +++ b/internal/domain/client.go @@ -40,4 +40,5 @@ const ( DownloadClientTypeDelugeV2 DownloadClientType = "DELUGE_V2" DownloadClientTypeRadarr DownloadClientType = "RADARR" DownloadClientTypeSonarr DownloadClientType = "SONARR" + DownloadClientTypeLidarr DownloadClientType = "LIDARR" ) diff --git a/internal/download_client/connection.go b/internal/download_client/connection.go index 5a0bbe4..ea63de9 100644 --- a/internal/download_client/connection.go +++ b/internal/download_client/connection.go @@ -4,6 +4,7 @@ import ( "time" "github.com/autobrr/autobrr/internal/domain" + "github.com/autobrr/autobrr/pkg/lidarr" "github.com/autobrr/autobrr/pkg/qbittorrent" "github.com/autobrr/autobrr/pkg/radarr" "github.com/autobrr/autobrr/pkg/sonarr" @@ -25,6 +26,9 @@ func (s *service) testConnection(client domain.DownloadClient) error { case domain.DownloadClientTypeSonarr: return s.testSonarrConnection(client) + + case domain.DownloadClientTypeLidarr: + return s.testLidarrConnection(client) } return nil @@ -128,3 +132,21 @@ func (s *service) testSonarrConnection(client domain.DownloadClient) error { return nil } + +func (s *service) testLidarrConnection(client domain.DownloadClient) error { + r := lidarr.New(lidarr.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("lidarr: connection test failed: %v", client.Host) + return err + } + + return nil +} diff --git a/pkg/lidarr/client.go b/pkg/lidarr/client.go new file mode 100644 index 0000000..ac59ca4 --- /dev/null +++ b/pkg/lidarr/client.go @@ -0,0 +1,82 @@ +package lidarr + +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/v1/%v", c.config.Hostname, endpoint) + + req, err := http.NewRequest(http.MethodGet, reqUrl, http.NoBody) + if err != nil { + log.Error().Err(err).Msgf("lidarr 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("lidarr 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/v1/%v", c.config.Hostname, endpoint) + + jsonData, err := json.Marshal(data) + if err != nil { + log.Error().Err(err).Msgf("lidarr 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("lidarr 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("lidarr client request error: %v", reqUrl) + return nil, err + } + + // validate response + if res.StatusCode == http.StatusUnauthorized { + log.Error().Err(err).Msgf("lidarr client bad request: %v", reqUrl) + return nil, errors.New("unauthorized: bad credentials") + } else if res.StatusCode != http.StatusOK { + log.Error().Err(err).Msgf("lidarr 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/lidarr/lidarr.go b/pkg/lidarr/lidarr.go new file mode 100644 index 0000000..886a720 --- /dev/null +++ b/pkg/lidarr/lidarr.go @@ -0,0 +1,131 @@ +package lidarr + +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 lidarr 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("lidarr client get error") + return nil, err + } + + defer res.Body.Close() + + body, err := io.ReadAll(res.Body) + if err != nil { + log.Error().Err(err).Msg("lidarr client error reading body") + return nil, err + } + + response := SystemStatusResponse{} + err = json.Unmarshal(body, &response) + if err != nil { + log.Error().Err(err).Msg("lidarr client error json unmarshal") + return nil, err + } + + log.Trace().Msgf("lidarr 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("lidarr client post error") + return err + } + + defer res.Body.Close() + + body, err := io.ReadAll(res.Body) + if err != nil { + log.Error().Err(err).Msg("lidarr client error reading body") + return err + } + + pushResponse := PushResponse{} + err = json.Unmarshal(body, &pushResponse) + if err != nil { + log.Error().Err(err).Msg("lidarr client error json unmarshal") + return err + } + + log.Trace().Msgf("lidarr release/push response body: %+v", string(body)) + + // log and return if rejected + if pushResponse.Rejected { + rejections := strings.Join(pushResponse.Rejections, ", ") + + log.Trace().Msgf("lidarr push rejected: %s - reasons: %q", release.Title, rejections) + return errors.New(fmt.Errorf("lidarr push rejected %v", rejections).Error()) + } + + return nil +} diff --git a/pkg/lidarr/lidarr_test.go b/pkg/lidarr/lidarr_test.go new file mode 100644 index 0000000..339f582 --- /dev/null +++ b/pkg/lidarr/lidarr_test.go @@ -0,0 +1,182 @@ +package lidarr + +import ( + "errors" + "io/ioutil" + "net/http" + "net/http/httptest" + "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, _ := 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: "JR Get Money - Nobody But You [2008] [Single] - FLAC / Lossless / Log / 100% / Cue / CD", + 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("lidarr push rejected Unknown Artist"), + wantErr: true, + }, + { + name: "push_error", + fields: fields{ + config: Config{ + Hostname: ts.URL, + APIKey: key, + BasicAuth: false, + Username: "", + Password: "", + }, + }, + args: args{release: Release{ + Title: "JR Get Money - Nobody But You [2008] [Single] - FLAC / Lossless / Log / 100% / Cue / CD", + 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("lidarr push rejected Unknown Artist"), + 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: "0.8.1.2135"}, + 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/lidarr/testdata/release_push_response.json b/pkg/lidarr/testdata/release_push_response.json new file mode 100644 index 0000000..0dd10d3 --- /dev/null +++ b/pkg/lidarr/testdata/release_push_response.json @@ -0,0 +1,39 @@ +{ + "guid": "PUSH-https://localhost/test/download.torrent", + "quality": { + "quality": { + "id": 6, + "name": "FLAC" + }, + "revision": { + "version": 1, + "real": 0, + "isRepack": false + } + }, + "qualityWeight": 1, + "age": 1301, + "ageHours": 31240.51104018011, + "ageMinutes": 1874430.6624115533, + "size": 0, + "indexerId": 0, + "indexer": "test", + "releaseHash": "", + "title": "JR Get Money - Nobody But You [2008] [Single] - FLAC / Lossless / Log / 100% / Cue / CD", + "discography": false, + "sceneSource": false, + "artistName": "JR Get Money", + "albumTitle": "Nobody But You", + "approved": false, + "temporarilyRejected": false, + "rejected": true, + "rejections": [ + "Unknown Artist" + ], + "publishDate": "2018-01-28T07:00:00Z", + "downloadUrl": "https://localhost/test/download.torrent", + "downloadAllowed": false, + "releaseWeight": 0, + "preferredWordScore": 0, + "protocol": "torrent" +} \ No newline at end of file diff --git a/pkg/lidarr/testdata/system_status_response.json b/pkg/lidarr/testdata/system_status_response.json new file mode 100644 index 0000000..22b474d --- /dev/null +++ b/pkg/lidarr/testdata/system_status_response.json @@ -0,0 +1,28 @@ +{ + "version": "0.8.1.2135", + "buildTime": "2021-04-18T15:43:22Z", + "isDebug": false, + "isProduction": true, + "isAdmin": false, + "isUserInteractive": false, + "startupPath": "/opt/Lidarr", + "appData": "/home/test/.config/Lidarr", + "osName": "debian", + "osVersion": "10", + "isNetCore": true, + "isMono": false, + "isLinux": true, + "isOsx": false, + "isWindows": false, + "isDocker": false, + "mode": "console", + "branch": "master", + "authentication": "none", + "sqliteVersion": "3.27.2", + "migrationVersion": 45, + "urlBase": "/lidarr", + "runtimeVersion": "3.1.13", + "runtimeName": "netCore", + "startTime": "2021-08-21T23:18:16.4948193Z", + "packageUpdateMechanism": "builtIn" +} \ No newline at end of file diff --git a/web/src/components/FilterActionList.tsx b/web/src/components/FilterActionList.tsx index a7c8e93..e4f4b32 100644 --- a/web/src/components/FilterActionList.tsx +++ b/web/src/components/FilterActionList.tsx @@ -317,6 +317,7 @@ function ListItem({ action, clients, filterID, idx }: ListItemProps) { ); case "RADARR": case "SONARR": + case "LIDARR": return (
); case "RADARR": + case "SONARR": + case "LIDARR": return (
@@ -20,7 +20,7 @@ function FormDefaultClientFields() { ); } -function FormRadarrFields() { +function FormFieldsArr() { const { input } = useField("settings.basic.auth"); return ( @@ -42,11 +42,11 @@ function FormRadarrFields() { ); } -// @ts-ignore export const componentMap: any = { - DELUGE_V1: , - DELUGE_V2: , - QBITTORRENT: , - RADARR: , - SONARR: , + DELUGE_V1: , + DELUGE_V2: , + QBITTORRENT: , + RADARR: , + SONARR: , + LIDARR: , };