Full automation engine expansion with scheduling, triggers, actions, and UI polish

This commit is contained in:
Broque Thomas 2026-03-05 15:13:32 -08:00
parent 75f9b7364a
commit da707dcf0a
7 changed files with 2647 additions and 9 deletions

View file

@ -13,7 +13,7 @@ def register_routes(bp):
@bp.route("/listenbrainz/playlists", methods=["GET"])
@require_api_key
def list_playlists():
def list_listenbrainz_playlists():
"""List cached ListenBrainz playlists.
Query params:
@ -71,7 +71,7 @@ def register_routes(bp):
@bp.route("/listenbrainz/playlists/<playlist_id>", methods=["GET"])
@require_api_key
def get_playlist(playlist_id):
def get_listenbrainz_playlist(playlist_id):
"""Get a ListenBrainz playlist with its tracks.
playlist_id can be the internal ID or the MusicBrainz playlist MBID.

528
core/automation_engine.py Normal file
View file

@ -0,0 +1,528 @@
"""
Automation Engine trigger action notify scheduler for SoulSync.
Architecture:
- Triggers (WHEN): schedule timer, event-based (track_downloaded, batch_complete, etc.)
- Actions (DO): real SoulSync operations registered by web_server.py
- Notifications (NOTIFY): optional Discord webhook with template variables
- Conditions: optional filters on event data (artist contains, title equals, etc.)
Uses threading.Timer pattern for schedule triggers.
Event triggers react to emit() calls from web_server.py hook points.
"""
import json
import time
import threading
import requests
from datetime import datetime, timedelta
from utils.logging_config import get_logger
logger = get_logger("automation_engine")
class AutomationEngine:
def __init__(self, db):
self.db = db
self._timers = {} # automation_id → threading.Timer
self._lock = threading.Lock()
self._running = False
# Action handlers registered by web_server.py (avoids circular imports)
# Format: {type: {'handler': fn(config)->dict, 'guard': fn()->bool or None}}
self._action_handlers = {}
# Event trigger cache: trigger_type → [automation_id, ...]
self._event_automations = {}
self._event_cache_dirty = True
# Trigger registry: type → setup function (schedule only — events use emit())
self._trigger_handlers = {
'schedule': self._setup_schedule_trigger,
'daily_time': self._setup_daily_time_trigger,
'weekly_time': self._setup_weekly_time_trigger,
}
# --- Action Handler Registration ---
def register_action_handler(self, action_type, handler_fn, guard_fn=None):
"""Register a callable for an action type.
handler_fn(config) -> dict with result data
guard_fn() -> bool (True = busy, should skip)
"""
self._action_handlers[action_type] = {
'handler': handler_fn,
'guard': guard_fn,
}
logger.debug(f"Registered action handler: {action_type}")
# --- Lifecycle ---
def start(self):
"""Load all enabled automations from DB and schedule them."""
self._running = True
self._event_cache_dirty = True
automations = self.db.get_automations()
scheduled = 0
event_count = 0
for auto in automations:
if auto.get('enabled'):
trigger_type = auto.get('trigger_type', '')
if trigger_type in self._trigger_handlers:
self.schedule_automation(auto['id'])
scheduled += 1
else:
event_count += 1
# Pre-build event cache
self._rebuild_event_cache()
logger.info(f"AutomationEngine started — {scheduled} scheduled, {event_count} event-based")
def stop(self):
"""Cancel all timers on shutdown."""
self._running = False
with self._lock:
for aid, timer in self._timers.items():
timer.cancel()
count = len(self._timers)
self._timers.clear()
if count:
logger.info(f"AutomationEngine stopped — cancelled {count} timer(s)")
# --- Scheduling ---
def schedule_automation(self, automation_id):
"""Set up timer for a single automation based on its trigger type."""
auto = self.db.get_automation(automation_id)
if not auto or not auto.get('enabled'):
return
trigger_type = auto.get('trigger_type')
setup_fn = self._trigger_handlers.get(trigger_type)
if not setup_fn:
# Event-based trigger — no timer needed, just invalidate cache
self._event_cache_dirty = True
return
try:
config = json.loads(auto.get('trigger_config') or '{}')
except json.JSONDecodeError:
config = {}
self.cancel_automation(automation_id)
setup_fn(automation_id, config)
def cancel_automation(self, automation_id):
"""Cancel timer for an automation and invalidate event cache."""
with self._lock:
timer = self._timers.pop(automation_id, None)
if timer:
timer.cancel()
self._event_cache_dirty = True
# --- Event Bus ---
def emit(self, event_type, data):
"""Called from web_server.py when events occur. Non-blocking."""
if not self._running:
return
thread = threading.Thread(
target=self._process_event,
args=(event_type, dict(data)),
daemon=True,
name=f'automation-event-{event_type}'
)
thread.start()
def _process_event(self, event_type, data):
"""Find matching automations and run them."""
try:
if self._event_cache_dirty:
self._rebuild_event_cache()
automation_ids = self._event_automations.get(event_type, [])
if not automation_ids:
return
logger.debug(f"Event '{event_type}' — checking {len(automation_ids)} automation(s)")
for aid in automation_ids:
try:
auto = self.db.get_automation(aid)
if not auto or not auto.get('enabled'):
continue
config = json.loads(auto.get('trigger_config') or '{}')
if self._evaluate_conditions(config, data):
logger.info(f"Event '{event_type}' matched automation '{auto.get('name')}' (id={aid})")
# Run in separate thread so delays don't block the event loop
threading.Thread(
target=self._run_event_automation,
args=(auto, aid, data),
daemon=True,
name=f'automation-exec-{aid}'
).start()
else:
logger.debug(f"Event '{event_type}' conditions not met for automation {aid}")
except Exception as e:
logger.error(f"Event automation {aid} error: {e}")
except Exception as e:
logger.error(f"Event processing error for '{event_type}': {e}")
def _rebuild_event_cache(self):
"""Cache which automations listen to which event types."""
self._event_automations = {}
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'])
except Exception as e:
logger.error(f"Failed to rebuild event cache: {e}")
self._event_cache_dirty = False
logger.debug(f"Event cache rebuilt: {dict((k, len(v)) for k, v in self._event_automations.items())}")
def _evaluate_conditions(self, trigger_config, event_data):
"""Check if event data matches trigger conditions. No conditions = always match."""
conditions = trigger_config.get('conditions', [])
if not conditions:
return True
match_mode = trigger_config.get('match', 'all')
results = []
for cond in conditions:
field = cond.get('field', '')
operator = cond.get('operator', 'contains')
value = cond.get('value', '').lower()
event_value = str(event_data.get(field, '')).lower()
if operator == 'contains':
results.append(value in event_value)
elif operator == 'equals':
results.append(value == event_value)
elif operator == 'starts_with':
results.append(event_value.startswith(value))
elif operator == 'not_contains':
results.append(value not in event_value)
else:
results.append(False)
if match_mode == 'any':
return any(results)
return all(results)
def _run_event_automation(self, auto, automation_id, event_data):
"""Execute action for an event-triggered automation."""
action_type = auto.get('action_type')
# Check for action delay
try:
action_config = json.loads(auto.get('action_config') or '{}')
except json.JSONDecodeError:
action_config = {}
delay_minutes = action_config.get('delay', 0)
if delay_minutes and delay_minutes > 0:
logger.info(f"Event automation '{auto.get('name')}' delaying {delay_minutes}m before action")
time.sleep(int(delay_minutes) * 60)
if not self._running:
return
# notify_only = no action, just send notification with event data
if action_type == 'notify_only':
result = {'status': 'triggered'}
else:
handler_info = self._action_handlers.get(action_type)
if not handler_info:
result = {'status': 'error', 'error': f'No handler for {action_type}'}
logger.warning(f"No handler for action '{action_type}' on event automation {automation_id}")
else:
guard_fn = handler_info.get('guard')
if guard_fn and guard_fn():
result = {'status': 'skipped', 'reason': f'{action_type} already running'}
logger.info(f"Event automation '{auto.get('name')}' skipped — {action_type} busy")
else:
try:
result = handler_info['handler'](action_config) or {}
logger.info(f"Event automation '{auto.get('name')}' executed: {result.get('status', 'ok')}")
except Exception as e:
result = {'status': 'error', 'error': str(e)}
logger.error(f"Event automation '{auto.get('name')}' action failed: {e}")
# Merge event data into result for notification variables
merged = {**event_data, **result}
try:
self._send_notification(auto, merged)
except Exception as e:
logger.error(f"Notification failed for event automation {automation_id}: {e}")
# Update run stats (no reschedule — event triggers don't use timers)
last_result = json.dumps(merged)
error = result.get('error') if result.get('status') == 'error' else None
self.db.update_automation_run(automation_id, error=error, last_result=last_result)
# --- Schedule Execution (timer-based) ---
def run_automation(self, automation_id, skip_delay=False):
"""Execute: check guard → run action → send notification → update stats → reschedule."""
if not self._running:
return
auto = self.db.get_automation(automation_id)
if not auto or not auto.get('enabled'):
return
action_type = auto.get('action_type')
# notify_only for scheduled automations
if action_type == 'notify_only':
result = {'status': 'triggered'}
try:
self._send_notification(auto, result)
except Exception as e:
logger.error(f"Notification failed for automation {automation_id}: {e}")
self._finish_run(auto, automation_id, result, error=None)
return
handler_info = self._action_handlers.get(action_type)
if not handler_info:
logger.warning(f"No handler for action '{action_type}' on automation {automation_id}")
self.db.update_automation_run(automation_id, error=f"No handler for action: {action_type}")
return
try:
action_config = json.loads(auto.get('action_config') or '{}')
except json.JSONDecodeError:
action_config = {}
# Action delay (skipped for manual run_now)
delay_minutes = action_config.get('delay', 0)
if not skip_delay and delay_minutes and delay_minutes > 0:
logger.info(f"Automation '{auto['name']}' delaying {delay_minutes}m before action")
time.sleep(int(delay_minutes) * 60)
if not self._running:
return
# Check guard (is the operation already running?)
guard_fn = handler_info.get('guard')
if guard_fn and guard_fn():
result = {'status': 'skipped', 'reason': f'{action_type} is already running'}
logger.info(f"Automation '{auto['name']}' skipped — {action_type} already running")
self._finish_run(auto, automation_id, result, error=None)
return
# Execute the action
error = None
result = {}
try:
result = handler_info['handler'](action_config) or {}
logger.info(f"Automation '{auto['name']}' (id={automation_id}) executed: {result.get('status', 'ok')}")
except Exception as e:
error = str(e)
result = {'status': 'error', 'error': error}
logger.error(f"Automation '{auto['name']}' (id={automation_id}) failed: {e}")
# Send notification if configured
try:
self._send_notification(auto, result)
except Exception as e:
logger.error(f"Notification failed for automation {automation_id}: {e}")
self._finish_run(auto, automation_id, result, error)
def _finish_run(self, auto, automation_id, result, error):
"""Update DB with run stats and reschedule."""
next_run_str = None
trigger_type = auto.get('trigger_type', '')
# Only compute next_run for timer-based triggers (event triggers don't have scheduled runs)
if trigger_type in self._trigger_handlers:
try:
trigger_config = json.loads(auto.get('trigger_config') or '{}')
if trigger_type == 'daily_time':
# Next run is tomorrow at the configured time
time_str = trigger_config.get('time', '00:00')
hour, minute = map(int, time_str.split(':'))
target = datetime.now().replace(hour=hour, minute=minute, second=0, microsecond=0) + timedelta(days=1)
next_run_str = target.strftime('%Y-%m-%d %H:%M:%S')
elif trigger_type == 'weekly_time':
time_str = trigger_config.get('time', '00:00')
hour, minute = map(int, time_str.split(':'))
target = self._next_weekly_occurrence(hour, minute, trigger_config.get('days', []))
next_run_str = target.strftime('%Y-%m-%d %H:%M:%S')
else:
delay = self._calc_delay_seconds(trigger_config)
if delay:
next_run_str = (datetime.now() + timedelta(seconds=delay)).strftime('%Y-%m-%d %H:%M:%S')
except Exception:
pass
last_result = json.dumps(result) if result else None
self.db.update_automation_run(automation_id, next_run=next_run_str, error=error, last_result=last_result)
if self._running:
self.schedule_automation(automation_id)
def run_now(self, automation_id):
"""Manual trigger — run immediately in a background thread.
Always uses run_automation (skips condition checks and action delay)."""
auto = self.db.get_automation(automation_id)
if not auto:
return False
thread = threading.Thread(
target=self.run_automation,
args=(automation_id, True),
daemon=True,
name=f'automation-run-{automation_id}'
)
thread.start()
return True
# --- Trigger handlers ---
def _calc_delay_seconds(self, config):
"""Calculate delay in seconds from schedule config."""
interval = config.get('interval', 1)
unit = config.get('unit', 'hours')
multipliers = {'minutes': 60, 'hours': 3600, 'days': 86400}
return max(int(interval), 1) * multipliers.get(unit, 3600)
def _setup_schedule_trigger(self, automation_id, config):
"""Config: {"interval": 6, "unit": "hours"}"""
delay = self._calc_delay_seconds(config)
# If there's a next_run in the future, use remaining time instead
auto = self.db.get_automation(automation_id)
if auto and auto.get('next_run'):
try:
next_run = datetime.strptime(auto['next_run'], '%Y-%m-%d %H:%M:%S')
remaining = (next_run - datetime.now()).total_seconds()
if remaining > 0:
delay = remaining
except (ValueError, TypeError):
pass
next_run_str = (datetime.now() + timedelta(seconds=delay)).strftime('%Y-%m-%d %H:%M:%S')
self.db.update_automation(automation_id, next_run=next_run_str)
timer = threading.Timer(delay, self.run_automation, args=(automation_id,))
timer.daemon = True
timer.start()
with self._lock:
self._timers[automation_id] = timer
logger.debug(f"Scheduled automation {automation_id} in {delay:.0f}s")
def _setup_daily_time_trigger(self, automation_id, config):
"""Config: {"time": "03:00"} — runs daily at the specified local time."""
time_str = config.get('time', '00:00')
try:
hour, minute = map(int, time_str.split(':'))
except (ValueError, AttributeError):
hour, minute = 0, 0
now = datetime.now()
target = now.replace(hour=hour, minute=minute, second=0, microsecond=0)
if target <= now:
target += timedelta(days=1)
delay = (target - now).total_seconds()
next_run_str = target.strftime('%Y-%m-%d %H:%M:%S')
self.db.update_automation(automation_id, next_run=next_run_str)
timer = threading.Timer(delay, self.run_automation, args=(automation_id,))
timer.daemon = True
timer.start()
with self._lock:
self._timers[automation_id] = timer
logger.debug(f"Daily automation {automation_id} scheduled for {time_str} (in {delay:.0f}s)")
def _setup_weekly_time_trigger(self, automation_id, config):
"""Config: {"time": "03:00", "days": ["mon", "wed", "fri"]}"""
time_str = config.get('time', '00:00')
try:
hour, minute = map(int, time_str.split(':'))
except (ValueError, AttributeError):
hour, minute = 0, 0
target = self._next_weekly_occurrence(hour, minute, config.get('days', []))
delay = (target - datetime.now()).total_seconds()
next_run_str = target.strftime('%Y-%m-%d %H:%M:%S')
self.db.update_automation(automation_id, next_run=next_run_str)
timer = threading.Timer(delay, self.run_automation, args=(automation_id,))
timer.daemon = True
timer.start()
with self._lock:
self._timers[automation_id] = timer
day_names = ', '.join(config.get('days', [])) or 'every day'
logger.debug(f"Weekly automation {automation_id} scheduled for {time_str} on {day_names} (in {delay:.0f}s)")
def _next_weekly_occurrence(self, hour, minute, days):
"""Find the next datetime matching one of the given weekday abbreviations."""
day_map = {'mon': 0, 'tue': 1, 'wed': 2, 'thu': 3, 'fri': 4, 'sat': 5, 'sun': 6}
allowed = {day_map[d] for d in days if d in day_map}
if not allowed:
allowed = set(range(7)) # no days selected = every day
now = datetime.now()
for offset in range(8): # check today + next 7 days
candidate = now + timedelta(days=offset)
if candidate.weekday() in allowed:
target = candidate.replace(hour=hour, minute=minute, second=0, microsecond=0)
if target > now:
return target
# 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
try:
config = json.loads(automation.get('notify_config') or '{}')
except json.JSONDecodeError:
config = {}
# Build template variables
variables = {
'time': datetime.now().strftime('%Y-%m-%d %H:%M:%S'),
'name': automation.get('name', 'Automation'),
'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 notify_type == 'discord_webhook':
self._send_discord_notification(config, variables)
def _send_discord_notification(self, config, variables):
"""POST to Discord webhook with template variable substitution."""
url = config.get('webhook_url', '').strip()
if not url:
raise ValueError("No webhook URL configured")
message = config.get('message', '{name} completed with status: {status}')
# Substitute all variables
for key, value in variables.items():
message = message.replace('{' + key + '}', value)
resp = requests.post(url, json={"content": message}, timeout=10)
if resp.status_code not in (200, 204):
raise RuntimeError(f"Discord webhook returned {resp.status_code}: {resp.text[:200]}")

View file

@ -375,6 +375,31 @@ class MusicDatabase:
cursor.execute("CREATE INDEX IF NOT EXISTS idx_mirrored_playlists_source ON mirrored_playlists (source, source_playlist_id)")
cursor.execute("CREATE INDEX IF NOT EXISTS idx_mirrored_tracks_playlist ON mirrored_playlist_tracks (playlist_id)")
# Automations table — trigger → action scheduled tasks
cursor.execute("""
CREATE TABLE IF NOT EXISTS automations (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
enabled INTEGER DEFAULT 1,
trigger_type TEXT NOT NULL,
trigger_config TEXT DEFAULT '{}',
action_type TEXT NOT NULL,
action_config TEXT DEFAULT '{}',
last_run TIMESTAMP,
next_run TIMESTAMP,
run_count INTEGER DEFAULT 0,
last_error TEXT,
profile_id INTEGER DEFAULT 1,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
""")
cursor.execute("CREATE INDEX IF NOT EXISTS idx_automations_profile ON automations (profile_id)")
cursor.execute("CREATE INDEX IF NOT EXISTS idx_automations_enabled ON automations (enabled)")
# Add notification columns to automations (migration)
self._add_automation_notify_columns(cursor)
conn.commit()
logger.info("Database initialized successfully")
@ -382,6 +407,18 @@ class MusicDatabase:
logger.error(f"Error initializing database: {e}")
raise
def _add_automation_notify_columns(self, cursor):
"""Add notification and result columns to automations table."""
try:
cursor.execute("PRAGMA table_info(automations)")
cols = [c[1] for c in cursor.fetchall()]
for col, typedef in [('notify_type', 'TEXT DEFAULT NULL'), ('notify_config', "TEXT DEFAULT '{}'"), ('last_result', 'TEXT DEFAULT NULL')]:
if col not in cols:
cursor.execute(f"ALTER TABLE automations ADD COLUMN {col} {typedef}")
logger.info(f"Added {col} column to automations table")
except Exception as e:
logger.error(f"Error adding automation notify columns: {e}")
def _add_server_source_columns(self, cursor):
"""Add server_source columns to existing tables for multi-server support"""
try:
@ -6483,6 +6520,122 @@ class MusicDatabase:
logger.error(f"Error deleting mirrored playlist: {e}")
return False
# ===========================
# AUTOMATIONS CRUD
# ===========================
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 = '{}'):
"""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))
conn.commit()
return cursor.lastrowid
except Exception as e:
logger.error(f"Error creating automation: {e}")
return None
def get_automations(self, profile_id: int = 1):
"""Get all automations for a profile."""
try:
with self._get_connection() as conn:
cursor = conn.cursor()
cursor.execute("""
SELECT * FROM automations WHERE profile_id = ? ORDER BY created_at DESC
""", (profile_id,))
rows = cursor.fetchall()
return [dict(row) for row in rows]
except Exception as e:
logger.error(f"Error getting automations: {e}")
return []
def get_automation(self, automation_id: int):
"""Get a single automation by ID."""
try:
with self._get_connection() as conn:
cursor = conn.cursor()
cursor.execute("SELECT * FROM automations WHERE id = ?", (automation_id,))
row = cursor.fetchone()
return dict(row) if row else None
except Exception as e:
logger.error(f"Error getting automation {automation_id}: {e}")
return None
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'}
updates = {k: v for k, v in kwargs.items() if k in allowed}
if not updates:
return False
try:
with self._get_connection() as conn:
cursor = conn.cursor()
set_clause = ', '.join(f"{k} = ?" for k in updates)
values = list(updates.values()) + [automation_id]
cursor.execute(
f"UPDATE automations SET {set_clause}, updated_at = CURRENT_TIMESTAMP WHERE id = ?",
values
)
conn.commit()
return cursor.rowcount > 0
except Exception as e:
logger.error(f"Error updating automation {automation_id}: {e}")
return False
def delete_automation(self, automation_id: int) -> bool:
"""Delete an automation."""
try:
with self._get_connection() as conn:
cursor = conn.cursor()
cursor.execute("DELETE FROM automations WHERE id = ?", (automation_id,))
conn.commit()
return cursor.rowcount > 0
except Exception as e:
logger.error(f"Error deleting automation {automation_id}: {e}")
return False
def toggle_automation(self, automation_id: int) -> bool:
"""Toggle the enabled state of an automation. Returns True on success."""
try:
with self._get_connection() as conn:
cursor = conn.cursor()
cursor.execute(
"UPDATE automations SET enabled = CASE WHEN enabled = 1 THEN 0 ELSE 1 END, updated_at = CURRENT_TIMESTAMP WHERE id = ?",
(automation_id,)
)
conn.commit()
return cursor.rowcount > 0
except Exception as e:
logger.error(f"Error toggling automation {automation_id}: {e}")
return False
def update_automation_run(self, automation_id: int, next_run=None, error=None, last_result=None) -> bool:
"""Record a run: set last_run=now, increment run_count, optionally set next_run, last_error, last_result."""
try:
with self._get_connection() as conn:
cursor = conn.cursor()
cursor.execute("""
UPDATE automations
SET last_run = CURRENT_TIMESTAMP,
run_count = run_count + 1,
next_run = ?,
last_error = ?,
last_result = ?,
updated_at = CURRENT_TIMESTAMP
WHERE id = ?
""", (next_run, error, last_result, automation_id))
conn.commit()
return cursor.rowcount > 0
except Exception as e:
logger.error(f"Error updating automation run {automation_id}: {e}")
return False
# Thread-safe singleton pattern for database access
_database_instances: Dict[int, MusicDatabase] = {} # Thread ID -> Database instance
_database_lock = threading.Lock()

View file

@ -76,6 +76,7 @@ from core.spotify_worker import SpotifyWorker
from core.itunes_worker import iTunesWorker
from core.hydrabase_worker import HydrabaseWorker
from core.hydrabase_client import HydrabaseClient
from core.automation_engine import AutomationEngine
# --- Flask App Setup ---
base_dir = os.path.abspath(os.path.dirname(__file__))
@ -254,6 +255,267 @@ except Exception as e:
print(f"🔴 FATAL: Error initializing service clients: {e}")
spotify_client = plex_client = jellyfin_client = navidrome_client = soulseek_client = tidal_client = matching_engine = sync_service = web_scan_manager = None
# --- Automation Engine ---
try:
automation_engine = AutomationEngine(get_database())
print("✅ Automation engine initialized.")
except Exception as e:
print(f"⚠️ Automation engine failed to initialize: {e}")
automation_engine = None
def _register_automation_handlers():
"""Register real SoulSync action handlers with the automation engine."""
if not automation_engine:
return
def _auto_process_wishlist(config):
if is_wishlist_actually_processing():
return {'status': 'skipped', 'reason': 'Wishlist already processing'}
threading.Thread(target=_process_wishlist_automatically, daemon=True, name='auto-wishlist').start()
return {'status': 'started', 'category': config.get('category', 'all')}
def _auto_scan_watchlist(config):
if is_watchlist_actually_scanning():
return {'status': 'skipped', 'reason': 'Watchlist already scanning'}
threading.Thread(target=_process_watchlist_scan_automatically, daemon=True, name='auto-watchlist').start()
return {'status': 'started'}
def _auto_scan_library(config):
if web_scan_manager:
result = web_scan_manager.request_scan('Automation trigger')
return {'status': result.get('status', 'unknown')}
return {'status': 'error', 'reason': 'Scan manager not available'}
automation_engine.register_action_handler('process_wishlist', _auto_process_wishlist, is_wishlist_actually_processing)
automation_engine.register_action_handler('scan_watchlist', _auto_scan_watchlist, is_watchlist_actually_scanning)
automation_engine.register_action_handler('scan_library', _auto_scan_library)
def _auto_refresh_mirrored(config):
"""Refresh mirrored playlist(s) from source."""
db = get_database()
playlist_id = config.get('playlist_id')
refresh_all = config.get('all', False)
if refresh_all:
playlists = db.get_mirrored_playlists()
elif playlist_id:
p = db.get_mirrored_playlist(int(playlist_id))
playlists = [p] if p else []
else:
return {'status': 'error', 'reason': 'No playlist specified'}
refreshed = 0
errors = []
for pl in playlists:
try:
source = pl.get('source', '')
source_id = pl.get('source_playlist_id', '')
if source == 'spotify' and spotify_client and spotify_client.is_spotify_authenticated():
playlist_obj = spotify_client.get_playlist_by_id(source_id)
if playlist_obj and playlist_obj.tracks:
tracks = []
for t in playlist_obj.tracks:
# Track.artists is List[str], not List[dict]
artist_name = t.artists[0] if t.artists else ''
tracks.append({
'track_name': t.name or '',
'artist_name': str(artist_name),
'album_name': t.album or '',
'duration_ms': t.duration_ms or 0,
'source_track_id': t.id or '',
})
# Compare old vs new track IDs to detect changes
old_tracks = db.get_mirrored_playlist_tracks(pl['id']) if pl.get('id') else []
old_ids = {t.get('source_track_id') for t in old_tracks if t.get('source_track_id')}
new_ids = {t.get('source_track_id') for t in tracks if t.get('source_track_id')}
db.mirror_playlist(
source=source,
source_playlist_id=source_id,
name=pl['name'],
tracks=tracks,
profile_id=pl.get('profile_id', 1),
# Preserve existing image/owner — Playlist dataclass lacks image_url
owner=getattr(playlist_obj, 'owner', pl.get('owner')),
image_url=pl.get('image_url'),
)
refreshed += 1
# Emit playlist_changed if tracks actually changed
if old_ids != new_ids:
try:
if automation_engine:
automation_engine.emit('playlist_changed', {
'playlist_name': pl.get('name', ''),
'old_count': str(len(old_ids)),
'new_count': str(len(new_ids)),
'added': str(len(new_ids - old_ids)),
'removed': str(len(old_ids - new_ids)),
})
except Exception:
pass
except Exception as e:
errors.append(f"{pl.get('name', '?')}: {str(e)}")
return {'status': 'completed', 'refreshed': str(refreshed), 'errors': str(len(errors))}
def _auto_sync_playlist(config):
"""Sync a mirrored playlist to media server."""
playlist_id = config.get('playlist_id')
if not playlist_id:
return {'status': 'error', 'reason': 'No playlist specified'}
db = get_database()
pl = db.get_mirrored_playlist(int(playlist_id))
if not pl:
return {'status': 'error', 'reason': 'Playlist not found'}
tracks = db.get_mirrored_playlist_tracks(int(playlist_id))
if not tracks:
return {'status': 'error', 'reason': 'No tracks in playlist'}
# Convert mirrored tracks to format expected by _run_sync_task
tracks_json = []
for t in tracks:
tracks_json.append({
'name': t.get('track_name', ''),
'artists': [{'name': t.get('artist_name', '')}],
'album': t.get('album_name', ''),
'duration_ms': t.get('duration_ms', 0),
'id': t.get('source_track_id', ''),
})
sync_id = f"auto_mirror_{playlist_id}"
threading.Thread(
target=_run_sync_task,
args=(sync_id, pl['name'], json.dumps(tracks_json)),
daemon=True,
name=f'auto-sync-{playlist_id}'
).start()
return {'status': 'started', 'playlist_name': pl['name']}
automation_engine.register_action_handler('refresh_mirrored', _auto_refresh_mirrored)
automation_engine.register_action_handler('sync_playlist', _auto_sync_playlist)
# --- Phase 3 action handlers ---
def _auto_start_database_update(config):
if db_update_state.get('status') == 'running':
return {'status': 'skipped', 'reason': 'Database update already running'}
full = config.get('full_refresh', False)
active_server = config_manager.get_active_media_server()
with db_update_lock:
db_update_state.update({
"status": "running", "phase": "Initializing...",
"progress": 0, "current_item": "", "processed": 0, "total": 0, "error_message": ""
})
db_update_executor.submit(_run_db_update_task, full, active_server)
return {'status': 'started', 'full_refresh': str(full)}
def _auto_run_duplicate_cleaner(config):
if duplicate_cleaner_state.get('status') == 'running':
return {'status': 'skipped', 'reason': 'Duplicate cleaner already running'}
duplicate_cleaner_executor.submit(_run_duplicate_cleaner)
return {'status': 'started'}
def _auto_clear_quarantine(config):
import shutil as _shutil
quarantine_path = os.path.join(docker_resolve_path(config_manager.get('soulseek.download_path', './downloads')), 'ss_quarantine')
if not os.path.exists(quarantine_path):
return {'status': 'completed', 'removed': '0'}
removed = 0
for f in os.listdir(quarantine_path):
fp = os.path.join(quarantine_path, f)
try:
if os.path.isfile(fp):
os.remove(fp)
removed += 1
elif os.path.isdir(fp):
_shutil.rmtree(fp)
removed += 1
except Exception:
pass
return {'status': 'completed', 'removed': str(removed)}
def _auto_cleanup_wishlist(config):
db = get_database()
removed = db.remove_wishlist_duplicates(get_current_profile_id())
return {'status': 'completed', 'removed': str(removed or 0)}
def _auto_update_discovery_pool(config):
try:
from core.watchlist_scanner import get_watchlist_scanner
scanner = get_watchlist_scanner(spotify_client)
threading.Thread(target=scanner.update_discovery_pool_incremental,
args=(get_current_profile_id(),), daemon=True).start()
return {'status': 'started'}
except Exception as e:
return {'status': 'error', 'reason': str(e)}
def _auto_start_quality_scan(config):
if quality_scanner_state.get('status') == 'running':
return {'status': 'skipped', 'reason': 'Quality scan already running'}
scope = config.get('scope', 'watchlist')
quality_scanner_executor.submit(_run_quality_scanner, scope, get_current_profile_id())
return {'status': 'started', 'scope': scope}
def _auto_backup_database(config):
import sqlite3, glob as _glob
db_path = os.environ.get('DATABASE_PATH', 'database/music_library.db')
if not os.path.exists(db_path):
return {'status': 'error', 'reason': 'Database file not found'}
max_backups = 3
timestamp = datetime.now().strftime('%Y%m%d_%H%M%S')
backup_path = f"{db_path}.backup_{timestamp}"
# Use SQLite backup API for safe hot-copy of active database
src = sqlite3.connect(db_path)
dst = sqlite3.connect(backup_path)
src.backup(dst)
dst.close()
src.close()
size_mb = round(os.path.getsize(backup_path) / (1024 * 1024), 1)
# Rolling cleanup — keep only the newest N backups
existing = sorted(_glob.glob(f"{db_path}.backup_*"), key=os.path.getmtime)
while len(existing) > max_backups:
try:
os.remove(existing.pop(0))
except Exception:
pass
return {'status': 'completed', 'backup_path': backup_path, 'size_mb': str(size_mb)}
automation_engine.register_action_handler('start_database_update', _auto_start_database_update,
lambda: db_update_state.get('status') == 'running')
automation_engine.register_action_handler('run_duplicate_cleaner', _auto_run_duplicate_cleaner,
lambda: duplicate_cleaner_state.get('status') == 'running')
automation_engine.register_action_handler('clear_quarantine', _auto_clear_quarantine)
automation_engine.register_action_handler('cleanup_wishlist', _auto_cleanup_wishlist)
automation_engine.register_action_handler('update_discovery_pool', _auto_update_discovery_pool)
automation_engine.register_action_handler('start_quality_scan', _auto_start_quality_scan,
lambda: quality_scanner_state.get('status') == 'running')
automation_engine.register_action_handler('backup_database', _auto_backup_database)
print("✅ Automation action handlers registered")
def _emit_track_downloaded(context):
"""Emit track_downloaded event for automation engine. Safe to call anywhere."""
try:
if not automation_engine:
return
ti = context.get('track_info') or context.get('search_result') or {}
artist_name = ''
artists = ti.get('artists', [])
if artists:
a = artists[0]
artist_name = a.get('name', str(a)) if isinstance(a, dict) else str(a)
automation_engine.emit('track_downloaded', {
'artist': artist_name,
'title': ti.get('name', ti.get('title', '')),
'album': ti.get('album', ''),
'quality': context.get('_audio_quality', 'Unknown'),
})
except Exception:
pass
# --- Register Public REST API Blueprint (v1) ---
try:
from api import create_api_blueprint, limiter
@ -1449,6 +1711,14 @@ def signal_handler(signum, frame):
IS_SHUTTING_DOWN = True
cleanup_monitor()
# Stop automation engine
try:
if automation_engine:
print("🛑 Stopping automation engine...")
automation_engine.stop()
except Exception as e:
print(f"⚠️ Error stopping automation engine: {e}")
# Shutdown executor to prevent new tasks
try:
print("🛑 Shutting down missing_download_executor...")
@ -3095,6 +3365,323 @@ def handle_log_level():
logger.error(f"Error getting log level: {e}")
return jsonify({"success": False, "error": str(e)}), 500
# ===========================
# AUTOMATIONS API
# ===========================
@app.route('/api/automations', methods=['GET'])
def list_automations():
"""List all automations for the current profile."""
try:
profile_id = session.get('profile_id', 1)
db = get_database()
automations = db.get_automations(profile_id)
# Parse JSON config fields for frontend
for auto in automations:
for field in ('trigger_config', 'action_config', 'notify_config', 'last_result'):
try:
auto[field] = json.loads(auto[field]) if isinstance(auto[field], str) else auto[field]
except (json.JSONDecodeError, TypeError):
if field in ('trigger_config', 'action_config', 'notify_config'):
auto[field] = {}
else:
auto[field] = None
return jsonify(automations)
except Exception as e:
logger.error(f"Error listing automations: {e}")
return jsonify({"error": str(e)}), 500
@app.route('/api/automations', methods=['POST'])
def create_automation():
"""Create a new automation."""
try:
data = request.get_json()
name = data.get('name', '').strip()
if not name:
return jsonify({"error": "Name is required"}), 400
trigger_type = data.get('trigger_type', 'schedule')
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 '{}'
profile_id = session.get('profile_id', 1)
db = get_database()
auto_id = db.create_automation(name, trigger_type, trigger_config, action_type, action_config, profile_id, notify_type, notify_config)
if auto_id is None:
return jsonify({"error": "Failed to create automation"}), 500
# Schedule it
if automation_engine:
automation_engine.schedule_automation(auto_id)
return jsonify({"success": True, "id": auto_id})
except Exception as e:
logger.error(f"Error creating automation: {e}")
return jsonify({"error": str(e)}), 500
@app.route('/api/automations/<int:automation_id>', methods=['GET'])
def get_automation(automation_id):
"""Get a single automation."""
try:
db = get_database()
auto = db.get_automation(automation_id)
if not auto:
return jsonify({"error": "Automation not found"}), 404
for field in ('trigger_config', 'action_config', 'notify_config', 'last_result'):
try:
auto[field] = json.loads(auto[field]) if isinstance(auto[field], str) else auto[field]
except (json.JSONDecodeError, TypeError):
if field in ('trigger_config', 'action_config', 'notify_config'):
auto[field] = {}
else:
auto[field] = None
return jsonify(auto)
except Exception as e:
logger.error(f"Error getting automation: {e}")
return jsonify({"error": str(e)}), 500
@app.route('/api/automations/<int:automation_id>', methods=['PUT'])
def update_automation_endpoint(automation_id):
"""Update an automation."""
try:
data = request.get_json()
db = get_database()
update_fields = {}
if 'name' in data:
update_fields['name'] = data['name'].strip()
if 'trigger_type' in data:
update_fields['trigger_type'] = data['trigger_type']
if 'trigger_config' in data:
update_fields['trigger_config'] = json.dumps(data['trigger_config'])
if 'action_type' in data:
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:
update_fields['notify_type'] = data['notify_type'] or None
if 'notify_config' in data:
update_fields['notify_config'] = json.dumps(data['notify_config'])
if not update_fields:
return jsonify({"error": "No fields to update"}), 400
success = db.update_automation(automation_id, **update_fields)
if not success:
return jsonify({"error": "Automation not found"}), 404
# Reschedule
if automation_engine:
auto = db.get_automation(automation_id)
if auto and auto.get('enabled'):
automation_engine.schedule_automation(automation_id)
else:
automation_engine.cancel_automation(automation_id)
return jsonify({"success": True})
except Exception as e:
logger.error(f"Error updating automation: {e}")
return jsonify({"error": str(e)}), 500
@app.route('/api/automations/<int:automation_id>', methods=['DELETE'])
def delete_automation_endpoint(automation_id):
"""Delete an automation."""
try:
if automation_engine:
automation_engine.cancel_automation(automation_id)
db = get_database()
success = db.delete_automation(automation_id)
if not success:
return jsonify({"error": "Automation not found"}), 404
return jsonify({"success": True})
except Exception as e:
logger.error(f"Error deleting automation: {e}")
return jsonify({"error": str(e)}), 500
@app.route('/api/automations/<int:automation_id>/toggle', methods=['POST'])
def toggle_automation_endpoint(automation_id):
"""Toggle an automation's enabled state."""
try:
db = get_database()
success = db.toggle_automation(automation_id)
if not success:
return jsonify({"error": "Automation not found"}), 404
# Reschedule or cancel based on new state
if automation_engine:
auto = db.get_automation(automation_id)
if auto and auto.get('enabled'):
automation_engine.schedule_automation(automation_id)
else:
automation_engine.cancel_automation(automation_id)
return jsonify({"success": True})
except Exception as e:
logger.error(f"Error toggling automation: {e}")
return jsonify({"error": str(e)}), 500
@app.route('/api/automations/<int:automation_id>/run', methods=['POST'])
def run_automation_endpoint(automation_id):
"""Manually trigger an automation."""
try:
if not automation_engine:
return jsonify({"error": "Automation engine not available"}), 500
success = automation_engine.run_now(automation_id)
if not success:
return jsonify({"error": "Automation not found"}), 404
return jsonify({"success": True})
except Exception as e:
logger.error(f"Error running automation: {e}")
return jsonify({"error": str(e)}), 500
@app.route('/api/automations/blocks', methods=['GET'])
def get_automation_blocks():
"""Return available block types for the automation builder sidebar."""
return jsonify({
"triggers": [
{"type": "schedule", "label": "Schedule", "icon": "clock", "description": "Run on a timer interval", "available": True,
"config_fields": [
{"key": "interval", "type": "number", "label": "Every", "default": 6, "min": 1},
{"key": "unit", "type": "select", "label": "Unit",
"options": [{"value": "minutes", "label": "Minutes"}, {"value": "hours", "label": "Hours"}, {"value": "days", "label": "Days"}],
"default": "hours"}
]},
{"type": "daily_time", "label": "Daily Time", "icon": "clock", "description": "Run every day at a specific time", "available": True,
"config_fields": [
{"key": "time", "type": "time", "label": "At", "default": "03:00"}
]},
{"type": "weekly_time", "label": "Weekly Schedule", "icon": "calendar", "description": "Run on specific days of the week at a set time", "available": True,
"config_fields": [
{"key": "time", "type": "time", "label": "At", "default": "03:00"},
{"key": "days", "type": "multi_select", "label": "Days",
"options": [{"value": "mon", "label": "Mon"}, {"value": "tue", "label": "Tue"}, {"value": "wed", "label": "Wed"},
{"value": "thu", "label": "Thu"}, {"value": "fri", "label": "Fri"}, {"value": "sat", "label": "Sat"}, {"value": "sun", "label": "Sun"}]}
]},
{"type": "app_started", "label": "App Started", "icon": "power", "description": "When SoulSync starts up", "available": True},
{"type": "track_downloaded", "label": "Track Downloaded", "icon": "download", "description": "When a track finishes downloading", "available": True,
"has_conditions": True,
"condition_fields": ["artist", "title", "album", "quality"],
"variables": ["artist", "title", "album", "quality"]},
{"type": "batch_complete", "label": "Batch Complete", "icon": "check-circle", "description": "When an album/playlist download finishes", "available": True,
"has_conditions": True,
"condition_fields": ["playlist_name"],
"variables": ["playlist_name", "total_tracks", "completed_tracks", "failed_tracks"]},
{"type": "watchlist_new_release", "label": "New Release Found", "icon": "bell", "description": "When watchlist detects new music", "available": True,
"has_conditions": True,
"condition_fields": ["artist"],
"variables": ["artist", "new_tracks", "added_to_wishlist"]},
{"type": "playlist_synced", "label": "Playlist Synced", "icon": "refresh", "description": "When a playlist sync completes", "available": True,
"has_conditions": True,
"condition_fields": ["playlist_name"],
"variables": ["playlist_name", "total_tracks", "matched_tracks", "synced_tracks", "failed_tracks"]},
{"type": "playlist_changed", "label": "Playlist Changed", "icon": "edit", "description": "When a mirrored playlist detects track changes from source", "available": True,
"has_conditions": True,
"condition_fields": ["playlist_name"],
"variables": ["playlist_name", "old_count", "new_count", "added", "removed"]},
# Phase 3 triggers
{"type": "wishlist_processing_completed", "label": "Wishlist Processed", "icon": "check-circle",
"description": "When auto-wishlist processing finishes", "available": True,
"variables": ["tracks_processed", "tracks_found", "tracks_failed"]},
{"type": "watchlist_scan_completed", "label": "Watchlist Scan Done", "icon": "check-circle",
"description": "When watchlist scan finishes", "available": True,
"variables": ["artists_scanned", "new_tracks_found", "tracks_added"]},
{"type": "database_update_completed", "label": "Database Updated", "icon": "database",
"description": "When library database refresh finishes", "available": True,
"variables": ["total_artists", "total_albums", "total_tracks"]},
{"type": "download_failed", "label": "Download Failed", "icon": "x-circle",
"description": "When a track permanently fails to download", "available": True,
"has_conditions": True, "condition_fields": ["artist", "title", "reason"],
"variables": ["artist", "title", "reason"]},
{"type": "download_quarantined", "label": "File Quarantined", "icon": "alert-triangle",
"description": "When AcoustID verification fails", "available": True,
"has_conditions": True, "condition_fields": ["artist", "title"],
"variables": ["artist", "title", "reason"]},
{"type": "wishlist_item_added", "label": "Wishlist Item Added", "icon": "plus-circle",
"description": "When a track is added to wishlist", "available": True,
"has_conditions": True, "condition_fields": ["artist", "title"],
"variables": ["artist", "title", "reason"]},
{"type": "watchlist_artist_added", "label": "Artist Watched", "icon": "user-plus",
"description": "When an artist is added to watchlist", "available": True,
"has_conditions": True, "condition_fields": ["artist"],
"variables": ["artist", "artist_id"]},
{"type": "watchlist_artist_removed", "label": "Artist Unwatched", "icon": "user-minus",
"description": "When an artist is removed from watchlist", "available": True,
"has_conditions": True, "condition_fields": ["artist"],
"variables": ["artist", "artist_id"]},
{"type": "import_completed", "label": "Import Complete", "icon": "upload",
"description": "When album/track import finishes", "available": True,
"has_conditions": True, "condition_fields": ["artist", "album_name"],
"variables": ["track_count", "album_name", "artist"]},
{"type": "mirrored_playlist_created", "label": "Playlist Mirrored", "icon": "copy",
"description": "When a new playlist is mirrored", "available": True,
"has_conditions": True, "condition_fields": ["playlist_name", "source"],
"variables": ["playlist_name", "source", "track_count"]},
{"type": "quality_scan_completed", "label": "Quality Scan Done", "icon": "bar-chart",
"description": "When quality scan finishes", "available": True,
"variables": ["quality_met", "low_quality", "total_scanned"]},
{"type": "duplicate_scan_completed", "label": "Duplicate Scan Done", "icon": "layers",
"description": "When duplicate cleaner finishes", "available": True,
"variables": ["files_scanned", "duplicates_found", "space_freed"]},
],
"actions": [
{"type": "process_wishlist", "label": "Process Wishlist", "icon": "list", "description": "Retry failed downloads from wishlist", "available": True,
"config_fields": [{"key": "category", "type": "select", "label": "Category", "options": [{"value": "all", "label": "All"}, {"value": "albums", "label": "Albums"}, {"value": "singles", "label": "Singles"}], "default": "all"}]},
{"type": "scan_watchlist", "label": "Scan Watchlist", "icon": "eye", "description": "Check watched artists for new releases", "available": True},
{"type": "scan_library", "label": "Scan Library", "icon": "refresh", "description": "Trigger media server library scan", "available": True},
{"type": "refresh_mirrored", "label": "Refresh Mirrored Playlist", "icon": "copy", "description": "Re-fetch playlist from source and update mirror", "available": True,
"config_fields": [
{"key": "playlist_id", "type": "mirrored_playlist_select", "label": "Playlist"},
{"key": "all", "type": "checkbox", "label": "Refresh all mirrored playlists", "default": False}
]},
{"type": "sync_playlist", "label": "Sync Playlist", "icon": "sync", "description": "Sync mirrored playlist to media server", "available": True,
"config_fields": [
{"key": "playlist_id", "type": "mirrored_playlist_select", "label": "Playlist"}
]},
{"type": "notify_only", "label": "Notify Only", "icon": "bell", "description": "No action — just send notification", "available": True},
# Phase 3 actions
{"type": "start_database_update", "label": "Update Database", "icon": "database",
"description": "Trigger library database refresh", "available": True,
"config_fields": [
{"key": "full_refresh", "type": "checkbox", "label": "Full refresh (slower)", "default": False}
]},
{"type": "run_duplicate_cleaner", "label": "Run Duplicate Cleaner", "icon": "layers",
"description": "Scan for and remove duplicate files", "available": True},
{"type": "clear_quarantine", "label": "Clear Quarantine", "icon": "trash",
"description": "Delete all quarantined files", "available": True},
{"type": "cleanup_wishlist", "label": "Clean Up Wishlist", "icon": "filter",
"description": "Remove duplicate/owned tracks from wishlist", "available": True},
{"type": "update_discovery_pool", "label": "Update Discovery", "icon": "compass",
"description": "Refresh discovery pool with new tracks", "available": True},
{"type": "start_quality_scan", "label": "Run Quality Scan", "icon": "bar-chart",
"description": "Scan for low-quality audio files", "available": True,
"config_fields": [
{"key": "scope", "type": "select", "label": "Scope",
"options": [{"value": "watchlist", "label": "Watchlist Artists"}, {"value": "library", "label": "Full Library"}],
"default": "watchlist"}
]},
{"type": "backup_database", "label": "Backup Database", "icon": "save",
"description": "Create timestamped database backup", "available": True},
],
"notifications": [
{"type": "discord_webhook", "label": "Discord Webhook", "icon": "message", "description": "Send a Discord notification", "available": True,
"variables": ["time", "name", "run_count", "status"]},
]
})
@app.route('/api/mirrored-playlists/list', methods=['GET'])
def get_mirrored_playlists_list():
"""Return simple list of mirrored playlists for automation config dropdowns."""
try:
database = get_database()
profile_id = get_current_profile_id()
playlists = database.get_mirrored_playlists(profile_id=profile_id)
return jsonify([{"id": p['id'], "name": p['name']} for p in playlists])
except Exception as e:
return jsonify([]), 200
@app.route('/api/test-connection', methods=['POST'])
def test_connection_endpoint():
data = request.get_json()
@ -10712,6 +11299,23 @@ def _move_to_quarantine(file_path: str, context: dict, reason: str) -> str:
logger.warning(f"Failed to write quarantine metadata: {e}")
logger.warning(f"🚫 File quarantined: {quarantine_path} - Reason: {reason}")
try:
if automation_engine:
ti = context.get('track_info', {})
artists = ti.get('artists', [])
artist_name = ''
if artists:
a = artists[0]
artist_name = a.get('name', str(a)) if isinstance(a, dict) else str(a)
automation_engine.emit('download_quarantined', {
'artist': artist_name,
'title': ti.get('name', ''),
'reason': reason or 'Unknown',
})
except Exception:
pass
return str(quarantine_path)
@ -11014,6 +11618,7 @@ def _post_process_matched_download(context_key, context, file_path):
# Set flag in context so verification function knows this was fully handled
context['_simple_download_completed'] = True
context['_final_path'] = str(destination)
_emit_track_downloaded(context)
return
# --- END SIMPLE DOWNLOAD HANDLING ---
@ -11083,6 +11688,8 @@ def _post_process_matched_download(context_key, context, file_path):
except Exception as wishlist_error:
print(f"⚠️ [Playlist Folder] Error checking wishlist removal: {wishlist_error}")
_emit_track_downloaded(context)
# NOTE: Don't call callbacks here - let verification function handle completion
# The verification function will check file exists and then call callbacks
return # Skip normal album/artist folder structure processing
@ -11380,6 +11987,8 @@ def _post_process_matched_download(context_key, context, file_path):
print(f"✅ Post-processing complete for: {context.get('_final_processed_path', final_path)}")
_emit_track_downloaded(context)
# RETAG DATA CAPTURE: Record completed album/single downloads for retag tool
try:
if not playlist_folder_mode:
@ -12904,7 +13513,17 @@ def _db_update_finished_callback(total_artists, total_albums, total_tracks, succ
if removed_artists > 0 or removed_albums > 0:
summary += f" | {removed_artists} artists, {removed_albums} albums removed"
add_activity_item("", "Database Update Complete", summary, "Now")
try:
if automation_engine:
automation_engine.emit('database_update_completed', {
'total_artists': str(total_artists),
'total_albums': str(total_albums),
'total_tracks': str(total_tracks),
})
except Exception:
pass
# WISHLIST CLEANUP: Automatically clean up wishlist after database update
try:
print("📋 [DB Update] Database update completed, starting automatic wishlist cleanup...")
@ -13901,6 +14520,34 @@ def stop_database_update():
else:
return jsonify({"success": False, "error": "No update is currently running."}), 404
@app.route('/api/database/backup', methods=['POST'])
def backup_database_endpoint():
"""Create a rolling backup of the database (max 3)."""
try:
import sqlite3, glob as _glob
db_path = os.environ.get('DATABASE_PATH', 'database/music_library.db')
if not os.path.exists(db_path):
return jsonify({"success": False, "error": "Database file not found"}), 404
max_backups = 3
timestamp = datetime.now().strftime('%Y%m%d_%H%M%S')
backup_path = f"{db_path}.backup_{timestamp}"
src = sqlite3.connect(db_path)
dst = sqlite3.connect(backup_path)
src.backup(dst)
dst.close()
src.close()
size_mb = round(os.path.getsize(backup_path) / (1024 * 1024), 1)
# Rolling cleanup
existing = sorted(_glob.glob(f"{db_path}.backup_*"), key=os.path.getmtime)
while len(existing) > max_backups:
try:
os.remove(existing.pop(0))
except Exception:
pass
return jsonify({"success": True, "backup_path": backup_path, "size_mb": size_mb})
except Exception as e:
return jsonify({"success": False, "error": str(e)}), 500
# ===============================
# == QUALITY SCANNER ==
# ===============================
@ -14232,6 +14879,16 @@ def _run_quality_scanner(scope='watchlist', profile_id=1):
add_activity_item("🔍", "Quality Scan Complete",
f"{quality_scanner_state['matched']} tracks added to wishlist", "Now")
try:
if automation_engine:
automation_engine.emit('quality_scan_completed', {
'quality_met': str(quality_scanner_state.get('quality_met', 0)),
'low_quality': str(quality_scanner_state.get('low_quality', 0)),
'total_scanned': str(quality_scanner_state.get('processed', 0)),
})
except Exception:
pass
except Exception as e:
print(f"❌ [Quality Scanner] Critical error: {e}")
import traceback
@ -14416,6 +15073,16 @@ def _run_duplicate_cleaner():
add_activity_item("🧹", "Duplicate Cleaner Complete",
f"{deleted_count} files removed, {space_mb:.1f} MB freed", "Now")
try:
if automation_engine:
automation_engine.emit('duplicate_scan_completed', {
'files_scanned': str(files_scanned),
'duplicates_found': str(duplicates_found),
'space_freed': f"{space_mb:.1f} MB",
})
except Exception:
pass
except Exception as e:
print(f"❌ [Duplicate Cleaner] Critical error: {e}")
import traceback
@ -15083,6 +15750,15 @@ def _process_failed_tracks_to_wishlist_exact(batch_id):
if success:
wishlist_added_count += 1
print(f"✅ [Wishlist Processing] Added {track_name} to wishlist")
try:
if automation_engine:
automation_engine.emit('wishlist_item_added', {
'artist': artist_name,
'title': track_name,
'reason': track.get('failure_reason', ''),
})
except Exception:
pass
else:
print(f"⚠️ [Wishlist Processing] Failed to add {track_name} to wishlist")
@ -15206,6 +15882,16 @@ def _process_failed_tracks_to_wishlist_exact_with_auto_completion(batch_id):
wishlist_auto_processing_timestamp = 0
# Don't clear wishlist_next_run_time here - let schedule function handle it atomically
try:
if automation_engine:
automation_engine.emit('wishlist_processing_completed', {
'tracks_processed': str(total_failed),
'tracks_found': str(tracks_added),
'tracks_failed': str(total_failed - tracks_added),
})
except Exception:
pass
# Schedule next automatic processing cycle (handles timer atomically with retry logic)
print("⏰ [Auto-Wishlist] Scheduling next automatic cycle in 30 minutes")
schedule_next_wishlist_processing() # Has built-in error handling and retry
@ -15282,7 +15968,17 @@ def _on_download_completed(batch_id, task_id, success=True):
else:
print(f"❌ [Batch Manager] Added failed track to batch tracking: {track_info['track_name']}")
add_activity_item("", "Download Failed", f"'{track_info['track_name']}'", "Now")
try:
if automation_engine:
automation_engine.emit('download_failed', {
'artist': track_info.get('artist_name', ''),
'title': track_info.get('track_name', ''),
'reason': track_info.get('failure_reason', 'Unknown'),
})
except Exception:
pass
# WISHLIST REMOVAL: Handle successful downloads for wishlist removal
if success and task_id in download_tasks:
try:
@ -15393,8 +16089,21 @@ def _on_download_completed(batch_id, task_id, success=True):
# Add activity for batch completion
playlist_name = batch.get('playlist_name', 'Unknown Playlist')
successful_downloads = finished_count - len(batch.get('permanently_failed_tracks', []))
failed_count = len(batch.get('permanently_failed_tracks', []))
successful_downloads = finished_count - failed_count
add_activity_item("", "Download Batch Complete", f"'{playlist_name}' - {successful_downloads} tracks downloaded", "Now")
# Emit batch_complete event for automation engine
try:
if automation_engine:
automation_engine.emit('batch_complete', {
'playlist_name': playlist_name,
'total_tracks': str(len(queue)),
'completed_tracks': str(successful_downloads),
'failed_tracks': str(failed_count),
})
except Exception:
pass
# Update YouTube playlist phase to 'download_complete' if this is a YouTube playlist
playlist_id = batch.get('playlist_id')
@ -21087,7 +21796,20 @@ def _run_sync_task(playlist_id, playlist_name, tracks_json):
"result": result.__dict__ # Keep result for backward compatibility
}
print(f"🏁 Sync finished for {playlist_id} - state updated")
# Emit playlist_synced event for automation engine
try:
if automation_engine:
automation_engine.emit('playlist_synced', {
'playlist_name': playlist_name,
'total_tracks': str(getattr(result, 'total_tracks', 0)),
'matched_tracks': str(getattr(result, 'matched_tracks', 0)),
'synced_tracks': str(getattr(result, 'synced_tracks', 0)),
'failed_tracks': str(getattr(result, 'failed_tracks', 0)),
})
except Exception:
pass
# Save sync status to storage/sync_status.json (same as GUI)
# Handle snapshot_id safely - may not exist in all playlist objects
snapshot_id = getattr(playlist, 'snapshot_id', None)
@ -22065,6 +22787,14 @@ def add_to_watchlist():
socketio.emit('watchlist:count', _build_watchlist_count_payload(profile_id=pid), room=f'profile:{pid}')
except Exception:
pass
try:
if automation_engine:
automation_engine.emit('watchlist_artist_added', {
'artist': artist_name,
'artist_id': str(artist_id),
})
except Exception:
pass
return jsonify({"success": True, "message": f"Added {artist_name} to watchlist"})
else:
return jsonify({"success": False, "error": "Failed to add artist to watchlist"}), 500
@ -22093,10 +22823,18 @@ def remove_from_watchlist():
socketio.emit('watchlist:count', _build_watchlist_count_payload(profile_id=pid), room=f'profile:{pid}')
except Exception:
pass
try:
if automation_engine:
automation_engine.emit('watchlist_artist_removed', {
'artist': data.get('artist_name', str(artist_id)),
'artist_id': str(artist_id),
})
except Exception:
pass
return jsonify({"success": True, "message": "Removed artist from watchlist"})
else:
return jsonify({"success": False, "error": "Failed to remove artist from watchlist"}), 500
except Exception as e:
print(f"Error removing from watchlist: {e}")
return jsonify({"success": False, "error": str(e)}), 500
@ -23510,6 +24248,18 @@ def _process_watchlist_scan_automatically():
print(f"✅ Scanned {artist.artist_name}: {artist_new_tracks} new tracks found, {artist_added_tracks} added to wishlist")
# Emit watchlist_new_release event if new tracks were found
if artist_new_tracks > 0:
try:
if automation_engine:
automation_engine.emit('watchlist_new_release', {
'artist': artist.artist_name,
'new_tracks': str(artist_new_tracks),
'added_to_wishlist': str(artist_added_tracks),
})
except Exception:
pass
# Delay between artists
if i < len(watchlist_artists) - 1:
watchlist_scan_state['current_phase'] = 'rate_limiting'
@ -23621,6 +24371,16 @@ def _process_watchlist_scan_automatically():
if total_added_to_wishlist > 0:
add_activity_item("👁️", "Watchlist Scan Complete", f"{total_added_to_wishlist} new tracks added to wishlist", "Now")
try:
if automation_engine:
automation_engine.emit('watchlist_scan_completed', {
'artists_scanned': str(len(scan_results)),
'new_tracks_found': str(total_new_tracks),
'tracks_added': str(total_added_to_wishlist),
})
except Exception:
pass
except Exception as e:
print(f"❌ Error in automatic watchlist scan: {e}")
import traceback
@ -27894,6 +28654,16 @@ def mirror_playlist_endpoint():
if playlist_id is None:
return jsonify({"error": "Failed to mirror playlist"}), 500
try:
if automation_engine:
automation_engine.emit('mirrored_playlist_created', {
'playlist_name': name,
'source': source,
'track_count': str(len(tracks)),
})
except Exception:
pass
return jsonify({"success": True, "playlist_id": playlist_id})
except Exception as e:
logger.error(f"Error mirroring playlist: {e}")
@ -30080,6 +30850,16 @@ def import_album_process():
add_activity_item("📥", "Album Imported", f"{album_name} by {artist_name} ({processed}/{len(matches)} tracks)", "Now")
try:
if automation_engine:
automation_engine.emit('import_completed', {
'track_count': str(processed),
'album_name': album_name or '',
'artist': artist_name or '',
})
except Exception:
pass
# Rebuild suggestions cache since staging contents changed
if processed > 0:
refresh_import_suggestions_cache()
@ -30325,6 +31105,16 @@ def import_singles_process():
add_activity_item("📥", "Singles Imported", f"{processed}/{len(files)} tracks processed", "Now")
try:
if automation_engine:
automation_engine.emit('import_completed', {
'track_count': str(processed),
'album_name': '',
'artist': 'Various',
})
except Exception:
pass
# Rebuild suggestions cache since staging contents changed
if processed > 0:
refresh_import_suggestions_cache()
@ -30891,6 +31681,17 @@ if __name__ == '__main__':
import time
app.start_time = time.time()
# Register action handlers and start automation engine
_register_automation_handlers()
if automation_engine:
print("🔧 Starting automation engine...")
automation_engine.start()
print("✅ Automation engine started")
try:
automation_engine.emit('app_started', {})
except Exception:
pass
# Add startup activity
add_activity_item("🚀", "System Started", "SoulSync Web UI Server initialized", "Now")

