From 93d2812eb571cefb74410a9be1a77f8891ed375c Mon Sep 17 00:00:00 2001
From: "Silas, Mariusz Grzybacz" <silas@qnapclub.pl>
Date: Fri, 2 Oct 2026 20:13:32 +0000
Subject: [PATCH 1/3] feat(script): run scripts on DOWNLOAD_ERROR

The script runner already defines ScriptEventDownloadError, but nothing
fired it, so scripts only ran when a download finished. Trigger the
scripts next to the DOWNLOAD_ERROR webhook, once the task has settled in
the error status. Scripts get the same GOPEED_TASK_ID, GOPEED_TASK_NAME,
GOPEED_TASK_STATUS and GOPEED_TASK_PATH variables as for DOWNLOAD_DONE.
GOPEED_TASK_PATH is only set when the task was resolved before it failed.

Tests cover a task that fails while resolving (server answers 500) and a
resolved task that fails while running.
---
 pkg/download/downloader.go  |   1 +
 pkg/download/script_test.go | 144 ++++++++++++++++++++++++++++++++++++
 2 files changed, 145 insertions(+)

diff --git a/pkg/download/downloader.go b/pkg/download/downloader.go
index 4d60c7e..2ab39c7 100644
--- a/pkg/download/downloader.go
+++ b/pkg/download/downloader.go
@@ -1596,6 +1596,7 @@ func (d *Downloader) handleOnError(task *Task, err error, resetFetcher bool) {
 		d.emit(EventKeyFinally, task, err)
 		d.notifyRunning()
 		d.triggerWebhooks(WebhookEventDownloadError, task, err)
+		d.triggerScripts(ScriptEventDownloadError, task, err)
 	}
 }
 
diff --git a/pkg/download/script_test.go b/pkg/download/script_test.go
index d155579..7bfab86 100644
--- a/pkg/download/script_test.go
+++ b/pkg/download/script_test.go
@@ -1,8 +1,13 @@
 package download
 
 import (
+	"errors"
+	"net/http"
+	"net/http/httptest"
 	"os"
 	"path/filepath"
+	"runtime"
+	"strings"
 	"testing"
 	"time"
 
@@ -126,6 +131,106 @@ func TestScript_ExecuteScriptAtPath_NonExistentFile(t *testing.T) {
 	})
 }
 
