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")
|
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:
|
class AutomationEngine:
|
||||||
def __init__(self, db):
|
def __init__(self, db):
|
||||||
|
|
@ -56,12 +73,51 @@ class AutomationEngine:
|
||||||
}
|
}
|
||||||
logger.debug(f"Registered action handler: {action_type}")
|
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 ---
|
# --- Lifecycle ---
|
||||||
|
|
||||||
def start(self):
|
def start(self):
|
||||||
"""Load all enabled automations from DB and schedule them."""
|
"""Load all enabled automations from DB and schedule them."""
|
||||||
self._running = True
|
self._running = True
|
||||||
self._event_cache_dirty = True
|
self._event_cache_dirty = True
|
||||||
|
self.ensure_system_automations()
|
||||||
automations = self.db.get_automations()
|
automations = self.db.get_automations()
|
||||||
scheduled = 0
|
scheduled = 0
|
||||||
event_count = 0
|
event_count = 0
|
||||||
|
|
@ -510,6 +566,10 @@ class AutomationEngine:
|
||||||
|
|
||||||
if notify_type == 'discord_webhook':
|
if notify_type == 'discord_webhook':
|
||||||
self._send_discord_notification(config, variables)
|
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):
|
def _send_discord_notification(self, config, variables):
|
||||||
"""POST to Discord webhook with template variable substitution."""
|
"""POST to Discord webhook with template variable substitution."""
|
||||||
|
|
@ -526,3 +586,46 @@ class AutomationEngine:
|
||||||
resp = requests.post(url, json={"content": message}, timeout=10)
|
resp = requests.post(url, json={"content": message}, timeout=10)
|
||||||
if resp.status_code not in (200, 204):
|
if resp.status_code not in (200, 204):
|
||||||
raise RuntimeError(f"Discord webhook returned {resp.status_code}: {resp.text[:200]}")
|
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)
|
# Add notification columns to automations (migration)
|
||||||
self._add_automation_notify_columns(cursor)
|
self._add_automation_notify_columns(cursor)
|
||||||
|
self._add_automation_system_column(cursor)
|
||||||
|
|
||||||
conn.commit()
|
conn.commit()
|
||||||
logger.info("Database initialized successfully")
|
logger.info("Database initialized successfully")
|
||||||
|
|
@ -419,6 +420,17 @@ class MusicDatabase:
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error adding automation notify columns: {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):
|
def _add_server_source_columns(self, cursor):
|
||||||
"""Add server_source columns to existing tables for multi-server support"""
|
"""Add server_source columns to existing tables for multi-server support"""
|
||||||
try:
|
try:
|
||||||
|
|
@ -6542,12 +6554,12 @@ class MusicDatabase:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def get_automations(self, profile_id: int = 1):
|
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:
|
try:
|
||||||
with self._get_connection() as conn:
|
with self._get_connection() as conn:
|
||||||
cursor = conn.cursor()
|
cursor = conn.cursor()
|
||||||
cursor.execute("""
|
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,))
|
""", (profile_id,))
|
||||||
rows = cursor.fetchall()
|
rows = cursor.fetchall()
|
||||||
return [dict(row) for row in rows]
|
return [dict(row) for row in rows]
|
||||||
|
|
@ -6555,6 +6567,18 @@ class MusicDatabase:
|
||||||
logger.error(f"Error getting automations: {e}")
|
logger.error(f"Error getting automations: {e}")
|
||||||
return []
|
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):
|
def get_automation(self, automation_id: int):
|
||||||
"""Get a single automation by ID."""
|
"""Get a single automation by ID."""
|
||||||
try:
|
try:
|
||||||
|
|
@ -6569,7 +6593,7 @@ class MusicDatabase:
|
||||||
|
|
||||||
def update_automation(self, automation_id: int, **kwargs) -> bool:
|
def update_automation(self, automation_id: int, **kwargs) -> bool:
|
||||||
"""Update automation fields."""
|
"""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}
|
updates = {k: v for k, v in kwargs.items() if k in allowed}
|
||||||
if not updates:
|
if not updates:
|
||||||
return False
|
return False
|
||||||
|
|
@ -6589,10 +6613,15 @@ class MusicDatabase:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
def delete_automation(self, automation_id: int) -> bool:
|
def delete_automation(self, automation_id: int) -> bool:
|
||||||
"""Delete an automation."""
|
"""Delete an automation. System automations cannot be deleted."""
|
||||||
try:
|
try:
|
||||||
with self._get_connection() as conn:
|
with self._get_connection() as conn:
|
||||||
cursor = conn.cursor()
|
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,))
|
cursor.execute("DELETE FROM automations WHERE id = ?", (automation_id,))
|
||||||
conn.commit()
|
conn.commit()
|
||||||
return cursor.rowcount > 0
|
return cursor.rowcount > 0
|
||||||
|
|
|
||||||
317
web_server.py
317
web_server.py
|
|
@ -269,16 +269,12 @@ def _register_automation_handlers():
|
||||||
return
|
return
|
||||||
|
|
||||||
def _auto_process_wishlist(config):
|
def _auto_process_wishlist(config):
|
||||||
if is_wishlist_actually_processing():
|
_process_wishlist_automatically()
|
||||||
return {'status': 'skipped', 'reason': 'Wishlist already processing'}
|
return {'status': 'completed'}
|
||||||
threading.Thread(target=_process_wishlist_automatically, daemon=True, name='auto-wishlist').start()
|
|
||||||
return {'status': 'started', 'category': config.get('category', 'all')}
|
|
||||||
|
|
||||||
def _auto_scan_watchlist(config):
|
def _auto_scan_watchlist(config):
|
||||||
if is_watchlist_actually_scanning():
|
_process_watchlist_scan_automatically()
|
||||||
return {'status': 'skipped', 'reason': 'Watchlist already scanning'}
|
return {'status': 'completed'}
|
||||||
threading.Thread(target=_process_watchlist_scan_automatically, daemon=True, name='auto-watchlist').start()
|
|
||||||
return {'status': 'started'}
|
|
||||||
|
|
||||||
def _auto_scan_library(config):
|
def _auto_scan_library(config):
|
||||||
if web_scan_manager:
|
if web_scan_manager:
|
||||||
|
|
@ -286,8 +282,9 @@ def _register_automation_handlers():
|
||||||
return {'status': result.get('status', 'unknown')}
|
return {'status': result.get('status', 'unknown')}
|
||||||
return {'status': 'error', 'reason': 'Scan manager not available'}
|
return {'status': 'error', 'reason': 'Scan manager not available'}
|
||||||
|
|
||||||
automation_engine.register_action_handler('process_wishlist', _auto_process_wishlist, is_wishlist_actually_processing)
|
cross_guard = lambda: is_wishlist_actually_processing() or is_watchlist_actually_scanning()
|
||||||
automation_engine.register_action_handler('scan_watchlist', _auto_scan_watchlist, 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)
|
automation_engine.register_action_handler('scan_library', _auto_scan_library)
|
||||||
|
|
||||||
def _auto_refresh_mirrored(config):
|
def _auto_refresh_mirrored(config):
|
||||||
|
|
@ -670,20 +667,15 @@ def _mark_task_completed(task_id, track_info=None):
|
||||||
|
|
||||||
# --- Automatic Wishlist Processing Infrastructure ---
|
# --- Automatic Wishlist Processing Infrastructure ---
|
||||||
# Server-side timer system for automatic wishlist processing (replaces client-side JavaScript timers)
|
# Server-side timer system for automatic wishlist processing (replaces client-side JavaScript timers)
|
||||||
wishlist_auto_processor = ThreadPoolExecutor(max_workers=1, thread_name_prefix="WishlistAutoProcessor")
|
# --- Automatic Wishlist/Watchlist Processing Flags ---
|
||||||
wishlist_auto_timer = None # threading.Timer for scheduling next auto-processing
|
# Processing state flags (guards/recovery — timers are now managed by AutomationEngine)
|
||||||
wishlist_auto_processing = False # Flag to prevent concurrent auto-processing
|
wishlist_auto_processing = False # Flag to prevent concurrent auto-processing
|
||||||
wishlist_auto_processing_timestamp = 0 # Timestamp when processing started (for stuck detection)
|
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 flag operations
|
||||||
wishlist_timer_lock = threading.Lock() # Thread safety for timer 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 = False # Flag to prevent concurrent auto-scanning
|
||||||
watchlist_auto_scanning_timestamp = 0 # Timestamp when scanning started (for stuck detection)
|
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 flag operations
|
||||||
watchlist_timer_lock = threading.Lock() # Thread safety for timer operations
|
|
||||||
|
|
||||||
# --- Shared Transfer Data Cache ---
|
# --- Shared Transfer Data Cache ---
|
||||||
# Cache transfer data to avoid hammering the Soulseek API with multiple concurrent modals
|
# 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'])
|
@app.route('/api/automations/<int:automation_id>', methods=['DELETE'])
|
||||||
def delete_automation_endpoint(automation_id):
|
def delete_automation_endpoint(automation_id):
|
||||||
"""Delete an automation."""
|
"""Delete an automation. System automations cannot be deleted."""
|
||||||
try:
|
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:
|
if automation_engine:
|
||||||
automation_engine.cancel_automation(automation_id)
|
automation_engine.cancel_automation(automation_id)
|
||||||
db = get_database()
|
|
||||||
success = db.delete_automation(automation_id)
|
success = db.delete_automation(automation_id)
|
||||||
if not success:
|
if not success:
|
||||||
return jsonify({"error": "Automation not found"}), 404
|
return jsonify({"error": "Automation not found"}), 404
|
||||||
|
|
@ -3668,6 +3663,10 @@ def get_automation_blocks():
|
||||||
"notifications": [
|
"notifications": [
|
||||||
{"type": "discord_webhook", "label": "Discord Webhook", "icon": "message", "description": "Send a Discord notification", "available": True,
|
{"type": "discord_webhook", "label": "Discord Webhook", "icon": "message", "description": "Send a Discord notification", "available": True,
|
||||||
"variables": ["time", "name", "run_count", "status"]},
|
"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 = False
|
||||||
wishlist_auto_processing_timestamp = 0
|
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
|
return True
|
||||||
|
|
||||||
# Check watchlist flag
|
# Check watchlist flag
|
||||||
|
|
@ -13006,13 +12999,6 @@ def check_and_recover_stuck_flags():
|
||||||
with watchlist_timer_lock:
|
with watchlist_timer_lock:
|
||||||
watchlist_auto_scanning = False
|
watchlist_auto_scanning = False
|
||||||
watchlist_auto_scanning_timestamp = 0
|
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 True
|
||||||
|
|
||||||
return False
|
return False
|
||||||
|
|
@ -13063,88 +13049,6 @@ def is_watchlist_actually_scanning():
|
||||||
|
|
||||||
return True
|
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):
|
def _classify_wishlist_track(track):
|
||||||
"""Classify a wishlist track as 'singles' or 'albums'.
|
"""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)
|
# This prevents deadlock and handles stuck flags (2-hour timeout)
|
||||||
if is_wishlist_actually_processing():
|
if is_wishlist_actually_processing():
|
||||||
print("⚠️ [Auto-Wishlist] Already processing (verified with stuck detection), skipping.")
|
print("⚠️ [Auto-Wishlist] Already processing (verified with stuck detection), skipping.")
|
||||||
schedule_next_wishlist_processing()
|
|
||||||
return
|
return
|
||||||
|
|
||||||
# Check conditions and set flag
|
# Check conditions and set flag
|
||||||
|
|
@ -13218,9 +13121,7 @@ def _process_wishlist_automatically():
|
||||||
wishlist_auto_processing_timestamp = time.time()
|
wishlist_auto_processing_timestamp = time.time()
|
||||||
print(f"🔒 [Auto-Wishlist] Flag set at timestamp {wishlist_auto_processing_timestamp}")
|
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:
|
if should_skip_already_running or should_skip_watchlist_conflict:
|
||||||
schedule_next_wishlist_processing()
|
|
||||||
return
|
return
|
||||||
|
|
||||||
# Use app context for database operations
|
# Use app context for database operations
|
||||||
|
|
@ -13238,8 +13139,6 @@ def _process_wishlist_automatically():
|
||||||
with wishlist_timer_lock:
|
with wishlist_timer_lock:
|
||||||
wishlist_auto_processing = False
|
wishlist_auto_processing = False
|
||||||
wishlist_auto_processing_timestamp = 0
|
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
|
return
|
||||||
|
|
||||||
print(f"🎵 [Auto-Wishlist] Found {count} tracks in wishlist, starting automatic processing...")
|
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")
|
print(f"⚠️ Wishlist processing already active in another batch ({batch_playlist_id}), skipping automatic start")
|
||||||
with wishlist_timer_lock:
|
with wishlist_timer_lock:
|
||||||
wishlist_auto_processing = False
|
wishlist_auto_processing = False
|
||||||
schedule_next_wishlist_processing()
|
|
||||||
return
|
return
|
||||||
|
|
||||||
# CRITICAL: Clean duplicates BEFORE fetching tracks to prevent count mismatches
|
# CRITICAL: Clean duplicates BEFORE fetching tracks to prevent count mismatches
|
||||||
|
|
@ -13330,9 +13228,8 @@ def _process_wishlist_automatically():
|
||||||
with wishlist_timer_lock:
|
with wishlist_timer_lock:
|
||||||
wishlist_auto_processing = False
|
wishlist_auto_processing = False
|
||||||
wishlist_auto_processing_timestamp = 0
|
wishlist_auto_processing_timestamp = 0
|
||||||
schedule_next_wishlist_processing()
|
|
||||||
return
|
return
|
||||||
|
|
||||||
# SANITIZE: Ensure consistent data format from wishlist service
|
# SANITIZE: Ensure consistent data format from wishlist service
|
||||||
wishlist_tracks = []
|
wishlist_tracks = []
|
||||||
seen_track_ids_sanitation = set() # Deduplicate during sanitization
|
seen_track_ids_sanitation = set() # Deduplicate during sanitization
|
||||||
|
|
@ -13411,7 +13308,6 @@ def _process_wishlist_automatically():
|
||||||
with wishlist_timer_lock:
|
with wishlist_timer_lock:
|
||||||
wishlist_auto_processing = False
|
wishlist_auto_processing = False
|
||||||
wishlist_auto_processing_timestamp = 0
|
wishlist_auto_processing_timestamp = 0
|
||||||
schedule_next_wishlist_processing()
|
|
||||||
return
|
return
|
||||||
|
|
||||||
# Use filtered tracks for processing
|
# Use filtered tracks for processing
|
||||||
|
|
@ -13463,10 +13359,6 @@ def _process_wishlist_automatically():
|
||||||
with wishlist_timer_lock:
|
with wishlist_timer_lock:
|
||||||
wishlist_auto_processing = False
|
wishlist_auto_processing = False
|
||||||
wishlist_auto_processing_timestamp = 0
|
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 ==
|
# == DATABASE UPDATER API ==
|
||||||
|
|
@ -13644,10 +13536,7 @@ def get_wishlist_stats():
|
||||||
total_count = singles_count + albums_count
|
total_count = singles_count + albums_count
|
||||||
|
|
||||||
# Calculate time until next auto-processing and get processing state
|
# Calculate time until next auto-processing and get processing state
|
||||||
next_run_in_seconds = 0
|
next_run_in_seconds = automation_engine.get_system_automation_next_run_seconds('process_wishlist') if automation_engine else 0
|
||||||
with wishlist_timer_lock:
|
|
||||||
if wishlist_next_run_time > 0:
|
|
||||||
next_run_in_seconds = max(0, int(wishlist_next_run_time - time.time()))
|
|
||||||
|
|
||||||
# Use smart function with stuck detection (not raw flag)
|
# Use smart function with stuck detection (not raw flag)
|
||||||
is_processing = is_wishlist_actually_processing()
|
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:
|
with wishlist_timer_lock:
|
||||||
wishlist_auto_processing = False
|
wishlist_auto_processing = False
|
||||||
wishlist_auto_processing_timestamp = 0
|
wishlist_auto_processing_timestamp = 0
|
||||||
# Don't clear wishlist_next_run_time here - let schedule function handle it atomically
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
if automation_engine:
|
if automation_engine:
|
||||||
|
|
@ -15892,26 +15780,17 @@ def _process_failed_tracks_to_wishlist_exact_with_auto_completion(batch_id):
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
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
|
return completion_summary
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"❌ [Auto-Wishlist] Error in auto-completion processing: {e}")
|
print(f"❌ [Auto-Wishlist] Error in auto-completion processing: {e}")
|
||||||
import traceback
|
import traceback
|
||||||
traceback.print_exc()
|
traceback.print_exc()
|
||||||
|
|
||||||
# Ensure auto-processing flag is reset even on error and reset timestamp
|
# Ensure auto-processing flag is reset even on error and reset timestamp
|
||||||
with wishlist_timer_lock:
|
with wishlist_timer_lock:
|
||||||
wishlist_auto_processing = False
|
wishlist_auto_processing = False
|
||||||
wishlist_auto_processing_timestamp = 0
|
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}
|
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'
|
youtube_playlist_states[url_hash]['phase'] = 'discovered'
|
||||||
print(f"📋 Reset YouTube playlist {url_hash} to discovered phase (error)")
|
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':
|
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
|
global wishlist_auto_processing, wishlist_auto_processing_timestamp
|
||||||
with wishlist_timer_lock:
|
with wishlist_timer_lock:
|
||||||
wishlist_auto_processing = False
|
wishlist_auto_processing = False
|
||||||
wishlist_auto_processing_timestamp = 0
|
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):
|
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 = False
|
||||||
wishlist_auto_processing_timestamp = 0
|
wishlist_auto_processing_timestamp = 0
|
||||||
print(f"🔓 [Wishlist Cancel] Reset wishlist auto-processing flag for cancelled auto-batch")
|
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:
|
else:
|
||||||
print(f"ℹ️ [Wishlist Cancel] Manual wishlist batch cancelled (no flag reset needed)")
|
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())
|
count = database.get_watchlist_count(profile_id=get_current_profile_id())
|
||||||
|
|
||||||
# Calculate time until next auto-scanning
|
# Calculate time until next auto-scanning
|
||||||
next_run_in_seconds = 0
|
next_run_in_seconds = automation_engine.get_system_automation_next_run_seconds('scan_watchlist') if automation_engine else 0
|
||||||
with watchlist_timer_lock:
|
|
||||||
if watchlist_next_run_time > 0:
|
|
||||||
next_run_in_seconds = max(0, int(watchlist_next_run_time - time.time()))
|
|
||||||
|
|
||||||
return jsonify({
|
return jsonify({
|
||||||
"success": True,
|
"success": True,
|
||||||
|
|
@ -23086,7 +22949,6 @@ def start_watchlist_scan():
|
||||||
with watchlist_timer_lock:
|
with watchlist_timer_lock:
|
||||||
watchlist_auto_scanning = False
|
watchlist_auto_scanning = False
|
||||||
watchlist_auto_scanning_timestamp = 0
|
watchlist_auto_scanning_timestamp = 0
|
||||||
watchlist_next_run_time = 0 # Clear timer for consistency
|
|
||||||
return
|
return
|
||||||
|
|
||||||
# Initialize scanner with MetadataService for cross-provider support
|
# Initialize scanner with MetadataService for cross-provider support
|
||||||
|
|
@ -23435,7 +23297,6 @@ def start_watchlist_scan():
|
||||||
with watchlist_timer_lock:
|
with watchlist_timer_lock:
|
||||||
watchlist_auto_scanning = False
|
watchlist_auto_scanning = False
|
||||||
watchlist_auto_scanning_timestamp = 0
|
watchlist_auto_scanning_timestamp = 0
|
||||||
watchlist_next_run_time = 0 # Clear timer for consistency
|
|
||||||
print("🔓 [Manual Watchlist Scan] Flag reset - scan complete")
|
print("🔓 [Manual Watchlist Scan] Flag reset - scan complete")
|
||||||
|
|
||||||
# Initialize scan state
|
# Initialize scan state
|
||||||
|
|
@ -23893,87 +23754,6 @@ watchlist_scan_state = {
|
||||||
'error': None
|
'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():
|
def _process_watchlist_scan_automatically():
|
||||||
"""Main automatic scanning logic that runs in background thread."""
|
"""Main automatic scanning logic that runs in background thread."""
|
||||||
global watchlist_auto_scanning, watchlist_auto_scanning_timestamp, watchlist_scan_state
|
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)
|
# This prevents deadlock and handles stuck flags (2-hour timeout)
|
||||||
if is_watchlist_actually_scanning():
|
if is_watchlist_actually_scanning():
|
||||||
print("⚠️ [Auto-Watchlist] Already scanning (verified with stuck detection), skipping.")
|
print("⚠️ [Auto-Watchlist] Already scanning (verified with stuck detection), skipping.")
|
||||||
schedule_next_watchlist_scan()
|
|
||||||
return
|
return
|
||||||
|
|
||||||
with watchlist_timer_lock:
|
with watchlist_timer_lock:
|
||||||
# Re-check inside lock to handle race conditions
|
# Re-check inside lock to handle race conditions
|
||||||
if watchlist_auto_scanning:
|
if watchlist_auto_scanning:
|
||||||
print("⚠️ [Auto-Watchlist] Already scanning (race condition check), skipping.")
|
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
|
return
|
||||||
|
|
||||||
# Set flag and timestamp
|
# Set flag and timestamp
|
||||||
|
|
@ -24032,8 +23799,6 @@ def _process_watchlist_scan_automatically():
|
||||||
with watchlist_timer_lock:
|
with watchlist_timer_lock:
|
||||||
watchlist_auto_scanning = False
|
watchlist_auto_scanning = False
|
||||||
watchlist_auto_scanning_timestamp = 0
|
watchlist_auto_scanning_timestamp = 0
|
||||||
watchlist_next_run_time = 0 # Clear old timer before rescheduling
|
|
||||||
schedule_next_watchlist_scan()
|
|
||||||
return
|
return
|
||||||
|
|
||||||
if not spotify_client or not spotify_client.is_authenticated():
|
if not spotify_client or not spotify_client.is_authenticated():
|
||||||
|
|
@ -24041,8 +23806,6 @@ def _process_watchlist_scan_automatically():
|
||||||
with watchlist_timer_lock:
|
with watchlist_timer_lock:
|
||||||
watchlist_auto_scanning = False
|
watchlist_auto_scanning = False
|
||||||
watchlist_auto_scanning_timestamp = 0
|
watchlist_auto_scanning_timestamp = 0
|
||||||
watchlist_next_run_time = 0 # Clear old timer before rescheduling
|
|
||||||
schedule_next_watchlist_scan()
|
|
||||||
return
|
return
|
||||||
|
|
||||||
print(f"👁️ [Auto-Watchlist] Found {watchlist_count} artists in watchlist, starting automatic scan...")
|
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()
|
itunes_enrichment_worker.resume()
|
||||||
print("▶️ [Auto-Watchlist] Resumed iTunes enrichment worker after scan")
|
print("▶️ [Auto-Watchlist] Resumed iTunes enrichment worker after scan")
|
||||||
|
|
||||||
# Always reset flag and schedule next scan
|
# Always reset flag
|
||||||
with watchlist_timer_lock:
|
with watchlist_timer_lock:
|
||||||
watchlist_auto_scanning = False
|
watchlist_auto_scanning = False
|
||||||
watchlist_auto_scanning_timestamp = 0
|
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 ---
|
# --- Metadata Updater System ---
|
||||||
|
|
||||||
|
|
@ -31289,10 +31050,7 @@ def _build_watchlist_count_payload(profile_id=1):
|
||||||
count = database.get_watchlist_count(profile_id=profile_id)
|
count = database.get_watchlist_count(profile_id=profile_id)
|
||||||
except Exception:
|
except Exception:
|
||||||
count = 0
|
count = 0
|
||||||
next_run_in_seconds = 0
|
next_run_in_seconds = automation_engine.get_system_automation_next_run_seconds('scan_watchlist') if automation_engine else 0
|
||||||
with watchlist_timer_lock:
|
|
||||||
if watchlist_next_run_time > 0:
|
|
||||||
next_run_in_seconds = max(0, int(watchlist_next_run_time - time.time()))
|
|
||||||
return {
|
return {
|
||||||
'success': True,
|
'success': True,
|
||||||
'count': count,
|
'count': count,
|
||||||
|
|
@ -31620,10 +31378,7 @@ def _emit_scan_status_loop():
|
||||||
logger.debug(f"Error emitting media scan: {e}")
|
logger.debug(f"Error emitting media scan: {e}")
|
||||||
# Wishlist stats (auto-processing detection + countdown refresh)
|
# Wishlist stats (auto-processing detection + countdown refresh)
|
||||||
try:
|
try:
|
||||||
next_run = 0
|
next_run = automation_engine.get_system_automation_next_run_seconds('process_wishlist') if automation_engine else 0
|
||||||
with wishlist_timer_lock:
|
|
||||||
if wishlist_next_run_time > 0:
|
|
||||||
next_run = max(0, int(wishlist_next_run_time - time.time()))
|
|
||||||
socketio.emit('wishlist:stats', {
|
socketio.emit('wishlist:stats', {
|
||||||
"is_auto_processing": is_wishlist_actually_processing(),
|
"is_auto_processing": is_wishlist_actually_processing(),
|
||||||
"next_run_in_seconds": next_run,
|
"next_run_in_seconds": next_run,
|
||||||
|
|
@ -31663,16 +31418,8 @@ if __name__ == '__main__':
|
||||||
start_simple_background_monitor()
|
start_simple_background_monitor()
|
||||||
print("✅ Simple background monitor started (includes automatic search cleanup)")
|
print("✅ Simple background monitor started (includes automatic search cleanup)")
|
||||||
|
|
||||||
# Start automatic wishlist processing when server starts
|
# Wishlist/watchlist timers are now managed by AutomationEngine system automations
|
||||||
print("🔧 Starting automatic wishlist processing...")
|
|
||||||
start_wishlist_auto_processing()
|
|
||||||
print("✅ Automatic wishlist processing started (1 minute initial delay, 30 minute cycles)")
|
|
||||||
|
|
||||||
# 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
|
# Pre-build import suggestions cache in background
|
||||||
print("🔧 Pre-building import suggestions cache...")
|
print("🔧 Pre-building import suggestions cache...")
|
||||||
start_import_suggestions_cache()
|
start_import_suggestions_cache()
|
||||||
|
|
|
||||||
|
|
@ -42137,7 +42137,7 @@ const _autoIcons = {
|
||||||
playlist_changed: '\u270F\uFE0F',
|
playlist_changed: '\u270F\uFE0F',
|
||||||
process_wishlist: '\uD83D\uDCCB', scan_watchlist: '\uD83D\uDC41\uFE0F',
|
process_wishlist: '\uD83D\uDCCB', scan_watchlist: '\uD83D\uDC41\uFE0F',
|
||||||
scan_library: '\uD83D\uDD04', refresh_mirrored: '\uD83D\uDCC2', sync_playlist: '\uD83D\uDD01',
|
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
|
// Phase 3
|
||||||
wishlist_processing_completed: '\u2705', watchlist_scan_completed: '\u2705',
|
wishlist_processing_completed: '\u2705', watchlist_scan_completed: '\u2705',
|
||||||
database_update_completed: '\uD83D\uDDC4\uFE0F', download_failed: '\u274C',
|
database_update_completed: '\uD83D\uDDC4\uFE0F', download_failed: '\u274C',
|
||||||
|
|
@ -42191,7 +42191,7 @@ async function loadAutomations() {
|
||||||
|
|
||||||
function renderAutomationCard(a) {
|
function renderAutomationCard(a) {
|
||||||
const card = document.createElement('div');
|
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;
|
card.dataset.id = a.id;
|
||||||
const tIcon = _autoIcons[a.trigger_type] || '\u2699\uFE0F';
|
const tIcon = _autoIcons[a.trigger_type] || '\u2699\uFE0F';
|
||||||
const aIcon = _autoIcons[a.action_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 nl = a.notify_type ? _autoFormatNotify(a.notify_type) : '';
|
||||||
const actionDelay = a.action_config && a.action_config.delay ? a.action_config.delay : 0;
|
const actionDelay = a.action_config && a.action_config.delay ? a.action_config.delay : 0;
|
||||||
const metaParts = [];
|
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));
|
if (a.last_run) metaParts.push('Last: ' + _autoTimeAgo(a.last_run));
|
||||||
const _timerTriggers = ['schedule', 'daily_time', 'weekly_time'];
|
const _timerTriggers = ['schedule', 'daily_time', 'weekly_time'];
|
||||||
if (a.next_run && a.enabled && _timerTriggers.includes(a.trigger_type)) metaParts.push('Next: ' + _autoTimeUntil(a.next_run));
|
if (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.run_count) metaParts.push('Runs: ' + a.run_count);
|
||||||
if (a.last_error) metaParts.push('Error: ' + _esc(a.last_error));
|
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 = `
|
card.innerHTML = `
|
||||||
<div class="automation-status ${a.enabled ? 'enabled' : 'disabled'}"></div>
|
<div class="automation-status ${a.enabled ? 'enabled' : 'disabled'}"></div>
|
||||||
<div class="automation-info">
|
<div class="automation-info">
|
||||||
|
|
@ -42227,7 +42231,7 @@ function renderAutomationCard(a) {
|
||||||
<span class="toggle-slider"></span>
|
<span class="toggle-slider"></span>
|
||||||
</label>
|
</label>
|
||||||
<button class="automation-edit-btn" title="Edit" onclick="event.stopPropagation(); showAutomationBuilder(${a.id})">⚙</button>
|
<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>
|
</div>
|
||||||
`;
|
`;
|
||||||
return card;
|
return card;
|
||||||
|
|
@ -42269,6 +42273,8 @@ function _autoFormatAction(type) {
|
||||||
}
|
}
|
||||||
function _autoFormatNotify(type) {
|
function _autoFormatNotify(type) {
|
||||||
if (type === 'discord_webhook') return 'Discord';
|
if (type === 'discord_webhook') return 'Discord';
|
||||||
|
if (type === 'pushbullet') return 'Pushbullet';
|
||||||
|
if (type === 'telegram') return 'Telegram';
|
||||||
return type || '';
|
return type || '';
|
||||||
}
|
}
|
||||||
function _autoTimeAgo(ts) {
|
function _autoTimeAgo(ts) {
|
||||||
|
|
@ -42368,7 +42374,7 @@ async function showAutomationBuilder(editId) {
|
||||||
}
|
}
|
||||||
|
|
||||||
_autoMirroredPlaylists = null; // invalidate so it re-fetches
|
_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 editing, load automation data
|
||||||
if (editId) {
|
if (editId) {
|
||||||
|
|
@ -42380,11 +42386,15 @@ async function showAutomationBuilder(editId) {
|
||||||
_autoBuilder.when = { type: a.trigger_type, config: a.trigger_config || {} };
|
_autoBuilder.when = { type: a.trigger_type, config: a.trigger_config || {} };
|
||||||
_autoBuilder.do = { type: a.action_type, config: a.action_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 || {} };
|
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; }
|
} catch (err) { showToast('Failed to load automation', 'error'); return; }
|
||||||
} else {
|
} else {
|
||||||
document.getElementById('builder-name').value = '';
|
document.getElementById('builder-name').value = '';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// System automations: lock the name field
|
||||||
|
document.getElementById('builder-name').readOnly = _autoBuilder.isSystem;
|
||||||
|
|
||||||
_renderBuilderSidebar();
|
_renderBuilderSidebar();
|
||||||
_renderBuilderCanvas();
|
_renderBuilderCanvas();
|
||||||
|
|
||||||
|
|
@ -42395,7 +42405,8 @@ async function showAutomationBuilder(editId) {
|
||||||
function hideAutomationBuilder() {
|
function hideAutomationBuilder() {
|
||||||
document.getElementById('automations-builder-view').style.display = 'none';
|
document.getElementById('automations-builder-view').style.display = 'none';
|
||||||
document.getElementById('automations-list-view').style.display = '';
|
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 ---
|
// --- Sidebar ---
|
||||||
|
|
@ -42446,14 +42457,13 @@ function _renderBuilderCanvas() {
|
||||||
slots.forEach((slot, i) => {
|
slots.forEach((slot, i) => {
|
||||||
if (i > 0) html += '<div class="flow-connector"></div>';
|
if (i > 0) html += '<div class="flow-connector"></div>';
|
||||||
const data = _autoBuilder[slot.key];
|
const data = _autoBuilder[slot.key];
|
||||||
|
html += `<span class="flow-slot-label ${slot.labelClass}">${slot.label}</span>`;
|
||||||
if (data) {
|
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}')">
|
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)}
|
${_renderPlacedBlock(slot.key, data)}
|
||||||
</div>`;
|
</div>`;
|
||||||
} else {
|
} 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}')">
|
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 class="flow-slot-prompt">${slot.prompt}</div>
|
||||||
</div>`;
|
</div>`;
|
||||||
}
|
}
|
||||||
|
|
@ -42485,11 +42495,15 @@ function _renderPlacedBlock(slotKey, data) {
|
||||||
</div></div>`;
|
</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">
|
<div class="placed-block-header">
|
||||||
<span class="placed-block-icon">${icon}</span>
|
<span class="placed-block-icon">${icon}</span>
|
||||||
<span class="placed-block-label">${_esc(label)}</span>
|
<span class="placed-block-label">${_esc(label)}</span>
|
||||||
<button class="placed-block-remove" onclick="_autoRemoveBlock('${slotKey}')">\u2715</button>
|
${removeBtn}
|
||||||
</div>
|
</div>
|
||||||
${configHtml ? '<div class="placed-block-config">' + configHtml + '</div>' : ''}
|
${configHtml ? '<div class="placed-block-config">' + configHtml + '</div>' : ''}
|
||||||
${delayHtml}
|
${delayHtml}
|
||||||
|
|
@ -42573,26 +42587,62 @@ function _renderBlockConfigFields(slotKey, blockType, config) {
|
||||||
</select>
|
</select>
|
||||||
</div>`;
|
</div>`;
|
||||||
}
|
}
|
||||||
if (blockType === 'discord_webhook') {
|
// Shared variable tags builder for notification types
|
||||||
const url = _escAttr(config.webhook_url || '');
|
function _notifyVarHtml(slotKey) {
|
||||||
// Merge base variables with trigger-specific variables
|
|
||||||
let allVars = ['time', 'name', 'run_count', 'status'];
|
let allVars = ['time', 'name', 'run_count', 'status'];
|
||||||
const triggerDef = _autoBuilder.when ? _findBlockDef(_autoBuilder.when.type) : null;
|
const triggerDef = _autoBuilder.when ? _findBlockDef(_autoBuilder.when.type) : null;
|
||||||
if (triggerDef && triggerDef.variables) {
|
if (triggerDef && triggerDef.variables) {
|
||||||
triggerDef.variables.forEach(v => { if (!allVars.includes(v)) allVars.push(v); });
|
triggerDef.variables.forEach(v => { if (!allVars.includes(v)) allVars.push(v); });
|
||||||
}
|
}
|
||||||
let varHtml = '<div class="variable-tags">';
|
let html = '<div class="variable-tags">';
|
||||||
allVars.forEach(v => { varHtml += `<span class="variable-tag" onclick="_autoInsertVar('cfg-${slotKey}-message','{${v}}')">{${v}}</span>`; });
|
allVars.forEach(v => { html += `<span class="variable-tag" onclick="_autoInsertVar('cfg-${slotKey}-message','{${v}}')">{${v}}</span>`; });
|
||||||
varHtml += '</div>';
|
return html + '</div>';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (blockType === 'discord_webhook') {
|
||||||
|
const url = _escAttr(config.webhook_url || '');
|
||||||
return `<div class="config-row">
|
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/...">
|
<input type="text" id="cfg-${slotKey}-webhook_url" value="${url}" placeholder="https://discord.com/api/webhooks/...">
|
||||||
</div>
|
</div>
|
||||||
<div class="config-row">
|
<div class="config-row">
|
||||||
<label>Message</label>
|
<label>Message</label>
|
||||||
<textarea id="cfg-${slotKey}-message" placeholder="Message with {variables}...">${config.message || '{name} completed with status: {status}'}</textarea>
|
<textarea id="cfg-${slotKey}-message" placeholder="Message with {variables}...">${config.message || '{name} completed with status: {status}'}</textarea>
|
||||||
</div>
|
</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 '';
|
return '';
|
||||||
}
|
}
|
||||||
|
|
@ -42774,6 +42824,20 @@ function _readPlacedConfig(slotKey) {
|
||||||
message: document.getElementById('cfg-' + slotKey + '-message')?.value || '',
|
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 {};
|
return {};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -42806,6 +42870,7 @@ function _autoDragLeave(e, slotKey) {
|
||||||
function _autoDrop(e, slotKey) {
|
function _autoDrop(e, slotKey) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
document.getElementById('slot-' + slotKey)?.classList.remove('drag-over');
|
document.getElementById('slot-' + slotKey)?.classList.remove('drag-over');
|
||||||
|
if (_autoBuilder.isSystem && (slotKey === 'when' || slotKey === 'do')) return;
|
||||||
try {
|
try {
|
||||||
const data = JSON.parse(e.dataTransfer.getData('text/plain'));
|
const data = JSON.parse(e.dataTransfer.getData('text/plain'));
|
||||||
if (data.slot !== slotKey) { showToast('Wrong slot — drop ' + data.slot + ' blocks here', 'error'); return; }
|
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)
|
// Click-to-add (alternative to drag)
|
||||||
function _autoClickBlock(blockType, slotCategory) {
|
function _autoClickBlock(blockType, slotCategory) {
|
||||||
|
if (_autoBuilder.isSystem && (slotCategory === 'when' || slotCategory === 'do')) return;
|
||||||
_autoBuilder[slotCategory] = { type: blockType, config: {} };
|
_autoBuilder[slotCategory] = { type: blockType, config: {} };
|
||||||
_renderBuilderCanvas();
|
_renderBuilderCanvas();
|
||||||
}
|
}
|
||||||
|
|
||||||
function _autoRemoveBlock(slotKey) {
|
function _autoRemoveBlock(slotKey) {
|
||||||
|
if (_autoBuilder.isSystem && (slotKey === 'when' || slotKey === 'do')) return;
|
||||||
_autoBuilder[slotKey] = null;
|
_autoBuilder[slotKey] = null;
|
||||||
_renderBuilderCanvas();
|
_renderBuilderCanvas();
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -22748,7 +22748,7 @@ body {
|
||||||
transform: scale(1.1);
|
transform: scale(1.1);
|
||||||
}
|
}
|
||||||
|
|
||||||
.toggle-slider {
|
.search-mode-toggle .toggle-slider {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 4px;
|
top: 4px;
|
||||||
left: 4px;
|
left: 4px;
|
||||||
|
|
@ -28333,46 +28333,31 @@ body {
|
||||||
|
|
||||||
/* --- Automation Card --- */
|
/* --- Automation Card --- */
|
||||||
.automation-card {
|
.automation-card {
|
||||||
background: linear-gradient(135deg, rgba(26, 26, 26, 0.95) 0%, rgba(18, 18, 18, 0.98) 100%);
|
background: rgba(22, 22, 22, 0.95);
|
||||||
border-radius: 20px; border: 1px solid rgba(255, 255, 255, 0.08);
|
border-radius: 12px; border: 1px solid rgba(255, 255, 255, 0.08);
|
||||||
border-top: 1px solid rgba(255, 255, 255, 0.12);
|
margin: 6px 0; padding: 12px 16px;
|
||||||
margin: 10px 0; padding: 20px 22px;
|
display: flex; align-items: center; gap: 12px;
|
||||||
display: flex; align-items: center; gap: 16px;
|
transition: all 0.2s ease;
|
||||||
transition: all 0.4s cubic-bezier(0.4, 0, 0.2, 1);
|
|
||||||
position: relative; overflow: hidden;
|
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);
|
box-shadow: 0 2px 8px rgba(0,0,0,0.3);
|
||||||
}
|
|
||||||
.automation-card::before {
|
|
||||||
content: '';
|
|
||||||
position: absolute;
|
|
||||||
left: 0; top: 50%;
|
|
||||||
transform: translateY(-50%);
|
|
||||||
width: 4px; height: 30%;
|
|
||||||
background: linear-gradient(180deg, transparent 0%, rgba(var(--accent-rgb), 0.15) 100%);
|
|
||||||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
|
||||||
border-radius: 0 4px 4px 0;
|
|
||||||
}
|
}
|
||||||
.automation-card:hover {
|
.automation-card:hover {
|
||||||
background: linear-gradient(135deg, rgba(30,30,30,0.98) 0%, rgba(22,22,22,1) 100%);
|
background: rgba(28, 28, 28, 0.98);
|
||||||
border-color: rgba(var(--accent-rgb), 0.3); border-top-color: rgba(var(--accent-rgb), 0.4);
|
border-color: rgba(var(--accent-rgb), 0.25);
|
||||||
transform: translateY(-3px) scale(1.01);
|
box-shadow: 0 4px 16px rgba(0,0,0,0.4);
|
||||||
box-shadow: 0 16px 48px rgba(0,0,0,0.5), 0 8px 16px rgba(0,0,0,0.3), 0 0 20px rgba(var(--accent-rgb),0.12), inset 0 1px 0 rgba(255,255,255,0.15);
|
|
||||||
}
|
|
||||||
.automation-card:hover::before {
|
|
||||||
height: 60%;
|
|
||||||
background: linear-gradient(180deg, rgb(var(--accent-light-rgb)), rgb(var(--accent-rgb)));
|
|
||||||
box-shadow: 0 0 8px rgba(var(--accent-rgb), 0.5);
|
|
||||||
}
|
}
|
||||||
.automation-card.disabled { opacity: 0.55; }
|
.automation-card.disabled { opacity: 0.55; }
|
||||||
.automation-card.disabled:hover { opacity: 0.75; }
|
.automation-card.disabled:hover { opacity: 0.75; }
|
||||||
.automation-card.disabled .automation-name { color: rgba(255,255,255,0.5); }
|
.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 { 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.enabled { background: #4ade80; box-shadow: 0 0 8px rgba(74,222,128,0.4); }
|
||||||
.automation-status.disabled { background: #666; }
|
.automation-status.disabled { background: #666; }
|
||||||
|
|
||||||
.automation-info { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 6px; }
|
.automation-info { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 3px; }
|
||||||
.automation-name { font-size: 15px; font-weight: 600; color: #fff; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
.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; }
|
.automation-flow { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
|
||||||
.flow-trigger, .flow-action, .flow-notify {
|
.flow-trigger, .flow-action, .flow-notify {
|
||||||
font-size: 12px; padding: 3px 10px; border-radius: 12px; white-space: nowrap;
|
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); }
|
.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-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 {
|
.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);
|
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;
|
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-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-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; }
|
.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 switch */
|
||||||
.automation-toggle { position: relative; display: inline-block; width: 40px; height: 22px; }
|
.automation-toggle { position: relative; display: inline-block; width: 36px; height: 20px; }
|
||||||
.automation-toggle input { opacity: 0; width: 0; height: 0; }
|
.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;
|
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 {
|
.automation-toggle .toggle-slider::before {
|
||||||
content: ''; position: absolute; height: 16px; width: 16px; left: 2px; bottom: 2px;
|
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;
|
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 { 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 --- */
|
/* --- Empty State --- */
|
||||||
.automations-empty {
|
.automations-empty {
|
||||||
|
|
@ -28534,9 +28522,8 @@ body {
|
||||||
}
|
}
|
||||||
|
|
||||||
.flow-slot-label {
|
.flow-slot-label {
|
||||||
position: absolute; top: -10px; left: 16px;
|
font-size: 10px; font-weight: 700; text-transform: uppercase; letter-spacing: 1px;
|
||||||
font-size: 9px; font-weight: 700; text-transform: uppercase; letter-spacing: 1px;
|
padding: 3px 10px; border-radius: 6px; margin-bottom: 6px;
|
||||||
padding: 2px 8px; border-radius: 6px;
|
|
||||||
}
|
}
|
||||||
.flow-slot-label.when { background: rgba(var(--accent-rgb),0.2); color: rgb(var(--accent-light-rgb)); }
|
.flow-slot-label.when { background: rgba(var(--accent-rgb),0.2); color: rgb(var(--accent-light-rgb)); }
|
||||||
.flow-slot-label.do { background: rgba(88,101,242,0.2); color: #7289da; }
|
.flow-slot-label.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);
|
box-shadow: 0 4px 16px rgba(0,0,0,0.3);
|
||||||
}
|
}
|
||||||
.placed-block:hover { border-color: rgba(255,255,255,0.18); }
|
.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 {
|
.placed-block-header {
|
||||||
display: flex; align-items: center; gap: 10px; padding: 14px 16px;
|
display: flex; align-items: center; gap: 10px; padding: 14px 16px;
|
||||||
border-bottom: 1px solid rgba(255,255,255,0.06);
|
border-bottom: 1px solid rgba(255,255,255,0.06);
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue