feat(filters): add external script and webhook checks

This commit is contained in:
ze0s 2022-07-23 15:19:28 +02:00
parent 16dd8c5419
commit d56693cd33
17 changed files with 635 additions and 200 deletions

View file

@ -135,7 +135,7 @@ func (s *service) delugeV1(client *domain.DownloadClient, action domain.Action,
} }
// macros handle args and replace vars // macros handle args and replace vars
m := NewMacro(release) m := domain.NewMacro(release)
options, err := s.prepareDelugeOptions(action, m) options, err := s.prepareDelugeOptions(action, m)
if err != nil { if err != nil {
@ -224,7 +224,7 @@ func (s *service) delugeV2(client *domain.DownloadClient, action domain.Action,
} }
// macros handle args and replace vars // macros handle args and replace vars
m := NewMacro(release) m := domain.NewMacro(release)
// set options // set options
options, err := s.prepareDelugeOptions(action, m) options, err := s.prepareDelugeOptions(action, m)
@ -265,7 +265,7 @@ func (s *service) delugeV2(client *domain.DownloadClient, action domain.Action,
return nil, nil return nil, nil
} }
func (s *service) prepareDelugeOptions(action domain.Action, m Macro) (delugeClient.Options, error) { func (s *service) prepareDelugeOptions(action domain.Action, m domain.Macro) (delugeClient.Options, error) {
// set options // set options
options := delugeClient.Options{} options := delugeClient.Options{}

View file

@ -56,7 +56,7 @@ func (s *service) execCmd(action domain.Action, release domain.Release) error {
func (s *service) parseExecArgs(release domain.Release, execArgs string) ([]string, error) { func (s *service) parseExecArgs(release domain.Release, execArgs string) ([]string, error) {
// handle args and replace vars // handle args and replace vars
m := NewMacro(release) m := domain.NewMacro(release)
// parse and replace values in argument string before continuing // parse and replace values in argument string before continuing
parsedArgs, err := m.Parse(execArgs) parsedArgs, err := m.Parse(execArgs)

View file

@ -76,7 +76,7 @@ func (s *service) qbittorrent(action domain.Action, release domain.Release) ([]s
} }
// macros handle args and replace vars // macros handle args and replace vars
m := NewMacro(release) m := domain.NewMacro(release)
options, err := s.prepareQbitOptions(action, m) options, err := s.prepareQbitOptions(action, m)
if err != nil { if err != nil {
@ -100,7 +100,7 @@ func (s *service) qbittorrent(action domain.Action, release domain.Release) ([]s
return nil, nil return nil, nil
} }
func (s *service) prepareQbitOptions(action domain.Action, m Macro) (map[string]string, error) { func (s *service) prepareQbitOptions(action domain.Action, m domain.Macro) (map[string]string, error) {
options := map[string]string{} options := map[string]string{}

View file

@ -136,7 +136,7 @@ func (s *service) watchFolder(action domain.Action, release domain.Release) erro
} }
} }
m := NewMacro(release) m := domain.NewMacro(release)
// parse and replace values in argument string before continuing // parse and replace values in argument string before continuing
watchFolderArgs, err := m.Parse(action.WatchFolder) watchFolderArgs, err := m.Parse(action.WatchFolder)
@ -187,7 +187,7 @@ func (s *service) webhook(action domain.Action, release domain.Release) error {
} }
} }
m := NewMacro(release) m := domain.NewMacro(release)
// parse and replace values in argument string before continuing // parse and replace values in argument string before continuing
dataArgs, err := m.Parse(action.WebhookData) dataArgs, err := m.Parse(action.WebhookData)

View file

