diff --git a/internal/feed/service.go b/internal/feed/service.go
index 42701ac..190e6ca 100644
--- a/internal/feed/service.go
+++ b/internal/feed/service.go
@@ -11,6 +11,7 @@ import (
"github.com/autobrr/autobrr/pkg/errors"
"github.com/autobrr/autobrr/pkg/torznab"
+ "github.com/dcarbone/zadapters/zstdlog"
"github.com/rs/zerolog"
)
@@ -20,6 +21,7 @@ type Service interface {
Find(ctx context.Context) ([]domain.Feed, error)
Store(ctx context.Context, feed *domain.Feed) error
Update(ctx context.Context, feed *domain.Feed) error
+ Test(ctx context.Context, feed *domain.Feed) error
ToggleEnabled(ctx context.Context, id int, enabled bool) error
Delete(ctx context.Context, id int) error
@@ -199,6 +201,28 @@ func (s *service) toggleEnabled(ctx context.Context, id int, enabled bool) error
return nil
}
+func (s *service) Test(ctx context.Context, feed *domain.Feed) error {
+
+ subLogger := zstdlog.NewStdLoggerWithLevel(s.log.With().Logger(), zerolog.DebugLevel)
+
+ // setup torznab Client
+ c := torznab.NewClient(torznab.Config{Host: feed.URL, ApiKey: feed.ApiKey, Log: subLogger})
+ caps, err := c.GetCaps()
+ if err != nil {
+ s.log.Error().Err(err).Msg("error testing feed")
+ return err
+ }
+
+ if caps == nil {
+ s.log.Error().Msg("could not test feed and get caps")
+ return errors.New("could not test feed and get caps")
+ }
+
+ s.log.Debug().Msgf("test successful - connected to feed: %+v", feed.URL)
+
+ return nil
+}
+
func (s *service) Start() error {
// get all torznab indexer definitions
feeds, err := s.repo.Find(context.TODO())
@@ -286,7 +310,7 @@ func (s *service) addTorznabJob(f feedInstance) error {
l := s.log.With().Str("feed", f.Name).Logger()
// setup torznab Client
- c := torznab.NewClient(f.URL, f.ApiKey)
+ c := torznab.NewClient(torznab.Config{Host: f.URL, ApiKey: f.ApiKey})
// create job
job := NewTorznabJob(f.Name, f.IndexerIdentifier, l, f.URL, c, s.cacheRepo, s.releaseSvc)
diff --git a/internal/feed/torznab.go b/internal/feed/torznab.go
index f75a544..f73854b 100644
--- a/internal/feed/torznab.go
+++ b/internal/feed/torznab.go
@@ -17,7 +17,7 @@ type TorznabJob struct {
IndexerIdentifier string
Log zerolog.Logger
URL string
- Client *torznab.Client
+ Client torznab.Client
Repo domain.FeedCacheRepo
ReleaseSvc release.Service
@@ -27,7 +27,7 @@ type TorznabJob struct {
JobID int
}
-func NewTorznabJob(name string, indexerIdentifier string, log zerolog.Logger, url string, client *torznab.Client, repo domain.FeedCacheRepo, releaseSvc release.Service) *TorznabJob {
+func NewTorznabJob(name string, indexerIdentifier string, log zerolog.Logger, url string, client torznab.Client, repo domain.FeedCacheRepo, releaseSvc release.Service) *TorznabJob {
return &TorznabJob{
Name: name,
IndexerIdentifier: indexerIdentifier,
diff --git a/internal/http/feed.go b/internal/http/feed.go
index 86ff704..c643866 100644
--- a/internal/http/feed.go
+++ b/internal/http/feed.go
@@ -17,6 +17,7 @@ type feedService interface {
Update(ctx context.Context, feed *domain.Feed) error
Delete(ctx context.Context, id int) error
ToggleEnabled(ctx context.Context, id int, enabled bool) error
+ Test(ctx context.Context, feed *domain.Feed) error
}
type feedHandler struct {
@@ -34,6 +35,7 @@ func newFeedHandler(encoder encoder, service feedService) *feedHandler {
func (h feedHandler) Routes(r chi.Router) {
r.Get("/", h.find)
r.Post("/", h.store)
+ r.Post("/test", h.test)
r.Put("/{feedID}", h.update)
r.Patch("/{feedID}/enabled", h.toggleEnabled)
r.Delete("/{feedID}", h.delete)
@@ -73,6 +75,27 @@ func (h feedHandler) store(w http.ResponseWriter, r *http.Request) {
h.encoder.StatusResponse(ctx, w, data, http.StatusCreated)
}
+func (h feedHandler) test(w http.ResponseWriter, r *http.Request) {
+ var (
+ ctx = r.Context()
+ data *domain.Feed
+ )
+
+ if err := json.NewDecoder(r.Body).Decode(&data); err != nil {
+ // encode error
+ h.encoder.StatusInternalError(w)
+ return
+ }
+
+ if err := h.service.Test(ctx, data); err != nil {
+ // encode error
+ h.encoder.StatusInternalError(w)
+ return
+ }
+
+ h.encoder.NoContent(w)
+}
+
func (h feedHandler) update(w http.ResponseWriter, r *http.Request) {
var (
ctx = r.Context()
diff --git a/pkg/torznab/caps.go b/pkg/torznab/caps.go
new file mode 100644
index 0000000..b930b74
--- /dev/null
+++ b/pkg/torznab/caps.go
@@ -0,0 +1,98 @@
+package torznab
+
+import "encoding/xml"
+
+type Server struct {
+ Version string `xml:"version,attr"`
+ Title string `xml:"title,attr"`
+ Strapline string `xml:"strapline,attr"`
+ Email string `xml:"email,attr"`
+ URL string `xml:"url,attr"`
+ Image string `xml:"image,attr"`
+}
+type Limits struct {
+ Max string `xml:"max,attr"`
+ Default string `xml:"default,attr"`
+}
+type Retention struct {
+ Days string `xml:"days,attr"`
+}
+
+type Registration struct {
+ Available string `xml:"available,attr"`
+ Open string `xml:"open,attr"`
+}
+
+type Searching struct {
+ Search Search `xml:"search"`
+ TvSearch Search `xml:"tv-search"`
+ MovieSearch Search `xml:"movie-search"`
+ AudioSearch Search `xml:"audio-search"`
+ BookSearch Search `xml:"book-search"`
+}
+
+type Search struct {
+ Available string `xml:"available,attr"`
+ SupportedParams string `xml:"supportedParams,attr"`
+}
+
+type Categories struct {
+ Category []Category `xml:"category"`
+}
+
+type Category struct {
+ ID string `xml:"id,attr"`
+ Name string `xml:"name,attr"`
+ Subcat []SubCategory `xml:"subcat"`
+}
+
+type SubCategory struct {
+ ID string `xml:"id,attr"`
+ Name string `xml:"name,attr"`
+}
+
+type Groups struct {
+ Group Group `xml:"group"`
+}
+type Group struct {
+ ID string `xml:"id,attr"`
+ Name string `xml:"name,attr"`
+ Description string `xml:"description,attr"`
+ Lastupdate string `xml:"lastupdate,attr"`
+}
+
+type Genres struct {
+ Genre Genre `xml:"genre"`
+}
+
+type Genre struct {
+ ID string `xml:"id,attr"`
+ Categoryid string `xml:"categoryid,attr"`
+ Name string `xml:"name,attr"`
+}
+
+type Tags struct {
+ Tag []Tag `xml:"tag"`
+}
+
+type Tag struct {
+ Name string `xml:"name,attr"`
+ Description string `xml:"description,attr"`
+}
+
+type CapsResponse struct {
+ Caps Caps `xml:"caps"`
+}
+
+type Caps struct {
+ XMLName xml.Name `xml:"caps"`
+ Server Server `xml:"server"`
+ Limits Limits `xml:"limits"`
+ Retention Retention `xml:"retention"`
+ Registration Registration `xml:"registration"`
+ Searching Searching `xml:"searching"`
+ Categories Categories `xml:"categories"`
+ Groups Groups `xml:"groups"`
+ Genres Genres `xml:"genres"`
+ Tags Tags `xml:"tags"`
+}
diff --git a/pkg/torznab/client.go b/pkg/torznab/client.go
deleted file mode 100644
index b4d7baf..0000000
--- a/pkg/torznab/client.go
+++ /dev/null
@@ -1,176 +0,0 @@
-package torznab
-
-import (
- "bytes"
- "encoding/xml"
- "fmt"
- "io"
- "net/http"
- "net/url"
- "time"
-
- "github.com/autobrr/autobrr/pkg/errors"
-)
-
-type Response struct {
- Channel struct {
- Items []FeedItem `xml:"item"`
- } `xml:"channel"`
-}
-
-type FeedItem struct {
- Title string `xml:"title,omitempty"`
- GUID string `xml:"guid,omitempty"`
- PubDate Time `xml:"pub_date,omitempty"`
- Prowlarrindexer struct {
- Text string `xml:",chardata"`
- ID string `xml:"id,attr"`
- } `xml:"prowlarrindexer"`
- Comments string `xml:"comments"`
- Size string `xml:"size"`
- Link string `xml:"link"`
- Category []string `xml:"category,omitempty"`
- Categories []string
-
- // attributes
- TvdbId string `xml:"tvdb,omitempty"`
- //TvMazeId string
- ImdbId string `xml:"imdb,omitempty"`
- TmdbId string `xml:"tmdb,omitempty"`
-
- Attributes []struct {
- XMLName xml.Name
- Name string `xml:"name,attr"`
- Value string `xml:"value,attr"`
- } `xml:"attr"`
-}
-
-// Time credits: https://github.com/mrobinsn/go-newznab/blob/cd89d9c56447859fa1298dc9a0053c92c45ac7ef/newznab/structs.go#L150
-type Time struct {
- time.Time
-}
-
-func (t *Time) MarshalXML(e *xml.Encoder, start xml.StartElement) error {
- if err := e.EncodeToken(start); err != nil {
- return errors.Wrap(err, "failed to encode xml token")
- }
- if err := e.EncodeToken(xml.CharData([]byte(t.UTC().Format(time.RFC1123Z)))); err != nil {
- return errors.Wrap(err, "failed to encode xml token")
- }
- if err := e.EncodeToken(xml.EndElement{Name: start.Name}); err != nil {
- return errors.Wrap(err, "failed to encode xml token")
- }
- return nil
-}
-
-func (t *Time) UnmarshalXML(d *xml.Decoder, start xml.StartElement) error {
- var raw string
-
- err := d.DecodeElement(&raw, &start)
- if err != nil {
- return errors.Wrap(err, "could not decode element")
- }
-
- date, err := time.Parse(time.RFC1123Z, raw)
- if err != nil {
- return errors.Wrap(err, "could not parse date")
- }
-
- *t = Time{date}
- return nil
-}
-
-type Client struct {
- http *http.Client
-
- Host string
- ApiKey string
-
- UseBasicAuth bool
- BasicAuth BasicAuth
-}
-
-type BasicAuth struct {
- Username string
- Password string
-}
-
-func NewClient(url string, apiKey string) *Client {
- httpClient := &http.Client{
- Timeout: time.Second * 20,
- }
-
- c := &Client{
- http: httpClient,
- Host: url,
- ApiKey: apiKey,
- }
-
- return c
-}
-
-func (c *Client) get(endpoint string, opts map[string]string) (int, *Response, error) {
- reqUrl := fmt.Sprintf("%v%v", c.Host, endpoint)
-
- req, err := http.NewRequest("GET", reqUrl, nil)
- if err != nil {
- return 0, nil, errors.Wrap(err, "could not build request")
- }
-
- if c.UseBasicAuth {
- req.SetBasicAuth(c.BasicAuth.Username, c.BasicAuth.Password)
- }
-
- if c.ApiKey != "" {
- req.Header.Add("X-API-Key", c.ApiKey)
- }
-
- resp, err := c.http.Do(req)
- if err != nil {
- return 0, nil, errors.Wrap(err, "could not make request. %+v", req)
- }
-
- defer resp.Body.Close()
-
- var buf bytes.Buffer
- if _, err = io.Copy(&buf, resp.Body); err != nil {
- return resp.StatusCode, nil, errors.Wrap(err, "torznab.io.Copy")
- }
-
- var response Response
- if err := xml.Unmarshal(buf.Bytes(), &response); err != nil {
- return resp.StatusCode, nil, errors.Wrap(err, "torznab: could not decode feed")
- }
-
- return resp.StatusCode, &response, nil
-}
-
-func (c *Client) GetFeed() ([]FeedItem, error) {
- status, res, err := c.get("?t=search", nil)
- if err != nil {
- return nil, errors.Wrap(err, "could not get feed")
- }
-
- if status != http.StatusOK {
- return nil, errors.New("could not get feed")
- }
-
- return res.Channel.Items, nil
-}
-
-func (c *Client) Search(query string) ([]FeedItem, error) {
- v := url.Values{}
- v.Add("q", query)
- params := v.Encode()
-
- status, res, err := c.get("&t=search&"+params, nil)
- if err != nil {
- return nil, errors.Wrap(err, "could not search feed")
- }
-
- if status != http.StatusOK {
- return nil, errors.New("could not search feed")
- }
-
- return res.Channel.Items, nil
-}
diff --git a/pkg/torznab/feed.go b/pkg/torznab/feed.go
new file mode 100644
index 0000000..26f2ecf
--- /dev/null
+++ b/pkg/torznab/feed.go
@@ -0,0 +1,76 @@
+package torznab
+
+import (
+ "encoding/xml"
+ "time"
+
+ "github.com/autobrr/autobrr/pkg/errors"
+)
+
+type Response struct {
+ Channel struct {
+ Items []FeedItem `xml:"item"`
+ } `xml:"channel"`
+}
+
+type FeedItem struct {
+ Title string `xml:"title,omitempty"`
+ GUID string `xml:"guid,omitempty"`
+ PubDate Time `xml:"pub_date,omitempty"`
+ Prowlarrindexer struct {
+ Text string `xml:",chardata"`
+ ID string `xml:"id,attr"`
+ } `xml:"prowlarrindexer"`
+ Comments string `xml:"comments"`
+ Size string `xml:"size"`
+ Link string `xml:"link"`
+ Category []string `xml:"category,omitempty"`
+ Categories []string
+
+ // attributes
+ TvdbId string `xml:"tvdb,omitempty"`
+ //TvMazeId string
+ ImdbId string `xml:"imdb,omitempty"`
+ TmdbId string `xml:"tmdb,omitempty"`
+
+ Attributes []struct {
+ XMLName xml.Name
+ Name string `xml:"name,attr"`
+ Value string `xml:"value,attr"`
+ } `xml:"attr"`
+}
+
+// Time credits: https://github.com/mrobinsn/go-newznab/blob/cd89d9c56447859fa1298dc9a0053c92c45ac7ef/newznab/structs.go#L150
+type Time struct {
+ time.Time
+}
+
+func (t *Time) MarshalXML(e *xml.Encoder, start xml.StartElement) error {
+ if err := e.EncodeToken(start); err != nil {
+ return errors.Wrap(err, "failed to encode xml token")
+ }
+ if err := e.EncodeToken(xml.CharData([]byte(t.UTC().Format(time.RFC1123Z)))); err != nil {
+ return errors.Wrap(err, "failed to encode xml token")
+ }
+ if err := e.EncodeToken(xml.EndElement{Name: start.Name}); err != nil {
+ return errors.Wrap(err, "failed to encode xml token")
+ }
+ return nil
+}
+
+func (t *Time) UnmarshalXML(d *xml.Decoder, start xml.StartElement) error {
+ var raw string
+
+ err := d.DecodeElement(&raw, &start)
+ if err != nil {
+ return errors.Wrap(err, "could not decode element")
+ }
+
+ date, err := time.Parse(time.RFC1123Z, raw)
+ if err != nil {
+ return errors.Wrap(err, "could not parse date")
+ }
+
+ *t = Time{date}
+ return nil
+}
diff --git a/pkg/torznab/testdata/caps_response.xml b/pkg/torznab/testdata/caps_response.xml
new file mode 100644
index 0000000..fa57a1e
--- /dev/null
+++ b/pkg/torznab/testdata/caps_response.xml
@@ -0,0 +1,41 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/pkg/torznab/torznab.go b/pkg/torznab/torznab.go
new file mode 100644
index 0000000..765c784
--- /dev/null
+++ b/pkg/torznab/torznab.go
@@ -0,0 +1,209 @@
+package torznab
+
+import (
+ "bytes"
+ "encoding/xml"
+ "io"
+ "log"
+ "net/http"
+ "net/http/httputil"
+ "net/url"
+ "strings"
+ "time"
+
+ "github.com/autobrr/autobrr/pkg/errors"
+)
+
+type Client interface {
+ GetFeed() ([]FeedItem, error)
+ GetCaps() (*Caps, error)
+}
+
+type client struct {
+ http *http.Client
+
+ Host string
+ ApiKey string
+
+ UseBasicAuth bool
+ BasicAuth BasicAuth
+
+ Log *log.Logger
+}
+
+type BasicAuth struct {
+ Username string
+ Password string
+}
+
+type Config struct {
+ Host string
+ ApiKey string
+
+ UseBasicAuth bool
+ BasicAuth BasicAuth
+
+ Log *log.Logger
+}
+
+func NewClient(config Config) Client {
+ httpClient := &http.Client{
+ Timeout: time.Second * 20,
+ }
+
+ c := &client{
+ http: httpClient,
+ Host: config.Host,
+ ApiKey: config.ApiKey,
+ Log: log.New(io.Discard, "", log.LstdFlags),
+ }
+
+ if config.Log != nil {
+ c.Log = config.Log
+ }
+
+ return c
+}
+
+func (c *client) get(endpoint string, opts map[string]string) (int, *Response, error) {
+ params := url.Values{
+ "t": {"search"},
+ }
+
+ u, err := url.Parse(c.Host)
+ u.Path = strings.TrimSuffix(u.Path, "/")
+ u.RawQuery = params.Encode()
+ reqUrl := u.String()
+
+ req, err := http.NewRequest("GET", reqUrl, nil)
+ if err != nil {
+ return 0, nil, errors.Wrap(err, "could not build request")
+ }
+
+ if c.UseBasicAuth {
+ req.SetBasicAuth(c.BasicAuth.Username, c.BasicAuth.Password)
+ }
+
+ if c.ApiKey != "" {
+ req.Header.Add("X-API-Key", c.ApiKey)
+ }
+
+ resp, err := c.http.Do(req)
+ if err != nil {
+ return 0, nil, errors.Wrap(err, "could not make request. %+v", req)
+ }
+
+ defer resp.Body.Close()
+
+ var buf bytes.Buffer
+ if _, err = io.Copy(&buf, resp.Body); err != nil {
+ return resp.StatusCode, nil, errors.Wrap(err, "torznab.io.Copy")
+ }
+
+ var response Response
+ if err := xml.Unmarshal(buf.Bytes(), &response); err != nil {
+ return resp.StatusCode, nil, errors.Wrap(err, "torznab: could not decode feed")
+ }
+
+ return resp.StatusCode, &response, nil
+}
+
+func (c *client) GetFeed() ([]FeedItem, error) {
+ status, res, err := c.get("", nil)
+ if err != nil {
+ return nil, errors.Wrap(err, "could not get feed")
+ }
+
+ if status != http.StatusOK {
+ return nil, errors.New("could not get feed")
+ }
+
+ return res.Channel.Items, nil
+}
+
+func (c *client) getCaps(endpoint string, opts map[string]string) (int, *Caps, error) {
+ params := url.Values{
+ "t": {"caps"},
+ }
+
+ u, err := url.Parse(c.Host)
+ u.Path = strings.TrimSuffix(u.Path, "/")
+ u.RawQuery = params.Encode()
+ reqUrl := u.String()
+
+ req, err := http.NewRequest("GET", reqUrl, nil)
+ if err != nil {
+ return 0, nil, errors.Wrap(err, "could not build request")
+ }
+
+ if c.UseBasicAuth {
+ req.SetBasicAuth(c.BasicAuth.Username, c.BasicAuth.Password)
+ }
+
+ if c.ApiKey != "" {
+ req.Header.Add("X-API-Key", c.ApiKey)
+ }
+
+ resp, err := c.http.Do(req)
+ if err != nil {
+ return 0, nil, errors.Wrap(err, "could not make request. %+v", req)
+ }
+
+ defer resp.Body.Close()
+
+ dump, err := httputil.DumpResponse(resp, true)
+ if err != nil {
+ return 0, nil, errors.Wrap(err, "could not dump response")
+ }
+
+ c.Log.Printf("get torrent trackers response dump: %q", dump)
+
+ if resp.StatusCode == http.StatusUnauthorized {
+ return resp.StatusCode, nil, errors.New("unauthorized")
+ } else if resp.StatusCode != http.StatusOK {
+ return resp.StatusCode, nil, errors.New("bad status: %d", resp.StatusCode)
+ }
+
+ var buf bytes.Buffer
+ if _, err = io.Copy(&buf, resp.Body); err != nil {
+ return resp.StatusCode, nil, errors.Wrap(err, "torznab.io.Copy")
+ }
+
+ var response Caps
+ if err := xml.Unmarshal(buf.Bytes(), &response); err != nil {
+ return resp.StatusCode, nil, errors.Wrap(err, "torznab: could not decode feed")
+ }
+
+ return resp.StatusCode, &response, nil
+}
+
+func (c *client) GetCaps() (*Caps, error) {
+
+ status, res, err := c.getCaps("?t=caps", nil)
+ if err != nil {
+ return nil, errors.Wrap(err, "could not get caps for feed")
+ }
+
+ if status != http.StatusOK {
+ return nil, errors.Wrap(err, "could not get caps for feed")
+ }
+
+ return res, nil
+}
+
+func (c *client) Search(query string) ([]FeedItem, error) {
+ v := url.Values{}
+ v.Add("q", query)
+ params := v.Encode()
+
+ status, res, err := c.get("&t=search&"+params, nil)
+ if err != nil {
+ return nil, errors.Wrap(err, "could not search feed")
+ }
+
+ if status != http.StatusOK {
+ return nil, errors.New("could not search feed")
+ }
+
+ return res.Channel.Items, nil
+}
diff --git a/pkg/torznab/torznab_test.go b/pkg/torznab/torznab_test.go
new file mode 100644
index 0000000..11d40f7
--- /dev/null
+++ b/pkg/torznab/torznab_test.go
@@ -0,0 +1,235 @@
+package torznab
+
+import (
+ "encoding/xml"
+ "io/ioutil"
+ "net/http"
+ "net/http/httptest"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+)
+
+//func TestClient_GetFeed(t *testing.T) {
+// 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
+// }
+// }
+// payload, err := ioutil.ReadFile("testdata/torznab_response.xml")
+// if err != nil {
+// http.Error(w, err.Error(), http.StatusInternalServerError)
+// return
+// }
+// w.Header().Set("Content-Type", "application/xml")
+// w.Write(payload)
+// }))
+// defer srv.Close()
+//
+// type fields struct {
+// Host string
+// ApiKey string
+// BasicAuth BasicAuth
+// }
+// tests := []struct {
+// name string
+// fields fields
+// want []FeedItem
+// wantErr bool
+// }{
+// {
+// name: "get feed",
+// fields: fields{
+// Host: srv.URL + "/api",
+// ApiKey: key,
+// BasicAuth: BasicAuth{},
+// },
+// want: nil,
+// wantErr: false,
+// },
+// }
+// for _, tt := range tests {
+// t.Run(tt.name, func(t *testing.T) {
+// c := NewClient(Config{Host: tt.fields.Host, ApiKey: tt.fields.ApiKey})
+//
+// _, err := c.GetFeed()
+// if tt.wantErr && assert.Error(t, err) {
+// assert.Equal(t, tt.wantErr, err)
+// }
+// //assert.Equal(t, tt.want, got)
+// })
+// }
+//}
+
+func TestClient_GetCaps(t *testing.T) {
+ key := "mock-key"
+
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ apiKey := r.Header.Get("X-API-Key")
+ if apiKey != key {
+ w.WriteHeader(http.StatusUnauthorized)
+ w.Write(nil)
+ return
+ }
+
+ payload, err := ioutil.ReadFile("testdata/caps_response.xml")
+ if err != nil {
+ http.Error(w, err.Error(), http.StatusInternalServerError)
+ return
+ }
+ w.Header().Set("Content-Type", "application/xml")
+ w.Write(payload)
+ }))
+ defer srv.Close()
+
+ type fields struct {
+ Host string
+ ApiKey string
+ BasicAuth BasicAuth
+ }
+ tests := []struct {
+ name string
+ fields fields
+ want *Caps
+ wantErr bool
+ expectedErr string
+ }{
+ {
+ name: "get caps",
+ fields: fields{
+ Host: srv.URL + "/api/",
+ ApiKey: key,
+ BasicAuth: BasicAuth{},
+ },
+ want: &Caps{
+ XMLName: xml.Name{
+ Space: "",
+ Local: "caps",
+ },
+ Server: Server{
+ Version: "1.1",
+ Title: "...",
+ Strapline: "...",
+ Email: "...",
+ URL: "http://indexer.local/",
+ Image: "http://indexer.local/content/banner.jpg",
+ },
+ Limits: Limits{
+ Max: "100",
+ Default: "50",
+ },
+ Retention: Retention{
+ Days: "400",
+ },
+ Registration: Registration{
+ Available: "yes",
+ Open: "yes",
+ },
+ Searching: Searching{
+ Search: Search{
+ Available: "yes",
+ SupportedParams: "q",
+ },
+ TvSearch: Search{
+ Available: "yes",
+ SupportedParams: "q,rid,tvdbid,season,ep",
+ },
+ MovieSearch: Search{
+ Available: "no",
+ SupportedParams: "q,imdbid,genre",
+ },
+ AudioSearch: Search{
+ Available: "no",
+ SupportedParams: "q",
+ },
+ BookSearch: Search{
+ Available: "no",
+ SupportedParams: "q",
+ },
+ },
+ Categories: Categories{Category: []Category{
+ {
+ ID: "2000",
+ Name: "Movies",
+ Subcat: []SubCategory{
+ {
+ ID: "2010",
+ Name: "Foreign",
+ },
+ },
+ },
+ {
+ ID: "5000",
+ Name: "TV",
+ Subcat: []SubCategory{
+ {
+ ID: "5040",
+ Name: "HD",
+ },
+ {
+ ID: "5070",
+ Name: "Anime",
+ },
+ },
+ },
+ }},
+ Groups: Groups{Group: Group{
+ ID: "1",
+ Name: "alt.binaries....",
+ Description: "...",
+ Lastupdate: "...",
+ }},
+ Genres: Genres{
+ Genre: Genre{
+ ID: "1",
+ Categoryid: "5000",
+ Name: "Kids",
+ },
+ },
+ Tags: Tags{Tag: []Tag{
+ {
+ Name: "anonymous",
+ Description: "Uploader is anonymous",
+ },
+ {
+ Name: "trusted",
+ Description: "Uploader has high reputation",
+ },
+ {
+ Name: "internal",
+ Description: "Uploader is an internal release group",
+ },
+ }},
+ },
+ wantErr: false,
+ },
+ {
+ name: "bad key",
+ fields: fields{
+ Host: srv.URL,
+ ApiKey: "badkey",
+ BasicAuth: BasicAuth{},
+ },
+ want: nil,
+ wantErr: true,
+ expectedErr: "could not get caps for feed: unauthorized",
+ },
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ c := NewClient(Config{Host: tt.fields.Host, ApiKey: tt.fields.ApiKey})
+
+ got, err := c.GetCaps()
+ if tt.wantErr && assert.Error(t, err) {
+ assert.EqualErrorf(t, err, tt.expectedErr, "Error should be: %v, got: %v", tt.wantErr, err)
+ }
+ assert.Equal(t, tt.want, got)
+ })
+ }
+}
diff --git a/web/src/api/APIClient.ts b/web/src/api/APIClient.ts
index e669d2f..3244ab4 100644
--- a/web/src/api/APIClient.ts
+++ b/web/src/api/APIClient.ts
@@ -98,7 +98,8 @@ export const APIClient = {
create: (feed: FeedCreate) => appClient.Post("api/feeds", feed),
toggleEnable: (id: number, enabled: boolean) => appClient.Patch(`api/feeds/${id}/enabled`, { enabled }),
update: (feed: Feed) => appClient.Put(`api/feeds/${feed.id}`, feed),
- delete: (id: number) => appClient.Delete(`api/feeds/${id}`)
+ delete: (id: number) => appClient.Delete(`api/feeds/${id}`),
+ test: (feed: Feed) => appClient.Post("api/feeds/test", feed),
},
indexers: {
// returns indexer options for all currently present/enabled indexers
diff --git a/web/src/components/panels/index.tsx b/web/src/components/panels/index.tsx
index 3d35042..6ac3df5 100644
--- a/web/src/components/panels/index.tsx
+++ b/web/src/components/panels/index.tsx
@@ -18,6 +18,9 @@ interface SlideOverProps {
deleteAction?: () => void;
type: "CREATE" | "UPDATE";
testFn?: (data: unknown) => void;
+ isTesting?: boolean;
+ isTestSuccessful?: boolean;
+ isTestError?: boolean;
}
function SlideOver({
@@ -30,7 +33,10 @@ function SlideOver({
toggle,
type,
children,
- testFn
+ testFn,
+ isTesting,
+ isTestSuccessful,
+ isTestError,
}: SlideOverProps): React.ReactElement {
const cancelModalButtonRef = useRef(null);
const [deleteModalIsOpen, toggleDeleteModal] = useToggle(false);
@@ -121,10 +127,46 @@ function SlideOver({
{testFn && (
)}
diff --git a/web/src/forms/settings/FeedForms.tsx b/web/src/forms/settings/FeedForms.tsx
index 737f643..3ddb8b4 100644
--- a/web/src/forms/settings/FeedForms.tsx
+++ b/web/src/forms/settings/FeedForms.tsx
@@ -7,6 +7,8 @@ import { SlideOver } from "../../components/panels";
import { NumberFieldWide, PasswordFieldWide, SwitchGroupWide, TextFieldWide } from "../../components/inputs";
import { ImplementationMap } from "../../screens/settings/Feed";
import { componentMapType } from "./DownloadClientForms";
+import { sleep } from "../../utils";
+import { useState } from "react";
interface UpdateProps {
isOpen: boolean;
@@ -15,6 +17,10 @@ interface UpdateProps {
}
export function FeedUpdateForm({ isOpen, toggle, feed }: UpdateProps) {
+ const [isTesting, setIsTesting] = useState(false);
+ const [isTestSuccessful, setIsSuccessfulTest] = useState(false);
+ const [isTestError, setIsErrorTest] = useState(false);
+
const mutation = useMutation(
(feed: Feed) => APIClient.feeds.update(feed),
{
@@ -26,6 +32,10 @@ export function FeedUpdateForm({ isOpen, toggle, feed }: UpdateProps) {
}
);
+ const onSubmit = (formData: unknown) => {
+ mutation.mutate(formData as Feed);
+ };
+
const deleteMutation = useMutation(
(feedID: number) => APIClient.feeds.delete(feedID),
{
@@ -36,14 +46,45 @@ export function FeedUpdateForm({ isOpen, toggle, feed }: UpdateProps) {
}
);
- const onSubmit = (formData: unknown) => {
- mutation.mutate(formData as Feed);
- };
const deleteAction = () => {
deleteMutation.mutate(feed.id);
};
+ const testFeedMutation = useMutation(
+ (feed: Feed) => APIClient.feeds.test(feed),
+ {
+ onMutate: () => {
+ setIsTesting(true);
+ setIsErrorTest(false);
+ setIsSuccessfulTest(false);
+ },
+ onSuccess: () => {
+ sleep(1000)
+ .then(() => {
+ setIsTesting(false);
+ setIsSuccessfulTest(true);
+ })
+ .then(() => {
+ sleep(2500).then(() => {
+ setIsSuccessfulTest(false);
+ });
+ });
+ },
+ onError: () => {
+ setIsTesting(false);
+ setIsErrorTest(true);
+ sleep(2500).then(() => {
+ setIsErrorTest(false);
+ });
+ }
+ }
+ );
+
+ const testFeed = (data: unknown) => {
+ testFeedMutation.mutate(data as Feed);
+ };
+
const initialValues = {
id: feed.id,
indexer: feed.indexer,
@@ -64,6 +105,10 @@ export function FeedUpdateForm({ isOpen, toggle, feed }: UpdateProps) {
onSubmit={onSubmit}
deleteAction={deleteAction}
initialValues={initialValues}
+ testFn={testFeed}
+ isTesting={isTesting}
+ isTestSuccessful={isTestSuccessful}
+ isTestError={isTestError}
>
{(values) => (