Replace hardcoded wishlist/watchlist timers with system automations + add Pushbullet & Telegram notifications
This commit is contained in:
parent
da707dcf0a
commit
f99f873d60
5 changed files with 283 additions and 348 deletions
|
|
@ -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]}")
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
317
web_server.py
317
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/<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()
|
||||
|
|
|
|||
|
|
@ -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)}')">🗑</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})">⚙</button>
|
||||
<button class="automation-delete-btn" title="Delete" onclick="event.stopPropagation(); deleteAutomation(${a.id}, '${_escAttr(a.name)}')">🗑</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();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
Loading…
Reference in a new issue