@ -123,6 +123,14 @@ func (r *FilterRepo) FindByID(ctx context.Context, filterID int) (*domain.Filter
"tags", "tags",
"except_tags", "except_tags",
"origins", "origins",
"external_script_enabled",
"external_script_cmd",
"external_script_args",
"external_script_expect_status",
"external_webhook_enabled",
"external_webhook_host",
"external_webhook_data",
"external_webhook_expect_status",
"created_at", "created_at",
"updated_at", "updated_at",
). ).
@ -140,11 +148,11 @@ func (r *FilterRepo) FindByID(ctx context.Context, filterID int) (*domain.Filter
} }
var f domain.Filter var f domain.Filter
var minSize, maxSize, maxDownloadsUnit, matchReleases, exceptReleases, matchReleaseGroups, exceptReleaseGroups, freeleechPercent, shows, seasons, episodes, years, artists, albums, matchCategories, exceptCategories, matchUploaders, exceptUploaders, tags, exceptTags sql.NullString var minSize, maxSize, maxDownloadsUnit, matchReleases, exceptReleases, matchReleaseGroups, exceptReleaseGroups, freeleechPercent, shows, seasons, episodes, years, artists, albums, matchCategories, exceptCategories, matchUploaders, exceptUploaders, tags, exceptTags, extScriptCmd, extScriptArgs, extWebhookHost, extWebhookData sql.NullString
var useRegex, scene, freeleech, hasLog, hasCue, perfectFlac sql.NullBool var useRegex, scene, freeleech, hasLog, hasCue, perfectFlac, extScriptEnabled, extWebhookEnabled sql.NullBool
var delay, maxDownloads, logScore sql.NullInt32 var delay, maxDownloads, logScore, extWebhookStatus, extScriptStatus sql.NullInt32
if err := row.Scan(&f.ID, &f.Enabled, &f.Name, &minSize, &maxSize, &delay, &f.Priority, &maxDownloads, &maxDownloadsUnit, &matchReleases, &exceptReleases, &useRegex, &matchReleaseGroups, &exceptReleaseGroups, &scene, &freeleech, &freeleechPercent, &shows, &seasons, &episodes, pq.Array(&f.Resolutions), pq.Array(&f.Codecs), pq.Array(&f.Sources), pq.Array(&f.Containers), pq.Array(&f.MatchHDR), pq.Array(&f.ExceptHDR), pq.Array(&f.MatchOther), pq.Array(&f.ExceptOther), &years, &artists, &albums, pq.Array(&f.MatchReleaseTypes), pq.Array(&f.Formats), pq.Array(&f.Quality), pq.Array(&f.Media), &logScore, &hasLog, &hasCue, &perfectFlac, &matchCategories, &exceptCategories, &matchUploaders, &exceptUploaders, &tags, &exceptTags, pq.Array(&f.Origins), &f.CreatedAt, &f.UpdatedAt); err != nil { if err := row.Scan(&f.ID, &f.Enabled, &f.Name, &minSize, &maxSize, &delay, &f.Priority, &maxDownloads, &maxDownloadsUnit, &matchReleases, &exceptReleases, &useRegex, &matchReleaseGroups, &exceptReleaseGroups, &scene, &freeleech, &freeleechPercent, &shows, &seasons, &episodes, pq.Array(&f.Resolutions), pq.Array(&f.Codecs), pq.Array(&f.Sources), pq.Array(&f.Containers), pq.Array(&f.MatchHDR), pq.Array(&f.ExceptHDR), pq.Array(&f.MatchOther), pq.Array(&f.ExceptOther), &years, &artists, &albums, pq.Array(&f.MatchReleaseTypes), pq.Array(&f.Formats), pq.Array(&f.Quality), pq.Array(&f.Media), &logScore, &hasLog, &hasCue, &perfectFlac, &matchCategories, &exceptCategories, &matchUploaders, &exceptUploaders, &tags, &exceptTags, pq.Array(&f.Origins), &extScriptEnabled, &extScriptCmd, &extScriptArgs, &extScriptStatus, &extWebhookEnabled, &extWebhookHost, &extWebhookData, &extWebhookStatus, &f.CreatedAt, &f.UpdatedAt); err != nil {
return nil, errors.Wrap(err, "error scanning row") return nil, errors.Wrap(err, "error scanning row")
} }
@ -178,6 +186,16 @@ func (r *FilterRepo) FindByID(ctx context.Context, filterID int) (*domain.Filter
f.Scene = scene.Bool f.Scene = scene.Bool
f.Freeleech = freeleech.Bool f.Freeleech = freeleech.Bool
f.ExternalScriptEnabled = extScriptEnabled.Bool
f.ExternalScriptCmd = extScriptCmd.String
f.ExternalScriptArgs = extScriptArgs.String
f.ExternalScriptExpectStatus = int(extScriptStatus.Int32)
f.ExternalWebhookEnabled = extWebhookEnabled.Bool
f.ExternalWebhookHost = extWebhookHost.String
f.ExternalWebhookData = extWebhookData.String
f.ExternalWebhookExpectStatus = int(extWebhookStatus.Int32)
return &f, nil return &f, nil
} }
@ -255,6 +273,14 @@ func (r *FilterRepo) findByIndexerIdentifier(ctx context.Context, tx *Tx, indexe
"f.tags", "f.tags",
"f.except_tags", "f.except_tags",
"f.origins", "f.origins",
"f.external_script_enabled",
"f.external_script_cmd",
"f.external_script_args",
"f.external_script_expect_status",
"f.external_webhook_enabled",
"f.external_webhook_host",
"f.external_webhook_data",
"f.external_webhook_expect_status",
"f.created_at", "f.created_at",
"f.updated_at", "f.updated_at",
). ).
@ -282,11 +308,11 @@ func (r *FilterRepo) findByIndexerIdentifier(ctx context.Context, tx *Tx, indexe
for rows.Next() { for rows.Next() {
var f domain.Filter var f domain.Filter
var minSize, maxSize, maxDownloadsUnit, matchReleases, exceptReleases, matchReleaseGroups, exceptReleaseGroups, freeleechPercent, shows, seasons, episodes, years, artists, albums, matchCategories, exceptCategories, matchUploaders, exceptUploaders, tags, exceptTags sql.NullString var minSize, maxSize, maxDownloadsUnit, matchReleases, exceptReleases, matchReleaseGroups, exceptReleaseGroups, freeleechPercent, shows, seasons, episodes, years, artists, albums, matchCategories, exceptCategories, matchUploaders, exceptUploaders, tags, exceptTags, extScriptCmd, extScriptArgs, extWebhookHost, extWebhookData sql.NullString
var useRegex, scene, freeleech, hasLog, hasCue, perfectFlac sql.NullBool var useRegex, scene, freeleech, hasLog, hasCue, perfectFlac, extScriptEnabled, extWebhookEnabled sql.NullBool
var delay, maxDownloads, logScore sql.NullInt32 var delay, maxDownloads, logScore, extWebhookStatus, extScriptStatus sql.NullInt32
if err := rows.Scan(&f.ID, &f.Enabled, &f.Name, &minSize, &maxSize, &delay, &f.Priority, &maxDownloads, &maxDownloadsUnit, &matchReleases, &exceptReleases, &useRegex, &matchReleaseGroups, &exceptReleaseGroups, &scene, &freeleech, &freeleechPercent, &shows, &seasons, &episodes, pq.Array(&f.Resolutions), pq.Array(&f.Codecs), pq.Array(&f.Sources), pq.Array(&f.Containers), pq.Array(&f.MatchHDR), pq.Array(&f.ExceptHDR), pq.Array(&f.MatchOther), pq.Array(&f.ExceptOther), &years, &artists, &albums, pq.Array(&f.MatchReleaseTypes), pq.Array(&f.Formats), pq.Array(&f.Quality), pq.Array(&f.Media), &logScore, &hasLog, &hasCue, &perfectFlac, &matchCategories, &exceptCategories, &matchUploaders, &exceptUploaders, &tags, &exceptTags, pq.Array(&f.Origins), &f.CreatedAt, &f.UpdatedAt); err != nil { if err := rows.Scan(&f.ID, &f.Enabled, &f.Name, &minSize, &maxSize, &delay, &f.Priority, &maxDownloads, &maxDownloadsUnit, &matchReleases, &exceptReleases, &useRegex, &matchReleaseGroups, &exceptReleaseGroups, &scene, &freeleech, &freeleechPercent, &shows, &seasons, &episodes, pq.Array(&f.Resolutions), pq.Array(&f.Codecs), pq.Array(&f.Sources), pq.Array(&f.Containers), pq.Array(&f.MatchHDR), pq.Array(&f.ExceptHDR), pq.Array(&f.MatchOther), pq.Array(&f.ExceptOther), &years, &artists, &albums, pq.Array(&f.MatchReleaseTypes), pq.Array(&f.Formats), pq.Array(&f.Quality), pq.Array(&f.Media), &logScore, &hasLog, &hasCue, &perfectFlac, &matchCategories, &exceptCategories, &matchUploaders, &exceptUploaders, &tags, &exceptTags, pq.Array(&f.Origins), &extScriptEnabled, &extScriptCmd, &extScriptArgs, &extScriptStatus, &extWebhookEnabled, &extWebhookHost, &extWebhookData, &extWebhookStatus, &f.CreatedAt, &f.UpdatedAt); err != nil {
return nil, errors.Wrap(err, "error scanning row") return nil, errors.Wrap(err, "error scanning row")
} }
@ -320,6 +346,16 @@ func (r *FilterRepo) findByIndexerIdentifier(ctx context.Context, tx *Tx, indexe
f.Scene = scene.Bool f.Scene = scene.Bool
f.Freeleech = freeleech.Bool f.Freeleech = freeleech.Bool
f.ExternalScriptEnabled = extScriptEnabled.Bool
f.ExternalScriptCmd = extScriptCmd.String
f.ExternalScriptArgs = extScriptArgs.String
f.ExternalScriptExpectStatus = int(extScriptStatus.Int32)
f.ExternalWebhookEnabled = extWebhookEnabled.Bool
f.ExternalWebhookHost = extWebhookHost.String
f.ExternalWebhookData = extWebhookData.String
f.ExternalWebhookExpectStatus = int(extWebhookStatus.Int32)
filters = append(filters, f) filters = append(filters, f)
} }
@ -375,6 +411,14 @@ func (r *FilterRepo) Store(ctx context.Context, filter domain.Filter) (*domain.F
"has_cue", "has_cue",
"perfect_flac", "perfect_flac",
"origins", "origins",
"external_script_enabled",
"external_script_cmd",
"external_script_args",
"external_script_expect_status",
"external_webhook_enabled",
"external_webhook_host",
"external_webhook_data",
"external_webhook_expect_status",
). ).
Values( Values(
filter.Name, filter.Name,
@ -422,6 +466,14 @@ func (r *FilterRepo) Store(ctx context.Context, filter domain.Filter) (*domain.F
filter.Cue, filter.Cue,
filter.PerfectFlac, filter.PerfectFlac,
pq.Array(filter.Origins), pq.Array(filter.Origins),
filter.ExternalScriptEnabled,
filter.ExternalScriptCmd,
filter.ExternalScriptArgs,
filter.ExternalScriptExpectStatus,
filter.ExternalWebhookEnabled,
filter.ExternalWebhookHost,
filter.ExternalWebhookData,
filter.ExternalWebhookExpectStatus,
). ).
Suffix("RETURNING id").RunWith(r.db.handler) Suffix("RETURNING id").RunWith(r.db.handler)
@ -488,6 +540,14 @@ func (r *FilterRepo) Update(ctx context.Context, filter domain.Filter) (*domain.
Set("has_cue", filter.Cue). Set("has_cue", filter.Cue).
Set("perfect_flac", filter.PerfectFlac). Set("perfect_flac", filter.PerfectFlac).
Set("origins", pq.Array(filter.Origins)). Set("origins", pq.Array(filter.Origins)).
Set("external_script_enabled", filter.ExternalScriptEnabled).
Set("external_script_cmd", filter.ExternalScriptCmd).
Set("external_script_args", filter.ExternalScriptArgs).
Set("external_script_expect_status", filter.ExternalScriptExpectStatus).
Set("external_webhook_enabled", filter.ExternalWebhookEnabled).
Set("external_webhook_host", filter.ExternalWebhookHost).
Set("external_webhook_data", filter.ExternalWebhookData).
Set("external_webhook_expect_status", filter.ExternalWebhookExpectStatus).
Set("updated_at", time.Now().Format(time.RFC3339)). Set("updated_at", time.Now().Format(time.RFC3339)).
Where("id = ?", filter.ID) Where("id = ?", filter.ID)

View file

@ -107,6 +107,14 @@ CREATE TABLE filter
tags TEXT, tags TEXT,
except_tags TEXT, except_tags TEXT,
origins TEXT [] DEFAULT '{}', origins TEXT [] DEFAULT '{}',
external_script_enabled BOOLEAN DEFAULT FALSE,
external_script_cmd TEXT,
external_script_args TEXT,
external_script_expect_status INTEGER,
external_webhook_enabled BOOLEAN DEFAULT FALSE,
external_webhook_host TEXT,
external_webhook_data TEXT,
external_webhook_expect_status INTEGER,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
); );
@ -494,4 +502,29 @@ CREATE INDEX indexer_identifier_index
ALTER TABLE release_action_status ALTER TABLE release_action_status
ADD COLUMN filter TEXT; ADD COLUMN filter TEXT;
`, `,
`
ALTER TABLE filter
ADD COLUMN external_script_enabled BOOLEAN DEFAULT FALSE;
ALTER TABLE filter
ADD COLUMN external_script_cmd TEXT;
ALTER TABLE filter
ADD COLUMN external_script_args TEXT;
ALTER TABLE filter
ADD COLUMN external_script_expect_status INTEGER;
ALTER TABLE filter
ADD COLUMN external_webhook_enabled BOOLEAN DEFAULT FALSE;
ALTER TABLE filter
ADD COLUMN external_webhook_host TEXT;
ALTER TABLE filter
ADD COLUMN external_webhook_data TEXT;
ALTER TABLE filter
ADD COLUMN external_webhook_expect_status INTEGER;
`,
} }

