Skip to content

Commit 1b1631b

Browse files
authored
feat: support post-task script hooks (#1265)
1 parent 3eb6df5 commit 1b1631b

20 files changed

Lines changed: 911 additions & 0 deletions

pkg/base/model.go

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -184,6 +184,7 @@ type DownloaderStoreConfig struct {
184184
Extra map[string]any `json:"extra"`
185185
Proxy *DownloaderProxyConfig `json:"proxy"`
186186
Webhook *WebhookConfig `json:"webhook"` // Webhook is the webhook configuration
187+
Script *ScriptConfig `json:"script"` // Script is the script execution configuration
187188
AutoTorrent *AutoTorrentConfig `json:"autoTorrent"` // AutoTorrent is the auto torrent task creation configuration
188189
Archive *ArchiveConfig `json:"archive"` // Archive is the archive extraction configuration
189190
AutoDeleteMissingFileTasks bool `json:"autoDeleteMissingFileTasks"` // AutoDeleteMissingFileTasks enables automatic deletion of tasks with missing files
@@ -202,6 +203,9 @@ func (cfg *DownloaderStoreConfig) Init() *DownloaderStoreConfig {
202203
if cfg.Webhook == nil {
203204
cfg.Webhook = &WebhookConfig{}
204205
}
206+
if cfg.Script == nil {
207+
cfg.Script = &ScriptConfig{}
208+
}
205209
if cfg.AutoTorrent == nil {
206210
cfg.AutoTorrent = &AutoTorrentConfig{
207211
Enable: false,
@@ -239,6 +243,9 @@ func (cfg *DownloaderStoreConfig) Merge(beforeCfg *DownloaderStoreConfig) *Downl
239243
if cfg.Webhook == nil {
240244
cfg.Webhook = beforeCfg.Webhook
241245
}
246+
if cfg.Script == nil {
247+
cfg.Script = beforeCfg.Script
248+
}
242249
if cfg.AutoTorrent == nil {
243250
cfg.AutoTorrent = beforeCfg.AutoTorrent
244251
}
@@ -254,6 +261,12 @@ type WebhookConfig struct {
254261
URLs []string `json:"urls"` // URLs is the list of webhook URLs
255262
}
256263

264+
// ScriptConfig is the script execution configuration
265+
type ScriptConfig struct {
266+
Enable bool `json:"enable"` // Enable is the flag to enable/disable script execution
267+
Paths []string `json:"paths"` // Paths is the list of script paths to execute
268+
}
269+
257270
// AutoTorrentConfig is the auto torrent task creation configuration
258271
type AutoTorrentConfig struct {
259272
Enable bool `json:"enable"` // Enable enables automatic BT task creation when downloading .torrent files

pkg/base/model_test.go

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ func TestDownloaderStoreConfig_Init(t *testing.T) {
1919
ProtocolConfig: map[string]any{},
2020
Proxy: &DownloaderProxyConfig{},
2121
Webhook: &WebhookConfig{},
22+
Script: &ScriptConfig{},
2223
AutoTorrent: &AutoTorrentConfig{
2324
Enable: false,
2425
DeleteAfterDownload: false,
@@ -39,6 +40,7 @@ func TestDownloaderStoreConfig_Init(t *testing.T) {
3940
ProtocolConfig: map[string]any{},
4041
Proxy: &DownloaderProxyConfig{},
4142
Webhook: &WebhookConfig{},
43+
Script: &ScriptConfig{},
4244
AutoTorrent: &AutoTorrentConfig{
4345
Enable: false,
4446
DeleteAfterDownload: false,
@@ -63,6 +65,7 @@ func TestDownloaderStoreConfig_Init(t *testing.T) {
6365
},
6466
Proxy: &DownloaderProxyConfig{},
6567
Webhook: &WebhookConfig{},
68+
Script: &ScriptConfig{},
6669
AutoTorrent: &AutoTorrentConfig{
6770
Enable: false,
6871
DeleteAfterDownload: false,
@@ -87,6 +90,7 @@ func TestDownloaderStoreConfig_Init(t *testing.T) {
8790
Enable: true,
8891
},
8992
Webhook: &WebhookConfig{},
93+
Script: &ScriptConfig{},
9094
AutoTorrent: &AutoTorrentConfig{
9195
Enable: false,
9296
DeleteAfterDownload: false,
@@ -110,6 +114,7 @@ func TestDownloaderStoreConfig_Init(t *testing.T) {
110114
ProtocolConfig: map[string]any{},
111115
Proxy: &DownloaderProxyConfig{},
112116
Webhook: &WebhookConfig{},
117+
Script: &ScriptConfig{},
113118
AutoTorrent: &AutoTorrentConfig{
114119
Enable: true,
115120
DeleteAfterDownload: true,
@@ -133,6 +138,7 @@ func TestDownloaderStoreConfig_Init(t *testing.T) {
133138
ProtocolConfig: map[string]any{},
134139
Proxy: &DownloaderProxyConfig{},
135140
Webhook: &WebhookConfig{},
141+
Script: &ScriptConfig{},
136142
AutoTorrent: &AutoTorrentConfig{
137143
Enable: false,
138144
DeleteAfterDownload: false,
@@ -154,6 +160,7 @@ func TestDownloaderStoreConfig_Init(t *testing.T) {
154160
Extra: tt.fields.Extra,
155161
Proxy: tt.fields.Proxy,
156162
Webhook: tt.fields.Webhook,
163+
Script: tt.fields.Script,
157164
AutoTorrent: tt.fields.AutoTorrent,
158165
Archive: tt.fields.Archive,
159166
}

pkg/download/downloader.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -995,6 +995,7 @@ func (d *Downloader) watch(task *Task) {
995995
d.notifyRunning()
996996
d.triggerOnDone(task)
997997
d.triggerWebhooks(WebhookEventDownloadDone, task, nil)
998+
d.triggerScripts(ScriptEventDownloadDone, task, nil)
998999

9991000
if e, ok := task.Meta.Opts.Extra.(*http.OptsExtra); ok {
10001001
downloadFilePath := task.Meta.SingleFilepath()

pkg/download/script.go

Lines changed: 161 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,161 @@
1+
package download
2+
3+
import (
4+
"fmt"
5+
"os"
6+
"os/exec"
7+
"path/filepath"
8+
"runtime"
9+
"time"
10+
)
11+
12+
// ScriptEvent represents the type of script event
13+
type ScriptEvent string
14+
15+
const (
16+
ScriptEventDownloadDone ScriptEvent = "DOWNLOAD_DONE"
17+
ScriptEventDownloadError ScriptEvent = "DOWNLOAD_ERROR"
18+
)
19+
20+
// ScriptData is the internal data structure for passing script information
21+
type ScriptData struct {
22+
Event ScriptEvent
23+
Time int64 // Unix timestamp in milliseconds
24+
Payload *ScriptPayload
25+
}
26+
27+
// ScriptPayload contains the task data
28+
type ScriptPayload struct {
29+
Task *Task
30+
}
31+
32+
// getScriptPaths extracts script paths from config
33+
func (d *Downloader) getScriptPaths() []string {
34+
cfg := d.cfg.DownloaderStoreConfig
35+
if cfg == nil {
36+
return nil
37+
}
38+
39+
// Check new script config
40+
if cfg.Script != nil && cfg.Script.Enable && len(cfg.Script.Paths) > 0 {
41+
paths := make([]string, 0, len(cfg.Script.Paths))
42+
for _, path := range cfg.Script.Paths {
43+
if path != "" {
44+
paths = append(paths, path)
45+
}
46+
}
47+
if len(paths) > 0 {
48+
return paths
49+
}
50+
}
51+
52+
return nil
53+
}
54+
55+
// executeScriptAtPath executes a single script with the given data
56+
// Returns any error that occurred during execution
57+
func (d *Downloader) executeScriptAtPath(scriptPath string, data *ScriptData) error {
58+
if scriptPath == "" {
59+
return fmt.Errorf("script path is empty")
60+
}
61+
62+
// Check if script file exists
63+
if _, err := os.Stat(scriptPath); os.IsNotExist(err) {
64+
return fmt.Errorf("script file does not exist: %s", scriptPath)
65+
}
66+
67+
// Determine the script interpreter based on file extension
68+
var cmd *exec.Cmd
69+
ext := filepath.Ext(scriptPath)
70+
71+
switch ext {
72+
case ".sh", ".bash":
73+
cmd = exec.Command("bash", scriptPath)
74+
case ".py":
75+
cmd = exec.Command("python3", scriptPath)
76+
case ".js":
77+
cmd = exec.Command("node", scriptPath)
78+
case ".bat", ".cmd":
79+
// Windows batch files
80+
if runtime.GOOS == "windows" {
81+
cmd = exec.Command("cmd", "/c", scriptPath)
82+
} else {
83+
// Batch files are Windows-specific
84+
return fmt.Errorf("batch files (.bat/.cmd) are only supported on Windows")
85+
}
86+
case ".ps1":
87+
// PowerShell scripts
88+
if runtime.GOOS == "windows" {
89+
cmd = exec.Command("powershell", "-ExecutionPolicy", "Bypass", "-File", scriptPath)
90+
} else {
91+
// Try pwsh (PowerShell Core) on non-Windows systems
92+
cmd = exec.Command("pwsh", "-File", scriptPath)
93+
}
94+
case "":
95+
// No extension, try to execute directly (assumes shebang or executable)
96+
cmd = exec.Command(scriptPath)
97+
default:
98+
// Unknown extension, try to execute directly
99+
cmd = exec.Command(scriptPath)
100+
}
101+
102+
// Set environment variables with task information
103+
cmd.Env = append(os.Environ(),
104+
fmt.Sprintf("GOPEED_EVENT=%s", data.Event),
105+
fmt.Sprintf("GOPEED_TASK_ID=%s", data.Payload.Task.ID),
106+
fmt.Sprintf("GOPEED_TASK_NAME=%s", data.Payload.Task.Name()),
107+
fmt.Sprintf("GOPEED_TASK_STATUS=%s", data.Payload.Task.Status),
108+
)
109+
110+
// Add task path using the same logic as task deletion
111+
if data.Payload.Task.Meta != nil && data.Payload.Task.Meta.Res != nil {
112+
var taskPath string
113+
if data.Payload.Task.Meta.Res.Name != "" {
114+
// Multi-file task (folder)
115+
taskPath = data.Payload.Task.Meta.FolderPath()
116+
} else {
117+
// Single file task
118+
taskPath = data.Payload.Task.Meta.SingleFilepath()
119+
}
120+
cmd.Env = append(cmd.Env,
121+
fmt.Sprintf("GOPEED_TASK_PATH=%s", taskPath),
122+
)
123+
}
124+
125+
// Start and wait for the command to complete (no timeout)
126+
return cmd.Run()
127+
}
128+
129+
// triggerScripts executes all configured scripts
130+
func (d *Downloader) triggerScripts(event ScriptEvent, task *Task, err error) {
131+
paths := d.getScriptPaths()
132+
if len(paths) == 0 {
133+
return
134+
}
135+
136+
data := &ScriptData{
137+
Event: event,
138+
Time: time.Now().UnixMilli(),
139+
Payload: &ScriptPayload{
140+
Task: task.clone(),
141+
},
142+
}
143+
144+
go d.executeScripts(paths, data)
145+
}
146+
147+
func (d *Downloader) executeScripts(paths []string, data *ScriptData) {
148+
for _, path := range paths {
149+
if path == "" {
150+
continue
151+
}
152+
go func(scriptPath string) {
153+
err := d.executeScriptAtPath(scriptPath, data)
154+
if err != nil {
155+
d.Logger.Warn().Err(err).Str("path", scriptPath).Msg("script: failed to execute")
156+
return
157+
}
158+
d.Logger.Debug().Str("path", scriptPath).Msg("script: executed successfully")
159+
}(path)
160+
}
161+
}

0 commit comments

Comments
 (0)