Skip to content
Merged
Show file tree
Hide file tree
Changes from 16 commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
7cf859f
feat(automation): add execute command action
0rkag Jan 20, 2026
a51396a
chore(automation): clean up code
0rkag Jan 21, 2026
a68d38f
feat(automation): add execute command action to automation configuration
0rkag Jan 21, 2026
4777910
fix(automation): improve external program execution reliability and s…
0rkag Jan 21, 2026
30f8671
docs(automation): add execute command automation action documentation
0rkag Jan 21, 2026
1ed928e
chore(automation): lint & format
0rkag Jan 21, 2026
065bae5
fix(automation): add ExternalProgram validation to create/update path…
0rkag Jan 21, 2026
eeb30c4
feat(externalprograms): add support for macOS and cross-platform term…
0rkag Jan 21, 2026
067ea49
fix(automations): add ExternalProgram to conditionsUseField check
0rkag Jan 21, 2026
bdd695d
fix(automation): improve external program error handling and cascade …
0rkag Jan 21, 2026
51ae0ae
chore(automation): fix linter errors
0rkag Jan 21, 2026
e00a2fc
fix(web): fix extractErrorData method calls and improve type safety
0rkag Jan 21, 2026
ad702ad
chore(automation): implement coderabbit suggestions
0rkag Jan 21, 2026
7149bd2
fix(automation): improve external program handling and add tests
0rkag Jan 21, 2026
751f1e6
fix(automation): improve external program validation
0rkag Jan 21, 2026
52ab6af
fix(ui): respect base path for settings link
s0up4200 Feb 2, 2026
f6c7e6e
fix(ui): show loading state for external programs
s0up4200 Feb 2, 2026
8053220
chore: merge origin/develop
s0up4200 Feb 2, 2026
36fc2c9
fix(ui): label external program activity
s0up4200 Feb 2, 2026
6de494a
fix: adjust external program validation and labels
s0up4200 Feb 2, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 8 additions & 3 deletions cmd/qui/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ import (
"github.com/autobrr/qui/internal/services/automations"
"github.com/autobrr/qui/internal/services/crossseed"
"github.com/autobrr/qui/internal/services/dirscan"
"github.com/autobrr/qui/internal/services/externalprograms"
"github.com/autobrr/qui/internal/services/filesmanager"
"github.com/autobrr/qui/internal/services/jackett"
"github.com/autobrr/qui/internal/services/license"
Expand Down Expand Up @@ -572,13 +573,16 @@ func (app *Application) runServer() {
arrService := arr.NewService(arrInstanceStore, arrIDCacheStore)
log.Info().Msg("ARR service initialized")

// Initialize automation activity store and external programs service
automationActivityStore := models.NewAutomationActivityStore(db)
externalProgramService := externalprograms.NewService(externalProgramStore, automationActivityStore, cfg.Config)

// Initialize cross-seed automation store and service
crossSeedStore := models.NewCrossSeedStore(db)
instanceCrossSeedCompletionStore := models.NewInstanceCrossSeedCompletionStore(db)
crossSeedService := crossseed.NewService(instanceStore, syncManager, filesManagerService, crossSeedStore, jackettService, arrService, externalProgramStore, instanceCrossSeedCompletionStore, trackerCustomizationStore, cfg.Config.CrossSeedRecoverErroredTorrents)
crossSeedService := crossseed.NewService(instanceStore, syncManager, filesManagerService, crossSeedStore, jackettService, arrService, externalProgramStore, externalProgramService, instanceCrossSeedCompletionStore, trackerCustomizationStore, cfg.Config.CrossSeedRecoverErroredTorrents)
reannounceService := reannounce.NewService(reannounce.DefaultConfig(), instanceStore, instanceReannounceStore, reannounceSettingsCache, clientPool, syncManager)
automationActivityStore := models.NewAutomationActivityStore(db)
automationService := automations.NewService(automations.DefaultConfig(), instanceStore, automationStore, automationActivityStore, trackerCustomizationStore, syncManager)
automationService := automations.NewService(automations.DefaultConfig(), instanceStore, automationStore, automationActivityStore, trackerCustomizationStore, syncManager, externalProgramService)

orphanScanStore := models.NewOrphanScanStore(db)
orphanScanService := orphanscan.NewService(orphanscan.DefaultConfig(), instanceStore, orphanScanStore, syncManager)
Expand Down Expand Up @@ -685,6 +689,7 @@ func (app *Application) runServer() {
ReannounceService: reannounceService,
ClientAPIKeyStore: clientAPIKeyStore,
ExternalProgramStore: externalProgramStore,
ExternalProgramService: externalProgramService,
ClientPool: clientPool,
SyncManager: syncManager,
LicenseService: licenseService,
Expand Down
54 changes: 51 additions & 3 deletions documentation/docs/features/automations.md
Original file line number Diff line number Diff line change
Expand Up @@ -241,6 +241,40 @@ Move torrents to a different path on disk. This is needed to move the contents i
Options:
- **Skip if cross-seeds don't match the rule's conditions** - Skip the move if the torrent has cross-seeds that don't match the rule's conditions

### External Program

Run a pre-configured external program when torrents match the automation rule. Uses the same programs configured in **Settings → External Programs**.

| Field | Description |
|-------|-------------|
| **Program** | Select from enabled external programs |
| **Condition Override** | Optional condition specific to this action |

**Behavior:**

- Executes asynchronously (fire-and-forget) to avoid blocking automation processing
- Can be combined with other actions (speed limits, share limits, pause, tag, category)
- Only enabled programs appear in the dropdown
- Activity is logged with rule name, torrent details, and success/failure status

:::note
The program must be enabled in Settings → External Programs to appear in the automation dropdown.
:::

:::note
When multiple rules match the same torrent with External Program actions enabled, the **last matching rule** (by sort order) determines which program executes for that torrent. Only one program runs per torrent per automation cycle.
:::

:::warning
The program's executable path must be present in the application's allowlist. Programs that are disabled or have forbidden paths will not run—attempts are rejected and logged in the activity log with the rule name and torrent details.
:::

**Use cases:**
- Run post-processing scripts when torrents complete
- Notify external systems (webhooks, notifications) when conditions are met
- Trigger media library scans after category changes
- Execute cleanup scripts for old or stalled torrents

## Cross-Seed Awareness

Automations detect cross-seeded torrents (same content/files) and can handle them specially:
Expand Down Expand Up @@ -291,9 +325,9 @@ Only sends API calls when the torrent's current setting differs from the desired

### Processing Order

- **First match wins** for exclusive actions (delete, category)
- **Accumulative** for combinable actions (tags, speed limits)
- Delete ends torrent processing (no further rules evaluated)
- **First match wins** for delete actions (delete ends torrent processing, no further rules evaluated)
- **Last rule wins** for speed limits, share limits, category, and external program actions
- **Accumulative** for tag actions (tags are combined across matching rules)

### Free Space Condition Behavior

Expand Down Expand Up @@ -434,3 +468,17 @@ When a torrent matches, any other torrents pointing to the same downloaded files
Move torrents to tracker-named categories:
- Tracker: `tracker.example.com`
- Action: Category "example" with "Include Cross-Seeds" enabled

### Post-Processing on Completion

Run a script when torrents finish downloading:
- Tracker: `*`
- Condition: `State is completed` AND `Progress = 100`
- Action: External Program "post-process.sh"

### Notify on Stalled Torrents

Alert an external monitoring system when torrents stall:
- Tracker: `*`
- Condition: `State is stalled` AND `Last Activity Age > 24 hours`
- Action: External Program "send-alert" + Tag "stalled" (mode: add)
Comment thread
0rkag marked this conversation as resolved.
77 changes: 76 additions & 1 deletion documentation/docs/features/external-programs.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ External programs always run on the same machine (or container) that is hosting
| **Program Path** | Absolute path to the executable or script. Use the host path seen by the qui backend (e.g. `/usr/local/bin/my-script.sh`, `C:\Scripts\postprocess.bat`, `C:\python312\python.exe`). |
| **Arguments Template** | Optional string of command-line arguments. qui substitutes torrent metadata placeholders before spawning the process. |
| **Path Mappings** | Optional array of `from → to` prefixes that rewrite remote qBittorrent paths into local mount points. Helpful when qui runs locally but qBittorrent stores data elsewhere. |
| **Launch in terminal window** | Opens the program in an interactive terminal (`cmd.exe` on Windows, first available emulator on Linux/macOS). Disable for GUI apps or background daemons. |
| **Launch in terminal window** | Opens the program in an interactive terminal window. See [Supported Terminal Emulators](#supported-terminal-emulators) for the list of detected terminals. Disable for GUI apps or background daemons. |
| **Enable this program** | Determines whether the program shows up in the torrent context menu. |

## Torrent Placeholders
Expand Down Expand Up @@ -90,6 +90,34 @@ Given the template above, `{save_path}` becomes `/mnt/qbt/Movies` instead of `/d

Programs run asynchronously - qui does not wait for completion.

### Supported Terminal Emulators

When "Launch in terminal window" is enabled, qui automatically detects and uses an available terminal emulator. Detection priority:

1. **TERM_PROGRAM environment variable** - If qui is running inside a terminal, that terminal is preferred
2. **Cross-platform terminals** (checked on all platforms):
- WezTerm
- Hyper
- Kitty
- Alacritty
3. **Linux terminals**:
- GNOME Terminal
- Konsole
- Xfce4 Terminal
- MATE Terminal
- xterm
- Terminator
4. **macOS native terminals**:
- iTerm2
- Terminal.app
5. **Fallback**: If no terminal is found, the command runs in the background via `sh -c`

On Windows, `cmd.exe` is always used.

:::tip
Terminal windows stay open after the command finishes, allowing you to inspect output. Close the window manually when done.
:::

## Executing Programs

1. Select one or more torrents
Expand Down Expand Up @@ -126,8 +154,55 @@ Content-Type: application/json

The response contains a `results` array with per-hash `success` flags and optional error messages. Treat the endpoint as fire-and-forget; it returns once the processes have been spawned.

## Automation Integration

External programs can be triggered automatically via automation rules, allowing you to run scripts when torrents match specific conditions.

### Setting Up Automation Triggers

1. Create and enable an external program in **Settings → External Programs**
2. Go to **Automations** and create or edit a rule
3. Add an **External Program** action and select your program
4. Optionally add a condition override specific to this action

### Behavior

| Aspect | Description |
|--------|-------------|
| **Execution** | Programs run asynchronously (fire-and-forget) to avoid blocking automation processing |
| **Configuration** | Uses the same program settings (path, arguments, path mappings) as manual execution |
| **Availability** | Only enabled programs appear in the automation dropdown |
| **Combinable** | Can be combined with other actions (speed limits, share limits, pause, tag, category) |

### Activity Logging

Automation-triggered executions are logged in the activity feed with:
- Rule name and rule ID that triggered the execution
- Torrent name and hash
- Success or failure status
- Error details if the program failed to start

:::note
Success is logged after the program actually starts, not when queued. If the program fails to start (e.g., executable not found, permission denied), the error is captured and logged.
:::

### Example Use Cases

**Post-processing completed downloads:**
- Condition: `State is completed`
- Action: External Program that runs a media processing script

**Webhook notifications:**
- Condition: `Is Unregistered is true`
- Action: External Program that sends a notification via curl/webhook

**Media library scans:**
- Condition: Category changed to "movies" (use category action + external program)
- Action: External Program that triggers Plex/Jellyfin scan

## Troubleshooting

- **Docker**: The executable must be inside the container or bind-mounted.
- **Paths are wrong**: Add or adjust path mappings so `{save_path}` and `{content_path}` resolve to local mount points.
- **Multiple torrents**: The program runs once per torrent. Ensure your script handles concurrent executions or uses a locking mechanism.
- **Automation not triggering**: Ensure the program is enabled in Settings → External Programs. Disabled programs do not appear in automation dropdowns.
46 changes: 36 additions & 10 deletions internal/api/handlers/automations.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,18 +22,20 @@ import (
)

type AutomationHandler struct {
store *models.AutomationStore
activityStore *models.AutomationActivityStore
instanceStore *models.InstanceStore
service *automations.Service
store *models.AutomationStore
activityStore *models.AutomationActivityStore
instanceStore *models.InstanceStore
externalProgramStore *models.ExternalProgramStore
service *automations.Service
}

func NewAutomationHandler(store *models.AutomationStore, activityStore *models.AutomationActivityStore, instanceStore *models.InstanceStore, service *automations.Service) *AutomationHandler {
func NewAutomationHandler(store *models.AutomationStore, activityStore *models.AutomationActivityStore, instanceStore *models.InstanceStore, externalProgramStore *models.ExternalProgramStore, service *automations.Service) *AutomationHandler {
return &AutomationHandler{
store: store,
activityStore: activityStore,
instanceStore: instanceStore,
service: service,
store: store,
activityStore: activityStore,
instanceStore: instanceStore,
externalProgramStore: externalProgramStore,
service: service,
}
}

Expand Down Expand Up @@ -350,6 +352,26 @@ func (h *AutomationHandler) validatePayload(ctx context.Context, instanceID int,
return status, msg, err
}

// Validate ExternalProgram action has a valid programId when enabled
if err := payload.Conditions.ExternalProgram.Validate(); err != nil {
return http.StatusBadRequest, "External program action requires a valid program selection", err
}

// Verify the referenced external program exists
if payload.Conditions.ExternalProgram != nil && payload.Conditions.ExternalProgram.Enabled && payload.Conditions.ExternalProgram.ProgramID > 0 {
if h.externalProgramStore == nil {
log.Warn().Msg("automations: external program store is nil, skipping program existence check")
return http.StatusServiceUnavailable, "External program service not available", errors.New("external program store is nil")
}
_, err := h.externalProgramStore.GetByID(ctx, payload.Conditions.ExternalProgram.ProgramID)
if err != nil {
if errors.Is(err, models.ErrExternalProgramNotFound) {
return http.StatusBadRequest, "Referenced external program does not exist", err
}
return http.StatusInternalServerError, "Failed to verify external program", err
}
}

return 0, "", nil
}

Expand All @@ -367,7 +389,8 @@ func conditionsUseField(conditions *models.ActionConditions, field automations.C
(c.Pause != nil && check(c.Pause.Enabled, c.Pause.Condition)) ||
(c.Delete != nil && check(c.Delete.Enabled, c.Delete.Condition)) ||
(c.Tag != nil && check(c.Tag.Enabled, c.Tag.Condition)) ||
(c.Category != nil && check(c.Category.Enabled, c.Category.Condition))
(c.Category != nil && check(c.Category.Enabled, c.Category.Condition)) ||
(c.ExternalProgram != nil && check(c.ExternalProgram.Enabled, c.ExternalProgram.Condition))
}

// conditionsRequireLocalAccess checks if any enabled action condition uses fields
Expand Down Expand Up @@ -710,6 +733,9 @@ func collectConditionRegexErrors(conditions *models.ActionConditions) []RegexVal
if conditions.Category != nil {
validateConditionRegex(conditions.Category.Condition, "/conditions/category/condition", &result)
}
if conditions.ExternalProgram != nil {
validateConditionRegex(conditions.ExternalProgram.Condition, "/conditions/externalProgram/condition", &result)
}

return result
}
Expand Down
87 changes: 87 additions & 0 deletions internal/api/handlers/automations_validation_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,54 @@ func TestValidateFreeSpaceSourcePayload_WindowsRejectsPathAlways(t *testing.T) {
}
}

func TestExternalProgramActionValidate(t *testing.T) {
tests := []struct {
name string
action *models.ExternalProgramAction
wantErr bool
}{
{
name: "nil action is valid",
action: nil,
wantErr: false,
},
{
name: "disabled action with programId 0 is valid",
action: &models.ExternalProgramAction{Enabled: false, ProgramID: 0},
wantErr: false,
},
{
name: "disabled action with valid programId is valid",
action: &models.ExternalProgramAction{Enabled: false, ProgramID: 5},
wantErr: false,
},
{
name: "enabled action with valid programId is valid",
action: &models.ExternalProgramAction{Enabled: true, ProgramID: 1},
wantErr: false,
},
{
name: "enabled action with programId 0 is invalid",
action: &models.ExternalProgramAction{Enabled: true, ProgramID: 0},
wantErr: true,
},
{
name: "enabled action with negative programId is invalid",
action: &models.ExternalProgramAction{Enabled: true, ProgramID: -1},
wantErr: true,
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := tt.action.Validate()
if (err != nil) != tt.wantErr {
t.Errorf("ExternalProgramAction.Validate() error = %v, wantErr %v", err, tt.wantErr)
}
})
}
}

func TestConditionsUseFreeSpace(t *testing.T) {
tests := []struct {
name string
Expand Down Expand Up @@ -283,6 +331,45 @@ func TestConditionsUseFreeSpace(t *testing.T) {
},
want: true,
},
{
name: "external program disabled returns false",
conditions: &models.ActionConditions{
ExternalProgram: &models.ExternalProgramAction{
Enabled: false,
ProgramID: 1,
Condition: &automations.RuleCondition{
Field: automations.FieldFreeSpace,
},
},
},
want: false,
},
{
name: "external program enabled with FREE_SPACE returns true",
conditions: &models.ActionConditions{
ExternalProgram: &models.ExternalProgramAction{
Enabled: true,
ProgramID: 1,
Condition: &automations.RuleCondition{
Field: automations.FieldFreeSpace,
},
},
},
want: true,
},
{
name: "external program enabled without FREE_SPACE returns false",
conditions: &models.ActionConditions{
ExternalProgram: &models.ExternalProgramAction{
Enabled: true,
ProgramID: 1,
Condition: &automations.RuleCondition{
Field: automations.FieldSize,
},
},
},
want: false,
},
}

for _, tt := range tests {
Expand Down
Loading