View file

@ -107,6 +107,14 @@ CREATE TABLE filter
tags TEXT, tags TEXT,
except_tags TEXT, except_tags TEXT,
origins TEXT [] DEFAULT '{}', origins TEXT [] DEFAULT '{}',
external_script_enabled BOOLEAN DEFAULT FALSE,
external_script_cmd TEXT,
external_script_args TEXT,
external_script_expect_status INTEGER,
external_webhook_enabled BOOLEAN DEFAULT FALSE,
external_webhook_host TEXT,
external_webhook_data TEXT,
external_webhook_expect_status INTEGER,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
); );
@ -814,4 +822,29 @@ CREATE INDEX indexer_identifier_index
ALTER TABLE release_action_status ALTER TABLE release_action_status
ADD COLUMN filter TEXT; ADD COLUMN filter TEXT;
`, `,
`
ALTER TABLE filter
ADD COLUMN external_script_enabled BOOLEAN DEFAULT FALSE;
ALTER TABLE filter
ADD COLUMN external_script_cmd TEXT;
ALTER TABLE filter
ADD COLUMN external_script_args TEXT;
ALTER TABLE filter
ADD COLUMN external_script_expect_status INTEGER;
ALTER TABLE filter
ADD COLUMN external_webhook_enabled BOOLEAN DEFAULT FALSE;
ALTER TABLE filter
ADD COLUMN external_webhook_host TEXT;
ALTER TABLE filter
ADD COLUMN external_webhook_data TEXT;
ALTER TABLE filter
ADD COLUMN external_webhook_expect_status INTEGER;
`,
} }