View file

@ -111,6 +111,10 @@
<span class="nav-icon">🎵</span>
<span class="nav-text">Artists</span>
</button>
<button class="nav-button" data-page="automations">
<span class="nav-icon"></span>
<span class="nav-text">Automations</span>
</button>
<button class="nav-button" data-page="library">
<span class="nav-icon">📚</span>
<span class="nav-text">Library</span>
@ -498,8 +502,12 @@
<div class="tool-card" id="db-updater-card">
<div class="tool-card-header">
<h4 class="tool-card-title">Database Updater</h4>
<button class="tool-help-button" data-tool="db-updater"
title="Learn more about this tool">?</button>
<div style="display:flex;gap:4px;">
<button class="tool-help-button" id="db-backup-button"
title="Create a rolling backup of the database"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z"/><polyline points="17 21 17 13 7 13 7 21"/><polyline points="7 3 7 8 15 8"/></svg></button>
<button class="tool-help-button" data-tool="db-updater"
title="Learn more about this tool">?</button>
</div>
</div>
<p class="tool-card-info">Last Full Refresh: <span id="db-last-refresh">Never</span></p>
<div class="tool-card-stats">
@ -1947,6 +1955,49 @@
</div>
</div>
<!-- Automations Page -->
<div class="page" id="automations-page">
<!-- List View -->
<div class="automations-list-view" id="automations-list-view">
<div class="automations-container">
<div class="dashboard-header">
<div class="header-text">
<h2 class="header-title">Automations</h2>
<p class="header-subtitle">Configure scheduled tasks and automated workflows</p>
</div>
<div class="header-spacer"></div>
<div class="header-actions">
<button class="auto-new-btn" onclick="showAutomationBuilder()">+ New Automation</button>
</div>
</div>
<div class="automations-stats" id="automations-stats"></div>
<div class="automations-list" id="automations-list"></div>
<div class="automations-empty" id="automations-empty" style="display:none;">
<div class="automations-empty-icon">&#9889;</div>
<div class="automations-empty-title">No automations yet</div>
<div class="automations-empty-text">Create your first automation to schedule tasks and trigger actions automatically.</div>
<button class="auto-new-btn" onclick="showAutomationBuilder()">+ New Automation</button>
</div>
</div>
</div>
<!-- Builder View -->
<div class="automations-builder-view" id="automations-builder-view" style="display:none;">
<div class="builder-header">
<button class="builder-back-btn" onclick="hideAutomationBuilder()" title="Back to list">&#8592;</button>
<input type="text" id="builder-name" class="builder-name-input" placeholder="Automation Name">
<div class="builder-header-actions">
<button class="btn-cancel" onclick="hideAutomationBuilder()">Cancel</button>
<button class="btn-save" onclick="saveAutomation()">Save</button>
</div>
</div>
<div class="builder-content">
<div class="builder-sidebar" id="builder-sidebar"></div>
<div class="builder-canvas" id="builder-canvas"></div>
</div>
</div>
</div>
<!-- Library Page -->
<div class="page" id="library-page">
<div class="library-container">

