159
radarr/api.go
Normal file
159
radarr/api.go
Normal file
@@ -0,0 +1,159 @@
|
||||
package radarr
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"github.com/l3uddz/nabarr/media"
|
||||
"github.com/l3uddz/nabarr/util"
|
||||
"github.com/lucperkins/rek"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrItemNotFound = errors.New("not found")
|
||||
)
|
||||
|
||||
func (c *Client) getSystemStatus() (*systemStatus, error) {
|
||||
// send request
|
||||
resp, err := rek.Get(util.JoinURL(c.apiURL, "/system/status"), rek.Headers(c.apiHeaders),
|
||||
rek.Timeout(c.apiTimeout))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("request system status: %w", err)
|
||||
}
|
||||
defer resp.Body().Close()
|
||||
|
||||
// validate response
|
||||
if resp.StatusCode() != 200 {
|
||||
return nil, fmt.Errorf("validate system status response: %s", resp.Status())
|
||||
}
|
||||
|
||||
// decode response
|
||||
b := new(systemStatus)
|
||||
if err := json.NewDecoder(resp.Body()).Decode(b); err != nil {
|
||||
return nil, fmt.Errorf("decode system status response: %w", err)
|
||||
}
|
||||
|
||||
return b, nil
|
||||
}
|
||||
|
||||
func (c *Client) getQualityProfileId(profileName string) (int, error) {
|
||||
// send request
|
||||
resp, err := rek.Get(util.JoinURL(c.apiURL, "/profile"), rek.Headers(c.apiHeaders),
|
||||
rek.Timeout(c.apiTimeout))
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("request quality profiles: %w", err)
|
||||
}
|
||||
defer resp.Body().Close()
|
||||
|
||||
// validate response
|
||||
if resp.StatusCode() != 200 {
|
||||
return 0, fmt.Errorf("validate quality profiles response: %s", resp.Status())
|
||||
}
|
||||
|
||||
// decode response
|
||||
b := new([]qualityProfile)
|
||||
if err := json.NewDecoder(resp.Body()).Decode(b); err != nil {
|
||||
return 0, fmt.Errorf("decode quality profiles response: %w", err)
|
||||
}
|
||||
|
||||
// find quality profile
|
||||
for _, profile := range *b {
|
||||
if strings.EqualFold(profile.Name, profileName) {
|
||||
return profile.Id, nil
|
||||
}
|
||||
}
|
||||
|
||||
return 0, errors.New("quality profile not found")
|
||||
}
|
||||
|
||||
func (c *Client) lookupMediaItem(item *media.Item) (*lookupRequest, error) {
|
||||
// determine metadata id to use
|
||||
mdType := "imdb"
|
||||
mdId := item.ImdbId
|
||||
|
||||
if item.TmdbId != "" && item.TmdbId != "0" {
|
||||
// radarr prefers tmdb
|
||||
mdType = "tmdb"
|
||||
mdId = item.TmdbId
|
||||
}
|
||||
|
||||
// prepare request
|
||||
reqUrl, err := util.URLWithQuery(util.JoinURL(c.apiURL, "/movie/lookup"),
|
||||
url.Values{"term": []string{fmt.Sprintf("%s:%s", mdType, mdId)}})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("generate movie lookup request url: %w", err)
|
||||
}
|
||||
|
||||
// send request
|
||||
resp, err := rek.Get(reqUrl, rek.Headers(c.apiHeaders), rek.Timeout(c.apiTimeout))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("request movie lookup: %w", err)
|
||||
}
|
||||
defer resp.Body().Close()
|
||||
|
||||
// validate response
|
||||
if resp.StatusCode() != 200 {
|
||||
return nil, fmt.Errorf("validate movie lookup response: %s", resp.Status())
|
||||
}
|
||||
|
||||
// decode response
|
||||
b := new([]lookupRequest)
|
||||
if err := json.NewDecoder(resp.Body()).Decode(b); err != nil {
|
||||
return nil, fmt.Errorf("decode movie lookup response: %w", err)
|
||||
}
|
||||
|
||||
// find movie
|
||||
for _, s := range *b {
|
||||
switch mdType {
|
||||
case "tmdb":
|
||||
if strconv.Itoa(s.TmdbId) == item.TmdbId {
|
||||
return &s, nil
|
||||
}
|
||||
default:
|
||||
if s.ImdbId == item.ImdbId {
|
||||
return &s, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("movie lookup %sId: %v: %w", mdType, mdId, ErrItemNotFound)
|
||||
}
|
||||
|
||||
func (c *Client) AddMediaItem(item *media.Item) error {
|
||||
// prepare request
|
||||
req := addRequest{
|
||||
Title: item.Title,
|
||||
TitleSlug: item.Slug,
|
||||
Year: item.Year,
|
||||
QualityProfileId: c.qualityProfileId,
|
||||
Images: []string{},
|
||||
Monitored: true,
|
||||
RootFolderPath: c.rootFolder,
|
||||
MinimumAvailability: "released",
|
||||
AddOptions: addOptions{
|
||||
SearchForMovie: true,
|
||||
IgnoreEpisodesWithFiles: false,
|
||||
IgnoreEpisodesWithoutFiles: false,
|
||||
},
|
||||
TmdbId: util.Atoi(item.TmdbId, 0),
|
||||
ImdbId: item.ImdbId,
|
||||
}
|
||||
|
||||
// send request
|
||||
resp, err := rek.Post(util.JoinURL(c.apiURL, "/movie"), rek.Headers(c.apiHeaders), rek.Json(req),
|
||||
rek.Timeout(c.apiTimeout))
|
||||
if err != nil {
|
||||
return fmt.Errorf("request add movie: %w", err)
|
||||
}
|
||||
defer resp.Body().Close()
|
||||
|
||||
// validate response
|
||||
if resp.StatusCode() != 200 && resp.StatusCode() != 201 {
|
||||
return fmt.Errorf("validate add movie response: %s", resp.Status())
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
47
radarr/expression.go
Normal file
47
radarr/expression.go
Normal file
@@ -0,0 +1,47 @@
|
||||
package radarr
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"github.com/antonmedv/expr"
|
||||
"github.com/l3uddz/nabarr"
|
||||
"github.com/l3uddz/nabarr/media"
|
||||
"github.com/pkg/errors"
|
||||
)
|
||||
|
||||
func (c *Client) compileExpressions(filters nabarr.PvrFilters) error {
|
||||
exprEnv := &nabarr.ExprEnv{}
|
||||
|
||||
// compile ignores
|
||||
for _, ignoreExpr := range filters.Ignores {
|
||||
program, err := expr.Compile(ignoreExpr, expr.Env(exprEnv), expr.AsBool())
|
||||
if err != nil {
|
||||
return fmt.Errorf("ignore expression: %v: %w", ignoreExpr, err)
|
||||
}
|
||||
|
||||
c.ignoresExpr = append(c.ignoresExpr, nabarr.NewExprProgram(ignoreExpr, program))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Client) ShouldIgnore(mediaItem *media.Item) (bool, string, error) {
|
||||
exprItem := nabarr.NewExprEnv(mediaItem)
|
||||
|
||||
for _, expression := range c.ignoresExpr {
|
||||
result, err := expr.Run(expression.Program, exprItem)
|
||||
if err != nil {
|
||||
return true, expression.String(), fmt.Errorf("checking ignore expression: %w", err)
|
||||
}
|
||||
|
||||
expResult, ok := result.(bool)
|
||||
if !ok {
|
||||
return true, expression.String(), errors.New("type assert ignore expression result")
|
||||
}
|
||||
|
||||
if expResult {
|
||||
return true, expression.String(), nil
|
||||
}
|
||||
}
|
||||
|
||||
return false, "", nil
|
||||
}
|
||||
228
radarr/queue.go
Normal file
228
radarr/queue.go
Normal file
@@ -0,0 +1,228 @@
|
||||
package radarr
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"github.com/l3uddz/nabarr/media"
|
||||
"github.com/lefelys/state"
|
||||
)
|
||||
|
||||
func (c *Client) QueueFeedItem(item *media.FeedItem) {
|
||||
c.queue <- item
|
||||
}
|
||||
|
||||
func (c *Client) Start() state.State {
|
||||
st, tail := state.WithShutdown()
|
||||
go c.queueProcessor(tail)
|
||||
return st
|
||||
}
|
||||
|
||||
func (c *Client) queueProcessor(tail state.ShutdownTail) {
|
||||
for {
|
||||
select {
|
||||
case <-tail.End():
|
||||
// shutdown
|
||||
tail.Done()
|
||||
return
|
||||
case feedItem := <-c.queue:
|
||||
// stop processing
|
||||
if feedItem == nil {
|
||||
tail.Done()
|
||||
return
|
||||
}
|
||||
|
||||
// validate item has required id(s)
|
||||
if feedItem.ImdbId == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
// check cache / add item to cache
|
||||
pvrCacheBucket := fmt.Sprintf("pvr_%s_%s", c.Type(), c.name)
|
||||
cacheKey := fmt.Sprintf("imdb_%s", feedItem.ImdbId)
|
||||
if !c.testMode {
|
||||
// not running in test mode, so use cache
|
||||
if cacheValue, err := c.cache.Get(pvrCacheBucket, cacheKey); err == nil {
|
||||
// item already exists in the cache
|
||||
switch string(cacheValue) {
|
||||
case c.name:
|
||||
// perm cache entry, item exists in the pvr
|
||||
continue
|
||||
case c.cacheFiltersHash:
|
||||
// temp cache entry, item recently checked with the same filters
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
// insert temp cache entry
|
||||
if err := c.cache.Put(pvrCacheBucket, cacheKey, []byte(c.cacheFiltersHash), c.cacheTempDuration); err != nil {
|
||||
c.log.Error().
|
||||
Err(err).
|
||||
Msg("Failed storing item in temp cache")
|
||||
}
|
||||
}
|
||||
|
||||
// get media info
|
||||
mediaItem, err := c.m.GetMovieInfo(feedItem)
|
||||
if err != nil {
|
||||
if errors.Is(err, media.ErrItemNotFound) {
|
||||
c.log.Debug().
|
||||
Err(err).
|
||||
Str("feed_title", feedItem.Title).
|
||||
Str("feed_imdb_id", feedItem.ImdbId).
|
||||
Str("feed_name", feedItem.Feed).
|
||||
Msg("Item was not found on trakt")
|
||||
continue
|
||||
}
|
||||
|
||||
c.log.Error().
|
||||
Err(err).
|
||||
Str("feed_title", feedItem.Title).
|
||||
Str("feed_imdb_id", feedItem.ImdbId).
|
||||
Str("feed_name", feedItem.Feed).
|
||||
Msg("Failed finding item on trakt")
|
||||
continue
|
||||
}
|
||||
|
||||
if c.testMode {
|
||||
c.log.Debug().
|
||||
Interface("trakt_item", mediaItem).
|
||||
Msg("Item found on trakt")
|
||||
}
|
||||
|
||||
// validate tmdbId was found
|
||||
if mediaItem.TmdbId == "" || mediaItem.TmdbId == "0" {
|
||||
c.log.Warn().
|
||||
Str("feed_title", mediaItem.FeedTitle).
|
||||
Str("feed_imdb_id", feedItem.ImdbId).
|
||||
Str("feed_name", feedItem.Feed).
|
||||
Msg("Item had no tmdbId on trakt")
|
||||
continue
|
||||
}
|
||||
|
||||
// trakt expression check
|
||||
ignore, filter, err := c.ShouldIgnore(mediaItem)
|
||||
if err != nil {
|
||||
c.log.Error().
|
||||
Err(err).
|
||||
Str("feed_title", mediaItem.FeedTitle).
|
||||
Str("trakt_title", mediaItem.Title).
|
||||
Str("trakt_imdb_id", mediaItem.ImdbId).
|
||||
Str("feed_name", feedItem.Feed).
|
||||
Str("ignore_filter", filter).
|
||||
Msg("Failed checking item against ignore filters")
|
||||
continue
|
||||
}
|
||||
|
||||
if ignore {
|
||||
c.log.Debug().
|
||||
Str("feed_title", mediaItem.FeedTitle).
|
||||
Str("trakt_title", mediaItem.Title).
|
||||
Str("trakt_imdb_id", mediaItem.ImdbId).
|
||||
Str("feed_name", feedItem.Feed).
|
||||
Str("ignore_filter", filter).
|
||||
Msg("Item matched ignore filters")
|
||||
continue
|
||||
}
|
||||
|
||||
// lookup item in pvr
|
||||
s, err := c.lookupMediaItem(mediaItem)
|
||||
if err != nil {
|
||||
if errors.Is(err, ErrItemNotFound) {
|
||||
// the item was not found
|
||||
c.log.Warn().
|
||||
Err(err).
|
||||
Str("feed_title", mediaItem.FeedTitle).
|
||||
Str("feed_imdb_id", feedItem.ImdbId).
|
||||
Str("feed_name", feedItem.Feed).
|
||||
Msg("Item was not found via pvr lookup")
|
||||
continue
|
||||
}
|
||||
|
||||
c.log.Error().
|
||||
Err(err).
|
||||
Str("feed_title", mediaItem.FeedTitle).
|
||||
Str("feed_imdb_id", feedItem.ImdbId).
|
||||
Str("feed_name", feedItem.Feed).
|
||||
Msg("Failed finding item via pvr lookup")
|
||||
}
|
||||
|
||||
if s.Id > 0 {
|
||||
// item already existed in pvr
|
||||
c.log.Debug().
|
||||
Str("pvr_title", s.Title).
|
||||
Int("pvr_year", s.Year).
|
||||
Str("pvr_imdb_id", s.ImdbId).
|
||||
Int("pvr_tmdb_id", s.TmdbId).
|
||||
Str("feed_name", feedItem.Feed).
|
||||
Msg("Item already existed in pvr")
|
||||
|
||||
// add item to perm cache (items already in pvr)
|
||||
if !c.testMode {
|
||||
if err := c.cache.Put(pvrCacheBucket, cacheKey, []byte(c.name), 0); err != nil {
|
||||
c.log.Error().
|
||||
Err(err).
|
||||
Msg("Failed storing item in perm cache")
|
||||
}
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
// add item to pvr
|
||||
c.log.Debug().
|
||||
Str("feed_title", mediaItem.FeedTitle).
|
||||
Str("trakt_title", mediaItem.Title).
|
||||
Str("trakt_imdb_id", mediaItem.ImdbId).
|
||||
Str("trakt_tmdb_id", mediaItem.TmdbId).
|
||||
Int("trakt_year", mediaItem.Year).
|
||||
Str("feed_name", feedItem.Feed).
|
||||
Msg("Sending item to pvr")
|
||||
|
||||
if s.TitleSlug != "" {
|
||||
// use slug from pvr search
|
||||
mediaItem.Slug = s.TitleSlug
|
||||
}
|
||||
|
||||
if c.testMode {
|
||||
c.log.Info().
|
||||
Err(err).
|
||||
Str("trakt_title", mediaItem.Title).
|
||||
Str("trakt_imdb_id", mediaItem.ImdbId).
|
||||
Str("trakt_tmdb_id", mediaItem.TmdbId).
|
||||
Int("trakt_year", mediaItem.Year).
|
||||
Str("feed_name", feedItem.Feed).
|
||||
Msg("Added item (test mode)")
|
||||
continue
|
||||
}
|
||||
|
||||
if err := c.AddMediaItem(mediaItem); err != nil {
|
||||
c.log.Error().
|
||||
Err(err).
|
||||
Str("feed_title", mediaItem.FeedTitle).
|
||||
Str("trakt_title", mediaItem.Title).
|
||||
Str("trakt_imdb_id", mediaItem.ImdbId).
|
||||
Str("trakt_tmdb_id", mediaItem.TmdbId).
|
||||
Int("trakt_year", mediaItem.Year).
|
||||
Str("feed_name", feedItem.Feed).
|
||||
Msg("Failed adding item to pvr")
|
||||
}
|
||||
|
||||
// add item to perm cache (item was added to pvr)
|
||||
if !c.testMode {
|
||||
if err := c.cache.Put(pvrCacheBucket, cacheKey, []byte(c.name), 0); err != nil {
|
||||
c.log.Error().
|
||||
Err(err).
|
||||
Msg("Failed storing item in perm cache")
|
||||
}
|
||||
}
|
||||
|
||||
c.log.Info().
|
||||
Err(err).
|
||||
Str("trakt_title", mediaItem.Title).
|
||||
Str("trakt_imdb_id", mediaItem.ImdbId).
|
||||
Str("trakt_tmdb_id", mediaItem.TmdbId).
|
||||
Int("trakt_year", mediaItem.Year).
|
||||
Str("feed_name", feedItem.Feed).
|
||||
Msg("Added item")
|
||||
}
|
||||
}
|
||||
}
|
||||
110
radarr/radarr.go
Normal file
110
radarr/radarr.go
Normal file
@@ -0,0 +1,110 @@
|
||||
package radarr
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"github.com/l3uddz/nabarr"
|
||||
"github.com/l3uddz/nabarr/cache"
|
||||
"github.com/l3uddz/nabarr/logger"
|
||||
"github.com/l3uddz/nabarr/media"
|
||||
"github.com/l3uddz/nabarr/util"
|
||||
"github.com/rs/zerolog"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type Client struct {
|
||||
pvrType string
|
||||
name string
|
||||
testMode bool
|
||||
|
||||
rootFolder string
|
||||
qualityProfileId int
|
||||
|
||||
apiURL string
|
||||
apiHeaders map[string]string
|
||||
apiTimeout time.Duration
|
||||
|
||||
cache *cache.Client
|
||||
cacheTempDuration time.Duration
|
||||
cacheFiltersHash string
|
||||
|
||||
queue chan *media.FeedItem
|
||||
|
||||
m *media.Client
|
||||
log zerolog.Logger
|
||||
ignoresExpr []*nabarr.ExprProgram
|
||||
}
|
||||
|
||||
func New(c nabarr.PvrConfig, mode string, m *media.Client, cc *cache.Client) (*Client, error) {
|
||||
l := logger.New(c.Verbosity).With().
|
||||
Str("pvr_name", c.Name).
|
||||
Str("pvr_type", c.Type).
|
||||
Logger()
|
||||
|
||||
// set config defaults (if not set)
|
||||
if c.CacheDuration == 0 {
|
||||
c.CacheDuration = 24 * time.Hour
|
||||
}
|
||||
|
||||
// set api url
|
||||
apiURL := ""
|
||||
if strings.Contains(strings.ToLower(c.URL), "/api") {
|
||||
apiURL = c.URL
|
||||
} else {
|
||||
apiURL = util.JoinURL(c.URL, "/api")
|
||||
}
|
||||
|
||||
// set api headers
|
||||
apiHeaders := map[string]string{
|
||||
"X-Api-Key": c.ApiKey,
|
||||
}
|
||||
|
||||
// create client
|
||||
cl := &Client{
|
||||
pvrType: "radarr",
|
||||
name: strings.ToLower(c.Name),
|
||||
testMode: strings.EqualFold(mode, "test"),
|
||||
|
||||
rootFolder: c.RootFolder,
|
||||
|
||||
cache: cc,
|
||||
cacheTempDuration: c.CacheDuration,
|
||||
cacheFiltersHash: util.AsSHA256(c.Filters),
|
||||
|
||||
queue: make(chan *media.FeedItem, 1024),
|
||||
|
||||
apiURL: apiURL,
|
||||
apiHeaders: apiHeaders,
|
||||
apiTimeout: 60 * time.Second,
|
||||
|
||||
m: m,
|
||||
log: l,
|
||||
}
|
||||
|
||||
// compile expressions
|
||||
if err := cl.compileExpressions(c.Filters); err != nil {
|
||||
return nil, fmt.Errorf("compile expressions: %w", err)
|
||||
}
|
||||
|
||||
// validate api access
|
||||
ss, err := cl.getSystemStatus()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("validate api: %w", err)
|
||||
}
|
||||
|
||||
// get quality profile
|
||||
if qid, err := cl.getQualityProfileId(c.QualityProfile); err != nil {
|
||||
return nil, fmt.Errorf("get quality profile: %v: %w", c.QualityProfile, err)
|
||||
} else {
|
||||
cl.qualityProfileId = qid
|
||||
}
|
||||
|
||||
cl.log.Info().
|
||||
Str("pvr_version", ss.Version).
|
||||
Msg("Initialised")
|
||||
return cl, nil
|
||||
}
|
||||
|
||||
func (c *Client) Type() string {
|
||||
return c.pvrType
|
||||
}
|
||||
39
radarr/struct.go
Normal file
39
radarr/struct.go
Normal file
@@ -0,0 +1,39 @@
|
||||
package radarr
|
||||
|
||||
type systemStatus struct {
|
||||
Version string
|
||||
}
|
||||
|
||||
type qualityProfile struct {
|
||||
Name string
|
||||
Id int
|
||||
}
|
||||
|
||||
type lookupRequest struct {
|
||||
Id int `json:"id,omitempty"`
|
||||
Title string `json:"title"`
|
||||
TitleSlug string `json:"titleSlug"`
|
||||
Year int `json:"year,omitempty"`
|
||||
ImdbId string `json:"imdbId"`
|
||||
TmdbId int `json:"tmdbId"`
|
||||
}
|
||||
|
||||
type addRequest struct {
|
||||
Title string `json:"title"`
|
||||
TitleSlug string `json:"titleSlug"`
|
||||
Year int `json:"year"`
|
||||
QualityProfileId int `json:"qualityProfileId"`
|
||||
Images []string `json:"images"`
|
||||
Monitored bool `json:"monitored"`
|
||||
RootFolderPath string `json:"rootFolderPath"`
|
||||
MinimumAvailability string `json:"minimumAvailability"`
|
||||
AddOptions addOptions `json:"addOptions"`
|
||||
TmdbId int `json:"tmdbId,omitempty"`
|
||||
ImdbId string `json:"imdbId,omitempty"`
|
||||
}
|
||||
|
||||
type addOptions struct {
|
||||
SearchForMovie bool `json:"searchForMovie"`
|
||||
IgnoreEpisodesWithFiles bool `json:"ignoreEpisodesWithFiles"`
|
||||
IgnoreEpisodesWithoutFiles bool `json:"ignoreEpisodesWithoutFiles"`
|
||||
}
|
||||
Reference in New Issue
Block a user