[wip]: starting to work on the runner application

This commit is contained in:
Tigor Hutasuhut 2024-06-25 16:50:13 +07:00
parent 352810c309
commit b21b352923
10 changed files with 352 additions and 43 deletions

25
README.md Normal file
View file

@ -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.

View file

@ -1,24 +1,11 @@
package cli package cli
import "github.com/spf13/cobra" import (
"github.com/spf13/cobra"
"github.com/tigorlazuardi/qbitrun/lib/qbitrun"
)
type handleArgsS struct { var handleArgs = qbitrun.RunnerContext{}
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 handleCmd = &cobra.Command{ var handleCmd = &cobra.Command{
Use: "handle", Use: "handle",
@ -30,43 +17,43 @@ var handleCmd = &cobra.Command{
func init() { func init() {
flags := handleCmd.Flags() 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") flags.StringVarP(&handleArgs.TorrentName, "torrent-name", "N", "", "Torrent Name")
handleCmd.MarkFlagRequired("torrent-name") _ = handleCmd.MarkFlagRequired("torrent-name")
flags.StringVarP(&handleArgs.category, "category", "L", "", "Category") flags.StringVarP(&handleArgs.Category, "category", "L", "", "Category")
handleCmd.MarkFlagRequired("category") _ = handleCmd.MarkFlagRequired("category")
flags.StringSliceVarP(&handleArgs.tags, "tags", "G", []string{}, "Tags (seperated by comma)") flags.StringSliceVarP(&handleArgs.Tags, "tags", "G", []string{}, "Tags (seperated by comma)")
handleCmd.MarkFlagRequired("tags") _ = handleCmd.MarkFlagRequired("tags")
flags.StringVarP(&handleArgs.contentPath, "content-path", "F", "", "Content Path (same as root path for multifile torrent") flags.StringVarP(&handleArgs.ContentPath, "content-path", "F", "", "Content Path (same as root path for multifile torrent)")
handleCmd.MarkFlagRequired("content-path") _ = handleCmd.MarkFlagRequired("content-path")
flags.StringVarP(&handleArgs.rootPath, "root-path", "R", "", "Root path (first torrent subdirectory path)") flags.StringVarP(&handleArgs.RootPath, "root-path", "R", "", "Root path (first torrent subdirectory path)")
handleCmd.MarkFlagRequired("root-path") _ = handleCmd.MarkFlagRequired("root-path")
flags.StringVarP(&handleArgs.savePath, "save-path", "D", "", "Save path") flags.StringVarP(&handleArgs.SavePath, "save-path", "D", "", "Save path")
handleCmd.MarkFlagRequired("save-path") _ = handleCmd.MarkFlagRequired("save-path")
flags.IntVarP(&handleArgs.numberOfFiles, "number-of-files", "C", 0, "Number of files") flags.IntVarP(&handleArgs.NumberOfFiles, "number-of-files", "C", 0, "Number of files")
handleCmd.MarkFlagRequired("number-of-files") _ = handleCmd.MarkFlagRequired("number-of-files")
flags.Uint64VarP(&handleArgs.torrentSize, "torrent-size", "Z", 0, "Torrent size") flags.Uint64VarP(&handleArgs.TorrentSize, "torrent-size", "Z", 0, "Torrent size")
handleCmd.MarkFlagRequired("torrent-size") _ = handleCmd.MarkFlagRequired("torrent-size")
flags.StringVarP(&handleArgs.currentTracker, "current-tracker", "T", "", "Current tracker") flags.StringVarP(&handleArgs.CurrentTracker, "current-tracker", "T", "", "Current tracker")
handleCmd.MarkFlagRequired("current-tracker") _ = handleCmd.MarkFlagRequired("current-tracker")
flags.StringVarP(&handleArgs.infoHashV1, "info-hash-v1", "I", "", "Info hash v1") flags.StringVarP(&handleArgs.InfoHashV1, "info-hash-v1", "I", "", "Info hash v1")
handleCmd.MarkFlagRequired("info-hash-v1") _ = handleCmd.MarkFlagRequired("info-hash-v1")
flags.StringVarP(&handleArgs.infoHashV2, "info-hash-v2", "J", "", "Info hash v2") flags.StringVarP(&handleArgs.InfoHashV2, "info-hash-v2", "J", "", "Info hash v2")
handleCmd.MarkFlagRequired("info-hash-v2") _ = handleCmd.MarkFlagRequired("info-hash-v2")
flags.StringVarP(&handleArgs.torrentID, "torrent-id", "K", "", "Torrent ID") flags.StringVarP(&handleArgs.TorrentID, "torrent-id", "K", "", "Torrent ID")
handleCmd.MarkFlagRequired("torrent-id") _ = handleCmd.MarkFlagRequired("torrent-id")
RootCmd.AddCommand(handleCmd) RootCmd.AddCommand(handleCmd)
} }

4
go.mod
View file

@ -3,7 +3,11 @@ module github.com/tigorlazuardi/qbitrun
go 1.22.3 go 1.22.3
require ( require (
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // 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/cobra v1.8.1 // indirect
github.com/spf13/pflag v1.0.5 // 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
) )

7
go.sum
View file

@ -1,10 +1,17 @@
github.com/cpuguy83/go-md2man/v2 v2.0.4/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= 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 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= 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/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 h1:e5/vxKd/rZsfSJMUX1agtjeTDf+qv1/JdBF8gg5k9ZM=
github.com/spf13/cobra v1.8.1/go.mod h1:wHxEcudfqmLYa8iTfL+OuZPbBZkmvliBWKIezN3kD9Y= 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 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=
github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= 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/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= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

37
lib/qbitrun/qbitrun.go Normal file
View file

@ -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
}

74
lib/qbitrun/workflow.go Normal file
View file

@ -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)
}

59
lib/workflow/job.go Normal file
View file

@ -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"
)

70
lib/workflow/job_test.go Normal file
View file

@ -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)
})
}
}

31
lib/workflow/sequence.go Normal file
View file

@ -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
}

15
lib/workflow/workflow.go Normal file
View file

@ -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"`
}