From f99f873d602adb3c7e6cdccb0d98626ac3aa69de Mon Sep 17 00:00:00 2001 From: Broque Thomas <26755000+Nezreka@users.noreply.github.com> Date: Thu, 5 Mar 2026 16:50:09 -0800 Subject: [PATCH] Replace hardcoded wishlist/watchlist timers with system automations + add Pushbullet & Telegram notifications --- core/automation_engine.py | 103 ++++++++++++ database/music_database.py | 37 ++++- web_server.py | 317 ++++--------------------------------- webui/static/script.js | 101 ++++++++++-- webui/static/style.css | 73 ++++----- 5 files changed, 283 insertions(+), 348 deletions(-) diff --git a/core/automation_engine.py b/core/automation_engine.py index 55a1a9d4..99224e04 100644 --- a/core/automation_engine.py +++ b/core/automation_engine.py @@ -20,6 +20,23 @@ from utils.logging_config import get_logger logger = get_logger("automation_engine") +SYSTEM_AUTOMATIONS = [ + { + 'name': 'Auto-Process Wishlist', + 'trigger_type': 'schedule', + 'trigger_config': {'interval': 30, 'unit': 'minutes'}, + 'action_type': 'process_wishlist', + 'initial_delay': 60, # 1 minute after startup + }, + { + 'name': 'Auto-Scan Watchlist', + 'trigger_type': 'schedule', + 'trigger_config': {'interval': 24, 'unit': 'hours'}, + 'action_type': 'scan_watchlist', + 'initial_delay': 300, # 5 minutes after startup + }, +] + class AutomationEngine: def __init__(self, db): @@ -56,12 +73,51 @@ class AutomationEngine: } logger.debug(f"Registered action handler: {action_type}") + # --- System Automations --- + + def ensure_system_automations(self): + """Create system automations if they don't exist, and reset next_run to initial delays on every startup.""" + for spec in SYSTEM_AUTOMATIONS: + existing = self.db.get_system_automation_by_action(spec['action_type']) + if not existing: + aid = self.db.create_automation( + name=spec['name'], + trigger_type=spec['trigger_type'], + trigger_config=json.dumps(spec['trigger_config']), + action_type=spec['action_type'], + action_config='{}', + profile_id=1, + ) + if aid: + self.db.update_automation(aid, is_system=1) + logger.info(f"Created system automation: {spec['name']} (id={aid})") + existing = self.db.get_system_automation_by_action(spec['action_type']) + + if existing: + # Reset next_run to initial delay on every startup + next_run = (datetime.now() + timedelta(seconds=spec['initial_delay'])).strftime('%Y-%m-%d %H:%M:%S') + self.db.update_automation(existing['id'], next_run=next_run) + logger.info(f"System automation '{spec['name']}' next_run reset to {spec['initial_delay']}s from now") + + def get_system_automation_next_run_seconds(self, action_type): + """Get seconds until next run for a system automation. Returns 0 if not found or disabled.""" + auto = self.db.get_system_automation_by_action(action_type) + if not auto or not auto.get('enabled') or not auto.get('next_run'): + return 0 + try: + next_run = datetime.strptime(auto['next_run'], '%Y-%m-%d %H:%M:%S') + remaining = (next_run - datetime.now()).total_seconds() + return max(0, int(remaining)) + except (ValueError, TypeError): + return 0 + # --- Lifecycle --- def start(self): """Load all enabled automations from DB and schedule them.""" self._running = True self._event_cache_dirty = True + self.ensure_system_automations() automations = self.db.get_automations() scheduled = 0 event_count = 0 @@ -510,6 +566,10 @@ class AutomationEngine: if notify_type == 'discord_webhook': self._send_discord_notification(config, variables) + elif notify_type == 'pushbullet': + self._send_pushbullet_notification(config, variables) + elif notify_type == 'telegram': + self._send_telegram_notification(config, variables) def _send_discord_notification(self, config, variables): """POST to Discord webhook with template variable substitution.""" @@ -526,3 +586,46 @@ class AutomationEngine: 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]}") + + def _send_pushbullet_notification(self, config, variables): + """Send push notification via Pushbullet API.""" + token = config.get('access_token', '').strip() + if not token: + raise ValueError("No Pushbullet access token configured") + + title = config.get('title', '{name}') + message = config.get('message', 'Completed with status: {status}') + + for key, value in variables.items(): + title = title.replace('{' + key + '}', value) + message = message.replace('{' + key + '}', value) + + resp = requests.post( + 'https://api.pushbullet.com/v2/pushes', + json={"type": "note", "title": title, "body": message}, + headers={"Access-Token": token}, + timeout=10, + ) + if resp.status_code not in (200, 201): + raise RuntimeError(f"Pushbullet returned {resp.status_code}: {resp.text[:200]}") + + def _send_telegram_notification(self, config, variables): + """Send message via Telegram Bot API.""" + bot_token = config.get('bot_token', '').strip() + chat_id = config.get('chat_id', '').strip() + if not bot_token or not chat_id: + raise ValueError("Bot token and chat ID are required for Telegram") + + message = config.get('message', '{name} completed with status: {status}') + + for key, value in variables.items(): + message = message.replace('{' + key + '}', value) + + resp = requests.post( + f'https://api.telegram.org/bot{bot_token}/sendMessage', + json={"chat_id": chat_id, "text": message, "parse_mode": "HTML"}, + timeout=10, + ) + data = resp.json() if resp.status_code == 200 else {} + if not data.get('ok'): + raise RuntimeError(f"Telegram returned {resp.status_code}: {resp.text[:200]}") diff --git a/database/music_database.py b/database/music_database.py index 7e4a645b..214f4098 100644 --- a/database/music_database.py +++ b/database/music_database.py @@ -399,6 +399,7 @@ class MusicDatabase: # Add notification columns to automations (migration) self._add_automation_notify_columns(cursor) + self._add_automation_system_column(cursor) conn.commit() logger.info("Database initialized successfully") @@ -419,6 +420,17 @@ class MusicDatabase: except Exception as e: logger.error(f"Error adding automation notify columns: {e}") + def _add_automation_system_column(self, cursor): + """Add is_system column to automations table for non-deletable system automations.""" + try: + cursor.execute("PRAGMA table_info(automations)") + cols = [c[1] for c in cursor.fetchall()] + if 'is_system' not in cols: + cursor.execute("ALTER TABLE automations ADD COLUMN is_system INTEGER DEFAULT 0") + logger.info("Added is_system column to automations table") + except Exception as e: + logger.error(f"Error adding automation system column: {e}") + def _add_server_source_columns(self, cursor): """Add server_source columns to existing tables for multi-server support""" try: @@ -6542,12 +6554,12 @@ class MusicDatabase: return None def get_automations(self, profile_id: int = 1): - """Get all automations for a profile.""" + """Get all automations for a profile (includes system automations regardless of profile).""" try: with self._get_connection() as conn: cursor = conn.cursor() cursor.execute(""" - SELECT * FROM automations WHERE profile_id = ? ORDER BY created_at DESC + SELECT * FROM automations WHERE profile_id = ? OR is_system = 1 ORDER BY is_system DESC, created_at DESC """, (profile_id,)) rows = cursor.fetchall() return [dict(row) for row in rows] @@ -6555,6 +6567,18 @@ class MusicDatabase: logger.error(f"Error getting automations: {e}") return [] + def get_system_automation_by_action(self, action_type: str): + """Get a system automation by its action_type. Returns dict or None.""" + try: + with self._get_connection() as conn: + cursor = conn.cursor() + cursor.execute("SELECT * FROM automations WHERE is_system = 1 AND action_type = ?", (action_type,)) + row = cursor.fetchone() + return dict(row) if row else None + except Exception as e: + logger.error(f"Error getting system automation for {action_type}: {e}") + return None + def get_automation(self, automation_id: int): """Get a single automation by ID.""" try: @@ -6569,7 +6593,7 @@ class MusicDatabase: def update_automation(self, automation_id: int, **kwargs) -> bool: """Update automation fields.""" - allowed = {'name', 'enabled', 'trigger_type', 'trigger_config', 'action_type', 'action_config', 'next_run', 'notify_type', 'notify_config', 'last_result'} + allowed = {'name', 'enabled', 'trigger_type', 'trigger_config', 'action_type', 'action_config', 'next_run', 'notify_type', 'notify_config', 'last_result', 'is_system'} updates = {k: v for k, v in kwargs.items() if k in allowed} if not updates: return False @@ -6589,10 +6613,15 @@ class MusicDatabase: return False def delete_automation(self, automation_id: int) -> bool: - """Delete an automation.""" + """Delete an automation. System automations cannot be deleted.""" try: with self._get_connection() as conn: cursor = conn.cursor() + cursor.execute("SELECT is_system FROM automations WHERE id = ?", (automation_id,)) + row = cursor.fetchone() + if row and row['is_system']: + logger.warning(f"Attempted to delete system automation {automation_id}") + return False cursor.execute("DELETE FROM automations WHERE id = ?", (automation_id,)) conn.commit() return cursor.rowcount > 0 diff --git a/web_server.py b/web_server.py index 9a83dd2e..e002d562 100644 --- a/web_server.py +++ b/web_server.py @@ -269,16 +269,12 @@ def _register_automation_handlers(): 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')} + _process_wishlist_automatically() + return {'status': 'completed'} 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'} + _process_watchlist_scan_automatically() + return {'status': 'completed'} def _auto_scan_library(config): if web_scan_manager: @@ -286,8 +282,9 @@ def _register_automation_handlers(): 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) + cross_guard = lambda: is_wishlist_actually_processing() or is_watchlist_actually_scanning() + automation_engine.register_action_handler('process_wishlist', _auto_process_wishlist, cross_guard) + automation_engine.register_action_handler('scan_watchlist', _auto_scan_watchlist, cross_guard) automation_engine.register_action_handler('scan_library', _auto_scan_library) def _auto_refresh_mirrored(config): @@ -670,20 +667,15 @@ def _mark_task_completed(task_id, track_info=None): # --- Automatic Wishlist Processing Infrastructure --- # Server-side timer system for automatic wishlist processing (replaces client-side JavaScript timers) -wishlist_auto_processor = ThreadPoolExecutor(max_workers=1, thread_name_prefix="WishlistAutoProcessor") -wishlist_auto_timer = None # threading.Timer for scheduling next auto-processing +# --- Automatic Wishlist/Watchlist Processing Flags --- +# Processing state flags (guards/recovery — timers are now managed by AutomationEngine) wishlist_auto_processing = False # Flag to prevent concurrent auto-processing wishlist_auto_processing_timestamp = 0 # Timestamp when processing started (for stuck detection) -wishlist_next_run_time = 0 # Timestamp when next auto-processing is scheduled (for countdown display) -wishlist_timer_lock = threading.Lock() # Thread safety for timer operations +wishlist_timer_lock = threading.Lock() # Thread safety for flag operations -# --- Automatic Watchlist Scanning Infrastructure --- -# Server-side timer system for automatic watchlist scanning (mirrors wishlist pattern for consistency) -watchlist_auto_timer = None # threading.Timer for scheduling next auto-scanning watchlist_auto_scanning = False # Flag to prevent concurrent auto-scanning watchlist_auto_scanning_timestamp = 0 # Timestamp when scanning started (for stuck detection) -watchlist_next_run_time = 0 # Timestamp when next auto-scanning is scheduled (for countdown display) -watchlist_timer_lock = threading.Lock() # Thread safety for timer operations +watchlist_timer_lock = threading.Lock() # Thread safety for flag operations # --- Shared Transfer Data Cache --- # Cache transfer data to avoid hammering the Soulseek API with multiple concurrent modals @@ -3488,11 +3480,14 @@ def update_automation_endpoint(automation_id): @app.route('/api/automations/', methods=['DELETE']) def delete_automation_endpoint(automation_id): - """Delete an automation.""" + """Delete an automation. System automations cannot be deleted.""" try: + db = get_database() + auto = db.get_automation(automation_id) + if auto and auto.get('is_system'): + return jsonify({"error": "System automations cannot be deleted"}), 403 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 @@ -3668,6 +3663,10 @@ def get_automation_blocks(): "notifications": [ {"type": "discord_webhook", "label": "Discord Webhook", "icon": "message", "description": "Send a Discord notification", "available": True, "variables": ["time", "name", "run_count", "status"]}, + {"type": "pushbullet", "label": "Pushbullet", "icon": "push", "description": "Push notification to phone/desktop", "available": True, + "variables": ["time", "name", "run_count", "status"]}, + {"type": "telegram", "label": "Telegram", "icon": "message", "description": "Send a Telegram message", "available": True, + "variables": ["time", "name", "run_count", "status"]}, ] }) @@ -12989,12 +12988,6 @@ def check_and_recover_stuck_flags(): wishlist_auto_processing = False wishlist_auto_processing_timestamp = 0 - # CRITICAL FIX: Reschedule timer after recovery to maintain continuity - print("🔄 [Stuck Recovery] Rescheduling wishlist processing in 30 minutes") - try: - schedule_next_wishlist_processing() - except Exception as e: - print(f"❌ [Stuck Recovery] Failed to reschedule wishlist processing: {e}") return True # Check watchlist flag @@ -13006,13 +12999,6 @@ def check_and_recover_stuck_flags(): with watchlist_timer_lock: watchlist_auto_scanning = False watchlist_auto_scanning_timestamp = 0 - - # CRITICAL FIX: Reschedule timer after recovery to maintain continuity - print("🔄 [Stuck Recovery] Rescheduling watchlist scan in 24 hours") - try: - schedule_next_watchlist_scan() - except Exception as e: - print(f"❌ [Stuck Recovery] Failed to reschedule watchlist scan: {e}") return True return False @@ -13063,88 +13049,6 @@ def is_watchlist_actually_scanning(): return True -def start_wishlist_auto_processing(): - """Start automatic wishlist processing with 1-minute initial delay.""" - global wishlist_auto_timer, wishlist_next_run_time - - print("🚀 [Auto-Wishlist] Initializing automatic wishlist processing...") - - with wishlist_timer_lock: - # Stop any existing timer to prevent duplicates - if wishlist_auto_timer is not None: - wishlist_auto_timer.cancel() - - print("🔄 Starting automatic wishlist processing system (1 minute initial delay)") - wishlist_next_run_time = time.time() + 60.0 # Set timestamp for countdown display - wishlist_auto_timer = threading.Timer(60.0, _process_wishlist_automatically) # 1 minute - wishlist_auto_timer.daemon = True - wishlist_auto_timer.start() - print(f"✅ [Debug] Timer started successfully - will trigger in 60 seconds") - -def stop_wishlist_auto_processing(): - """Stop automatic wishlist processing and cleanup timer.""" - global wishlist_auto_timer, wishlist_auto_processing, wishlist_auto_processing_timestamp, wishlist_next_run_time - - with wishlist_timer_lock: - if wishlist_auto_timer is not None: - wishlist_auto_timer.cancel() - wishlist_auto_timer = None - print("âšī¸ Stopped automatic wishlist processing") - - wishlist_auto_processing = False - wishlist_auto_processing_timestamp = 0 - wishlist_next_run_time = 0 # Clear countdown timer - -def schedule_next_wishlist_processing(retry_count=0, max_retries=3): - """ - Schedule next automatic wishlist processing in 30 minutes. - Includes retry logic and atomic timer updates to prevent "0s" stuck state. - - Args: - retry_count: Current retry attempt (internal use) - max_retries: Maximum number of retry attempts - """ - global wishlist_auto_timer, wishlist_next_run_time - - try: - with wishlist_timer_lock: - # Cancel existing timer if present (prevent orphaned timers) - if wishlist_auto_timer is not None: - try: - wishlist_auto_timer.cancel() - except Exception as cancel_error: - print(f"âš ī¸ Failed to cancel old wishlist timer: {cancel_error}") - - # Calculate next run time BEFORE creating timer - next_time = time.time() + 1800.0 # 30 minutes - - # Create and start new timer - new_timer = threading.Timer(1800.0, _process_wishlist_automatically) - new_timer.daemon = True - new_timer.start() - - # Only update globals AFTER successful timer creation and start - wishlist_next_run_time = next_time - wishlist_auto_timer = new_timer - - print(f"⏰ Scheduled next wishlist processing in 30 minutes") - - except Exception as e: - print(f"❌ [CRITICAL] Failed to schedule wishlist processing (attempt {retry_count + 1}/{max_retries}): {e}") - import traceback - traceback.print_exc() - - # Retry with exponential backoff - if retry_count < max_retries: - retry_delay = 5 * (2 ** retry_count) # 5s, 10s, 20s - print(f"🔄 Retrying wishlist scheduling in {retry_delay} seconds...") - retry_timer = threading.Timer(retry_delay, lambda: schedule_next_wishlist_processing(retry_count + 1, max_retries)) - retry_timer.daemon = True - retry_timer.start() - else: - print(f"❌ [FATAL] Failed to schedule wishlist processing after {max_retries} attempts!") - print("âš ī¸ MANUAL INTERVENTION REQUIRED - Wishlist auto-processing will not run!") - def _classify_wishlist_track(track): """Classify a wishlist track as 'singles' or 'albums'. @@ -13193,7 +13097,6 @@ def _process_wishlist_automatically(): # This prevents deadlock and handles stuck flags (2-hour timeout) if is_wishlist_actually_processing(): print("âš ī¸ [Auto-Wishlist] Already processing (verified with stuck detection), skipping.") - schedule_next_wishlist_processing() return # Check conditions and set flag @@ -13218,9 +13121,7 @@ def _process_wishlist_automatically(): wishlist_auto_processing_timestamp = time.time() print(f"🔒 [Auto-Wishlist] Flag set at timestamp {wishlist_auto_processing_timestamp}") - # Schedule next run OUTSIDE the lock to avoid deadlock if should_skip_already_running or should_skip_watchlist_conflict: - schedule_next_wishlist_processing() return # Use app context for database operations @@ -13238,8 +13139,6 @@ def _process_wishlist_automatically(): with wishlist_timer_lock: wishlist_auto_processing = False wishlist_auto_processing_timestamp = 0 - # Don't clear wishlist_next_run_time - let schedule function handle atomically - schedule_next_wishlist_processing() # Has built-in error handling and retry return print(f"đŸŽĩ [Auto-Wishlist] Found {count} tracks in wishlist, starting automatic processing...") @@ -13255,7 +13154,6 @@ def _process_wishlist_automatically(): print(f"âš ī¸ Wishlist processing already active in another batch ({batch_playlist_id}), skipping automatic start") with wishlist_timer_lock: wishlist_auto_processing = False - schedule_next_wishlist_processing() return # CRITICAL: Clean duplicates BEFORE fetching tracks to prevent count mismatches @@ -13330,9 +13228,8 @@ def _process_wishlist_automatically(): with wishlist_timer_lock: wishlist_auto_processing = False wishlist_auto_processing_timestamp = 0 - schedule_next_wishlist_processing() return - + # SANITIZE: Ensure consistent data format from wishlist service wishlist_tracks = [] seen_track_ids_sanitation = set() # Deduplicate during sanitization @@ -13411,7 +13308,6 @@ def _process_wishlist_automatically(): with wishlist_timer_lock: wishlist_auto_processing = False wishlist_auto_processing_timestamp = 0 - schedule_next_wishlist_processing() return # Use filtered tracks for processing @@ -13463,10 +13359,6 @@ def _process_wishlist_automatically(): with wishlist_timer_lock: wishlist_auto_processing = False wishlist_auto_processing_timestamp = 0 - # Don't clear wishlist_next_run_time - let schedule function handle atomically - - # Reschedule next cycle (has built-in error handling and retry logic) - schedule_next_wishlist_processing() # =============================== # == DATABASE UPDATER API == @@ -13644,10 +13536,7 @@ def get_wishlist_stats(): total_count = singles_count + albums_count # Calculate time until next auto-processing and get processing state - next_run_in_seconds = 0 - with wishlist_timer_lock: - if wishlist_next_run_time > 0: - next_run_in_seconds = max(0, int(wishlist_next_run_time - time.time())) + next_run_in_seconds = automation_engine.get_system_automation_next_run_seconds('process_wishlist') if automation_engine else 0 # Use smart function with stuck detection (not raw flag) is_processing = is_wishlist_actually_processing() @@ -15880,7 +15769,6 @@ def _process_failed_tracks_to_wishlist_exact_with_auto_completion(batch_id): with wishlist_timer_lock: wishlist_auto_processing = False wishlist_auto_processing_timestamp = 0 - # Don't clear wishlist_next_run_time here - let schedule function handle it atomically try: if automation_engine: @@ -15892,26 +15780,17 @@ def _process_failed_tracks_to_wishlist_exact_with_auto_completion(batch_id): 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 - return completion_summary - + except Exception as e: print(f"❌ [Auto-Wishlist] Error in auto-completion processing: {e}") import traceback traceback.print_exc() - + # Ensure auto-processing flag is reset even on error and reset timestamp with wishlist_timer_lock: wishlist_auto_processing = False wishlist_auto_processing_timestamp = 0 - # Don't clear wishlist_next_run_time here - let schedule function handle it atomically - - # Schedule next cycle even after error to maintain continuity (has built-in retry logic) - print("⏰ [Auto-Wishlist] Scheduling next cycle after error (30 minutes)") - schedule_next_wishlist_processing() # Has built-in error handling and retry return {'tracks_added': 0, 'errors': 1, 'total_failed': 0} @@ -16429,19 +16308,13 @@ def _run_full_missing_tracks_process(batch_id, playlist_id, tracks_json): youtube_playlist_states[url_hash]['phase'] = 'discovered' print(f"📋 Reset YouTube playlist {url_hash} to discovered phase (error)") - # Handle auto-initiated wishlist errors - reset flag and reschedule timer + # Handle auto-initiated wishlist errors - reset flag if is_auto_batch and playlist_id == 'wishlist': - print("❌ [Auto-Wishlist] Master worker error - resetting auto-processing flag and rescheduling timer") + print("❌ [Auto-Wishlist] Master worker error - resetting auto-processing flag") global wishlist_auto_processing, wishlist_auto_processing_timestamp with wishlist_timer_lock: wishlist_auto_processing = False wishlist_auto_processing_timestamp = 0 - try: - schedule_next_wishlist_processing() - except Exception as schedule_error: - print(f"❌ [CRITICAL] Failed to schedule next wishlist processing: {schedule_error}") - import traceback - traceback.print_exc() def _run_post_processing_worker(task_id, batch_id): """ @@ -18585,13 +18458,6 @@ def cancel_batch(batch_id): wishlist_auto_processing = False wishlist_auto_processing_timestamp = 0 print(f"🔓 [Wishlist Cancel] Reset wishlist auto-processing flag for cancelled auto-batch") - # Schedule next cycle since this one was cancelled - try: - schedule_next_wishlist_processing() - except Exception as schedule_error: - print(f"❌ [CRITICAL] Failed to schedule next wishlist processing: {schedule_error}") - import traceback - traceback.print_exc() else: print(f"â„šī¸ [Wishlist Cancel] Manual wishlist batch cancelled (no flag reset needed)") @@ -22659,10 +22525,7 @@ def get_watchlist_count(): count = database.get_watchlist_count(profile_id=get_current_profile_id()) # Calculate time until next auto-scanning - next_run_in_seconds = 0 - with watchlist_timer_lock: - if watchlist_next_run_time > 0: - next_run_in_seconds = max(0, int(watchlist_next_run_time - time.time())) + next_run_in_seconds = automation_engine.get_system_automation_next_run_seconds('scan_watchlist') if automation_engine else 0 return jsonify({ "success": True, @@ -23086,7 +22949,6 @@ def start_watchlist_scan(): with watchlist_timer_lock: watchlist_auto_scanning = False watchlist_auto_scanning_timestamp = 0 - watchlist_next_run_time = 0 # Clear timer for consistency return # Initialize scanner with MetadataService for cross-provider support @@ -23435,7 +23297,6 @@ def start_watchlist_scan(): with watchlist_timer_lock: watchlist_auto_scanning = False watchlist_auto_scanning_timestamp = 0 - watchlist_next_run_time = 0 # Clear timer for consistency print("🔓 [Manual Watchlist Scan] Flag reset - scan complete") # Initialize scan state @@ -23893,87 +23754,6 @@ watchlist_scan_state = { 'error': None } -def start_watchlist_auto_scanning(): - """Start automatic watchlist scanning with 5-minute initial delay (Timer-based like wishlist)""" - global watchlist_auto_timer, watchlist_next_run_time - - print("🚀 [Auto-Watchlist] Initializing automatic watchlist scanning...") - - with watchlist_timer_lock: - # Stop any existing timer to prevent duplicates - if watchlist_auto_timer is not None: - watchlist_auto_timer.cancel() - - print("🔄 Starting automatic watchlist scanning system (5 minute initial delay)") - watchlist_next_run_time = time.time() + 300.0 # Set timestamp for countdown display - watchlist_auto_timer = threading.Timer(300.0, _process_watchlist_scan_automatically) # 5 minutes - watchlist_auto_timer.daemon = True - watchlist_auto_timer.start() - print(f"✅ [Auto-Watchlist] Timer started successfully - will trigger in 5 minutes") - -def stop_watchlist_auto_scanning(): - """Stop automatic watchlist scanning and cleanup timer.""" - global watchlist_auto_timer, watchlist_auto_scanning - - with watchlist_timer_lock: - if watchlist_auto_timer is not None: - watchlist_auto_timer.cancel() - watchlist_auto_timer = None - print("âšī¸ Stopped automatic watchlist scanning") - - watchlist_auto_scanning = False - watchlist_auto_scanning_timestamp = 0 - -def schedule_next_watchlist_scan(retry_count=0, max_retries=3): - """ - Schedule next automatic watchlist scan in 24 hours. - Includes retry logic and atomic timer updates to prevent "0s" stuck state. - - Args: - retry_count: Current retry attempt (internal use) - max_retries: Maximum number of retry attempts - """ - global watchlist_auto_timer, watchlist_next_run_time - - try: - with watchlist_timer_lock: - # Cancel existing timer if present (prevent orphaned timers) - if watchlist_auto_timer is not None: - try: - watchlist_auto_timer.cancel() - except Exception as cancel_error: - print(f"âš ī¸ Failed to cancel old watchlist timer: {cancel_error}") - - # Calculate next run time BEFORE creating timer - next_time = time.time() + 86400.0 # 24 hours - - # Create and start new timer - new_timer = threading.Timer(86400.0, _process_watchlist_scan_automatically) - new_timer.daemon = True - new_timer.start() - - # Only update globals AFTER successful timer creation and start - watchlist_next_run_time = next_time - watchlist_auto_timer = new_timer - - print(f"⏰ Scheduled next watchlist scan in 24 hours") - - except Exception as e: - print(f"❌ [CRITICAL] Failed to schedule watchlist scan (attempt {retry_count + 1}/{max_retries}): {e}") - import traceback - traceback.print_exc() - - # Retry with exponential backoff - if retry_count < max_retries: - retry_delay = 5 * (2 ** retry_count) # 5s, 10s, 20s - print(f"🔄 Retrying watchlist scheduling in {retry_delay} seconds...") - retry_timer = threading.Timer(retry_delay, lambda: schedule_next_watchlist_scan(retry_count + 1, max_retries)) - retry_timer.daemon = True - retry_timer.start() - else: - print(f"❌ [FATAL] Failed to schedule watchlist scan after {max_retries} attempts!") - print("âš ī¸ MANUAL INTERVENTION REQUIRED - Watchlist auto-scanning will not run!") - def _process_watchlist_scan_automatically(): """Main automatic scanning logic that runs in background thread.""" global watchlist_auto_scanning, watchlist_auto_scanning_timestamp, watchlist_scan_state @@ -23988,25 +23768,12 @@ def _process_watchlist_scan_automatically(): # This prevents deadlock and handles stuck flags (2-hour timeout) if is_watchlist_actually_scanning(): print("âš ī¸ [Auto-Watchlist] Already scanning (verified with stuck detection), skipping.") - schedule_next_watchlist_scan() return with watchlist_timer_lock: # Re-check inside lock to handle race conditions if watchlist_auto_scanning: print("âš ī¸ [Auto-Watchlist] Already scanning (race condition check), skipping.") - schedule_next_watchlist_scan() - return - - # Check if wishlist processing is currently running (using smart detection) - if is_wishlist_actually_processing(): - print("đŸŽĩ Wishlist processing in progress, rescheduling watchlist scan for 10 minutes from now") - # Smart retry: don't wait 24 hours, just wait 10 minutes and try again - global watchlist_auto_timer, watchlist_next_run_time - watchlist_next_run_time = time.time() + 600.0 # Set timestamp for countdown display - watchlist_auto_timer = threading.Timer(600.0, _process_watchlist_scan_automatically) # 10 minutes - watchlist_auto_timer.daemon = True - watchlist_auto_timer.start() return # Set flag and timestamp @@ -24032,8 +23799,6 @@ def _process_watchlist_scan_automatically(): with watchlist_timer_lock: watchlist_auto_scanning = False watchlist_auto_scanning_timestamp = 0 - watchlist_next_run_time = 0 # Clear old timer before rescheduling - schedule_next_watchlist_scan() return if not spotify_client or not spotify_client.is_authenticated(): @@ -24041,8 +23806,6 @@ def _process_watchlist_scan_automatically(): with watchlist_timer_lock: watchlist_auto_scanning = False watchlist_auto_scanning_timestamp = 0 - watchlist_next_run_time = 0 # Clear old timer before rescheduling - schedule_next_watchlist_scan() return print(f"đŸ‘ī¸ [Auto-Watchlist] Found {watchlist_count} artists in watchlist, starting automatic scan...") @@ -24398,14 +24161,12 @@ def _process_watchlist_scan_automatically(): itunes_enrichment_worker.resume() print("â–ļī¸ [Auto-Watchlist] Resumed iTunes enrichment worker after scan") - # Always reset flag and schedule next scan + # Always reset flag with watchlist_timer_lock: watchlist_auto_scanning = False watchlist_auto_scanning_timestamp = 0 - watchlist_next_run_time = 0 # Clear old timer before rescheduling - schedule_next_watchlist_scan() - print("✅ Automatic watchlist scanning initialized") + print("✅ Automatic watchlist scanning complete") # --- Metadata Updater System --- @@ -31289,10 +31050,7 @@ def _build_watchlist_count_payload(profile_id=1): count = database.get_watchlist_count(profile_id=profile_id) except Exception: count = 0 - next_run_in_seconds = 0 - with watchlist_timer_lock: - if watchlist_next_run_time > 0: - next_run_in_seconds = max(0, int(watchlist_next_run_time - time.time())) + next_run_in_seconds = automation_engine.get_system_automation_next_run_seconds('scan_watchlist') if automation_engine else 0 return { 'success': True, 'count': count, @@ -31620,10 +31378,7 @@ def _emit_scan_status_loop(): logger.debug(f"Error emitting media scan: {e}") # Wishlist stats (auto-processing detection + countdown refresh) try: - next_run = 0 - with wishlist_timer_lock: - if wishlist_next_run_time > 0: - next_run = max(0, int(wishlist_next_run_time - time.time())) + next_run = automation_engine.get_system_automation_next_run_seconds('process_wishlist') if automation_engine else 0 socketio.emit('wishlist:stats', { "is_auto_processing": is_wishlist_actually_processing(), "next_run_in_seconds": next_run, @@ -31663,16 +31418,8 @@ if __name__ == '__main__': start_simple_background_monitor() print("✅ Simple background monitor started (includes automatic search cleanup)") - # Start automatic wishlist processing when server starts - print("🔧 Starting automatic wishlist processing...") - start_wishlist_auto_processing() - print("✅ Automatic wishlist processing started (1 minute initial delay, 30 minute cycles)") + # Wishlist/watchlist timers are now managed by AutomationEngine system automations - # Start automatic watchlist scanning when server starts - print("🔧 Starting automatic watchlist scanning...") - start_watchlist_auto_scanning() - print("✅ Automatic watchlist scanning started (5 minute initial delay, 24 hour cycles)") - # Pre-build import suggestions cache in background print("🔧 Pre-building import suggestions cache...") start_import_suggestions_cache() diff --git a/webui/static/script.js b/webui/static/script.js index 11a50b9b..c364fb5f 100644 --- a/webui/static/script.js +++ b/webui/static/script.js @@ -42137,7 +42137,7 @@ const _autoIcons = { 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', + notify_only: '\uD83D\uDD14', discord_webhook: '\uD83D\uDCAC', pushbullet: '\uD83D\uDD14', telegram: '\u2709\uFE0F', // Phase 3 wishlist_processing_completed: '\u2705', watchlist_scan_completed: '\u2705', database_update_completed: '\uD83D\uDDC4\uFE0F', download_failed: '\u274C', @@ -42191,7 +42191,7 @@ async function loadAutomations() { function renderAutomationCard(a) { const card = document.createElement('div'); - card.className = 'automation-card' + (a.enabled ? '' : ' disabled'); + card.className = 'automation-card' + (a.enabled ? '' : ' disabled') + (a.is_system ? ' system' : ''); card.dataset.id = a.id; const tIcon = _autoIcons[a.trigger_type] || '\u2699\uFE0F'; const aIcon = _autoIcons[a.action_type] || '\u2699\uFE0F'; @@ -42200,6 +42200,7 @@ function renderAutomationCard(a) { 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.is_system) metaParts.push('System'); 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)); @@ -42207,6 +42208,9 @@ function renderAutomationCard(a) { if (a.run_count) metaParts.push('Runs: ' + a.run_count); if (a.last_error) metaParts.push('Error: ' + _esc(a.last_error)); + const deleteBtn = a.is_system ? '' : + ``; + card.innerHTML = `
@@ -42227,7 +42231,7 @@ function renderAutomationCard(a) { - + ${deleteBtn}
`; return card; @@ -42269,6 +42273,8 @@ function _autoFormatAction(type) { } function _autoFormatNotify(type) { if (type === 'discord_webhook') return 'Discord'; + if (type === 'pushbullet') return 'Pushbullet'; + if (type === 'telegram') return 'Telegram'; return type || ''; } function _autoTimeAgo(ts) { @@ -42368,7 +42374,7 @@ async function showAutomationBuilder(editId) { } _autoMirroredPlaylists = null; // invalidate so it re-fetches - _autoBuilder = { editId: editId || null, when: null, do: null, notify: null }; + _autoBuilder = { editId: editId || null, when: null, do: null, notify: null, isSystem: false }; // If editing, load automation data if (editId) { @@ -42380,11 +42386,15 @@ async function showAutomationBuilder(editId) { _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 || {} }; + _autoBuilder.isSystem = !!a.is_system; } catch (err) { showToast('Failed to load automation', 'error'); return; } } else { document.getElementById('builder-name').value = ''; } + // System automations: lock the name field + document.getElementById('builder-name').readOnly = _autoBuilder.isSystem; + _renderBuilderSidebar(); _renderBuilderCanvas(); @@ -42395,7 +42405,8 @@ async function showAutomationBuilder(editId) { 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 }; + document.getElementById('builder-name').readOnly = false; + _autoBuilder = { editId: null, when: null, do: null, notify: null, isSystem: false }; } // --- Sidebar --- @@ -42446,14 +42457,13 @@ function _renderBuilderCanvas() { slots.forEach((slot, i) => { if (i > 0) html += '
'; const data = _autoBuilder[slot.key]; + html += `${slot.label}`; if (data) { html += `
- ${slot.label} ${_renderPlacedBlock(slot.key, data)}
`; } else { html += `
- ${slot.label}
${slot.prompt}
`; } @@ -42485,11 +42495,15 @@ function _renderPlacedBlock(slotKey, data) { `; } - return `
+ // System automations: lock trigger and action slots (no remove, no replace) + const locked = _autoBuilder.isSystem && (slotKey === 'when' || slotKey === 'do'); + const removeBtn = locked ? '' : ``; + + return `
${icon} ${_esc(label)} - + ${removeBtn}
${configHtml ? '
' + configHtml + '
' : ''} ${delayHtml} @@ -42573,26 +42587,62 @@ function _renderBlockConfigFields(slotKey, blockType, config) {
`; } - if (blockType === 'discord_webhook') { - const url = _escAttr(config.webhook_url || ''); - // Merge base variables with trigger-specific variables + // Shared variable tags builder for notification types + function _notifyVarHtml(slotKey) { 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 += '
'; + let html = '
'; + allVars.forEach(v => { html += `{${v}}`; }); + return html + '
'; + } + + if (blockType === 'discord_webhook') { + const url = _escAttr(config.webhook_url || ''); return `
- +
- ${varHtml}`; + ${_notifyVarHtml(slotKey)}`; + } + if (blockType === 'pushbullet') { + const token = _escAttr(config.access_token || ''); + return `
+ + +
+
+ + +
+
+ + +
+ ${_notifyVarHtml(slotKey)}`; + } + if (blockType === 'telegram') { + const botToken = _escAttr(config.bot_token || ''); + const chatId = _escAttr(config.chat_id || ''); + return `
+ + +
+
+ + +
+
+ + +
+ ${_notifyVarHtml(slotKey)}`; } return ''; } @@ -42774,6 +42824,20 @@ function _readPlacedConfig(slotKey) { message: document.getElementById('cfg-' + slotKey + '-message')?.value || '', }; } + if (type === 'pushbullet') { + return { + access_token: document.getElementById('cfg-' + slotKey + '-access_token')?.value?.trim() || '', + title: document.getElementById('cfg-' + slotKey + '-title')?.value || '', + message: document.getElementById('cfg-' + slotKey + '-message')?.value || '', + }; + } + if (type === 'telegram') { + return { + bot_token: document.getElementById('cfg-' + slotKey + '-bot_token')?.value?.trim() || '', + chat_id: document.getElementById('cfg-' + slotKey + '-chat_id')?.value?.trim() || '', + message: document.getElementById('cfg-' + slotKey + '-message')?.value || '', + }; + } return {}; } @@ -42806,6 +42870,7 @@ function _autoDragLeave(e, slotKey) { function _autoDrop(e, slotKey) { e.preventDefault(); document.getElementById('slot-' + slotKey)?.classList.remove('drag-over'); + if (_autoBuilder.isSystem && (slotKey === 'when' || slotKey === 'do')) return; try { const data = JSON.parse(e.dataTransfer.getData('text/plain')); if (data.slot !== slotKey) { showToast('Wrong slot — drop ' + data.slot + ' blocks here', 'error'); return; } @@ -42816,11 +42881,13 @@ function _autoDrop(e, slotKey) { // Click-to-add (alternative to drag) function _autoClickBlock(blockType, slotCategory) { + if (_autoBuilder.isSystem && (slotCategory === 'when' || slotCategory === 'do')) return; _autoBuilder[slotCategory] = { type: blockType, config: {} }; _renderBuilderCanvas(); } function _autoRemoveBlock(slotKey) { + if (_autoBuilder.isSystem && (slotKey === 'when' || slotKey === 'do')) return; _autoBuilder[slotKey] = null; _renderBuilderCanvas(); } diff --git a/webui/static/style.css b/webui/static/style.css index 77a969c5..9e08d57a 100644 --- a/webui/static/style.css +++ b/webui/static/style.css @@ -22748,7 +22748,7 @@ body { transform: scale(1.1); } -.toggle-slider { +.search-mode-toggle .toggle-slider { position: absolute; top: 4px; left: 4px; @@ -28333,46 +28333,31 @@ body { /* --- 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); + background: rgba(22, 22, 22, 0.95); + border-radius: 12px; border: 1px solid rgba(255, 255, 255, 0.08); + margin: 6px 0; padding: 12px 16px; + display: flex; align-items: center; gap: 12px; + transition: all 0.2s ease; 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; + box-shadow: 0 2px 8px rgba(0,0,0,0.3); } .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); + background: rgba(28, 28, 28, 0.98); + border-color: rgba(var(--accent-rgb), 0.25); + box-shadow: 0 4px 16px rgba(0,0,0,0.4); } .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-card.system { border-left: 3px solid var(--accent, #6366f1); } +.system-badge { display: inline-block; font-size: 10px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.5px; padding: 1px 6px; border-radius: 8px; background: rgba(99,102,241,0.2); color: #818cf8; vertical-align: middle; } .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-info { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 3px; } +.automation-name { font-size: 14px; 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; @@ -28388,30 +28373,33 @@ body { .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-actions { display: flex; align-items: center; gap: 6px; 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; + border-radius: 8px; width: 30px; height: 30px; 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); + cursor: pointer; font-size: 13px; 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 switch */ +.automation-toggle { position: relative; display: inline-block; width: 36px; height: 20px; } .automation-toggle input { opacity: 0; width: 0; height: 0; } -.toggle-slider { +.automation-toggle .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); + width: auto; height: auto; + background: rgba(255,255,255,0.1); border-radius: 20px; transition: all 0.3s ease; border: 1px solid rgba(255,255,255,0.15); + box-shadow: none; z-index: auto; } -.toggle-slider::before { - content: ''; position: absolute; height: 16px; width: 16px; left: 2px; bottom: 2px; +.automation-toggle .toggle-slider::before { + content: ''; position: absolute; height: 14px; width: 14px; left: 2px; bottom: 2px; background: rgba(255,255,255,0.6); border-radius: 50%; transition: all 0.3s ease; + transform: none; } .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)); } +.automation-toggle input:checked + .toggle-slider::before { transform: translateX(16px); background: rgb(var(--accent-light-rgb)); } /* --- Empty State --- */ .automations-empty { @@ -28534,9 +28522,8 @@ body { } .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; + font-size: 10px; font-weight: 700; text-transform: uppercase; letter-spacing: 1px; + padding: 3px 10px; border-radius: 6px; margin-bottom: 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; } @@ -28551,6 +28538,8 @@ body { box-shadow: 0 4px 16px rgba(0,0,0,0.3); } .placed-block:hover { border-color: rgba(255,255,255,0.18); } +.placed-block.locked { border-color: rgba(var(--accent-rgb),0.2); opacity: 0.85; } +.placed-block.locked .placed-block-header::after { content: 'Locked'; font-size: 10px; color: rgba(255,255,255,0.3); text-transform: uppercase; letter-spacing: 0.5px; } .placed-block-header { display: flex; align-items: center; gap: 10px; padding: 14px 16px; border-bottom: 1px solid rgba(255,255,255,0.06);