Add Run Script action to automation engine

New automation action that executes user scripts from a dedicated
scripts/ directory. Available as both a DO action and THEN action.
Scripts are selected from a dropdown populated by /api/scripts.

Security: only scripts in the scripts dir can run, path traversal
blocked, no shell=True, stdout/stderr capped, configurable timeout
(max 300s). Scripts receive SOULSYNC_EVENT, SOULSYNC_AUTOMATION,
and SOULSYNC_SCRIPTS_DIR environment variables.

Includes Dockerfile + docker-compose.yml changes for the scripts
volume mount, and three example scripts (hello_world.sh,
system_info.py, notify_ntfy.sh).
This commit is contained in:
Broque Thomas 2026-04-09 18:20:29 -07:00
parent 5ed819a062
commit 959bca2b8d
9 changed files with 191 additions and 3 deletions

View file

@ -35,7 +35,7 @@ COPY . .
# Create necessary directories with proper permissions
# NOTE: /app/data is for database FILES, /app/database is the Python package
RUN mkdir -p /app/config /app/data /app/logs /app/downloads /app/Transfer && \
RUN mkdir -p /app/config /app/data /app/logs /app/downloads /app/Transfer /app/scripts && \
chown -R soulsync:soulsync /app
# Create defaults directory and copy template files
@ -47,7 +47,7 @@ RUN mkdir -p /defaults && \
# Create volume mount points
# NOTE: Changed /app/database to /app/data to avoid overwriting Python package
VOLUME ["/app/config", "/app/data", "/app/logs", "/app/downloads", "/app/Transfer"]
VOLUME ["/app/config", "/app/data", "/app/logs", "/app/downloads", "/app/Transfer", "/app/scripts"]
# Copy and set up entrypoint script
COPY entrypoint.sh /entrypoint.sh

View file

