diff --git a/Dockerfile b/Dockerfile index 1b90257f..a8c246b9 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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 diff --git a/config/settings.py b/config/settings.py index 6820b9fc..85272c93 100644 --- a/config/settings.py +++ b/config/settings.py @@ -463,6 +463,10 @@ class ConfigManager: "library": { "music_paths": [] }, + "scripts": { + "path": "./scripts", + "timeout": 60 + }, "import": { "staging_path": "./Staging", "replace_lower_quality": False diff --git a/core/automation_engine.py b/core/automation_engine.py index 48a77715..9964e474 100644 --- a/core/automation_engine.py +++ b/core/automation_engine.py @@ -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}") diff --git a/docker-compose.yml b/docker-compose.yml index 9b4327b2..15f0a61d 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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 diff --git a/scripts/hello_world.sh b/scripts/hello_world.sh new file mode 100644 index 00000000..20d41224 --- /dev/null +++ b/scripts/hello_world.sh @@ -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)" diff --git a/scripts/notify_ntfy.sh b/scripts/notify_ntfy.sh new file mode 100644 index 00000000..b7c47440 --- /dev/null +++ b/scripts/notify_ntfy.sh @@ -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}" diff --git a/scripts/system_info.py b/scripts/system_info.py new file mode 100644 index 00000000..d3229caf --- /dev/null +++ b/scripts/system_info.py @@ -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") diff --git a/web_server.py b/web_server.py index 6b4d5356..1b519105 100644 --- a/web_server.py +++ b/web_server.py @@ -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(), }) diff --git a/webui/static/script.js b/webui/static/script.js index bb6c3b25..c016253d 100644 --- a/webui/static/script.js +++ b/webui/static/script.js @@ -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) {
Other automations with "Signal Received" trigger will wake up
`; } + 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 = '' + + data.scripts.map(s => ``).join(''); + } + } catch (e) { console.warn('Failed to load scripts:', e); } + }, 100); + return `
+ + +
+
+ + seconds +
+
Place scripts in the scripts/ folder. Supported: .sh, .py, .bat, .ps1
`; + } if (blockType === 'scan_watchlist' || blockType === 'scan_library' || blockType === 'notify_only') { return '
No configuration needed
'; } @@ -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() || '',