+func TestScript_TriggerOnError(t *testing.T) {
+	scriptPath := getTestScriptPath(t, envDumpScriptName())
+	ensureScriptExecutable(t, scriptPath)
+	outputFile := filepath.Join(t.TempDir(), "env_output.txt")
+
+	server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		w.WriteHeader(http.StatusInternalServerError)
+	}))
+	defer server.Close()
+
+	setupScriptTest(t, func(downloader *Downloader) {
+		cfg, _ := downloader.GetConfig()
+		cfg.Script = &base.ScriptConfig{
+			Enable: true,
+			Paths:  []string{scriptPath},
+		}
+		downloader.PutConfig(cfg)
+
+		t.Setenv("GOPEED_TEST_OUTPUT_FILE", outputFile)
+		id, err := downloader.CreateDirect(&base.Request{URL: server.URL + "/error.bin"}, &base.Options{
+			Path: t.TempDir(),
+			Name: "error.bin",
+		})
+		if err != nil {
+			t.Fatalf("Failed to create task: %v", err)
+		}
+		waitForTaskError(t, downloader, id, 10*time.Second)
+
+		output := waitForFileContains(t, outputFile, "GOPEED_TASK_PATH=", 5*time.Second)
+		for _, want := range []string{
+			"GOPEED_EVENT=DOWNLOAD_ERROR",
+			"GOPEED_TASK_ID=" + id,
+			"GOPEED_TASK_NAME=error.bin",
+			"GOPEED_TASK_STATUS=" + string(base.DownloadStatusError),
+		} {
+			if !strings.Contains(output, want) {
+				t.Errorf("Expected %q in output, got: %s", want, output)
+			}
+		}
+	})
+}
+
+func TestScript_TriggerOnError_ResolvedTask(t *testing.T) {
+	scriptPath := getTestScriptPath(t, envDumpScriptName())
+	ensureScriptExecutable(t, scriptPath)
+	outputFile := filepath.Join(t.TempDir(), "env_output.txt")
+
+	manager := &generationTestManager{holdOpen: true}
+	downloader := NewDownloader(&DownloaderConfig{
+		FetchManagers: []fetcher.FetcherManager{manager},
+		Storage:       NewMemStorage(),
+	})
+	if err := downloader.Setup(); err != nil {
+		t.Fatal(err)
+	}
+	defer downloader.Clear()
+
+	cfg, _ := downloader.GetConfig()
+	cfg.Script = &base.ScriptConfig{
+		Enable: true,
+		Paths:  []string{scriptPath},
+	}
+	downloader.PutConfig(cfg)
+
+	t.Setenv("GOPEED_TEST_OUTPUT_FILE", outputFile)
+	id, err := downloader.CreateDirect(&base.Request{URL: "generation://script-error"}, &base.Options{
+		Path: t.TempDir(),
+		Name: "failed.bin",
+	})
+	if err != nil {
+		t.Fatalf("Failed to create task: %v", err)
+	}
+	deadline := time.Now().Add(2 * time.Second)
+	for manager.starts.Load() == 0 {
+		if time.Now().After(deadline) {
+			t.Fatal("Timeout waiting for the task to start")
+		}
+		time.Sleep(10 * time.Millisecond)
+	}
+
+	// The task is resolved and started, so the script gets the same path it gets on DOWNLOAD_DONE.
+	task := downloader.GetTask(id)
+	taskPath := task.Meta.SingleFilepath()
+	task.fetcher.(*generationTestFetcher).done <- errors.New("download failed mid-way")
+	waitForTaskError(t, downloader, id, 2*time.Second)
+
+	output := waitForFileContains(t, outputFile, "GOPEED_TASK_PATH=", 5*time.Second)
+	for _, want := range []string{
+		"GOPEED_EVENT=DOWNLOAD_ERROR",
+		"GOPEED_TASK_ID=" + id,
+		"GOPEED_TASK_NAME=failed.bin",
+		"GOPEED_TASK_STATUS=" + string(base.DownloadStatusError),
+		"GOPEED_TASK_PATH=" + taskPath,
+	} {
+		if !strings.Contains(output, want) {
+			t.Errorf("Expected %q in output, got: %s", want, output)
+		}
+	}
+}
+
 func createDownloadDoneTask(t *testing.T, downloadDir, fileName string) (*Task, string) {
 	t.Helper()
 	content := []byte("downloaded file")
@@ -171,6 +276,45 @@ func waitForFile(t *testing.T, path string, timeout time.Duration) {
 	t.Fatalf("Timeout waiting for file: %s", path)
 }
 
+// waitForFileContains waits until the file contains want and returns its content.
+// Test scripts write the file line by line, so waiting for the file to exist is not enough.
+func waitForFileContains(t *testing.T, path, want string, timeout time.Duration) string {
+	t.Helper()
+	deadline := time.Now().Add(timeout)
+	for time.Now().Before(deadline) {
+		if content, err := os.ReadFile(path); err == nil && strings.Contains(string(content), want) {
+			return string(content)
+		}
+		time.Sleep(50 * time.Millisecond)
+	}
+	content, _ := os.ReadFile(path)
+	t.Fatalf("Timeout waiting for %q in file %s, got: %q", want, path, content)
+	return ""
+}
+
+// waitForTaskError waits until the task is in the error status. Unlike
+// waitForTaskStatus it reads the status under the task's status lock.
+func waitForTaskError(t *testing.T, downloader *Downloader, id string, timeout time.Duration) {
+	t.Helper()
+	deadline := time.Now().Add(timeout)
+	for time.Now().Before(deadline) {
+		if task := downloader.GetTask(id); task != nil && downloader.taskStatus(task) == base.DownloadStatusError {
+			return
+		}
+		time.Sleep(10 * time.Millisecond)
+	}
+	t.Fatalf("Timeout waiting for task %s to fail", id)
+}
+
+// envDumpScriptName returns the test script that dumps the GOPEED_* variables
+// into the file named by GOPEED_TEST_OUTPUT_FILE.
+func envDumpScriptName() string {
+	if runtime.GOOS == "windows" {
+		return "env_dump.bat"
+	}
+	return "env_dump.sh"
+}
+
 func getTestScriptPath(t *testing.T, name string) string {
 	t.Helper()
 	path := filepath.Join("testdata", "scripts", name)
-- 
2.55.0


From b79144914e965613adf5669f43ae531ce20fe64b Mon Sep 17 00:00:00 2001
From: "Silas, Mariusz Grzybacz" <silas@qnapclub.pl>
Date: Fri, 2 Oct 2026 20:21:28 +0000
Subject: [PATCH 2/3] feat(ui): say scripts run when a download completes or
 fails

Scripts now also run on DOWNLOAD_ERROR, so the two settings strings that
said "completes successfully" were wrong. Update the English and Chinese
values to match the webhook strings. The keys stay the same, so every
locale still has the same key set as app_en.arb; the other locales keep
their current text.
---
 ui/flutter/lib/l10n/app_en.arb | 4 ++--
 ui/flutter/lib/l10n/app_zh.arb | 4 ++--
 2 files changed, 4 insertions(+), 4 deletions(-)

diff --git a/ui/flutter/lib/l10n/app_en.arb b/ui/flutter/lib/l10n/app_en.arb
index 883bed0..a91b498 100644
--- a/ui/flutter/lib/l10n/app_en.arb
+++ b/ui/flutter/lib/l10n/app_en.arb
@@ -91,7 +91,7 @@
   "webhookTestFail": "Webhook test failed",
   "script": "Script",
   "scriptEnable": "Enable Scripts",
-  "scriptDesc": "Execute custom scripts when downloads complete successfully",
+  "scriptDesc": "Execute custom scripts when downloads complete or fail",
   "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",
@@ -311,7 +311,7 @@
   "webhookPush": "Endpoints",
   "webhookPushDescription": "Send HTTP POST notifications to these endpoints when tasks complete or fail",
   "scriptExecution": "Scripts",
-  "scriptExecutionDescription": "Run these scripts after a download completes successfully",
+  "scriptExecutionDescription": "Run these scripts after a download completes or fails",
   "addWebhook": "Add Webhook",
   "editWebhook": "Edit Webhook",
   "addScript": "Add Script",
diff --git a/ui/flutter/lib/l10n/app_zh.arb b/ui/flutter/lib/l10n/app_zh.arb
index fed70a8..8f7505f 100644
--- a/ui/flutter/lib/l10n/app_zh.arb
+++ b/ui/flutter/lib/l10n/app_zh.arb
@@ -84,7 +84,7 @@
   "webhookTestFail": "Webhook 测试失败",
   "script": "脚本执行",
   "scriptEnable": "启用脚本",
-  "scriptDesc": "在下载成功完成时执行自定义脚本",
+  "scriptDesc": "在下载完成或失败时执行自定义脚本",
   "scriptPathHint": "请输入脚本文件路径（例如：/path/to/script.sh）",
   "urlInvalid": "请输入有效的 HTTP 或 HTTPS 链接",
   "required": "此项为必填项",
@@ -287,7 +287,7 @@
   "webhookPush": "Webhook 地址",
   "webhookPushDescription": "任务完成或失败时向这些地址发送 HTTP POST 通知",
   "scriptExecution": "脚本",
-  "scriptExecutionDescription": "下载成功完成后执行这些脚本",
+  "scriptExecutionDescription": "下载完成或失败后执行这些脚本",
   "addWebhook": "添加 Webhook",
   "editWebhook": "编辑 Webhook",
   "addScript": "添加脚本",
-- 
2.55.0


From 4ef5f0d0f0149b110a17462f015ecd2652a7d514 Mon Sep 17 00:00:00 2001
From: "Silas, Mariusz Grzybacz" <silas@qnapclub.pl>
Date: Fri, 2 Oct 2026 20:21:28 +0000
Subject: [PATCH 3/3] test(script): reuse waitForTaskStatus in the error tests

Drop the new waitForTaskError helper and wait for the error status with
the package's existing waitForTaskStatus.
---
 pkg/download/script_test.go | 18 ++----------------
 1 file changed, 2 insertions(+), 16 deletions(-)

diff --git a/pkg/download/script_test.go b/pkg/download/script_test.go
index 7bfab86..f009112 100644
--- a/pkg/download/script_test.go
+++ b/pkg/download/script_test.go
@@ -157,7 +157,7 @@ func TestScript_TriggerOnError(t *testing.T) {
 		if err != nil {
 			t.Fatalf("Failed to create task: %v", err)
 		}
-		waitForTaskError(t, downloader, id, 10*time.Second)
+		waitForTaskStatus(t, downloader, id, base.DownloadStatusError, 10*time.Second)
 
 		output := waitForFileContains(t, outputFile, "GOPEED_TASK_PATH=", 5*time.Second)
 		for _, want := range []string{
@@ -215,7 +215,7 @@ func TestScript_TriggerOnError_ResolvedTask(t *testing.T) {
 	task := downloader.GetTask(id)
 	taskPath := task.Meta.SingleFilepath()
 	task.fetcher.(*generationTestFetcher).done <- errors.New("download failed mid-way")
-	waitForTaskError(t, downloader, id, 2*time.Second)
+	waitForTaskStatus(t, downloader, id, base.DownloadStatusError, 2*time.Second)
 
 	output := waitForFileContains(t, outputFile, "GOPEED_TASK_PATH=", 5*time.Second)
 	for _, want := range []string{
@@ -292,20 +292,6 @@ func waitForFileContains(t *testing.T, path, want string, timeout time.Duration)
 	return ""
 }
 
-// waitForTaskError waits until the task is in the error status. Unlike
-// waitForTaskStatus it reads the status under the task's status lock.
-func waitForTaskError(t *testing.T, downloader *Downloader, id string, timeout time.Duration) {
-	t.Helper()
-	deadline := time.Now().Add(timeout)
-	for time.Now().Before(deadline) {
-		if task := downloader.GetTask(id); task != nil && downloader.taskStatus(task) == base.DownloadStatusError {
-			return
-		}
-		time.Sleep(10 * time.Millisecond)
-	}
-	t.Fatalf("Timeout waiting for task %s to fail", id)
-}
-
 // envDumpScriptName returns the test script that dumps the GOPEED_* variables
 // into the file named by GOPEED_TEST_OUTPUT_FILE.
 func envDumpScriptName() string {
-- 
2.55.0

