diff --git a/api/listenbrainz.py b/api/listenbrainz.py index 98d13e1e..abbfa2a5 100644 --- a/api/listenbrainz.py +++ b/api/listenbrainz.py @@ -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/", 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. diff --git a/core/automation_engine.py b/core/automation_engine.py new file mode 100644 index 00000000..55a1a9d4 --- /dev/null +++ b/core/automation_engine.py @@ -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]}") diff --git a/database/music_database.py b/database/music_database.py index da3fe78a..7e4a645b 100644 --- a/database/music_database.py +++ b/database/music_database.py @@ -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() diff --git a/web_server.py b/web_server.py index 64910bbf..9a83dd2e 100644 --- a/web_server.py +++ b/web_server.py @@ -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/', 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/', 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/', 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//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//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") diff --git a/webui/index.html b/webui/index.html index 55976f51..0d1b47cf 100644 --- a/webui/index.html +++ b/webui/index.html @@ -111,6 +111,10 @@ 🎵 Artists + +
+ + +

Last Full Refresh: Never

@@ -1947,6 +1955,49 @@
+ +
+ +
+
+
+
+

Automations

+

Configure scheduled tasks and automated workflows

+
+
+
+ +
+
+
+
+ +
+
+ + + +
+
diff --git a/webui/static/script.js b/webui/static/script.js index 2d2e8377..11a50b9b 100644 --- a/webui/static/script.js +++ b/webui/static/script.js @@ -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 = ` + ${total} Total + ${active} Active + ${scheduled} Scheduled + ${eventBased} Event-Based + `; + } + } 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 = ` +
+
+
${_esc(a.name)}
+
+ ${_esc(tl)} + + ${actionDelay ? `\u23F3 ${actionDelay}m` : ''} + ${_esc(al)} + ${nl ? `${_esc(nl)}` : ''} +
+
${metaParts.join(' · ')}
+
+
+ + + + +
+ `; + 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 += `'; + }); + 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 += '
'; + const data = _autoBuilder[slot.key]; + if (data) { + html += `
+ ${slot.label} + ${_renderPlacedBlock(slot.key, data)} +
`; + } else { + html += `
+ ${slot.label} +
${slot.prompt}
+
`; + } + }); + + 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 = `
+ + +
`; + } + + return `
+
+ ${icon} + ${_esc(label)} + +
+ ${configHtml ? '
' + configHtml + '
' : ''} + ${delayHtml} +
`; +} + +function _renderBlockConfigFields(slotKey, blockType, config) { + if (blockType === 'schedule') { + const interval = config.interval || 6; + const unit = config.unit || 'hours'; + return `
+ + + +
`; + } + if (blockType === 'daily_time') { + const timeVal = config.time || '03:00'; + return `
+ + +
`; + } + 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 = '
'; + allDays.forEach(([val, lbl]) => { + const active = selectedDays.includes(val) ? ' active' : ''; + dayHtml += ``; + }); + dayHtml += '
'; + return `
+ + +
${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 `
+ + +
`; + } + if (blockType === 'scan_watchlist' || blockType === 'scan_library' || blockType === 'notify_only') { + return '
No configuration needed
'; + } + if (blockType === 'refresh_mirrored') { + const allChecked = config.all ? ' checked' : ''; + return `
+ + +
+
+ +
`; + } + if (blockType === 'sync_playlist') { + return `
+ + +
`; + } + 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 = '
'; + allVars.forEach(v => { varHtml += `{${v}}`; }); + varHtml += '
'; + return `
+ + +
+
+ + +
+ ${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 = '
'; + html += `
+ + +
`; + + html += '
'; + if (conditions.length) { + conditions.forEach((cond, i) => { + html += _renderConditionRow(slotKey, i, fields, cond); + }); + } + html += '
'; + + html += ``; + html += '
'; + + if (!conditions.length) { + html += '
No conditions = triggers on every event
'; + } + + 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 += ``; }); + + return `
+ + + + +
`; +} + +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 = ''; + _autoMirroredPlaylists.forEach(p => { + sel.innerHTML += ``; + }); + }); +} + +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) { diff --git a/webui/static/style.css b/webui/static/style.css index d71cb42a..77a969c5 100644 --- a/webui/static/style.css +++ b/webui/static/style.css @@ -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)); } \ No newline at end of file