allow multiple notification calls per automation as well as a new signal fire utility

This commit is contained in:
Broque Thomas 2026-03-06 18:10:15 -08:00
parent dd5f2f07e9
commit 8b6a2c0adc
5 changed files with 530 additions and 82 deletions

View file

@ -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): 13 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."""

View file

@ -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

View file

@ -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'])

View file

@ -16187,6 +16187,60 @@ const TOOL_HELP_CONTENT = {
<li>Peace of mind for your library data</li>
</ul>
`
},
// ==================== Signal System Help ====================
'auto-signal_received': {
title: 'Signal Received',
content: `
<h4>What is this trigger?</h4>
<p>Fires when another automation sends a named signal using the <strong>Fire Signal</strong> then-action. This lets you chain automations together one automation finishes and wakes up another.</p>
<h4>Configuration</h4>
<ul>
<li><strong>Signal Name:</strong> The name to listen for (e.g. <code>library_ready</code>, <code>scan_done</code>). Must match the name used in the Fire Signal action.</li>
</ul>
<h4>How chaining works</h4>
<ol>
<li><strong>Automation A:</strong> Trigger = Batch Complete, Action = Scan Library, Then = Fire Signal "scan_done"</li>
<li><strong>Automation B:</strong> Trigger = Signal Received "scan_done", Action = Update Database</li>
<li>When a download finishes A scans library fires signal B wakes up updates database</li>
</ol>
<h4>Safety</h4>
<ul>
<li>Circular signal chains are detected and blocked when you save</li>
<li>Maximum chain depth of 5 levels to prevent runaway cascades</li>
<li>Same signal can only fire once every 10 seconds (cooldown)</li>
</ul>
<h4>Signal names</h4>
<p>Use descriptive lowercase names with underscores: <code>library_ready</code>, <code>scan_complete</code>, <code>downloads_done</code>. Existing signal names from other automations appear as suggestions.</p>
`
},
'auto-fire_signal': {
title: 'Fire Signal',
content: `
<h4>What does this then-action do?</h4>
<p>Fires a named signal after the automation's action completes. Any other automation with a <strong>Signal Received</strong> trigger listening for this signal name will wake up and run.</p>
<h4>Configuration</h4>
<ul>
<li><strong>Signal Name:</strong> The signal to fire (e.g. <code>library_ready</code>). Use the same name in a Signal Received trigger on another automation to connect them.</li>
</ul>
<h4>Use cases</h4>
<ul>
<li><strong>Multi-step workflows:</strong> Scan library fire signal update database fire signal send notification</li>
<li><strong>Fan-out:</strong> One signal can trigger multiple automations simultaneously</li>
<li><strong>Decoupled logic:</strong> Keep each automation simple with one job, chain them via signals</li>
</ul>
<h4>Combining with notifications</h4>
<p>You can add up to 3 then-actions per automation. For example: Fire Signal + Discord notification + Telegram notification all run after the action completes.</p>
`
}
};
@ -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('<span class="system-badge">System</span>');
@ -43260,7 +43315,7 @@ function renderAutomationCard(a) {
<span class="flow-arrow">&rarr;</span>
${actionDelay ? `<span class="flow-delay">\u23F3 ${actionDelay}m</span><span class="flow-arrow">&rarr;</span>` : ''}
<span class="flow-action">${_esc(al)}</span>
${nl ? `<span class="flow-arrow">&rarr;</span><span class="flow-notify">${_esc(nl)}</span>` : ''}
${thenItems.length ? thenItems.map(t => `<span class="flow-arrow">&rarr;</span><span class="flow-notify">${_esc(_autoFormatNotify(t.type))}</span>`).join('') : ''}
</div>
<div class="automation-meta">${metaParts.join(' &middot; ')}</div>
</div>
@ -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 += '<div class="flow-connector"></div>';
const data = _autoBuilder[slot.key];
html += `<span class="flow-slot-label ${slot.labelClass}">${slot.label}</span>`;
if (data) {
html += `<div class="flow-slot filled" id="slot-${slot.key}" ondragover="_autoDragOver(event,'${slot.key}')" ondragleave="_autoDragLeave(event,'${slot.key}')" ondrop="_autoDrop(event,'${slot.key}')">
${_renderPlacedBlock(slot.key, data)}
// WHEN slot
const whenData = _autoBuilder.when;
html += '<span class="flow-slot-label when">WHEN</span>';
if (whenData) {
html += `<div class="flow-slot filled" id="slot-when" ondragover="_autoDragOver(event,'when')" ondragleave="_autoDragLeave(event,'when')" ondrop="_autoDrop(event,'when')">
${_renderPlacedBlock('when', whenData)}
</div>`;
} else {
html += `<div class="flow-slot empty" id="slot-when" ondragover="_autoDragOver(event,'when')" ondragleave="_autoDragLeave(event,'when')" ondrop="_autoDrop(event,'when')">
<div class="flow-slot-prompt">Drag a trigger here WHEN does this run?</div>
</div>`;
}
html += '<div class="flow-connector"></div>';
// DO slot
const doData = _autoBuilder.do;
html += '<span class="flow-slot-label do">DO</span>';
if (doData) {
html += `<div class="flow-slot filled" id="slot-do" ondragover="_autoDragOver(event,'do')" ondragleave="_autoDragLeave(event,'do')" ondrop="_autoDrop(event,'do')">
${_renderPlacedBlock('do', doData)}
</div>`;
} else {
html += `<div class="flow-slot empty" id="slot-do" ondragover="_autoDragOver(event,'do')" ondragleave="_autoDragLeave(event,'do')" ondrop="_autoDrop(event,'do')">
<div class="flow-slot-prompt">Drag an action here WHAT should it do?</div>
</div>`;
}
html += '<div class="flow-connector"></div>';
// THEN section (multi-slot, 1-3 items)
html += '<span class="flow-slot-label then">THEN</span>';
if (_autoBuilder.then.length > 0) {
_autoBuilder.then.forEach((item, i) => {
if (i > 0) html += '<div class="flow-connector small"></div>';
html += `<div class="flow-slot filled then-slot" id="slot-then-${i}">
${_renderPlacedBlock('then-' + i, item)}
</div>`;
} else {
html += `<div class="flow-slot empty" id="slot-${slot.key}" ondragover="_autoDragOver(event,'${slot.key}')" ondragleave="_autoDragLeave(event,'${slot.key}')" ondrop="_autoDrop(event,'${slot.key}')">
<div class="flow-slot-prompt">${slot.prompt}</div>
</div>`;
}
});
});
}
if (_autoBuilder.then.length < 3) {
if (_autoBuilder.then.length > 0) html += '<div class="flow-connector small"></div>';
html += `<div class="flow-slot empty then-add" id="slot-then-add"
ondragover="_autoDragOver(event,'then')" ondragleave="_autoDragLeave(event,'then')" ondrop="_autoDrop(event,'then')">
<div class="flow-slot-prompt">${_autoBuilder.then.length === 0
? 'Drag a then-action here (optional)'
: '+ Add another (max 3)'}</div>
</div>`;
}
canvas.innerHTML = html;
// Load mirrored playlist selects if any are present
_autoLoadMirroredSelects();
// Set up checkbox state for refresh_mirrored
['when', 'do', 'notify'].forEach(sk => {
['when', 'do'].forEach(sk => {
const allCb = document.getElementById('cfg-' + sk + '-all');
if (allCb) _autoTogglePlaylistSelect(sk);
});
// Also check then slots
_autoBuilder.then.forEach((item, i) => {
const allCb = document.getElementById('cfg-then-' + i + '-all');
if (allCb) _autoTogglePlaylistSelect('then-' + i);
});
}
function _renderPlacedBlock(slotKey, data) {
@ -43708,6 +43815,36 @@ function _renderBlockConfigFields(slotKey, blockType, config) {
</select>
</div>`;
}
if (blockType === 'signal_received') {
const sigName = _escAttr(config.signal_name || '');
const knownSignals = (_autoBlocks && _autoBlocks.known_signals) || [];
return `<div class="config-row">
<label>Signal Name</label>
<input type="text" id="cfg-${slotKey}-signal_name" value="${sigName}"
list="known-signals-list-${slotKey}" placeholder="e.g. library_ready"
oninput="this.value = this.value.toLowerCase().replace(/[^a-z0-9_\\-]/g, '_').replace(/_+/g, '_').replace(/^_|_$/g, '')"
style="font-family:monospace;">
<datalist id="known-signals-list-${slotKey}">
${knownSignals.map(s => `<option value="${s}">`).join('')}
</datalist>
</div>
<div class="config-row" style="color:rgba(255,255,255,0.35);font-size:11px;">Triggers when another automation fires this signal</div>`;
}
if (blockType === 'fire_signal') {
const sigName = _escAttr(config.signal_name || '');
const knownSignals = (_autoBlocks && _autoBlocks.known_signals) || [];
return `<div class="config-row">
<label>Signal Name</label>
<input type="text" id="cfg-${slotKey}-signal_name" value="${sigName}"
list="known-signals-fire-${slotKey}" placeholder="e.g. library_ready"
oninput="this.value = this.value.toLowerCase().replace(/[^a-z0-9_\\-]/g, '_').replace(/_+/g, '_').replace(/^_|_$/g, '')"
style="font-family:monospace;">
<datalist id="known-signals-fire-${slotKey}">
${knownSignals.map(s => `<option value="${s}">`).join('')}
</datalist>
</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 === '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>';
}
@ -43948,7 +44085,13 @@ async function _autoLoadMirroredSelects() {
}
function _readPlacedConfig(slotKey) {
const data = _autoBuilder[slotKey];
let data;
if (slotKey.startsWith('then-')) {
const idx = parseInt(slotKey.split('-')[1]);
data = _autoBuilder.then[idx];
} else {
data = _autoBuilder[slotKey];
}
if (!data) return {};
const type = data.type;
if (type === 'schedule') {
@ -43997,6 +44140,9 @@ function _readPlacedConfig(slotKey) {
all: allCb ? allCb.checked : false,
};
}
if (type === 'signal_received' || type === 'fire_signal') {
return { signal_name: document.getElementById('cfg-' + slotKey + '-signal_name')?.value?.trim() || '' };
}
if (type === 'discord_webhook') {
return {
webhook_url: document.getElementById('cfg-' + slotKey + '-webhook_url')?.value?.trim() || '',
@ -44039,21 +44185,31 @@ function _autoDragStart(e, blockType, slotCategory) {
function _autoDragOver(e, slotKey) {
e.preventDefault();
e.dataTransfer.dropEffect = 'copy';
document.getElementById('slot-' + slotKey)?.classList.add('drag-over');
const targetId = slotKey === 'then' ? 'slot-then-add' : 'slot-' + slotKey;
document.getElementById(targetId)?.classList.add('drag-over');
}
function _autoDragLeave(e, slotKey) {
document.getElementById('slot-' + slotKey)?.classList.remove('drag-over');
const targetId = slotKey === 'then' ? 'slot-then-add' : 'slot-' + slotKey;
document.getElementById(targetId)?.classList.remove('drag-over');
}
function _autoDrop(e, slotKey) {
e.preventDefault();
document.getElementById('slot-' + slotKey)?.classList.remove('drag-over');
const dropTargetId = slotKey === 'then' ? 'slot-then-add' : 'slot-' + slotKey;
document.getElementById(dropTargetId)?.classList.remove('drag-over');
if (_autoBuilder.isSystem && (slotKey === 'when' || slotKey === 'do')) return;
try {
const data = JSON.parse(e.dataTransfer.getData('text/plain'));
if (data.slot !== slotKey) { showToast('Wrong slot — drop ' + data.slot + ' blocks here', 'error'); return; }
_autoBuilder[slotKey] = { type: data.type, config: {} };
// Handle THEN slot (append to array)
if (slotKey === 'then') {
if (data.slot !== 'then') { showToast('Wrong slot — drop ' + data.slot + ' blocks here', 'error'); return; }
if (_autoBuilder.then.length >= 3) { showToast('Maximum 3 then-actions', 'error'); return; }
_autoBuilder.then.push({ type: data.type, config: {} });
} else {
if (data.slot !== slotKey) { showToast('Wrong slot — drop ' + data.slot + ' blocks here', 'error'); return; }
_autoBuilder[slotKey] = { type: data.type, config: {} };
}
_renderBuilderCanvas();
} catch (err) {}
}
@ -44061,13 +44217,26 @@ function _autoDrop(e, slotKey) {
// Click-to-add (alternative to drag)
function _autoClickBlock(blockType, slotCategory) {
if (_autoBuilder.isSystem && (slotCategory === 'when' || slotCategory === 'do')) return;
_autoBuilder[slotCategory] = { type: blockType, config: {} };
if (slotCategory === 'then') {
if (_autoBuilder.then.length >= 3) { showToast('Maximum 3 then-actions', 'error'); return; }
_autoBuilder.then.push({ type: blockType, config: {} });
} else {
_autoBuilder[slotCategory] = { type: blockType, config: {} };
}
_renderBuilderCanvas();
}
function _autoRemoveBlock(slotKey) {
if (_autoBuilder.isSystem && (slotKey === 'when' || slotKey === 'do')) return;
_autoBuilder[slotKey] = null;
// Handle then-N slots
if (slotKey.startsWith('then-')) {
const idx = parseInt(slotKey.split('-')[1]);
if (!isNaN(idx) && idx >= 0 && idx < _autoBuilder.then.length) {
_autoBuilder.then.splice(idx, 1);
}
} else {
_autoBuilder[slotKey] = null;
}
_renderBuilderCanvas();
}

View file

@ -29110,6 +29110,18 @@ body {
.flow-slot-label.when { background: rgba(var(--accent-rgb),0.2); color: rgb(var(--accent-light-rgb)); }
.flow-slot-label.do { background: rgba(88,101,242,0.2); color: #7289da; }
.flow-slot-label.notify { background: rgba(250,204,21,0.15); color: #fbbf24; }
.flow-slot-label.then { background: rgba(250,204,21,0.15); color: #fbbf24; }
.flow-connector.small { height: 16px; }
.then-add {
min-height: 48px !important;
border-style: dashed;
opacity: 0.6;
transition: opacity 0.3s ease;
}
.then-add:hover, .then-add.drag-over { opacity: 1; }
.then-add .flow-slot-prompt { font-size: 12px; padding: 12px; }
/* --- Placed Block (in flow) --- */
.placed-block {