@ -463,6 +463,10 @@ class ConfigManager:
"library": {
"music_paths": []
},
"scripts": {
"path": "./scripts",
"timeout": 60
},
"import": {
"staging_path": "./Staging",
"replace_lower_quality": False

View file

@ -846,6 +846,14 @@ class AutomationEngine:
emit_data['signal_name'] = sig
logger.info(f"Automation '{automation.get('name')}' firing signal: {sig} (depth={chain_depth + 1})")
self.emit('signal:' + sig, emit_data)
elif t == 'run_script':
handler = self._action_handlers.get('run_script')
if handler:
script_config = dict(c)
# Pass action result as environment context
script_config['_automation_name'] = automation.get('name', '')
script_config['_event_data'] = {'type': 'then_action', 'result': {k: str(v) for k, v in action_result.items() if not k.startswith('_')}}
handler['handler'](script_config)
except Exception as e:
logger.error(f"Then-action '{item.get('type')}' failed for automation {automation.get('id')}: {e}")

View file

@ -30,6 +30,7 @@ services:
- ./logs:/app/logs
- ./downloads:/app/downloads
- ./Staging:/app/Staging
- ./scripts:/app/scripts
# Use named volume for database persistence (separate from host database)
# NOTE: Changed from /app/database to /app/data to avoid overwriting Python package
- soulsync_database:/app/data

6
scripts/hello_world.sh Normal file
View file

@ -0,0 +1,6 @@
#!/bin/bash
# Simple test script — verifies script execution is working
echo "Hello from SoulSync Scripts!"
echo "Automation: $SOULSYNC_AUTOMATION"
echo "Event: $SOULSYNC_EVENT"
echo "Time: $(date)"

12
scripts/notify_ntfy.sh Normal file
View file

@ -0,0 +1,12 @@
#!/bin/bash
# Send a notification via ntfy.sh (self-hosted or public)
# Configure: set NTFY_URL and NTFY_TOPIC environment variables
# Example: NTFY_URL=https://ntfy.sh NTFY_TOPIC=soulsync
NTFY_URL="${NTFY_URL:-https://ntfy.sh}"
NTFY_TOPIC="${NTFY_TOPIC:-soulsync}"
curl -s -d "SoulSync automation '${SOULSYNC_AUTOMATION}' completed" \
"${NTFY_URL}/${NTFY_TOPIC}" > /dev/null 2>&1
echo "Notification sent to ${NTFY_URL}/${NTFY_TOPIC}"

17
scripts/system_info.py Normal file
View file

@ -0,0 +1,17 @@
#!/usr/bin/env python3
"""Reports basic system info — useful for debugging Docker setups."""
import os
import platform
import shutil
print(f"Platform: {platform.system()} {platform.release()}")
print(f"Python: {platform.python_version()}")
print(f"Working Dir: {os.getcwd()}")
# Disk usage for common SoulSync paths
for path in ['/app/downloads', '/app/Transfer', '/app/data', './downloads', './Transfer']:
if os.path.exists(path):
usage = shutil.disk_usage(path)
free_gb = usage.free / (1024**3)
total_gb = usage.total / (1024**3)
print(f"Disk {path}: {free_gb:.1f} GB free / {total_gb:.1f} GB total")

View file

@ -1737,6 +1737,79 @@ def _register_automation_handlers():
'_manages_own_progress': True,
}
def _auto_run_script(config):
"""Execute a user script from the scripts directory."""
import subprocess as _sp
script_name = config.get('script_name', '')
timeout = min(int(config.get('timeout', 60)), 300)
automation_id = config.get('_automation_id')
if not script_name:
return {'status': 'error', 'error': 'No script selected'}
scripts_dir = docker_resolve_path(config_manager.get('scripts.path', './scripts'))
if not scripts_dir or not os.path.isdir(scripts_dir):
os.makedirs(scripts_dir, exist_ok=True)
return {'status': 'error', 'error': 'Scripts directory is empty. Add scripts to the scripts/ folder.'}
script_path = os.path.join(scripts_dir, script_name)
script_path = os.path.realpath(script_path)
# Security: block path traversal
if not script_path.startswith(os.path.realpath(scripts_dir)):
return {'status': 'error', 'error': 'Script path traversal blocked'}
if not os.path.isfile(script_path):
return {'status': 'error', 'error': f'Script not found: {script_name}'}
_update_automation_progress(automation_id, phase=f'Running {script_name}...', progress=10)
# Build environment with SoulSync context
env = os.environ.copy()
event_data = config.get('_event_data') or {}
env['SOULSYNC_EVENT'] = str(event_data.get('type', ''))
env['SOULSYNC_AUTOMATION'] = config.get('_automation_name', '')
env['SOULSYNC_SCRIPTS_DIR'] = scripts_dir
try:
# Determine how to run the script
if script_path.endswith('.py'):
cmd = ['python', script_path]
elif script_path.endswith('.sh'):
cmd = ['bash', script_path]
else:
cmd = [script_path]
result = _sp.run(
cmd,
capture_output=True, text=True, timeout=timeout,
cwd=scripts_dir, env=env
)
_update_automation_progress(automation_id, phase='Script completed', progress=100)
stdout = result.stdout[:2000] if result.stdout else ''
stderr = result.stderr[:1000] if result.stderr else ''
if result.returncode == 0:
logger.info(f"Script '{script_name}' completed (exit 0)")
else:
logger.warning(f"Script '{script_name}' exited with code {result.returncode}")
return {
'status': 'completed' if result.returncode == 0 else 'error',
'exit_code': str(result.returncode),
'stdout': stdout,
'stderr': stderr,
'script': script_name,
}
except _sp.TimeoutExpired:
_update_automation_progress(automation_id, phase='Script timed out', progress=100)
return {'status': 'error', 'error': f'Script timed out after {timeout}s', 'script': script_name}
except Exception as e:
return {'status': 'error', 'error': str(e), 'script': script_name}
automation_engine.register_action_handler('run_script', _auto_run_script)
automation_engine.register_action_handler('full_cleanup', _auto_full_cleanup)
automation_engine.register_action_handler('start_database_update', _auto_start_database_update,
@ -5789,6 +5862,30 @@ def _collect_known_signals():
pass
return sorted(signals)
@app.route('/api/scripts', methods=['GET'])
def list_available_scripts():
"""List executable scripts in the scripts directory."""
try:
scripts_dir = docker_resolve_path(config_manager.get('scripts.path', './scripts'))
if not scripts_dir or not os.path.isdir(scripts_dir):
return jsonify({'scripts': []})
allowed_ext = {'.sh', '.py', '.bat', '.ps1', '.rb', '.pl', '.js'}
scripts = []
for fname in sorted(os.listdir(scripts_dir)):
ext = os.path.splitext(fname)[1].lower()
fpath = os.path.join(scripts_dir, fname)
if os.path.isfile(fpath) and (ext in allowed_ext or os.access(fpath, os.X_OK)):
scripts.append({
'name': fname,
'extension': ext,
'size': os.path.getsize(fpath),
})
return jsonify({'scripts': scripts})
except Exception as e:
return jsonify({'scripts': [], 'error': str(e)})
@app.route('/api/automations/blocks', methods=['GET'])
def get_automation_blocks():
"""Return available block types for the automation builder sidebar."""
@ -5953,6 +6050,8 @@ def get_automation_blocks():
"description": "Clear quarantine, download queue, staging folder, and search history in one sweep", "available": True},
{"type": "deep_scan_library", "label": "Deep Scan Library", "icon": "search",
"description": "Full library comparison without losing enrichment data", "available": True},
{"type": "run_script", "label": "Run Script", "icon": "terminal",
"description": "Execute a script from the scripts folder", "available": True},
],
"notifications": [
{"type": "discord_webhook", "label": "Discord Webhook", "icon": "message", "description": "Send a Discord notification", "available": True,
@ -5969,6 +6068,12 @@ def get_automation_blocks():
"config_fields": [
{"key": "signal_name", "type": "signal_input", "label": "Signal Name"}
]},
# Run script then-action
{"type": "run_script", "label": "Run Script", "icon": "terminal",
"description": "Execute a script after the action completes", "available": True,
"config_fields": [
{"key": "script_name", "type": "script_select", "label": "Script"}
]},
],
"known_signals": _collect_known_signals(),
})

