feat(feeds): torznab add test button (#347)

This commit is contained in:
Ludvig Lundgren 2022-07-10 15:54:56 +02:00 committed by GitHub
parent c1df9c817f
commit ebba72ec1f
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
12 changed files with 804 additions and 186 deletions

View file

@ -11,6 +11,7 @@ import (
"github.com/autobrr/autobrr/pkg/errors" "github.com/autobrr/autobrr/pkg/errors"
"github.com/autobrr/autobrr/pkg/torznab" "github.com/autobrr/autobrr/pkg/torznab"
"github.com/dcarbone/zadapters/zstdlog"
"github.com/rs/zerolog" "github.com/rs/zerolog"
) )
@ -20,6 +21,7 @@ type Service interface {
Find(ctx context.Context) ([]domain.Feed, error) Find(ctx context.Context) ([]domain.Feed, error)
Store(ctx context.Context, feed *domain.Feed) error Store(ctx context.Context, feed *domain.Feed) error
Update(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 ToggleEnabled(ctx context.Context, id int, enabled bool) error
Delete(ctx context.Context, id int) 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 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 { func (s *service) Start() error {
// get all torznab indexer definitions // get all torznab indexer definitions
feeds, err := s.repo.Find(context.TODO()) 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() l := s.log.With().Str("feed", f.Name).Logger()
// setup torznab Client // setup torznab Client
c := torznab.NewClient(f.URL, f.ApiKey) c := torznab.NewClient(torznab.Config{Host: f.URL, ApiKey: f.ApiKey})
// create job // create job
job := NewTorznabJob(f.Name, f.IndexerIdentifier, l, f.URL, c, s.cacheRepo, s.releaseSvc) job := NewTorznabJob(f.Name, f.IndexerIdentifier, l, f.URL, c, s.cacheRepo, s.releaseSvc)

View file

@ -17,7 +17,7 @@ type TorznabJob struct {
IndexerIdentifier string IndexerIdentifier string
Log zerolog.Logger Log zerolog.Logger
URL string URL string
Client *torznab.Client Client torznab.Client
Repo domain.FeedCacheRepo Repo domain.FeedCacheRepo
ReleaseSvc release.Service ReleaseSvc release.Service
@ -27,7 +27,7 @@ type TorznabJob struct {
JobID int 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{ return &TorznabJob{
Name: name, Name: name,
IndexerIdentifier: indexerIdentifier, IndexerIdentifier: indexerIdentifier,

View file

@ -17,6 +17,7 @@ type feedService interface {
Update(ctx context.Context, feed *domain.Feed) error Update(ctx context.Context, feed *domain.Feed) error
Delete(ctx context.Context, id int) error Delete(ctx context.Context, id int) error
ToggleEnabled(ctx context.Context, id int, enabled bool) error ToggleEnabled(ctx context.Context, id int, enabled bool) error
Test(ctx context.Context, feed *domain.Feed) error
} }
type feedHandler struct { type feedHandler struct {
@ -34,6 +35,7 @@ func newFeedHandler(encoder encoder, service feedService) *feedHandler {
func (h feedHandler) Routes(r chi.Router) { func (h feedHandler) Routes(r chi.Router) {
r.Get("/", h.find) r.Get("/", h.find)
r.Post("/", h.store) r.Post("/", h.store)
r.Post("/test", h.test)
r.Put("/{feedID}", h.update) r.Put("/{feedID}", h.update)
r.Patch("/{feedID}/enabled", h.toggleEnabled) r.Patch("/{feedID}/enabled", h.toggleEnabled)
r.Delete("/{feedID}", h.delete) 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) 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) { func (h feedHandler) update(w http.ResponseWriter, r *http.Request) {
var ( var (
ctx = r.Context() ctx = r.Context()

98
pkg/torznab/caps.go Normal file
View file

@ -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"`
}

View file

@ -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
}

76
pkg/torznab/feed.go Normal file
View file

@ -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
}

41
pkg/torznab/testdata/caps_response.xml vendored Normal file
View file

@ -0,0 +1,41 @@
<?xml version="1.0" encoding="UTF-8"?>
<caps>
<server version="1.1" title="..." strapline="..."
email="..." url="http://indexer.local/"
image="http://indexer.local/content/banner.jpg" />
<limits max="100" default="50" />
<retention days="400" />
<registration available="yes" open="yes" />
<searching>
<search available="yes" supportedParams="q" />
<tv-search available="yes" supportedParams="q,rid,tvdbid,season,ep" />
<movie-search available="no" supportedParams="q,imdbid,genre" />
<audio-search available="no" supportedParams="q" />
<book-search available="no" supportedParams="q" />
</searching>
<categories>
<category id="2000" name="Movies">
<subcat id="2010" name="Foreign" />
</category>
<category id="5000" name="TV">
<subcat id="5040" name="HD" />
<subcat id="5070" name="Anime" />
</category>
</categories>
<groups>
<group id="1" name="alt.binaries...." description="..." lastupdate="..." />
</groups>
<genres>
<genre id="1" categoryid="5000" name="Kids" />
</genres>
<tags>
<tag name="anonymous" description="Uploader is anonymous" />
<tag name="trusted" description="Uploader has high reputation" />
<tag name="internal" description="Uploader is an internal release group" />
</tags>
</caps>

209
pkg/torznab/torznab.go Normal file
View file

@ -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
}

235
pkg/torznab/torznab_test.go Normal file
View file

@ -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)
})
}
}

View file

@ -98,7 +98,8 @@ export const APIClient = {
create: (feed: FeedCreate) => appClient.Post("api/feeds", feed), create: (feed: FeedCreate) => appClient.Post("api/feeds", feed),
toggleEnable: (id: number, enabled: boolean) => appClient.Patch(`api/feeds/${id}/enabled`, { enabled }), toggleEnable: (id: number, enabled: boolean) => appClient.Patch(`api/feeds/${id}/enabled`, { enabled }),
update: (feed: Feed) => appClient.Put(`api/feeds/${feed.id}`, feed), 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: { indexers: {
// returns indexer options for all currently present/enabled indexers // returns indexer options for all currently present/enabled indexers

View file

@ -18,6 +18,9 @@ interface SlideOverProps<DataType> {
deleteAction?: () => void; deleteAction?: () => void;
type: "CREATE" | "UPDATE"; type: "CREATE" | "UPDATE";
testFn?: (data: unknown) => void; testFn?: (data: unknown) => void;
isTesting?: boolean;
isTestSuccessful?: boolean;
isTestError?: boolean;
} }
function SlideOver<DataType>({ function SlideOver<DataType>({
@ -30,7 +33,10 @@ function SlideOver<DataType>({
toggle, toggle,
type, type,
children, children,
testFn testFn,
isTesting,
isTestSuccessful,
isTestError,
}: SlideOverProps<DataType>): React.ReactElement { }: SlideOverProps<DataType>): React.ReactElement {
const cancelModalButtonRef = useRef<HTMLInputElement | null>(null); const cancelModalButtonRef = useRef<HTMLInputElement | null>(null);
const [deleteModalIsOpen, toggleDeleteModal] = useToggle(false); const [deleteModalIsOpen, toggleDeleteModal] = useToggle(false);
@ -121,10 +127,46 @@ function SlideOver<DataType>({
{testFn && ( {testFn && (
<button <button
type="button" type="button"
className="mr-2 bg-white dark:bg-gray-700 py-2 px-4 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm text-sm font-medium text-gray-700 dark:text-gray-200 hover:bg-gray-50 dark:hover:bg-gray-600 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 dark:focus:ring-blue-500" className={classNames(
isTestSuccessful
? "text-green-500 border-green-500 bg-green-50"
: isTestError
? "text-red-500 border-red-500 bg-red-50"
: "border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-400 bg-white dark:bg-gray-700 hover:bg-gray-50 focus:border-rose-700 active:bg-rose-700",
isTesting ? "cursor-not-allowed" : "",
"mr-2 inline-flex items-center px-4 py-2 border font-medium rounded-md shadow-sm text-sm transition ease-in-out duration-150 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 dark:focus:ring-blue-500"
)}
disabled={isTesting}
onClick={() => test(values)} onClick={() => test(values)}
> >
Test {isTesting ? (
<svg
className="animate-spin h-5 w-5 text-green-500"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
>
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="4"
></circle>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
></path>
</svg>
) : isTestSuccessful ? (
"OK!"
) : isTestError ? (
"ERROR"
) : (
"Test"
)}
</button> </button>
)} )}

View file

@ -7,6 +7,8 @@ import { SlideOver } from "../../components/panels";
import { NumberFieldWide, PasswordFieldWide, SwitchGroupWide, TextFieldWide } from "../../components/inputs"; import { NumberFieldWide, PasswordFieldWide, SwitchGroupWide, TextFieldWide } from "../../components/inputs";
import { ImplementationMap } from "../../screens/settings/Feed"; import { ImplementationMap } from "../../screens/settings/Feed";
import { componentMapType } from "./DownloadClientForms"; import { componentMapType } from "./DownloadClientForms";
import { sleep } from "../../utils";
import { useState } from "react";
interface UpdateProps { interface UpdateProps {
isOpen: boolean; isOpen: boolean;
@ -15,6 +17,10 @@ interface UpdateProps {
} }
export function FeedUpdateForm({ isOpen, toggle, feed }: 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( const mutation = useMutation(
(feed: Feed) => APIClient.feeds.update(feed), (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( const deleteMutation = useMutation(
(feedID: number) => APIClient.feeds.delete(feedID), (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 = () => { const deleteAction = () => {
deleteMutation.mutate(feed.id); 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 = { const initialValues = {
id: feed.id, id: feed.id,
indexer: feed.indexer, indexer: feed.indexer,
@ -64,6 +105,10 @@ export function FeedUpdateForm({ isOpen, toggle, feed }: UpdateProps) {
onSubmit={onSubmit} onSubmit={onSubmit}
deleteAction={deleteAction} deleteAction={deleteAction}
initialValues={initialValues} initialValues={initialValues}
testFn={testFeed}
isTesting={isTesting}
isTestSuccessful={isTestSuccessful}
isTestError={isTestError}
> >
{(values) => ( {(values) => (
<div> <div>