initial code (#6)

* initial code commit
This commit is contained in:
l3uddz
2021-02-14 16:18:26 +00:00
committed by GitHub
parent 3f55336fbd
commit ce3807b819
53 changed files with 3694 additions and 0 deletions

149
sonarr/api.go Normal file
View File

@@ -0,0 +1,149 @@
package sonarr
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) {
// prepare request
reqUrl, err := util.URLWithQuery(util.JoinURL(c.apiURL, "/series/lookup"),
url.Values{"term": []string{fmt.Sprintf("tvdb:%s", item.TvdbId)}})
if err != nil {
return nil, fmt.Errorf("generate series 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 series lookup: %w", err)
}
defer resp.Body().Close()
// validate response
if resp.StatusCode() != 200 {
return nil, fmt.Errorf("validate series 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 series lookup response: %w", err)
}
// find series
for _, s := range *b {
if strconv.Itoa(s.TvdbId) == item.TvdbId {
return &s, nil
}
}
return nil, fmt.Errorf("series lookup tvdbId: %v: %w", item.TvdbId, ErrItemNotFound)
}
func (c *Client) AddMediaItem(item *media.Item) error {
// prepare request
tvdbId, err := strconv.Atoi(item.TvdbId)
if err != nil {
return fmt.Errorf("converting tvdb id to int: %q", item.TvdbId)
}
req := addRequest{
Title: item.Title,
TitleSlug: item.Slug,
Year: item.Year,
QualityProfileId: c.qualityProfileId,
Images: []string{},
Tags: []string{},
Monitored: true,
RootFolderPath: c.rootFolder,
AddOptions: addOptions{
SearchForMissingEpisodes: true,
IgnoreEpisodesWithFiles: false,
IgnoreEpisodesWithoutFiles: false,
},
Seasons: []string{},
SeriesType: "standard",
SeasonFolder: true,
TvdbId: tvdbId,
}
// send request
resp, err := rek.Post(util.JoinURL(c.apiURL, "/series"), rek.Headers(c.apiHeaders), rek.Json(req),
rek.Timeout(c.apiTimeout))
if err != nil {
return fmt.Errorf("request add series: %w", err)
}
defer resp.Body().Close()
// validate response
if resp.StatusCode() != 200 && resp.StatusCode() != 201 {
return fmt.Errorf("validate add series response: %s", resp.Status())
}
return nil
}

47
sonarr/expression.go Normal file
View File

@@ -0,0 +1,47 @@
package sonarr
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
}

213
sonarr/queue.go Normal file
View File

@@ -0,0 +1,213 @@
package sonarr
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.TvdbId == "" {
continue
}
// check cache / add item to cache
pvrCacheBucket := fmt.Sprintf("pvr_%s_%s", c.Type(), c.name)
cacheKey := fmt.Sprintf("tvdb_%s", feedItem.TvdbId)
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.GetShowInfo(feedItem)
if err != nil {
if errors.Is(err, media.ErrItemNotFound) {
c.log.Debug().
Err(err).
Str("feed_title", feedItem.Title).
Str("feed_tvdb_id", feedItem.TvdbId).
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_tvdb_id", feedItem.TvdbId).
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")
}
// 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_tvdb_id", mediaItem.TvdbId).
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_tvdb_id", mediaItem.TvdbId).
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_tvdb_id", feedItem.TvdbId).
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_tvdb_id", feedItem.TvdbId).
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).
Int("pvr_tvdb_id", s.TvdbId).
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_tvdb_id", mediaItem.TvdbId).
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_tvdb_id", mediaItem.TvdbId).
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_tvdb_id", mediaItem.TvdbId).
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_tvdb_id", mediaItem.TvdbId).
Int("trakt_year", mediaItem.Year).
Str("feed_name", feedItem.Feed).
Msg("Added item")
}
}
}

110
sonarr/sonarr.go Normal file
View File

@@ -0,0 +1,110 @@
package sonarr
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: "sonarr",
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
}

40
sonarr/struct.go Normal file
View File

@@ -0,0 +1,40 @@
package sonarr
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"`
TvdbId int `json:"tvdbId"`
}
type addRequest struct {
Title string `json:"title"`
TitleSlug string `json:"titleSlug"`
Year int `json:"year"`
QualityProfileId int `json:"qualityProfileId"`
Images []string `json:"images"`
Tags []string `json:"tags"`
Monitored bool `json:"monitored"`
RootFolderPath string `json:"rootFolderPath"`
AddOptions addOptions `json:"addOptions"`
Seasons []string `json:"seasons"`
SeriesType string `json:"seriesType"`
SeasonFolder bool `json:"seasonFolder"`
TvdbId int `json:"tvdbId"`
}
type addOptions struct {
SearchForMissingEpisodes bool `json:"searchForMissingEpisodes"`
IgnoreEpisodesWithFiles bool `json:"ignoreEpisodesWithFiles"`
IgnoreEpisodesWithoutFiles bool `json:"ignoreEpisodesWithoutFiles"`
}