View file

@ -1522,6 +1522,9 @@ async function loadPageData(pageId) {
// Load comparisons
loadHydrabaseComparisons();
break;
case 'automations':
await loadAutomations();
break;
}
} catch (error) {
console.error(`Error loading ${pageId} data:`, error);
@ -16264,6 +16267,11 @@ async function loadDashboardData() {
updateButton.addEventListener('click', handleDbUpdateButtonClick);
}
const backupButton = document.getElementById('db-backup-button');
if (backupButton) {
backupButton.addEventListener('click', handleDbBackupButtonClick);
}
// Attach event listeners for the metadata updater tool
const metadataButton = document.getElementById('metadata-update-button');
if (metadataButton) {
@ -18372,6 +18380,26 @@ async function handleDbUpdateButtonClick() {
}
}
async function handleDbBackupButtonClick() {
const button = document.getElementById('db-backup-button');
const origText = button.textContent;
button.disabled = true;
button.textContent = 'Backing up...';
try {
const res = await fetch('/api/database/backup', { method: 'POST' });
const data = await res.json();
if (data.success) {
showToast(`Database backed up (${data.size_mb} MB)`, 'success');
} else {
showToast(`Backup failed: ${data.error}`, 'error');
}
} catch (e) {
showToast('Backup request failed', 'error');
}
button.disabled = false;
button.textContent = origText;
}
async function handleWishlistButtonClick() {
try {
const playlistId = 'wishlist';
@ -42094,6 +42122,719 @@ async function discoverMirroredPlaylist(playlistId) {
}
}
// ===============================
// AUTOMATIONS — Visual Builder
// ===============================
let _autoBlocks = null; // cached block definitions from /api/automations/blocks
let _autoBuilder = { editId: null, when: null, do: null, notify: null };
let _autoMirroredPlaylists = null; // cached mirrored playlist list
const _autoIcons = {
schedule: '\u23F1\uFE0F', daily_time: '\u{1F570}\uFE0F', weekly_time: '\uD83D\uDCC5', app_started: '\uD83D\uDE80', track_downloaded: '\u2B07\uFE0F', batch_complete: '\u2705',
watchlist_new_release: '\uD83D\uDD14', playlist_synced: '\uD83D\uDD04',
playlist_changed: '\u270F\uFE0F',
process_wishlist: '\uD83D\uDCCB', scan_watchlist: '\uD83D\uDC41\uFE0F',
scan_library: '\uD83D\uDD04', refresh_mirrored: '\uD83D\uDCC2', sync_playlist: '\uD83D\uDD01',
notify_only: '\uD83D\uDD14', discord_webhook: '\uD83D\uDCAC',
// Phase 3
wishlist_processing_completed: '\u2705', watchlist_scan_completed: '\u2705',
database_update_completed: '\uD83D\uDDC4\uFE0F', download_failed: '\u274C',
download_quarantined: '\u26A0\uFE0F', wishlist_item_added: '\u2795',
watchlist_artist_added: '\uD83D\uDC64', watchlist_artist_removed: '\uD83D\uDC64',
import_completed: '\uD83D\uDCE5', mirrored_playlist_created: '\uD83D\uDCC2',
quality_scan_completed: '\uD83D\uDCCA', duplicate_scan_completed: '\uD83D\uDDC2\uFE0F',
start_database_update: '\uD83D\uDDC4\uFE0F', run_duplicate_cleaner: '\uD83D\uDDC2\uFE0F',
clear_quarantine: '\uD83D\uDDD1\uFE0F', cleanup_wishlist: '\uD83E\uDDF9',
update_discovery_pool: '\uD83E\uDDED', start_quality_scan: '\uD83D\uDCCA',
backup_database: '\uD83D\uDCBE',
};
// --- Load & Render List ---
async function loadAutomations() {
const list = document.getElementById('automations-list');
const empty = document.getElementById('automations-empty');
const statsBar = document.getElementById('automations-stats');
if (!list || !empty) return;
try {
const res = await fetch('/api/automations');
const automations = await res.json();
if (automations.error) throw new Error(automations.error);
if (!automations.length) {
list.innerHTML = ''; empty.style.display = '';
if (statsBar) statsBar.innerHTML = '';
return;
}
empty.style.display = 'none';
list.innerHTML = '';
automations.forEach(a => list.appendChild(renderAutomationCard(a)));
// Stats summary bar
if (statsBar) {
const total = automations.length;
const active = automations.filter(a => a.enabled).length;
const scheduled = automations.filter(a => a.enabled && a.trigger_type === 'schedule').length;
const eventBased = automations.filter(a => a.enabled && a.trigger_type !== 'schedule').length;
statsBar.innerHTML = `
<span class="auto-stat"><strong>${total}</strong> Total</span>
<span class="auto-stat"><strong>${active}</strong> Active</span>
<span class="auto-stat"><strong>${scheduled}</strong> Scheduled</span>
<span class="auto-stat"><strong>${eventBased}</strong> Event-Based</span>
`;
}
} catch (err) {
list.innerHTML = ''; empty.style.display = '';
if (statsBar) statsBar.innerHTML = '';
}
}
function renderAutomationCard(a) {
const card = document.createElement('div');
card.className = 'automation-card' + (a.enabled ? '' : ' disabled');
card.dataset.id = a.id;
const tIcon = _autoIcons[a.trigger_type] || '\u2699\uFE0F';
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 actionDelay = a.action_config && a.action_config.delay ? a.action_config.delay : 0;
const metaParts = [];
if (a.last_run) metaParts.push('Last: ' + _autoTimeAgo(a.last_run));
const _timerTriggers = ['schedule', 'daily_time', 'weekly_time'];
if (a.next_run && a.enabled && _timerTriggers.includes(a.trigger_type)) metaParts.push('Next: ' + _autoTimeUntil(a.next_run));
if (!_timerTriggers.includes(a.trigger_type) && a.enabled) metaParts.push('Listening');
if (a.run_count) metaParts.push('Runs: ' + a.run_count);
if (a.last_error) metaParts.push('Error: ' + _esc(a.last_error));
card.innerHTML = `
<div class="automation-status ${a.enabled ? 'enabled' : 'disabled'}"></div>
<div class="automation-info">
<div class="automation-name">${_esc(a.name)}</div>
<div class="automation-flow">
<span class="flow-trigger">${_esc(tl)}</span>
<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>` : ''}
</div>
<div class="automation-meta">${metaParts.join(' &middot; ')}</div>
</div>
<div class="automation-actions">
<button class="automation-run-btn" title="Run now" onclick="event.stopPropagation(); runAutomation(${a.id})">&#9654;</button>
<label class="automation-toggle" onclick="event.stopPropagation();">
<input type="checkbox" ${a.enabled ? 'checked' : ''} onchange="toggleAutomation(${a.id})">
<span class="toggle-slider"></span>
</label>
<button class="automation-edit-btn" title="Edit" onclick="event.stopPropagation(); showAutomationBuilder(${a.id})">&#9881;</button>
<button class="automation-delete-btn" title="Delete" onclick="event.stopPropagation(); deleteAutomation(${a.id}, '${_escAttr(a.name)}')">&#128465;</button>
</div>
`;
return card;
}
function _autoFormatTrigger(type, config) {
if (type === 'schedule' && config) return 'Every ' + (config.interval || 1) + ' ' + (config.unit || 'hours');
if (type === 'daily_time' && config) return 'Daily at ' + (config.time || '00:00');
if (type === 'weekly_time' && config) {
const days = (config.days || []).map(d => d.charAt(0).toUpperCase() + d.slice(1)).join(', ');
return (days || 'Every day') + ' at ' + (config.time || '00:00');
}
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',
wishlist_processing_completed: 'Wishlist Processed', watchlist_scan_completed: 'Watchlist Scan Done',
database_update_completed: 'Database Updated', download_failed: 'Download Failed',
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' };
let label = labels[type] || type || 'Unknown';
if (config && config.conditions && config.conditions.length) {
const first = config.conditions[0];
label += ' (' + first.field + ' ' + first.operator + ' "' + first.value + '"' +
(config.conditions.length > 1 ? ' +' + (config.conditions.length - 1) + ' more' : '') + ')';
}
return label;
}
function _autoFormatAction(type) {
const labels = { process_wishlist: 'Process Wishlist', scan_watchlist: 'Scan Watchlist',
scan_library: 'Scan Library', refresh_mirrored: 'Refresh Mirrored',
sync_playlist: 'Sync Playlist', notify_only: 'Notify Only',
start_database_update: 'Update Database', run_duplicate_cleaner: 'Run Duplicate Cleaner',
clear_quarantine: 'Clear Quarantine', cleanup_wishlist: 'Clean Up Wishlist',
update_discovery_pool: 'Update Discovery', start_quality_scan: 'Run Quality Scan',
backup_database: 'Backup Database' };
return labels[type] || type || 'Unknown';
}
function _autoFormatNotify(type) {
if (type === 'discord_webhook') return 'Discord';
return type || '';
}
function _autoTimeAgo(ts) {
if (!ts) return 'Never';
const d = (Date.now() - new Date(ts + 'Z').getTime()) / 1000;
if (d < 60) return 'just now'; if (d < 3600) return Math.floor(d/60) + 'm ago';
if (d < 86400) return Math.floor(d/3600) + 'h ago'; return Math.floor(d/86400) + 'd ago';
}
function _autoTimeUntil(ts) {
if (!ts) return '';
const d = (new Date(ts + 'Z').getTime() - Date.now()) / 1000;
if (d <= 0) return 'soon'; if (d < 60) return 'in ' + Math.ceil(d) + 's';
if (d < 3600) return 'in ' + Math.ceil(d/60) + 'm'; if (d < 86400) return 'in ' + Math.round(d/3600) + 'h';
return 'in ' + Math.round(d/86400) + 'd';
}
// --- CRUD ---
async function deleteAutomation(id, name) {
if (!confirm('Delete automation "' + name + '"?')) return;
try {
const res = await fetch('/api/automations/' + id, { method: 'DELETE' });
const data = await res.json();
if (data.error) throw new Error(data.error);
showToast('Automation deleted', 'success');
await loadAutomations();
} catch (err) { showToast('Error: ' + err.message, 'error'); }
}
async function toggleAutomation(id) {
try {
const res = await fetch('/api/automations/' + id + '/toggle', { method: 'POST' });
const data = await res.json();
if (data.error) throw new Error(data.error);
await loadAutomations();
} catch (err) { showToast('Error: ' + err.message, 'error'); }
}
async function runAutomation(id) {
try {
const res = await fetch('/api/automations/' + id + '/run', { method: 'POST' });
const data = await res.json();
if (data.error) throw new Error(data.error);
showToast('Automation triggered', 'success');
setTimeout(() => loadAutomations(), 1500);
} catch (err) { showToast('Error: ' + err.message, 'error'); }
}
async function saveAutomation() {
const name = document.getElementById('builder-name').value.trim();
if (!name) { showToast('Name is required', 'error'); return; }
if (!_autoBuilder.when) { showToast('Add a trigger (WHEN)', 'error'); return; }
if (!_autoBuilder.do) { showToast('Add an action (DO)', 'error'); return; }
// Read configs from DOM
const triggerConfig = _readPlacedConfig('when');
const actionConfig = _readPlacedConfig('do');
const notifyConfig = _autoBuilder.notify ? _readPlacedConfig('notify') : {};
// Read optional delay from DO slot
const delayEl = document.getElementById('cfg-do-delay');
const delayVal = delayEl ? parseInt(delayEl.value) : 0;
if (delayVal > 0) actionConfig.delay = delayVal;
const body = {
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 : {},
};
try {
let res;
if (_autoBuilder.editId) {
res = await fetch('/api/automations/' + _autoBuilder.editId, { method: 'PUT', headers: {'Content-Type':'application/json'}, body: JSON.stringify(body) });
} else {
res = await fetch('/api/automations', { method: 'POST', headers: {'Content-Type':'application/json'}, body: JSON.stringify(body) });
}
const data = await res.json();
if (data.error) throw new Error(data.error);
showToast(_autoBuilder.editId ? 'Automation updated' : 'Automation created', 'success');
hideAutomationBuilder();
await loadAutomations();
} catch (err) { showToast('Error: ' + err.message, 'error'); }
}
// --- Builder View ---
async function showAutomationBuilder(editId) {
// Load block definitions (always refresh)
try {
const res = await fetch('/api/automations/blocks');
_autoBlocks = await res.json();
} catch (e) {
if (!_autoBlocks) { showToast('Failed to load blocks', 'error'); return; }
}
_autoMirroredPlaylists = null; // invalidate so it re-fetches
_autoBuilder = { editId: editId || null, when: null, do: null, notify: null };
// If editing, load automation data
if (editId) {
try {
const res = await fetch('/api/automations/' + editId);
const a = await res.json();
if (a.error) throw new Error(a.error);
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 || {} };
} catch (err) { showToast('Failed to load automation', 'error'); return; }
} else {
document.getElementById('builder-name').value = '';
}
_renderBuilderSidebar();
_renderBuilderCanvas();
document.getElementById('automations-list-view').style.display = 'none';
document.getElementById('automations-builder-view').style.display = '';
}
function hideAutomationBuilder() {
document.getElementById('automations-builder-view').style.display = 'none';
document.getElementById('automations-list-view').style.display = '';
_autoBuilder = { editId: null, when: null, do: null, notify: null };
}
// --- Sidebar ---
function _renderBuilderSidebar() {
const sidebar = document.getElementById('builder-sidebar');
if (!sidebar || !_autoBlocks) return;
let html = '';
const sections = [
{ key: 'triggers', title: 'Triggers', slot: 'when' },
{ key: 'actions', title: 'Actions', slot: 'do' },
{ key: 'notifications', title: 'Notifications', slot: 'notify' },
];
sections.forEach(sec => {
html += `<div class="sidebar-section"><div class="sidebar-section-title">${sec.title}</div>`;
(_autoBlocks[sec.key] || []).forEach(block => {
const icon = _autoIcons[block.type] || '\u2699\uFE0F';
const disabled = !block.available;
html += `<div class="block-item${disabled ? ' coming-soon' : ''}" ${!disabled ? `draggable="true" ondragstart="_autoDragStart(event,'${block.type}','${sec.slot}')" onclick="_autoClickBlock('${block.type}','${sec.slot}')"` : ''}>
<div class="block-item-icon">${icon}</div>
<div class="block-item-text">
<div class="block-item-label">${_esc(block.label)}</div>
<div class="block-item-desc">${_esc(block.description)}</div>
</div>
${disabled ? '<span class="coming-soon-badge">Soon</span>' : ''}
</div>`;
});
html += '</div>';
});
sidebar.innerHTML = html;
}
// --- Canvas ---
function _renderBuilderCanvas() {
const canvas = document.getElementById('builder-canvas');
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];
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}')">
<span class="flow-slot-label ${slot.labelClass}">${slot.label}</span>
${_renderPlacedBlock(slot.key, data)}
</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}')">
<span class="flow-slot-label ${slot.labelClass}">${slot.label}</span>
<div class="flow-slot-prompt">${slot.prompt}</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 => {
const allCb = document.getElementById('cfg-' + sk + '-all');
if (allCb) _autoTogglePlaylistSelect(sk);
});
}
function _renderPlacedBlock(slotKey, data) {
const blockDef = _findBlockDef(data.type);
const icon = _autoIcons[data.type] || '\u2699\uFE0F';
const label = blockDef ? blockDef.label : data.type;
const configHtml = _renderBlockConfigFields(slotKey, data.type, data.config || {});
// Add optional delay field for action blocks
let delayHtml = '';
if (slotKey === 'do') {
const delayVal = (data.config && data.config.delay) || '';
delayHtml = `<div class="placed-block-config"><div class="config-row">
<label>Delay (minutes)</label>
<input type="number" id="cfg-${slotKey}-delay" value="${delayVal}" min="0" placeholder="0" style="width:80px;" title="Wait before executing action">
</div></div>`;
}
return `<div class="placed-block" data-type="${_escAttr(data.type)}">
<div class="placed-block-header">
<span class="placed-block-icon">${icon}</span>
<span class="placed-block-label">${_esc(label)}</span>
<button class="placed-block-remove" onclick="_autoRemoveBlock('${slotKey}')">\u2715</button>
</div>
${configHtml ? '<div class="placed-block-config">' + configHtml + '</div>' : ''}
${delayHtml}
</div>`;
}
function _renderBlockConfigFields(slotKey, blockType, config) {
if (blockType === 'schedule') {
const interval = config.interval || 6;
const unit = config.unit || 'hours';
return `<div class="config-row">
<label>Every</label>
<input type="number" id="cfg-${slotKey}-interval" value="${interval}" min="1" style="width:70px;">
<select id="cfg-${slotKey}-unit">
<option value="minutes"${unit==='minutes'?' selected':''}>Minutes</option>
<option value="hours"${unit==='hours'?' selected':''}>Hours</option>
<option value="days"${unit==='days'?' selected':''}>Days</option>
</select>
</div>`;
}
if (blockType === 'daily_time') {
const timeVal = config.time || '03:00';
return `<div class="config-row">
<label>At</label>
<input type="time" id="cfg-${slotKey}-time" value="${timeVal}">
</div>`;
}
if (blockType === 'weekly_time') {
const timeVal = config.time || '03:00';
const selectedDays = config.days || [];
const allDays = [['mon','Mon'],['tue','Tue'],['wed','Wed'],['thu','Thu'],['fri','Fri'],['sat','Sat'],['sun','Sun']];
let dayHtml = '<div class="config-row"><label>Days</label><div class="day-picker" id="cfg-' + slotKey + '-days">';
allDays.forEach(([val, lbl]) => {
const active = selectedDays.includes(val) ? ' active' : '';
dayHtml += `<button type="button" class="day-btn${active}" data-day="${val}" onclick="this.classList.toggle('active')">${lbl}</button>`;
});
dayHtml += '</div></div>';
return `<div class="config-row">
<label>At</label>
<input type="time" id="cfg-${slotKey}-time" value="${timeVal}">
</div>${dayHtml}`;
}
// Event triggers with conditions
const blockDef = _findBlockDef(blockType);
if (blockDef && blockDef.has_conditions) {
return _renderConditionBuilder(slotKey, blockDef, config);
}
if (blockType === 'process_wishlist') {
const cat = config.category || 'all';
return `<div class="config-row">
<label>Category</label>
<select id="cfg-${slotKey}-category">
<option value="all"${cat==='all'?' selected':''}>All</option>
<option value="albums"${cat==='albums'?' selected':''}>Albums</option>
<option value="singles"${cat==='singles'?' selected':''}>Singles</option>
</select>
</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>';
}
if (blockType === 'refresh_mirrored') {
const allChecked = config.all ? ' checked' : '';
return `<div class="config-row">
<label>Playlist</label>
<select id="cfg-${slotKey}-playlist_id" class="mirrored-playlist-select" data-value="${_escAttr(config.playlist_id || '')}">
<option value="">Loading...</option>
</select>
</div>
<div class="config-row">
<label><input type="checkbox" id="cfg-${slotKey}-all"${allChecked} onchange="_autoTogglePlaylistSelect('${slotKey}')"> Refresh all mirrored playlists</label>
</div>`;
}
if (blockType === 'sync_playlist') {
return `<div class="config-row">
<label>Playlist</label>
<select id="cfg-${slotKey}-playlist_id" class="mirrored-playlist-select" data-value="${_escAttr(config.playlist_id || '')}">
<option value="">Loading...</option>
</select>
</div>`;
}
if (blockType === 'discord_webhook') {
const url = _escAttr(config.webhook_url || '');
// Merge base variables with trigger-specific variables
let allVars = ['time', 'name', 'run_count', 'status'];
const triggerDef = _autoBuilder.when ? _findBlockDef(_autoBuilder.when.type) : null;
if (triggerDef && triggerDef.variables) {
triggerDef.variables.forEach(v => { if (!allVars.includes(v)) allVars.push(v); });
}
let varHtml = '<div class="variable-tags">';
allVars.forEach(v => { varHtml += `<span class="variable-tag" onclick="_autoInsertVar('cfg-${slotKey}-message','{${v}}')">{${v}}</span>`; });
varHtml += '</div>';
return `<div class="config-row">
<label>URL</label>
<input type="text" id="cfg-${slotKey}-webhook_url" value="${url}" placeholder="https://discord.com/api/webhooks/...">
</div>
<div class="config-row">
<label>Message</label>
<textarea id="cfg-${slotKey}-message" placeholder="Message with {variables}...">${config.message || '{name} completed with status: {status}'}</textarea>
</div>
${varHtml}`;
}
return '';
}
// --- Condition Builder ---
function _renderConditionBuilder(slotKey, blockDef, config) {
const conditions = config.conditions || [];
const match = config.match || 'all';
const fields = blockDef.condition_fields || [];
let html = '<div class="condition-builder" id="conditions-' + slotKey + '">';
html += `<div class="config-row condition-header">
<label>Match</label>
<select id="cfg-${slotKey}-match" class="condition-match-select">
<option value="all"${match==='all'?' selected':''}>All conditions</option>
<option value="any"${match==='any'?' selected':''}>Any condition</option>
</select>
</div>`;
html += '<div id="condition-rows-' + slotKey + '">';
if (conditions.length) {
conditions.forEach((cond, i) => {
html += _renderConditionRow(slotKey, i, fields, cond);
});
}
html += '</div>';
html += `<button class="add-condition-btn" onclick="_autoAddCondition('${slotKey}')">+ Add Condition</button>`;
html += '</div>';
if (!conditions.length) {
html += '<div class="config-row" style="color:rgba(255,255,255,0.35);font-size:12px;margin-top:4px;">No conditions = triggers on every event</div>';
}
return html;
}
function _renderConditionRow(slotKey, index, fields, cond) {
const field = cond ? cond.field : (fields[0] || '');
const operator = cond ? cond.operator : 'contains';
const value = cond ? _escAttr(cond.value) : '';
let fieldOpts = '';
fields.forEach(f => { fieldOpts += `<option value="${f}"${f===field?' selected':''}>${f}</option>`; });
return `<div class="condition-row" data-index="${index}">
<select class="cond-field" data-slot="${slotKey}" data-idx="${index}">${fieldOpts}</select>
<select class="cond-operator" data-slot="${slotKey}" data-idx="${index}">
<option value="contains"${operator==='contains'?' selected':''}>contains</option>
<option value="equals"${operator==='equals'?' selected':''}>equals</option>
<option value="starts_with"${operator==='starts_with'?' selected':''}>starts with</option>
<option value="not_contains"${operator==='not_contains'?' selected':''}>not contains</option>
</select>
<input type="text" class="cond-value" data-slot="${slotKey}" data-idx="${index}" value="${value}" placeholder="value...">
<button class="remove-condition-btn" onclick="_autoRemoveCondition('${slotKey}',${index})">\u2715</button>
</div>`;
}
function _autoAddCondition(slotKey) {
const data = _autoBuilder[slotKey];
if (!data) return;
if (!data.config) data.config = {};
if (!data.config.conditions) data.config.conditions = [];
// Save existing conditions from DOM before re-render
_autoSaveConditionsFromDOM(slotKey);
const blockDef = _findBlockDef(data.type);
const fields = blockDef ? (blockDef.condition_fields || []) : [];
data.config.conditions.push({ field: fields[0] || '', operator: 'contains', value: '' });
_renderBuilderCanvas();
// Re-populate mirrored playlist selects if needed
_autoLoadMirroredSelects();
}
function _autoRemoveCondition(slotKey, index) {
const data = _autoBuilder[slotKey];
if (!data || !data.config || !data.config.conditions) return;
_autoSaveConditionsFromDOM(slotKey);
data.config.conditions.splice(index, 1);
_renderBuilderCanvas();
_autoLoadMirroredSelects();
}
function _autoSaveConditionsFromDOM(slotKey) {
const data = _autoBuilder[slotKey];
if (!data || !data.config) return;
const container = document.getElementById('condition-rows-' + slotKey);
if (!container) return;
const rows = container.querySelectorAll('.condition-row');
const conditions = [];
rows.forEach(row => {
const field = row.querySelector('.cond-field')?.value || '';
const operator = row.querySelector('.cond-operator')?.value || 'contains';
const value = row.querySelector('.cond-value')?.value || '';
conditions.push({ field, operator, value });
});
data.config.conditions = conditions;
// Also save match mode
const matchEl = document.getElementById('cfg-' + slotKey + '-match');
if (matchEl) data.config.match = matchEl.value;
}
// --- Mirrored Playlist Select ---
function _autoTogglePlaylistSelect(slotKey) {
const allCb = document.getElementById('cfg-' + slotKey + '-all');
const sel = document.getElementById('cfg-' + slotKey + '-playlist_id');
if (sel) sel.disabled = allCb && allCb.checked;
}
async function _autoLoadMirroredSelects() {
const selects = document.querySelectorAll('.mirrored-playlist-select');
if (!selects.length) return;
if (!_autoMirroredPlaylists) {
try {
const res = await fetch('/api/mirrored-playlists/list');
_autoMirroredPlaylists = await res.json();
} catch (e) { _autoMirroredPlaylists = []; }
}
selects.forEach(sel => {
const savedValue = sel.dataset.value || '';
sel.innerHTML = '<option value="">-- Select playlist --</option>';
_autoMirroredPlaylists.forEach(p => {
sel.innerHTML += `<option value="${p.id}"${String(p.id) === savedValue ? ' selected' : ''}>${_esc(p.name)}</option>`;
});
});
}
function _readPlacedConfig(slotKey) {
const data = _autoBuilder[slotKey];
if (!data) return {};
const type = data.type;
if (type === 'schedule') {
return {
interval: parseInt(document.getElementById('cfg-' + slotKey + '-interval')?.value) || 6,
unit: document.getElementById('cfg-' + slotKey + '-unit')?.value || 'hours',
};
}
if (type === 'daily_time') {
return { time: document.getElementById('cfg-' + slotKey + '-time')?.value || '03:00' };
}
if (type === 'weekly_time') {
const daysEl = document.getElementById('cfg-' + slotKey + '-days');
const days = daysEl ? Array.from(daysEl.querySelectorAll('.day-btn.active')).map(b => b.dataset.day) : [];
return {
time: document.getElementById('cfg-' + slotKey + '-time')?.value || '03:00',
days,
};
}
// Event triggers with conditions
const blockDef = _findBlockDef(type);
if (blockDef && blockDef.has_conditions) {
_autoSaveConditionsFromDOM(slotKey);
return {
conditions: (data.config && data.config.conditions) || [],
match: document.getElementById('cfg-' + slotKey + '-match')?.value || 'all',
};
}
if (type === 'process_wishlist') {
return { category: document.getElementById('cfg-' + slotKey + '-category')?.value || 'all' };
}
if (type === 'refresh_mirrored') {
const allCb = document.getElementById('cfg-' + slotKey + '-all');
return {
playlist_id: document.getElementById('cfg-' + slotKey + '-playlist_id')?.value || '',
all: allCb ? allCb.checked : false,
};
}
if (type === 'sync_playlist') {
return { playlist_id: document.getElementById('cfg-' + slotKey + '-playlist_id')?.value || '' };
}
if (type === 'discord_webhook') {
return {
webhook_url: document.getElementById('cfg-' + slotKey + '-webhook_url')?.value?.trim() || '',
message: document.getElementById('cfg-' + slotKey + '-message')?.value || '',
};
}
return {};
}
function _findBlockDef(type) {
if (!_autoBlocks) return null;
for (const cat of ['triggers','actions','notifications']) {
const found = (_autoBlocks[cat] || []).find(b => b.type === type);
if (found) return found;
}
return null;
}
// --- Drag & Drop ---
function _autoDragStart(e, blockType, slotCategory) {
e.dataTransfer.setData('text/plain', JSON.stringify({ type: blockType, slot: slotCategory }));
e.dataTransfer.effectAllowed = 'copy';
}
function _autoDragOver(e, slotKey) {
e.preventDefault();
e.dataTransfer.dropEffect = 'copy';
document.getElementById('slot-' + slotKey)?.classList.add('drag-over');
}
function _autoDragLeave(e, slotKey) {
document.getElementById('slot-' + slotKey)?.classList.remove('drag-over');
}
function _autoDrop(e, slotKey) {
e.preventDefault();
document.getElementById('slot-' + slotKey)?.classList.remove('drag-over');
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: {} };
_renderBuilderCanvas();
} catch (err) {}
}
// Click-to-add (alternative to drag)
function _autoClickBlock(blockType, slotCategory) {
_autoBuilder[slotCategory] = { type: blockType, config: {} };
_renderBuilderCanvas();
}
function _autoRemoveBlock(slotKey) {
_autoBuilder[slotKey] = null;
_renderBuilderCanvas();
}
// Variable insertion
function _autoInsertVar(textareaId, variable) {
const el = document.getElementById(textareaId);
if (!el) return;
const start = el.selectionStart, end = el.selectionEnd;
el.value = el.value.substring(0, start) + variable + el.value.substring(end);
el.selectionStart = el.selectionEnd = start + variable.length;
el.focus();
}
// --- Helpers ---
function _esc(str) {

View file

@ -28275,4 +28275,368 @@ body {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* ===========================
AUTOMATIONS PAGE
=========================== */
.automations-container { padding: 20px 24px; }
.automations-list { display: flex; flex-direction: column; gap: 0; }
/* --- New Automation Button --- */
.auto-new-btn {
background: linear-gradient(135deg, rgb(var(--accent-rgb)) 0%, rgb(var(--accent-light-rgb)) 100%);
color: #fff;
border: none;
padding: 10px 22px;
border-radius: 12px;
font-size: 14px;
font-weight: 600;
cursor: pointer;
transition: all 0.3s ease;
letter-spacing: 0.3px;
}
.auto-new-btn:hover {
transform: translateY(-2px);
box-shadow: 0 8px 24px rgba(var(--accent-rgb), 0.4);
filter: brightness(1.1);
}
/* --- Stats Summary Bar --- */
.automations-stats {
display: flex;
gap: 10px;
padding: 0 0 16px 0;
flex-wrap: wrap;
}
.auto-stat {
display: flex;
align-items: center;
gap: 6px;
padding: 6px 14px;
border-radius: 10px;
background: rgba(255,255,255,0.04);
border: 1px solid rgba(255,255,255,0.06);
font-size: 12px;
color: rgba(255,255,255,0.5);
transition: all 0.2s ease;
}
.auto-stat:hover {
background: rgba(255,255,255,0.06);
border-color: rgba(255,255,255,0.1);
}
.auto-stat-value {
font-weight: 700;
color: rgb(var(--accent-light-rgb));
}
/* --- Automation Card --- */
.automation-card {
background: linear-gradient(135deg, rgba(26, 26, 26, 0.95) 0%, rgba(18, 18, 18, 0.98) 100%);
border-radius: 20px; border: 1px solid rgba(255, 255, 255, 0.08);
border-top: 1px solid rgba(255, 255, 255, 0.12);
margin: 10px 0; padding: 20px 22px;
display: flex; align-items: center; gap: 16px;
transition: all 0.4s cubic-bezier(0.4, 0, 0.2, 1);
position: relative; overflow: hidden;
box-shadow: 0 8px 32px rgba(0,0,0,0.4), 0 2px 8px rgba(0,0,0,0.2), inset 0 1px 0 rgba(255,255,255,0.1);
}
.automation-card::before {
content: '';
position: absolute;
left: 0; top: 50%;
transform: translateY(-50%);
width: 4px; height: 30%;
background: linear-gradient(180deg, transparent 0%, rgba(var(--accent-rgb), 0.15) 100%);
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
border-radius: 0 4px 4px 0;
}
.automation-card:hover {
background: linear-gradient(135deg, rgba(30,30,30,0.98) 0%, rgba(22,22,22,1) 100%);
border-color: rgba(var(--accent-rgb), 0.3); border-top-color: rgba(var(--accent-rgb), 0.4);
transform: translateY(-3px) scale(1.01);
box-shadow: 0 16px 48px rgba(0,0,0,0.5), 0 8px 16px rgba(0,0,0,0.3), 0 0 20px rgba(var(--accent-rgb),0.12), inset 0 1px 0 rgba(255,255,255,0.15);
}
.automation-card:hover::before {
height: 60%;
background: linear-gradient(180deg, rgb(var(--accent-light-rgb)), rgb(var(--accent-rgb)));
box-shadow: 0 0 8px rgba(var(--accent-rgb), 0.5);
}
.automation-card.disabled { opacity: 0.55; }
.automation-card.disabled:hover { opacity: 0.75; }
.automation-card.disabled .automation-name { color: rgba(255,255,255,0.5); }
.automation-status { width: 10px; height: 10px; border-radius: 50%; flex-shrink: 0; transition: background 0.3s ease; }
.automation-status.enabled { background: #4ade80; box-shadow: 0 0 8px rgba(74,222,128,0.4); }
.automation-status.disabled { background: #666; }
.automation-info { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 6px; }
.automation-name { font-size: 15px; font-weight: 600; color: #fff; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.automation-flow { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
.flow-trigger, .flow-action, .flow-notify {
font-size: 12px; padding: 3px 10px; border-radius: 12px; white-space: nowrap;
}
.flow-trigger { background: rgba(var(--accent-rgb),0.15); color: rgb(var(--accent-light-rgb)); border: 1px solid rgba(var(--accent-rgb),0.25); }
.flow-action { background: rgba(88,101,242,0.15); color: #7289da; border: 1px solid rgba(88,101,242,0.25); }
.flow-notify { background: rgba(250,204,21,0.12); color: #fbbf24; border: 1px solid rgba(250,204,21,0.2); }
.flow-arrow { color: rgba(255,255,255,0.3); font-size: 14px; font-weight: 600; }
.flow-delay { font-size: 11px; padding: 2px 8px; border-radius: 10px; white-space: nowrap; background: rgba(255,255,255,0.06); color: rgba(255,255,255,0.45); border: 1px dashed rgba(255,255,255,0.15); }
.day-picker { display: flex; gap: 4px; flex-wrap: wrap; }
.day-btn { background: rgba(255,255,255,0.06); color: rgba(255,255,255,0.5); border: 1px solid rgba(255,255,255,0.12); border-radius: 8px; padding: 4px 10px; font-size: 12px; cursor: pointer; transition: all 0.2s ease; }
.day-btn:hover { background: rgba(255,255,255,0.1); color: rgba(255,255,255,0.7); }
.day-btn.active { background: rgba(var(--accent-rgb), 0.2); color: rgb(var(--accent-light-rgb)); border-color: rgba(var(--accent-rgb), 0.4); }
.automation-meta { font-size: 11px; color: rgba(255,255,255,0.4); }
.automation-actions { display: flex; align-items: center; gap: 8px; flex-shrink: 0; }
.automation-run-btn, .automation-edit-btn, .automation-delete-btn {
background: rgba(255,255,255,0.06); border: 1px solid rgba(255,255,255,0.1);
border-radius: 10px; width: 34px; height: 34px;
display: flex; align-items: center; justify-content: center;
cursor: pointer; font-size: 14px; transition: all 0.2s ease; color: rgba(255,255,255,0.6);
}
.automation-run-btn:hover { background: rgba(var(--accent-rgb),0.2); border-color: rgba(var(--accent-rgb),0.4); color: rgb(var(--accent-light-rgb)); }
.automation-edit-btn:hover { background: rgba(255,255,255,0.1); border-color: rgba(255,255,255,0.2); color: #fff; }
.automation-delete-btn:hover { background: rgba(239,68,68,0.2); border-color: rgba(239,68,68,0.4); color: #ef4444; }
/* Toggle switch */
.automation-toggle { position: relative; display: inline-block; width: 40px; height: 22px; }
.automation-toggle input { opacity: 0; width: 0; height: 0; }
.toggle-slider {
position: absolute; cursor: pointer; top: 0; left: 0; right: 0; bottom: 0;
background: rgba(255,255,255,0.1); border-radius: 22px; transition: all 0.3s ease; border: 1px solid rgba(255,255,255,0.15);
}
.toggle-slider::before {
content: ''; position: absolute; height: 16px; width: 16px; left: 2px; bottom: 2px;
background: rgba(255,255,255,0.6); border-radius: 50%; transition: all 0.3s ease;
}
.automation-toggle input:checked + .toggle-slider { background: rgba(var(--accent-rgb),0.5); border-color: rgba(var(--accent-rgb),0.6); }
.automation-toggle input:checked + .toggle-slider::before { transform: translateX(18px); background: rgb(var(--accent-light-rgb)); }
/* --- Empty State --- */
.automations-empty {
text-align: center; padding: 80px 24px;
background: linear-gradient(135deg, rgba(var(--accent-rgb), 0.03) 0%, transparent 60%);
border-radius: 20px;
border: 1px dashed rgba(255,255,255,0.08);
}
.automations-empty-icon { font-size: 56px; margin-bottom: 20px; filter: grayscale(0.3); }
.automations-empty-title {
font-size: 22px; font-weight: 700; margin-bottom: 10px;
background: linear-gradient(135deg, #fff 0%, rgb(var(--accent-light-rgb)) 100%);
-webkit-background-clip: text; -webkit-text-fill-color: transparent; background-clip: text;
}
.automations-empty-text {
font-size: 14px; color: rgba(255,255,255,0.4); margin-bottom: 28px;
max-width: 420px; margin-left: auto; margin-right: auto; line-height: 1.6;
}
/* --- Builder View --- */
.automations-builder-view { display: flex; flex-direction: column; height: 100%; }
.builder-header {
display: flex; align-items: center; gap: 12px;
padding: 16px 24px; border-bottom: 1px solid rgba(255,255,255,0.08);
background: rgba(18,18,18,0.95); flex-shrink: 0;
}
.builder-back-btn {
background: rgba(255,255,255,0.06); border: 1px solid rgba(255,255,255,0.1);
border-radius: 10px; width: 36px; height: 36px; display: flex; align-items: center; justify-content: center;
cursor: pointer; font-size: 18px; color: rgba(255,255,255,0.6); transition: all 0.2s ease;
}
.builder-back-btn:hover { background: rgba(255,255,255,0.1); color: #fff; }
.builder-name-input {
flex: 1; background: rgba(255,255,255,0.06); border: 1px solid rgba(255,255,255,0.1);
border-radius: 10px; padding: 8px 14px; color: #fff; font-size: 16px; font-weight: 600;
outline: none; transition: border-color 0.2s ease;
}
.builder-name-input:focus { border-color: rgba(var(--accent-rgb),0.5); }
.builder-name-input::placeholder { color: rgba(255,255,255,0.3); }
.builder-header-actions { display: flex; gap: 8px; }
.builder-header-actions .btn-cancel {
background: rgba(255,255,255,0.06); border: 1px solid rgba(255,255,255,0.12);
color: rgba(255,255,255,0.7); padding: 8px 20px; border-radius: 10px; cursor: pointer; font-size: 13px; transition: all 0.2s ease;
}
.builder-header-actions .btn-cancel:hover { background: rgba(255,255,255,0.1); color: #fff; }
.builder-header-actions .btn-save {
background: rgb(var(--accent-rgb)); border: none; color: #fff;
padding: 8px 24px; border-radius: 10px; cursor: pointer; font-size: 13px; font-weight: 600; transition: all 0.2s ease;
}
.builder-header-actions .btn-save:hover { filter: brightness(1.15); transform: translateY(-1px); }
.builder-content { display: flex; flex: 1; overflow: hidden; }
/* --- Builder Sidebar --- */
.builder-sidebar {
width: 260px; flex-shrink: 0; overflow-y: auto;
border-right: 1px solid rgba(255,255,255,0.06);
padding: 16px; background: rgba(14,14,14,0.5);
}
.sidebar-section { margin-bottom: 20px; }
.sidebar-section-title {
font-size: 10px; font-weight: 700; text-transform: uppercase; letter-spacing: 1.2px;
color: rgba(255,255,255,0.35); margin-bottom: 8px; padding-left: 4px;
}
.block-item {
display: flex; align-items: center; gap: 10px;
padding: 10px 12px; border-radius: 12px;
background: rgba(255,255,255,0.04); border: 1px solid rgba(255,255,255,0.06);
margin-bottom: 6px; cursor: grab; transition: all 0.2s ease; user-select: none;
}
.block-item:hover {
background: rgba(255,255,255,0.08); border-color: rgba(var(--accent-rgb),0.3);
transform: translateX(2px);
}
.block-item:active { cursor: grabbing; }
.block-item.coming-soon {
opacity: 0.35; cursor: default; pointer-events: none;
}
.block-item-icon { font-size: 18px; flex-shrink: 0; width: 28px; text-align: center; }
.block-item-text { flex: 1; min-width: 0; }
.block-item-label { font-size: 13px; font-weight: 500; color: #fff; }
.block-item-desc { font-size: 10px; color: rgba(255,255,255,0.35); margin-top: 1px; }
.block-item .coming-soon-badge {
font-size: 8px; text-transform: uppercase; letter-spacing: 0.5px;
color: rgba(255,255,255,0.3); background: rgba(255,255,255,0.06);
padding: 2px 6px; border-radius: 6px; white-space: nowrap;
}
/* --- Builder Canvas --- */
.builder-canvas {
flex: 1; overflow-y: auto; display: flex; flex-direction: column;
align-items: center; padding: 40px 24px; gap: 0;
}
.flow-slot {
width: 100%; max-width: 480px; min-height: 70px;
border: 2px dashed rgba(255,255,255,0.1); border-radius: 16px;
display: flex; align-items: center; justify-content: center;
transition: all 0.3s ease; position: relative;
}
.flow-slot.empty { cursor: default; }
.flow-slot.empty .flow-slot-prompt {
font-size: 13px; color: rgba(255,255,255,0.25); text-align: center; padding: 20px;
}
.flow-slot.drag-over {
border-color: rgba(var(--accent-rgb),0.6); background: rgba(var(--accent-rgb),0.05);
box-shadow: 0 0 20px rgba(var(--accent-rgb),0.1);
}
.flow-slot.filled { border: none; min-height: auto; }
.flow-connector {
width: 2px; height: 32px; background: rgba(255,255,255,0.1); position: relative;
}
.flow-connector::after {
content: ''; position: absolute; bottom: -4px; left: -4px;
width: 0; height: 0;
border-left: 5px solid transparent; border-right: 5px solid transparent;
border-top: 6px solid rgba(255,255,255,0.15);
}
.flow-slot-label {
position: absolute; top: -10px; left: 16px;
font-size: 9px; font-weight: 700; text-transform: uppercase; letter-spacing: 1px;
padding: 2px 8px; border-radius: 6px;
}
.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; }
/* --- Placed Block (in flow) --- */
.placed-block {
width: 100%; max-width: 480px;
background: linear-gradient(135deg, rgba(26,26,26,0.95) 0%, rgba(18,18,18,0.98) 100%);
border-radius: 16px; border: 1px solid rgba(255,255,255,0.1);
overflow: hidden; transition: all 0.3s ease;
box-shadow: 0 4px 16px rgba(0,0,0,0.3);
}
.placed-block:hover { border-color: rgba(255,255,255,0.18); }
.placed-block-header {
display: flex; align-items: center; gap: 10px; padding: 14px 16px;
border-bottom: 1px solid rgba(255,255,255,0.06);
}
.placed-block-icon { font-size: 18px; }
.placed-block-label { flex: 1; font-size: 14px; font-weight: 600; color: #fff; }
.placed-block-remove {
background: none; border: none; color: rgba(255,255,255,0.3);
font-size: 16px; cursor: pointer; padding: 2px 6px; border-radius: 6px; transition: all 0.2s ease;
}
.placed-block-remove:hover { background: rgba(239,68,68,0.2); color: #ef4444; }
.placed-block-config { padding: 14px 16px; }
.placed-block-config .config-row {
display: flex; align-items: center; gap: 8px; margin-bottom: 10px;
}
.placed-block-config .config-row:last-child { margin-bottom: 0; }
.placed-block-config label {
font-size: 12px; color: rgba(255,255,255,0.5); text-transform: uppercase;
letter-spacing: 0.5px; white-space: nowrap; min-width: 50px;
}
.placed-block-config input[type="text"],
.placed-block-config input[type="number"],
.placed-block-config textarea {
flex: 1; background: rgba(255,255,255,0.06); border: 1px solid rgba(255,255,255,0.1);
border-radius: 8px; padding: 7px 10px; color: #fff; font-size: 13px;
outline: none; transition: border-color 0.2s ease;
}
.placed-block-config input:focus,
.placed-block-config textarea:focus { border-color: rgba(var(--accent-rgb),0.5); }
.placed-block-config select {
background: rgba(255,255,255,0.06); border: 1px solid rgba(255,255,255,0.1);
border-radius: 8px; padding: 7px 10px; color: #fff; font-size: 13px;
outline: none; cursor: pointer;
}
.placed-block-config select option,
.condition-match-select option,
.condition-row select option {
background: #1a1a1a; color: #fff;
}
.placed-block-config textarea { resize: vertical; min-height: 60px; font-family: inherit; }
/* Variable tags */
.variable-tags { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 8px; }
.variable-tag {
font-size: 11px; padding: 3px 8px; border-radius: 8px; cursor: pointer;
background: rgba(var(--accent-rgb),0.1); color: rgb(var(--accent-light-rgb));
border: 1px solid rgba(var(--accent-rgb),0.2); transition: all 0.2s ease; font-family: monospace;
}
.variable-tag:hover { background: rgba(var(--accent-rgb),0.2); border-color: rgba(var(--accent-rgb),0.4); }
/* Condition builder */
.condition-builder { margin-top: 8px; }
.condition-header { margin-bottom: 8px; }
.condition-match-select {
background: rgba(255,255,255,0.06); border: 1px solid rgba(255,255,255,0.12);
color: #fff; padding: 4px 8px; border-radius: 6px; font-size: 12px;
}
.condition-row {
display: flex; gap: 6px; align-items: center; margin-bottom: 6px; padding: 8px 10px;
background: rgba(255,255,255,0.03); border-radius: 6px; border: 1px solid rgba(255,255,255,0.06);
}
.condition-row select, .condition-row input[type="text"] {
background: rgba(255,255,255,0.06); border: 1px solid rgba(255,255,255,0.12);
color: #fff; padding: 5px 8px; border-radius: 5px; font-size: 12px;
}
.condition-row select { min-width: 80px; }
.condition-row input[type="text"] { flex: 1; min-width: 80px; }
.condition-row select:focus, .condition-row input:focus {
border-color: rgba(var(--accent-rgb),0.5); outline: none;
}
.add-condition-btn {
display: inline-block; margin-top: 6px; padding: 5px 12px; font-size: 12px;
background: transparent; color: rgba(var(--accent-light-rgb),0.8);
border: 1px dashed rgba(var(--accent-rgb),0.3); border-radius: 6px; cursor: pointer;
transition: all 0.2s ease;
}
.add-condition-btn:hover {
background: rgba(var(--accent-rgb),0.1); border-color: rgba(var(--accent-rgb),0.5);
}
.remove-condition-btn {
background: none; border: none; color: rgba(255,255,255,0.3); cursor: pointer;
font-size: 14px; padding: 2px 6px; border-radius: 4px; transition: all 0.2s ease;
}
.remove-condition-btn:hover { color: #ff6b6b; background: rgba(255,0,0,0.1); }
/* Mirrored playlist select */
.placed-block-config input[type="checkbox"] {
margin-right: 6px; accent-color: rgb(var(--accent-rgb));
}