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:
parent
5ed819a062
commit
959bca2b8d
9 changed files with 191 additions and 3 deletions
|
|
@ -35,7 +35,7 @@ COPY . .
|
||||||
|
|
||||||
# Create necessary directories with proper permissions
|
# Create necessary directories with proper permissions
|
||||||
# NOTE: /app/data is for database FILES, /app/database is the Python package
|
# 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
|
chown -R soulsync:soulsync /app
|
||||||
|
|
||||||
# Create defaults directory and copy template files
|
# Create defaults directory and copy template files
|
||||||
|
|
@ -47,7 +47,7 @@ RUN mkdir -p /defaults && \
|
||||||
|
|
||||||
# Create volume mount points
|
# Create volume mount points
|
||||||
# NOTE: Changed /app/database to /app/data to avoid overwriting Python package
|
# 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 and set up entrypoint script
|
||||||
COPY entrypoint.sh /entrypoint.sh
|
COPY entrypoint.sh /entrypoint.sh
|
||||||
|
|
|
||||||
|
|
@ -463,6 +463,10 @@ class ConfigManager:
|
||||||
"library": {
|
"library": {
|
||||||
"music_paths": []
|
"music_paths": []
|
||||||
},
|
},
|
||||||
|
"scripts": {
|
||||||
|
"path": "./scripts",
|
||||||
|
"timeout": 60
|
||||||
|
},
|
||||||
"import": {
|
"import": {
|
||||||
"staging_path": "./Staging",
|
"staging_path": "./Staging",
|
||||||
"replace_lower_quality": False
|
"replace_lower_quality": False
|
||||||
|
|
|
||||||
|
|
@ -846,6 +846,14 @@ class AutomationEngine:
|
||||||
emit_data['signal_name'] = sig
|
emit_data['signal_name'] = sig
|
||||||
logger.info(f"Automation '{automation.get('name')}' firing signal: {sig} (depth={chain_depth + 1})")
|
logger.info(f"Automation '{automation.get('name')}' firing signal: {sig} (depth={chain_depth + 1})")
|
||||||
self.emit('signal:' + sig, emit_data)
|
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:
|
except Exception as e:
|
||||||
logger.error(f"Then-action '{item.get('type')}' failed for automation {automation.get('id')}: {e}")
|
logger.error(f"Then-action '{item.get('type')}' failed for automation {automation.get('id')}: {e}")
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -30,6 +30,7 @@ services:
|
||||||
- ./logs:/app/logs
|
- ./logs:/app/logs
|
||||||
- ./downloads:/app/downloads
|
- ./downloads:/app/downloads
|
||||||
- ./Staging:/app/Staging
|
- ./Staging:/app/Staging
|
||||||
|
- ./scripts:/app/scripts
|
||||||
# Use named volume for database persistence (separate from host database)
|
# Use named volume for database persistence (separate from host database)
|
||||||
# NOTE: Changed from /app/database to /app/data to avoid overwriting Python package
|
# NOTE: Changed from /app/database to /app/data to avoid overwriting Python package
|
||||||
- soulsync_database:/app/data
|
- soulsync_database:/app/data
|
||||||
|
|
|
||||||
6
scripts/hello_world.sh
Normal file
6
scripts/hello_world.sh
Normal 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
12
scripts/notify_ntfy.sh
Normal 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
17
scripts/system_info.py
Normal 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")
|
||||||
105
web_server.py
105
web_server.py
|
|
@ -1737,6 +1737,79 @@ def _register_automation_handlers():
|
||||||
'_manages_own_progress': True,
|
'_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('full_cleanup', _auto_full_cleanup)
|
||||||
|
|
||||||
automation_engine.register_action_handler('start_database_update', _auto_start_database_update,
|
automation_engine.register_action_handler('start_database_update', _auto_start_database_update,
|
||||||
|
|
@ -5789,6 +5862,30 @@ def _collect_known_signals():
|
||||||
pass
|
pass
|
||||||
return sorted(signals)
|
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'])
|
@app.route('/api/automations/blocks', methods=['GET'])
|
||||||
def get_automation_blocks():
|
def get_automation_blocks():
|
||||||
"""Return available block types for the automation builder sidebar."""
|
"""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},
|
"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",
|
{"type": "deep_scan_library", "label": "Deep Scan Library", "icon": "search",
|
||||||
"description": "Full library comparison without losing enrichment data", "available": True},
|
"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": [
|
"notifications": [
|
||||||
{"type": "discord_webhook", "label": "Discord Webhook", "icon": "message", "description": "Send a Discord notification", "available": True,
|
{"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": [
|
"config_fields": [
|
||||||
{"key": "signal_name", "type": "signal_input", "label": "Signal Name"}
|
{"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(),
|
"known_signals": _collect_known_signals(),
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -66398,7 +66398,7 @@ const _autoIcons = {
|
||||||
scan_library: '\uD83D\uDD04', refresh_mirrored: '\uD83D\uDCC2', sync_playlist: '\uD83D\uDD01',
|
scan_library: '\uD83D\uDD04', refresh_mirrored: '\uD83D\uDCC2', sync_playlist: '\uD83D\uDD01',
|
||||||
discover_playlist: '\uD83D\uDD0D', discovery_completed: '\uD83D\uDD0D',
|
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',
|
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
|
// Phase 3
|
||||||
wishlist_processing_completed: '\u2705', watchlist_scan_completed: '\u2705',
|
wishlist_processing_completed: '\u2705', watchlist_scan_completed: '\u2705',
|
||||||
database_update_completed: '\uD83D\uDDC4\uFE0F', download_failed: '\u274C',
|
database_update_completed: '\uD83D\uDDC4\uFE0F', download_failed: '\u274C',
|
||||||
|
|
@ -67662,6 +67662,7 @@ function _autoFormatNotify(type) {
|
||||||
if (type === 'pushbullet') return 'Pushbullet';
|
if (type === 'pushbullet') return 'Pushbullet';
|
||||||
if (type === 'telegram') return 'Telegram';
|
if (type === 'telegram') return 'Telegram';
|
||||||
if (type === 'fire_signal') return '\u26A1 Signal';
|
if (type === 'fire_signal') return '\u26A1 Signal';
|
||||||
|
if (type === 'run_script') return '\uD83D\uDCBB Script';
|
||||||
return type || '';
|
return type || '';
|
||||||
}
|
}
|
||||||
function _autoParseUTC(ts) {
|
function _autoParseUTC(ts) {
|
||||||
|
|
@ -68342,6 +68343,34 @@ function _renderBlockConfigFields(slotKey, blockType, config) {
|
||||||
</div>
|
</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>`;
|
<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') {
|
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>';
|
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') {
|
if (type === 'signal_received' || type === 'fire_signal') {
|
||||||
return { signal_name: document.getElementById('cfg-' + slotKey + '-signal_name')?.value?.trim() || '' };
|
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') {
|
if (type === 'discord_webhook') {
|
||||||
return {
|
return {
|
||||||
webhook_url: document.getElementById('cfg-' + slotKey + '-webhook_url')?.value?.trim() || '',
|
webhook_url: document.getElementById('cfg-' + slotKey + '-webhook_url')?.value?.trim() || '',
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue