diff --git a/internal/action/radarr.go b/internal/action/radarr.go new file mode 100644 index 0000000..c2b4b51 --- /dev/null +++ b/internal/action/radarr.go @@ -0,0 +1,65 @@ +package action + +import ( + "time" + + "github.com/autobrr/autobrr/internal/domain" + "github.com/autobrr/autobrr/pkg/radarr" + + "github.com/rs/zerolog/log" +) + +func (s *service) radarr(announce domain.Announce, action domain.Action) error { + log.Trace().Msg("action RADARR") + + // 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 := radarr.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 := radarr.New(cfg) + + release := radarr.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("radarr: failed to push release: %v", release) + return err + } + + // TODO save pushed release + + log.Debug().Msgf("radarr: 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 207f878..f83c6d6 100644 --- a/internal/action/service.go +++ b/internal/action/service.go @@ -65,7 +65,14 @@ func (s *service) RunActions(torrentFile string, hash string, filter domain.Filt } }() - // pvr *arr + case domain.ActionTypeRadarr: + go func() { + err := s.radarr(announce, action) + if err != nil { + log.Error().Err(err).Msg("error sending torrent to radarr") + } + }() + default: log.Warn().Msgf("unsupported action: %v type: %v", action.Name, action.Type) } diff --git a/internal/database/download_client.go b/internal/database/download_client.go index ae1773c..a4043c1 100644 --- a/internal/database/download_client.go +++ b/internal/database/download_client.go @@ -2,6 +2,7 @@ package database import ( "database/sql" + "encoding/json" "github.com/autobrr/autobrr/internal/domain" @@ -18,7 +19,7 @@ func NewDownloadClientRepo(db *sql.DB) domain.DownloadClientRepo { func (r *DownloadClientRepo) List() ([]domain.DownloadClient, error) { - rows, err := r.db.Query("SELECT id, name, type, enabled, host, port, ssl, username, password FROM client") + rows, err := r.db.Query("SELECT id, name, type, enabled, host, port, ssl, username, password, settings FROM client") if err != nil { log.Fatal().Err(err) } @@ -29,14 +30,21 @@ func (r *DownloadClientRepo) List() ([]domain.DownloadClient, error) { for rows.Next() { var f domain.DownloadClient + var settingsJsonStr string - if err := rows.Scan(&f.ID, &f.Name, &f.Type, &f.Enabled, &f.Host, &f.Port, &f.SSL, &f.Username, &f.Password); err != nil { + if err := rows.Scan(&f.ID, &f.Name, &f.Type, &f.Enabled, &f.Host, &f.Port, &f.SSL, &f.Username, &f.Password, &settingsJsonStr); err != nil { log.Error().Err(err) } if err != nil { return nil, err } + if settingsJsonStr != "" { + if err := json.Unmarshal([]byte(settingsJsonStr), &f.Settings); err != nil { + return nil, err + } + } + clients = append(clients, f) } if err := rows.Err(); err != nil { @@ -49,7 +57,7 @@ func (r *DownloadClientRepo) List() ([]domain.DownloadClient, error) { func (r *DownloadClientRepo) FindByID(id int32) (*domain.DownloadClient, error) { query := ` - SELECT id, name, type, enabled, host, port, ssl, username, password FROM client WHERE id = ? + SELECT id, name, type, enabled, host, port, ssl, username, password, settings FROM client WHERE id = ? ` row := r.db.QueryRow(query, id) @@ -58,18 +66,25 @@ func (r *DownloadClientRepo) FindByID(id int32) (*domain.DownloadClient, error) } var client domain.DownloadClient + var settingsJsonStr string - if err := row.Scan(&client.ID, &client.Name, &client.Type, &client.Enabled, &client.Host, &client.Port, &client.SSL, &client.Username, &client.Password); err != nil { + if err := row.Scan(&client.ID, &client.Name, &client.Type, &client.Enabled, &client.Host, &client.Port, &client.SSL, &client.Username, &client.Password, &settingsJsonStr); err != nil { log.Error().Err(err).Msg("could not scan download client to struct") return nil, err } + if settingsJsonStr != "" { + if err := json.Unmarshal([]byte(settingsJsonStr), &client.Settings); err != nil { + return nil, err + } + } + return &client, nil } func (r *DownloadClientRepo) FindByActionID(actionID int) ([]domain.DownloadClient, error) { - rows, err := r.db.Query("SELECT id, name, type, enabled, host, port, ssl, username, password FROM client, action_client WHERE client.id = action_client.client_id AND action_client.action_id = ?", actionID) + rows, err := r.db.Query("SELECT id, name, type, enabled, host, port, ssl, username, password, settings FROM client, action_client WHERE client.id = action_client.client_id AND action_client.action_id = ?", actionID) if err != nil { log.Fatal().Err(err) } @@ -79,14 +94,21 @@ func (r *DownloadClientRepo) FindByActionID(actionID int) ([]domain.DownloadClie var clients []domain.DownloadClient for rows.Next() { var f domain.DownloadClient + var settingsJsonStr string - if err := rows.Scan(&f.ID, &f.Name, &f.Type, &f.Enabled, &f.Host, &f.Port, &f.SSL, &f.Username, &f.Password); err != nil { + if err := rows.Scan(&f.ID, &f.Name, &f.Type, &f.Enabled, &f.Host, &f.Port, &f.SSL, &f.Username, &f.Password, &settingsJsonStr); err != nil { log.Error().Err(err) } if err != nil { return nil, err } + if settingsJsonStr != "" { + if err := json.Unmarshal([]byte(settingsJsonStr), &f.Settings); err != nil { + return nil, err + } + } + clients = append(clients, f) } if err := rows.Err(); err != nil { @@ -97,16 +119,27 @@ func (r *DownloadClientRepo) FindByActionID(actionID int) ([]domain.DownloadClie } func (r *DownloadClientRepo) Store(client domain.DownloadClient) (*domain.DownloadClient, error) { - var err error + + settings := domain.DownloadClientSettings{ + APIKey: client.Settings.APIKey, + Basic: client.Settings.Basic, + } + + settingsJson, err := json.Marshal(&settings) + if err != nil { + log.Error().Err(err).Msgf("could not marshal download client settings %v", settings) + return nil, err + } + if client.ID != 0 { log.Info().Msg("UPDATE existing record") - _, err = r.db.Exec(`UPDATE client SET name = ?, type = ?, enabled = ?, host = ?, port = ?, ssl = ?, username = ?, password = ? WHERE id = ?`, client.Name, client.Type, client.Enabled, client.Host, client.Port, client.SSL, client.Username, client.Password, client.ID) + _, err = r.db.Exec(`UPDATE client SET name = ?, type = ?, enabled = ?, host = ?, port = ?, ssl = ?, username = ?, password = ?, settings = json_set(?) WHERE id = ?`, client.Name, client.Type, client.Enabled, client.Host, client.Port, client.SSL, client.Username, client.Password, client.ID, settingsJson) } else { var res sql.Result - res, err = r.db.Exec(`INSERT INTO client(name, type, enabled, host, port, ssl, username, password) - VALUES (?, ?, ?, ?, ?, ? , ?, ?) ON CONFLICT DO NOTHING`, client.Name, client.Type, client.Enabled, client.Host, client.Port, client.SSL, client.Username, client.Password) + res, err = r.db.Exec(`INSERT INTO client(name, type, enabled, host, port, ssl, username, password, settings) + VALUES (?, ?, ?, ?, ?, ? , ?, ?, json_set(?)) ON CONFLICT DO NOTHING`, client.Name, client.Type, client.Enabled, client.Host, client.Port, client.SSL, client.Username, client.Password, settingsJson) if err != nil { log.Error().Err(err) return nil, err diff --git a/internal/database/migrate.go b/internal/database/migrate.go index 8687ccc..c0f30ef 100644 --- a/internal/database/migrate.go +++ b/internal/database/migrate.go @@ -111,7 +111,7 @@ CREATE TABLE client ssl BOOLEAN, username TEXT, password TEXT, - settings TEXT + settings JSON ); CREATE TABLE action diff --git a/internal/domain/action.go b/internal/domain/action.go index dafe2cb..46ff12b 100644 --- a/internal/domain/action.go +++ b/internal/domain/action.go @@ -37,4 +37,5 @@ const ( ActionTypeDelugeV1 ActionType = "DELUGE_V1" ActionTypeDelugeV2 ActionType = "DELUGE_V2" ActionTypeWatchFolder ActionType = "WATCH_FOLDER" + ActionTypeRadarr ActionType = "RADARR" ) diff --git a/internal/domain/client.go b/internal/domain/client.go index 8d717c2..d827ca2 100644 --- a/internal/domain/client.go +++ b/internal/domain/client.go @@ -9,15 +9,27 @@ type DownloadClientRepo interface { } type DownloadClient struct { - ID int `json:"id"` - Name string `json:"name"` - Type DownloadClientType `json:"type"` - Enabled bool `json:"enabled"` - Host string `json:"host"` - Port int `json:"port"` - SSL bool `json:"ssl"` - Username string `json:"username"` - Password string `json:"password"` + ID int `json:"id"` + Name string `json:"name"` + Type DownloadClientType `json:"type"` + Enabled bool `json:"enabled"` + Host string `json:"host"` + Port int `json:"port"` + SSL bool `json:"ssl"` + Username string `json:"username"` + Password string `json:"password"` + Settings DownloadClientSettings `json:"settings,omitempty"` +} + +type DownloadClientSettings struct { + APIKey string `json:"apikey,omitempty"` + Basic BasicAuth `json:"basic,omitempty"` +} + +type BasicAuth struct { + Auth bool `json:"auth,omitempty"` + Username string `json:"username,omitempty"` + Password string `json:"password,omitempty"` } type DownloadClientType string @@ -26,4 +38,5 @@ const ( DownloadClientTypeQbittorrent DownloadClientType = "QBITTORRENT" DownloadClientTypeDelugeV1 DownloadClientType = "DELUGE_V1" DownloadClientTypeDelugeV2 DownloadClientType = "DELUGE_V2" + DownloadClientTypeRadarr DownloadClientType = "RADARR" ) diff --git a/internal/download_client/connection.go b/internal/download_client/connection.go new file mode 100644 index 0000000..be2d7bc --- /dev/null +++ b/internal/download_client/connection.go @@ -0,0 +1,108 @@ +package download_client + +import ( + "time" + + "github.com/autobrr/autobrr/internal/domain" + "github.com/autobrr/autobrr/pkg/qbittorrent" + "github.com/autobrr/autobrr/pkg/radarr" + + delugeClient "github.com/gdm85/go-libdeluge" + "github.com/rs/zerolog/log" +) + +func (s *service) testConnection(client domain.DownloadClient) error { + switch client.Type { + case domain.DownloadClientTypeQbittorrent: + return s.testQbittorrentConnection(client) + + case domain.DownloadClientTypeDelugeV1, domain.DownloadClientTypeDelugeV2: + return s.testDelugeConnection(client) + + case domain.DownloadClientTypeRadarr: + return s.testRadarrConnection(client) + } + + return nil +} + +func (s *service) testQbittorrentConnection(client domain.DownloadClient) error { + qbtSettings := qbittorrent.Settings{ + Hostname: client.Host, + Port: uint(client.Port), + Username: client.Username, + Password: client.Password, + SSL: client.SSL, + } + + qbt := qbittorrent.NewClient(qbtSettings) + err := qbt.Login() + if err != nil { + log.Error().Err(err).Msgf("error logging into client: %v", client.Host) + return err + } + + return nil +} + +func (s *service) testDelugeConnection(client domain.DownloadClient) error { + var deluge delugeClient.DelugeClient + + settings := delugeClient.Settings{ + Hostname: client.Host, + Port: uint(client.Port), + Login: client.Username, + Password: client.Password, + DebugServerResponses: true, + ReadWriteTimeout: time.Second * 10, + } + + switch client.Type { + case "DELUGE_V1": + deluge = delugeClient.NewV1(settings) + + case "DELUGE_V2": + deluge = delugeClient.NewV2(settings) + + default: + deluge = delugeClient.NewV2(settings) + } + + // perform connection to Deluge server + err := deluge.Connect() + if err != nil { + log.Error().Err(err).Msgf("error logging into client: %v", client.Host) + return err + } + + defer deluge.Close() + + // print daemon version + ver, err := deluge.DaemonVersion() + if err != nil { + log.Error().Err(err).Msgf("could not get daemon version: %v", client.Host) + return err + } + + log.Debug().Msgf("daemon version: %v", ver) + + return nil +} + +func (s *service) testRadarrConnection(client domain.DownloadClient) error { + r := radarr.New(radarr.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("radarr: connection test failed: %v", client.Host) + return err + } + + return nil +} diff --git a/internal/download_client/service.go b/internal/download_client/service.go index 5af27fd..59cef0a 100644 --- a/internal/download_client/service.go +++ b/internal/download_client/service.go @@ -2,11 +2,9 @@ package download_client import ( "errors" - "time" "github.com/autobrr/autobrr/internal/domain" - "github.com/autobrr/autobrr/pkg/qbittorrent" - delugeClient "github.com/gdm85/go-libdeluge" + "github.com/rs/zerolog/log" ) @@ -91,77 +89,3 @@ func (s *service) Test(client domain.DownloadClient) error { return nil } - -func (s *service) testConnection(client domain.DownloadClient) error { - switch client.Type { - case domain.DownloadClientTypeQbittorrent: - return s.testQbittorrentConnection(client) - case domain.DownloadClientTypeDelugeV1, domain.DownloadClientTypeDelugeV2: - return s.testDelugeConnection(client) - } - - return nil -} - -func (s *service) testQbittorrentConnection(client domain.DownloadClient) error { - qbtSettings := qbittorrent.Settings{ - Hostname: client.Host, - Port: uint(client.Port), - Username: client.Username, - Password: client.Password, - SSL: client.SSL, - } - - qbt := qbittorrent.NewClient(qbtSettings) - err := qbt.Login() - if err != nil { - log.Error().Err(err).Msgf("error logging into client: %v", client.Host) - return err - } - - return nil -} - -func (s *service) testDelugeConnection(client domain.DownloadClient) error { - var deluge delugeClient.DelugeClient - - settings := delugeClient.Settings{ - Hostname: client.Host, - Port: uint(client.Port), - Login: client.Username, - Password: client.Password, - DebugServerResponses: true, - ReadWriteTimeout: time.Second * 10, - } - - switch client.Type { - case "DELUGE_V1": - deluge = delugeClient.NewV1(settings) - - case "DELUGE_V2": - deluge = delugeClient.NewV2(settings) - - default: - deluge = delugeClient.NewV2(settings) - } - - // perform connection to Deluge server - err := deluge.Connect() - if err != nil { - log.Error().Err(err).Msgf("error logging into client: %v", client.Host) - return err - } - - defer deluge.Close() - - // print daemon version - ver, err := deluge.DaemonVersion() - if err != nil { - log.Error().Err(err).Msgf("could not get daemon version: %v", client.Host) - return err - } - - log.Debug().Msgf("daemon version: %v", ver) - - return nil -} diff --git a/pkg/radarr/client.go b/pkg/radarr/client.go new file mode 100644 index 0000000..c527584 --- /dev/null +++ b/pkg/radarr/client.go @@ -0,0 +1,82 @@ +package radarr + +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("radarr 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("radarr 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("radarr 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("radarr 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("radarr client request error: %v", reqUrl) + return nil, err + } + + // validate response + if res.StatusCode == http.StatusUnauthorized { + log.Error().Err(err).Msgf("radarr client bad request: %v", reqUrl) + return nil, errors.New("unauthorized: bad credentials") + } else if res.StatusCode != http.StatusOK { + log.Error().Err(err).Msgf("radarr 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/radarr/radarr.go b/pkg/radarr/radarr.go new file mode 100644 index 0000000..e1e595b --- /dev/null +++ b/pkg/radarr/radarr.go @@ -0,0 +1,130 @@ +package radarr + +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 +} + +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("radarr client get error") + return nil, err + } + + defer res.Body.Close() + + body, err := io.ReadAll(res.Body) + if err != nil { + log.Error().Err(err).Msg("radarr client error reading body") + return nil, err + } + + response := SystemStatusResponse{} + err = json.Unmarshal(body, &response) + if err != nil { + log.Error().Err(err).Msg("radarr client error json unmarshal") + return nil, err + } + + log.Trace().Msgf("radarr 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("radarr client post error") + return err + } + + defer res.Body.Close() + + body, err := io.ReadAll(res.Body) + if err != nil { + log.Error().Err(err).Msg("radarr client error reading body") + return err + } + + pushResponse := make([]PushResponse, 0) + err = json.Unmarshal(body, &pushResponse) + if err != nil { + log.Error().Err(err).Msg("radarr client error json unmarshal") + return err + } + + log.Trace().Msgf("radarr 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("radarr push rejected: %s - reasons: %q", release.Title, rejections) + return errors.New(fmt.Errorf("radarr push rejected %v", rejections).Error()) + } + + return nil +} diff --git a/pkg/radarr/radarr_test.go b/pkg/radarr/radarr_test.go new file mode 100644 index 0000000..76c4bf5 --- /dev/null +++ b/pkg/radarr/radarr_test.go @@ -0,0 +1,183 @@ +package radarr + +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: "Some.Old.Movie.1996.Remastered.1080p.BluRay.REMUX.AVC.MULTI.TrueHD.Atmos.7.1-NOGROUP", + DownloadUrl: "https://www.test.org/rss/download/0000001/00000000000000000000/Some.Old.Movie.1996.Remastered.1080p.BluRay.REMUX.AVC.MULTI.TrueHD.Atmos.7.1-NOGROUP.torrent", + Size: 0, + Indexer: "test", + DownloadProtocol: "torrent", + Protocol: "torrent", + PublishDate: "2021-08-21T15:36:00Z", + }}, + err: errors.New("radarr push rejected Could not find Some Old Movie"), + wantErr: true, + }, + { + name: "push_error", + fields: fields{ + config: Config{ + Hostname: ts.URL, + APIKey: key, + BasicAuth: false, + Username: "", + Password: "", + }, + }, + args: args{release: Release{ + Title: "Some.Old.Movie.1996.Remastered.1080p.BluRay.REMUX.AVC.MULTI.TrueHD.Atmos.7.1-NOGROUP", + DownloadUrl: "https://www.test.org/rss/download/0000001/00000000000000000000/Some.Old.Movie.1996.Remastered.1080p.BluRay.REMUX.AVC.MULTI.TrueHD.Atmos.7.1-NOGROUP.torrent", + Size: 0, + Indexer: "test", + DownloadProtocol: "torrent", + Protocol: "torrent", + PublishDate: "2021-08-21T15:36:00Z", + }}, + err: errors.New("radarr push rejected Could not find Some Old Movie"), + 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.2.2.5080"}, + 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/radarr/testdata/release_push_response.json b/pkg/radarr/testdata/release_push_response.json new file mode 100644 index 0000000..a3394ba --- /dev/null +++ b/pkg/radarr/testdata/release_push_response.json @@ -0,0 +1,54 @@ +[ + { + "guid": "PUSH-https://www.test.org/rss/download/0000001/00000000000000000000/Some.Old.Movie.1996.Remastered.1080p.BluRay.REMUX.AVC.MULTI.TrueHD.Atmos.7.1-NOGROUP.torrent", + "quality": { + "quality": { + "id": 30, + "name": "Remux-1080p", + "source": "bluray", + "resolution": 1080, + "modifier": "remux" + }, + "revision": { + "version": 1, + "real": 0, + "isRepack": false + } + }, + "customFormats": [], + "customFormatScore": 0, + "qualityWeight": 1901, + "age": 0, + "ageHours": 0.028290299305555554, + "ageMinutes": 1.69741874, + "size": 0, + "indexerId": 0, + "indexer": "test", + "releaseGroup": "NOGROUP", + "releaseHash": "", + "title": "Some.Old.Movie.1996.Remastered.1080p.BluRay.REMUX.AVC.MULTI.TrueHD.Atmos.7.1-NOGROUP", + "sceneSource": false, + "movieTitle": "Twister", + "languages": [ + { + "id": 1, + "name": "English" + } + ], + "approved": false, + "temporarilyRejected": false, + "rejected": true, + "tmdbId": 0, + "imdbId": 0, + "rejections": [ + "Could not find Some Old Movie" + ], + "publishDate": "2021-08-21T15:36:00Z", + "downloadUrl": "https://www.test.org/rss/download/0000001/00000000000000000000/Some.Old.Movie.1996.Remastered.1080p.BluRay.REMUX.AVC.MULTI.TrueHD.Atmos.7.1-NOGROUP.torrent", + "downloadAllowed": false, + "releaseWeight": 0, + "indexerFlags": [], + "edition": "Remastered", + "protocol": "torrent" + } +] \ No newline at end of file diff --git a/pkg/radarr/testdata/system_status_response.json b/pkg/radarr/testdata/system_status_response.json new file mode 100644 index 0000000..7190d8e --- /dev/null +++ b/pkg/radarr/testdata/system_status_response.json @@ -0,0 +1,28 @@ +{ + "version": "3.2.2.5080", + "buildTime": "2021-06-03T11:51:33Z", + "isDebug": false, + "isProduction": true, + "isAdmin": false, + "isUserInteractive": true, + "startupPath": "/opt/Radarr", + "appData": "/home/test/.config/Radarr", + "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": 195, + "urlBase": "/radarr", + "runtimeVersion": "5.0.5", + "runtimeName": "netCore", + "startTime": "2021-08-20T20:49:42Z", + "packageUpdateMechanism": "builtIn" +} \ No newline at end of file diff --git a/web/src/components/FilterActionList.tsx b/web/src/components/FilterActionList.tsx index bbae54b..34e0d75 100644 --- a/web/src/components/FilterActionList.tsx +++ b/web/src/components/FilterActionList.tsx @@ -1,654 +1,481 @@ -import {Action, DownloadClient} from "../domain/interfaces"; -import React, {Fragment, useEffect, useRef } from "react"; -import {Dialog, Listbox, Switch, Transition} from '@headlessui/react' -import {classNames} from "../styles/utils"; -import {CheckIcon, ChevronRightIcon, ExclamationIcon, SelectorIcon,} from "@heroicons/react/solid"; -import {useToggle} from "../hooks/hooks"; -import {useMutation} from "react-query"; -import {Field, Form} from "react-final-form"; -import {TextField} from "./inputs"; +import { Action, DownloadClient } from "../domain/interfaces"; +import { Fragment, useEffect, useRef } from "react"; +import { Dialog, Listbox, Switch, Transition } from "@headlessui/react"; +import { classNames } from "../styles/utils"; +import { + CheckIcon, + ChevronRightIcon, + SelectorIcon, +} from "@heroicons/react/solid"; +import { useToggle } from "../hooks/hooks"; +import { useMutation } from "react-query"; +import { Field, Form } from "react-final-form"; +import { TextField } from "./inputs"; +import { NumberField, SelectField } from "./inputs/compact"; import DEBUG from "./debug"; import APIClient from "../api/APIClient"; -import {queryClient} from "../App"; -import {ActionTypeNameMap, ActionTypeOptions, DownloadClientTypeNameMap} from "../domain/constants"; +import { queryClient } from "../App"; +import { ActionTypeNameMap, ActionTypeOptions } from "../domain/constants"; +import { AlertWarning } from "./alerts"; +import { DeleteModal } from "./modals"; -interface FilterListProps { - actions: Action[]; - clients: DownloadClient[]; - filterID: number; +interface DownloadClientSelectProps { + name: string; + action: Action; + clients: DownloadClient[]; } -export function FilterActionList({actions, clients, filterID}: FilterListProps) { - useEffect(() => { - // console.log("render list") - }, []) +function DownloadClientSelect({ + name, + action, + clients, +}: DownloadClientSelectProps) { + return ( +
+ ( + + {({ open }) => ( + <> + + Client + +
+ + + {input.value + ? clients.find((c) => c.id === input.value)!.name + : "Choose a client"} + + {/*Choose a client*/} + + + - return ( -
-
    - {actions.map((action, idx) => ( - - ))} -
-
- ) + + + {clients + .filter((c) => c.type === action.type) + .map((client: any) => ( + + classNames( + active + ? "text-white bg-indigo-600" + : "text-gray-900", + "cursor-default select-none relative py-2 pl-3 pr-9" + ) + } + value={client.id} + > + {({ selected, active }) => ( + <> + + {client.name} + + + {selected ? ( + + + ) : null} + + )} + + ))} + + +
+ + )} +
+ )} + /> +
+ ); +} + +interface FilterListProps { + actions: Action[]; + clients: DownloadClient[]; + filterID: number; +} + +export function FilterActionList({ + actions, + clients, + filterID, +}: FilterListProps) { + useEffect(() => { + // console.log("render list") + }, []); + + return ( +
+ +
+ ); } interface ListItemProps { - action: Action; - clients: DownloadClient[]; - filterID: number; - idx: number; + action: Action; + clients: DownloadClient[]; + filterID: number; + idx: number; } -function ListItem({action, clients, filterID, idx}: ListItemProps) { - const [deleteModalIsOpen, toggleDeleteModal] = useToggle(false) - const [edit, toggleEdit] = useToggle(false) +function ListItem({ action, clients, filterID, idx }: ListItemProps) { + const [deleteModalIsOpen, toggleDeleteModal] = useToggle(false); + const [edit, toggleEdit] = useToggle(false); - const deleteMutation = useMutation((actionID: number) => APIClient.actions.delete(actionID), { - onSuccess: () => { - queryClient.invalidateQueries(['filter',filterID]); - toggleDeleteModal() - } - }) - - const enabledMutation = useMutation((actionID: number) => APIClient.actions.toggleEnable(actionID), { - onSuccess: () => { - queryClient.invalidateQueries(['filter',filterID]); - } - }) - - const updateMutation = useMutation((action: Action) => APIClient.actions.update(action), { - onSuccess: () => { - queryClient.invalidateQueries(['filter',filterID]); - } - }) - - const toggleActive = () => { - enabledMutation.mutate(action.id) + const deleteMutation = useMutation( + (actionID: number) => APIClient.actions.delete(actionID), + { + onSuccess: () => { + queryClient.invalidateQueries(["filter", filterID]); + toggleDeleteModal(); + }, } + ); - useEffect(() => { - }, [action]) - - const cancelButtonRef = useRef(null) - - const deleteAction = () => { - deleteMutation.mutate(action.id) + const enabledMutation = useMutation( + (actionID: number) => APIClient.actions.toggleEnable(actionID), + { + onSuccess: () => { + queryClient.invalidateQueries(["filter", filterID]); + }, } + ); - const onSubmit = (action: Action) => { - // TODO clear data depending on type - updateMutation.mutate(action) - }; - - const TypeForm = (action: Action) => { - switch (action.type) { - case "TEST": - return ( -
-
-
-
-
-
-

Notice

-
-

- The test action does nothing except to show if the filter works. -

-
-
-
-
-
- ) - case "EXEC": - return ( -
-
- - -
-
- ) - case "WATCH_FOLDER": - return ( -
- -
- ) - case "QBITTORRENT": - return ( -
-
- -
- ( - - {({open}) => ( - <> - Client -
- - {input.value ? clients.find(c => c.id === input.value)!.name : "Choose a client"} - {/*Choose a client*/} - - - - - - - {clients.filter((c) => c.type === action.type).map((client: any) => ( - - classNames( - active ? 'text-white bg-indigo-600' : 'text-gray-900', - 'cursor-default select-none relative py-2 pl-3 pr-9' - ) - } - value={client.id} - > - {({selected, active}) => ( - <> - - {client.name} - - - {selected ? ( - - - ) : null} - - )} - - ))} - - -
- - )} -
- )}/> -
- -
- -
-
- -
- - -
- -
-
- - - - {({input, meta}) => ( -
- - {meta.touched && meta.error && - {meta.error}} -
- )} -
-
- -
- - - - {({input, meta}) => ( -
- - {meta.touched && meta.error && - {meta.error}} -
- )} -
-
-
-
- ) - case "DELUGE_V1": - case "DELUGE_V2": - return ( -
-
-
- ( - - {({open}) => ( - <> - Client -
- - {input.value ? clients.find(c => c.id === input.value)!.name : "Choose a client"} - {/*Choose a client*/} - - - - - - - {clients.filter((c) => c.type === action.type).map((client: any) => ( - - classNames( - active ? 'text-white bg-indigo-600' : 'text-gray-900', - 'cursor-default select-none relative py-2 pl-3 pr-9' - ) - } - value={client.id} - > - {({selected, active}) => ( - <> - - {client.name} - - - {selected ? ( - - - ) : null} - - )} - - ))} - - -
- - )} -
- )}/> - -
-
- -
-
- -
- -
- -
-
- - - - {({input, meta}) => ( -
- - {meta.touched && meta.error && - {meta.error}} -
- )} -
-
- -
- - - - {({input, meta}) => ( -
- - {meta.touched && meta.error && - {meta.error}} -
- )} -
-
-
-
- ) - - default: - return

default

- } + const updateMutation = useMutation( + (action: Action) => APIClient.actions.update(action), + { + onSuccess: () => { + queryClient.invalidateQueries(["filter", filterID]); + }, } + ); - return ( -
  • -
    - - Use setting -
  • +
    + + Use setting + + +
    + + {edit && ( +
    + + + + + + +
    + {({ handleSubmit, values }) => { + return ( + +
    + - - + +
    + + +
    -
    -
    - -
    + - {edit && -
    - - -
    - - - - - {/* This element is to trick the browser into centering the modal contents. */} - - -
    -
    -
    -
    -
    -
    - - Remove filter action - -
    -

    - Are you sure you want to remove this action? - This action cannot be undone. -

    -
    -
    -
    -
    -
    - - -
    -
    -
    -
    -
    -
    - - - {({handleSubmit, values}) => { - return ( - - -
    -
    - - ( - - {({open}) => ( - <> - Type -
    - - {input.value ? ActionTypeOptions.find(c => c.value === input.value)!.label : "Choose a type"} - - - - - - - {ActionTypeOptions.map((opt) => ( - - classNames( - active ? 'text-white bg-indigo-600' : 'text-gray-900', - 'cursor-default select-none relative py-2 pl-3 pr-9' - ) - } - value={opt.value} - > - {({selected, active}) => ( - <> - - {opt.label} - - - {selected ? ( - - - ) : null} - - )} - - ))} - - -
    - - )} -
    - )}/> -
    - - - -
    - - {TypeForm(values)} - -
    -
    - - -
    - - -
    -
    -
    - - - - - ) - }} - -
    - } -
  • - ) + + + ); + }} + + + )} + + ); } diff --git a/web/src/components/alerts/index.ts b/web/src/components/alerts/index.ts new file mode 100644 index 0000000..8f5135b --- /dev/null +++ b/web/src/components/alerts/index.ts @@ -0,0 +1 @@ +export { default as AlertWarning } from "./warning"; diff --git a/web/src/components/alerts/warning.tsx b/web/src/components/alerts/warning.tsx new file mode 100644 index 0000000..0d9908f --- /dev/null +++ b/web/src/components/alerts/warning.tsx @@ -0,0 +1,32 @@ +import { ExclamationIcon } from "@heroicons/react/solid"; +import React from "react"; + +interface props { + title: string; + text: string; +} + +function AlertWarning({ title, text }: props) { + return ( +
    +
    +
    +
    +
    +
    +

    {title}

    +
    +

    {text}

    +
    +
    +
    +
    +
    + ); +} + +export default AlertWarning; diff --git a/web/src/components/inputs/SelectField.tsx b/web/src/components/inputs/SelectField.tsx deleted file mode 100644 index b1de78d..0000000 --- a/web/src/components/inputs/SelectField.tsx +++ /dev/null @@ -1,97 +0,0 @@ -import {Field} from "react-final-form"; -import {Listbox, Transition} from "@headlessui/react"; -import {CheckIcon, SelectorIcon} from "@heroicons/react/solid"; -import React, {Fragment} from "react"; -import {classNames} from "../../styles/utils"; - - -interface SelectOption { - label: string; - value: string; -} - -interface props { - name: string; - label: string; - optionDefaultText: string; - options: SelectOption[]; -} - -function SelectField({name, label, optionDefaultText, options}: props) { - return ( -
    - ( - - {({open}) => ( -
    - {label} -
    - - {input.value ? options.find(c => c.value === input.value)!.label : optionDefaultText} - - - - - - - {options.map((opt) => ( - - classNames( - active ? 'text-white bg-indigo-600' : 'text-gray-900', - 'cursor-default select-none relative py-2 pl-3 pr-9' - ) - } - value={opt.value} - > - {({selected, active}) => ( - <> - - {opt.label} - - - {selected ? ( - - - ) : null} - - )} - - ))} - - -
    -
    - )} -
    - )}/> -
    - ) -} - -export default SelectField; diff --git a/web/src/components/inputs/compact/NumberField.tsx b/web/src/components/inputs/compact/NumberField.tsx new file mode 100644 index 0000000..d010cec --- /dev/null +++ b/web/src/components/inputs/compact/NumberField.tsx @@ -0,0 +1,47 @@ +import { Field } from "react-final-form"; +import React from "react"; +import Error from "../Error"; +import { classNames } from "../../../styles/utils"; + +interface Props { + name: string; + label?: string; + placeholder?: string; + className?: string; + required?: boolean; +} + +const NumberField: React.FC = ({ + name, + label, + placeholder, + required, + className, +}) => ( +
    + + + v & parseInt(v, 10)}> + {({ input, meta }) => ( +
    + + +
    + )} +
    +
    +); + +export default NumberField; diff --git a/web/src/components/inputs/compact/SelectField.tsx b/web/src/components/inputs/compact/SelectField.tsx new file mode 100644 index 0000000..1147466 --- /dev/null +++ b/web/src/components/inputs/compact/SelectField.tsx @@ -0,0 +1,111 @@ +import { Field } from "react-final-form"; +import { Listbox, Transition } from "@headlessui/react"; +import { CheckIcon, SelectorIcon } from "@heroicons/react/solid"; +import { Fragment } from "react"; +import { classNames } from "../../../styles/utils"; + +interface SelectOption { + label: string; + value: string; +} + +interface props { + name: string; + label: string; + optionDefaultText: string; + options: SelectOption[]; +} + +function SelectField({ name, label, optionDefaultText, options }: props) { + return ( +
    + ( + + {({ open }) => ( + <> + + {label} + +
    + + + {input.value + ? options.find((c) => c.value === input.value)!.label + : optionDefaultText} + + + + + + + + {options.map((opt) => ( + + classNames( + active + ? "text-white bg-indigo-600" + : "text-gray-900", + "cursor-default select-none relative py-2 pl-3 pr-9" + ) + } + value={opt.value} + > + {({ selected, active }) => ( + <> + + {opt.label} + + + {selected ? ( + + + ) : null} + + )} + + ))} + + +
    + + )} +
    + )} + /> +
    + ); +} + +export default SelectField; diff --git a/web/src/components/inputs/compact/index.ts b/web/src/components/inputs/compact/index.ts new file mode 100644 index 0000000..94cd4b2 --- /dev/null +++ b/web/src/components/inputs/compact/index.ts @@ -0,0 +1,2 @@ +export { default as NumberField } from "./NumberField"; +export { default as SelectField } from "./SelectField"; diff --git a/web/src/components/inputs/index.ts b/web/src/components/inputs/index.ts index fbdffcf..d38d278 100644 --- a/web/src/components/inputs/index.ts +++ b/web/src/components/inputs/index.ts @@ -5,4 +5,3 @@ export { default as TextAreaWide } from "./TextAreaWide"; export { default as MultiSelectField } from "./MultiSelectField"; export { default as RadioFieldset } from "./RadioFieldset"; export { default as SwitchGroup } from "./SwitchGroup"; -export { default as SelectField } from "./SelectField"; diff --git a/web/src/components/inputs/wide/NumberField.tsx b/web/src/components/inputs/wide/NumberField.tsx new file mode 100644 index 0000000..ce195c9 --- /dev/null +++ b/web/src/components/inputs/wide/NumberField.tsx @@ -0,0 +1,54 @@ +import { Field } from "react-final-form"; +import React from "react"; +import Error from "../Error"; +import { classNames } from "../../../styles/utils"; + +interface Props { + name: string; + label?: string; + placeholder?: string; + className?: string; + required?: boolean; +} + +const NumberFieldWide: React.FC = ({ + name, + label, + placeholder, + required, + className, +}) => ( +
    +
    + +
    +
    + v & parseInt(v, 10)} + render={({ input, meta }) => ( + + )} + /> + +
    +
    +); + +export default NumberFieldWide; diff --git a/web/src/components/inputs/wide/RadioFieldsetWide.tsx b/web/src/components/inputs/wide/RadioFieldsetWide.tsx new file mode 100644 index 0000000..844c96c --- /dev/null +++ b/web/src/components/inputs/wide/RadioFieldsetWide.tsx @@ -0,0 +1,105 @@ +import { Field, useFormState } from "react-final-form"; +import { RadioGroup } from "@headlessui/react"; +import { classNames } from "../../../styles/utils"; +import { Fragment } from "react"; +import { radioFieldsetOption } from "../RadioFieldset"; + +interface props { + name: string; + legend: string; + options: radioFieldsetOption[]; +} + +function RadioFieldsetWide({ name, legend, options }: props) { + const { values } = useFormState(); + + return ( +
    +
    +
    + + {legend} + +
    +
    +
    + ( + + + Privacy setting + +
    + {options.map((setting, settingIdx) => ( + + classNames( + settingIdx === 0 + ? "rounded-tl-md rounded-tr-md" + : "", + settingIdx === options.length - 1 + ? "rounded-bl-md rounded-br-md" + : "", + checked + ? "bg-indigo-50 border-indigo-200 z-10" + : "border-gray-200", + "relative border p-4 flex cursor-pointer focus:outline-none" + ) + } + > + {({ active, checked }) => ( + + + )} + + ))} +
    +
    + )} + /> +
    +
    +
    +
    + ); +} + +export default RadioFieldsetWide; diff --git a/web/src/components/inputs/wide/SelectField.tsx b/web/src/components/inputs/wide/SelectField.tsx new file mode 100644 index 0000000..6ef335c --- /dev/null +++ b/web/src/components/inputs/wide/SelectField.tsx @@ -0,0 +1,111 @@ +import { Field } from "react-final-form"; +import { Listbox, Transition } from "@headlessui/react"; +import { CheckIcon, SelectorIcon } from "@heroicons/react/solid"; +import React, { Fragment } from "react"; +import { classNames } from "../../../styles/utils"; + +interface SelectOption { + label: string; + value: string; +} + +interface props { + name: string; + label: string; + optionDefaultText: string; + options: SelectOption[]; +} + +function SelectField({ name, label, optionDefaultText, options }: props) { + return ( +
    + ( + + {({ open }) => ( +
    + + {label} + +
    + + + {input.value + ? options.find((c) => c.value === input.value)!.label + : optionDefaultText} + + + + + + + + {options.map((opt) => ( + + classNames( + active + ? "text-white bg-indigo-600" + : "text-gray-900", + "cursor-default select-none relative py-2 pl-3 pr-9" + ) + } + value={opt.value} + > + {({ selected, active }) => ( + <> + + {opt.label} + + + {selected ? ( + + + ) : null} + + )} + + ))} + + +
    +
    + )} +
    + )} + /> +
    + ); +} + +export default SelectField; diff --git a/web/src/components/inputs/wide/index.ts b/web/src/components/inputs/wide/index.ts new file mode 100644 index 0000000..41300ac --- /dev/null +++ b/web/src/components/inputs/wide/index.ts @@ -0,0 +1,3 @@ +export { default as NumberFieldWide } from "./NumberField"; +export { default as RadioFieldsetWide } from "./RadioFieldsetWide"; +export { default as SelectFieldWide } from "./SelectField"; diff --git a/web/src/domain/constants.ts b/web/src/domain/constants.ts index 46a3de5..e6401e8 100644 --- a/web/src/domain/constants.ts +++ b/web/src/domain/constants.ts @@ -72,11 +72,13 @@ export const DownloadClientTypeOptions: radioFieldsetOption[] = [ {label: "qBittorrent", description: "Add torrents directly to qBittorrent", value: DOWNLOAD_CLIENT_TYPES.qBittorrent}, {label: "Deluge", description: "Add torrents directly to Deluge", value: DOWNLOAD_CLIENT_TYPES.DelugeV1}, {label: "Deluge 2", description: "Add torrents directly to Deluge 2", value: DOWNLOAD_CLIENT_TYPES.DelugeV2}, + {label: "Radarr", description: "Send to Radarr and let it decide", value: DOWNLOAD_CLIENT_TYPES.Radarr}, ]; export const DownloadClientTypeNameMap = { "DELUGE_V1": "Deluge v1", "DELUGE_V2": "Deluge v2", - "QBITTORRENT": "qBittorrent" + "QBITTORRENT": "qBittorrent", + "RADARR": "Radarr", }; export const ActionTypeOptions: radioFieldsetOption[] = [ @@ -86,6 +88,7 @@ export const ActionTypeOptions: radioFieldsetOption[] = [ {label: "qBittorrent", description: "Add torrents directly to qBittorrent", value: "QBITTORRENT"}, {label: "Deluge", description: "Add torrents directly to Deluge", value: "DELUGE_V1"}, {label: "Deluge v2", description: "Add torrents directly to Deluge 2", value: "DELUGE_V2"}, + {label: "Radarr", description: "Send to Radarr and let it decide", value: DOWNLOAD_CLIENT_TYPES.Radarr}, ]; export const ActionTypeNameMap = { @@ -94,5 +97,6 @@ export const ActionTypeNameMap = { "EXEC": "Exec", "DELUGE_V1": "Deluge v1", "DELUGE_V2": "Deluge v2", - "QBITTORRENT": "qBittorrent" + "QBITTORRENT": "qBittorrent", + "RADARR": "Radarr" }; diff --git a/web/src/domain/interfaces.ts b/web/src/domain/interfaces.ts index c7dfc20..37122ee 100644 --- a/web/src/domain/interfaces.ts +++ b/web/src/domain/interfaces.ts @@ -64,16 +64,17 @@ export interface Filter { indexers: Indexer[]; } -export type ActionType = 'TEST' | 'EXEC' | 'WATCH_FOLDER' | 'QBITTORRENT' | 'DELUGE_V1' | 'DELUGE_V2'; -export const ACTIONTYPES: ActionType[] = ['TEST', 'EXEC' , 'WATCH_FOLDER' , 'QBITTORRENT' , 'DELUGE_V1', 'DELUGE_V2']; +export type ActionType = 'TEST' | 'EXEC' | 'WATCH_FOLDER' | 'QBITTORRENT' | 'DELUGE_V1' | 'DELUGE_V2' | 'RADARR'; +export const ACTIONTYPES: ActionType[] = ['TEST', 'EXEC' , 'WATCH_FOLDER' , 'QBITTORRENT' , 'DELUGE_V1', 'DELUGE_V2', 'RADARR']; -export type DownloadClientType = 'QBITTORRENT' | 'DELUGE_V1' | 'DELUGE_V2'; +export type DownloadClientType = 'QBITTORRENT' | 'DELUGE_V1' | 'DELUGE_V2' | 'RADARR'; export enum DOWNLOAD_CLIENT_TYPES { qBittorrent = 'QBITTORRENT', DelugeV1 = 'DELUGE_V1', - DelugeV2 = 'DELUGE_V2' + DelugeV2 = 'DELUGE_V2', + Radarr = 'RADARR' } export interface DownloadClient { diff --git a/web/src/forms/filters/FilterActionAddForm.tsx b/web/src/forms/filters/FilterActionAddForm.tsx index 48cfa87..02a2435 100644 --- a/web/src/forms/filters/FilterActionAddForm.tsx +++ b/web/src/forms/filters/FilterActionAddForm.tsx @@ -1,817 +1,400 @@ -import React, {Fragment, useEffect } from "react"; -import {useMutation} from "react-query"; -import {Action, DownloadClient, Filter} from "../../domain/interfaces"; -import {queryClient} from "../../App"; -import {sleep} from "../../utils/utils"; -import {CheckIcon, ExclamationIcon, SelectorIcon, XIcon} from "@heroicons/react/solid"; -import {Dialog, Listbox, RadioGroup, Transition} from "@headlessui/react"; -import {classNames} from "../../styles/utils"; -import {Field, Form} from "react-final-form"; +import { Fragment, useEffect } from "react"; +import { useMutation } from "react-query"; +import { Action, DownloadClient, Filter } from "../../domain/interfaces"; +import { queryClient } from "../../App"; +import { sleep } from "../../utils/utils"; +import { CheckIcon, SelectorIcon, XIcon } from "@heroicons/react/solid"; +import { Dialog, Listbox, Transition } from "@headlessui/react"; +import { classNames } from "../../styles/utils"; +import { Field, Form } from "react-final-form"; import DEBUG from "../../components/debug"; import APIClient from "../../api/APIClient"; -import {ActionTypeOptions} from "../../domain/constants"; +import { ActionTypeOptions } from "../../domain/constants"; +import { TextFieldWide } from "../../components/inputs"; +import { AlertWarning } from "../../components/alerts"; +import { + NumberFieldWide, + RadioFieldsetWide, +} from "../../components/inputs/wide"; + +interface DownloadClientSelectProps { + name: string; + clients: DownloadClient[]; + values: any; +} + +export function DownloadClientSelect({ + name, + clients, + values, +}: DownloadClientSelectProps) { + return ( +
    + ( + + {({ open }) => ( + <> + + Client + +
    + + + {input.value + ? clients.find((c) => c.id === input.value)!.name + : "Choose a client"} + + {/*Choose a client*/} + + + + + + + {clients + .filter((c) => c.type === values.type) + .map((client: any) => ( + + classNames( + active + ? "text-white bg-indigo-600" + : "text-gray-900", + "cursor-default select-none relative py-2 pl-3 pr-9" + ) + } + value={client.id} + > + {({ selected, active }) => ( + <> + + {client.name} + + + {selected ? ( + + + ) : null} + + )} + + ))} + + +
    + + )} +
    + )} + /> +
    + ); +} interface props { - filter: Filter; - isOpen: boolean; - toggle: any; - clients: DownloadClient[]; + filter: Filter; + isOpen: boolean; + toggle: any; + clients: DownloadClient[]; } -function FilterActionAddForm({filter, isOpen, toggle, clients}: props) { - const mutation = useMutation((action: Action) => APIClient.actions.create(action), { - onSuccess: () => { - queryClient.invalidateQueries(['filter', filter.id]); - sleep(500).then(() => toggle()) - } - }) - - useEffect(() => { - // console.log("render add action form", clients) - }, []); - - const onSubmit = (data: any) => { - // TODO clear data depending on type - mutation.mutate(data) - }; - - const TypeForm = (values: any) => { - switch (values.type) { - case "TEST": - return ( -
    -
    -
    -
    -
    -
    -

    Notice

    -
    -

    - The test action does nothing except to show if the filter works. -

    -
    -
    -
    -
    -
    - ) - case "WATCH_FOLDER": - return ( -
    -
    -
    - -
    -
    - - {({input, meta}) => ( -
    - - {meta.touched && meta.error && - {meta.error}} -
    - )} -
    -
    -
    -
    - ) - case "EXEC": - return ( -
    -
    -
    - -
    -
    - - {({input, meta}) => ( -
    - - {meta.touched && meta.error && - {meta.error}} -
    - )} -
    -
    -
    - -
    -
    - -
    -
    - - {({input, meta}) => ( -
    - - {meta.touched && meta.error && - {meta.error}} -
    - )} -
    -
    -
    -
    - - ) - case "QBITTORRENT": - - return ( -
    - -
    - {/*// TODO change available clients to match only selected action type. eg qbittorrent or deluge*/} - - ( - - {({open}) => ( - <> - Client -
    - - {input.value ? clients.find(c => c.id === input.value)!.name : "Choose a client"} - {/*Choose a client*/} - - - - - - - {clients.filter((c) => c.type === values.type).map((client: any) => ( - - classNames( - active ? 'text-white bg-indigo-600' : 'text-gray-900', - 'cursor-default select-none relative py-2 pl-3 pr-9' - ) - } - value={client.id} - > - {({selected, active}) => ( - <> - - {client.name} - - - {selected ? ( - - - ) : null} - - )} - - ))} - - -
    - - )} -
    - )} /> - -
    - -
    -
    - -
    -
    - - {({input, meta}) => ( -
    - - {meta.touched && meta.error && - {meta.error}} -
    - )} -
    -
    -
    - -
    -
    - -
    -
    - - {({input, meta}) => ( -
    - - {meta.touched && meta.error && - {meta.error}} -
    - )} -
    -
    -
    - -
    -
    - -
    -
    - - {({input, meta}) => ( -
    - - {meta.touched && meta.error && - {meta.error}} -
    - )} -
    -
    -
    - -
    -
    -

    Limit speeds

    -

    - Limit download and upload speed for torrents in this filter. In KB/s. -

    -
    -
    -
    - -
    -
    - -
    -
    - - {({input, meta}) => ( -
    - - {meta.touched && meta.error && - {meta.error}} -
    - )} -
    -
    -
    -
    -
    - - -
    -
    - -
    -
    - -
    -
    - - {({input, meta}) => ( -
    - - {meta.touched && meta.error && - {meta.error}} -
    - )} -
    -
    -
    -
    -
    - -
    -
    - ) - case "DELUGE_V1": - case "DELUGE_V2": - return ( -
    - {/*TODO choose client*/} - -
    - ( - - {({open}) => ( - <> - Client -
    - - {input.value ? clients.find(c => c.id === input.value)!.name : "Choose a client"} - {/*Choose a client*/} - - - - - - - {clients.filter((c) => c.type === values.type).map((client: any) => ( - - classNames( - active ? 'text-white bg-indigo-600' : 'text-gray-900', - 'cursor-default select-none relative py-2 pl-3 pr-9' - ) - } - value={client.id} - > - {({selected, active}) => ( - <> - - {client.name} - - - {selected ? ( - - - ) : null} - - )} - - ))} - - -
    - - )} -
    - )} /> -
    - -
    -
    - -
    -
    - - {({input, meta}) => ( -
    - - {meta.touched && meta.error && - {meta.error}} -
    - )} -
    -
    -
    - -
    -
    - -
    -
    - - {({input, meta}) => ( -
    - - {meta.touched && meta.error && - {meta.error}} -
    - )} -
    -
    -
    - -
    -
    -

    Limit speeds

    -

    - Limit download and upload speed for torrents in this filter. In KB/s. -

    -
    -
    -
    - -
    -
    - -
    -
    - - {({input, meta}) => ( -
    - - {meta.touched && meta.error && - {meta.error}} -
    - )} -
    -
    -
    -
    -
    - - -
    -
    - -
    -
    - -
    -
    - - {({input, meta}) => ( -
    - - {meta.touched && meta.error && - {meta.error}} -
    - )} -
    -
    -
    -
    -
    -
    -
    - ) - default: - return ( -
    -
    -
    -
    -
    -
    -

    Notice

    -
    -

    - The test action does nothing except to show if the filter works. -

    -
    -
    -
    -
    -
    - ) - } +function FilterActionAddForm({ filter, isOpen, toggle, clients }: props) { + const mutation = useMutation( + (action: Action) => APIClient.actions.create(action), + { + onSuccess: () => { + queryClient.invalidateQueries(["filter", filter.id]); + sleep(500).then(() => toggle()); + }, } + ); - return ( - - -
    - + useEffect(() => { + // console.log("render add action form", clients) + }, []); -
    - -
    -
    { + // TODO clear data depending on type + mutation.mutate(data); + }; + + const TypeForm = (values: any) => { + switch (values.type) { + case "TEST": + return ( + + ); + case "WATCH_FOLDER": + return ( +
    + +
    + ); + case "EXEC": + return ( +
    + + + +
    + ); + case "QBITTORRENT": + return ( +
    + + + + + + +
    +
    +

    + Limit speeds +

    +

    + Limit download and upload speed for torrents in this filter. + In KB/s. +

    +
    + + +
    +
    + ); + case "DELUGE_V1": + case "DELUGE_V2": + return ( +
    + + + + + +
    +
    +

    + Limit speeds +

    +

    + Limit download and upload speed for torrents in this filter. + In KB/s. +

    +
    + + +
    +
    + ); + case "RADARR": + return ( +
    + +
    + ); + default: + return ( + + ); + } + }; + + return ( + + +
    + + +
    + +
    + + {({ handleSubmit, values }) => { + return ( + +
    +
    +
    +
    + + Add action + +

    + Add filter action. +

    +
    +
    + -
    -
    -
    - - {/* Divider container */} -
    -
    -
    - -
    - - {({input, meta}) => ( -
    - - {meta.touched && meta.error && - {meta.error}} -
    - )} -
    -
    - -
    -
    -
    - Type - -
    -
    -
    - ( - - Privacy setting -
    - {ActionTypeOptions.map((setting, settingIdx) => ( - - classNames( - settingIdx === 0 ? 'rounded-tl-md rounded-tr-md' : '', - settingIdx === ActionTypeOptions.length - 1 ? 'rounded-bl-md rounded-br-md' : '', - checked ? 'bg-indigo-50 border-indigo-200 z-10' : 'border-gray-200', - 'relative border p-4 flex cursor-pointer focus:outline-none' - ) - } - > - {({ - active, - checked - }) => ( - - - )} - - ))} -
    -
    - - )} - /> - -
    -
    -
    -
    - - {TypeForm(values)} - -
    -
    - -
    -
    - - -
    -
    - - - - ) - }} - + Close panel +
    +
    - -
    -
    -
    -
    - ) +
    + + + + {TypeForm(values)} +
    + + +
    +
    + + +
    +
    + + + + ); + }} + + + + + + + + ); } -export default FilterActionAddForm; \ No newline at end of file +export default FilterActionAddForm; diff --git a/web/src/forms/filters/FilterActionUpdateForm.tsx b/web/src/forms/filters/FilterActionUpdateForm.tsx index 24253e2..ac962e5 100644 --- a/web/src/forms/filters/FilterActionUpdateForm.tsx +++ b/web/src/forms/filters/FilterActionUpdateForm.tsx @@ -1,822 +1,307 @@ -import {Fragment, useEffect} from "react"; -import {useMutation} from "react-query"; -import {Action, DownloadClient, Filter} from "../../domain/interfaces"; -import {queryClient} from "../../App"; -import {sleep} from "../../utils/utils"; -import {CheckIcon, ExclamationIcon, SelectorIcon, XIcon} from "@heroicons/react/solid"; -import {Dialog, Listbox, RadioGroup, Transition} from "@headlessui/react"; -import {classNames} from "../../styles/utils"; -import {Field, Form} from "react-final-form"; +import { Fragment, useEffect } from "react"; +import { useMutation } from "react-query"; +import { Action, DownloadClient, Filter } from "../../domain/interfaces"; +import { queryClient } from "../../App"; +import { sleep } from "../../utils/utils"; +import { XIcon } from "@heroicons/react/solid"; +import { Dialog, Transition } from "@headlessui/react"; +import { Form } from "react-final-form"; import DEBUG from "../../components/debug"; import APIClient from "../../api/APIClient"; -import {ActionTypeOptions} from "../../domain/constants"; +import { ActionTypeOptions } from "../../domain/constants"; +import { AlertWarning } from "../../components/alerts"; +import { TextFieldWide } from "../../components/inputs"; +import { + NumberFieldWide, + RadioFieldsetWide, +} from "../../components/inputs/wide"; +import { DownloadClientSelect } from "./FilterActionAddForm"; interface props { - filter: Filter; - isOpen: boolean; - toggle: any; - clients: DownloadClient[]; - action: Action; + filter: Filter; + isOpen: boolean; + toggle: any; + clients: DownloadClient[]; + action: Action; } -function FilterActionUpdateForm({filter, isOpen, toggle, clients, action}: props) { - const mutation = useMutation((action: Action) => APIClient.actions.update(action), { - onSuccess: () => { - // console.log("add action"); - queryClient.invalidateQueries(['filter', filter.id]); - sleep(1500) +function FilterActionUpdateForm({ + filter, + isOpen, + toggle, + clients, + action, +}: props) { + const mutation = useMutation( + (action: Action) => APIClient.actions.update(action), + { + onSuccess: () => { + // console.log("add action"); + queryClient.invalidateQueries(["filter", filter.id]); + sleep(1500); - toggle() - } - }) - - useEffect(() => { - // console.log("render add action form", clients) - }, [clients]); - - const onSubmit = (data: any) => { - // TODO clear data depending on type - - console.log(data) - mutation.mutate(data) - }; - - const TypeForm = (values: any) => { - switch (values.type) { - case "TEST": - return ( -
    -
    -
    -
    -
    -
    -

    Notice

    -
    -

    - The test action does nothing except to show if the filter works. -

    -
    -
    -
    -
    -
    - ) - case "WATCH_FOLDER": - return ( -
    -
    -
    - -
    -
    - - {({input, meta}) => ( -
    - - {meta.touched && meta.error && - {meta.error}} -
    - )} -
    -
    -
    -
    - ) - case "EXEC": - return ( -
    -
    -
    - -
    -
    - - {({input, meta}) => ( -
    - - {meta.touched && meta.error && - {meta.error}} -
    - )} -
    -
    -
    - -
    -
    - -
    -
    - - {({input, meta}) => ( -
    - - {meta.touched && meta.error && - {meta.error}} -
    - )} -
    -
    -
    -
    - - ) - case "QBITTORRENT": - - return ( -
    - -
    - {/*// TODO change available clients to match only selected action type. eg qbittorrent or deluge*/} - - ( - - {({open}) => ( - <> - Client -
    - - {input.value ? clients.find(c => c.id === input.value)!.name : "Choose a client"} - {/*Choose a client*/} - - - - - - - {clients.filter((c) => c.type === values.type).map((client: any) => ( - - classNames( - active ? 'text-white bg-indigo-600' : 'text-gray-900', - 'cursor-default select-none relative py-2 pl-3 pr-9' - ) - } - value={client.id} - > - {({selected, active}) => ( - <> - - {client.name} - - - {selected ? ( - - - ) : null} - - )} - - ))} - - -
    - - )} -
    - )} /> - -
    - -
    -
    - -
    -
    - - {({input, meta}) => ( -
    - - {meta.touched && meta.error && - {meta.error}} -
    - )} -
    -
    -
    - -
    -
    - -
    -
    - - {({input, meta}) => ( -
    - - {meta.touched && meta.error && - {meta.error}} -
    - )} -
    -
    -
    - -
    -
    - -
    -
    - - {({input, meta}) => ( -
    - - {meta.touched && meta.error && - {meta.error}} -
    - )} -
    -
    -
    - -
    -
    -

    Limit speeds

    -

    - Limit download and upload speed for torrents in this filter. In KB/s. -

    -
    -
    -
    - -
    -
    - -
    -
    - - {({input, meta}) => ( -
    - - {meta.touched && meta.error && - {meta.error}} -
    - )} -
    -
    -
    -
    -
    - - -
    -
    - -
    -
    - -
    -
    - - {({input, meta}) => ( -
    - - {meta.touched && meta.error && - {meta.error}} -
    - )} -
    -
    -
    -
    -
    - -
    -
    - ) - case "DELUGE_V1": - case "DELUGE_V2": - return ( -
    - {/*TODO choose client*/} - -
    - ( - - {({open}) => ( - <> - Client -
    - - {input.value ? clients.find(c => c.id === input.value)!.name : "Choose a client"} - {/*Choose a client*/} - - - - - - - {clients.filter((c) => c.type === values.type).map((client: any) => ( - - classNames( - active ? 'text-white bg-indigo-600' : 'text-gray-900', - 'cursor-default select-none relative py-2 pl-3 pr-9' - ) - } - value={client.id} - > - {({selected, active}) => ( - <> - - {client.name} - - - {selected ? ( - - - ) : null} - - )} - - ))} - - -
    - - )} -
    - )} /> -
    - -
    -
    - -
    -
    - - {({input, meta}) => ( -
    - - {meta.touched && meta.error && - {meta.error}} -
    - )} -
    -
    -
    - -
    -
    - -
    -
    - - {({input, meta}) => ( -
    - - {meta.touched && meta.error && - {meta.error}} -
    - )} -
    -
    -
    - -
    -
    -

    Limit speeds

    -

    - Limit download and upload speed for torrents in this filter. In KB/s. -

    -
    -
    -
    - -
    -
    - -
    -
    - - {({input, meta}) => ( -
    - - {meta.touched && meta.error && - {meta.error}} -
    - )} -
    -
    -
    -
    -
    - - -
    -
    - -
    -
    - -
    -
    - - {({input, meta}) => ( -
    - - {meta.touched && meta.error && - {meta.error}} -
    - )} -
    -
    -
    -
    -
    -
    -
    - ) - default: - return ( -
    -
    -
    -
    -
    -
    -

    Notice

    -
    -

    - The test action does nothing except to show if the filter works. -

    -
    -
    -
    -
    -
    - ) - } + toggle(); + }, } + ); - return ( - - -
    - + useEffect(() => { + // console.log("render add action form", clients) + }, [clients]); -
    - -
    -
    { + // TODO clear data depending on type + + console.log(data); + mutation.mutate(data); + }; + + const TypeForm = (values: any) => { + switch (values.type) { + case "TEST": + return ( + + ); + case "WATCH_FOLDER": + return ( +
    + +
    + ); + case "EXEC": + return ( +
    + + + +
    + ); + case "QBITTORRENT": + return ( +
    + + + + + + +
    +
    +

    + Limit speeds +

    +

    + Limit download and upload speed for torrents in this filter. + In KB/s. +

    +
    + + +
    +
    + ); + case "DELUGE_V1": + case "DELUGE_V2": + return ( +
    + + + + + +
    +
    +

    + Limit speeds +

    +

    + Limit download and upload speed for torrents in this filter. + In KB/s. +

    +
    + + +
    +
    + ); + case "RADARR": + return ( +
    + +
    + ); + default: + return ( + + ); + } + }; + + return ( + + +
    + + +
    + +
    + + {({ handleSubmit, values }) => { + return ( + +
    +
    +
    +
    + + Update action + +

    + Add filter action. +

    +
    +
    + -
    -
    -
    - - {/* Divider container */} -
    -
    -
    - -
    - - {({input, meta}) => ( -
    - - {meta.touched && meta.error && - {meta.error}} -
    - )} -
    -
    - -
    -
    -
    - Type - -
    -
    -
    - ( - - Privacy setting -
    - {ActionTypeOptions.map((setting, settingIdx) => ( - - classNames( - settingIdx === 0 ? 'rounded-tl-md rounded-tr-md' : '', - settingIdx === ActionTypeOptions.length - 1 ? 'rounded-bl-md rounded-br-md' : '', - checked ? 'bg-indigo-50 border-indigo-200 z-10' : 'border-gray-200', - 'relative border p-4 flex cursor-pointer focus:outline-none' - ) - } - > - {({ - active, - checked - }) => ( - - - )} - - ))} -
    -
    - - )} - /> - -
    -
    -
    -
    - - {TypeForm(values)} - -
    -
    - -
    -
    - - -
    -
    - - - - ) - }} - + Close panel +
    +
    - -
    -
    -
    -
    - ) +
    + + + + {TypeForm(values)} +
    + + +
    +
    + + +
    +
    + + + + ); + }} + + + + + + + + ); } export default FilterActionUpdateForm; diff --git a/web/src/forms/index.ts b/web/src/forms/index.ts index 6617bfd..b27d312 100644 --- a/web/src/forms/index.ts +++ b/web/src/forms/index.ts @@ -2,8 +2,8 @@ export { default as FilterAddForm } from "./filters/FilterAddForm"; export { default as FilterActionAddForm } from "./filters/FilterActionAddForm"; export { default as FilterActionUpdateForm } from "./filters/FilterActionUpdateForm"; -export { default as DownloadClientAddForm } from "./settings/DownloadClientAddForm"; -export { default as DownloadClientUpdateForm } from "./settings/DownloadClientUpdateForm"; +export { default as DownloadClientAddForm } from "./settings/downloadclient/DownloadClientAddForm"; +export { default as DownloadClientUpdateForm } from "./settings/downloadclient/DownloadClientUpdateForm"; export { default as IndexerAddForm } from "./settings/IndexerAddForm"; export { default as IndexerUpdateForm } from "./settings/IndexerUpdateForm"; diff --git a/web/src/forms/settings/DownloadClientAddForm.tsx b/web/src/forms/settings/DownloadClientAddForm.tsx deleted file mode 100644 index 46e550f..0000000 --- a/web/src/forms/settings/DownloadClientAddForm.tsx +++ /dev/null @@ -1,397 +0,0 @@ -import React, {Fragment, useState} from "react"; -import {useMutation} from "react-query"; -import {DOWNLOAD_CLIENT_TYPES, DownloadClient} from "../../domain/interfaces"; -import {Dialog, RadioGroup, Transition} from "@headlessui/react"; -import {XIcon} from "@heroicons/react/solid"; -import {classNames} from "../../styles/utils"; -import {Field, Form} from "react-final-form"; -import DEBUG from "../../components/debug"; -import {SwitchGroup} from "../../components/inputs"; -import {queryClient} from "../../App"; -import APIClient from "../../api/APIClient"; -import {sleep} from "../../utils/utils"; -import {DownloadClientTypeOptions} from "../../domain/constants"; - -function DownloadClientAddForm({isOpen, toggle}: any) { - const [isTesting, setIsTesting] = useState(false) - const [isSuccessfulTest, setIsSuccessfulTest] = useState(false) - const [isErrorTest, setIsErrorTest] = useState(false) - - const mutation = useMutation((client: DownloadClient) => APIClient.download_clients.create(client), { - onSuccess: () => { - queryClient.invalidateQueries(['downloadClients']); - toggle() - } - }) - - const testClientMutation = useMutation((client: DownloadClient) => APIClient.download_clients.test(client), { - onMutate: () => { - setIsTesting(true) - setIsErrorTest(false) - setIsSuccessfulTest(false) - }, - onSuccess: () => { - sleep(1000).then(() => { - setIsTesting(false) - setIsSuccessfulTest(true) - }).then(() => { - sleep(2500).then(() => { - setIsSuccessfulTest(false) - }) - }) - }, - onError: (error) => { - setIsTesting(false) - setIsErrorTest(true) - sleep(2500).then(() => { - setIsErrorTest(false) - }) - }, - }) - - const onSubmit = (data: any) => { - mutation.mutate(data) - }; - - const testClient = (data: any) => { - testClientMutation.mutate(data) - } - - return ( - - -
    - - -
    - -
    - -
    - {({handleSubmit, values}) => { - return ( - -
    - {/* Header */} -
    -
    -
    - Add - client -

    - Add download client. -

    -
    -
    - -
    -
    -
    - -
    - -
    -
    - -
    - - {({input, meta}) => ( -
    - - {meta.touched && meta.error && - {meta.error}} -
    - )} -
    -
    - -
    - -
    - -
    -
    -
    - Type - -
    -
    -
    - ( - - Client - type -
    - {DownloadClientTypeOptions.map((setting, settingIdx) => ( - - classNames( - settingIdx === 0 ? 'rounded-tl-md rounded-tr-md' : '', - settingIdx === DownloadClientTypeOptions.length - 1 ? 'rounded-bl-md rounded-br-md' : '', - checked ? 'bg-indigo-50 border-indigo-200 z-10' : 'border-gray-200', - 'relative border p-4 flex cursor-pointer focus:outline-none' - ) - } - > - {({ - active, - checked - }) => ( - - - )} - - ))} -
    -
    - - )} - /> - -
    -
    -
    -
    - -
    -
    -
    - -
    - - {({input, meta}) => ( -
    - - {meta.touched && meta.error && - {meta.error}} -
    - )} -
    -
    - -
    -
    - -
    - v && parseInt(v, 10)}> - {({input, meta}) => ( -
    - - {meta.touched && meta.error && - {meta.error}} -
    - )} -
    -
    - -
    - -
    - -
    -
    - -
    - - {({input, meta}) => ( -
    - - {meta.touched && meta.error && - {meta.error}} -
    - )} -
    -
    - -
    -
    - -
    - - {({input, meta}) => ( -
    - - {meta.touched && meta.error && - {meta.error}} -
    - )} -
    -
    - -
    -
    -
    - -
    -
    - - - -
    -
    - - - - ) - }} - -
    - -
    -
    -
    -
    -
    - ) -} - -export default DownloadClientAddForm; \ No newline at end of file diff --git a/web/src/forms/settings/DownloadClientUpdateForm.tsx b/web/src/forms/settings/DownloadClientUpdateForm.tsx deleted file mode 100644 index 92cd1fd..0000000 --- a/web/src/forms/settings/DownloadClientUpdateForm.tsx +++ /dev/null @@ -1,506 +0,0 @@ -import {Fragment, useRef, useState} from "react"; -import {useToggle} from "../../hooks/hooks"; -import {useMutation} from "react-query"; -import {DownloadClient} from "../../domain/interfaces"; -import {queryClient} from "../../App"; -import {Dialog, RadioGroup, Transition} from "@headlessui/react"; -import {ExclamationIcon, XIcon} from "@heroicons/react/solid"; -import {classNames} from "../../styles/utils"; -import {Field, Form} from "react-final-form"; -import DEBUG from "../../components/debug"; -import {SwitchGroup} from "../../components/inputs"; -import {DownloadClientTypeOptions} from "../../domain/constants"; -import APIClient from "../../api/APIClient"; -import {sleep} from "../../utils/utils"; - -function DownloadClientUpdateForm({client, isOpen, toggle}: any) { - const [isTesting, setIsTesting] = useState(false) - const [isSuccessfulTest, setIsSuccessfulTest] = useState(false) - const [isErrorTest, setIsErrorTest] = useState(false) - const [deleteModalIsOpen, toggleDeleteModal] = useToggle(false) - - const mutation = useMutation((client: DownloadClient) => APIClient.download_clients.update(client), { - onSuccess: () => { - queryClient.invalidateQueries(['downloadClients']); - - toggle() - } - }) - - const deleteMutation = useMutation((clientID: number) => APIClient.download_clients.delete(clientID), { - onSuccess: () => { - queryClient.invalidateQueries(); - toggleDeleteModal() - } - }) - - const testClientMutation = useMutation((client: DownloadClient) => APIClient.download_clients.test(client), { - onMutate: () => { - setIsTesting(true) - setIsErrorTest(false) - setIsSuccessfulTest(false) - }, - onSuccess: () => { - sleep(1000).then(() => { - setIsTesting(false) - setIsSuccessfulTest(true) - }).then(() => { - sleep(2500).then(() => { - setIsSuccessfulTest(false) - }) - }) - }, - onError: (error) => { - setIsTesting(false) - setIsErrorTest(true) - sleep(2500).then(() => { - setIsErrorTest(false) - }) - }, - }) - - const onSubmit = (data: any) => { - mutation.mutate(data) - }; - - const cancelButtonRef = useRef(null) - const cancelModalButtonRef = useRef(null) - - const deleteAction = () => { - deleteMutation.mutate(client.id) - } - - const testClient = (data: any) => { - testClientMutation.mutate(data) - } - - return ( - - - - -
    - - - - - {/* This element is to trick the browser into centering the modal contents. */} - - -
    -
    -
    -
    -
    -
    - - Remove client - -
    -

    - Are you sure you want to remove this client? - This action cannot be undone. -

    -
    -
    -
    -
    -
    - - -
    -
    -
    -
    -
    -
    -
    - - -
    - -
    - -
    - {({handleSubmit, values}) => { - return ( - -
    - {/* Header */} -
    -
    -
    - Edit - client -

    - Edit download client settings. -

    -
    -
    - -
    -
    -
    - -
    -
    -
    - -
    - - {({input, meta}) => ( -
    - - {meta.touched && meta.error && - {meta.error}} -
    - )} -
    -
    - -
    - -
    - -
    -
    -
    - Type - -
    -
    -
    - ( - - Privacy - setting -
    - {DownloadClientTypeOptions.map((setting, settingIdx) => ( - - classNames( - settingIdx === 0 ? 'rounded-tl-md rounded-tr-md' : '', - settingIdx === DownloadClientTypeOptions.length - 1 ? 'rounded-bl-md rounded-br-md' : '', - checked ? 'bg-indigo-50 border-indigo-200 z-10' : 'border-gray-200', - 'relative border p-4 flex cursor-pointer focus:outline-none' - ) - } - > - {({ - active, - checked - }) => ( - - - )} - - ))} -
    -
    - - )} - /> - -
    -
    -
    -
    - -
    - -
    -
    - -
    - - {({input, meta}) => ( -
    - - {meta.touched && meta.error && - {meta.error}} -
    - )} -
    -
    - - -
    -
    - -
    - v && parseInt(v, 10)}> - {({input, meta}) => ( -
    - - {meta.touched && meta.error && - {meta.error}} -
    - )} -
    -
    - -
    - -
    - -
    -
    - -
    - - {({input, meta}) => ( -
    - - {meta.touched && meta.error && - {meta.error}} -
    - )} -
    -
    - -
    -
    - -
    - - {({input, meta}) => ( -
    - - {meta.touched && meta.error && - {meta.error}} -
    - )} -
    -
    - -
    -
    -
    - -
    -
    - -
    - - - - -
    -
    -
    - - - ) - }} - -
    - -
    -
    -
    -
    -
    - ) -} - -export default DownloadClientUpdateForm; \ No newline at end of file diff --git a/web/src/forms/settings/downloadclient/DownloadClientAddForm.tsx b/web/src/forms/settings/downloadclient/DownloadClientAddForm.tsx new file mode 100644 index 0000000..84e2dcf --- /dev/null +++ b/web/src/forms/settings/downloadclient/DownloadClientAddForm.tsx @@ -0,0 +1,234 @@ +import React, { Fragment, useState } from "react"; +import { useMutation } from "react-query"; +import { + DOWNLOAD_CLIENT_TYPES, + DownloadClient, +} from "../../../domain/interfaces"; +import { Dialog, Transition } from "@headlessui/react"; +import { XIcon } from "@heroicons/react/solid"; +import { classNames } from "../../../styles/utils"; +import { Form } from "react-final-form"; +import DEBUG from "../../../components/debug"; +import { SwitchGroup, TextFieldWide } from "../../../components/inputs"; +import { queryClient } from "../../../App"; +import APIClient from "../../../api/APIClient"; +import { sleep } from "../../../utils/utils"; +import { DownloadClientTypeOptions } from "../../../domain/constants"; +import { RadioFieldsetWide } from "../../../components/inputs/wide"; +import { componentMap } from "./shared"; + +function DownloadClientAddForm({ isOpen, toggle }: any) { + const [isTesting, setIsTesting] = useState(false); + const [isSuccessfulTest, setIsSuccessfulTest] = useState(false); + const [isErrorTest, setIsErrorTest] = useState(false); + + const mutation = useMutation( + (client: DownloadClient) => APIClient.download_clients.create(client), + { + onSuccess: () => { + queryClient.invalidateQueries(["downloadClients"]); + toggle(); + }, + } + ); + + const testClientMutation = useMutation( + (client: DownloadClient) => APIClient.download_clients.test(client), + { + onMutate: () => { + setIsTesting(true); + setIsErrorTest(false); + setIsSuccessfulTest(false); + }, + onSuccess: () => { + sleep(1000) + .then(() => { + setIsTesting(false); + setIsSuccessfulTest(true); + }) + .then(() => { + sleep(2500).then(() => { + setIsSuccessfulTest(false); + }); + }); + }, + onError: (error) => { + setIsTesting(false); + setIsErrorTest(true); + sleep(2500).then(() => { + setIsErrorTest(false); + }); + }, + } + ); + + const onSubmit = (data: any) => { + mutation.mutate(data); + }; + + const testClient = (data: any) => { + testClientMutation.mutate(data); + }; + + return ( + + +
    + + +
    + +
    +
    + {({ handleSubmit, values }) => { + return ( + +
    +
    +
    +
    + + Add client + +

    + Add download client. +

    +
    +
    + +
    +
    +
    + +
    + + +
    + +
    + + + +
    {componentMap[values.type]}
    +
    +
    + +
    +
    + + + +
    +
    + + + + ); + }} + +
    +
    +
    +
    +
    +
    + ); +} + +export default DownloadClientAddForm; diff --git a/web/src/forms/settings/downloadclient/DownloadClientUpdateForm.tsx b/web/src/forms/settings/downloadclient/DownloadClientUpdateForm.tsx new file mode 100644 index 0000000..850304a --- /dev/null +++ b/web/src/forms/settings/downloadclient/DownloadClientUpdateForm.tsx @@ -0,0 +1,272 @@ +import { Fragment, useRef, useState } from "react"; +import { useToggle } from "../../../hooks/hooks"; +import { useMutation } from "react-query"; +import { DownloadClient } from "../../../domain/interfaces"; +import { queryClient } from "../../../App"; +import { Dialog, Transition } from "@headlessui/react"; +import { XIcon } from "@heroicons/react/solid"; +import { classNames } from "../../../styles/utils"; +import { Form } from "react-final-form"; +import DEBUG from "../../../components/debug"; +import { SwitchGroup, TextFieldWide } from "../../../components/inputs"; +import { DownloadClientTypeOptions } from "../../../domain/constants"; +import APIClient from "../../../api/APIClient"; +import { sleep } from "../../../utils/utils"; +import { componentMap } from "./shared"; +import { RadioFieldsetWide } from "../../../components/inputs/wide"; +import { DeleteModal } from "../../../components/modals"; + +function DownloadClientUpdateForm({ client, isOpen, toggle }: any) { + const [isTesting, setIsTesting] = useState(false); + const [isSuccessfulTest, setIsSuccessfulTest] = useState(false); + const [isErrorTest, setIsErrorTest] = useState(false); + const [deleteModalIsOpen, toggleDeleteModal] = useToggle(false); + + const mutation = useMutation( + (client: DownloadClient) => APIClient.download_clients.update(client), + { + onSuccess: () => { + queryClient.invalidateQueries(["downloadClients"]); + + toggle(); + }, + } + ); + + const deleteMutation = useMutation( + (clientID: number) => APIClient.download_clients.delete(clientID), + { + onSuccess: () => { + queryClient.invalidateQueries(); + toggleDeleteModal(); + }, + } + ); + + const testClientMutation = useMutation( + (client: DownloadClient) => APIClient.download_clients.test(client), + { + onMutate: () => { + setIsTesting(true); + setIsErrorTest(false); + setIsSuccessfulTest(false); + }, + onSuccess: () => { + sleep(1000) + .then(() => { + setIsTesting(false); + setIsSuccessfulTest(true); + }) + .then(() => { + sleep(2500).then(() => { + setIsSuccessfulTest(false); + }); + }); + }, + onError: (error) => { + setIsTesting(false); + setIsErrorTest(true); + sleep(2500).then(() => { + setIsErrorTest(false); + }); + }, + } + ); + + const onSubmit = (data: any) => { + mutation.mutate(data); + }; + + const cancelButtonRef = useRef(null); + const cancelModalButtonRef = useRef(null); + + const deleteAction = () => { + deleteMutation.mutate(client.id); + }; + + const testClient = (data: any) => { + testClientMutation.mutate(data); + }; + + return ( + + + +
    + + +
    + +
    +
    + {({ handleSubmit, values }) => { + return ( + +
    +
    +
    +
    + + Edit client + +

    + Edit download client settings. +

    +
    +
    + +
    +
    +
    + +
    + + +
    + +
    + + + +
    {componentMap[values.type]}
    +
    +
    + +
    +
    + +
    + + + + +
    +
    +
    + + + ); + }} + +
    +
    +
    +
    +
    +
    + ); +} + +export default DownloadClientUpdateForm; diff --git a/web/src/forms/settings/downloadclient/shared.tsx b/web/src/forms/settings/downloadclient/shared.tsx new file mode 100644 index 0000000..1e824eb --- /dev/null +++ b/web/src/forms/settings/downloadclient/shared.tsx @@ -0,0 +1,51 @@ +import React, { Fragment } from "react"; +import { SwitchGroup, TextFieldWide } from "../../../components/inputs"; +import { NumberFieldWide } from "../../../components/inputs/wide"; +import { useField } from "react-final-form"; + +function FormDefaultClientFields() { + return ( + + + + + +
    + +
    + + + +
    + ); +} + +function FormRadarrFields() { + const { input } = useField("settings.basic.auth"); + return ( + + + + + +
    + +
    + + {input.value === true && ( + + + + + )} +
    + ); +} + +// @ts-ignore +export const componentMap: any = { + DELUGE_V1: , + DELUGE_V2: , + QBITTORRENT: , + RADARR: , +};