diff --git a/README.md b/README.md new file mode 100644 index 0000000..f21bd2b --- /dev/null +++ b/README.md @@ -0,0 +1,25 @@ +# QBitRun + +Runner application for qBittorrent. + +--- + +# Anything Below This Line is not ready!! + +--- + +## Quick Start + +Start by copying the following command into the "Run external program on torrent completion" field in qBittorrent `Settings > Downloads`. + +```sh +/path/to/qbitrun handle -N "%N" -L "%L" -G "%G" -F "%F" -R "%R" -D "%D" -C "%C" -Z "%Z" -T "%T" -I "%I" -J "%J" -K "%K" +``` + +If wanting to handle "Run external program on torrent torrent added", add `--add-event` (or `-a` for short) to the command. This is for the qbitrun and you to be able to distinguish whether this run is triggered by "torrent added" event. + +```sh +/path/to/qbitrun handle --add-event -N "%N" -L "%L" -G "%G" -F "%F" -R "%R" -D "%D" -C "%C" -Z "%Z" -T "%T" -I "%I" -J "%J" -K "%K" +``` + +You can filter out which events to handle later in the runner .yaml files. diff --git a/cmd/qbitrun/cli/handle.go b/cmd/qbitrun/cli/handle.go index 5ad9b53..62273d5 100644 --- a/cmd/qbitrun/cli/handle.go +++ b/cmd/qbitrun/cli/handle.go @@ -1,24 +1,11 @@ package cli -import "github.com/spf13/cobra" +import ( + "github.com/spf13/cobra" + "github.com/tigorlazuardi/qbitrun/lib/qbitrun" +) -type handleArgsS struct { - torrentName string - category string - tags []string - contentPath string - rootPath string - savePath string - numberOfFiles int - torrentSize uint64 - currentTracker string - infoHashV1 string - infoHashV2 string - torrentID string - addEvent bool -} - -var handleArgs = handleArgsS{} +var handleArgs = qbitrun.RunnerContext{} var handleCmd = &cobra.Command{ Use: "handle", @@ -30,43 +17,43 @@ var handleCmd = &cobra.Command{ func init() { flags := handleCmd.Flags() - flags.BoolVarP(&handleArgs.addEvent, "add-event", "a", false, "mark this event as add torrent event") + flags.BoolVarP(&handleArgs.AddEvent, "add-event", "a", false, "mark this event as add torrent event") - flags.StringVarP(&handleArgs.torrentName, "torrent-name", "N", "", "Torrent Name") - handleCmd.MarkFlagRequired("torrent-name") + flags.StringVarP(&handleArgs.TorrentName, "torrent-name", "N", "", "Torrent Name") + _ = handleCmd.MarkFlagRequired("torrent-name") - flags.StringVarP(&handleArgs.category, "category", "L", "", "Category") - handleCmd.MarkFlagRequired("category") + flags.StringVarP(&handleArgs.Category, "category", "L", "", "Category") + _ = handleCmd.MarkFlagRequired("category") - flags.StringSliceVarP(&handleArgs.tags, "tags", "G", []string{}, "Tags (seperated by comma)") - handleCmd.MarkFlagRequired("tags") + flags.StringSliceVarP(&handleArgs.Tags, "tags", "G", []string{}, "Tags (seperated by comma)") + _ = handleCmd.MarkFlagRequired("tags") - flags.StringVarP(&handleArgs.contentPath, "content-path", "F", "", "Content Path (same as root path for multifile torrent") - handleCmd.MarkFlagRequired("content-path") + flags.StringVarP(&handleArgs.ContentPath, "content-path", "F", "", "Content Path (same as root path for multifile torrent)") + _ = handleCmd.MarkFlagRequired("content-path") - flags.StringVarP(&handleArgs.rootPath, "root-path", "R", "", "Root path (first torrent subdirectory path)") - handleCmd.MarkFlagRequired("root-path") + flags.StringVarP(&handleArgs.RootPath, "root-path", "R", "", "Root path (first torrent subdirectory path)") + _ = handleCmd.MarkFlagRequired("root-path") - flags.StringVarP(&handleArgs.savePath, "save-path", "D", "", "Save path") - handleCmd.MarkFlagRequired("save-path") + flags.StringVarP(&handleArgs.SavePath, "save-path", "D", "", "Save path") + _ = handleCmd.MarkFlagRequired("save-path") - flags.IntVarP(&handleArgs.numberOfFiles, "number-of-files", "C", 0, "Number of files") - handleCmd.MarkFlagRequired("number-of-files") + flags.IntVarP(&handleArgs.NumberOfFiles, "number-of-files", "C", 0, "Number of files") + _ = handleCmd.MarkFlagRequired("number-of-files") - flags.Uint64VarP(&handleArgs.torrentSize, "torrent-size", "Z", 0, "Torrent size") - handleCmd.MarkFlagRequired("torrent-size") + flags.Uint64VarP(&handleArgs.TorrentSize, "torrent-size", "Z", 0, "Torrent size") + _ = handleCmd.MarkFlagRequired("torrent-size") - flags.StringVarP(&handleArgs.currentTracker, "current-tracker", "T", "", "Current tracker") - handleCmd.MarkFlagRequired("current-tracker") + flags.StringVarP(&handleArgs.CurrentTracker, "current-tracker", "T", "", "Current tracker") + _ = handleCmd.MarkFlagRequired("current-tracker") - flags.StringVarP(&handleArgs.infoHashV1, "info-hash-v1", "I", "", "Info hash v1") - handleCmd.MarkFlagRequired("info-hash-v1") + flags.StringVarP(&handleArgs.InfoHashV1, "info-hash-v1", "I", "", "Info hash v1") + _ = handleCmd.MarkFlagRequired("info-hash-v1") - flags.StringVarP(&handleArgs.infoHashV2, "info-hash-v2", "J", "", "Info hash v2") - handleCmd.MarkFlagRequired("info-hash-v2") + flags.StringVarP(&handleArgs.InfoHashV2, "info-hash-v2", "J", "", "Info hash v2") + _ = handleCmd.MarkFlagRequired("info-hash-v2") - flags.StringVarP(&handleArgs.torrentID, "torrent-id", "K", "", "Torrent ID") - handleCmd.MarkFlagRequired("torrent-id") + flags.StringVarP(&handleArgs.TorrentID, "torrent-id", "K", "", "Torrent ID") + _ = handleCmd.MarkFlagRequired("torrent-id") RootCmd.AddCommand(handleCmd) } diff --git a/go.mod b/go.mod index c4925ce..e8dc3c9 100644 --- a/go.mod +++ b/go.mod @@ -3,7 +3,11 @@ module github.com/tigorlazuardi/qbitrun go 1.22.3 require ( + github.com/davecgh/go-spew v1.1.1 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect github.com/spf13/cobra v1.8.1 // indirect github.com/spf13/pflag v1.0.5 // indirect + github.com/stretchr/testify v1.9.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index 912390a..c7da2f0 100644 --- a/go.sum +++ b/go.sum @@ -1,10 +1,17 @@ github.com/cpuguy83/go-md2man/v2 v2.0.4/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/spf13/cobra v1.8.1 h1:e5/vxKd/rZsfSJMUX1agtjeTDf+qv1/JdBF8gg5k9ZM= github.com/spf13/cobra v1.8.1/go.mod h1:wHxEcudfqmLYa8iTfL+OuZPbBZkmvliBWKIezN3kD9Y= github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= +github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/lib/qbitrun/qbitrun.go b/lib/qbitrun/qbitrun.go new file mode 100644 index 0000000..3cec9c9 --- /dev/null +++ b/lib/qbitrun/qbitrun.go @@ -0,0 +1,37 @@ +package qbitrun + +type QBitRun struct { + context RunnerContext + workflow WorkflowDir +} + +// New creates a QBitRun instance intended for single session. +// +// QBitRun assumes ownership of the WorkflowDir and will close all files +// when the session is done. +// +// Users must not use or modify the []io.ReadCloser in the WorkflowDir after +// calling this function, especially if the WorkflowDir is created using +// WorkflowFromReadClosers or WorkflowFromReaders. +func New(workflows WorkflowDir, ctx RunnerContext) *QBitRun { + return &QBitRun{ + context: ctx, + workflow: workflows, + } +} + +type RunnerContext struct { + TorrentName string + Category string + Tags []string + ContentPath string + RootPath string + SavePath string + NumberOfFiles int + TorrentSize uint64 + CurrentTracker string + InfoHashV1 string + InfoHashV2 string + TorrentID string + AddEvent bool +} diff --git a/lib/qbitrun/workflow.go b/lib/qbitrun/workflow.go new file mode 100644 index 0000000..a600ace --- /dev/null +++ b/lib/qbitrun/workflow.go @@ -0,0 +1,74 @@ +package qbitrun + +import ( + "io" + "io/fs" + "os" + "path/filepath" +) + +type WorkflowDir interface { + Files() []io.ReadCloser +} + +type workflowFiles []io.ReadCloser + +func (wf workflowFiles) Files() []io.ReadCloser { + return wf +} + +// OpenDir opens a directory and returns a WorkflowDir. +// +// Set filter to filter out unwanted files. +func OpenDir(dir string, filters ...func(path string, d fs.FileInfo) bool) (WorkflowDir, error) { + var files []io.ReadCloser + err := filepath.Walk(dir, func(path string, info fs.FileInfo, err error) error { + if err != nil { + return err + } + if info.IsDir() { + return nil + } + ok := true + for _, filter := range filters { + ok = filter(path, info) + if !ok { + break + } + } + if ok { + file, err := os.Open(path) + if err != nil { + return err + } + files = append(files, file) + } + return nil + }) + if err != nil { + return nil, err + } + + return workflowFiles(files), nil +} + +// WorkflowFromReadClosers creates a WorkflowDir from a list of io.ReadCloser. +func WorkflowFromReadClosers(rcs ...io.ReadCloser) WorkflowDir { + return workflowFiles(rcs) +} + +// WorkflowFromReaders creates a WorkflowDir from a list of io.Reader. +// +// If the Reader implements io.ReadCloser, it will be used as is. +// If not, it will be wrapped with io.NopCloser. +func WorkflowFromReaders(readers ...io.Reader) WorkflowDir { + var rcs []io.ReadCloser + for _, r := range readers { + if rc, ok := r.(io.ReadCloser); ok { + rcs = append(rcs, rc) + } else { + rcs = append(rcs, io.NopCloser(r)) + } + } + return workflowFiles(rcs) +} diff --git a/lib/workflow/job.go b/lib/workflow/job.go new file mode 100644 index 0000000..01a4c55 --- /dev/null +++ b/lib/workflow/job.go @@ -0,0 +1,59 @@ +package workflow + +import ( + "fmt" + "strings" + + "gopkg.in/yaml.v3" +) + +type Job struct { + After []string `yaml:"after"` + Steps []Step `yaml:"steps"` + // Error is the action taken when a step fails. + Error ErrorAction `yaml:"error"` +} + +type ErrorAction string + +func (ea *ErrorAction) UnmarshalYAML(value *yaml.Node) error { + if value.IsZero() { + *ea = ErrorActionStopJob + return nil + } + var s string + if err := value.Decode(&s); err != nil { + return err + } + trimmed := strings.TrimSpace(s) + lowered := strings.ToLower(trimmed) + switch lowered { + case "continue": + *ea = ErrorActionContinue + case "stop-job": + *ea = ErrorActionStopJob + case "stop-action": + *ea = ErrorActionStopAction + case "stop-run": + *ea = ErrorActionStopRun + default: + return &yaml.TypeError{ + Errors: []string{fmt.Sprintf("invalid job error action: %q", trimmed)}, + } + } + return nil +} + +func (ea ErrorAction) String() string { + if ea == ErrorActionStopJob { + return "stop-job" + } + return string(ea) +} + +const ( + ErrorActionStopJob ErrorAction = "" // default case + ErrorActionContinue ErrorAction = "continue" + ErrorActionStopAction ErrorAction = "stop-action" + ErrorActionStopRun ErrorAction = "stop-run" +) diff --git a/lib/workflow/job_test.go b/lib/workflow/job_test.go new file mode 100644 index 0000000..107dd02 --- /dev/null +++ b/lib/workflow/job_test.go @@ -0,0 +1,70 @@ +package workflow + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "gopkg.in/yaml.v3" +) + +func TestErrorAction_UnmarshalYAML(t *testing.T) { + tests := []struct { + name string + input []byte + want ErrorAction + }{ + { + name: "continue", + input: []byte(`E: ConTinue`), + want: ErrorActionContinue, + }, + { + name: "stop-job", + input: []byte(`E: stop-job`), + want: ErrorActionStopJob, + }, + { + name: "stop-job", + input: []byte(`E: stop-job`), + want: ErrorActionStopJob, + }, + { + name: "null is stop-job", + input: []byte(`E: null`), + want: ErrorActionStopJob, + }, + { + name: "unset is stop-job", + input: []byte(`E: `), + want: ErrorActionStopJob, + }, + { + name: "unset is stop-job 2", + input: []byte(`foo: bar`), + want: ErrorActionStopJob, + }, + { + name: "stop-action", + input: []byte(`E: stop-action`), + want: ErrorActionStopAction, + }, + { + name: "stop-run", + input: []byte(`E: stop-run`), + want: ErrorActionStopRun, + }, + } + type placeholder struct { + E ErrorAction `yaml:"E"` + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var placeholder placeholder + err := yaml.Unmarshal(tt.input, &placeholder) + if !assert.NoError(t, err) { + return + } + assert.Equal(t, tt.want, placeholder.E) + }) + } +} diff --git a/lib/workflow/sequence.go b/lib/workflow/sequence.go new file mode 100644 index 0000000..996abb7 --- /dev/null +++ b/lib/workflow/sequence.go @@ -0,0 +1,31 @@ +package workflow + +import ( + "sync" + "time" +) + +// Sequencer handles sequencing of jobs. +// +// It handles the Lifecycle of a Workflow. +// +// When Jobs have no dependencies (defined via After), they are run in parallel. +// +// Sequencer's instance job is to keep track of the order of Jobs and Steps. NOT RUNNING THEM. +// +// Sequencer is thread-safe. +type Sequencer struct { + queued Jobs + finished Jobs + + reports []SequenceReport + + mu sync.Mutex +} + +type SequenceReport struct { + started time.Time + end time.Time + job Job + err error +} diff --git a/lib/workflow/workflow.go b/lib/workflow/workflow.go new file mode 100644 index 0000000..59571e9 --- /dev/null +++ b/lib/workflow/workflow.go @@ -0,0 +1,15 @@ +package workflow + +type Workflow struct { + Name string `yaml:"name"` + Disabled bool `yaml:"disabled"` + When string `yaml:"when"` + Jobs Jobs `yaml:"jobs"` +} + +type Jobs map[string]Job + +type Step struct { + Name string `yaml:"name"` + Run string `yaml:"run"` +}