View file

@ -101,6 +101,14 @@ type Filter struct {
ExceptTags string `json:"except_tags"` ExceptTags string `json:"except_tags"`
TagsAny string `json:"tags_any"` TagsAny string `json:"tags_any"`
ExceptTagsAny string `json:"except_tags_any"` ExceptTagsAny string `json:"except_tags_any"`
ExternalScriptEnabled bool `json:"external_script_enabled"`
ExternalScriptCmd string `json:"external_script_cmd"`
ExternalScriptArgs string `json:"external_script_args"`
ExternalScriptExpectStatus int `json:"external_script_expect_status"`
ExternalWebhookEnabled bool `json:"external_webhook_enabled"`
ExternalWebhookHost string `json:"external_webhook_host"`
ExternalWebhookData string `json:"external_webhook_data"`
ExternalWebhookExpectStatus int `json:"external_webhook_expect_status"`
Actions []*Action `json:"actions"` Actions []*Action `json:"actions"`
Indexers []Indexer `json:"indexers"` Indexers []Indexer `json:"indexers"`
Downloads *FilterDownloads `json:"-"` Downloads *FilterDownloads `json:"-"`

View file

@ -1,4 +1,4 @@
package action package domain
import ( import (
"bytes" "bytes"
@ -6,7 +6,6 @@ import (
"text/template" "text/template"
"time" "time"
"github.com/autobrr/autobrr/internal/domain"
"github.com/autobrr/autobrr/pkg/errors" "github.com/autobrr/autobrr/pkg/errors"
) )
@ -32,7 +31,7 @@ type Macro struct {
CurrentSecond int CurrentSecond int
} }
func NewMacro(release domain.Release) Macro { func NewMacro(release Release) Macro {
currentTime := time.Now() currentTime := time.Now()
ma := Macro{ ma := Macro{

View file

@ -1,12 +1,10 @@
package action package domain
import ( import (
"fmt" "fmt"
"testing" "testing"
"time" "time"
"github.com/autobrr/autobrr/internal/domain"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
) )
@ -25,14 +23,14 @@ func TestMacros_Parse(t *testing.T) {
tests := []struct { tests := []struct {
name string name string
fields fields fields fields
release domain.Release release Release
args args args args
want string want string
wantErr bool wantErr bool
}{ }{
{ {
name: "test_ok", name: "test_ok",
release: domain.Release{ release: Release{
TorrentName: "This movie 2021", TorrentName: "This movie 2021",
TorrentTmpFile: "/tmp/a-temporary-file.torrent", TorrentTmpFile: "/tmp/a-temporary-file.torrent",
Indexer: "mock1", Indexer: "mock1",
@ -43,7 +41,7 @@ func TestMacros_Parse(t *testing.T) {
}, },
{ {
name: "test_bad", name: "test_bad",
release: domain.Release{ release: Release{
TorrentName: "This movie 2021", TorrentName: "This movie 2021",
TorrentTmpFile: "/tmp/a-temporary-file.torrent", TorrentTmpFile: "/tmp/a-temporary-file.torrent",
Indexer: "mock1", Indexer: "mock1",
@ -54,7 +52,7 @@ func TestMacros_Parse(t *testing.T) {
}, },
{ {
name: "test_program_arg", name: "test_program_arg",
release: domain.Release{ release: Release{
TorrentName: "This movie 2021", TorrentName: "This movie 2021",
TorrentTmpFile: "/tmp/a-temporary-file.torrent", TorrentTmpFile: "/tmp/a-temporary-file.torrent",
Indexer: "mock1", Indexer: "mock1",
@ -65,7 +63,7 @@ func TestMacros_Parse(t *testing.T) {
}, },
{ {
name: "test_program_arg_bad", name: "test_program_arg_bad",
release: domain.Release{ release: Release{
TorrentTmpFile: "/tmp/a-temporary-file.torrent", TorrentTmpFile: "/tmp/a-temporary-file.torrent",
Indexer: "mock1", Indexer: "mock1",
}, },
@ -75,7 +73,7 @@ func TestMacros_Parse(t *testing.T) {
}, },
{ {
name: "test_program_arg", name: "test_program_arg",
release: domain.Release{ release: Release{
TorrentName: "This movie 2021", TorrentName: "This movie 2021",
TorrentTmpFile: "/tmp/a-temporary-file.torrent", TorrentTmpFile: "/tmp/a-temporary-file.torrent",
Indexer: "mock1", Indexer: "mock1",
@ -86,7 +84,7 @@ func TestMacros_Parse(t *testing.T) {
}, },
{ {
name: "test_args_long", name: "test_args_long",
release: domain.Release{ release: Release{
TorrentName: "This movie 2021", TorrentName: "This movie 2021",
TorrentURL: "https://some.site/download/fakeid", TorrentURL: "https://some.site/download/fakeid",
Indexer: "mock1", Indexer: "mock1",
@ -97,7 +95,7 @@ func TestMacros_Parse(t *testing.T) {
}, },
{ {
name: "test_args_long_1", name: "test_args_long_1",
release: domain.Release{ release: Release{
TorrentName: "This movie 2021", TorrentName: "This movie 2021",
TorrentURL: "https://some.site/download/fakeid", TorrentURL: "https://some.site/download/fakeid",
Indexer: "mock1", Indexer: "mock1",
@ -108,7 +106,7 @@ func TestMacros_Parse(t *testing.T) {
}, },
{ {
name: "test_args_category", name: "test_args_category",
release: domain.Release{ release: Release{
TorrentName: "This movie 2021", TorrentName: "This movie 2021",
TorrentURL: "https://some.site/download/fakeid", TorrentURL: "https://some.site/download/fakeid",
Indexer: "mock1", Indexer: "mock1",
@ -119,7 +117,7 @@ func TestMacros_Parse(t *testing.T) {
}, },
{ {
name: "test_args_category_year", name: "test_args_category_year",
release: domain.Release{ release: Release{
TorrentName: "This movie 2021", TorrentName: "This movie 2021",
TorrentURL: "https://some.site/download/fakeid", TorrentURL: "https://some.site/download/fakeid",
Indexer: "mock1", Indexer: "mock1",
@ -130,7 +128,7 @@ func TestMacros_Parse(t *testing.T) {
}, },
{ {
name: "test_args_category_year", name: "test_args_category_year",
release: domain.Release{ release: Release{
TorrentName: "This movie 2021", TorrentName: "This movie 2021",
TorrentURL: "https://some.site/download/fakeid", TorrentURL: "https://some.site/download/fakeid",
Indexer: "mock1", Indexer: "mock1",
@ -143,7 +141,7 @@ func TestMacros_Parse(t *testing.T) {
}, },
{ {
name: "test_args_category_and_if", name: "test_args_category_and_if",
release: domain.Release{ release: Release{
TorrentName: "This movie 2021", TorrentName: "This movie 2021",
TorrentURL: "https://some.site/download/fakeid", TorrentURL: "https://some.site/download/fakeid",
Indexer: "mock1", Indexer: "mock1",
@ -156,7 +154,7 @@ func TestMacros_Parse(t *testing.T) {
}, },
{ {
name: "test_release_year_1", name: "test_release_year_1",
release: domain.Release{ release: Release{
TorrentName: "This movie 2021", TorrentName: "This movie 2021",
TorrentURL: "https://some.site/download/fakeid", TorrentURL: "https://some.site/download/fakeid",
Indexer: "mock1", Indexer: "mock1",

View file

@ -344,6 +344,10 @@ func (r *Release) addRejection(reason string) {
r.Rejections = append(r.Rejections, reason) r.Rejections = append(r.Rejections, reason)
} }
func (r *Release) AddRejectionF(format string, v ...interface{}) {
r.addRejectionF(format, v...)
}
func (r *Release) addRejectionF(format string, v ...interface{}) { func (r *Release) addRejectionF(format string, v ...interface{}) {
r.Rejections = append(r.Rejections, fmt.Sprintf(format, v...)) r.Rejections = append(r.Rejections, fmt.Sprintf(format, v...))
} }

View file

@ -1,15 +1,22 @@
package filter package filter
import ( import (
"bytes"
"context" "context"
"errors" "crypto/tls"
"fmt" "fmt"
"net/http"
"os/exec"
"strings"
"time"
"github.com/autobrr/autobrr/internal/domain" "github.com/autobrr/autobrr/internal/domain"
"github.com/autobrr/autobrr/internal/indexer" "github.com/autobrr/autobrr/internal/indexer"
"github.com/autobrr/autobrr/internal/logger" "github.com/autobrr/autobrr/internal/logger"
"github.com/autobrr/autobrr/pkg/errors"
"github.com/dustin/go-humanize" "github.com/dustin/go-humanize"
"github.com/mattn/go-shellwords"
"github.com/rs/zerolog" "github.com/rs/zerolog"
) )
@ -269,6 +276,37 @@ func (s *service) CheckFilter(f domain.Filter, release *domain.Release) (bool, e
} }
} }
// run external script
if f.ExternalScriptEnabled && f.ExternalScriptCmd != "" {
exitCode, err := s.execCmd(release, f.ExternalScriptCmd, f.ExternalScriptArgs)
if err != nil {
s.log.Error().Err(err).Msgf("filter.Service.CheckFilter: error executing external command for filter: %+v", f.Name)
return false, err
}
if exitCode != f.ExternalScriptExpectStatus {
s.log.Trace().Msgf("filter.Service.CheckFilter: external script unexpected exit code. got: %v want: %v", exitCode, f.ExternalScriptExpectStatus)
release.AddRejectionF("external script unexpected exit code. got: %v want: %v", exitCode, f.ExternalScriptExpectStatus)
return false, nil
}
}
// run external webhook
if f.ExternalWebhookEnabled && f.ExternalWebhookHost != "" && f.ExternalWebhookData != "" {
// run external scripts
statusCode, err := s.webhook(release, f.ExternalWebhookHost, f.ExternalWebhookData)
if err != nil {
s.log.Error().Err(err).Msgf("filter.Service.CheckFilter: error executing external webhook for filter: %v", f.Name)
return false, err
}
if statusCode != f.ExternalWebhookExpectStatus {
s.log.Trace().Msgf("filter.Service.CheckFilter: external webhook unexpected status code. got: %v want: %v", statusCode, f.ExternalWebhookExpectStatus)
release.AddRejectionF("external webhook unexpected status code. got: %v want: %v", statusCode, f.ExternalWebhookExpectStatus)
return false, nil
}
}
// found matching filter, lets find the filter actions and attach // found matching filter, lets find the filter actions and attach
actions, err := s.actionRepo.FindByFilterID(context.TODO(), f.ID) actions, err := s.actionRepo.FindByFilterID(context.TODO(), f.ID)
if err != nil { if err != nil {
@ -279,7 +317,7 @@ func (s *service) CheckFilter(f domain.Filter, release *domain.Release) (bool, e
// if no actions, continue to next filter // if no actions, continue to next filter
if len(actions) == 0 { if len(actions) == 0 {
s.log.Trace().Msgf("filter.Service.CheckFilter: no actions found for filter '%v', trying next one..", f.Name) s.log.Trace().Msgf("filter.Service.CheckFilter: no actions found for filter '%v', trying next one..", f.Name)
return false, err return false, nil
} }
release.Filter.Actions = actions release.Filter.Actions = actions
@ -371,3 +409,102 @@ func checkSizeFilter(minSize string, maxSize string, releaseSize uint64) (bool,
return true, nil return true, nil
} }
func (s *service) execCmd(release *domain.Release, cmd string, args string) (int, error) {
s.log.Debug().Msgf("filter exec release: %v", release.TorrentName)
if release.TorrentTmpFile == "" && strings.Contains(args, "TorrentPathName") {
if err := release.DownloadTorrentFile(); err != nil {
return 0, errors.Wrap(err, "error downloading torrent file for release: %v", release.TorrentName)
}
}
// check if program exists
cmd, err := exec.LookPath(cmd)
if err != nil {
return 0, errors.Wrap(err, "exec failed, could not find program: %v", cmd)
}
// handle args and replace vars
m := domain.NewMacro(*release)
// parse and replace values in argument string before continuing
parsedArgs, err := m.Parse(args)
if err != nil {
return 0, errors.Wrap(err, "could not parse macro")
}
// we need to split on space into a string slice, so we can spread the args into exec
p := shellwords.NewParser()
p.ParseBacktick = true
commandArgs, err := p.Parse(parsedArgs)
if err != nil {
return 0, errors.Wrap(err, "could not parse into shell-words")
}
start := time.Now()
// setup command and args
command := exec.Command(cmd, commandArgs...)
err = command.Run()
var exitErr *exec.ExitError
if errors.As(err, &exitErr) {
s.log.Debug().Msgf("filter script command exited with non zero code: %v", exitErr.ExitCode())
return exitErr.ExitCode(), nil
}
duration := time.Since(start)
s.log.Debug().Msgf("executed external script: (%v), args: (%v) for release: (%v) indexer: (%v) total time (%v)", cmd, args, release.TorrentName, release.Indexer, duration)
return 0, nil
}
func (s *service) webhook(release *domain.Release, url string, data string) (int, error) {
if release.TorrentTmpFile == "" && strings.Contains(data, "TorrentPathName") {
if err := release.DownloadTorrentFile(); err != nil {
return 0, errors.Wrap(err, "webhook: could not download torrent file for release: %v", release.TorrentName)
}
}
m := domain.NewMacro(*release)
// parse and replace values in argument string before continuing
dataArgs, err := m.Parse(data)
if err != nil {
return 0, errors.Wrap(err, "could not parse webhook data macro: %v", data)
}
t := &http.Transport{
TLSClientConfig: &tls.Config{
InsecureSkipVerify: true,
},
}
client := http.Client{Transport: t, Timeout: 15 * time.Second}
req, err := http.NewRequest(http.MethodPost, url, bytes.NewBufferString(dataArgs))
if err != nil {
return 0, errors.Wrap(err, "could not build request for webhook")
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("User-Agent", "autobrr")
res, err := client.Do(req)
if err != nil {
return 0, errors.Wrap(err, "could not make request for webhook")
}
defer res.Body.Close()
if res.StatusCode > 299 {
return res.StatusCode, nil
}
s.log.Debug().Msgf("successfully ran external webhook filter to: (%v) payload: (%v)", url, dataArgs)
return res.StatusCode, nil
}

View file

@ -114,8 +114,6 @@ func (s *service) Process(release *domain.Release) {
release.FilterName = f.Name release.FilterName = f.Name
release.FilterID = f.ID release.FilterID = f.ID
// TODO filter limit checks
// test filter // test filter
match, err := s.filterSvc.CheckFilter(f, release) match, err := s.filterSvc.CheckFilter(f, release)
if err != nil { if err != nil {

View file

@ -13,6 +13,7 @@ interface TextFieldProps {
columns?: COL_WIDTHS; columns?: COL_WIDTHS;
autoComplete?: string; autoComplete?: string;
hidden?: boolean; hidden?: boolean;
disabled?: boolean;
} }
export const TextField = ({ export const TextField = ({
@ -22,7 +23,8 @@ export const TextField = ({
placeholder, placeholder,
columns, columns,
autoComplete, autoComplete,
hidden hidden,
disabled,
}: TextFieldProps) => ( }: TextFieldProps) => (
<div <div
className={classNames( className={classNames(
@ -47,7 +49,12 @@ export const TextField = ({
type="text" type="text"
defaultValue={defaultValue} defaultValue={defaultValue}
autoComplete={autoComplete} autoComplete={autoComplete}
className={classNames(meta.touched && meta.error ? "focus:ring-red-500 focus:border-red-500 border-red-500" : "focus:ring-indigo-500 dark:focus:ring-blue-500 focus:border-indigo-500 dark:focus:border-blue-500 border-gray-300 dark:border-gray-700", "mt-2 block w-full dark:bg-gray-800 dark:text-gray-100 rounded-md")} className={classNames(
meta.touched && meta.error ? "focus:ring-red-500 focus:border-red-500 border-red-500" : "focus:ring-indigo-500 dark:focus:ring-blue-500 focus:border-indigo-500 dark:focus:border-blue-500 border-gray-300 dark:border-gray-700",
disabled ? "bg-gray-100 dark:bg-gray-700 cursor-not-allowed" : "dark:bg-gray-800",
"mt-2 block w-full dark:text-gray-100 rounded-md",
)}
disabled={disabled}
placeholder={placeholder} placeholder={placeholder}
/> />
@ -60,6 +67,70 @@ export const TextField = ({
</div> </div>
); );
interface TextAreaProps {
name: string;
defaultValue?: string;
label?: string;
placeholder?: string;
columns?: COL_WIDTHS;
rows?: number;
autoComplete?: string;
hidden?: boolean;
disabled?: boolean;
}
export const TextArea = ({
name,
defaultValue,
label,
placeholder,
columns,
rows,
autoComplete,
hidden,
disabled,
}: TextAreaProps) => (
<div
className={classNames(
hidden ? "hidden" : "",
columns ? `col-span-${columns}` : "col-span-12"
)}
>
{label && (
<label htmlFor={name} className="block text-xs font-bold text-gray-700 dark:text-gray-200 uppercase tracking-wide">
{label}
</label>
)}
<Field name={name}>
{({
field,
meta
}: FieldProps) => (
<div>
<textarea
{...field}
id={name}
rows={rows}
defaultValue={defaultValue}
autoComplete={autoComplete}
className={classNames(
meta.touched && meta.error ? "focus:ring-red-500 focus:border-red-500 border-red-500" : "focus:ring-indigo-500 dark:focus:ring-blue-500 focus:border-indigo-500 dark:focus:border-blue-500 border-gray-300 dark:border-gray-700",
disabled ? "bg-gray-100 dark:bg-gray-700 cursor-not-allowed" : "dark:bg-gray-800",
"mt-2 block w-full dark:text-gray-100 rounded-md",
)}
placeholder={placeholder}
disabled={disabled}
/>
{meta.touched && meta.error && (
<p className="error text-sm text-red-600 mt-1">* {meta.error}</p>
)}
</div>
)}
</Field>
</div>
);
interface PasswordFieldProps { interface PasswordFieldProps {
name: string; name: string;
label?: string; label?: string;
@ -132,13 +203,15 @@ interface NumberFieldProps {
label?: string; label?: string;
placeholder?: string; placeholder?: string;
step?: number; step?: number;
disabled?: boolean;
} }
export const NumberField = ({ export const NumberField = ({
name, name,
label, label,
placeholder, placeholder,
step step,
disabled,
}: NumberFieldProps) => ( }: NumberFieldProps) => (
<div className="col-span-12 sm:col-span-6"> <div className="col-span-12 sm:col-span-6">
<label htmlFor={name} className="block text-xs font-bold text-gray-700 dark:text-gray-200 uppercase tracking-wide"> <label htmlFor={name} className="block text-xs font-bold text-gray-700 dark:text-gray-200 uppercase tracking-wide">
@ -159,9 +232,11 @@ export const NumberField = ({
meta.touched && meta.error meta.touched && meta.error
? "focus:ring-red-500 focus:border-red-500 border-red-500" ? "focus:ring-red-500 focus:border-red-500 border-red-500"
: "focus:ring-indigo-500 dark:focus:ring-blue-500 focus:border-indigo-500 dark:focus:border-blue-500 border-gray-300", : "focus:ring-indigo-500 dark:focus:ring-blue-500 focus:border-indigo-500 dark:focus:border-blue-500 border-gray-300",
"mt-2 block w-full dark:bg-gray-800 border border-gray-300 dark:border-gray-700 dark:text-gray-100 rounded-md" "mt-2 block w-full border border-gray-300 dark:border-gray-700 dark:text-gray-100 rounded-md",
disabled ? "bg-gray-100 dark:bg-gray-700 cursor-not-allowed" : "dark:bg-gray-800",
)} )}
placeholder={placeholder} placeholder={placeholder}
disabled={disabled}
/> />
{meta.touched && meta.error && ( {meta.touched && meta.error && (
<div className="error">{meta.error}</div> <div className="error">{meta.error}</div>

View file

@ -74,16 +74,18 @@ interface SwitchGroupProps {
label?: string; label?: string;
description?: string; description?: string;
className?: string; className?: string;
heading?: boolean;
} }
const SwitchGroup = ({ const SwitchGroup = ({
name, name,
label, label,
description description,
heading,
}: SwitchGroupProps) => ( }: SwitchGroupProps) => (
<HeadlessSwitch.Group as="ol" className="py-4 flex items-center justify-between"> <HeadlessSwitch.Group as="ol" className="py-4 flex items-center justify-between">
{label && <div className="flex flex-col"> {label && <div className="flex flex-col">
<HeadlessSwitch.Label as="p" className="text-sm font-medium text-gray-900 dark:text-gray-100" <HeadlessSwitch.Label as={heading ? "h2" : "p"} className={classNames("font-medium text-gray-900 dark:text-gray-100", heading ? "text-lg" : "text-sm")}
passive> passive>
{label} {label}
</HeadlessSwitch.Label> </HeadlessSwitch.Label>

View file

@ -9,7 +9,7 @@ import {
useParams useParams
} from "react-router-dom"; } from "react-router-dom";
import { toast } from "react-hot-toast"; import { toast } from "react-hot-toast";
import { Field, FieldArray, FieldProps, Form, Formik, FormikValues } from "formik"; import { Field, FieldArray, FieldProps, Form, Formik, FormikValues, useFormikContext } from "formik";
import { Dialog, Transition, Switch as SwitchBasic } from "@headlessui/react"; import { Dialog, Transition, Switch as SwitchBasic } from "@headlessui/react";
import { ChevronDownIcon, ChevronRightIcon } from "@heroicons/react/solid"; import { ChevronDownIcon, ChevronRightIcon } from "@heroicons/react/solid";
@ -50,6 +50,7 @@ import { AlertWarning } from "../../components/alerts";
import { DeleteModal } from "../../components/modals"; import { DeleteModal } from "../../components/modals";
import { TitleSubtitle } from "../../components/headings"; import { TitleSubtitle } from "../../components/headings";
import { EmptyListState } from "../../components/emptystates"; import { EmptyListState } from "../../components/emptystates";
import { TextArea } from "../../components/inputs/input";
interface tabType { interface tabType {
name: string; name: string;
@ -61,6 +62,7 @@ const tabs: tabType[] = [
{ name: "Movies and TV", href: "movies-tv" }, { name: "Movies and TV", href: "movies-tv" },
{ name: "Music", href: "music" }, { name: "Music", href: "music" },
{ name: "Advanced", href: "advanced" }, { name: "Advanced", href: "advanced" },
{ name: "External", href: "external" },
{ name: "Actions", href: "actions" } { name: "Actions", href: "actions" }
]; ];
@ -280,7 +282,15 @@ export default function FilterDetails() {
albums: filter.albums, albums: filter.albums,
origins: filter.origins || [], origins: filter.origins || [],
indexers: filter.indexers || [], indexers: filter.indexers || [],
actions: filter.actions || [] actions: filter.actions || [],
external_script_enabled: filter.external_script_enabled || false,
external_script_cmd: filter.external_script_cmd || "",
external_script_args: filter.external_script_args || "",
external_script_expect_status: filter.external_script_expect_status || 0,
external_webhook_enabled: filter.external_webhook_enabled || false,
external_webhook_host: filter.external_webhook_host || "",
external_webhook_data: filter.external_webhook_data ||"",
external_webhook_expect_status: filter.external_webhook_expect_status || 0,
} as Filter} } as Filter}
onSubmit={handleSubmit} onSubmit={handleSubmit}
> >
@ -291,6 +301,7 @@ export default function FilterDetails() {
<Route path="movies-tv" element={<MoviesTv />} /> <Route path="movies-tv" element={<MoviesTv />} />
<Route path="music" element={<Music />} /> <Route path="music" element={<Music />} />
<Route path="advanced" element={<Advanced />} /> <Route path="advanced" element={<Advanced />} />
<Route path="external" element={<External />} />
<Route path="actions" element={<FilterActions filter={filter} values={values} />} <Route path="actions" element={<FilterActions filter={filter} values={values} />}
/> />
</Routes> </Routes>
@ -527,6 +538,75 @@ function CollapsableSection({ title, subtitle, children }: CollapsableSectionPro
); );
} }
export function External() {
const { values } = useFormikContext<Filter>();
return (
<div>
<div className="mt-6">
<SwitchGroup name="external_script_enabled" heading={true} label="Script" description="Run external script and check status as part of filtering" />
<div className="mt-6 grid grid-cols-12 gap-6">
<TextField
name="external_script_cmd"
label="Command"
columns={6}
placeholder="Path to program eg. /bin/test"
disabled={!values.external_script_enabled}
/>
<TextField
name="external_script_args"
label="Arguments"
columns={6}
placeholder="Arguments eg. --test"
disabled={!values.external_script_enabled}
/>
<NumberField
name="external_script_expect_status"
label="Expected exit status"
placeholder="0"
disabled={!values.external_script_enabled}
/>
</div>
</div>
<div className="mt-6">
<div className="border-t dark:border-gray-700">
<SwitchGroup name="external_webhook_enabled" heading={true} label="Webhook" description="Run external webhook and check status as part of filtering" />
</div>
<div className="mt-6 grid grid-cols-12 gap-6">
<div className="grid col-span-6 gap-6">
<TextField
name="external_webhook_host"
label="Host"
columns={6}
placeholder="Host eg. http://localhost/webhook"
disabled={!values.external_webhook_enabled}
/>
<NumberField
name="external_webhook_expect_status"
label="Expected http status"
placeholder="200"
disabled={!values.external_webhook_enabled}
/>
</div>
<TextArea
name="external_webhook_data"
label="Data (json)"
columns={6}
rows={5}
placeholder={"{ \"key\": \"value\" }"}
disabled={!values.external_webhook_enabled}
/>
</div>
</div>
</div>
);
}
interface FilterActionsProps { interface FilterActionsProps {
filter: Filter; filter: Filter;
values: FormikValues; values: FormikValues;

View file

@ -52,6 +52,14 @@ interface Filter {
except_tags_any: string; except_tags_any: string;
actions: Action[]; actions: Action[];
indexers: Indexer[]; indexers: Indexer[];
external_script_enabled: boolean;
external_script_cmd: string;
external_script_args: string;
external_script_expect_status: number;
external_webhook_enabled: boolean;
external_webhook_host: string;
external_webhook_data: string;
external_webhook_expect_status: number;
} }
interface Action { interface Action {