Skip to content
Open
18 changes: 14 additions & 4 deletions internal/config/settings.go
Original file line number Diff line number Diff line change
Expand Up @@ -28,9 +28,15 @@ type GeneralSettings struct {
CategoryEnabled bool `json:"category_enabled"`
Categories []Category `json:"categories"`

ClipboardMonitor bool `json:"clipboard_monitor"`
Theme int `json:"theme"`
LogRetentionCount int `json:"log_retention_count"`
ClipboardMonitor bool `json:"clipboard_monitor"`
Theme int `json:"theme"`
LogRetentionCount int `json:"log_retention_count"`
PostDownload PostDownloadActions `json:"post_download"`
}

type PostDownloadActions struct {
OnCompleteCommand string `json:"on_complete_command"`
OnErrorCommand string `json:"on_error_command"`
}

const (
Expand Down Expand Up @@ -86,6 +92,10 @@ func GetSettingsMetadata() map[string][]SettingMeta {
"Categories": {
{Key: "category_enabled", Label: "Manage Categories", Description: "Sort downloads into subfolders by file type. Press Enter to open Category Manager.", Type: "bool"},
},
"Post-Download": {
{Key: "on_complete_command", Label: "On Complete Command", Description: "Shell command to run after a download completes. Variables: {filename}, {filepath}, {size}, {speed}, {duration}, {id}", Type: "string"},
{Key: "on_error_command", Label: "On Error Command", Description: "Shell command to run when a download fails. Variables: {filename}, {filepath}, {id}, {error}", Type: "string"},
},
"Network": {
{Key: "max_connections_per_host", Label: "Max Connections/Host", Description: "Maximum concurrent connections per host (1-64).", Type: "int"},
{Key: "max_concurrent_downloads", Label: "Max Concurrent Downloads", Description: "Maximum number of downloads running at once (1-10). Requires restart.", Type: "int"},
Expand All @@ -107,7 +117,7 @@ func GetSettingsMetadata() map[string][]SettingMeta {

// CategoryOrder returns the order of categories for UI tabs.
func CategoryOrder() []string {
return []string{"General", "Network", "Performance", "Categories"}
return []string{"General", "Post-Download", "Network", "Performance", "Categories"}
}

const (
Expand Down
2 changes: 1 addition & 1 deletion internal/config/settings_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -453,7 +453,7 @@ func TestCategoryOrder(t *testing.T) {
}

// Should have all expected categories
expectedCount := 4 // General, Network, Performance, Categories
expectedCount := 5 // General, Post-Download, Network, Performance, Categories
if len(order) != expectedCount {
t.Errorf("Expected %d categories, got %d", expectedCount, len(order))
}
Expand Down
43 changes: 33 additions & 10 deletions internal/processing/events.go
Original file line number Diff line number Diff line change
Expand Up @@ -271,7 +271,8 @@ func (mgr *LifecycleManager) StartEventWorker(ch <-chan interface{}) {
if err := state.DeleteTasks(m.DownloadID); err != nil {
utils.Debug("Lifecycle: Failed to delete completed tasks: %v", err)
}
if settings := mgr.GetSettings(); settings != nil && settings.General.DownloadCompleteNotification {
settings := mgr.GetSettings()
if settings != nil && settings.General.DownloadCompleteNotification {

if filename == "" {
filename = m.Filename
Expand All @@ -288,10 +289,28 @@ func (mgr *LifecycleManager) StartEventWorker(ch <-chan interface{}) {
notify(title, fmt.Sprintf("Download complete in %s (%.2f MB/s)", m.Elapsed.Truncate(time.Second), avgSpeed/float64(types.MB)))
}
}
if settings != nil {
// Run post-download actions
Comment thread
SuperCoolPencil marked this conversation as resolved.
Outdated
go RunPostActions(settings.General.PostDownload, PostActionContext{
Filename: filename,
FilePath: destPath,
Size: m.Total,
Speed: avgSpeed,
Duration: m.Elapsed,
ID: m.DownloadID,
})
}

case events.DownloadErrorMsg:
existing, _ := state.GetDownload(m.DownloadID)
destPath := m.DestPath
filename := m.Filename
if filename == "" && existing != nil {
filename = existing.Filename
}
if filename == "" {
filename = m.DownloadID
}
if existing != nil {
existing.Status = "error"
if err := state.AddToMasterList(*existing); err != nil {
Expand All @@ -307,22 +326,26 @@ func (mgr *LifecycleManager) StartEventWorker(ch <-chan interface{}) {
}
}
if settings := mgr.GetSettings(); settings != nil && settings.General.DownloadCompleteNotification {

filename := m.Filename
if filename == "" && existing != nil {
filename = existing.Filename
}
if filename == "" {
filename = m.DownloadID
}

msg := "Download failed"
if m.Err != nil {
msg = m.Err.Error()
}

notify(fmt.Sprintf("Download failed: %s", filename), msg)
}
// Run post-download error action
if settings := mgr.GetSettings(); settings != nil && settings.General.PostDownload.OnErrorCommand != "" {
Comment thread
SuperCoolPencil marked this conversation as resolved.
Outdated
errMsg := ""
if m.Err != nil {
errMsg = m.Err.Error()
}
go RunPostActions(settings.General.PostDownload, PostActionContext{
Filename: filename,
FilePath: m.DestPath,
ID: m.DownloadID,
Comment thread
greptile-apps[bot] marked this conversation as resolved.
Error: errMsg,
})
}

case events.DownloadRemovedMsg:
// Remove resume metadata before touching files so a deleted download does not
Expand Down
66 changes: 66 additions & 0 deletions internal/processing/post_actions.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
package processing

import (
"fmt"
"os/exec"
"runtime"
"strings"
"time"

"github.com/SurgeDM/Surge/internal/config"
"github.com/SurgeDM/Surge/internal/utils"
)

// PostActionContext holds information about a completed download for template substitution.
type PostActionContext struct {
Filename string
FilePath string
Size int64
Speed float64
Duration time.Duration
ID string
Error string
}

// expandTemplate replaces {variable} placeholders with actual values.
func expandTemplate(template string, ctx PostActionContext) string {
r := strings.NewReplacer(
"{filename}", ctx.Filename,
"{filepath}", ctx.FilePath,
"{size}", fmt.Sprintf("%d", ctx.Size),
"{speed}", fmt.Sprintf("%.2f", ctx.Speed),
"{duration}", ctx.Duration.Truncate(time.Second).String(),
"{id}", ctx.ID,
"{error}", ctx.Error,
)
return r.Replace(template)
Comment thread
greptile-apps[bot] marked this conversation as resolved.
}

// RunPostActions runs configured post-download actions.
// Errors are logged but never propagated to prevent post-action failures from
// corrupting the download lifecycle.
func RunPostActions(settings config.PostDownloadActions, ctx PostActionContext) {
cmd := settings.OnCompleteCommand
if ctx.Error != "" {
cmd = settings.OnErrorCommand
}
Comment thread
greptile-apps[bot] marked this conversation as resolved.
Outdated
if cmd == "" {
return
}

expanded := expandTemplate(cmd, ctx)
utils.Debug("PostAction: executing %q", expanded)

var c *exec.Cmd
if runtime.GOOS == "windows" {
c = exec.Command("cmd", "/C", expanded)
} else {
c = exec.Command("sh", "-c", expanded)
}

if output, err := c.CombinedOutput(); err != nil {
utils.Debug("PostAction: command failed: %v (output: %s)", err, string(output))
} else {
utils.Debug("PostAction: command succeeded (output: %s)", string(output))
}
}
64 changes: 64 additions & 0 deletions internal/processing/post_actions_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
package processing

import (
"testing"
"time"

"github.com/SurgeDM/Surge/internal/config"
"github.com/stretchr/testify/assert"
)

func TestExpandTemplate(t *testing.T) {
ctx := PostActionContext{
Filename: "test.zip",
FilePath: "/downloads/test.zip",
Size: 1048576,
Speed: 524288.0,
Duration: 2 * time.Second,
ID: "abc123",
Error: "",
}

tests := []struct {
name string
template string
want string
}{
{"filename", "echo {filename}", "echo test.zip"},
{"filepath", "mv {filepath} /done/", "mv /downloads/test.zip /done/"},
{"all vars", "{id}: {filename} ({size} bytes, {speed} B/s, {duration})", "abc123: test.zip (1048576 bytes, 524288.00 B/s, 2s)"},
{"no vars", "echo done", "echo done"},
{"empty", "", ""},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := expandTemplate(tt.template, ctx)
assert.Equal(t, tt.want, got)
})
}
}

func TestRunPostActions_EmptyCommand(t *testing.T) {
// Should not panic or error with empty commands
RunPostActions(config.PostDownloadActions{}, PostActionContext{
Filename: "test.zip",
})
}

func TestRunPostActions_ValidCommand(t *testing.T) {
RunPostActions(config.PostDownloadActions{
OnCompleteCommand: "echo {filename}",
}, PostActionContext{
Filename: "test.zip",
})
}

func TestRunPostActions_ErrorPath(t *testing.T) {
RunPostActions(config.PostDownloadActions{
OnErrorCommand: "echo error: {error}",
}, PostActionContext{
Filename: "test.zip",
Error: "connection reset",
})
}
Loading