View file

@ -66398,7 +66398,7 @@ const _autoIcons = {
scan_library: '\uD83D\uDD04', refresh_mirrored: '\uD83D\uDCC2', sync_playlist: '\uD83D\uDD01',
discover_playlist: '\uD83D\uDD0D', discovery_completed: '\uD83D\uDD0D',
notify_only: '\uD83D\uDD14', discord_webhook: '\uD83D\uDCAC', pushbullet: '\uD83D\uDD14', telegram: '\u2709\uFE0F', webhook: '\uD83C\uDF10',
signal_received: '\u26A1', fire_signal: '\u26A1',
signal_received: '\u26A1', fire_signal: '\u26A1', run_script: '\uD83D\uDCBB',
// Phase 3
wishlist_processing_completed: '\u2705', watchlist_scan_completed: '\u2705',
database_update_completed: '\uD83D\uDDC4\uFE0F', download_failed: '\u274C',
@ -67662,6 +67662,7 @@ function _autoFormatNotify(type) {
if (type === 'pushbullet') return 'Pushbullet';
if (type === 'telegram') return 'Telegram';
if (type === 'fire_signal') return '\u26A1 Signal';
if (type === 'run_script') return '\uD83D\uDCBB Script';
return type || '';
}
function _autoParseUTC(ts) {
@ -68342,6 +68343,34 @@ function _renderBlockConfigFields(slotKey, blockType, config) {
</div>
<div class="config-row" style="color:rgba(255,255,255,0.35);font-size:11px;">Other automations with "Signal Received" trigger will wake up</div>`;
}
if (blockType === 'run_script') {
const scriptName = _escAttr(config.script_name || '');
const timeout = config.timeout || 60;
// Fetch scripts list and populate
const selectId = `cfg-${slotKey}-script_name`;
setTimeout(async () => {
try {
const resp = await fetch('/api/scripts');
const data = await resp.json();
const sel = document.getElementById(selectId);
if (sel && data.scripts) {
sel.innerHTML = '<option value="">Select a script...</option>' +
data.scripts.map(s => `<option value="${_escAttr(s.name)}"${s.name === scriptName ? ' selected' : ''}>${escapeHtml(s.name)} (${s.extension})</option>`).join('');
}
} catch (e) { console.warn('Failed to load scripts:', e); }
}, 100);
return `<div class="config-row">
<label>Script</label>
<select id="${selectId}">
<option value="${scriptName}">${scriptName || 'Loading...'}</option>
</select>
</div>
<div class="config-row">
<label>Timeout</label>
<input type="number" id="cfg-${slotKey}-timeout" value="${timeout}" min="5" max="300" style="width:80px;"> seconds
</div>
<div class="config-row" style="color:rgba(255,255,255,0.35);font-size:11px;">Place scripts in the <code>scripts/</code> folder. Supported: .sh, .py, .bat, .ps1</div>`;
}
if (blockType === 'scan_watchlist' || blockType === 'scan_library' || blockType === 'notify_only') {
return '<div class="config-row" style="color:rgba(255,255,255,0.4);font-size:12px;">No configuration needed</div>';
}
@ -68701,6 +68730,12 @@ function _readPlacedConfig(slotKey) {
if (type === 'signal_received' || type === 'fire_signal') {
return { signal_name: document.getElementById('cfg-' + slotKey + '-signal_name')?.value?.trim() || '' };
}
if (type === 'run_script') {
return {
script_name: document.getElementById('cfg-' + slotKey + '-script_name')?.value || '',
timeout: parseInt(document.getElementById('cfg-' + slotKey + '-timeout')?.value || '60') || 60,
};
}
if (type === 'discord_webhook') {
return {
webhook_url: document.getElementById('cfg-' + slotKey + '-webhook_url')?.value?.trim() || '',