feat: add post-download actions (on-complete and on-error shell hooks)#323
feat: add post-download actions (on-complete and on-error shell hooks)#323mvanhorn wants to merge 4 commits intoSurgeDM:mainfrom
Conversation
Add configurable shell commands that run after a download completes or
fails. Commands support template variables ({filename}, {filepath},
{size}, {speed}, {duration}, {id}, {error}) for flexible automation.
Actions run in a goroutine so they never block the download lifecycle.
A new "Post-Download" settings tab exposes the configuration in the TUI.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Binary Size Analysis
|
|
Fixed the shell injection in The P1 (empty error routing to OnComplete) is a theoretical edge case since error messages are always non-empty in practice. The P2 about redundant |
TestExpandTemplate hardcoded single-quoted expected values, which made it pass on Unix but fail on windows-latest where shellEscape wraps values in double quotes. Rebuild the expected strings by calling shellEscape directly so the test validates template substitution without pinning a particular quoting style.
|
Fixed the Windows CI failures. |
| go RunPostActions(settings.General.PostDownload, PostActionContext{ | ||
| Filename: filename, | ||
| FilePath: m.DestPath, | ||
| ID: m.DownloadID, |
There was a problem hiding this comment.
m.DestPath instead of resolved destPath in error hook
The error handler builds a corrected destPath by falling back to existing.DestPath when m.DestPath is empty (lines 308–323), and then correctly uses that variable for RemoveIncompleteFile. The RunPostActions call on line 347 bypasses that fix and passes m.DestPath directly, so {filepath} will be an empty string in error hooks whenever the message carries no path but the DB does — the same scenario the complete handler explicitly documents ("DownloadCompleteMsg does not carry destPath, so we recover the stable final location from the DB entry").
| ID: m.DownloadID, | |
| FilePath: destPath, |
Prompt To Fix With AI
This is a comment left during a code review.
Path: internal/processing/events.go
Line: 347
Comment:
**`m.DestPath` instead of resolved `destPath` in error hook**
The error handler builds a corrected `destPath` by falling back to `existing.DestPath` when `m.DestPath` is empty (lines 308–323), and then correctly uses that variable for `RemoveIncompleteFile`. The `RunPostActions` call on line 347 bypasses that fix and passes `m.DestPath` directly, so `{filepath}` will be an empty string in error hooks whenever the message carries no path but the DB does — the same scenario the complete handler explicitly documents ("DownloadCompleteMsg does not carry destPath, so we recover the stable final location from the DB entry").
```suggestion
FilePath: destPath,
```
How can I resolve this? If you propose a fix, please make it concise.
Summary
Add configurable shell commands that run after a download completes or fails. Commands support template variables for flexible automation.
Why this matters
aria2 has
--on-download-completehooks. wget has--executefor post-processing. IDM and JDownloader have post-download actions. Surge currently has no way to automate what happens after a download finishes.With Surge's daemon architecture, this is especially useful for headless servers and Raspberry Pi setups where users want downloads to auto-extract, move to media directories, or trigger notifications.
Changes
PostDownloadActionsconfig struct withon_complete_commandandon_error_commandfields tosettings.jsonRunPostActions()ininternal/processing/post_actions.gowith template variable substitution ({filename},{filepath},{size},{speed},{duration},{id},{error})StartEventWorkerfor bothDownloadCompleteMsgandDownloadErrorMsgeventssh -con Unix,cmd /Con WindowsTesting
All tests pass including new tests for template expansion and command execution:
This contribution was developed with AI assistance (Codex + Claude Code).
Greptile Summary
This PR adds configurable post-download shell hooks (
on_complete_command/on_error_command) with template variable substitution, wired into theStartEventWorkerevent loop via goroutines. Prior review concerns about shell injection and error-routing via empty string have been resolved withshellEscapeand an explicitisError boolparameter.DownloadErrorMsghandler,RunPostActionsreceivesm.DestPath(the raw event field) instead of the locally-resolveddestPathvariable, so{filepath}is empty whenever the download path is recovered from the DB — the exact scenario the complete handler explicitly guards against.Confidence Score: 4/5
One P1 bug in the error path should be fixed before merging; all other findings are P2.
The m.DestPath vs destPath mismatch in the error handler is a present defect that silently delivers an empty {filepath} to users' error hooks. Everything else — injection hardening, error routing, config struct, and tests — is solid. Fix that one line and this is merge-ready.
internal/processing/events.go — specifically the DownloadErrorMsg RunPostActions call at line 347.
Important Files Changed
Sequence Diagram
sequenceDiagram participant Engine participant EventWorker as StartEventWorker participant DB as state (DB) participant PostActions as RunPostActions participant Shell Engine->>EventWorker: DownloadCompleteMsg EventWorker->>DB: GetDownload(id) → destPath, filename EventWorker->>DB: AddToMasterList(completed) EventWorker->>PostActions: go RunPostActions(settings, ctx{destPath}, isError=false) PostActions->>PostActions: expandTemplate → shellEscape vars PostActions->>Shell: sh -c on_complete_command Shell-->>PostActions: output / error (logged) Engine->>EventWorker: DownloadErrorMsg EventWorker->>DB: GetDownload(id) → existing EventWorker->>DB: AddToMasterList(error) EventWorker->>PostActions: go RunPostActions(settings, ctx{m.DestPath ⚠️}, isError=true) PostActions->>PostActions: expandTemplate → shellEscape vars PostActions->>Shell: sh -c on_error_command Shell-->>PostActions: output / error (logged)Comments Outside Diff (1)
internal/tui/view_settings.go, line 247-283 (link)getSettingsValueshas no"Post-Download"case, so the tab always renders empty values.setSettingValue(line 298) andresetSettingToDefault(line 631) also lack this case, meaning every edit the user types is silently discarded — the feature is only configurable by hand-editingsettings.json.Three switch blocks all need a
"Post-Download"arm. For example ingetSettingsValues:And in
setSettingValue, route to a new helper:And in
resetSettingToDefault:Rule Used: What: Enforce separation of concerns by keeping bu... (source)
Prompt To Fix With AI
Prompt To Fix All With AI
Reviews (4): Last reviewed commit: "test(post-actions): derive expected quot..." | Re-trigger Greptile