Replace hardcoded wishlist/watchlist timers with system automations + add Pushbullet & Telegram notifications

This commit is contained in:
Broque Thomas 2026-03-05 16:50:09 -08:00
parent da707dcf0a
commit f99f873d60
5 changed files with 283 additions and 348 deletions

View file

@ -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]}")

View file

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

View file

@ -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/<int:automation_id>', 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()

View file

@ -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('<span class="system-badge">System</span>');
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 ? '' :
`<button class="automation-delete-btn" title="Delete" onclick="event.stopPropagation(); deleteAutomation(${a.id}, '${_escAttr(a.name)}')">&#128465;</button>`;
card.innerHTML = `
<div class="automation-status ${a.enabled ? 'enabled' : 'disabled'}"></div>
<div class="automation-info">
@ -42227,7 +42231,7 @@ function renderAutomationCard(a) {
<span class="toggle-slider"></span>
</label>
<button class="automation-edit-btn" title="Edit" onclick="event.stopPropagation(); showAutomationBuilder(${a.id})">&#9881;</button>
<button class="automation-delete-btn" title="Delete" onclick="event.stopPropagation(); deleteAutomation(${a.id}, '${_escAttr(a.name)}')">&#128465;</button>
${deleteBtn}
</div>
`;
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 += '<div class="flow-connector"></div>';
const data = _autoBuilder[slot.key];
html += `<span class="flow-slot-label ${slot.labelClass}">${slot.label}</span>`;
if (data) {
html += `<div class="flow-slot filled" id="slot-${slot.key}" ondragover="_autoDragOver(event,'${slot.key}')" ondragleave="_autoDragLeave(event,'${slot.key}')" ondrop="_autoDrop(event,'${slot.key}')">
<span class="flow-slot-label ${slot.labelClass}">${slot.label}</span>
${_renderPlacedBlock(slot.key, data)}
</div>`;
} else {
html += `<div class="flow-slot empty" id="slot-${slot.key}" ondragover="_autoDragOver(event,'${slot.key}')" ondragleave="_autoDragLeave(event,'${slot.key}')" ondrop="_autoDrop(event,'${slot.key}')">
<span class="flow-slot-label ${slot.labelClass}">${slot.label}</span>
<div class="flow-slot-prompt">${slot.prompt}</div>
</div>`;
}
@ -42485,11 +42495,15 @@ function _renderPlacedBlock(slotKey, data) {
</div></div>`;
}
return `<div class="placed-block" data-type="${_escAttr(data.type)}">
// System automations: lock trigger and action slots (no remove, no replace)
const locked = _autoBuilder.isSystem && (slotKey === 'when' || slotKey === 'do');
const removeBtn = locked ? '' : `<button class="placed-block-remove" onclick="_autoRemoveBlock('${slotKey}')">\u2715</button>`;
return `<div class="placed-block${locked ? ' locked' : ''}" data-type="${_escAttr(data.type)}">
<div class="placed-block-header">
<span class="placed-block-icon">${icon}</span>
<span class="placed-block-label">${_esc(label)}</span>
<button class="placed-block-remove" onclick="_autoRemoveBlock('${slotKey}')">\u2715</button>
${removeBtn}
</div>
${configHtml ? '<div class="placed-block-config">' + configHtml + '</div>' : ''}
${delayHtml}
@ -42573,26 +42587,62 @@ function _renderBlockConfigFields(slotKey, blockType, config) {
</select>
</div>`;
}
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 = '<div class="variable-tags">';
allVars.forEach(v => { varHtml += `<span class="variable-tag" onclick="_autoInsertVar('cfg-${slotKey}-message','{${v}}')">{${v}}</span>`; });
varHtml += '</div>';
let html = '<div class="variable-tags">';
allVars.forEach(v => { html += `<span class="variable-tag" onclick="_autoInsertVar('cfg-${slotKey}-message','{${v}}')">{${v}}</span>`; });
return html + '</div>';
}
if (blockType === 'discord_webhook') {
const url = _escAttr(config.webhook_url || '');
return `<div class="config-row">
<label>URL</label>
<label>Webhook URL</label>
<input type="text" id="cfg-${slotKey}-webhook_url" value="${url}" placeholder="https://discord.com/api/webhooks/...">
</div>
<div class="config-row">
<label>Message</label>
<textarea id="cfg-${slotKey}-message" placeholder="Message with {variables}...">${config.message || '{name} completed with status: {status}'}</textarea>
</div>
${varHtml}`;
${_notifyVarHtml(slotKey)}`;
}
if (blockType === 'pushbullet') {
const token = _escAttr(config.access_token || '');
return `<div class="config-row">
<label>Access Token</label>
<input type="text" id="cfg-${slotKey}-access_token" value="${token}" placeholder="o.xxxxxxxxxxxxxxxxxxxx">
</div>
<div class="config-row">
<label>Title</label>
<input type="text" id="cfg-${slotKey}-title" value="${_escAttr(config.title || '{name}')}" placeholder="Notification title">
</div>
<div class="config-row">
<label>Message</label>
<textarea id="cfg-${slotKey}-message" placeholder="Message with {variables}...">${config.message || 'Completed with status: {status}'}</textarea>
</div>
${_notifyVarHtml(slotKey)}`;
}
if (blockType === 'telegram') {
const botToken = _escAttr(config.bot_token || '');
const chatId = _escAttr(config.chat_id || '');
return `<div class="config-row">
<label>Bot Token</label>
<input type="text" id="cfg-${slotKey}-bot_token" value="${botToken}" placeholder="123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11">
</div>
<div class="config-row">
<label>Chat ID</label>
<input type="text" id="cfg-${slotKey}-chat_id" value="${chatId}" placeholder="-1001234567890 or @channelname">
</div>
<div class="config-row">
<label>Message</label>
<textarea id="cfg-${slotKey}-message" placeholder="Message with {variables}...">${config.message || '{name} completed with status: {status}'}</textarea>
</div>
${_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();
}

View file

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