mirror of
https://github.com/GopeedLab/gopeed.git
synced 2026-11-09 10:00:27 +00:00
feat: support post-task script hooks (#1265)
This commit is contained in:
1 parent
3eb6df55df
commit
1b1631b41a
20 files changed
+911
No files matched your search
@@ -184,6 +184,7 @@ type DownloaderStoreConfig struct {
|
|||||||
Extra map[string]any `json:"extra"`
|
Extra map[string]any `json:"extra"`
|
||||||
Proxy *DownloaderProxyConfig `json:"proxy"`
|
Proxy *DownloaderProxyConfig `json:"proxy"`
|
||||||
Webhook *WebhookConfig `json:"webhook"` // Webhook is the webhook configuration
|
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
|
AutoTorrent *AutoTorrentConfig `json:"autoTorrent"` // AutoTorrent is the auto torrent task creation configuration
|
||||||
Archive *ArchiveConfig `json:"archive"` // Archive is the archive extraction configuration
|
Archive *ArchiveConfig `json:"archive"` // Archive is the archive extraction configuration
|
||||||
AutoDeleteMissingFileTasks bool `json:"autoDeleteMissingFileTasks"` // AutoDeleteMissingFileTasks enables automatic deletion of tasks with missing files
|
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 {
|
if cfg.Webhook == nil {
|
||||||
cfg.Webhook = &WebhookConfig{}
|
cfg.Webhook = &WebhookConfig{}
|
||||||
}
|
}
|
||||||
|
if cfg.Script == nil {
|
||||||
|
cfg.Script = &ScriptConfig{}
|
||||||
|
}
|
||||||
if cfg.AutoTorrent == nil {
|
if cfg.AutoTorrent == nil {
|
||||||
cfg.AutoTorrent = &AutoTorrentConfig{
|
cfg.AutoTorrent = &AutoTorrentConfig{
|
||||||
Enable: false,
|
Enable: false,
|
||||||
@@ -239,6 +243,9 @@ func (cfg *DownloaderStoreConfig) Merge(beforeCfg *DownloaderStoreConfig) *Downl
|
|||||||
if cfg.Webhook == nil {
|
if cfg.Webhook == nil {
|
||||||
cfg.Webhook = beforeCfg.Webhook
|
cfg.Webhook = beforeCfg.Webhook
|
||||||
}
|
}
|
||||||
|
if cfg.Script == nil {
|
||||||
|
cfg.Script = beforeCfg.Script
|
||||||
|
}
|
||||||
if cfg.AutoTorrent == nil {
|
if cfg.AutoTorrent == nil {
|
||||||
cfg.AutoTorrent = beforeCfg.AutoTorrent
|
cfg.AutoTorrent = beforeCfg.AutoTorrent
|
||||||
}
|
}
|
||||||
@@ -254,6 +261,12 @@ type WebhookConfig struct {
|
|||||||
URLs []string `json:"urls"` // URLs is the list of webhook URLs
|
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
|
// AutoTorrentConfig is the auto torrent task creation configuration
|
||||||
type AutoTorrentConfig struct {
|
type AutoTorrentConfig struct {
|
||||||
Enable bool `json:"enable"` // Enable enables automatic BT task creation when downloading .torrent files
|
Enable bool `json:"enable"` // Enable enables automatic BT task creation when downloading .torrent files
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ func TestDownloaderStoreConfig_Init(t *testing.T) {
|
|||||||
ProtocolConfig: map[string]any{},
|
ProtocolConfig: map[string]any{},
|
||||||
Proxy: &DownloaderProxyConfig{},
|
Proxy: &DownloaderProxyConfig{},
|
||||||
Webhook: &WebhookConfig{},
|
Webhook: &WebhookConfig{},
|
||||||
|
Script: &ScriptConfig{},
|
||||||
AutoTorrent: &AutoTorrentConfig{
|
AutoTorrent: &AutoTorrentConfig{
|
||||||
Enable: false,
|
Enable: false,
|
||||||
DeleteAfterDownload: false,
|
DeleteAfterDownload: false,
|
||||||
@@ -39,6 +40,7 @@ func TestDownloaderStoreConfig_Init(t *testing.T) {
|
|||||||
ProtocolConfig: map[string]any{},
|
ProtocolConfig: map[string]any{},
|
||||||
Proxy: &DownloaderProxyConfig{},
|
Proxy: &DownloaderProxyConfig{},
|
||||||
Webhook: &WebhookConfig{},
|
Webhook: &WebhookConfig{},
|
||||||
|
Script: &ScriptConfig{},
|
||||||
AutoTorrent: &AutoTorrentConfig{
|
AutoTorrent: &AutoTorrentConfig{
|
||||||
Enable: false,
|
Enable: false,
|
||||||
DeleteAfterDownload: false,
|
DeleteAfterDownload: false,
|
||||||
@@ -63,6 +65,7 @@ func TestDownloaderStoreConfig_Init(t *testing.T) {
|
|||||||
},
|
},
|
||||||
Proxy: &DownloaderProxyConfig{},
|
Proxy: &DownloaderProxyConfig{},
|
||||||
Webhook: &WebhookConfig{},
|
Webhook: &WebhookConfig{},
|
||||||
|
Script: &ScriptConfig{},
|
||||||
AutoTorrent: &AutoTorrentConfig{
|
AutoTorrent: &AutoTorrentConfig{
|
||||||
Enable: false,
|
Enable: false,
|
||||||
DeleteAfterDownload: false,
|
DeleteAfterDownload: false,
|
||||||
@@ -87,6 +90,7 @@ func TestDownloaderStoreConfig_Init(t *testing.T) {
|
|||||||
Enable: true,
|
Enable: true,
|
||||||
},
|
},
|
||||||
Webhook: &WebhookConfig{},
|
Webhook: &WebhookConfig{},
|
||||||
|
Script: &ScriptConfig{},
|
||||||
AutoTorrent: &AutoTorrentConfig{
|
AutoTorrent: &AutoTorrentConfig{
|
||||||
Enable: false,
|
Enable: false,
|
||||||
DeleteAfterDownload: false,
|
DeleteAfterDownload: false,
|
||||||
@@ -110,6 +114,7 @@ func TestDownloaderStoreConfig_Init(t *testing.T) {
|
|||||||
ProtocolConfig: map[string]any{},
|
ProtocolConfig: map[string]any{},
|
||||||
Proxy: &DownloaderProxyConfig{},
|
Proxy: &DownloaderProxyConfig{},
|
||||||
Webhook: &WebhookConfig{},
|
Webhook: &WebhookConfig{},
|
||||||
|
Script: &ScriptConfig{},
|
||||||
AutoTorrent: &AutoTorrentConfig{
|
AutoTorrent: &AutoTorrentConfig{
|
||||||
Enable: true,
|
Enable: true,
|
||||||
DeleteAfterDownload: true,
|
DeleteAfterDownload: true,
|
||||||
@@ -133,6 +138,7 @@ func TestDownloaderStoreConfig_Init(t *testing.T) {
|
|||||||
ProtocolConfig: map[string]any{},
|
ProtocolConfig: map[string]any{},
|
||||||
Proxy: &DownloaderProxyConfig{},
|
Proxy: &DownloaderProxyConfig{},
|
||||||
Webhook: &WebhookConfig{},
|
Webhook: &WebhookConfig{},
|
||||||
|
Script: &ScriptConfig{},
|
||||||
AutoTorrent: &AutoTorrentConfig{
|
AutoTorrent: &AutoTorrentConfig{
|
||||||
Enable: false,
|
Enable: false,
|
||||||
DeleteAfterDownload: false,
|
DeleteAfterDownload: false,
|
||||||
@@ -154,6 +160,7 @@ func TestDownloaderStoreConfig_Init(t *testing.T) {
|
|||||||
Extra: tt.fields.Extra,
|
Extra: tt.fields.Extra,
|
||||||
Proxy: tt.fields.Proxy,
|
Proxy: tt.fields.Proxy,
|
||||||
Webhook: tt.fields.Webhook,
|
Webhook: tt.fields.Webhook,
|
||||||
|
Script: tt.fields.Script,
|
||||||
AutoTorrent: tt.fields.AutoTorrent,
|
AutoTorrent: tt.fields.AutoTorrent,
|
||||||
Archive: tt.fields.Archive,
|
Archive: tt.fields.Archive,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -995,6 +995,7 @@ func (d *Downloader) watch(task *Task) {
|
|||||||
d.notifyRunning()
|
d.notifyRunning()
|
||||||
d.triggerOnDone(task)
|
d.triggerOnDone(task)
|
||||||
d.triggerWebhooks(WebhookEventDownloadDone, task, nil)
|
d.triggerWebhooks(WebhookEventDownloadDone, task, nil)
|
||||||
|
d.triggerScripts(ScriptEventDownloadDone, task, nil)
|
||||||
|
|
||||||
if e, ok := task.Meta.Opts.Extra.(*http.OptsExtra); ok {
|
if e, ok := task.Meta.Opts.Extra.(*http.OptsExtra); ok {
|
||||||
downloadFilePath := task.Meta.SingleFilepath()
|
downloadFilePath := task.Meta.SingleFilepath()
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
+9
@@ -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%"
|
||||||
+13
@@ -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"
|
||||||
+8
@@ -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
|
||||||
+10
@@ -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/"
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
@echo off
|
||||||
|
setlocal
|
||||||
|
|
||||||
|
if "%GOPEED_TEST_OUTPUT_FILE_1%"=="" exit /b 2
|
||||||
|
echo Script 1 > "%GOPEED_TEST_OUTPUT_FILE_1%"
|
||||||
@@ -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"
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
@echo off
|
||||||
|
setlocal
|
||||||
|
|
||||||
|
if "%GOPEED_TEST_OUTPUT_FILE_2%"=="" exit /b 2
|
||||||
|
echo Script 2 > "%GOPEED_TEST_OUTPUT_FILE_2%"
|
||||||
@@ -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"
|
||||||
@@ -10,6 +10,7 @@ class DownloaderConfig {
|
|||||||
ExtraConfig extra = ExtraConfig();
|
ExtraConfig extra = ExtraConfig();
|
||||||
ProxyConfig proxy = ProxyConfig();
|
ProxyConfig proxy = ProxyConfig();
|
||||||
WebhookConfig webhook = WebhookConfig();
|
WebhookConfig webhook = WebhookConfig();
|
||||||
|
ScriptConfig script = ScriptConfig();
|
||||||
AutoTorrentConfig autoTorrent = AutoTorrentConfig();
|
AutoTorrentConfig autoTorrent = AutoTorrentConfig();
|
||||||
ArchiveConfig archive = ArchiveConfig();
|
ArchiveConfig archive = ArchiveConfig();
|
||||||
bool autoDeleteMissingFileTasks;
|
bool autoDeleteMissingFileTasks;
|
||||||
@@ -149,6 +150,22 @@ class WebhookConfig {
|
|||||||
Map<String, dynamic> toJson() => _$WebhookConfigToJson(this);
|
Map<String, dynamic> toJson() => _$WebhookConfigToJson(this);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@JsonSerializable()
|
||||||
|
class ScriptConfig {
|
||||||
|
bool enable;
|
||||||
|
List<String> paths;
|
||||||
|
|
||||||
|
ScriptConfig({
|
||||||
|
this.enable = false,
|
||||||
|
this.paths = const [],
|
||||||
|
});
|
||||||
|
|
||||||
|
factory ScriptConfig.fromJson(Map<String, dynamic>? json) =>
|
||||||
|
json == null ? ScriptConfig() : _$ScriptConfigFromJson(json);
|
||||||
|
|
||||||
|
Map<String, dynamic> toJson() => _$ScriptConfigToJson(this);
|
||||||
|
}
|
||||||
|
|
||||||
@JsonSerializable()
|
@JsonSerializable()
|
||||||
class ProxyConfig {
|
class ProxyConfig {
|
||||||
bool enable;
|
bool enable;
|
||||||
|
|||||||
@@ -19,6 +19,8 @@ DownloaderConfig _$DownloaderConfigFromJson(Map<String, dynamic> json) =>
|
|||||||
..proxy = ProxyConfig.fromJson(json['proxy'] as Map<String, dynamic>)
|
..proxy = ProxyConfig.fromJson(json['proxy'] as Map<String, dynamic>)
|
||||||
..webhook =
|
..webhook =
|
||||||
WebhookConfig.fromJson(json['webhook'] as Map<String, dynamic>?)
|
WebhookConfig.fromJson(json['webhook'] as Map<String, dynamic>?)
|
||||||
|
..script =
|
||||||
|
ScriptConfig.fromJson(json['script'] as Map<String, dynamic>?)
|
||||||
..autoTorrent = AutoTorrentConfig.fromJson(
|
..autoTorrent = AutoTorrentConfig.fromJson(
|
||||||
json['autoTorrent'] as Map<String, dynamic>?)
|
json['autoTorrent'] as Map<String, dynamic>?)
|
||||||
..archive =
|
..archive =
|
||||||
@@ -32,6 +34,7 @@ Map<String, dynamic> _$DownloaderConfigToJson(DownloaderConfig instance) =>
|
|||||||
'extra': instance.extra.toJson(),
|
'extra': instance.extra.toJson(),
|
||||||
'proxy': instance.proxy.toJson(),
|
'proxy': instance.proxy.toJson(),
|
||||||
'webhook': instance.webhook.toJson(),
|
'webhook': instance.webhook.toJson(),
|
||||||
|
'script': instance.script.toJson(),
|
||||||
'autoTorrent': instance.autoTorrent.toJson(),
|
'autoTorrent': instance.autoTorrent.toJson(),
|
||||||
'archive': instance.archive.toJson(),
|
'archive': instance.archive.toJson(),
|
||||||
'autoDeleteMissingFileTasks': instance.autoDeleteMissingFileTasks,
|
'autoDeleteMissingFileTasks': instance.autoDeleteMissingFileTasks,
|
||||||
@@ -153,6 +156,20 @@ Map<String, dynamic> _$WebhookConfigToJson(WebhookConfig instance) =>
|
|||||||
'urls': instance.urls,
|
'urls': instance.urls,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
ScriptConfig _$ScriptConfigFromJson(Map<String, dynamic> json) =>
|
||||||
|
ScriptConfig(
|
||||||
|
enable: json['enable'] as bool? ?? false,
|
||||||
|
paths:
|
||||||
|
(json['paths'] as List<dynamic>?)?.map((e) => e as String).toList() ??
|
||||||
|
const [],
|
||||||
|
);
|
||||||
|
|
||||||
|
Map<String, dynamic> _$ScriptConfigToJson(ScriptConfig instance) =>
|
||||||
|
<String, dynamic>{
|
||||||
|
'enable': instance.enable,
|
||||||
|
'paths': instance.paths,
|
||||||
|
};
|
||||||
|
|
||||||
ProxyConfig _$ProxyConfigFromJson(Map<String, dynamic> json) => ProxyConfig(
|
ProxyConfig _$ProxyConfigFromJson(Map<String, dynamic> json) => ProxyConfig(
|
||||||
enable: json['enable'] as bool? ?? false,
|
enable: json['enable'] as bool? ?? false,
|
||||||
system: json['system'] as bool? ?? false,
|
system: json['system'] as bool? ?? false,
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import 'dart:async';
|
|||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
|
|
||||||
import 'package:badges/badges.dart' as badges;
|
import 'package:badges/badges.dart' as badges;
|
||||||
|
import 'package:file_picker/file_picker.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
import 'package:get/get.dart';
|
import 'package:get/get.dart';
|
||||||
@@ -1432,6 +1433,92 @@ class SettingView extends GetView<SettingController> {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// 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<String>.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
|
// advanced config log items start
|
||||||
buildLogsDir() {
|
buildLogsDir() {
|
||||||
return ListTile(
|
return ListTile(
|
||||||
@@ -1603,6 +1690,7 @@ class SettingView extends GetView<SettingController> {
|
|||||||
child: Column(
|
child: Column(
|
||||||
children: _addDivider([
|
children: _addDivider([
|
||||||
buildWebhook(),
|
buildWebhook(),
|
||||||
|
if (Util.isDesktop()) buildScript(),
|
||||||
buildLogsDir(),
|
buildLogsDir(),
|
||||||
]),
|
]),
|
||||||
)),
|
)),
|
||||||
@@ -1734,6 +1822,104 @@ class SettingView extends GetView<SettingController> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void _showScriptDialog({int? index, String? initialPath}) {
|
||||||
|
final pathController = TextEditingController(text: initialPath ?? '');
|
||||||
|
final saveController = TextButtonLoadingController();
|
||||||
|
final appController = Get.find<AppController>();
|
||||||
|
final downloaderCfg = appController.downloaderConfig;
|
||||||
|
final isEdit = index != null;
|
||||||
|
final formKey = GlobalKey<FormState>();
|
||||||
|
|
||||||
|
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<String>.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}) {
|
void _showGithubMirrorDialog({int? index, GithubMirror? initialMirror}) {
|
||||||
final isEdit = index != null;
|
final isEdit = index != null;
|
||||||
GithubMirrorType selectedType =
|
GithubMirrorType selectedType =
|
||||||
|
|||||||
@@ -80,6 +80,10 @@ const enUS = {
|
|||||||
'webhookTest': 'Test',
|
'webhookTest': 'Test',
|
||||||
'webhookTestSuccess': 'Webhook test successful',
|
'webhookTestSuccess': 'Webhook test successful',
|
||||||
'webhookTestFail': 'Webhook test failed',
|
'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',
|
'urlInvalid': 'Please enter a valid HTTP or HTTPS URL',
|
||||||
'required': 'This field is required',
|
'required': 'This field is required',
|
||||||
'show': 'Show',
|
'show': 'Show',
|
||||||
|
|||||||
@@ -78,6 +78,10 @@ const zhCN = {
|
|||||||
'webhookTest': '测试',
|
'webhookTest': '测试',
|
||||||
'webhookTestSuccess': 'Webhook 测试成功',
|
'webhookTestSuccess': 'Webhook 测试成功',
|
||||||
'webhookTestFail': 'Webhook 测试失败',
|
'webhookTestFail': 'Webhook 测试失败',
|
||||||
|
'script': '脚本执行',
|
||||||
|
'scriptEnable': '启用脚本',
|
||||||
|
'scriptDesc': '在下载成功完成时执行自定义脚本',
|
||||||
|
'scriptPathHint': '请输入脚本文件路径(例如:/path/to/script.sh)',
|
||||||
'urlInvalid': '请输入有效的 HTTP 或 HTTPS 链接',
|
'urlInvalid': '请输入有效的 HTTP 或 HTTPS 链接',
|
||||||
'required': '此项为必填项',
|
'required': '此项为必填项',
|
||||||
'show': '显示',
|
'show': '显示',
|
||||||
|
|||||||
Reference in new issue
Block a user