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 (
+
+
+ {actions.map((action, idx) => (
+
+ ))}
+
+
+ );
}
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
- {
+ enabledMutation.mutate(action.id);
+ };
+
+ useEffect(() => {}, [action]);
+
+ const cancelButtonRef = useRef(null);
+
+ const deleteAction = () => {
+ deleteMutation.mutate(action.id);
+ };
+
+ const onSubmit = (action: Action) => {
+ // TODO clear data depending on type
+ updateMutation.mutate(action);
+ };
+
+ const TypeForm = (action: Action) => {
+ switch (action.type) {
+ case "TEST":
+ return (
+
+ );
+ case "EXEC":
+ return (
+
+ );
+ case "WATCH_FOLDER":
+ return (
+
+
+
+ );
+ case "QBITTORRENT":
+ return (
+
+ );
+ case "DELUGE_V1":
+ case "DELUGE_V2":
+ return (
+
+ );
+ case "RADARR":
+ return (
+
+
+
+ );
+
+ default:
+ return null;
+ }
+ };
+
+ return (
+
+
+
+ Use setting
+
+
+
+
+
+ {edit && (
+
+
+
+
+
+
+ );
+ }}
+
+
+ )}
+
+ );
}
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 (
+
+ );
+}
+
+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 (
+
+ );
+}
+
+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 (
-
-