From 8b6a2c0adc62612b5fff88e367449159693450c3 Mon Sep 17 00:00:00 2001 From: Broque Thomas <26755000+Nezreka@users.noreply.github.com> Date: Fri, 6 Mar 2026 18:10:15 -0800 Subject: [PATCH] allow multiple notification calls per automation as well as a new signal fire utility --- core/automation_engine.py | 195 ++++++++++++++++++++++++----- database/music_database.py | 33 ++++- web_server.py | 127 ++++++++++++++++++- webui/static/script.js | 245 +++++++++++++++++++++++++++++++------ webui/static/style.css | 12 ++ 5 files changed, 530 insertions(+), 82 deletions(-) diff --git a/core/automation_engine.py b/core/automation_engine.py index 4b3ceaad..6b3945e3 100644 --- a/core/automation_engine.py +++ b/core/automation_engine.py @@ -1,17 +1,19 @@ """ -Automation Engine — trigger → action → notify scheduler for SoulSync. +Automation Engine — trigger → action → then scheduler for SoulSync. Architecture: -- Triggers (WHEN): schedule timer, event-based (track_downloaded, batch_complete, etc.) +- Triggers (WHEN): schedule timer, event-based, signal-based (signal_received) - Actions (DO): real SoulSync operations registered by web_server.py -- Notifications (NOTIFY): optional Discord webhook with template variables +- Then (THEN): 1–3 post-action steps — notifications (Discord/Pushbullet/Telegram) and/or fire_signal - Conditions: optional filters on event data (artist contains, title equals, etc.) +- Signals: user-named events that chain automations together (fire_signal → signal_received) Uses threading.Timer pattern for schedule triggers. Event triggers react to emit() calls from web_server.py hook points. """ import json +import re import time import threading import requests @@ -57,6 +59,11 @@ class AutomationEngine: self._event_automations = {} self._event_cache_dirty = True + # Signal safety: cooldown tracking and chain depth limit + self._signal_cooldowns = {} # signal event key → last fire timestamp + self._max_chain_depth = 5 + self._signal_cooldown_seconds = 10 + # Trigger registry: type → setup function (schedule only — events use emit()) self._trigger_handlers = { 'schedule': self._setup_schedule_trigger, @@ -82,6 +89,16 @@ class AutomationEngine: self._progress_init_fn = init_fn self._progress_finish_fn = finish_fn + @staticmethod + def _sanitize_signal_name(name): + """Sanitize signal name: lowercase, alphanumeric + underscore/hyphen, max 50 chars.""" + if not name: + return '' + name = name.lower().strip() + name = re.sub(r'[^a-z0-9_\-]', '_', name) + name = re.sub(r'_+', '_', name).strip('_') + return name[:50] + # --- System Automations --- def ensure_system_automations(self): @@ -202,6 +219,20 @@ class AutomationEngine: def _process_event(self, event_type, data): """Find matching automations and run them.""" try: + # Signal safety: chain depth limit and cooldown + if event_type.startswith('signal:'): + depth = data.get('_chain_depth', 0) + if depth >= self._max_chain_depth: + logger.warning(f"Signal chain depth limit ({self._max_chain_depth}) reached for {event_type}, stopping") + return + with self._lock: + now = time.time() + last = self._signal_cooldowns.get(event_type, 0) + if now - last < self._signal_cooldown_seconds: + logger.info(f"Signal {event_type} on cooldown ({self._signal_cooldown_seconds}s), skipping") + return + self._signal_cooldowns[event_type] = now + if self._event_cache_dirty: self._rebuild_event_cache() @@ -234,17 +265,29 @@ class AutomationEngine: def _rebuild_event_cache(self): """Cache which automations listen to which event types.""" - self._event_automations = {} + new_cache = {} try: all_autos = self.db.get_automations() for auto in all_autos: if not auto.get('enabled'): continue tt = auto.get('trigger_type', '') - if tt and tt not in self._trigger_handlers: - self._event_automations.setdefault(tt, []).append(auto['id']) + if tt == 'signal_received': + # Signal triggers map to 'signal:{name}' event key + try: + tc = json.loads(auto.get('trigger_config') or '{}') + except (json.JSONDecodeError, TypeError): + tc = {} + sig = tc.get('signal_name', '') + if sig: + key = 'signal:' + self._sanitize_signal_name(sig) + new_cache.setdefault(key, []).append(auto['id']) + elif tt and tt not in self._trigger_handlers: + new_cache.setdefault(tt, []).append(auto['id']) except Exception as e: logger.error(f"Failed to rebuild event cache: {e}") + # Atomic swap — safe for concurrent readers + self._event_automations = new_cache self._event_cache_dirty = False logger.debug(f"Event cache rebuilt: {dict((k, len(v)) for k, v in self._event_automations.items())}") @@ -328,16 +371,17 @@ class AutomationEngine: try: self._progress_finish_fn(automation_id, result) except Exception: pass - # Merge event data into result for notification variables + # Merge event data into result for then-action variables merged = {**event_data, **result} + chain_depth = event_data.get('_chain_depth', 0) try: - self._send_notification(auto, merged) + self._execute_then_actions(auto, merged, chain_depth) except Exception as e: - logger.error(f"Notification failed for event automation {automation_id}: {e}") + logger.error(f"Then-actions failed for event automation {automation_id}: {e}") # Update run stats (no reschedule — event triggers don't use timers) - last_result = json.dumps(merged) + last_result = json.dumps({k: v for k, v in merged.items() if not k.startswith('_')}) error = result.get('error') if result.get('status') == 'error' else None self.db.update_automation_run(automation_id, error=error, last_result=last_result) @@ -358,9 +402,9 @@ class AutomationEngine: if action_type == 'notify_only': result = {'status': 'triggered'} try: - self._send_notification(auto, result) + self._execute_then_actions(auto, result, chain_depth=0) except Exception as e: - logger.error(f"Notification failed for automation {automation_id}: {e}") + logger.error(f"Then-actions failed for automation {automation_id}: {e}") self._finish_run(auto, automation_id, result, error=None) return @@ -416,11 +460,11 @@ class AutomationEngine: try: self._progress_finish_fn(automation_id, result) except Exception: pass - # Send notification if configured + # Execute then-actions (notifications + fire_signal) try: - self._send_notification(auto, result) + self._execute_then_actions(auto, result, chain_depth=0) except Exception as e: - logger.error(f"Notification failed for automation {automation_id}: {e}") + logger.error(f"Then-actions failed for automation {automation_id}: {e}") self._finish_run(auto, automation_id, result, error) @@ -576,18 +620,28 @@ class AutomationEngine: # Fallback: tomorrow (shouldn't happen with 8-day scan) return now.replace(hour=hour, minute=minute, second=0, microsecond=0) + timedelta(days=1) - # --- Notification --- - - def _send_notification(self, automation, action_result): - """Send notification after action completes, if configured.""" - notify_type = automation.get('notify_type') - if not notify_type: - return + # --- Then Actions (notifications + signals) --- + def _execute_then_actions(self, automation, action_result, chain_depth=0): + """Execute all THEN actions: notifications (Discord/Pushbullet/Telegram) and fire_signal.""" + # Read then_actions array try: - config = json.loads(automation.get('notify_config') or '{}') - except json.JSONDecodeError: - config = {} + then_actions = json.loads(automation.get('then_actions') or '[]') + except (json.JSONDecodeError, TypeError): + then_actions = [] + + # Backward compat: fall back to notify_type/notify_config + if not then_actions: + nt = automation.get('notify_type') + if nt: + try: + nc = json.loads(automation.get('notify_config') or '{}') + except (json.JSONDecodeError, TypeError): + nc = {} + then_actions = [{'type': nt, 'config': nc}] + + if not then_actions: + return # Build template variables variables = { @@ -596,16 +650,91 @@ class AutomationEngine: 'run_count': str(automation.get('run_count', 0) + 1), 'status': action_result.get('status', 'unknown'), } - # Add all action result fields as variables for k, v in action_result.items(): - variables[k] = str(v) + if not k.startswith('_'): + variables[k] = str(v) - if notify_type == 'discord_webhook': - self._send_discord_notification(config, variables) - elif notify_type == 'pushbullet': - self._send_pushbullet_notification(config, variables) - elif notify_type == 'telegram': - self._send_telegram_notification(config, variables) + for item in then_actions: + try: + t = item.get('type', '') + c = item.get('config', {}) + if t == 'discord_webhook': + self._send_discord_notification(c, variables) + elif t == 'pushbullet': + self._send_pushbullet_notification(c, variables) + elif t == 'telegram': + self._send_telegram_notification(c, variables) + elif t == 'fire_signal': + sig = self._sanitize_signal_name(c.get('signal_name', '')) + if sig: + emit_data = {k: v for k, v in action_result.items() if not k.startswith('_')} + emit_data['_chain_depth'] = chain_depth + 1 + 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) + except Exception as e: + logger.error(f"Then-action '{item.get('type')}' failed for automation {automation.get('id')}: {e}") + + # --- Signal Cycle Detection --- + + def detect_signal_cycles(self, automations_list): + """Build signal dependency graph from automations list, return cycle path or None. + Used by web_server.py to validate before saving an automation.""" + # Build graph: signal listened → set of signals fired + graph = {} + for auto in automations_list: + if not auto.get('enabled', True): + continue + tt = auto.get('trigger_type', '') + if tt != 'signal_received': + continue + tc = auto.get('trigger_config') or {} + if isinstance(tc, str): + try: + tc = json.loads(tc) + except (json.JSONDecodeError, TypeError): + tc = {} + listen_sig = self._sanitize_signal_name(tc.get('signal_name', '')) + if not listen_sig: + continue + # What signals does this automation fire? + ta = auto.get('then_actions') or '[]' + if isinstance(ta, str): + try: + ta = json.loads(ta) + except (json.JSONDecodeError, TypeError): + ta = [] + for item in ta: + if item.get('type') == 'fire_signal': + fire_sig = self._sanitize_signal_name(item.get('config', {}).get('signal_name', '')) + if fire_sig: + graph.setdefault(listen_sig, set()).add(fire_sig) + + # DFS cycle detection with ordered path for readable error messages + def has_cycle(node, visited, path_list, path_set): + if node in path_set: + # Extract the cycle portion from path_list + cycle_start = path_list.index(node) + return path_list[cycle_start:] + [node] + if node in visited: + return None + visited.add(node) + path_list.append(node) + path_set.add(node) + for neighbor in graph.get(node, []): + result = has_cycle(neighbor, visited, path_list, path_set) + if result: + return result + path_list.pop() + path_set.discard(node) + return None + + visited = set() + for start in graph: + cycle = has_cycle(start, visited, [], set()) + if cycle: + return cycle + return None def _send_discord_notification(self, config, variables): """POST to Discord webhook with template variable substitution.""" diff --git a/database/music_database.py b/database/music_database.py index 83ecec0b..76a5431b 100644 --- a/database/music_database.py +++ b/database/music_database.py @@ -400,6 +400,7 @@ class MusicDatabase: # Add notification columns to automations (migration) self._add_automation_notify_columns(cursor) self._add_automation_system_column(cursor) + self._add_automation_then_actions_column(cursor) conn.commit() logger.info("Database initialized successfully") @@ -431,6 +432,27 @@ class MusicDatabase: except Exception as e: logger.error(f"Error adding automation system column: {e}") + def _add_automation_then_actions_column(self, cursor): + """Add then_actions column to automations table and migrate existing notify data.""" + try: + cursor.execute("PRAGMA table_info(automations)") + cols = [c[1] for c in cursor.fetchall()] + if 'then_actions' not in cols: + cursor.execute("ALTER TABLE automations ADD COLUMN then_actions TEXT DEFAULT '[]'") + logger.info("Added then_actions column to automations table") + # Migrate existing notify_type/notify_config into then_actions + cursor.execute("SELECT id, notify_type, notify_config FROM automations WHERE notify_type IS NOT NULL AND notify_type != ''") + for row in cursor.fetchall(): + try: + config = json.loads(row[2]) if row[2] else {} + then_actions = json.dumps([{'type': row[1], 'config': config}]) + cursor.execute("UPDATE automations SET then_actions = ? WHERE id = ?", (then_actions, row[0])) + except Exception: + pass + logger.info("Migrated existing notify data to then_actions") + except Exception as e: + logger.error(f"Error adding automation then_actions column: {e}") + def _add_server_source_columns(self, cursor): """Add server_source columns to existing tables for multi-server support""" try: @@ -6803,15 +6825,16 @@ class MusicDatabase: def create_automation(self, name: str, trigger_type: str, trigger_config: str, action_type: str, action_config: str, profile_id: int = 1, - notify_type: str = None, notify_config: str = '{}'): + notify_type: str = None, notify_config: str = '{}', + then_actions: str = '[]'): """Create a new automation. Returns the new automation ID or None.""" try: with self._get_connection() as conn: cursor = conn.cursor() cursor.execute(""" - INSERT INTO automations (name, trigger_type, trigger_config, action_type, action_config, profile_id, notify_type, notify_config) - VALUES (?, ?, ?, ?, ?, ?, ?, ?) - """, (name, trigger_type, trigger_config, action_type, action_config, profile_id, notify_type, notify_config)) + INSERT INTO automations (name, trigger_type, trigger_config, action_type, action_config, profile_id, notify_type, notify_config, then_actions) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + """, (name, trigger_type, trigger_config, action_type, action_config, profile_id, notify_type, notify_config, then_actions)) conn.commit() return cursor.lastrowid except Exception as e: @@ -6858,7 +6881,7 @@ class MusicDatabase: def update_automation(self, automation_id: int, **kwargs) -> bool: """Update automation fields.""" - allowed = {'name', 'enabled', 'trigger_type', 'trigger_config', 'action_type', 'action_config', 'next_run', 'notify_type', 'notify_config', 'last_result', 'is_system'} + allowed = {'name', 'enabled', 'trigger_type', 'trigger_config', 'action_type', 'action_config', 'next_run', 'notify_type', 'notify_config', 'last_result', 'is_system', 'then_actions'} updates = {k: v for k, v in kwargs.items() if k in allowed} if not updates: return False diff --git a/web_server.py b/web_server.py index ed211321..2c2e568a 100644 --- a/web_server.py +++ b/web_server.py @@ -3580,6 +3580,14 @@ def list_automations(): auto[field] = {} else: auto[field] = None + # Parse then_actions + try: + auto['then_actions'] = json.loads(auto.get('then_actions') or '[]') if isinstance(auto.get('then_actions'), str) else (auto.get('then_actions') or []) + except (json.JSONDecodeError, TypeError): + auto['then_actions'] = [] + # Backward compat: if then_actions empty but notify_type set, build it + if not auto['then_actions'] and auto.get('notify_type'): + auto['then_actions'] = [{'type': auto['notify_type'], 'config': auto.get('notify_config', {})}] return jsonify(automations) except Exception as e: logger.error(f"Error listing automations: {e}") @@ -3598,12 +3606,35 @@ def create_automation(): trigger_config = json.dumps(data.get('trigger_config', {})) action_type = data.get('action_type', 'process_wishlist') action_config = json.dumps(data.get('action_config', {})) - notify_type = data.get('notify_type') or None - notify_config = json.dumps(data.get('notify_config', {})) if notify_type else '{}' + # then_actions array (new multi-then system) + then_actions = data.get('then_actions', []) + then_actions_json = json.dumps(then_actions) + # Backward compat: derive notify_type/notify_config from first then_action + if then_actions: + notify_type = then_actions[0].get('type') + notify_config = json.dumps(then_actions[0].get('config', {})) + else: + notify_type = data.get('notify_type') or None + notify_config = json.dumps(data.get('notify_config', {})) if notify_type else '{}' profile_id = session.get('profile_id', 1) + # Signal cycle detection + if automation_engine and (trigger_type == 'signal_received' or any(t.get('type') == 'fire_signal' for t in then_actions)): + db = get_database() + all_autos = db.get_automations(profile_id) + test_auto = { + 'trigger_type': trigger_type, + 'trigger_config': trigger_config, + 'then_actions': then_actions_json, + 'enabled': True, + } + all_autos.append(test_auto) + cycle = automation_engine.detect_signal_cycles(all_autos) + if cycle: + return jsonify({"error": f"Signal cycle detected: {' → '.join(cycle)}. This would cause an infinite loop."}), 400 + db = get_database() - auto_id = db.create_automation(name, trigger_type, trigger_config, action_type, action_config, profile_id, notify_type, notify_config) + auto_id = db.create_automation(name, trigger_type, trigger_config, action_type, action_config, profile_id, notify_type, notify_config, then_actions_json) if auto_id is None: return jsonify({"error": "Failed to create automation"}), 500 @@ -3632,6 +3663,13 @@ def get_automation(automation_id): auto[field] = {} else: auto[field] = None + # Parse then_actions + try: + auto['then_actions'] = json.loads(auto.get('then_actions') or '[]') if isinstance(auto.get('then_actions'), str) else (auto.get('then_actions') or []) + except (json.JSONDecodeError, TypeError): + auto['then_actions'] = [] + if not auto['then_actions'] and auto.get('notify_type'): + auto['then_actions'] = [{'type': auto['notify_type'], 'config': auto.get('notify_config', {})}] return jsonify(auto) except Exception as e: logger.error(f"Error getting automation: {e}") @@ -3655,14 +3693,48 @@ def update_automation_endpoint(automation_id): update_fields['action_type'] = data['action_type'] if 'action_config' in data: update_fields['action_config'] = json.dumps(data['action_config']) - if 'notify_type' in data: + if 'then_actions' in data: + then_actions = data['then_actions'] + update_fields['then_actions'] = json.dumps(then_actions) + # Backward compat: derive notify_type/notify_config from first then_action + if then_actions: + update_fields['notify_type'] = then_actions[0].get('type') + update_fields['notify_config'] = json.dumps(then_actions[0].get('config', {})) + else: + update_fields['notify_type'] = None + update_fields['notify_config'] = '{}' + elif 'notify_type' in data: update_fields['notify_type'] = data['notify_type'] or None - if 'notify_config' in data: + if 'notify_config' in data and 'then_actions' not in data: update_fields['notify_config'] = json.dumps(data['notify_config']) if not update_fields: return jsonify({"error": "No fields to update"}), 400 + # Signal cycle detection + trigger_type = data.get('trigger_type', '') + then_actions = data.get('then_actions', []) + if automation_engine and (trigger_type == 'signal_received' or any(t.get('type') == 'fire_signal' for t in then_actions)): + all_autos = db.get_automations() + # Replace the automation being edited with the updated version + test_autos = [] + for a in all_autos: + if a['id'] == automation_id: + merged = dict(a) + if 'trigger_type' in data: + merged['trigger_type'] = data['trigger_type'] + if 'trigger_config' in data: + merged['trigger_config'] = json.dumps(data['trigger_config']) + if 'then_actions' in data: + merged['then_actions'] = json.dumps(data['then_actions']) + merged['enabled'] = True + test_autos.append(merged) + else: + test_autos.append(a) + cycle = automation_engine.detect_signal_cycles(test_autos) + if cycle: + return jsonify({"error": f"Signal cycle detected: {' → '.join(cycle)}. This would cause an infinite loop."}), 400 + success = db.update_automation(automation_id, **update_fields) if not success: return jsonify({"error": "Automation not found"}), 404 @@ -3749,6 +3821,35 @@ def get_automation_progress(): except Exception as e: return jsonify({"error": str(e)}), 500 +def _collect_known_signals(): + """Collect all signal names used across automations (for autocomplete).""" + signals = set() + try: + db = get_database() + for auto in db.get_automations(): + # Check signal_received triggers + if auto.get('trigger_type') == 'signal_received': + try: + tc = json.loads(auto.get('trigger_config') or '{}') + sig = tc.get('signal_name', '').strip() + if sig: + signals.add(sig) + except (json.JSONDecodeError, TypeError): + pass + # Check fire_signal in then_actions + try: + ta = json.loads(auto.get('then_actions') or '[]') + for item in ta: + if item.get('type') == 'fire_signal': + sig = item.get('config', {}).get('signal_name', '').strip() + if sig: + signals.add(sig) + except (json.JSONDecodeError, TypeError): + pass + except Exception: + pass + return sorted(signals) + @app.route('/api/automations/blocks', methods=['GET']) def get_automation_blocks(): """Return available block types for the automation builder sidebar.""" @@ -3841,6 +3942,13 @@ def get_automation_blocks(): {"type": "duplicate_scan_completed", "label": "Duplicate Scan Done", "icon": "layers", "description": "When duplicate cleaner finishes", "available": True, "variables": ["files_scanned", "duplicates_found", "space_freed"]}, + # Signal trigger + {"type": "signal_received", "label": "Signal Received", "icon": "zap", + "description": "When another automation fires a named signal", "available": True, + "config_fields": [ + {"key": "signal_name", "type": "signal_input", "label": "Signal Name"} + ], + "variables": ["signal_name"]}, ], "actions": [ {"type": "process_wishlist", "label": "Process Wishlist", "icon": "list", "description": "Retry failed downloads from wishlist", "available": True, @@ -3893,7 +4001,14 @@ def get_automation_blocks(): "variables": ["time", "name", "run_count", "status"]}, {"type": "telegram", "label": "Telegram", "icon": "message", "description": "Send a Telegram message", "available": True, "variables": ["time", "name", "run_count", "status"]}, - ] + # Signal fire action + {"type": "fire_signal", "label": "Fire Signal", "icon": "zap", + "description": "Fire a signal that other automations can listen for", "available": True, + "config_fields": [ + {"key": "signal_name", "type": "signal_input", "label": "Signal Name"} + ]}, + ], + "known_signals": _collect_known_signals(), }) @app.route('/api/mirrored-playlists/list', methods=['GET']) diff --git a/webui/static/script.js b/webui/static/script.js index 14c9b25b..abad2d99 100644 --- a/webui/static/script.js +++ b/webui/static/script.js @@ -16187,6 +16187,60 @@ const TOOL_HELP_CONTENT = {
Fires when another automation sends a named signal using the Fire Signal then-action. This lets you chain automations together — one automation finishes and wakes up another.
+ +library_ready, scan_done). Must match the name used in the Fire Signal action.Use descriptive lowercase names with underscores: library_ready, scan_complete, downloads_done. Existing signal names from other automations appear as suggestions.
Fires a named signal after the automation's action completes. Any other automation with a Signal Received trigger listening for this signal name will wake up and run.
+ +library_ready). Use the same name in a Signal Received trigger on another automation to connect them.You can add up to 3 then-actions per automation. For example: Fire Signal + Discord notification + Telegram notification — all run after the action completes.
+ ` } }; @@ -43160,7 +43214,7 @@ async function discoverMirroredPlaylist(playlistId) { // =============================== let _autoBlocks = null; // cached block definitions from /api/automations/blocks -let _autoBuilder = { editId: null, when: null, do: null, notify: null }; +let _autoBuilder = { editId: null, when: null, do: null, then: [], isSystem: false }; let _autoMirroredPlaylists = null; // cached mirrored playlist list @@ -43172,6 +43226,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', + signal_received: '\u26A1', fire_signal: '\u26A1', // Phase 3 wishlist_processing_completed: '\u2705', watchlist_scan_completed: '\u2705', database_update_completed: '\uD83D\uDDC4\uFE0F', download_failed: '\u274C', @@ -43237,7 +43292,7 @@ function renderAutomationCard(a) { const aIcon = _autoIcons[a.action_type] || '\u2699\uFE0F'; const tl = tIcon + ' ' + _autoFormatTrigger(a.trigger_type, a.trigger_config); const al = aIcon + ' ' + _autoFormatAction(a.action_type); - const nl = a.notify_type ? _autoFormatNotify(a.notify_type) : ''; + const thenItems = a.then_actions || []; const actionDelay = a.action_config && a.action_config.delay ? a.action_config.delay : 0; const metaParts = []; if (a.is_system) metaParts.push('System'); @@ -43260,7 +43315,7 @@ function renderAutomationCard(a) { → ${actionDelay ? `\u23F3 ${actionDelay}m→` : ''} ${_esc(al)} - ${nl ? `→${_esc(nl)}` : ''} + ${thenItems.length ? thenItems.map(t => `→${_esc(_autoFormatNotify(t.type))}`).join('') : ''} @@ -43284,6 +43339,10 @@ function _autoFormatTrigger(type, config) { const days = (config.days || []).map(d => d.charAt(0).toUpperCase() + d.slice(1)).join(', '); return (days || 'Every day') + ' at ' + (config.time || '00:00'); } + if (type === 'signal_received' && config) { + const sig = config.signal_name || 'unknown'; + return 'Signal: ' + sig; + } const labels = { app_started: 'App Started', track_downloaded: 'Track Downloaded', batch_complete: 'Batch Complete', watchlist_new_release: 'New Release Found', playlist_synced: 'Playlist Synced', playlist_changed: 'Playlist Changed', discovery_completed: 'Discovery Complete', @@ -43292,7 +43351,8 @@ function _autoFormatTrigger(type, config) { download_quarantined: 'File Quarantined', wishlist_item_added: 'Wishlist Item Added', watchlist_artist_added: 'Artist Watched', watchlist_artist_removed: 'Artist Unwatched', import_completed: 'Import Complete', mirrored_playlist_created: 'Playlist Mirrored', - quality_scan_completed: 'Quality Scan Done', duplicate_scan_completed: 'Duplicate Scan Done' }; + quality_scan_completed: 'Quality Scan Done', duplicate_scan_completed: 'Duplicate Scan Done', + signal_received: 'Signal Received' }; let label = labels[type] || type || 'Unknown'; if (config && config.conditions && config.conditions.length) { const first = config.conditions[0]; @@ -43316,6 +43376,7 @@ function _autoFormatNotify(type) { if (type === 'discord_webhook') return 'Discord'; if (type === 'pushbullet') return 'Pushbullet'; if (type === 'telegram') return 'Telegram'; + if (type === 'fire_signal') return '\u26A1 Signal'; return type || ''; } function _autoTimeAgo(ts) { @@ -43473,7 +43534,12 @@ async function saveAutomation() { // Read configs from DOM const triggerConfig = _readPlacedConfig('when'); const actionConfig = _readPlacedConfig('do'); - const notifyConfig = _autoBuilder.notify ? _readPlacedConfig('notify') : {}; + + // Read THEN actions (multi-slot) + const thenActions = _autoBuilder.then.map((item, i) => ({ + type: item.type, + config: _readPlacedConfig('then-' + i), + })); // Read optional delay from DO slot const delayEl = document.getElementById('cfg-do-delay'); @@ -43484,8 +43550,7 @@ async function saveAutomation() { name, trigger_type: _autoBuilder.when.type, trigger_config: triggerConfig, action_type: _autoBuilder.do.type, action_config: actionConfig, - notify_type: _autoBuilder.notify ? _autoBuilder.notify.type : null, - notify_config: _autoBuilder.notify ? notifyConfig : {}, + then_actions: thenActions, }; try { @@ -43515,7 +43580,7 @@ async function showAutomationBuilder(editId) { } _autoMirroredPlaylists = null; // invalidate so it re-fetches - _autoBuilder = { editId: editId || null, when: null, do: null, notify: null, isSystem: false }; + _autoBuilder = { editId: editId || null, when: null, do: null, then: [], isSystem: false }; // If editing, load automation data if (editId) { @@ -43526,7 +43591,14 @@ async function showAutomationBuilder(editId) { document.getElementById('builder-name').value = a.name || ''; _autoBuilder.when = { type: a.trigger_type, config: a.trigger_config || {} }; _autoBuilder.do = { type: a.action_type, config: a.action_config || {} }; - if (a.notify_type) _autoBuilder.notify = { type: a.notify_type, config: a.notify_config || {} }; + // Load then_actions array + _autoBuilder.then = (a.then_actions || []).map(item => ({ + type: item.type, config: item.config || {} + })); + // Backward compat: if no then_actions but has notify_type + if (!_autoBuilder.then.length && a.notify_type) { + _autoBuilder.then = [{ type: a.notify_type, config: a.notify_config || {} }]; + } _autoBuilder.isSystem = !!a.is_system; } catch (err) { showToast('Failed to load automation', 'error'); return; } } else { @@ -43547,7 +43619,7 @@ function hideAutomationBuilder() { document.getElementById('automations-builder-view').style.display = 'none'; document.getElementById('automations-list-view').style.display = ''; document.getElementById('builder-name').readOnly = false; - _autoBuilder = { editId: null, when: null, do: null, notify: null, isSystem: false }; + _autoBuilder = { editId: null, when: null, do: null, then: [], isSystem: false }; } // --- Sidebar --- @@ -43560,7 +43632,7 @@ function _renderBuilderSidebar() { const sections = [ { key: 'triggers', title: 'Triggers', slot: 'when' }, { key: 'actions', title: 'Actions', slot: 'do' }, - { key: 'notifications', title: 'Notifications', slot: 'notify' }, + { key: 'notifications', title: 'Then', slot: 'then' }, ]; sections.forEach(sec => { @@ -43592,35 +43664,70 @@ function _renderBuilderCanvas() { if (!canvas) return; let html = ''; - const slots = [ - { key: 'when', label: 'WHEN', labelClass: 'when', prompt: 'Drag a trigger here — WHEN does this run?' }, - { key: 'do', label: 'DO', labelClass: 'do', prompt: 'Drag an action here — WHAT should it do?' }, - { key: 'notify', label: 'NOTIFY', labelClass: 'notify', prompt: 'Drag a notification here (optional)' }, - ]; - slots.forEach((slot, i) => { - if (i > 0) html += ''; - const data = _autoBuilder[slot.key]; - html += `${slot.label}`; - if (data) { - html += `