diff --git a/cmd/autobrr/main.go b/cmd/autobrr/main.go index ca73b01..61ce6d3 100644 --- a/cmd/autobrr/main.go +++ b/cmd/autobrr/main.go @@ -90,8 +90,9 @@ func main() { var ( downloadClientService = download_client.NewService(downloadClientRepo) actionService = action.NewService(actionRepo, downloadClientService, bus) - indexerService = indexer.NewService(indexerRepo) - filterService = filter.NewService(filterRepo, actionRepo, indexerService) + apiService = indexer.NewAPIService() + indexerService = indexer.NewService(indexerRepo, apiService) + filterService = filter.NewService(filterRepo, actionRepo, apiService, indexerService) releaseService = release.NewService(releaseRepo, actionService) ircService = irc.NewService(ircRepo, filterService, indexerService, releaseService) userService = user.NewService(userRepo) diff --git a/go.mod b/go.mod index ba25957..3d89376 100644 --- a/go.mod +++ b/go.mod @@ -25,6 +25,7 @@ require ( golang.org/x/sys v0.0.0-20211124211545-fe61309f8881 // indirect golang.org/x/term v0.0.0-20210927222741-03fcf44c2211 // indirect golang.org/x/text v0.3.7 // indirect + golang.org/x/time v0.0.0-20210723032227-1f47c861a9ac gopkg.in/ini.v1 v1.64.0 // indirect gopkg.in/irc.v3 v3.1.4 gopkg.in/natefinch/lumberjack.v2 v2.0.0 diff --git a/go.sum b/go.sum index 6780fce..2c87862 100644 --- a/go.sum +++ b/go.sum @@ -1181,6 +1181,7 @@ golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxb golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.0.0-20201208040808-7e3f01d25324/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.0.0-20210220033141-f8bda1e9f3ba/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= +golang.org/x/time v0.0.0-20210723032227-1f47c861a9ac h1:7zkz7BUtwNFFqcowJ+RIgu2MaV/MapERkDIy+mwPyjs= golang.org/x/time v0.0.0-20210723032227-1f47c861a9ac/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/tools v0.0.0-20180221164845-07fd8470d635/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20180828015842-6cd1fcedba52/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= diff --git a/internal/action/run.go b/internal/action/run.go index 1d9a91d..77524eb 100644 --- a/internal/action/run.go +++ b/internal/action/run.go @@ -5,10 +5,8 @@ import ( "os" "path" - "github.com/anacrolix/torrent/metainfo" "github.com/rs/zerolog/log" - "github.com/autobrr/autobrr/internal/client" "github.com/autobrr/autobrr/internal/domain" ) @@ -32,25 +30,30 @@ func (s *service) RunActions(actions []domain.Action, release domain.Release) er s.bus.Publish("release:update-push-status", release.ID, domain.ReleasePushStatusApproved) case domain.ActionTypeExec: - if tmpFile == "" { - tmpFile, hash, err = downloadFile(release.TorrentURL) + if release.TorrentTmpFile == "" { + t, err := release.DownloadTorrentFile(nil) if err != nil { log.Error().Stack().Err(err) return err } + + tmpFile = t.TmpFileName } + go func(release domain.Release, action domain.Action, tmpFile string) { s.execCmd(release, action, tmpFile) s.bus.Publish("release:update-push-status", release.ID, domain.ReleasePushStatusApproved) }(release, action, tmpFile) case domain.ActionTypeWatchFolder: - if tmpFile == "" { - tmpFile, hash, err = downloadFile(release.TorrentURL) + if release.TorrentTmpFile == "" { + t, err := release.DownloadTorrentFile(nil) if err != nil { log.Error().Stack().Err(err) return err } + + tmpFile = t.TmpFileName } s.watchFolder(action.WatchFolder, tmpFile) s.bus.Publish("release:update-push-status", release.ID, domain.ReleasePushStatusApproved) @@ -65,12 +68,14 @@ func (s *service) RunActions(actions []domain.Action, release domain.Release) er s.bus.Publish("release:update-push-status-rejected", release.ID, "deluge busy") continue } - if tmpFile == "" { - tmpFile, hash, err = downloadFile(release.TorrentURL) + if release.TorrentTmpFile == "" { + t, err := release.DownloadTorrentFile(nil) if err != nil { log.Error().Stack().Err(err) return err } + + tmpFile = t.TmpFileName } go func(action domain.Action, tmpFile string) { @@ -92,12 +97,15 @@ func (s *service) RunActions(actions []domain.Action, release domain.Release) er continue } - if tmpFile == "" { - tmpFile, hash, err = downloadFile(release.TorrentURL) + if release.TorrentTmpFile == "" { + t, err := release.DownloadTorrentFile(nil) if err != nil { log.Error().Stack().Err(err) return err } + + tmpFile = t.TmpFileName + hash = t.MetaInfo.HashInfoBytes().String() } go func(action domain.Action, hash string, tmpFile string) { @@ -145,33 +153,43 @@ func (s *service) RunActions(actions []domain.Action, release domain.Release) er return nil } -// downloadFile returns tmpFile, hash, error -func downloadFile(url string) (string, string, error) { - // create http client - c := client.NewHttpClient() +func (s *service) CheckCanDownload(actions []domain.Action) bool { + for _, action := range actions { + if !action.Enabled { + // only run active actions + continue + } - // download torrent file - // TODO check extra headers, cookie - res, err := c.DownloadFile(url, nil) - if err != nil { - log.Error().Stack().Err(err).Msgf("could not download file: %v", url) - return "", "", err + log.Debug().Msgf("action-service: check can download action: %v", action.Name) + + switch action.Type { + case domain.ActionTypeDelugeV1, domain.ActionTypeDelugeV2: + canDownload, err := s.delugeCheckRulesCanDownload(action) + if err != nil { + log.Error().Stack().Err(err).Msgf("error checking client rules: %v", action.Name) + continue + } + if !canDownload { + continue + } + + return true + + case domain.ActionTypeQbittorrent: + canDownload, err := s.qbittorrentCheckRulesCanDownload(action) + if err != nil { + log.Error().Stack().Err(err).Msgf("error checking client rules: %v", action.Name) + continue + } + if !canDownload { + continue + } + + return true + } } - // match more filters like torrent size - - // Get meta info from file to find out the hash for later use - meta, err := metainfo.LoadFromFile(res.FileName) - //meta, err := metainfo.Load(res.Body) - if err != nil { - log.Error().Stack().Err(err).Msgf("metainfo could not open file: %v", res.FileName) - return "", "", err - } - - // torrent info hash used for re-announce - hash := meta.HashInfoBytes().String() - - return res.FileName, hash, nil + return false } func (s *service) test(name string) { @@ -190,10 +208,10 @@ func (s *service) watchFolder(dir string, torrentFile string) { defer original.Close() _, tmpFileName := path.Split(torrentFile) - fullFileName := path.Join(dir, tmpFileName) + fullFileName := path.Join(dir, tmpFileName+".torrent") // Create new file - newFile, err := os.Create(fullFileName + ".torrent") + newFile, err := os.Create(fullFileName) if err != nil { log.Error().Stack().Err(err).Msgf("could not create new temp file '%v'", fullFileName) return diff --git a/internal/action/service.go b/internal/action/service.go index 1e35163..af07e09 100644 --- a/internal/action/service.go +++ b/internal/action/service.go @@ -17,6 +17,7 @@ type Service interface { ToggleEnabled(actionID int) error RunActions(actions []domain.Action, release domain.Release) error + CheckCanDownload(actions []domain.Action) bool } type service struct { diff --git a/internal/announce/announce.go b/internal/announce/announce.go index c3ba485..7ef4dc4 100644 --- a/internal/announce/announce.go +++ b/internal/announce/announce.go @@ -230,17 +230,13 @@ func (a *announceProcessor) onLinesMatched(def domain.IndexerDefinition, vars ma return err } - // generate torrent url - torrentUrl, err := a.processTorrentUrl(def.Parse.Match.TorrentURL, vars, def.SettingsMap, def.Parse.Match.Encode) + // parse torrentUrl + err = release.ParseTorrentUrl(def.Parse.Match.TorrentURL, vars, def.SettingsMap, def.Parse.Match.Encode) if err != nil { - log.Error().Stack().Err(err).Msg("announce: could not process torrent url") + log.Error().Stack().Err(err).Msg("announce: could not parse torrent url") return err } - if torrentUrl != "" { - release.TorrentURL = torrentUrl - } - return nil } diff --git a/internal/client/http.go b/internal/client/http.go index b3e497a..6e55b70 100644 --- a/internal/client/http.go +++ b/internal/client/http.go @@ -1,15 +1,14 @@ package client import ( - "crypto/md5" - "encoding/hex" "errors" - "fmt" "io" "net/http" "os" "time" + "github.com/anacrolix/torrent/metainfo" + "github.com/rs/zerolog/log" ) @@ -18,6 +17,11 @@ type DownloadFileResponse struct { FileName string } +type DownloadTorrentFileResponse struct { + MetaInfo *metainfo.MetaInfo + TmpFileName string +} + type HttpClient struct { http *http.Client } @@ -33,42 +37,36 @@ func NewHttpClient() *HttpClient { func (c *HttpClient) DownloadFile(url string, opts map[string]string) (*DownloadFileResponse, error) { if url == "" { - return nil, nil + return nil, errors.New("download_file: url can't be empty") } - // create md5 hash of url for tmp file - hash := md5.Sum([]byte(url)) - hashString := hex.EncodeToString(hash[:]) - tmpFileName := fmt.Sprintf("/tmp/%v", hashString) - - // Create the file - out, err := os.Create(tmpFileName) + // Create tmp file + tmpFile, err := os.CreateTemp("", "autobrr-") if err != nil { - log.Error().Stack().Err(err).Msgf("error creating temp file: %v", tmpFileName) + log.Error().Stack().Err(err).Msg("error creating temp file") return nil, err } - - defer out.Close() + defer tmpFile.Close() // Get the data resp, err := http.Get(url) if err != nil { - log.Error().Stack().Err(err).Msgf("error downloading file %v from %v", tmpFileName, url) + log.Error().Stack().Err(err).Msgf("error downloading file from %v", url) return nil, err } defer resp.Body.Close() // retry logic - if resp.StatusCode != 200 { - log.Error().Stack().Err(err).Msgf("error downloading file: %v - bad status: %d", tmpFileName, resp.StatusCode) + if resp.StatusCode != http.StatusOK { + log.Error().Stack().Err(err).Msgf("error downloading file from: %v - bad status: %d", url, resp.StatusCode) return nil, err } // Write the body to file - _, err = io.Copy(out, resp.Body) + _, err = io.Copy(tmpFile, resp.Body) if err != nil { - log.Error().Stack().Err(err).Msgf("error writing downloaded file: %v", tmpFileName) + log.Error().Stack().Err(err).Msgf("error writing downloaded file: %v", tmpFile.Name()) return nil, err } @@ -76,7 +74,7 @@ func (c *HttpClient) DownloadFile(url string, opts map[string]string) (*Download res := DownloadFileResponse{ Body: &resp.Body, - FileName: tmpFileName, + FileName: tmpFile.Name(), } if res.FileName == "" || res.Body == nil { @@ -84,7 +82,65 @@ func (c *HttpClient) DownloadFile(url string, opts map[string]string) (*Download return nil, errors.New("error downloading file, no tmp file") } - log.Debug().Msgf("successfully downloaded file: %v", tmpFileName) + log.Debug().Msgf("successfully downloaded file: %v", tmpFile.Name()) + + return &res, nil +} + +func (c *HttpClient) DownloadTorrentFile(url string, opts map[string]string) (*DownloadTorrentFileResponse, error) { + if url == "" { + return nil, errors.New("download_file: url can't be empty") + } + + // Create tmp file + tmpFile, err := os.CreateTemp("", "autobrr-") + if err != nil { + log.Error().Stack().Err(err).Msg("error creating temp file") + return nil, err + } + defer tmpFile.Close() + + // Get the data + resp, err := http.Get(url) + if err != nil { + log.Error().Stack().Err(err).Msgf("error downloading file from %v", url) + return nil, err + } + defer resp.Body.Close() + + // retry logic + + if resp.StatusCode != http.StatusOK { + log.Error().Stack().Err(err).Msgf("error downloading file from: %v - bad status: %d", url, resp.StatusCode) + return nil, err + } + + // Write the body to file + _, err = io.Copy(tmpFile, resp.Body) + if err != nil { + log.Error().Stack().Err(err).Msgf("error writing downloaded file: %v", tmpFile.Name()) + return nil, err + } + + meta, err := metainfo.Load(resp.Body) + if err != nil { + log.Error().Stack().Err(err).Msgf("metainfo could not load file contents: %v", tmpFile.Name()) + return nil, err + } + + // remove file if fail + + res := DownloadTorrentFileResponse{ + MetaInfo: meta, + TmpFileName: tmpFile.Name(), + } + + if res.TmpFileName == "" || res.MetaInfo == nil { + log.Error().Stack().Err(err).Msgf("tmp file error - empty body: %v", url) + return nil, errors.New("error downloading file, no tmp file") + } + + log.Debug().Msgf("successfully downloaded file: %v", tmpFile.Name()) return &res, nil } diff --git a/internal/domain/indexer.go b/internal/domain/indexer.go index 6a6afa2..702eea6 100644 --- a/internal/domain/indexer.go +++ b/internal/domain/indexer.go @@ -1,6 +1,10 @@ package domain -import "context" +import ( + "context" + + "github.com/dustin/go-humanize" +) type IndexerRepo interface { Store(indexer Indexer) (*Indexer, error) @@ -29,12 +33,22 @@ type IndexerDefinition struct { Privacy string `json:"privacy"` Protocol string `json:"protocol"` URLS []string `json:"urls"` + Supports []string `json:"supports"` Settings []IndexerSetting `json:"settings"` SettingsMap map[string]string `json:"-"` IRC *IndexerIRC `json:"irc"` Parse IndexerParse `json:"parse"` } +func (i IndexerDefinition) HasApi() bool { + for _, a := range i.Supports { + if a == "api" { + return true + } + } + return false +} + type IndexerSetting struct { Name string `json:"name"` Required bool `json:"required,omitempty"` @@ -74,3 +88,22 @@ type IndexerParseMatch struct { TorrentURL string `json:"torrenturl"` Encode []string `json:"encode"` } + +type TorrentBasic struct { + Id string `json:"Id"` + TorrentId string `json:"TorrentId,omitempty"` + InfoHash string `json:"InfoHash"` + Size string `json:"Size"` +} + +func (t TorrentBasic) ReleaseSizeBytes() uint64 { + if t.Size == "" { + return 0 + } + + releaseSizeBytes, err := humanize.ParseBytes(t.Size) + if err != nil { + // log could not parse into bytes + } + return releaseSizeBytes +} diff --git a/internal/domain/release.go b/internal/domain/release.go index 02621ab..a5de577 100644 --- a/internal/domain/release.go +++ b/internal/domain/release.go @@ -1,16 +1,24 @@ package domain import ( + "bytes" "context" + "crypto/tls" "fmt" "html" + "io" + "net/http" + "net/url" + "os" "regexp" "strconv" "strings" + "text/template" "time" "github.com/autobrr/autobrr/pkg/wildcard" + "github.com/anacrolix/torrent/metainfo" "github.com/dustin/go-humanize" "github.com/pkg/errors" "github.com/rs/zerolog/log" @@ -37,6 +45,7 @@ type Release struct { GroupID string `json:"group_id"` TorrentID string `json:"torrent_id"` TorrentURL string `json:"-"` + TorrentTmpFile string `json:"-"` TorrentName string `json:"torrent_name"` // full release name Size uint64 `json:"size"` Raw string `json:"raw"` // Raw release @@ -478,6 +487,120 @@ func (r *Release) extractReleaseTags() error { return nil } +func (r *Release) ParseTorrentUrl(match string, vars map[string]string, extraVars map[string]string, encode []string) error { + tmpVars := map[string]string{} + + // copy vars to new tmp map + for k, v := range vars { + tmpVars[k] = v + } + + // merge extra vars with vars + if extraVars != nil { + for k, v := range extraVars { + tmpVars[k] = v + } + } + + // handle url encode of values + if encode != nil { + for _, e := range encode { + if v, ok := tmpVars[e]; ok { + // url encode value + t := url.QueryEscape(v) + tmpVars[e] = t + } + } + } + + // setup text template to inject variables into + tmpl, err := template.New("torrenturl").Parse(match) + if err != nil { + log.Error().Err(err).Msg("could not create torrent url template") + return err + } + + var urlBytes bytes.Buffer + err = tmpl.Execute(&urlBytes, &tmpVars) + if err != nil { + log.Error().Err(err).Msg("could not write torrent url template output") + return err + } + + r.TorrentURL = urlBytes.String() + + // TODO handle cookies + + return nil +} + +func (r *Release) DownloadTorrentFile(opts map[string]string) (*DownloadTorrentFileResponse, error) { + if r.TorrentURL == "" { + return nil, errors.New("download_file: url can't be empty") + } else if r.TorrentTmpFile != "" { + // already downloaded + return nil, nil + } + + customTransport := http.DefaultTransport.(*http.Transport).Clone() + customTransport.TLSClientConfig = &tls.Config{InsecureSkipVerify: true} + client := &http.Client{Transport: customTransport} + + // Get the data + resp, err := client.Get(r.TorrentURL) + if err != nil { + log.Error().Stack().Err(err).Msg("error downloading file") + return nil, err + } + defer resp.Body.Close() + + // retry logic + + if resp.StatusCode != http.StatusOK { + log.Error().Stack().Err(err).Msgf("error downloading file from: %v - bad status: %d", r.TorrentURL, resp.StatusCode) + return nil, err + } + + // Create tmp file + tmpFile, err := os.CreateTemp("", "autobrr-") + if err != nil { + log.Error().Stack().Err(err).Msg("error creating temp file") + return nil, err + } + defer tmpFile.Close() + + r.TorrentTmpFile = tmpFile.Name() + + // Write the body to file + _, err = io.Copy(tmpFile, resp.Body) + if err != nil { + log.Error().Stack().Err(err).Msgf("error writing downloaded file: %v", tmpFile.Name()) + return nil, err + } + + meta, err := metainfo.LoadFromFile(tmpFile.Name()) + if err != nil { + log.Error().Stack().Err(err).Msgf("metainfo could not load file contents: %v", tmpFile.Name()) + return nil, err + } + + // remove file if fail + + res := DownloadTorrentFileResponse{ + MetaInfo: meta, + TmpFileName: tmpFile.Name(), + } + + if res.TmpFileName == "" || res.MetaInfo == nil { + log.Error().Stack().Err(err).Msgf("tmp file error - empty body: %v", r.TorrentURL) + return nil, errors.New("error downloading file, no tmp file") + } + + log.Debug().Msgf("successfully downloaded file: %v", tmpFile.Name()) + + return &res, nil +} + func (r *Release) addRejection(reason string) { r.Rejections = append(r.Rejections, reason) } @@ -612,9 +735,9 @@ func (r *Release) CheckFilter(filter Filter) bool { } // CheckSizeFilter additional size check -// for indexers that doesn't announce size, like some cabals +// for indexers that doesn't announce size, like some gazelle based // set flag r.AdditionalSizeCheckRequired if there's a size in the filter, otherwise go a head -// implement API for ptp,btn,bhd,ggn to check for size if needed +// implement API for ptp,btn,ggn to check for size if needed // for others pull down torrent and do check func (r *Release) CheckSizeFilter(minSize string, maxSize string) bool { @@ -667,6 +790,10 @@ func (r *Release) MapVars(varMap map[string]string) error { r.TorrentName = html.UnescapeString(torrentName) } + if torrentID, err := getStringMapValue(varMap, "torrentId"); err == nil { + r.TorrentID = torrentID + } + if category, err := getStringMapValue(varMap, "category"); err == nil { r.Category = category } @@ -1153,6 +1280,11 @@ func cleanReleaseName(input string) string { return processedString } +type DownloadTorrentFileResponse struct { + MetaInfo *metainfo.MetaInfo + TmpFileName string +} + type ReleaseStats struct { TotalCount int64 `json:"total_count"` FilteredCount int64 `json:"filtered_count"` diff --git a/internal/filter/service.go b/internal/filter/service.go index 1cc3d96..c98e42f 100644 --- a/internal/filter/service.go +++ b/internal/filter/service.go @@ -4,6 +4,8 @@ import ( "context" "errors" + "github.com/anacrolix/torrent/metainfo" + "github.com/dustin/go-humanize" "github.com/rs/zerolog/log" "github.com/autobrr/autobrr/internal/domain" @@ -25,12 +27,14 @@ type service struct { repo domain.FilterRepo actionRepo domain.ActionRepo indexerSvc indexer.Service + apiService indexer.APIService } -func NewService(repo domain.FilterRepo, actionRepo domain.ActionRepo, indexerSvc indexer.Service) Service { +func NewService(repo domain.FilterRepo, actionRepo domain.ActionRepo, apiService indexer.APIService, indexerSvc indexer.Service) Service { return &service{ repo: repo, actionRepo: actionRepo, + apiService: apiService, indexerSvc: indexerSvc, } } @@ -175,44 +179,113 @@ func (s *service) Delete(ctx context.Context, filterID int) error { } func (s *service) FindAndCheckFilters(release *domain.Release) (bool, *domain.Filter, error) { - + // find all enabled filters for indexer filters, err := s.repo.FindByIndexerIdentifier(release.Indexer) if err != nil { - log.Error().Err(err).Msgf("could not find filters for indexer: %v", release.Indexer) + log.Error().Err(err).Msgf("filter-service.find_and_check_filters: could not find filters for indexer: %v", release.Indexer) return false, nil, err } log.Trace().Msgf("filter-service.find_and_check_filters: found (%d) active filters to check for indexer '%v'", len(filters), release.Indexer) + // save outside of loop to check multiple filters with only one fetch + var torrentInfo *domain.TorrentBasic + var torrentFileRes *domain.DownloadTorrentFileResponse + var torrentMetaInfo metainfo.Info + // loop and check release to filter until match for _, f := range filters { - log.Trace().Msgf("checking filter: %+v", f.Name) + log.Trace().Msgf("filter-service.find_and_check_filters: checking filter: %+v", f.Name) matchedFilter := release.CheckFilter(f) - // if matched, attach actions and return the f if matchedFilter { - //release.Filter = &f - //release.FilterID = f.ID - //release.FilterName = f.Name + // if matched, do additional size check if needed, attach actions and return the filter - log.Debug().Msgf("found and matched filter: %+v", f.Name) + log.Debug().Msgf("filter-service.find_and_check_filters: found and matched filter: %+v", f.Name) - // TODO do additional size check against indexer api or torrent for size + // Some indexers do not announce the size and if size (min,max) is set in a filter then it will need + // additional size check. Some indexers have api implemented to fetch this data and for the others + // it will download the torrent file to parse and make the size check. This is all to minimize the amount of downloads. + + // do additional size check against indexer api or torrent for size if release.AdditionalSizeCheckRequired { - log.Debug().Msgf("additional size check required for: %+v", f.Name) - // check if indexer = btn,ptp,ggn,red - // fetch api for data - // else download torrent and add to tmpPath - // if size != response.size - // r.RecheckSizeFilter(f) - //continue + log.Debug().Msgf("filter-service.find_and_check_filters: (%v) additional size check required", f.Name) + + // check if indexer = btn,ptp (ggn,red later) + if release.Indexer == "ptp" || release.Indexer == "btn" || release.Indexer == "ggn" { + // fetch torrent info from api + // save outside of loop to check multiple filters with only one fetch + if torrentInfo == nil { + torrentInfo, err = s.apiService.GetTorrentByID(release.Indexer, release.TorrentID) + if err != nil || torrentInfo == nil { + log.Error().Stack().Err(err).Msgf("filter-service.find_and_check_filters: (%v) could not get torrent: '%v' from: %v", f.Name, release.TorrentID, release.Indexer) + continue + } + + log.Debug().Msgf("filter-service.find_and_check_filters: (%v) got torrent info: %+v", f.Name, torrentInfo) + } + + // compare size against filters + match, err := checkSizeFilter(f.MinSize, f.MaxSize, torrentInfo.ReleaseSizeBytes()) + if err != nil { + log.Error().Stack().Err(err).Msgf("filter-service.find_and_check_filters: (%v) could not check size filter", f.Name) + continue + } + + // no match, lets continue to next filter + if !match { + log.Debug().Msgf("filter-service.find_and_check_filters: (%v) filter did not match after additional size check, trying next", f.Name) + continue + } + + // store size on the release + release.Size = torrentInfo.ReleaseSizeBytes() + } else { + log.Trace().Msgf("filter-service.find_and_check_filters: (%v) additional size check required: preparing to download metafile", f.Name) + + // if indexer doesn't have api, download torrent and add to tmpPath + torrentFileRes, err = release.DownloadTorrentFile(nil) + if err != nil { + log.Error().Stack().Err(err).Msgf("filter-service.find_and_check_filters: (%v) could not download torrent file with id: '%v' from: %v", f.Name, release.TorrentID, release.Indexer) + continue + } + + // parse torrent metainfo + torrentMetaInfo, err = torrentFileRes.MetaInfo.UnmarshalInfo() + if err != nil { + log.Error().Stack().Err(err).Msgf("filter-service.find_and_check_filters: could not download torrent file: '%v' from: %v", release.TorrentID, release.Indexer) + continue + } + + // compare size against filter + match, err := checkSizeFilter(f.MinSize, f.MaxSize, uint64(torrentMetaInfo.TotalLength())) + if err != nil { + log.Error().Stack().Err(err).Msgf("filter-service.find_and_check_filters: (%v) could not check size filter", f.Name) + continue + } + + // no match, lets continue to next filter + if !match { + log.Debug().Msgf("filter-service.find_and_check_filters: (%v) filter did not match after additional size check, trying next", f.Name) + continue + } + + // store size on the release + release.Size = uint64(torrentMetaInfo.TotalLength()) + } } - // find actions and attach + // found matching filter, lets find the filter actions and attach actions, err := s.actionRepo.FindByFilterID(f.ID) if err != nil { log.Error().Err(err).Msgf("could not find actions for filter: %+v", f.Name) } + + // if no actions, continue to next filter + if len(actions) == 0 { + log.Trace().Msgf("filter-service.find_and_check_filters: no actions found for filter '%v', trying next one..", f.Name) + continue + } f.Actions = actions return true, &f, nil @@ -222,3 +295,35 @@ func (s *service) FindAndCheckFilters(release *domain.Release) (bool, *domain.Fi // if no match, return nil return false, nil, nil } + +func checkSizeFilter(minSize string, maxSize string, releaseSize uint64) (bool, error) { + // handle both min and max + if minSize != "" { + // string to bytes + minSizeBytes, err := humanize.ParseBytes(minSize) + if err != nil { + // log could not parse into bytes + } + + if releaseSize <= minSizeBytes { + //r.addRejection("size: smaller than min size") + return false, nil + } + + } + + if maxSize != "" { + // string to bytes + maxSizeBytes, err := humanize.ParseBytes(maxSize) + if err != nil { + // log could not parse into bytes + } + + if releaseSize >= maxSizeBytes { + //r.addRejection("size: larger than max size") + return false, nil + } + } + + return true, nil +} diff --git a/internal/indexer/api.go b/internal/indexer/api.go new file mode 100644 index 0000000..61ce2fc --- /dev/null +++ b/internal/indexer/api.go @@ -0,0 +1,121 @@ +package indexer + +import ( + "fmt" + + "github.com/rs/zerolog/log" + + "github.com/autobrr/autobrr/internal/domain" + "github.com/autobrr/autobrr/pkg/btn" + "github.com/autobrr/autobrr/pkg/ggn" + "github.com/autobrr/autobrr/pkg/ptp" +) + +type APIService interface { + TestConnection(indexer string) (bool, error) + GetTorrentByID(indexer string, torrentID string) (*domain.TorrentBasic, error) + AddClient(indexer string, settings map[string]string) error + RemoveClient(indexer string) error +} + +type apiClient interface { + GetTorrentByID(torrentID string) (*domain.TorrentBasic, error) + TestAPI() (bool, error) +} + +type apiService struct { + apiClients map[string]apiClient +} + +func NewAPIService() APIService { + return &apiService{ + apiClients: make(map[string]apiClient), + } +} + +func (s *apiService) GetTorrentByID(indexer string, torrentID string) (*domain.TorrentBasic, error) { + v, ok := s.apiClients[indexer] + if !ok { + return nil, nil + } + + log.Trace().Str("service", "api").Str("method", "GetTorrentByID").Msgf("'%v' trying to fetch torrent from api", indexer) + + t, err := v.GetTorrentByID(torrentID) + if err != nil { + log.Error().Stack().Err(err).Msgf("could not get torrent: '%v' from: %v", torrentID, indexer) + return nil, err + } + + log.Trace().Str("service", "api").Str("method", "GetTorrentByID").Msgf("'%v' successfully fetched torrent from api: %+v", indexer, t) + + return t, nil +} + +func (s *apiService) TestConnection(indexer string) (bool, error) { + v, ok := s.apiClients[indexer] + if !ok { + return false, nil + } + + t, err := v.TestAPI() + if err != nil { + return false, err + } + + return t, nil +} + +func (s *apiService) AddClient(indexer string, settings map[string]string) error { + // basic validation + if indexer == "" { + return fmt.Errorf("api_service.add_client: validation falied: indexer can't be empty") + } else if len(settings) == 0 { + return fmt.Errorf("api_service.add_client: validation falied: settings can't be empty") + } + + log.Trace().Msgf("api-service.add_client: init api client for '%v'", indexer) + + // init client + switch indexer { + case "btn": + key, ok := settings["api_key"] + if !ok || key == "" { + return fmt.Errorf("api_service: could not initialize btn client: missing var 'api_key'") + } + s.apiClients[indexer] = btn.NewClient("", key) + + case "ptp": + user, ok := settings["api_user"] + if !ok || user == "" { + return fmt.Errorf("api_service: could not initialize ptp client: missing var 'api_user'") + } + + key, ok := settings["api_key"] + if !ok || key == "" { + return fmt.Errorf("api_service: could not initialize ptp client: missing var 'api_key'") + } + s.apiClients[indexer] = ptp.NewClient("", user, key) + + case "ggn": + key, ok := settings["api_key"] + if !ok || key == "" { + return fmt.Errorf("api_service: could not initialize ggn client: missing var 'api_key'") + } + s.apiClients[indexer] = ggn.NewClient("", key) + + default: + return fmt.Errorf("api_service: could not initialize client: unsupported indexer '%v'", indexer) + + } + return nil +} + +func (s *apiService) RemoveClient(indexer string) error { + _, ok := s.apiClients[indexer] + if ok { + delete(s.apiClients, indexer) + } + + return nil +} diff --git a/internal/indexer/definitions/btn.yaml b/internal/indexer/definitions/btn.yaml index 65a5912..7f3c000 100644 --- a/internal/indexer/definitions/btn.yaml +++ b/internal/indexer/definitions/btn.yaml @@ -11,6 +11,7 @@ protocol: torrent supports: - irc - rss + - api source: gazelle settings: - name: authkey @@ -21,6 +22,22 @@ settings: type: secret label: Torrent pass help: Right click DL on a torrent and get the torrent_pass. + - name: api_key + type: secret + label: API Key + help: Username -> Edit Profile -> API + +api: + url: https://api.broadcasthe.net + type: jsonrpc + limits: + max: 150 + per: hour + settings: + - name: api_key + type: secret + label: API Key + help: Username -> Edit Profile -> API irc: network: BroadcasTheNet @@ -42,12 +59,6 @@ irc: required: true label: NickServ Password help: NickServ password - - name: invite_command - type: secret - default: "CableGuy IDENTIFY USERNAME IRCKey" - required: true - label: Invite command - help: Invite auth with CableGuy. parse: type: multi diff --git a/internal/indexer/definitions/gazellegames.yaml b/internal/indexer/definitions/gazellegames.yaml index a6f4dcf..c70fb6d 100644 --- a/internal/indexer/definitions/gazellegames.yaml +++ b/internal/indexer/definitions/gazellegames.yaml @@ -11,6 +11,7 @@ protocol: torrent supports: - irc - rss + - api source: gazelle settings: - name: authkey @@ -21,6 +22,22 @@ settings: type: secret label: Torrent pass help: Right click DL on a torrent and get the torrent_pass. + - name: api_key + type: secret + label: API Key + help: Username -> Edit / Settings -> API Keys + +api: + url: https://gazellegames.net/api.php + type: json + limits: + max: 5 + per: 10 seconds + settings: + - name: api_key + type: secret + label: API Key + help: Username -> Edit / Settings -> API Keys irc: network: GGn diff --git a/internal/indexer/definitions/ptp.yaml b/internal/indexer/definitions/ptp.yaml index ea4bdef..ebd3f22 100644 --- a/internal/indexer/definitions/ptp.yaml +++ b/internal/indexer/definitions/ptp.yaml @@ -11,6 +11,7 @@ protocol: torrent supports: - irc - rss + - api source: gazelle settings: - name: authkey @@ -21,6 +22,30 @@ settings: type: secret label: Torrent pass help: Right click DL on a torrent and get the torrent_pass. + - name: api_user + type: secret + label: API User + help: Edit profile -> Security -> Generate new api keys + - name: api_key + type: secret + label: API Key + help: Edit profile -> Security -> Generate new api keys + +api: + url: https://passthepopcorn.me/ + type: json + limits: + max: 60 + per: minute + settings: + - name: api_user + type: secret + label: API User + help: Edit profile -> Security -> Generate new api keys + - name: api_key + type: secret + label: API Key + help: Edit profile -> Security -> Generate new api keys irc: network: PassThePopcorn diff --git a/internal/indexer/service.go b/internal/indexer/service.go index 1b7bddf..50fb4b8 100644 --- a/internal/indexer/service.go +++ b/internal/indexer/service.go @@ -26,7 +26,8 @@ type Service interface { } type service struct { - repo domain.IndexerRepo + repo domain.IndexerRepo + apiService APIService // contains all raw indexer definitions indexerDefinitions map[string]domain.IndexerDefinition @@ -37,9 +38,10 @@ type service struct { lookupIRCServerDefinition map[string]map[string]domain.IndexerDefinition } -func NewService(repo domain.IndexerRepo) Service { +func NewService(repo domain.IndexerRepo, apiService APIService) Service { return &service{ repo: repo, + apiService: apiService, indexerDefinitions: make(map[string]domain.IndexerDefinition), mapIndexerIRCToName: make(map[string]string), lookupIRCServerDefinition: make(map[string]map[string]domain.IndexerDefinition), @@ -150,6 +152,7 @@ func (s *service) mapIndexer(indexer domain.Indexer) (*domain.IndexerDefinition, Privacy: in.Privacy, Protocol: in.Protocol, URLS: in.URLS, + Supports: in.Supports, Settings: nil, SettingsMap: make(map[string]string), IRC: in.IRC, @@ -184,23 +187,34 @@ func (s *service) GetTemplates() ([]domain.IndexerDefinition, error) { } func (s *service) Start() error { + // load all indexer definitions err := s.LoadIndexerDefinitions() if err != nil { return err } + // load the indexers' setup by the user indexerDefinitions, err := s.GetAll() if err != nil { return err } - for _, indexerDefinition := range indexerDefinitions { - s.mapIRCIndexerLookup(indexerDefinition.Identifier, *indexerDefinition) + for _, indexer := range indexerDefinitions { + s.mapIRCIndexerLookup(indexer.Identifier, *indexer) // add to irc server lookup table - s.mapIRCServerDefinitionLookup(indexerDefinition.IRC.Server, *indexerDefinition) + s.mapIRCServerDefinitionLookup(indexer.IRC.Server, *indexer) + + // check if it has api and add to api service + if indexer.Enabled && indexer.HasApi() { + if err := s.apiService.AddClient(indexer.Identifier, indexer.SettingsMap); err != nil { + log.Error().Stack().Err(err).Msgf("indexer.start: could not init api client for: '%v'", indexer.Identifier) + } + } } + log.Info().Msgf("Loaded %d indexers", len(indexerDefinitions)) + return nil } @@ -305,7 +319,7 @@ func (s *service) LoadIndexerDefinitions() error { } } - log.Info().Msgf("Loaded %d indexer definitions", len(s.indexerDefinitions)) + log.Debug().Msgf("Loaded %d indexer definitions", len(s.indexerDefinitions)) return nil } diff --git a/internal/logger/logger.go b/internal/logger/logger.go index 59df272..e4ddd8d 100644 --- a/internal/logger/logger.go +++ b/internal/logger/logger.go @@ -10,11 +10,13 @@ import ( "github.com/r3labs/sse/v2" "github.com/rs/zerolog" "github.com/rs/zerolog/log" + "github.com/rs/zerolog/pkgerrors" "gopkg.in/natefinch/lumberjack.v2" ) func Setup(cfg domain.Config, sse *sse.Server) { zerolog.TimeFieldFormat = time.RFC3339 + zerolog.ErrorStackMarshaler = pkgerrors.MarshalStack switch cfg.LogLevel { case "INFO": diff --git a/pkg/btn/btn.go b/pkg/btn/btn.go new file mode 100644 index 0000000..eec0723 --- /dev/null +++ b/pkg/btn/btn.go @@ -0,0 +1,93 @@ +package btn + +import ( + "fmt" + + "github.com/autobrr/autobrr/internal/domain" +) + +func (c *Client) TestAPI() (bool, error) { + res, err := c.rpcClient.Call("userInfo", [2]string{c.APIKey}) + if err != nil { + return false, err + } + + var u *UserInfo + err = res.GetObject(&u) + if err != nil { + return false, err + } + + if u.Username != "" { + return true, nil + } + + return false, nil +} + +func (c *Client) GetTorrentByID(torrentID string) (*domain.TorrentBasic, error) { + if torrentID == "" { + return nil, fmt.Errorf("btn client: must have torrentID") + } + + res, err := c.rpcClient.Call("getTorrentById", [2]string{torrentID, c.APIKey}) + if err != nil { + return nil, err + } + + var r *domain.TorrentBasic + err = res.GetObject(&r) + if err != nil { + return nil, err + } + + return r, nil +} + +type Torrent struct { + GroupName string `json:"GroupName"` + GroupID string `json:"GroupID"` + TorrentID string `json:"TorrentID"` + SeriesID string `json:"SeriesID"` + Series string `json:"Series"` + SeriesBanner string `json:"SeriesBanner"` + SeriesPoster string `json:"SeriesPoster"` + YoutubeTrailer string `json:"YoutubeTrailer"` + Category string `json:"Category"` + Snatched string `json:"Snatched"` + Seeders string `json:"Seeders"` + Leechers string `json:"Leechers"` + Source string `json:"Source"` + Container string `json:"Container"` + Codec string `json:"Codec"` + Resolution string `json:"Resolution"` + Origin string `json:"Origin"` + ReleaseName string `json:"ReleaseName"` + Size string `json:"Size"` + Time string `json:"Time"` + TvdbID string `json:"TvdbID"` + TvrageID string `json:"TvrageID"` + ImdbID string `json:"ImdbID"` + InfoHash string `json:"InfoHash"` + DownloadURL string `json:"DownloadURL"` +} + +type UserInfo struct { + UserID string `json:"UserID"` + Username string `json:"Username"` + Email string `json:"Email"` + Upload string `json:"Upload"` + Download string `json:"Download"` + Lumens string `json:"Lumens"` + Bonus string `json:"Bonus"` + JoinDate string `json:"JoinDate"` + Title string `json:"Title"` + Enabled string `json:"Enabled"` + Paranoia string `json:"Paranoia"` + Invites string `json:"Invites"` + Class string `json:"Class"` + ClassLevel string `json:"ClassLevel"` + HnR string `json:"HnR"` + UploadsSnatched string `json:"UploadsSnatched"` + Snatches string `json:"Snatches"` +} diff --git a/pkg/btn/btn_test.go b/pkg/btn/btn_test.go new file mode 100644 index 0000000..e28a1a0 --- /dev/null +++ b/pkg/btn/btn_test.go @@ -0,0 +1,175 @@ +package btn + +import ( + "io/ioutil" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/autobrr/autobrr/internal/domain" + + "github.com/stretchr/testify/assert" + + "github.com/rs/zerolog" +) + +func TestAPI(t *testing.T) { + // disable logger + zerolog.SetGlobalLevel(zerolog.Disabled) + + mux := http.NewServeMux() + ts := httptest.NewServer(mux) + defer ts.Close() + + key := "mock-key" + + mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { + // request validation logic + //apiKey := r.Header.Get("ApiKey") + //if apiKey != key { + // w.WriteHeader(http.StatusUnauthorized) + // w.Write(nil) + // return + //} + + // read json response + jsonPayload, _ := ioutil.ReadFile("testdata/btn_get_user_info.json") + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + w.Write(jsonPayload) + }) + + type fields struct { + Url string + APIKey string + } + tests := []struct { + name string + fields fields + want bool + wantErr bool + }{ + { + name: "test_user", + fields: fields{ + Url: ts.URL, + APIKey: key, + }, + want: true, + wantErr: false, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + c := NewClient(tt.fields.Url, tt.fields.APIKey) + + got, err := c.TestAPI() + if tt.wantErr && assert.Error(t, err) { + assert.Equal(t, tt.wantErr, err) + } + + assert.Equal(t, tt.want, got) + }) + } +} + +func TestClient_GetTorrentByID(t *testing.T) { + // disable logger + zerolog.SetGlobalLevel(zerolog.Disabled) + + mux := http.NewServeMux() + ts := httptest.NewServer(mux) + defer ts.Close() + + key := "mock-key" + + mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + t.Errorf("Expected 'POST' reqeust, got '%v'", r.Method) + } + + defer r.Body.Close() + data, err := ioutil.ReadAll(r.Body) + if err != nil { + t.Errorf("expected error to be nil got %v", err) + } + + if !strings.Contains(string(data), "1555073") { + //t.Errorf( + // `response body "%s" does not contain "1555073"`, + // string(data), + //) + w.WriteHeader(http.StatusNotFound) + return + } + + if !strings.Contains(string(data), key) { + jsonPayload, _ := ioutil.ReadFile("testdata/btn_bad_creds.json") + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusUnauthorized) + w.Write(jsonPayload) + return + } + + // read json response + jsonPayload, _ := ioutil.ReadFile("testdata/btn_get_torrent_by_id.json") + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + w.Write(jsonPayload) + }) + + type fields struct { + Url string + APIKey string + } + type args struct { + torrentID string + } + tests := []struct { + name string + fields fields + args args + want *domain.TorrentBasic + wantErr bool + }{ + { + name: "btn_get_torrent_by_id", + fields: fields{ + Url: ts.URL, + APIKey: key, + }, + args: args{torrentID: "1555073"}, + want: &domain.TorrentBasic{ + Id: "", + TorrentId: "1555073", + InfoHash: "56CD94119F6BF7FC294A92D7A4099C3D1815C907", + Size: "3288852849", + }, + wantErr: false, + }, + { + name: "btn_get_torrent_by_id_not_found", + fields: fields{ + Url: ts.URL, + APIKey: key, + }, + args: args{torrentID: "9555073"}, + want: nil, + wantErr: false, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + + c := NewClient(tt.fields.Url, tt.fields.APIKey) + + got, err := c.GetTorrentByID(tt.args.torrentID) + if tt.wantErr && assert.Error(t, err) { + assert.Equal(t, tt.wantErr, err) + } + + assert.Equal(t, tt.want, got) + }) + } +} diff --git a/pkg/btn/client.go b/pkg/btn/client.go new file mode 100644 index 0000000..079884b --- /dev/null +++ b/pkg/btn/client.go @@ -0,0 +1,58 @@ +package btn + +import ( + "context" + "net/http" + "time" + + "github.com/autobrr/autobrr/internal/domain" + "github.com/autobrr/autobrr/pkg/jsonrpc" + + "golang.org/x/time/rate" +) + +type BTNClient interface { + GetTorrentByID(torrentID string) (*domain.TorrentBasic, error) + TestAPI() (bool, error) +} + +type Client struct { + Timeout int + client *http.Client + rpcClient jsonrpc.Client + Ratelimiter *rate.Limiter + APIKey string + Headers http.Header +} + +func NewClient(url string, apiKey string) BTNClient { + if url == "" { + url = "https://api.broadcasthe.net/" + } + + c := &Client{ + client: http.DefaultClient, + rpcClient: jsonrpc.NewClientWithOpts(url, &jsonrpc.ClientOpts{ + Headers: map[string]string{ + "User-Agent": "autobrr", + }, + }), + APIKey: apiKey, + Ratelimiter: rate.NewLimiter(rate.Every(150*time.Hour), 1), // 150 rpcRequest every 1 hour + } + + return c +} + +func (c *Client) Do(req *http.Request) (*http.Response, error) { + ctx := context.Background() + err := c.Ratelimiter.Wait(ctx) // This is a blocking call. Honors the rate limit + if err != nil { + return nil, err + } + resp, err := c.client.Do(req) + if err != nil { + return nil, err + } + return resp, nil +} diff --git a/pkg/btn/testdata/btn_bad_creds.json b/pkg/btn/testdata/btn_bad_creds.json new file mode 100644 index 0000000..dedc5c8 --- /dev/null +++ b/pkg/btn/testdata/btn_bad_creds.json @@ -0,0 +1,8 @@ +{ + "id": 1, + "result": null, + "error": { + "code": -32001, + "message": "Invalid API Key" + } +} \ No newline at end of file diff --git a/pkg/btn/testdata/btn_get_torrent_by_id.json b/pkg/btn/testdata/btn_get_torrent_by_id.json new file mode 100644 index 0000000..732f11a --- /dev/null +++ b/pkg/btn/testdata/btn_get_torrent_by_id.json @@ -0,0 +1,30 @@ +{ + "id": 1, + "result": { + "GroupName": "S05E04", + "GroupID": "755034", + "TorrentID": "1555073", + "SeriesID": "70834", + "Series": "That Show", + "SeriesBanner": "\/\/cdn2.broadcasthe.net\/tvdb\/banners\/graphical\/0000000000000.jpg", + "SeriesPoster": "\/\/cdn2.broadcasthe.net\/tvdb\/banners\/posters\/0000000000000\/resized_w300.jpg", + "YoutubeTrailer": "", + "Category": "Episode", + "Snatched": "4", + "Seeders": "5", + "Leechers": "41", + "Source": "WEB-DL", + "Container": "MP4", + "Codec": "H.264", + "Resolution": "1080p", + "Origin": "None", + "ReleaseName": "That.Show.S05E04.1080p.WEB-DL.H.264-NOGRP", + "Size": "3288852849", + "Time": "1641153886", + "TvdbID": "332747", + "TvrageID": "0", + "ImdbID": "7252812", + "InfoHash": "56CD94119F6BF7FC294A92D7A4099C3D1815C907", + "DownloadURL": "https:\/\/broadcasthe.net\/torrents.php?action=download&id=1555073&authkey=REDACTED&torrent_pass=REDACTED" + } +} \ No newline at end of file diff --git a/pkg/btn/testdata/btn_get_user_info.json b/pkg/btn/testdata/btn_get_user_info.json new file mode 100644 index 0000000..a5411db --- /dev/null +++ b/pkg/btn/testdata/btn_get_user_info.json @@ -0,0 +1,22 @@ +{ + "id": 1, + "result": { + "UserID": "0000000", + "Username": "username", + "Email": "email@example.com", + "Upload": "90000000000004", + "Download": "10000000000002", + "Lumens": "10000", + "Bonus": "1000000000", + "JoinDate": "1578088136", + "Title": "", + "Enabled": "1", + "Paranoia": "1", + "Invites": "0", + "Class": "Elite", + "ClassLevel": "301", + "HnR": "0", + "UploadsSnatched": "100", + "Snatches": "100" + } +} diff --git a/pkg/ggn/ggn.go b/pkg/ggn/ggn.go new file mode 100644 index 0000000..b471e63 --- /dev/null +++ b/pkg/ggn/ggn.go @@ -0,0 +1,245 @@ +package ggn + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io/ioutil" + "net/http" + "net/url" + "strconv" + "time" + + "github.com/autobrr/autobrr/internal/domain" + + "github.com/rs/zerolog/log" + "golang.org/x/time/rate" +) + +type Client interface { + GetTorrentByID(torrentID string) (*domain.TorrentBasic, error) + TestAPI() (bool, error) +} + +type client struct { + Url string + Timeout int + client *http.Client + Ratelimiter *rate.Limiter + APIKey string + Headers http.Header +} + +func NewClient(url string, apiKey string) Client { + // set default url + if url == "" { + url = "https://gazellegames.net/api.php" + } + + c := &client{ + APIKey: apiKey, + client: http.DefaultClient, + Url: url, + Ratelimiter: rate.NewLimiter(rate.Every(5*time.Second), 1), // 5 request every 10 seconds + } + + return c +} + +type Group struct { + BbWikiBody string `json:"bbWikiBody"` + WikiBody string `json:"wikiBody"` + WikiImage string `json:"wikiImage"` + Id int `json:"id"` + Name string `json:"name"` + Aliases []string `json:"aliases"` + Year int `json:"year"` + CategoryId int `json:"categoryId"` + CategoryName string `json:"categoryName"` + MasterGroup int `json:"masterGroup"` + Time string `json:"time"` + GameInfo struct { + Screenshots []string `json:"screenshots"` + Trailer string `json:"trailer"` + Rating string `json:"rating"` + MetaRating struct { + Score string `json:"score"` + Percent string `json:"percent"` + Link string `json:"link"` + } `json:"metaRating"` + IgnRating struct { + Score string `json:"score"` + Percent string `json:"percent"` + Link string `json:"link"` + } `json:"ignRating"` + GamespotRating struct { + Score string `json:"score"` + Percent string `json:"percent"` + Link string `json:"link"` + } `json:"gamespotRating"` + Weblinks struct { + GamesWebsite string `json:"GamesWebsite"` + Wikipedia string `json:"Wikipedia"` + Giantbomb string `json:"Giantbomb"` + GameFAQs string `json:"GameFAQs"` + PCGamingWiki string `json:"PCGamingWiki"` + Steam string `json:"Steam"` + Amazon string `json:"Amazon"` + GOG string `json:"GOG"` + HowLongToBeat string `json:"HowLongToBeat"` + } `json:"weblinks"` + } `json:"gameInfo"` + Tags []string `json:"tags"` + Platform string `json:"platform"` +} + +type Torrent struct { + Id int `json:"id"` + InfoHash string `json:"infoHash"` + Type string `json:"type"` + Link string `json:"link"` + Format string `json:"format"` + Encoding string `json:"encoding"` + Region string `json:"region"` + Language string `json:"language"` + Remastered bool `json:"remastered"` + RemasterYear int `json:"remasterYear"` + RemasterTitle string `json:"remasterTitle"` + Scene bool `json:"scene"` + HasCue bool `json:"hasCue"` + ReleaseTitle string `json:"releaseTitle"` + ReleaseType string `json:"releaseType"` + GameDOXType string `json:"gameDOXType"` + GameDOXVersion string `json:"gameDOXVersion"` + FileCount int `json:"fileCount"` + Size uint64 `json:"size"` + Seeders int `json:"seeders"` + Leechers int `json:"leechers"` + Snatched int `json:"snatched"` + FreeTorrent bool `json:"freeTorrent"` + NeutralTorrent bool `json:"neutralTorrent"` + Reported bool `json:"reported"` + Time string `json:"time"` + BbDescription string `json:"bbDescription"` + Description string `json:"description"` + FileList []struct { + Ext string `json:"ext"` + Size string `json:"size"` + Name string `json:"name"` + } `json:"fileList"` + FilePath string `json:"filePath"` + UserId int `json:"userId"` + Username string `json:"username"` +} + +type TorrentResponse struct { + Group Group `json:"group"` + Torrent Torrent `json:"torrent"` +} + +type Response struct { + Status string `json:"status"` + Response TorrentResponse `json:"response,omitempty"` + Error string `json:"error,omitempty"` +} + +func (c *client) Do(req *http.Request) (*http.Response, error) { + ctx := context.Background() + err := c.Ratelimiter.Wait(ctx) // This is a blocking call. Honors the rate limit + if err != nil { + return nil, err + } + resp, err := c.client.Do(req) + if err != nil { + return nil, err + } + return resp, nil +} + +func (c *client) get(url string) (*http.Response, error) { + req, err := http.NewRequest(http.MethodGet, url, http.NoBody) + if err != nil { + log.Error().Err(err).Msgf("ggn client request error : %v", url) + return nil, err + } + + req.Header.Add("X-API-Key", c.APIKey) + req.Header.Set("User-Agent", "autobrr") + + res, err := c.Do(req) + if err != nil { + log.Error().Err(err).Msgf("ggn client request error : %v", url) + return nil, err + } + + if res.StatusCode == http.StatusUnauthorized { + return nil, errors.New("unauthorized: bad credentials") + } else if res.StatusCode == http.StatusForbidden { + return nil, nil + } else if res.StatusCode == http.StatusTooManyRequests { + return nil, nil + } + + return res, nil +} + +func (c *client) GetTorrentByID(torrentID string) (*domain.TorrentBasic, error) { + if torrentID == "" { + return nil, fmt.Errorf("ggn client: must have torrentID") + } + + var r Response + + v := url.Values{} + v.Add("id", torrentID) + params := v.Encode() + + url := fmt.Sprintf("%v?%v&%v", c.Url, "request=torrent", params) + + resp, err := c.get(url) + if err != nil { + return nil, err + } + + defer resp.Body.Close() + + body, readErr := ioutil.ReadAll(resp.Body) + if readErr != nil { + return nil, readErr + } + + err = json.Unmarshal(body, &r) + if err != nil { + return nil, err + } + + if r.Status != "success" { + return nil, fmt.Errorf("bad status: %v", r.Status) + } + + t := &domain.TorrentBasic{ + Id: strconv.Itoa(r.Response.Torrent.Id), + InfoHash: r.Response.Torrent.InfoHash, + Size: strconv.FormatUint(r.Response.Torrent.Size, 10), + } + + return t, nil + +} + +// TestAPI try api access against torrents page +func (c *client) TestAPI() (bool, error) { + resp, err := c.get(c.Url) + if err != nil { + return false, err + } + + defer resp.Body.Close() + + if resp.StatusCode == http.StatusOK { + return true, nil + } + + return false, nil +} diff --git a/pkg/ggn/ggn_test.go b/pkg/ggn/ggn_test.go new file mode 100644 index 0000000..08ca45e --- /dev/null +++ b/pkg/ggn/ggn_test.go @@ -0,0 +1,99 @@ +package ggn + +import ( + "io/ioutil" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/rs/zerolog" + "github.com/stretchr/testify/assert" + + "github.com/autobrr/autobrr/internal/domain" +) + +func Test_client_GetTorrentByID(t *testing.T) { + // disable logger + zerolog.SetGlobalLevel(zerolog.Disabled) + + key := "mock-key" + + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // request validation logic + apiKey := r.Header.Get("X-API-Key") + if apiKey != key { + w.WriteHeader(http.StatusUnauthorized) + w.Write(nil) + return + } + + if !strings.Contains(r.RequestURI, "422368") { + jsonPayload, _ := ioutil.ReadFile("testdata/ggn_get_torrent_by_id_not_found.json") + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + w.Write(jsonPayload) + return + } + + // read json response + jsonPayload, _ := ioutil.ReadFile("testdata/ggn_get_torrent_by_id.json") + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + w.Write(jsonPayload) + })) + defer ts.Close() + + type fields struct { + Url string + APIKey string + } + type args struct { + torrentID string + } + tests := []struct { + name string + fields fields + args args + want *domain.TorrentBasic + wantErr bool + }{ + { + name: "get_by_id_1", + fields: fields{ + Url: ts.URL, + APIKey: key, + }, + args: args{torrentID: "422368"}, + want: &domain.TorrentBasic{ + Id: "422368", + InfoHash: "78DA2811E6732012B8224198D4DC2FD49A5E950F", + Size: "134800", + }, + wantErr: false, + }, + { + name: "get_by_id_2", + fields: fields{ + Url: ts.URL, + APIKey: key, + }, + args: args{torrentID: "100002"}, + want: nil, + wantErr: false, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + + c := NewClient(tt.fields.Url, tt.fields.APIKey) + + got, err := c.GetTorrentByID(tt.args.torrentID) + if tt.wantErr && assert.Error(t, err) { + assert.Equal(t, tt.wantErr, err) + } + + assert.Equal(t, tt.want, got) + }) + } +} diff --git a/pkg/ggn/testdata/ggn_get_by_id_not_found.json b/pkg/ggn/testdata/ggn_get_by_id_not_found.json new file mode 100644 index 0000000..4e7ea48 --- /dev/null +++ b/pkg/ggn/testdata/ggn_get_by_id_not_found.json @@ -0,0 +1 @@ +{"status":"failure","error":"bad id parameter"} \ No newline at end of file diff --git a/pkg/ggn/testdata/ggn_get_index.json b/pkg/ggn/testdata/ggn_get_index.json new file mode 100644 index 0000000..e8f5409 --- /dev/null +++ b/pkg/ggn/testdata/ggn_get_index.json @@ -0,0 +1 @@ +{"status":"success","response":{"api_version":"3.1.0"}} \ No newline at end of file diff --git a/pkg/ggn/testdata/ggn_get_torrent_by_id.json b/pkg/ggn/testdata/ggn_get_torrent_by_id.json new file mode 100644 index 0000000..46495f2 --- /dev/null +++ b/pkg/ggn/testdata/ggn_get_torrent_by_id.json @@ -0,0 +1,111 @@ +{ + "status": "success", + "response": { + "group": { + "bbWikiBody": "Some Game", + "wikiBody": "Some Game", + "wikiImage": "https:\/\/ptpimg.me\/2y8t7a.jpg", + "id": 2279, + "name": "Some Game", + "aliases": [ + "" + ], + "year": 2011, + "categoryId": 1, + "categoryName": "Games", + "masterGroup": 769, + "time": "2022-01-04 22:34:14", + "gameInfo": { + "screenshots": [ + "https%3A%2F%2Fptpimg.me%2Fw176o2.jpg", + "https%3A%2F%2Fptpimg.me%2Fw44511.jpg", + "https%3A%2F%2Fptpimg.me%2F591yx9.jpg", + "https%3A%2F%2Fptpimg.me%2Fqm7bw1.jpg" + ], + "trailer": "http:\/\/www.youtube.com\/watch?v=xxxxxxxxxxxx", + "rating": "7+", + "metaRating": { + "score": "73", + "percent": "73%", + "link": "http%3A%2F%2Fwww.metacritic.com%2Fgame%2Fpc%2Fthat-game" + }, + "ignRating": { + "score": "0", + "percent": "0%", + "link": "" + }, + "gamespotRating": { + "score": "6.5", + "percent": "65%", + "link": "http%3A%2F%2Fwww.gamespot.com%2Freviews%2Fthat-game" + }, + "weblinks": { + "GamesWebsite": "http:\/\/games.disney.com\/some-game", + "Wikipedia": "https:\/\/en.wikipedia.org\/wiki\/some-game", + "Giantbomb": "http:\/\/www.giantbomb.com\/some-game\/3030-33207\/", + "GameFAQs": "http:\/\/www.gamefaqs.com\/pc\/612250-some-game", + "PCGamingWiki": "http:\/\/pcgamingwiki.com\/wiki\/Some_Game", + "Steam": "http:\/\/store.steampowered.com\/app\/0000000\/", + "Amazon": "http:\/\/www.amazon.com\/Some-Game-PC\/dp\/B002I0JJMK", + "GOG": "https:\/\/www.gog.com\/game\/some_game", + "HowLongToBeat": "https:\/\/howlongtobeat.com\/game.php?id=0000" + } + }, + "tags": [ + "action", + "adventure" + ], + "platform": "Windows" + }, + "torrent": { + "id": 422368, + "infoHash": "78DA2811E6732012B8224198D4DC2FD49A5E950F", + "type": "Torrent", + "link": "", + "format": "", + "encoding": "", + "region": "", + "language": "English", + "remastered": false, + "remasterYear": 0, + "remasterTitle": "", + "scene": true, + "hasCue": false, + "releaseTitle": "Some_Game_Patch_1_Plus_5_Trainer-RazorDOX", + "releaseType": "GameDOX", + "gameDOXType": "Trainer", + "gameDOXVersion": "", + "fileCount": 3, + "size": 134800, + "seeders": 10, + "leechers": 0, + "snatched": 20, + "freeTorrent": false, + "neutralTorrent": false, + "reported": false, + "time": "2022-01-04 22:34:14", + "bbDescription": "[align=center][img]https:\/\/gazellegames.net\/nfoimg\/422368.png[\/img][\/align]", + "description": "