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) => (