From 1b1631b41a486d2a700e0ab9ea5db07cfdbffcfb Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Sun, 8 Feb 2026 18:40:06 +0800 Subject: [PATCH] feat: support post-task script hooks (#1265) --- pkg/base/model.go | 13 ++ pkg/base/model_test.go | 7 + pkg/download/downloader.go | 1 + pkg/download/script.go | 161 ++++++++++++++ pkg/download/script_test.go | 203 ++++++++++++++++++ pkg/download/script_unix_test.go | 117 ++++++++++ pkg/download/script_windows_test.go | 113 ++++++++++ pkg/download/testdata/scripts/env_dump.bat | 9 + pkg/download/testdata/scripts/env_dump.sh | 13 ++ pkg/download/testdata/scripts/move.bat | 8 + pkg/download/testdata/scripts/move.sh | 10 + .../testdata/scripts/write_output1.bat | 5 + .../testdata/scripts/write_output1.sh | 9 + .../testdata/scripts/write_output2.bat | 5 + .../testdata/scripts/write_output2.sh | 9 + .../lib/api/model/downloader_config.dart | 17 ++ .../lib/api/model/downloader_config.g.dart | 17 ++ .../modules/setting/views/setting_view.dart | 186 ++++++++++++++++ ui/flutter/lib/i18n/langs/en_us.dart | 4 + ui/flutter/lib/i18n/langs/zh_cn.dart | 4 + 20 files changed, 911 insertions(+) create mode 100644 pkg/download/script.go create mode 100644 pkg/download/script_test.go create mode 100644 pkg/download/script_unix_test.go create mode 100644 pkg/download/script_windows_test.go create mode 100644 pkg/download/testdata/scripts/env_dump.bat create mode 100644 pkg/download/testdata/scripts/env_dump.sh create mode 100644 pkg/download/testdata/scripts/move.bat create mode 100644 pkg/download/testdata/scripts/move.sh create mode 100644 pkg/download/testdata/scripts/write_output1.bat create mode 100644 pkg/download/testdata/scripts/write_output1.sh create mode 100644 pkg/download/testdata/scripts/write_output2.bat create mode 100644 pkg/download/testdata/scripts/write_output2.sh diff --git a/pkg/base/model.go b/pkg/base/model.go index 764de9c7..f385423f 100644 --- a/pkg/base/model.go +++ b/pkg/base/model.go @@ -184,6 +184,7 @@ type DownloaderStoreConfig struct { Extra map[string]any `json:"extra"` Proxy *DownloaderProxyConfig `json:"proxy"` Webhook *WebhookConfig `json:"webhook"` // Webhook is the webhook configuration + Script *ScriptConfig `json:"script"` // Script is the script execution configuration AutoTorrent *AutoTorrentConfig `json:"autoTorrent"` // AutoTorrent is the auto torrent task creation configuration Archive *ArchiveConfig `json:"archive"` // Archive is the archive extraction configuration AutoDeleteMissingFileTasks bool `json:"autoDeleteMissingFileTasks"` // AutoDeleteMissingFileTasks enables automatic deletion of tasks with missing files @@ -202,6 +203,9 @@ func (cfg *DownloaderStoreConfig) Init() *DownloaderStoreConfig { if cfg.Webhook == nil { cfg.Webhook = &WebhookConfig{} } + if cfg.Script == nil { + cfg.Script = &ScriptConfig{} + } if cfg.AutoTorrent == nil { cfg.AutoTorrent = &AutoTorrentConfig{ Enable: false, @@ -239,6 +243,9 @@ func (cfg *DownloaderStoreConfig) Merge(beforeCfg *DownloaderStoreConfig) *Downl if cfg.Webhook == nil { cfg.Webhook = beforeCfg.Webhook } + if cfg.Script == nil { + cfg.Script = beforeCfg.Script + } if cfg.AutoTorrent == nil { cfg.AutoTorrent = beforeCfg.AutoTorrent } @@ -254,6 +261,12 @@ type WebhookConfig struct { URLs []string `json:"urls"` // URLs is the list of webhook URLs } +// ScriptConfig is the script execution configuration +type ScriptConfig struct { + Enable bool `json:"enable"` // Enable is the flag to enable/disable script execution + Paths []string `json:"paths"` // Paths is the list of script paths to execute +} + // AutoTorrentConfig is the auto torrent task creation configuration type AutoTorrentConfig struct { Enable bool `json:"enable"` // Enable enables automatic BT task creation when downloading .torrent files diff --git a/pkg/base/model_test.go b/pkg/base/model_test.go index 2cac7e27..c0cf50f2 100644 --- a/pkg/base/model_test.go +++ b/pkg/base/model_test.go @@ -19,6 +19,7 @@ func TestDownloaderStoreConfig_Init(t *testing.T) { ProtocolConfig: map[string]any{}, Proxy: &DownloaderProxyConfig{}, Webhook: &WebhookConfig{}, + Script: &ScriptConfig{}, AutoTorrent: &AutoTorrentConfig{ Enable: false, DeleteAfterDownload: false, @@ -39,6 +40,7 @@ func TestDownloaderStoreConfig_Init(t *testing.T) { ProtocolConfig: map[string]any{}, Proxy: &DownloaderProxyConfig{}, Webhook: &WebhookConfig{}, + Script: &ScriptConfig{}, AutoTorrent: &AutoTorrentConfig{ Enable: false, DeleteAfterDownload: false, @@ -63,6 +65,7 @@ func TestDownloaderStoreConfig_Init(t *testing.T) { }, Proxy: &DownloaderProxyConfig{}, Webhook: &WebhookConfig{}, + Script: &ScriptConfig{}, AutoTorrent: &AutoTorrentConfig{ Enable: false, DeleteAfterDownload: false, @@ -87,6 +90,7 @@ func TestDownloaderStoreConfig_Init(t *testing.T) { Enable: true, }, Webhook: &WebhookConfig{}, + Script: &ScriptConfig{}, AutoTorrent: &AutoTorrentConfig{ Enable: false, DeleteAfterDownload: false, @@ -110,6 +114,7 @@ func TestDownloaderStoreConfig_Init(t *testing.T) { ProtocolConfig: map[string]any{}, Proxy: &DownloaderProxyConfig{}, Webhook: &WebhookConfig{}, + Script: &ScriptConfig{}, AutoTorrent: &AutoTorrentConfig{ Enable: true, DeleteAfterDownload: true, @@ -133,6 +138,7 @@ func TestDownloaderStoreConfig_Init(t *testing.T) { ProtocolConfig: map[string]any{}, Proxy: &DownloaderProxyConfig{}, Webhook: &WebhookConfig{}, + Script: &ScriptConfig{}, AutoTorrent: &AutoTorrentConfig{ Enable: false, DeleteAfterDownload: false, @@ -154,6 +160,7 @@ func TestDownloaderStoreConfig_Init(t *testing.T) { Extra: tt.fields.Extra, Proxy: tt.fields.Proxy, Webhook: tt.fields.Webhook, + Script: tt.fields.Script, AutoTorrent: tt.fields.AutoTorrent, Archive: tt.fields.Archive, } diff --git a/pkg/download/downloader.go b/pkg/download/downloader.go index 5f139b2f..6dbf9169 100644 --- a/pkg/download/downloader.go +++ b/pkg/download/downloader.go @@ -995,6 +995,7 @@ func (d *Downloader) watch(task *Task) { d.notifyRunning() d.triggerOnDone(task) d.triggerWebhooks(WebhookEventDownloadDone, task, nil) + d.triggerScripts(ScriptEventDownloadDone, task, nil) if e, ok := task.Meta.Opts.Extra.(*http.OptsExtra); ok { downloadFilePath := task.Meta.SingleFilepath() diff --git a/pkg/download/script.go b/pkg/download/script.go new file mode 100644 index 00000000..a852b6db --- /dev/null +++ b/pkg/download/script.go @@ -0,0 +1,161 @@ +package download + +import ( + "fmt" + "os" + "os/exec" + "path/filepath" + "runtime" + "time" +) + +// ScriptEvent represents the type of script event +type ScriptEvent string + +const ( + ScriptEventDownloadDone ScriptEvent = "DOWNLOAD_DONE" + ScriptEventDownloadError ScriptEvent = "DOWNLOAD_ERROR" +) + +// ScriptData is the internal data structure for passing script information +type ScriptData struct { + Event ScriptEvent + Time int64 // Unix timestamp in milliseconds + Payload *ScriptPayload +} + +// ScriptPayload contains the task data +type ScriptPayload struct { + Task *Task +} + +// getScriptPaths extracts script paths from config +func (d *Downloader) getScriptPaths() []string { + cfg := d.cfg.DownloaderStoreConfig + if cfg == nil { + return nil + } + + // Check new script config + if cfg.Script != nil && cfg.Script.Enable && len(cfg.Script.Paths) > 0 { + paths := make([]string, 0, len(cfg.Script.Paths)) + for _, path := range cfg.Script.Paths { + if path != "" { + paths = append(paths, path) + } + } + if len(paths) > 0 { + return paths + } + } + + return nil +} + +// executeScriptAtPath executes a single script with the given data +// Returns any error that occurred during execution +func (d *Downloader) executeScriptAtPath(scriptPath string, data *ScriptData) error { + if scriptPath == "" { + return fmt.Errorf("script path is empty") + } + + // Check if script file exists + if _, err := os.Stat(scriptPath); os.IsNotExist(err) { + return fmt.Errorf("script file does not exist: %s", scriptPath) + } + + // Determine the script interpreter based on file extension + var cmd *exec.Cmd + ext := filepath.Ext(scriptPath) + + switch ext { + case ".sh", ".bash": + cmd = exec.Command("bash", scriptPath) + case ".py": + cmd = exec.Command("python3", scriptPath) + case ".js": + cmd = exec.Command("node", scriptPath) + case ".bat", ".cmd": + // Windows batch files + if runtime.GOOS == "windows" { + cmd = exec.Command("cmd", "/c", scriptPath) + } else { + // Batch files are Windows-specific + return fmt.Errorf("batch files (.bat/.cmd) are only supported on Windows") + } + case ".ps1": + // PowerShell scripts + if runtime.GOOS == "windows" { + cmd = exec.Command("powershell", "-ExecutionPolicy", "Bypass", "-File", scriptPath) + } else { + // Try pwsh (PowerShell Core) on non-Windows systems + cmd = exec.Command("pwsh", "-File", scriptPath) + } + case "": + // No extension, try to execute directly (assumes shebang or executable) + cmd = exec.Command(scriptPath) + default: + // Unknown extension, try to execute directly + cmd = exec.Command(scriptPath) + } + + // Set environment variables with task information + cmd.Env = append(os.Environ(), + fmt.Sprintf("GOPEED_EVENT=%s", data.Event), + fmt.Sprintf("GOPEED_TASK_ID=%s", data.Payload.Task.ID), + fmt.Sprintf("GOPEED_TASK_NAME=%s", data.Payload.Task.Name()), + fmt.Sprintf("GOPEED_TASK_STATUS=%s", data.Payload.Task.Status), + ) + + // Add task path using the same logic as task deletion + if data.Payload.Task.Meta != nil && data.Payload.Task.Meta.Res != nil { + var taskPath string + if data.Payload.Task.Meta.Res.Name != "" { + // Multi-file task (folder) + taskPath = data.Payload.Task.Meta.FolderPath() + } else { + // Single file task + taskPath = data.Payload.Task.Meta.SingleFilepath() + } + cmd.Env = append(cmd.Env, + fmt.Sprintf("GOPEED_TASK_PATH=%s", taskPath), + ) + } + + // Start and wait for the command to complete (no timeout) + return cmd.Run() +} + +// triggerScripts executes all configured scripts +func (d *Downloader) triggerScripts(event ScriptEvent, task *Task, err error) { + paths := d.getScriptPaths() + if len(paths) == 0 { + return + } + + data := &ScriptData{ + Event: event, + Time: time.Now().UnixMilli(), + Payload: &ScriptPayload{ + Task: task.clone(), + }, + } + + go d.executeScripts(paths, data) +} + +func (d *Downloader) executeScripts(paths []string, data *ScriptData) { + for _, path := range paths { + if path == "" { + continue + } + go func(scriptPath string) { + err := d.executeScriptAtPath(scriptPath, data) + if err != nil { + d.Logger.Warn().Err(err).Str("path", scriptPath).Msg("script: failed to execute") + return + } + d.Logger.Debug().Str("path", scriptPath).Msg("script: executed successfully") + }(path) + } +} diff --git a/pkg/download/script_test.go b/pkg/download/script_test.go new file mode 100644 index 00000000..d155579d --- /dev/null +++ b/pkg/download/script_test.go @@ -0,0 +1,203 @@ +package download + +import ( + "os" + "path/filepath" + "testing" + "time" + + "github.com/GopeedLab/gopeed/internal/fetcher" + "github.com/GopeedLab/gopeed/pkg/base" +) + +func TestScript_NoScriptConfigured(t *testing.T) { + setupScriptTest(t, func(downloader *Downloader) { + // Create a mock task + task := NewTask() + task.Protocol = "http" + task.Meta = &mockFetcherMeta + + // Trigger script (should not panic with no scripts configured) + downloader.triggerScripts(ScriptEventDownloadDone, task, nil) + }) +} + +func TestScript_GetScriptPaths_EmptyConfig(t *testing.T) { + setupScriptTest(t, func(downloader *Downloader) { + paths := downloader.getScriptPaths() + if paths != nil { + t.Errorf("Expected nil, got %v", paths) + } + }) +} + +func TestScript_GetScriptPaths_NoScriptConfig(t *testing.T) { + setupScriptTest(t, func(downloader *Downloader) { + cfg, _ := downloader.GetConfig() + cfg.Script = nil + downloader.PutConfig(cfg) + + paths := downloader.getScriptPaths() + if paths != nil { + t.Errorf("Expected nil, got %v", paths) + } + }) +} + +func TestScript_GetScriptPaths_DisabledScript(t *testing.T) { + setupScriptTest(t, func(downloader *Downloader) { + cfg, _ := downloader.GetConfig() + cfg.Script = &base.ScriptConfig{ + Enable: false, + Paths: []string{"/path/to/script.sh"}, + } + downloader.PutConfig(cfg) + + paths := downloader.getScriptPaths() + if paths != nil { + t.Errorf("Expected nil for disabled script, got %v", paths) + } + }) +} + +func TestScript_GetScriptPaths_EmptyPaths(t *testing.T) { + setupScriptTest(t, func(downloader *Downloader) { + cfg, _ := downloader.GetConfig() + cfg.Script = &base.ScriptConfig{ + Enable: true, + Paths: []string{}, + } + downloader.PutConfig(cfg) + + paths := downloader.getScriptPaths() + if paths != nil { + t.Errorf("Expected nil for empty paths, got %v", paths) + } + }) +} + +func TestScript_GetScriptPaths_WithEmptyStrings(t *testing.T) { + setupScriptTest(t, func(downloader *Downloader) { + cfg, _ := downloader.GetConfig() + cfg.Script = &base.ScriptConfig{ + Enable: true, + Paths: []string{"/path/to/script1.sh", "", "/path/to/script2.sh", ""}, + } + downloader.PutConfig(cfg) + + paths := downloader.getScriptPaths() + if len(paths) != 2 { + t.Errorf("Expected 2 valid paths (ignoring empty strings), got %d: %v", len(paths), paths) + } + if paths[0] != "/path/to/script1.sh" || paths[1] != "/path/to/script2.sh" { + t.Errorf("Paths don't match expected values: %v", paths) + } + }) +} + +func TestScript_ExecuteScriptAtPath_EmptyPath(t *testing.T) { + setupScriptTest(t, func(downloader *Downloader) { + data := &ScriptData{ + Event: ScriptEventDownloadDone, + Time: time.Now().UnixMilli(), + } + + err := downloader.executeScriptAtPath("", data) + if err == nil { + t.Error("Expected error for empty path") + } + if err.Error() != "script path is empty" { + t.Errorf("Expected 'script path is empty' error, got: %v", err) + } + }) +} + +func TestScript_ExecuteScriptAtPath_NonExistentFile(t *testing.T) { + setupScriptTest(t, func(downloader *Downloader) { + data := &ScriptData{ + Event: ScriptEventDownloadDone, + Time: time.Now().UnixMilli(), + } + + err := downloader.executeScriptAtPath("/non/existent/script.sh", data) + if err == nil { + t.Error("Expected error for non-existent script") + } + }) +} + +func createDownloadDoneTask(t *testing.T, downloadDir, fileName string) (*Task, string) { + t.Helper() + content := []byte("downloaded file") + task := NewTask() + task.Protocol = "http" + task.Status = base.DownloadStatusDone + task.Meta = &fetcher.FetcherMeta{ + Req: &base.Request{ + URL: "https://example.com/" + fileName, + }, + Opts: &base.Options{ + Name: fileName, + Path: filepath.ToSlash(downloadDir), + }, + Res: &base.Resource{ + Size: int64(len(content)), + Files: []*base.FileInfo{ + {Name: fileName, Size: int64(len(content))}, + }, + }, + } + + filePath := task.Meta.SingleFilepath() + filePathOS := filepath.FromSlash(filePath) + if err := os.MkdirAll(filepath.Dir(filePathOS), 0755); err != nil { + t.Fatalf("Failed to create download dir: %v", err) + } + if err := os.WriteFile(filePathOS, content, 0644); err != nil { + t.Fatalf("Failed to create download file: %v", err) + } + return task, filePath +} + +func waitForFile(t *testing.T, path string, timeout time.Duration) { + t.Helper() + deadline := time.Now().Add(timeout) + for time.Now().Before(deadline) { + if _, err := os.Stat(path); err == nil { + return + } + time.Sleep(50 * time.Millisecond) + } + t.Fatalf("Timeout waiting for file: %s", path) +} + +func getTestScriptPath(t *testing.T, name string) string { + t.Helper() + path := filepath.Join("testdata", "scripts", name) + if _, err := os.Stat(path); err != nil { + t.Fatalf("Missing test script %s: %v", path, err) + } + return path +} + +func ensureScriptExecutable(t *testing.T, scriptPath string) { + t.Helper() + if filepath.Ext(scriptPath) != ".sh" { + return + } + if err := os.Chmod(scriptPath, 0755); err != nil { + t.Fatalf("Failed to chmod script: %v", err) + } +} + +func setupScriptTest(t *testing.T, fn func(downloader *Downloader)) { + defaultDownloader.Setup() + defaultDownloader.cfg.StorageDir = ".test_storage" + defaultDownloader.cfg.DownloadDir = ".test_download" + defer func() { + defaultDownloader.Clear() + os.RemoveAll(defaultDownloader.cfg.StorageDir) + os.RemoveAll(defaultDownloader.cfg.DownloadDir) + }() + fn(defaultDownloader) +} diff --git a/pkg/download/script_unix_test.go b/pkg/download/script_unix_test.go new file mode 100644 index 00000000..24ea62ef --- /dev/null +++ b/pkg/download/script_unix_test.go @@ -0,0 +1,117 @@ +//go:build !windows +// +build !windows + +package download + +import ( + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/GopeedLab/gopeed/pkg/base" +) + +func TestScript_TriggerOnDone_MoveFile(t *testing.T) { + tmpDir := t.TempDir() + downloadDir := filepath.Join(tmpDir, "downloads") + destDir := filepath.Join(tmpDir, "moved") + task, taskPath := createDownloadDoneTask(t, downloadDir, "test.txt") + srcPath := filepath.FromSlash(taskPath) + destFile := filepath.Join(destDir, "test.txt") + + scriptPath := getTestScriptPath(t, "move.sh") + ensureScriptExecutable(t, scriptPath) + + setupScriptTest(t, func(downloader *Downloader) { + cfg, _ := downloader.GetConfig() + cfg.Script = &base.ScriptConfig{ + Enable: true, + Paths: []string{scriptPath}, + } + downloader.PutConfig(cfg) + + t.Setenv("GOPEED_TEST_DEST_DIR", destDir) + downloader.triggerScripts(ScriptEventDownloadDone, task, nil) + + waitForFile(t, destFile, 3*time.Second) + if _, err := os.Stat(srcPath); !os.IsNotExist(err) { + t.Errorf("Expected source file to be moved, but it still exists: %s", srcPath) + } + }) +} + +func TestScript_MultipleScripts(t *testing.T) { + tmpDir := t.TempDir() + outputFile1 := filepath.Join(tmpDir, "output1.txt") + outputFile2 := filepath.Join(tmpDir, "output2.txt") + scriptPath1 := getTestScriptPath(t, "write_output1.sh") + ensureScriptExecutable(t, scriptPath1) + scriptPath2 := getTestScriptPath(t, "write_output2.sh") + ensureScriptExecutable(t, scriptPath2) + + setupScriptTest(t, func(downloader *Downloader) { + cfg, _ := downloader.GetConfig() + cfg.Script = &base.ScriptConfig{ + Enable: true, + Paths: []string{scriptPath1, scriptPath2}, + } + downloader.PutConfig(cfg) + + downloadDir := filepath.Join(tmpDir, "downloads") + task, _ := createDownloadDoneTask(t, downloadDir, "multi.txt") + + t.Setenv("GOPEED_TEST_OUTPUT_FILE_1", outputFile1) + t.Setenv("GOPEED_TEST_OUTPUT_FILE_2", outputFile2) + downloader.triggerScripts(ScriptEventDownloadDone, task, nil) + + waitForFile(t, outputFile1, 3*time.Second) + waitForFile(t, outputFile2, 3*time.Second) + }) +} + +func TestScript_EnvironmentVariables(t *testing.T) { + tmpDir := t.TempDir() + scriptPath := getTestScriptPath(t, "env_dump.sh") + ensureScriptExecutable(t, scriptPath) + outputFile := filepath.Join(tmpDir, "env_output.txt") + + setupScriptTest(t, func(downloader *Downloader) { + cfg, _ := downloader.GetConfig() + cfg.Script = &base.ScriptConfig{ + Enable: true, + Paths: []string{scriptPath}, + } + downloader.PutConfig(cfg) + + downloadDir := filepath.Join(tmpDir, "downloads") + task, taskPath := createDownloadDoneTask(t, downloadDir, "env.txt") + + t.Setenv("GOPEED_TEST_OUTPUT_FILE", outputFile) + downloader.triggerScripts(ScriptEventDownloadDone, task, nil) + + waitForFile(t, outputFile, 3*time.Second) + content, err := os.ReadFile(outputFile) + if err != nil { + t.Fatalf("Failed to read output file: %v", err) + } + + output := string(content) + if !strings.Contains(output, "GOPEED_EVENT=DOWNLOAD_DONE") { + t.Errorf("Expected GOPEED_EVENT in output, got: %s", output) + } + if !strings.Contains(output, "GOPEED_TASK_ID="+task.ID) { + t.Errorf("Expected GOPEED_TASK_ID in output, got: %s", output) + } + if !strings.Contains(output, "GOPEED_TASK_NAME="+task.Name()) { + t.Errorf("Expected GOPEED_TASK_NAME in output, got: %s", output) + } + if !strings.Contains(output, "GOPEED_TASK_STATUS="+string(task.Status)) { + t.Errorf("Expected GOPEED_TASK_STATUS in output, got: %s", output) + } + if !strings.Contains(output, "GOPEED_TASK_PATH="+taskPath) { + t.Errorf("Expected GOPEED_TASK_PATH in output, got: %s", output) + } + }) +} diff --git a/pkg/download/script_windows_test.go b/pkg/download/script_windows_test.go new file mode 100644 index 00000000..980f3394 --- /dev/null +++ b/pkg/download/script_windows_test.go @@ -0,0 +1,113 @@ +//go:build windows +// +build windows + +package download + +import ( + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/GopeedLab/gopeed/pkg/base" +) + +func TestScript_TriggerOnDone_MoveFile(t *testing.T) { + tmpDir := t.TempDir() + downloadDir := filepath.Join(tmpDir, "downloads") + destDir := filepath.Join(tmpDir, "moved") + task, taskPath := createDownloadDoneTask(t, downloadDir, "test.txt") + srcPath := filepath.FromSlash(taskPath) + destFile := filepath.Join(destDir, "test.txt") + + scriptPath := getTestScriptPath(t, "move.bat") + + setupScriptTest(t, func(downloader *Downloader) { + cfg, _ := downloader.GetConfig() + cfg.Script = &base.ScriptConfig{ + Enable: true, + Paths: []string{scriptPath}, + } + downloader.PutConfig(cfg) + + t.Setenv("GOPEED_TEST_DEST_DIR", destDir) + downloader.triggerScripts(ScriptEventDownloadDone, task, nil) + + waitForFile(t, destFile, 5*time.Second) + if _, err := os.Stat(srcPath); !os.IsNotExist(err) { + t.Errorf("Expected source file to be moved, but it still exists: %s", srcPath) + } + }) +} + +func TestScript_MultipleScripts(t *testing.T) { + tmpDir := t.TempDir() + outputFile1 := filepath.Join(tmpDir, "output1.txt") + outputFile2 := filepath.Join(tmpDir, "output2.txt") + scriptPath1 := getTestScriptPath(t, "write_output1.bat") + scriptPath2 := getTestScriptPath(t, "write_output2.bat") + + setupScriptTest(t, func(downloader *Downloader) { + cfg, _ := downloader.GetConfig() + cfg.Script = &base.ScriptConfig{ + Enable: true, + Paths: []string{scriptPath1, scriptPath2}, + } + downloader.PutConfig(cfg) + + downloadDir := filepath.Join(tmpDir, "downloads") + task, _ := createDownloadDoneTask(t, downloadDir, "multi.txt") + + t.Setenv("GOPEED_TEST_OUTPUT_FILE_1", outputFile1) + t.Setenv("GOPEED_TEST_OUTPUT_FILE_2", outputFile2) + downloader.triggerScripts(ScriptEventDownloadDone, task, nil) + + waitForFile(t, outputFile1, 5*time.Second) + waitForFile(t, outputFile2, 5*time.Second) + }) +} + +func TestScript_EnvironmentVariables(t *testing.T) { + tmpDir := t.TempDir() + scriptPath := getTestScriptPath(t, "env_dump.bat") + outputFile := filepath.Join(tmpDir, "env_output.txt") + + setupScriptTest(t, func(downloader *Downloader) { + cfg, _ := downloader.GetConfig() + cfg.Script = &base.ScriptConfig{ + Enable: true, + Paths: []string{scriptPath}, + } + downloader.PutConfig(cfg) + + downloadDir := filepath.Join(tmpDir, "downloads") + task, taskPath := createDownloadDoneTask(t, downloadDir, "env.txt") + + t.Setenv("GOPEED_TEST_OUTPUT_FILE", outputFile) + downloader.triggerScripts(ScriptEventDownloadDone, task, nil) + + waitForFile(t, outputFile, 5*time.Second) + content, err := os.ReadFile(outputFile) + if err != nil { + t.Fatalf("Failed to read output file: %v", err) + } + + output := string(content) + if !strings.Contains(output, "GOPEED_EVENT=DOWNLOAD_DONE") { + t.Errorf("Expected GOPEED_EVENT in output, got: %s", output) + } + if !strings.Contains(output, "GOPEED_TASK_ID="+task.ID) { + t.Errorf("Expected GOPEED_TASK_ID in output, got: %s", output) + } + if !strings.Contains(output, "GOPEED_TASK_NAME="+task.Name()) { + t.Errorf("Expected GOPEED_TASK_NAME in output, got: %s", output) + } + if !strings.Contains(output, "GOPEED_TASK_STATUS="+string(task.Status)) { + t.Errorf("Expected GOPEED_TASK_STATUS in output, got: %s", output) + } + if !strings.Contains(output, "GOPEED_TASK_PATH="+taskPath) { + t.Errorf("Expected GOPEED_TASK_PATH in output, got: %s", output) + } + }) +} diff --git a/pkg/download/testdata/scripts/env_dump.bat b/pkg/download/testdata/scripts/env_dump.bat new file mode 100644 index 00000000..4ca6aaf6 --- /dev/null +++ b/pkg/download/testdata/scripts/env_dump.bat @@ -0,0 +1,9 @@ +@echo off +setlocal + +if "%GOPEED_TEST_OUTPUT_FILE%"=="" exit /b 2 +echo GOPEED_EVENT=%GOPEED_EVENT% > "%GOPEED_TEST_OUTPUT_FILE%" +echo GOPEED_TASK_ID=%GOPEED_TASK_ID% >> "%GOPEED_TEST_OUTPUT_FILE%" +echo GOPEED_TASK_NAME=%GOPEED_TASK_NAME% >> "%GOPEED_TEST_OUTPUT_FILE%" +echo GOPEED_TASK_STATUS=%GOPEED_TASK_STATUS% >> "%GOPEED_TEST_OUTPUT_FILE%" +echo GOPEED_TASK_PATH=%GOPEED_TASK_PATH% >> "%GOPEED_TEST_OUTPUT_FILE%" diff --git a/pkg/download/testdata/scripts/env_dump.sh b/pkg/download/testdata/scripts/env_dump.sh new file mode 100644 index 00000000..6bd9dd05 --- /dev/null +++ b/pkg/download/testdata/scripts/env_dump.sh @@ -0,0 +1,13 @@ +#!/bin/bash +set -e + +if [ -z "$GOPEED_TEST_OUTPUT_FILE" ]; then + echo "GOPEED_TEST_OUTPUT_FILE is empty" >&2 + exit 2 +fi + +echo "GOPEED_EVENT=$GOPEED_EVENT" > "$GOPEED_TEST_OUTPUT_FILE" +echo "GOPEED_TASK_ID=$GOPEED_TASK_ID" >> "$GOPEED_TEST_OUTPUT_FILE" +echo "GOPEED_TASK_NAME=$GOPEED_TASK_NAME" >> "$GOPEED_TEST_OUTPUT_FILE" +echo "GOPEED_TASK_STATUS=$GOPEED_TASK_STATUS" >> "$GOPEED_TEST_OUTPUT_FILE" +echo "GOPEED_TASK_PATH=$GOPEED_TASK_PATH" >> "$GOPEED_TEST_OUTPUT_FILE" diff --git a/pkg/download/testdata/scripts/move.bat b/pkg/download/testdata/scripts/move.bat new file mode 100644 index 00000000..1e9f1b95 --- /dev/null +++ b/pkg/download/testdata/scripts/move.bat @@ -0,0 +1,8 @@ +@echo off +setlocal + +if "%GOPEED_TEST_DEST_DIR%"=="" exit /b 2 +set "SRC=%GOPEED_TASK_PATH:/=\%" +set "DEST=%GOPEED_TEST_DEST_DIR%" +if not exist "%DEST%" mkdir "%DEST%" +move /Y "%SRC%" "%DEST%\" >nul diff --git a/pkg/download/testdata/scripts/move.sh b/pkg/download/testdata/scripts/move.sh new file mode 100644 index 00000000..3824c3c3 --- /dev/null +++ b/pkg/download/testdata/scripts/move.sh @@ -0,0 +1,10 @@ +#!/bin/bash +set -e + +if [ -z "$GOPEED_TEST_DEST_DIR" ]; then + echo "GOPEED_TEST_DEST_DIR is empty" >&2 + exit 2 +fi + +mkdir -p "$GOPEED_TEST_DEST_DIR" +mv "$GOPEED_TASK_PATH" "$GOPEED_TEST_DEST_DIR/" diff --git a/pkg/download/testdata/scripts/write_output1.bat b/pkg/download/testdata/scripts/write_output1.bat new file mode 100644 index 00000000..0f15f231 --- /dev/null +++ b/pkg/download/testdata/scripts/write_output1.bat @@ -0,0 +1,5 @@ +@echo off +setlocal + +if "%GOPEED_TEST_OUTPUT_FILE_1%"=="" exit /b 2 +echo Script 1 > "%GOPEED_TEST_OUTPUT_FILE_1%" diff --git a/pkg/download/testdata/scripts/write_output1.sh b/pkg/download/testdata/scripts/write_output1.sh new file mode 100644 index 00000000..9c8a4c33 --- /dev/null +++ b/pkg/download/testdata/scripts/write_output1.sh @@ -0,0 +1,9 @@ +#!/bin/bash +set -e + +if [ -z "$GOPEED_TEST_OUTPUT_FILE_1" ]; then + echo "GOPEED_TEST_OUTPUT_FILE_1 is empty" >&2 + exit 2 +fi + +echo "Script 1" > "$GOPEED_TEST_OUTPUT_FILE_1" diff --git a/pkg/download/testdata/scripts/write_output2.bat b/pkg/download/testdata/scripts/write_output2.bat new file mode 100644 index 00000000..2738c9d1 --- /dev/null +++ b/pkg/download/testdata/scripts/write_output2.bat @@ -0,0 +1,5 @@ +@echo off +setlocal + +if "%GOPEED_TEST_OUTPUT_FILE_2%"=="" exit /b 2 +echo Script 2 > "%GOPEED_TEST_OUTPUT_FILE_2%" diff --git a/pkg/download/testdata/scripts/write_output2.sh b/pkg/download/testdata/scripts/write_output2.sh new file mode 100644 index 00000000..8ada1fbe --- /dev/null +++ b/pkg/download/testdata/scripts/write_output2.sh @@ -0,0 +1,9 @@ +#!/bin/bash +set -e + +if [ -z "$GOPEED_TEST_OUTPUT_FILE_2" ]; then + echo "GOPEED_TEST_OUTPUT_FILE_2 is empty" >&2 + exit 2 +fi + +echo "Script 2" > "$GOPEED_TEST_OUTPUT_FILE_2" diff --git a/ui/flutter/lib/api/model/downloader_config.dart b/ui/flutter/lib/api/model/downloader_config.dart index f85d5aab..323d4c19 100644 --- a/ui/flutter/lib/api/model/downloader_config.dart +++ b/ui/flutter/lib/api/model/downloader_config.dart @@ -10,6 +10,7 @@ class DownloaderConfig { ExtraConfig extra = ExtraConfig(); ProxyConfig proxy = ProxyConfig(); WebhookConfig webhook = WebhookConfig(); + ScriptConfig script = ScriptConfig(); AutoTorrentConfig autoTorrent = AutoTorrentConfig(); ArchiveConfig archive = ArchiveConfig(); bool autoDeleteMissingFileTasks; @@ -149,6 +150,22 @@ class WebhookConfig { Map toJson() => _$WebhookConfigToJson(this); } +@JsonSerializable() +class ScriptConfig { + bool enable; + List paths; + + ScriptConfig({ + this.enable = false, + this.paths = const [], + }); + + factory ScriptConfig.fromJson(Map? json) => + json == null ? ScriptConfig() : _$ScriptConfigFromJson(json); + + Map toJson() => _$ScriptConfigToJson(this); +} + @JsonSerializable() class ProxyConfig { bool enable; diff --git a/ui/flutter/lib/api/model/downloader_config.g.dart b/ui/flutter/lib/api/model/downloader_config.g.dart index 397da091..6c49b2db 100644 --- a/ui/flutter/lib/api/model/downloader_config.g.dart +++ b/ui/flutter/lib/api/model/downloader_config.g.dart @@ -19,6 +19,8 @@ DownloaderConfig _$DownloaderConfigFromJson(Map json) => ..proxy = ProxyConfig.fromJson(json['proxy'] as Map) ..webhook = WebhookConfig.fromJson(json['webhook'] as Map?) + ..script = + ScriptConfig.fromJson(json['script'] as Map?) ..autoTorrent = AutoTorrentConfig.fromJson( json['autoTorrent'] as Map?) ..archive = @@ -32,6 +34,7 @@ Map _$DownloaderConfigToJson(DownloaderConfig instance) => 'extra': instance.extra.toJson(), 'proxy': instance.proxy.toJson(), 'webhook': instance.webhook.toJson(), + 'script': instance.script.toJson(), 'autoTorrent': instance.autoTorrent.toJson(), 'archive': instance.archive.toJson(), 'autoDeleteMissingFileTasks': instance.autoDeleteMissingFileTasks, @@ -153,6 +156,20 @@ Map _$WebhookConfigToJson(WebhookConfig instance) => 'urls': instance.urls, }; +ScriptConfig _$ScriptConfigFromJson(Map json) => + ScriptConfig( + enable: json['enable'] as bool? ?? false, + paths: + (json['paths'] as List?)?.map((e) => e as String).toList() ?? + const [], + ); + +Map _$ScriptConfigToJson(ScriptConfig instance) => + { + 'enable': instance.enable, + 'paths': instance.paths, + }; + ProxyConfig _$ProxyConfigFromJson(Map json) => ProxyConfig( enable: json['enable'] as bool? ?? false, system: json['system'] as bool? ?? false, diff --git a/ui/flutter/lib/app/modules/setting/views/setting_view.dart b/ui/flutter/lib/app/modules/setting/views/setting_view.dart index 3755059b..d6bcb139 100644 --- a/ui/flutter/lib/app/modules/setting/views/setting_view.dart +++ b/ui/flutter/lib/app/modules/setting/views/setting_view.dart @@ -2,6 +2,7 @@ import 'dart:async'; import 'dart:io'; import 'package:badges/badges.dart' as badges; +import 'package:file_picker/file_picker.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:get/get.dart'; @@ -1432,6 +1433,92 @@ class SettingView extends GetView { }, ); + // advanced config script items + final buildScript = _buildConfigItem( + 'script', + () => downloaderCfg.value.script.enable ? 'on'.tr : 'off'.tr, + (Key key) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Text( + 'scriptEnable'.tr, + style: Theme.of(Get.context!).textTheme.bodyMedium, + ), + const Spacer(), + Switch( + value: downloaderCfg.value.script.enable, + onChanged: (value) { + downloaderCfg.update((val) { + val!.script.enable = value; + }); + debounceSave(); + }, + ), + ], + ), + _padding, + Text( + 'scriptDesc'.tr, + style: Theme.of(Get.context!).textTheme.bodySmall, + ), + _padding, + // List of existing script paths + ...downloaderCfg.value.script.paths.asMap().entries.map((entry) { + final index = entry.key; + final path = entry.value; + return Padding( + padding: const EdgeInsets.only(bottom: 8.0), + child: Row( + children: [ + Expanded( + child: Text( + path, + style: Theme.of(Get.context!).textTheme.bodyMedium, + overflow: TextOverflow.ellipsis, + ), + ), + IconButton( + icon: const Icon(Icons.edit, size: 20), + tooltip: 'edit'.tr, + onPressed: () { + _showScriptDialog(index: index, initialPath: path); + }, + ), + IconButton( + icon: const Icon(Icons.delete, size: 20), + tooltip: 'delete'.tr, + onPressed: () async { + // Create new list to avoid unmodifiable list error + final paths = + List.from(downloaderCfg.value.script.paths); + paths.removeAt(index); + downloaderCfg.update((val) { + val!.script.paths = paths; + }); + await debounceSave(); + }, + ), + ], + ), + ); + }), + _padding, + // Add button + OutlinedButton.icon( + onPressed: () { + _showScriptDialog(); + }, + icon: const Icon(Icons.add), + label: Text('add'.tr), + ), + ], + ); + }, + ); + // advanced config log items start buildLogsDir() { return ListTile( @@ -1603,6 +1690,7 @@ class SettingView extends GetView { child: Column( children: _addDivider([ buildWebhook(), + if (Util.isDesktop()) buildScript(), buildLogsDir(), ]), )), @@ -1734,6 +1822,104 @@ class SettingView extends GetView { ); } + void _showScriptDialog({int? index, String? initialPath}) { + final pathController = TextEditingController(text: initialPath ?? ''); + final saveController = TextButtonLoadingController(); + final appController = Get.find(); + final downloaderCfg = appController.downloaderConfig; + final isEdit = index != null; + final formKey = GlobalKey(); + + showDialog( + context: Get.context!, + builder: (dialogContext) => AlertDialog( + title: Text(isEdit ? 'edit'.tr : 'add'.tr), + content: Form( + key: formKey, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Row( + children: [ + Expanded( + child: TextFormField( + controller: pathController, + decoration: InputDecoration( + hintText: 'scriptPathHint'.tr, + ), + validator: (value) { + if (value == null || value.trim().isEmpty) { + return 'required'.tr; + } + return null; + }, + ), + ), + if (Util.isDesktop()) + Padding( + padding: const EdgeInsets.only(left: 8.0), + child: IconButton( + icon: const Icon(Icons.folder_open), + onPressed: () async { + final result = await FilePicker.platform.pickFiles(); + if (result != null && result.files.isNotEmpty) { + final filePath = result.files.first.path; + if (filePath != null) { + pathController.text = filePath; + } + } + }, + ), + ), + ], + ), + ], + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(dialogContext).pop(), + child: Text('cancel'.tr), + ), + TextButtonLoading( + controller: saveController, + onPressed: () async { + if (!formKey.currentState!.validate()) { + return; + } + final path = pathController.text.trim(); + if (path.isEmpty) return; + + saveController.start(); + try { + // Create new list to avoid unmodifiable list error + final paths = + List.from(downloaderCfg.value.script.paths); + if (isEdit) { + paths[index] = path; + } else { + paths.add(path); + } + downloaderCfg.update((val) { + val!.script.paths = paths; + }); + await appController.saveConfig(); + if (dialogContext.mounted) { + Navigator.of(dialogContext).pop(); + } + } catch (e) { + showErrorMessage(e); + } finally { + saveController.stop(); + } + }, + child: Text('confirm'.tr), + ), + ], + ), + ); + } + void _showGithubMirrorDialog({int? index, GithubMirror? initialMirror}) { final isEdit = index != null; GithubMirrorType selectedType = diff --git a/ui/flutter/lib/i18n/langs/en_us.dart b/ui/flutter/lib/i18n/langs/en_us.dart index 9dc46c79..de422488 100644 --- a/ui/flutter/lib/i18n/langs/en_us.dart +++ b/ui/flutter/lib/i18n/langs/en_us.dart @@ -80,6 +80,10 @@ const enUS = { 'webhookTest': 'Test', 'webhookTestSuccess': 'Webhook test successful', 'webhookTestFail': 'Webhook test failed', + 'script': 'Script', + 'scriptEnable': 'Enable Script', + 'scriptDesc': 'Execute custom scripts when downloads complete successfully', + 'scriptPathHint': 'Enter script file path (e.g., /path/to/script.sh)', 'urlInvalid': 'Please enter a valid HTTP or HTTPS URL', 'required': 'This field is required', 'show': 'Show', diff --git a/ui/flutter/lib/i18n/langs/zh_cn.dart b/ui/flutter/lib/i18n/langs/zh_cn.dart index c8732b90..98545381 100644 --- a/ui/flutter/lib/i18n/langs/zh_cn.dart +++ b/ui/flutter/lib/i18n/langs/zh_cn.dart @@ -78,6 +78,10 @@ const zhCN = { 'webhookTest': '测试', 'webhookTestSuccess': 'Webhook 测试成功', 'webhookTestFail': 'Webhook 测试失败', + 'script': '脚本执行', + 'scriptEnable': '启用脚本', + 'scriptDesc': '在下载成功完成时执行自定义脚本', + 'scriptPathHint': '请输入脚本文件路径(例如:/path/to/script.sh)', 'urlInvalid': '请输入有效的 HTTP 或 HTTPS 链接', 'required': '此项为必填项', 'show': '显示',