Full automation engine expansion with scheduling, triggers, actions, and UI polish
This commit is contained in:
parent
75f9b7364a
commit
da707dcf0a
7 changed files with 2647 additions and 9 deletions
|
|
@ -13,7 +13,7 @@ def register_routes(bp):
|
|||
|
||||
@bp.route("/listenbrainz/playlists", methods=["GET"])
|
||||
@require_api_key
|
||||
def list_playlists():
|
||||
def list_listenbrainz_playlists():
|
||||
"""List cached ListenBrainz playlists.
|
||||
|
||||
Query params:
|
||||
|
|
@ -71,7 +71,7 @@ def register_routes(bp):
|
|||
|
||||
@bp.route("/listenbrainz/playlists/<playlist_id>", methods=["GET"])
|
||||
@require_api_key
|
||||
def get_playlist(playlist_id):
|
||||
def get_listenbrainz_playlist(playlist_id):
|
||||
"""Get a ListenBrainz playlist with its tracks.
|
||||
|
||||
playlist_id can be the internal ID or the MusicBrainz playlist MBID.
|
||||
|
|
|
|||
528
core/automation_engine.py
Normal file
528
core/automation_engine.py
Normal file
|
|
@ -0,0 +1,528 @@
|
|||
"""
|
||||
Automation Engine — trigger → action → notify scheduler for SoulSync.
|
||||
|
||||
Architecture:
|
||||
- Triggers (WHEN): schedule timer, event-based (track_downloaded, batch_complete, etc.)
|
||||
- Actions (DO): real SoulSync operations registered by web_server.py
|
||||
- Notifications (NOTIFY): optional Discord webhook with template variables
|
||||
- Conditions: optional filters on event data (artist contains, title equals, etc.)
|
||||
|
||||
Uses threading.Timer pattern for schedule triggers.
|
||||
Event triggers react to emit() calls from web_server.py hook points.
|
||||
"""
|
||||
|
||||
import json
|
||||
import time
|
||||
import threading
|
||||
import requests
|
||||
from datetime import datetime, timedelta
|
||||
from utils.logging_config import get_logger
|
||||
|
||||
logger = get_logger("automation_engine")
|
||||
|
||||
|
||||
class AutomationEngine:
|
||||
def __init__(self, db):
|
||||
self.db = db
|
||||
self._timers = {} # automation_id → threading.Timer
|
||||
self._lock = threading.Lock()
|
||||
self._running = False
|
||||
|
||||
# Action handlers registered by web_server.py (avoids circular imports)
|
||||
# Format: {type: {'handler': fn(config)->dict, 'guard': fn()->bool or None}}
|
||||
self._action_handlers = {}
|
||||
|
||||
# Event trigger cache: trigger_type → [automation_id, ...]
|
||||
self._event_automations = {}
|
||||
self._event_cache_dirty = True
|
||||
|
||||
# Trigger registry: type → setup function (schedule only — events use emit())
|
||||
self._trigger_handlers = {
|
||||
'schedule': self._setup_schedule_trigger,
|
||||
'daily_time': self._setup_daily_time_trigger,
|
||||
'weekly_time': self._setup_weekly_time_trigger,
|
||||
}
|
||||
|
||||
# --- Action Handler Registration ---
|
||||
|
||||
def register_action_handler(self, action_type, handler_fn, guard_fn=None):
|
||||
"""Register a callable for an action type.
|
||||
handler_fn(config) -> dict with result data
|
||||
guard_fn() -> bool (True = busy, should skip)
|
||||
"""
|
||||
self._action_handlers[action_type] = {
|
||||
'handler': handler_fn,
|
||||
'guard': guard_fn,
|
||||
}
|
||||
logger.debug(f"Registered action handler: {action_type}")
|
||||
|
||||
# --- Lifecycle ---
|
||||
|
||||
def start(self):
|
||||
"""Load all enabled automations from DB and schedule them."""
|
||||
self._running = True
|
||||
self._event_cache_dirty = True
|
||||
automations = self.db.get_automations()
|
||||
scheduled = 0
|
||||
event_count = 0
|
||||
for auto in automations:
|
||||
if auto.get('enabled'):
|
||||
trigger_type = auto.get('trigger_type', '')
|
||||
if trigger_type in self._trigger_handlers:
|
||||
self.schedule_automation(auto['id'])
|
||||
scheduled += 1
|
||||
else:
|
||||
event_count += 1
|
||||
# Pre-build event cache
|
||||
self._rebuild_event_cache()
|
||||
logger.info(f"AutomationEngine started — {scheduled} scheduled, {event_count} event-based")
|
||||
|
||||
def stop(self):
|
||||
"""Cancel all timers on shutdown."""
|
||||
self._running = False
|
||||
with self._lock:
|
||||
for aid, timer in self._timers.items():
|
||||
timer.cancel()
|
||||
count = len(self._timers)
|
||||
self._timers.clear()
|
||||
if count:
|
||||
logger.info(f"AutomationEngine stopped — cancelled {count} timer(s)")
|
||||
|
||||
# --- Scheduling ---
|
||||
|
||||
def schedule_automation(self, automation_id):
|
||||
"""Set up timer for a single automation based on its trigger type."""
|
||||
auto = self.db.get_automation(automation_id)
|
||||
if not auto or not auto.get('enabled'):
|
||||
return
|
||||
|
||||
trigger_type = auto.get('trigger_type')
|
||||
setup_fn = self._trigger_handlers.get(trigger_type)
|
||||
|
||||
if not setup_fn:
|
||||
# Event-based trigger — no timer needed, just invalidate cache
|
||||
self._event_cache_dirty = True
|
||||
return
|
||||
|
||||
try:
|
||||
config = json.loads(auto.get('trigger_config') or '{}')
|
||||
except json.JSONDecodeError:
|
||||
config = {}
|
||||
|
||||
self.cancel_automation(automation_id)
|
||||
setup_fn(automation_id, config)
|
||||
|
||||
def cancel_automation(self, automation_id):
|
||||
"""Cancel timer for an automation and invalidate event cache."""
|
||||
with self._lock:
|
||||
timer = self._timers.pop(automation_id, None)
|
||||
if timer:
|
||||
timer.cancel()
|
||||
self._event_cache_dirty = True
|
||||
|
||||
# --- Event Bus ---
|
||||
|
||||
def emit(self, event_type, data):
|
||||
"""Called from web_server.py when events occur. Non-blocking."""
|
||||
if not self._running:
|
||||
return
|
||||
thread = threading.Thread(
|
||||
target=self._process_event,
|
||||
args=(event_type, dict(data)),
|
||||
daemon=True,
|
||||
name=f'automation-event-{event_type}'
|
||||
)
|
||||
thread.start()
|
||||
|
||||
def _process_event(self, event_type, data):
|
||||
"""Find matching automations and run them."""
|
||||
try:
|
||||
if self._event_cache_dirty:
|
||||
self._rebuild_event_cache()
|
||||
|
||||
automation_ids = self._event_automations.get(event_type, [])
|
||||
if not automation_ids:
|
||||
return
|
||||
|
||||
logger.debug(f"Event '{event_type}' — checking {len(automation_ids)} automation(s)")
|
||||
for aid in automation_ids:
|
||||
try:
|
||||
auto = self.db.get_automation(aid)
|
||||
if not auto or not auto.get('enabled'):
|
||||
continue
|
||||
config = json.loads(auto.get('trigger_config') or '{}')
|
||||
if self._evaluate_conditions(config, data):
|
||||
logger.info(f"Event '{event_type}' matched automation '{auto.get('name')}' (id={aid})")
|
||||
# Run in separate thread so delays don't block the event loop
|
||||
threading.Thread(
|
||||
target=self._run_event_automation,
|
||||
args=(auto, aid, data),
|
||||
daemon=True,
|
||||
name=f'automation-exec-{aid}'
|
||||
).start()
|
||||
else:
|
||||
logger.debug(f"Event '{event_type}' conditions not met for automation {aid}")
|
||||
except Exception as e:
|
||||
logger.error(f"Event automation {aid} error: {e}")
|
||||
except Exception as e:
|
||||
logger.error(f"Event processing error for '{event_type}': {e}")
|
||||
|
||||
def _rebuild_event_cache(self):
|
||||
"""Cache which automations listen to which event types."""
|
||||
self._event_automations = {}
|
||||
try:
|
||||
all_autos = self.db.get_automations()
|
||||
for auto in all_autos:
|
||||
if not auto.get('enabled'):
|
||||
continue
|
||||
tt = auto.get('trigger_type', '')
|
||||
if tt and tt not in self._trigger_handlers:
|
||||
self._event_automations.setdefault(tt, []).append(auto['id'])
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to rebuild event cache: {e}")
|
||||
self._event_cache_dirty = False
|
||||
logger.debug(f"Event cache rebuilt: {dict((k, len(v)) for k, v in self._event_automations.items())}")
|
||||
|
||||
def _evaluate_conditions(self, trigger_config, event_data):
|
||||
"""Check if event data matches trigger conditions. No conditions = always match."""
|
||||
conditions = trigger_config.get('conditions', [])
|
||||
if not conditions:
|
||||
return True
|
||||
|
||||
match_mode = trigger_config.get('match', 'all')
|
||||
results = []
|
||||
|
||||
for cond in conditions:
|
||||
field = cond.get('field', '')
|
||||
operator = cond.get('operator', 'contains')
|
||||
value = cond.get('value', '').lower()
|
||||
event_value = str(event_data.get(field, '')).lower()
|
||||
|
||||
if operator == 'contains':
|
||||
results.append(value in event_value)
|
||||
elif operator == 'equals':
|
||||
results.append(value == event_value)
|
||||
elif operator == 'starts_with':
|
||||
results.append(event_value.startswith(value))
|
||||
elif operator == 'not_contains':
|
||||
results.append(value not in event_value)
|
||||
else:
|
||||
results.append(False)
|
||||
|
||||
if match_mode == 'any':
|
||||
return any(results)
|
||||
return all(results)
|
||||
|
||||
def _run_event_automation(self, auto, automation_id, event_data):
|
||||
"""Execute action for an event-triggered automation."""
|
||||
action_type = auto.get('action_type')
|
||||
|
||||
# Check for action delay
|
||||
try:
|
||||
action_config = json.loads(auto.get('action_config') or '{}')
|
||||
except json.JSONDecodeError:
|
||||
action_config = {}
|
||||
delay_minutes = action_config.get('delay', 0)
|
||||
if delay_minutes and delay_minutes > 0:
|
||||
logger.info(f"Event automation '{auto.get('name')}' delaying {delay_minutes}m before action")
|
||||
time.sleep(int(delay_minutes) * 60)
|
||||
if not self._running:
|
||||
return
|
||||
|
||||
# notify_only = no action, just send notification with event data
|
||||
if action_type == 'notify_only':
|
||||
result = {'status': 'triggered'}
|
||||
else:
|
||||
handler_info = self._action_handlers.get(action_type)
|
||||
if not handler_info:
|
||||
result = {'status': 'error', 'error': f'No handler for {action_type}'}
|
||||
logger.warning(f"No handler for action '{action_type}' on event automation {automation_id}")
|
||||
else:
|
||||
guard_fn = handler_info.get('guard')
|
||||
if guard_fn and guard_fn():
|
||||
result = {'status': 'skipped', 'reason': f'{action_type} already running'}
|
||||
logger.info(f"Event automation '{auto.get('name')}' skipped — {action_type} busy")
|
||||
else:
|
||||
try:
|
||||
result = handler_info['handler'](action_config) or {}
|
||||
logger.info(f"Event automation '{auto.get('name')}' executed: {result.get('status', 'ok')}")
|
||||
except Exception as e:
|
||||
result = {'status': 'error', 'error': str(e)}
|
||||
logger.error(f"Event automation '{auto.get('name')}' action failed: {e}")
|
||||
|
||||
# Merge event data into result for notification variables
|
||||
merged = {**event_data, **result}
|
||||
|
||||
try:
|
||||
self._send_notification(auto, merged)
|
||||
except Exception as e:
|
||||
logger.error(f"Notification failed for event automation {automation_id}: {e}")
|
||||
|
||||
# Update run stats (no reschedule — event triggers don't use timers)
|
||||
last_result = json.dumps(merged)
|
||||
error = result.get('error') if result.get('status') == 'error' else None
|
||||
self.db.update_automation_run(automation_id, error=error, last_result=last_result)
|
||||
|
||||
# --- Schedule Execution (timer-based) ---
|
||||
|
||||
def run_automation(self, automation_id, skip_delay=False):
|
||||
"""Execute: check guard → run action → send notification → update stats → reschedule."""
|
||||
if not self._running:
|
||||
return
|
||||
|
||||
auto = self.db.get_automation(automation_id)
|
||||
if not auto or not auto.get('enabled'):
|
||||
return
|
||||
|
||||
action_type = auto.get('action_type')
|
||||
|
||||
# notify_only for scheduled automations
|
||||
if action_type == 'notify_only':
|
||||
result = {'status': 'triggered'}
|
||||
try:
|
||||
self._send_notification(auto, result)
|
||||
except Exception as e:
|
||||
logger.error(f"Notification failed for automation {automation_id}: {e}")
|
||||
self._finish_run(auto, automation_id, result, error=None)
|
||||
return
|
||||
|
||||
handler_info = self._action_handlers.get(action_type)
|
||||
if not handler_info:
|
||||
logger.warning(f"No handler for action '{action_type}' on automation {automation_id}")
|
||||
self.db.update_automation_run(automation_id, error=f"No handler for action: {action_type}")
|
||||
return
|
||||
|
||||
try:
|
||||
action_config = json.loads(auto.get('action_config') or '{}')
|
||||
except json.JSONDecodeError:
|
||||
action_config = {}
|
||||
|
||||
# Action delay (skipped for manual run_now)
|
||||
delay_minutes = action_config.get('delay', 0)
|
||||
if not skip_delay and delay_minutes and delay_minutes > 0:
|
||||
logger.info(f"Automation '{auto['name']}' delaying {delay_minutes}m before action")
|
||||
time.sleep(int(delay_minutes) * 60)
|
||||
if not self._running:
|
||||
return
|
||||
|
||||
# Check guard (is the operation already running?)
|
||||
guard_fn = handler_info.get('guard')
|
||||
if guard_fn and guard_fn():
|
||||
result = {'status': 'skipped', 'reason': f'{action_type} is already running'}
|
||||
logger.info(f"Automation '{auto['name']}' skipped — {action_type} already running")
|
||||
self._finish_run(auto, automation_id, result, error=None)
|
||||
return
|
||||
|
||||
# Execute the action
|
||||
error = None
|
||||
result = {}
|
||||
try:
|
||||
result = handler_info['handler'](action_config) or {}
|
||||
logger.info(f"Automation '{auto['name']}' (id={automation_id}) executed: {result.get('status', 'ok')}")
|
||||
except Exception as e:
|
||||
error = str(e)
|
||||
result = {'status': 'error', 'error': error}
|
||||
logger.error(f"Automation '{auto['name']}' (id={automation_id}) failed: {e}")
|
||||
|
||||
# Send notification if configured
|
||||
try:
|
||||
self._send_notification(auto, result)
|
||||
except Exception as e:
|
||||
logger.error(f"Notification failed for automation {automation_id}: {e}")
|
||||
|
||||
self._finish_run(auto, automation_id, result, error)
|
||||
|
||||
def _finish_run(self, auto, automation_id, result, error):
|
||||
"""Update DB with run stats and reschedule."""
|
||||
next_run_str = None
|
||||
trigger_type = auto.get('trigger_type', '')
|
||||
# Only compute next_run for timer-based triggers (event triggers don't have scheduled runs)
|
||||
if trigger_type in self._trigger_handlers:
|
||||
try:
|
||||
trigger_config = json.loads(auto.get('trigger_config') or '{}')
|
||||
if trigger_type == 'daily_time':
|
||||
# Next run is tomorrow at the configured time
|
||||
time_str = trigger_config.get('time', '00:00')
|
||||
hour, minute = map(int, time_str.split(':'))
|
||||
target = datetime.now().replace(hour=hour, minute=minute, second=0, microsecond=0) + timedelta(days=1)
|
||||
next_run_str = target.strftime('%Y-%m-%d %H:%M:%S')
|
||||
elif trigger_type == 'weekly_time':
|
||||
time_str = trigger_config.get('time', '00:00')
|
||||
hour, minute = map(int, time_str.split(':'))
|
||||
target = self._next_weekly_occurrence(hour, minute, trigger_config.get('days', []))
|
||||
next_run_str = target.strftime('%Y-%m-%d %H:%M:%S')
|
||||
else:
|
||||
delay = self._calc_delay_seconds(trigger_config)
|
||||
if delay:
|
||||
next_run_str = (datetime.now() + timedelta(seconds=delay)).strftime('%Y-%m-%d %H:%M:%S')
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
last_result = json.dumps(result) if result else None
|
||||
self.db.update_automation_run(automation_id, next_run=next_run_str, error=error, last_result=last_result)
|
||||
|
||||
if self._running:
|
||||
self.schedule_automation(automation_id)
|
||||
|
||||
def run_now(self, automation_id):
|
||||
"""Manual trigger — run immediately in a background thread.
|
||||
Always uses run_automation (skips condition checks and action delay)."""
|
||||
auto = self.db.get_automation(automation_id)
|
||||
if not auto:
|
||||
return False
|
||||
|
||||
thread = threading.Thread(
|
||||
target=self.run_automation,
|
||||
args=(automation_id, True),
|
||||
daemon=True,
|
||||
name=f'automation-run-{automation_id}'
|
||||
)
|
||||
thread.start()
|
||||
return True
|
||||
|
||||
# --- Trigger handlers ---
|
||||
|
||||
def _calc_delay_seconds(self, config):
|
||||
"""Calculate delay in seconds from schedule config."""
|
||||
interval = config.get('interval', 1)
|
||||
unit = config.get('unit', 'hours')
|
||||
multipliers = {'minutes': 60, 'hours': 3600, 'days': 86400}
|
||||
return max(int(interval), 1) * multipliers.get(unit, 3600)
|
||||
|
||||
def _setup_schedule_trigger(self, automation_id, config):
|
||||
"""Config: {"interval": 6, "unit": "hours"}"""
|
||||
delay = self._calc_delay_seconds(config)
|
||||
|
||||
# If there's a next_run in the future, use remaining time instead
|
||||
auto = self.db.get_automation(automation_id)
|
||||
if auto and auto.get('next_run'):
|
||||
try:
|
||||
next_run = datetime.strptime(auto['next_run'], '%Y-%m-%d %H:%M:%S')
|
||||
remaining = (next_run - datetime.now()).total_seconds()
|
||||
if remaining > 0:
|
||||
delay = remaining
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
|
||||
next_run_str = (datetime.now() + timedelta(seconds=delay)).strftime('%Y-%m-%d %H:%M:%S')
|
||||
self.db.update_automation(automation_id, next_run=next_run_str)
|
||||
|
||||
timer = threading.Timer(delay, self.run_automation, args=(automation_id,))
|
||||
timer.daemon = True
|
||||
timer.start()
|
||||
|
||||
with self._lock:
|
||||
self._timers[automation_id] = timer
|
||||
|
||||
logger.debug(f"Scheduled automation {automation_id} in {delay:.0f}s")
|
||||
|
||||
def _setup_daily_time_trigger(self, automation_id, config):
|
||||
"""Config: {"time": "03:00"} — runs daily at the specified local time."""
|
||||
time_str = config.get('time', '00:00')
|
||||
try:
|
||||
hour, minute = map(int, time_str.split(':'))
|
||||
except (ValueError, AttributeError):
|
||||
hour, minute = 0, 0
|
||||
|
||||
now = datetime.now()
|
||||
target = now.replace(hour=hour, minute=minute, second=0, microsecond=0)
|
||||
if target <= now:
|
||||
target += timedelta(days=1)
|
||||
|
||||
delay = (target - now).total_seconds()
|
||||
|
||||
next_run_str = target.strftime('%Y-%m-%d %H:%M:%S')
|
||||
self.db.update_automation(automation_id, next_run=next_run_str)
|
||||
|
||||
timer = threading.Timer(delay, self.run_automation, args=(automation_id,))
|
||||
timer.daemon = True
|
||||
timer.start()
|
||||
|
||||
with self._lock:
|
||||
self._timers[automation_id] = timer
|
||||
|
||||
logger.debug(f"Daily automation {automation_id} scheduled for {time_str} (in {delay:.0f}s)")
|
||||
|
||||
def _setup_weekly_time_trigger(self, automation_id, config):
|
||||
"""Config: {"time": "03:00", "days": ["mon", "wed", "fri"]}"""
|
||||
time_str = config.get('time', '00:00')
|
||||
try:
|
||||
hour, minute = map(int, time_str.split(':'))
|
||||
except (ValueError, AttributeError):
|
||||
hour, minute = 0, 0
|
||||
|
||||
target = self._next_weekly_occurrence(hour, minute, config.get('days', []))
|
||||
delay = (target - datetime.now()).total_seconds()
|
||||
|
||||
next_run_str = target.strftime('%Y-%m-%d %H:%M:%S')
|
||||
self.db.update_automation(automation_id, next_run=next_run_str)
|
||||
|
||||
timer = threading.Timer(delay, self.run_automation, args=(automation_id,))
|
||||
timer.daemon = True
|
||||
timer.start()
|
||||
|
||||
with self._lock:
|
||||
self._timers[automation_id] = timer
|
||||
|
||||
day_names = ', '.join(config.get('days', [])) or 'every day'
|
||||
logger.debug(f"Weekly automation {automation_id} scheduled for {time_str} on {day_names} (in {delay:.0f}s)")
|
||||
|
||||
def _next_weekly_occurrence(self, hour, minute, days):
|
||||
"""Find the next datetime matching one of the given weekday abbreviations."""
|
||||
day_map = {'mon': 0, 'tue': 1, 'wed': 2, 'thu': 3, 'fri': 4, 'sat': 5, 'sun': 6}
|
||||
allowed = {day_map[d] for d in days if d in day_map}
|
||||
if not allowed:
|
||||
allowed = set(range(7)) # no days selected = every day
|
||||
|
||||
now = datetime.now()
|
||||
for offset in range(8): # check today + next 7 days
|
||||
candidate = now + timedelta(days=offset)
|
||||
if candidate.weekday() in allowed:
|
||||
target = candidate.replace(hour=hour, minute=minute, second=0, microsecond=0)
|
||||
if target > now:
|
||||
return target
|
||||
# Fallback: tomorrow (shouldn't happen with 8-day scan)
|
||||
return now.replace(hour=hour, minute=minute, second=0, microsecond=0) + timedelta(days=1)
|
||||
|
||||
# --- Notification ---
|
||||
|
||||
def _send_notification(self, automation, action_result):
|
||||
"""Send notification after action completes, if configured."""
|
||||
notify_type = automation.get('notify_type')
|
||||
if not notify_type:
|
||||
return
|
||||
|
||||
try:
|
||||
config = json.loads(automation.get('notify_config') or '{}')
|
||||
except json.JSONDecodeError:
|
||||
config = {}
|
||||
|
||||
# Build template variables
|
||||
variables = {
|
||||
'time': datetime.now().strftime('%Y-%m-%d %H:%M:%S'),
|
||||
'name': automation.get('name', 'Automation'),
|
||||
'run_count': str(automation.get('run_count', 0) + 1),
|
||||
'status': action_result.get('status', 'unknown'),
|
||||
}
|
||||
# Add all action result fields as variables
|
||||
for k, v in action_result.items():
|
||||
variables[k] = str(v)
|
||||
|
||||
if notify_type == 'discord_webhook':
|
||||
self._send_discord_notification(config, variables)
|
||||
|
||||
def _send_discord_notification(self, config, variables):
|
||||
"""POST to Discord webhook with template variable substitution."""
|
||||
url = config.get('webhook_url', '').strip()
|
||||
if not url:
|
||||
raise ValueError("No webhook URL configured")
|
||||
|
||||
message = config.get('message', '{name} completed with status: {status}')
|
||||
|
||||
# Substitute all variables
|
||||
for key, value in variables.items():
|
||||
message = message.replace('{' + key + '}', value)
|
||||
|
||||
resp = requests.post(url, json={"content": message}, timeout=10)
|
||||
if resp.status_code not in (200, 204):
|
||||
raise RuntimeError(f"Discord webhook returned {resp.status_code}: {resp.text[:200]}")
|
||||
|
|
@ -375,6 +375,31 @@ class MusicDatabase:
|
|||
cursor.execute("CREATE INDEX IF NOT EXISTS idx_mirrored_playlists_source ON mirrored_playlists (source, source_playlist_id)")
|
||||
cursor.execute("CREATE INDEX IF NOT EXISTS idx_mirrored_tracks_playlist ON mirrored_playlist_tracks (playlist_id)")
|
||||
|
||||
# Automations table — trigger → action scheduled tasks
|
||||
cursor.execute("""
|
||||
CREATE TABLE IF NOT EXISTS automations (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL,
|
||||
enabled INTEGER DEFAULT 1,
|
||||
trigger_type TEXT NOT NULL,
|
||||
trigger_config TEXT DEFAULT '{}',
|
||||
action_type TEXT NOT NULL,
|
||||
action_config TEXT DEFAULT '{}',
|
||||
last_run TIMESTAMP,
|
||||
next_run TIMESTAMP,
|
||||
run_count INTEGER DEFAULT 0,
|
||||
last_error TEXT,
|
||||
profile_id INTEGER DEFAULT 1,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
""")
|
||||
cursor.execute("CREATE INDEX IF NOT EXISTS idx_automations_profile ON automations (profile_id)")
|
||||
cursor.execute("CREATE INDEX IF NOT EXISTS idx_automations_enabled ON automations (enabled)")
|
||||
|
||||
# Add notification columns to automations (migration)
|
||||
self._add_automation_notify_columns(cursor)
|
||||
|
||||
conn.commit()
|
||||
logger.info("Database initialized successfully")
|
||||
|
||||
|
|
@ -382,6 +407,18 @@ class MusicDatabase:
|
|||
logger.error(f"Error initializing database: {e}")
|
||||
raise
|
||||
|
||||
def _add_automation_notify_columns(self, cursor):
|
||||
"""Add notification and result columns to automations table."""
|
||||
try:
|
||||
cursor.execute("PRAGMA table_info(automations)")
|
||||
cols = [c[1] for c in cursor.fetchall()]
|
||||
for col, typedef in [('notify_type', 'TEXT DEFAULT NULL'), ('notify_config', "TEXT DEFAULT '{}'"), ('last_result', 'TEXT DEFAULT NULL')]:
|
||||
if col not in cols:
|
||||
cursor.execute(f"ALTER TABLE automations ADD COLUMN {col} {typedef}")
|
||||
logger.info(f"Added {col} column to automations table")
|
||||
except Exception as e:
|
||||
logger.error(f"Error adding automation notify columns: {e}")
|
||||
|
||||
def _add_server_source_columns(self, cursor):
|
||||
"""Add server_source columns to existing tables for multi-server support"""
|
||||
try:
|
||||
|
|
@ -6483,6 +6520,122 @@ class MusicDatabase:
|
|||
logger.error(f"Error deleting mirrored playlist: {e}")
|
||||
return False
|
||||
|
||||
# ===========================
|
||||
# AUTOMATIONS CRUD
|
||||
# ===========================
|
||||
|
||||
def create_automation(self, name: str, trigger_type: str, trigger_config: str,
|
||||
action_type: str, action_config: str, profile_id: int = 1,
|
||||
notify_type: str = None, notify_config: str = '{}'):
|
||||
"""Create a new automation. Returns the new automation ID or None."""
|
||||
try:
|
||||
with self._get_connection() as conn:
|
||||
cursor = conn.cursor()
|
||||
cursor.execute("""
|
||||
INSERT INTO automations (name, trigger_type, trigger_config, action_type, action_config, profile_id, notify_type, notify_config)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""", (name, trigger_type, trigger_config, action_type, action_config, profile_id, notify_type, notify_config))
|
||||
conn.commit()
|
||||
return cursor.lastrowid
|
||||
except Exception as e:
|
||||
logger.error(f"Error creating automation: {e}")
|
||||
return None
|
||||
|
||||
def get_automations(self, profile_id: int = 1):
|
||||
"""Get all automations for a profile."""
|
||||
try:
|
||||
with self._get_connection() as conn:
|
||||
cursor = conn.cursor()
|
||||
cursor.execute("""
|
||||
SELECT * FROM automations WHERE profile_id = ? ORDER BY created_at DESC
|
||||
""", (profile_id,))
|
||||
rows = cursor.fetchall()
|
||||
return [dict(row) for row in rows]
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting automations: {e}")
|
||||
return []
|
||||
|
||||
def get_automation(self, automation_id: int):
|
||||
"""Get a single automation by ID."""
|
||||
try:
|
||||
with self._get_connection() as conn:
|
||||
cursor = conn.cursor()
|
||||
cursor.execute("SELECT * FROM automations WHERE id = ?", (automation_id,))
|
||||
row = cursor.fetchone()
|
||||
return dict(row) if row else None
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting automation {automation_id}: {e}")
|
||||
return None
|
||||
|
||||
def update_automation(self, automation_id: int, **kwargs) -> bool:
|
||||
"""Update automation fields."""
|
||||
allowed = {'name', 'enabled', 'trigger_type', 'trigger_config', 'action_type', 'action_config', 'next_run', 'notify_type', 'notify_config', 'last_result'}
|
||||
updates = {k: v for k, v in kwargs.items() if k in allowed}
|
||||
if not updates:
|
||||
return False
|
||||
try:
|
||||
with self._get_connection() as conn:
|
||||
cursor = conn.cursor()
|
||||
set_clause = ', '.join(f"{k} = ?" for k in updates)
|
||||
values = list(updates.values()) + [automation_id]
|
||||
cursor.execute(
|
||||
f"UPDATE automations SET {set_clause}, updated_at = CURRENT_TIMESTAMP WHERE id = ?",
|
||||
values
|
||||
)
|
||||
conn.commit()
|
||||
return cursor.rowcount > 0
|
||||
except Exception as e:
|
||||
logger.error(f"Error updating automation {automation_id}: {e}")
|
||||
return False
|
||||
|
||||
def delete_automation(self, automation_id: int) -> bool:
|
||||
"""Delete an automation."""
|
||||
try:
|
||||
with self._get_connection() as conn:
|
||||
cursor = conn.cursor()
|
||||
cursor.execute("DELETE FROM automations WHERE id = ?", (automation_id,))
|
||||
conn.commit()
|
||||
return cursor.rowcount > 0
|
||||
except Exception as e:
|
||||
logger.error(f"Error deleting automation {automation_id}: {e}")
|
||||
return False
|
||||
|
||||
def toggle_automation(self, automation_id: int) -> bool:
|
||||
"""Toggle the enabled state of an automation. Returns True on success."""
|
||||
try:
|
||||
with self._get_connection() as conn:
|
||||
cursor = conn.cursor()
|
||||
cursor.execute(
|
||||
"UPDATE automations SET enabled = CASE WHEN enabled = 1 THEN 0 ELSE 1 END, updated_at = CURRENT_TIMESTAMP WHERE id = ?",
|
||||
(automation_id,)
|
||||
)
|
||||
conn.commit()
|
||||
return cursor.rowcount > 0
|
||||
except Exception as e:
|
||||
logger.error(f"Error toggling automation {automation_id}: {e}")
|
||||
return False
|
||||
|
||||
def update_automation_run(self, automation_id: int, next_run=None, error=None, last_result=None) -> bool:
|
||||
"""Record a run: set last_run=now, increment run_count, optionally set next_run, last_error, last_result."""
|
||||
try:
|
||||
with self._get_connection() as conn:
|
||||
cursor = conn.cursor()
|
||||
cursor.execute("""
|
||||
UPDATE automations
|
||||
SET last_run = CURRENT_TIMESTAMP,
|
||||
run_count = run_count + 1,
|
||||
next_run = ?,
|
||||
last_error = ?,
|
||||
last_result = ?,
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = ?
|
||||
""", (next_run, error, last_result, automation_id))
|
||||
conn.commit()
|
||||
return cursor.rowcount > 0
|
||||
except Exception as e:
|
||||
logger.error(f"Error updating automation run {automation_id}: {e}")
|
||||
return False
|
||||
|
||||
# Thread-safe singleton pattern for database access
|
||||
_database_instances: Dict[int, MusicDatabase] = {} # Thread ID -> Database instance
|
||||
_database_lock = threading.Lock()
|
||||
|
|
|
|||
811
web_server.py
811
web_server.py
|
|
@ -76,6 +76,7 @@ from core.spotify_worker import SpotifyWorker
|
|||
from core.itunes_worker import iTunesWorker
|
||||
from core.hydrabase_worker import HydrabaseWorker
|
||||
from core.hydrabase_client import HydrabaseClient
|
||||
from core.automation_engine import AutomationEngine
|
||||
|
||||
# --- Flask App Setup ---
|
||||
base_dir = os.path.abspath(os.path.dirname(__file__))
|
||||
|
|
@ -254,6 +255,267 @@ except Exception as e:
|
|||
print(f"🔴 FATAL: Error initializing service clients: {e}")
|
||||
spotify_client = plex_client = jellyfin_client = navidrome_client = soulseek_client = tidal_client = matching_engine = sync_service = web_scan_manager = None
|
||||
|
||||
# --- Automation Engine ---
|
||||
try:
|
||||
automation_engine = AutomationEngine(get_database())
|
||||
print("✅ Automation engine initialized.")
|
||||
except Exception as e:
|
||||
print(f"⚠️ Automation engine failed to initialize: {e}")
|
||||
automation_engine = None
|
||||
|
||||
def _register_automation_handlers():
|
||||
"""Register real SoulSync action handlers with the automation engine."""
|
||||
if not automation_engine:
|
||||
return
|
||||
|
||||
def _auto_process_wishlist(config):
|
||||
if is_wishlist_actually_processing():
|
||||
return {'status': 'skipped', 'reason': 'Wishlist already processing'}
|
||||
threading.Thread(target=_process_wishlist_automatically, daemon=True, name='auto-wishlist').start()
|
||||
return {'status': 'started', 'category': config.get('category', 'all')}
|
||||
|
||||
def _auto_scan_watchlist(config):
|
||||
if is_watchlist_actually_scanning():
|
||||
return {'status': 'skipped', 'reason': 'Watchlist already scanning'}
|
||||
threading.Thread(target=_process_watchlist_scan_automatically, daemon=True, name='auto-watchlist').start()
|
||||
return {'status': 'started'}
|
||||
|
||||
def _auto_scan_library(config):
|
||||
if web_scan_manager:
|
||||
result = web_scan_manager.request_scan('Automation trigger')
|
||||
return {'status': result.get('status', 'unknown')}
|
||||
return {'status': 'error', 'reason': 'Scan manager not available'}
|
||||
|
||||
automation_engine.register_action_handler('process_wishlist', _auto_process_wishlist, is_wishlist_actually_processing)
|
||||
automation_engine.register_action_handler('scan_watchlist', _auto_scan_watchlist, is_watchlist_actually_scanning)
|
||||
automation_engine.register_action_handler('scan_library', _auto_scan_library)
|
||||
|
||||
def _auto_refresh_mirrored(config):
|
||||
"""Refresh mirrored playlist(s) from source."""
|
||||
db = get_database()
|
||||
playlist_id = config.get('playlist_id')
|
||||
refresh_all = config.get('all', False)
|
||||
|
||||
if refresh_all:
|
||||
playlists = db.get_mirrored_playlists()
|
||||
elif playlist_id:
|
||||
p = db.get_mirrored_playlist(int(playlist_id))
|
||||
playlists = [p] if p else []
|
||||
else:
|
||||
return {'status': 'error', 'reason': 'No playlist specified'}
|
||||
|
||||
refreshed = 0
|
||||
errors = []
|
||||
for pl in playlists:
|
||||
try:
|
||||
source = pl.get('source', '')
|
||||
source_id = pl.get('source_playlist_id', '')
|
||||
if source == 'spotify' and spotify_client and spotify_client.is_spotify_authenticated():
|
||||
playlist_obj = spotify_client.get_playlist_by_id(source_id)
|
||||
if playlist_obj and playlist_obj.tracks:
|
||||
tracks = []
|
||||
for t in playlist_obj.tracks:
|
||||
# Track.artists is List[str], not List[dict]
|
||||
artist_name = t.artists[0] if t.artists else ''
|
||||
tracks.append({
|
||||
'track_name': t.name or '',
|
||||
'artist_name': str(artist_name),
|
||||
'album_name': t.album or '',
|
||||
'duration_ms': t.duration_ms or 0,
|
||||
'source_track_id': t.id or '',
|
||||
})
|
||||
# Compare old vs new track IDs to detect changes
|
||||
old_tracks = db.get_mirrored_playlist_tracks(pl['id']) if pl.get('id') else []
|
||||
old_ids = {t.get('source_track_id') for t in old_tracks if t.get('source_track_id')}
|
||||
new_ids = {t.get('source_track_id') for t in tracks if t.get('source_track_id')}
|
||||
|
||||
db.mirror_playlist(
|
||||
source=source,
|
||||
source_playlist_id=source_id,
|
||||
name=pl['name'],
|
||||
tracks=tracks,
|
||||
profile_id=pl.get('profile_id', 1),
|
||||
# Preserve existing image/owner — Playlist dataclass lacks image_url
|
||||
owner=getattr(playlist_obj, 'owner', pl.get('owner')),
|
||||
image_url=pl.get('image_url'),
|
||||
)
|
||||
refreshed += 1
|
||||
|
||||
# Emit playlist_changed if tracks actually changed
|
||||
if old_ids != new_ids:
|
||||
try:
|
||||
if automation_engine:
|
||||
automation_engine.emit('playlist_changed', {
|
||||
'playlist_name': pl.get('name', ''),
|
||||
'old_count': str(len(old_ids)),
|
||||
'new_count': str(len(new_ids)),
|
||||
'added': str(len(new_ids - old_ids)),
|
||||
'removed': str(len(old_ids - new_ids)),
|
||||
})
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as e:
|
||||
errors.append(f"{pl.get('name', '?')}: {str(e)}")
|
||||
return {'status': 'completed', 'refreshed': str(refreshed), 'errors': str(len(errors))}
|
||||
|
||||
def _auto_sync_playlist(config):
|
||||
"""Sync a mirrored playlist to media server."""
|
||||
playlist_id = config.get('playlist_id')
|
||||
if not playlist_id:
|
||||
return {'status': 'error', 'reason': 'No playlist specified'}
|
||||
|
||||
db = get_database()
|
||||
pl = db.get_mirrored_playlist(int(playlist_id))
|
||||
if not pl:
|
||||
return {'status': 'error', 'reason': 'Playlist not found'}
|
||||
|
||||
tracks = db.get_mirrored_playlist_tracks(int(playlist_id))
|
||||
if not tracks:
|
||||
return {'status': 'error', 'reason': 'No tracks in playlist'}
|
||||
|
||||
# Convert mirrored tracks to format expected by _run_sync_task
|
||||
tracks_json = []
|
||||
for t in tracks:
|
||||
tracks_json.append({
|
||||
'name': t.get('track_name', ''),
|
||||
'artists': [{'name': t.get('artist_name', '')}],
|
||||
'album': t.get('album_name', ''),
|
||||
'duration_ms': t.get('duration_ms', 0),
|
||||
'id': t.get('source_track_id', ''),
|
||||
})
|
||||
|
||||
sync_id = f"auto_mirror_{playlist_id}"
|
||||
threading.Thread(
|
||||
target=_run_sync_task,
|
||||
args=(sync_id, pl['name'], json.dumps(tracks_json)),
|
||||
daemon=True,
|
||||
name=f'auto-sync-{playlist_id}'
|
||||
).start()
|
||||
return {'status': 'started', 'playlist_name': pl['name']}
|
||||
|
||||
automation_engine.register_action_handler('refresh_mirrored', _auto_refresh_mirrored)
|
||||
automation_engine.register_action_handler('sync_playlist', _auto_sync_playlist)
|
||||
|
||||
# --- Phase 3 action handlers ---
|
||||
|
||||
def _auto_start_database_update(config):
|
||||
if db_update_state.get('status') == 'running':
|
||||
return {'status': 'skipped', 'reason': 'Database update already running'}
|
||||
full = config.get('full_refresh', False)
|
||||
active_server = config_manager.get_active_media_server()
|
||||
with db_update_lock:
|
||||
db_update_state.update({
|
||||
"status": "running", "phase": "Initializing...",
|
||||
"progress": 0, "current_item": "", "processed": 0, "total": 0, "error_message": ""
|
||||
})
|
||||
db_update_executor.submit(_run_db_update_task, full, active_server)
|
||||
return {'status': 'started', 'full_refresh': str(full)}
|
||||
|
||||
def _auto_run_duplicate_cleaner(config):
|
||||
if duplicate_cleaner_state.get('status') == 'running':
|
||||
return {'status': 'skipped', 'reason': 'Duplicate cleaner already running'}
|
||||
duplicate_cleaner_executor.submit(_run_duplicate_cleaner)
|
||||
return {'status': 'started'}
|
||||
|
||||
def _auto_clear_quarantine(config):
|
||||
import shutil as _shutil
|
||||
quarantine_path = os.path.join(docker_resolve_path(config_manager.get('soulseek.download_path', './downloads')), 'ss_quarantine')
|
||||
if not os.path.exists(quarantine_path):
|
||||
return {'status': 'completed', 'removed': '0'}
|
||||
removed = 0
|
||||
for f in os.listdir(quarantine_path):
|
||||
fp = os.path.join(quarantine_path, f)
|
||||
try:
|
||||
if os.path.isfile(fp):
|
||||
os.remove(fp)
|
||||
removed += 1
|
||||
elif os.path.isdir(fp):
|
||||
_shutil.rmtree(fp)
|
||||
removed += 1
|
||||
except Exception:
|
||||
pass
|
||||
return {'status': 'completed', 'removed': str(removed)}
|
||||
|
||||
def _auto_cleanup_wishlist(config):
|
||||
db = get_database()
|
||||
removed = db.remove_wishlist_duplicates(get_current_profile_id())
|
||||
return {'status': 'completed', 'removed': str(removed or 0)}
|
||||
|
||||
def _auto_update_discovery_pool(config):
|
||||
try:
|
||||
from core.watchlist_scanner import get_watchlist_scanner
|
||||
scanner = get_watchlist_scanner(spotify_client)
|
||||
threading.Thread(target=scanner.update_discovery_pool_incremental,
|
||||
args=(get_current_profile_id(),), daemon=True).start()
|
||||
return {'status': 'started'}
|
||||
except Exception as e:
|
||||
return {'status': 'error', 'reason': str(e)}
|
||||
|
||||
def _auto_start_quality_scan(config):
|
||||
if quality_scanner_state.get('status') == 'running':
|
||||
return {'status': 'skipped', 'reason': 'Quality scan already running'}
|
||||
scope = config.get('scope', 'watchlist')
|
||||
quality_scanner_executor.submit(_run_quality_scanner, scope, get_current_profile_id())
|
||||
return {'status': 'started', 'scope': scope}
|
||||
|
||||
def _auto_backup_database(config):
|
||||
import sqlite3, glob as _glob
|
||||
db_path = os.environ.get('DATABASE_PATH', 'database/music_library.db')
|
||||
if not os.path.exists(db_path):
|
||||
return {'status': 'error', 'reason': 'Database file not found'}
|
||||
max_backups = 3
|
||||
timestamp = datetime.now().strftime('%Y%m%d_%H%M%S')
|
||||
backup_path = f"{db_path}.backup_{timestamp}"
|
||||
# Use SQLite backup API for safe hot-copy of active database
|
||||
src = sqlite3.connect(db_path)
|
||||
dst = sqlite3.connect(backup_path)
|
||||
src.backup(dst)
|
||||
dst.close()
|
||||
src.close()
|
||||
size_mb = round(os.path.getsize(backup_path) / (1024 * 1024), 1)
|
||||
# Rolling cleanup — keep only the newest N backups
|
||||
existing = sorted(_glob.glob(f"{db_path}.backup_*"), key=os.path.getmtime)
|
||||
while len(existing) > max_backups:
|
||||
try:
|
||||
os.remove(existing.pop(0))
|
||||
except Exception:
|
||||
pass
|
||||
return {'status': 'completed', 'backup_path': backup_path, 'size_mb': str(size_mb)}
|
||||
|
||||
automation_engine.register_action_handler('start_database_update', _auto_start_database_update,
|
||||
lambda: db_update_state.get('status') == 'running')
|
||||
automation_engine.register_action_handler('run_duplicate_cleaner', _auto_run_duplicate_cleaner,
|
||||
lambda: duplicate_cleaner_state.get('status') == 'running')
|
||||
automation_engine.register_action_handler('clear_quarantine', _auto_clear_quarantine)
|
||||
automation_engine.register_action_handler('cleanup_wishlist', _auto_cleanup_wishlist)
|
||||
automation_engine.register_action_handler('update_discovery_pool', _auto_update_discovery_pool)
|
||||
automation_engine.register_action_handler('start_quality_scan', _auto_start_quality_scan,
|
||||
lambda: quality_scanner_state.get('status') == 'running')
|
||||
automation_engine.register_action_handler('backup_database', _auto_backup_database)
|
||||
|
||||
print("✅ Automation action handlers registered")
|
||||
|
||||
|
||||
def _emit_track_downloaded(context):
|
||||
"""Emit track_downloaded event for automation engine. Safe to call anywhere."""
|
||||
try:
|
||||
if not automation_engine:
|
||||
return
|
||||
ti = context.get('track_info') or context.get('search_result') or {}
|
||||
artist_name = ''
|
||||
artists = ti.get('artists', [])
|
||||
if artists:
|
||||
a = artists[0]
|
||||
artist_name = a.get('name', str(a)) if isinstance(a, dict) else str(a)
|
||||
automation_engine.emit('track_downloaded', {
|
||||
'artist': artist_name,
|
||||
'title': ti.get('name', ti.get('title', '')),
|
||||
'album': ti.get('album', ''),
|
||||
'quality': context.get('_audio_quality', 'Unknown'),
|
||||
})
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# --- Register Public REST API Blueprint (v1) ---
|
||||
try:
|
||||
from api import create_api_blueprint, limiter
|
||||
|
|
@ -1449,6 +1711,14 @@ def signal_handler(signum, frame):
|
|||
IS_SHUTTING_DOWN = True
|
||||
cleanup_monitor()
|
||||
|
||||
# Stop automation engine
|
||||
try:
|
||||
if automation_engine:
|
||||
print("🛑 Stopping automation engine...")
|
||||
automation_engine.stop()
|
||||
except Exception as e:
|
||||
print(f"⚠️ Error stopping automation engine: {e}")
|
||||
|
||||
# Shutdown executor to prevent new tasks
|
||||
try:
|
||||
print("🛑 Shutting down missing_download_executor...")
|
||||
|
|
@ -3095,6 +3365,323 @@ def handle_log_level():
|
|||
logger.error(f"Error getting log level: {e}")
|
||||
return jsonify({"success": False, "error": str(e)}), 500
|
||||
|
||||
# ===========================
|
||||
# AUTOMATIONS API
|
||||
# ===========================
|
||||
|
||||
@app.route('/api/automations', methods=['GET'])
|
||||
def list_automations():
|
||||
"""List all automations for the current profile."""
|
||||
try:
|
||||
profile_id = session.get('profile_id', 1)
|
||||
db = get_database()
|
||||
automations = db.get_automations(profile_id)
|
||||
# Parse JSON config fields for frontend
|
||||
for auto in automations:
|
||||
for field in ('trigger_config', 'action_config', 'notify_config', 'last_result'):
|
||||
try:
|
||||
auto[field] = json.loads(auto[field]) if isinstance(auto[field], str) else auto[field]
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
if field in ('trigger_config', 'action_config', 'notify_config'):
|
||||
auto[field] = {}
|
||||
else:
|
||||
auto[field] = None
|
||||
return jsonify(automations)
|
||||
except Exception as e:
|
||||
logger.error(f"Error listing automations: {e}")
|
||||
return jsonify({"error": str(e)}), 500
|
||||
|
||||
@app.route('/api/automations', methods=['POST'])
|
||||
def create_automation():
|
||||
"""Create a new automation."""
|
||||
try:
|
||||
data = request.get_json()
|
||||
name = data.get('name', '').strip()
|
||||
if not name:
|
||||
return jsonify({"error": "Name is required"}), 400
|
||||
|
||||
trigger_type = data.get('trigger_type', 'schedule')
|
||||
trigger_config = json.dumps(data.get('trigger_config', {}))
|
||||
action_type = data.get('action_type', 'process_wishlist')
|
||||
action_config = json.dumps(data.get('action_config', {}))
|
||||
notify_type = data.get('notify_type') or None
|
||||
notify_config = json.dumps(data.get('notify_config', {})) if notify_type else '{}'
|
||||
profile_id = session.get('profile_id', 1)
|
||||
|
||||
db = get_database()
|
||||
auto_id = db.create_automation(name, trigger_type, trigger_config, action_type, action_config, profile_id, notify_type, notify_config)
|
||||
if auto_id is None:
|
||||
return jsonify({"error": "Failed to create automation"}), 500
|
||||
|
||||
# Schedule it
|
||||
if automation_engine:
|
||||
automation_engine.schedule_automation(auto_id)
|
||||
|
||||
return jsonify({"success": True, "id": auto_id})
|
||||
except Exception as e:
|
||||
logger.error(f"Error creating automation: {e}")
|
||||
return jsonify({"error": str(e)}), 500
|
||||
|
||||
@app.route('/api/automations/<int:automation_id>', methods=['GET'])
|
||||
def get_automation(automation_id):
|
||||
"""Get a single automation."""
|
||||
try:
|
||||
db = get_database()
|
||||
auto = db.get_automation(automation_id)
|
||||
if not auto:
|
||||
return jsonify({"error": "Automation not found"}), 404
|
||||
for field in ('trigger_config', 'action_config', 'notify_config', 'last_result'):
|
||||
try:
|
||||
auto[field] = json.loads(auto[field]) if isinstance(auto[field], str) else auto[field]
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
if field in ('trigger_config', 'action_config', 'notify_config'):
|
||||
auto[field] = {}
|
||||
else:
|
||||
auto[field] = None
|
||||
return jsonify(auto)
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting automation: {e}")
|
||||
return jsonify({"error": str(e)}), 500
|
||||
|
||||
@app.route('/api/automations/<int:automation_id>', methods=['PUT'])
|
||||
def update_automation_endpoint(automation_id):
|
||||
"""Update an automation."""
|
||||
try:
|
||||
data = request.get_json()
|
||||
db = get_database()
|
||||
|
||||
update_fields = {}
|
||||
if 'name' in data:
|
||||
update_fields['name'] = data['name'].strip()
|
||||
if 'trigger_type' in data:
|
||||
update_fields['trigger_type'] = data['trigger_type']
|
||||
if 'trigger_config' in data:
|
||||
update_fields['trigger_config'] = json.dumps(data['trigger_config'])
|
||||
if 'action_type' in data:
|
||||
update_fields['action_type'] = data['action_type']
|
||||
if 'action_config' in data:
|
||||
update_fields['action_config'] = json.dumps(data['action_config'])
|
||||
if 'notify_type' in data:
|
||||
update_fields['notify_type'] = data['notify_type'] or None
|
||||
if 'notify_config' in data:
|
||||
update_fields['notify_config'] = json.dumps(data['notify_config'])
|
||||
|
||||
if not update_fields:
|
||||
return jsonify({"error": "No fields to update"}), 400
|
||||
|
||||
success = db.update_automation(automation_id, **update_fields)
|
||||
if not success:
|
||||
return jsonify({"error": "Automation not found"}), 404
|
||||
|
||||
# Reschedule
|
||||
if automation_engine:
|
||||
auto = db.get_automation(automation_id)
|
||||
if auto and auto.get('enabled'):
|
||||
automation_engine.schedule_automation(automation_id)
|
||||
else:
|
||||
automation_engine.cancel_automation(automation_id)
|
||||
|
||||
return jsonify({"success": True})
|
||||
except Exception as e:
|
||||
logger.error(f"Error updating automation: {e}")
|
||||
return jsonify({"error": str(e)}), 500
|
||||
|
||||
@app.route('/api/automations/<int:automation_id>', methods=['DELETE'])
|
||||
def delete_automation_endpoint(automation_id):
|
||||
"""Delete an automation."""
|
||||
try:
|
||||
if automation_engine:
|
||||
automation_engine.cancel_automation(automation_id)
|
||||
db = get_database()
|
||||
success = db.delete_automation(automation_id)
|
||||
if not success:
|
||||
return jsonify({"error": "Automation not found"}), 404
|
||||
return jsonify({"success": True})
|
||||
except Exception as e:
|
||||
logger.error(f"Error deleting automation: {e}")
|
||||
return jsonify({"error": str(e)}), 500
|
||||
|
||||
@app.route('/api/automations/<int:automation_id>/toggle', methods=['POST'])
|
||||
def toggle_automation_endpoint(automation_id):
|
||||
"""Toggle an automation's enabled state."""
|
||||
try:
|
||||
db = get_database()
|
||||
success = db.toggle_automation(automation_id)
|
||||
if not success:
|
||||
return jsonify({"error": "Automation not found"}), 404
|
||||
|
||||
# Reschedule or cancel based on new state
|
||||
if automation_engine:
|
||||
auto = db.get_automation(automation_id)
|
||||
if auto and auto.get('enabled'):
|
||||
automation_engine.schedule_automation(automation_id)
|
||||
else:
|
||||
automation_engine.cancel_automation(automation_id)
|
||||
|
||||
return jsonify({"success": True})
|
||||
except Exception as e:
|
||||
logger.error(f"Error toggling automation: {e}")
|
||||
return jsonify({"error": str(e)}), 500
|
||||
|
||||
@app.route('/api/automations/<int:automation_id>/run', methods=['POST'])
|
||||
def run_automation_endpoint(automation_id):
|
||||
"""Manually trigger an automation."""
|
||||
try:
|
||||
if not automation_engine:
|
||||
return jsonify({"error": "Automation engine not available"}), 500
|
||||
success = automation_engine.run_now(automation_id)
|
||||
if not success:
|
||||
return jsonify({"error": "Automation not found"}), 404
|
||||
return jsonify({"success": True})
|
||||
except Exception as e:
|
||||
logger.error(f"Error running automation: {e}")
|
||||
return jsonify({"error": str(e)}), 500
|
||||
|
||||
@app.route('/api/automations/blocks', methods=['GET'])
|
||||
def get_automation_blocks():
|
||||
"""Return available block types for the automation builder sidebar."""
|
||||
return jsonify({
|
||||
"triggers": [
|
||||
{"type": "schedule", "label": "Schedule", "icon": "clock", "description": "Run on a timer interval", "available": True,
|
||||
"config_fields": [
|
||||
{"key": "interval", "type": "number", "label": "Every", "default": 6, "min": 1},
|
||||
{"key": "unit", "type": "select", "label": "Unit",
|
||||
"options": [{"value": "minutes", "label": "Minutes"}, {"value": "hours", "label": "Hours"}, {"value": "days", "label": "Days"}],
|
||||
"default": "hours"}
|
||||
]},
|
||||
{"type": "daily_time", "label": "Daily Time", "icon": "clock", "description": "Run every day at a specific time", "available": True,
|
||||
"config_fields": [
|
||||
{"key": "time", "type": "time", "label": "At", "default": "03:00"}
|
||||
]},
|
||||
{"type": "weekly_time", "label": "Weekly Schedule", "icon": "calendar", "description": "Run on specific days of the week at a set time", "available": True,
|
||||
"config_fields": [
|
||||
{"key": "time", "type": "time", "label": "At", "default": "03:00"},
|
||||
{"key": "days", "type": "multi_select", "label": "Days",
|
||||
"options": [{"value": "mon", "label": "Mon"}, {"value": "tue", "label": "Tue"}, {"value": "wed", "label": "Wed"},
|
||||
{"value": "thu", "label": "Thu"}, {"value": "fri", "label": "Fri"}, {"value": "sat", "label": "Sat"}, {"value": "sun", "label": "Sun"}]}
|
||||
]},
|
||||
{"type": "app_started", "label": "App Started", "icon": "power", "description": "When SoulSync starts up", "available": True},
|
||||
{"type": "track_downloaded", "label": "Track Downloaded", "icon": "download", "description": "When a track finishes downloading", "available": True,
|
||||
"has_conditions": True,
|
||||
"condition_fields": ["artist", "title", "album", "quality"],
|
||||
"variables": ["artist", "title", "album", "quality"]},
|
||||
{"type": "batch_complete", "label": "Batch Complete", "icon": "check-circle", "description": "When an album/playlist download finishes", "available": True,
|
||||
"has_conditions": True,
|
||||
"condition_fields": ["playlist_name"],
|
||||
"variables": ["playlist_name", "total_tracks", "completed_tracks", "failed_tracks"]},
|
||||
{"type": "watchlist_new_release", "label": "New Release Found", "icon": "bell", "description": "When watchlist detects new music", "available": True,
|
||||
"has_conditions": True,
|
||||
"condition_fields": ["artist"],
|
||||
"variables": ["artist", "new_tracks", "added_to_wishlist"]},
|
||||
{"type": "playlist_synced", "label": "Playlist Synced", "icon": "refresh", "description": "When a playlist sync completes", "available": True,
|
||||
"has_conditions": True,
|
||||
"condition_fields": ["playlist_name"],
|
||||
"variables": ["playlist_name", "total_tracks", "matched_tracks", "synced_tracks", "failed_tracks"]},
|
||||
{"type": "playlist_changed", "label": "Playlist Changed", "icon": "edit", "description": "When a mirrored playlist detects track changes from source", "available": True,
|
||||
"has_conditions": True,
|
||||
"condition_fields": ["playlist_name"],
|
||||
"variables": ["playlist_name", "old_count", "new_count", "added", "removed"]},
|
||||
# Phase 3 triggers
|
||||
{"type": "wishlist_processing_completed", "label": "Wishlist Processed", "icon": "check-circle",
|
||||
"description": "When auto-wishlist processing finishes", "available": True,
|
||||
"variables": ["tracks_processed", "tracks_found", "tracks_failed"]},
|
||||
{"type": "watchlist_scan_completed", "label": "Watchlist Scan Done", "icon": "check-circle",
|
||||
"description": "When watchlist scan finishes", "available": True,
|
||||
"variables": ["artists_scanned", "new_tracks_found", "tracks_added"]},
|
||||
{"type": "database_update_completed", "label": "Database Updated", "icon": "database",
|
||||
"description": "When library database refresh finishes", "available": True,
|
||||
"variables": ["total_artists", "total_albums", "total_tracks"]},
|
||||
{"type": "download_failed", "label": "Download Failed", "icon": "x-circle",
|
||||
"description": "When a track permanently fails to download", "available": True,
|
||||
"has_conditions": True, "condition_fields": ["artist", "title", "reason"],
|
||||
"variables": ["artist", "title", "reason"]},
|
||||
{"type": "download_quarantined", "label": "File Quarantined", "icon": "alert-triangle",
|
||||
"description": "When AcoustID verification fails", "available": True,
|
||||
"has_conditions": True, "condition_fields": ["artist", "title"],
|
||||
"variables": ["artist", "title", "reason"]},
|
||||
{"type": "wishlist_item_added", "label": "Wishlist Item Added", "icon": "plus-circle",
|
||||
"description": "When a track is added to wishlist", "available": True,
|
||||
"has_conditions": True, "condition_fields": ["artist", "title"],
|
||||
"variables": ["artist", "title", "reason"]},
|
||||
{"type": "watchlist_artist_added", "label": "Artist Watched", "icon": "user-plus",
|
||||
"description": "When an artist is added to watchlist", "available": True,
|
||||
"has_conditions": True, "condition_fields": ["artist"],
|
||||
"variables": ["artist", "artist_id"]},
|
||||
{"type": "watchlist_artist_removed", "label": "Artist Unwatched", "icon": "user-minus",
|
||||
"description": "When an artist is removed from watchlist", "available": True,
|
||||
"has_conditions": True, "condition_fields": ["artist"],
|
||||
"variables": ["artist", "artist_id"]},
|
||||
{"type": "import_completed", "label": "Import Complete", "icon": "upload",
|
||||
"description": "When album/track import finishes", "available": True,
|
||||
"has_conditions": True, "condition_fields": ["artist", "album_name"],
|
||||
"variables": ["track_count", "album_name", "artist"]},
|
||||
{"type": "mirrored_playlist_created", "label": "Playlist Mirrored", "icon": "copy",
|
||||
"description": "When a new playlist is mirrored", "available": True,
|
||||
"has_conditions": True, "condition_fields": ["playlist_name", "source"],
|
||||
"variables": ["playlist_name", "source", "track_count"]},
|
||||
{"type": "quality_scan_completed", "label": "Quality Scan Done", "icon": "bar-chart",
|
||||
"description": "When quality scan finishes", "available": True,
|
||||
"variables": ["quality_met", "low_quality", "total_scanned"]},
|
||||
{"type": "duplicate_scan_completed", "label": "Duplicate Scan Done", "icon": "layers",
|
||||
"description": "When duplicate cleaner finishes", "available": True,
|
||||
"variables": ["files_scanned", "duplicates_found", "space_freed"]},
|
||||
],
|
||||
"actions": [
|
||||
{"type": "process_wishlist", "label": "Process Wishlist", "icon": "list", "description": "Retry failed downloads from wishlist", "available": True,
|
||||
"config_fields": [{"key": "category", "type": "select", "label": "Category", "options": [{"value": "all", "label": "All"}, {"value": "albums", "label": "Albums"}, {"value": "singles", "label": "Singles"}], "default": "all"}]},
|
||||
{"type": "scan_watchlist", "label": "Scan Watchlist", "icon": "eye", "description": "Check watched artists for new releases", "available": True},
|
||||
{"type": "scan_library", "label": "Scan Library", "icon": "refresh", "description": "Trigger media server library scan", "available": True},
|
||||
{"type": "refresh_mirrored", "label": "Refresh Mirrored Playlist", "icon": "copy", "description": "Re-fetch playlist from source and update mirror", "available": True,
|
||||
"config_fields": [
|
||||
{"key": "playlist_id", "type": "mirrored_playlist_select", "label": "Playlist"},
|
||||
{"key": "all", "type": "checkbox", "label": "Refresh all mirrored playlists", "default": False}
|
||||
]},
|
||||
{"type": "sync_playlist", "label": "Sync Playlist", "icon": "sync", "description": "Sync mirrored playlist to media server", "available": True,
|
||||
"config_fields": [
|
||||
{"key": "playlist_id", "type": "mirrored_playlist_select", "label": "Playlist"}
|
||||
]},
|
||||
{"type": "notify_only", "label": "Notify Only", "icon": "bell", "description": "No action — just send notification", "available": True},
|
||||
# Phase 3 actions
|
||||
{"type": "start_database_update", "label": "Update Database", "icon": "database",
|
||||
"description": "Trigger library database refresh", "available": True,
|
||||
"config_fields": [
|
||||
{"key": "full_refresh", "type": "checkbox", "label": "Full refresh (slower)", "default": False}
|
||||
]},
|
||||
{"type": "run_duplicate_cleaner", "label": "Run Duplicate Cleaner", "icon": "layers",
|
||||
"description": "Scan for and remove duplicate files", "available": True},
|
||||
{"type": "clear_quarantine", "label": "Clear Quarantine", "icon": "trash",
|
||||
"description": "Delete all quarantined files", "available": True},
|
||||
{"type": "cleanup_wishlist", "label": "Clean Up Wishlist", "icon": "filter",
|
||||
"description": "Remove duplicate/owned tracks from wishlist", "available": True},
|
||||
{"type": "update_discovery_pool", "label": "Update Discovery", "icon": "compass",
|
||||
"description": "Refresh discovery pool with new tracks", "available": True},
|
||||
{"type": "start_quality_scan", "label": "Run Quality Scan", "icon": "bar-chart",
|
||||
"description": "Scan for low-quality audio files", "available": True,
|
||||
"config_fields": [
|
||||
{"key": "scope", "type": "select", "label": "Scope",
|
||||
"options": [{"value": "watchlist", "label": "Watchlist Artists"}, {"value": "library", "label": "Full Library"}],
|
||||
"default": "watchlist"}
|
||||
]},
|
||||
{"type": "backup_database", "label": "Backup Database", "icon": "save",
|
||||
"description": "Create timestamped database backup", "available": True},
|
||||
],
|
||||
"notifications": [
|
||||
{"type": "discord_webhook", "label": "Discord Webhook", "icon": "message", "description": "Send a Discord notification", "available": True,
|
||||
"variables": ["time", "name", "run_count", "status"]},
|
||||
]
|
||||
})
|
||||
|
||||
@app.route('/api/mirrored-playlists/list', methods=['GET'])
|
||||
def get_mirrored_playlists_list():
|
||||
"""Return simple list of mirrored playlists for automation config dropdowns."""
|
||||
try:
|
||||
database = get_database()
|
||||
profile_id = get_current_profile_id()
|
||||
playlists = database.get_mirrored_playlists(profile_id=profile_id)
|
||||
return jsonify([{"id": p['id'], "name": p['name']} for p in playlists])
|
||||
except Exception as e:
|
||||
return jsonify([]), 200
|
||||
|
||||
@app.route('/api/test-connection', methods=['POST'])
|
||||
def test_connection_endpoint():
|
||||
data = request.get_json()
|
||||
|
|
@ -10712,6 +11299,23 @@ def _move_to_quarantine(file_path: str, context: dict, reason: str) -> str:
|
|||
logger.warning(f"Failed to write quarantine metadata: {e}")
|
||||
|
||||
logger.warning(f"🚫 File quarantined: {quarantine_path} - Reason: {reason}")
|
||||
|
||||
try:
|
||||
if automation_engine:
|
||||
ti = context.get('track_info', {})
|
||||
artists = ti.get('artists', [])
|
||||
artist_name = ''
|
||||
if artists:
|
||||
a = artists[0]
|
||||
artist_name = a.get('name', str(a)) if isinstance(a, dict) else str(a)
|
||||
automation_engine.emit('download_quarantined', {
|
||||
'artist': artist_name,
|
||||
'title': ti.get('name', ''),
|
||||
'reason': reason or 'Unknown',
|
||||
})
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return str(quarantine_path)
|
||||
|
||||
|
||||
|
|
@ -11014,6 +11618,7 @@ def _post_process_matched_download(context_key, context, file_path):
|
|||
# Set flag in context so verification function knows this was fully handled
|
||||
context['_simple_download_completed'] = True
|
||||
context['_final_path'] = str(destination)
|
||||
_emit_track_downloaded(context)
|
||||
return
|
||||
# --- END SIMPLE DOWNLOAD HANDLING ---
|
||||
|
||||
|
|
@ -11083,6 +11688,8 @@ def _post_process_matched_download(context_key, context, file_path):
|
|||
except Exception as wishlist_error:
|
||||
print(f"⚠️ [Playlist Folder] Error checking wishlist removal: {wishlist_error}")
|
||||
|
||||
_emit_track_downloaded(context)
|
||||
|
||||
# NOTE: Don't call callbacks here - let verification function handle completion
|
||||
# The verification function will check file exists and then call callbacks
|
||||
return # Skip normal album/artist folder structure processing
|
||||
|
|
@ -11380,6 +11987,8 @@ def _post_process_matched_download(context_key, context, file_path):
|
|||
|
||||
print(f"✅ Post-processing complete for: {context.get('_final_processed_path', final_path)}")
|
||||
|
||||
_emit_track_downloaded(context)
|
||||
|
||||
# RETAG DATA CAPTURE: Record completed album/single downloads for retag tool
|
||||
try:
|
||||
if not playlist_folder_mode:
|
||||
|
|
@ -12904,7 +13513,17 @@ def _db_update_finished_callback(total_artists, total_albums, total_tracks, succ
|
|||
if removed_artists > 0 or removed_albums > 0:
|
||||
summary += f" | {removed_artists} artists, {removed_albums} albums removed"
|
||||
add_activity_item("✅", "Database Update Complete", summary, "Now")
|
||||
|
||||
|
||||
try:
|
||||
if automation_engine:
|
||||
automation_engine.emit('database_update_completed', {
|
||||
'total_artists': str(total_artists),
|
||||
'total_albums': str(total_albums),
|
||||
'total_tracks': str(total_tracks),
|
||||
})
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# WISHLIST CLEANUP: Automatically clean up wishlist after database update
|
||||
try:
|
||||
print("📋 [DB Update] Database update completed, starting automatic wishlist cleanup...")
|
||||
|
|
@ -13901,6 +14520,34 @@ def stop_database_update():
|
|||
else:
|
||||
return jsonify({"success": False, "error": "No update is currently running."}), 404
|
||||
|
||||
@app.route('/api/database/backup', methods=['POST'])
|
||||
def backup_database_endpoint():
|
||||
"""Create a rolling backup of the database (max 3)."""
|
||||
try:
|
||||
import sqlite3, glob as _glob
|
||||
db_path = os.environ.get('DATABASE_PATH', 'database/music_library.db')
|
||||
if not os.path.exists(db_path):
|
||||
return jsonify({"success": False, "error": "Database file not found"}), 404
|
||||
max_backups = 3
|
||||
timestamp = datetime.now().strftime('%Y%m%d_%H%M%S')
|
||||
backup_path = f"{db_path}.backup_{timestamp}"
|
||||
src = sqlite3.connect(db_path)
|
||||
dst = sqlite3.connect(backup_path)
|
||||
src.backup(dst)
|
||||
dst.close()
|
||||
src.close()
|
||||
size_mb = round(os.path.getsize(backup_path) / (1024 * 1024), 1)
|
||||
# Rolling cleanup
|
||||
existing = sorted(_glob.glob(f"{db_path}.backup_*"), key=os.path.getmtime)
|
||||
while len(existing) > max_backups:
|
||||
try:
|
||||
os.remove(existing.pop(0))
|
||||
except Exception:
|
||||
pass
|
||||
return jsonify({"success": True, "backup_path": backup_path, "size_mb": size_mb})
|
||||
except Exception as e:
|
||||
return jsonify({"success": False, "error": str(e)}), 500
|
||||
|
||||
# ===============================
|
||||
# == QUALITY SCANNER ==
|
||||
# ===============================
|
||||
|
|
@ -14232,6 +14879,16 @@ def _run_quality_scanner(scope='watchlist', profile_id=1):
|
|||
add_activity_item("🔍", "Quality Scan Complete",
|
||||
f"{quality_scanner_state['matched']} tracks added to wishlist", "Now")
|
||||
|
||||
try:
|
||||
if automation_engine:
|
||||
automation_engine.emit('quality_scan_completed', {
|
||||
'quality_met': str(quality_scanner_state.get('quality_met', 0)),
|
||||
'low_quality': str(quality_scanner_state.get('low_quality', 0)),
|
||||
'total_scanned': str(quality_scanner_state.get('processed', 0)),
|
||||
})
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
except Exception as e:
|
||||
print(f"❌ [Quality Scanner] Critical error: {e}")
|
||||
import traceback
|
||||
|
|
@ -14416,6 +15073,16 @@ def _run_duplicate_cleaner():
|
|||
add_activity_item("🧹", "Duplicate Cleaner Complete",
|
||||
f"{deleted_count} files removed, {space_mb:.1f} MB freed", "Now")
|
||||
|
||||
try:
|
||||
if automation_engine:
|
||||
automation_engine.emit('duplicate_scan_completed', {
|
||||
'files_scanned': str(files_scanned),
|
||||
'duplicates_found': str(duplicates_found),
|
||||
'space_freed': f"{space_mb:.1f} MB",
|
||||
})
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
except Exception as e:
|
||||
print(f"❌ [Duplicate Cleaner] Critical error: {e}")
|
||||
import traceback
|
||||
|
|
@ -15083,6 +15750,15 @@ def _process_failed_tracks_to_wishlist_exact(batch_id):
|
|||
if success:
|
||||
wishlist_added_count += 1
|
||||
print(f"✅ [Wishlist Processing] Added {track_name} to wishlist")
|
||||
try:
|
||||
if automation_engine:
|
||||
automation_engine.emit('wishlist_item_added', {
|
||||
'artist': artist_name,
|
||||
'title': track_name,
|
||||
'reason': track.get('failure_reason', ''),
|
||||
})
|
||||
except Exception:
|
||||
pass
|
||||
else:
|
||||
print(f"⚠️ [Wishlist Processing] Failed to add {track_name} to wishlist")
|
||||
|
||||
|
|
@ -15206,6 +15882,16 @@ def _process_failed_tracks_to_wishlist_exact_with_auto_completion(batch_id):
|
|||
wishlist_auto_processing_timestamp = 0
|
||||
# Don't clear wishlist_next_run_time here - let schedule function handle it atomically
|
||||
|
||||
try:
|
||||
if automation_engine:
|
||||
automation_engine.emit('wishlist_processing_completed', {
|
||||
'tracks_processed': str(total_failed),
|
||||
'tracks_found': str(tracks_added),
|
||||
'tracks_failed': str(total_failed - tracks_added),
|
||||
})
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Schedule next automatic processing cycle (handles timer atomically with retry logic)
|
||||
print("⏰ [Auto-Wishlist] Scheduling next automatic cycle in 30 minutes")
|
||||
schedule_next_wishlist_processing() # Has built-in error handling and retry
|
||||
|
|
@ -15282,7 +15968,17 @@ def _on_download_completed(batch_id, task_id, success=True):
|
|||
else:
|
||||
print(f"❌ [Batch Manager] Added failed track to batch tracking: {track_info['track_name']}")
|
||||
add_activity_item("❌", "Download Failed", f"'{track_info['track_name']}'", "Now")
|
||||
|
||||
|
||||
try:
|
||||
if automation_engine:
|
||||
automation_engine.emit('download_failed', {
|
||||
'artist': track_info.get('artist_name', ''),
|
||||
'title': track_info.get('track_name', ''),
|
||||
'reason': track_info.get('failure_reason', 'Unknown'),
|
||||
})
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# WISHLIST REMOVAL: Handle successful downloads for wishlist removal
|
||||
if success and task_id in download_tasks:
|
||||
try:
|
||||
|
|
@ -15393,8 +16089,21 @@ def _on_download_completed(batch_id, task_id, success=True):
|
|||
|
||||
# Add activity for batch completion
|
||||
playlist_name = batch.get('playlist_name', 'Unknown Playlist')
|
||||
successful_downloads = finished_count - len(batch.get('permanently_failed_tracks', []))
|
||||
failed_count = len(batch.get('permanently_failed_tracks', []))
|
||||
successful_downloads = finished_count - failed_count
|
||||
add_activity_item("✅", "Download Batch Complete", f"'{playlist_name}' - {successful_downloads} tracks downloaded", "Now")
|
||||
|
||||
# Emit batch_complete event for automation engine
|
||||
try:
|
||||
if automation_engine:
|
||||
automation_engine.emit('batch_complete', {
|
||||
'playlist_name': playlist_name,
|
||||
'total_tracks': str(len(queue)),
|
||||
'completed_tracks': str(successful_downloads),
|
||||
'failed_tracks': str(failed_count),
|
||||
})
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Update YouTube playlist phase to 'download_complete' if this is a YouTube playlist
|
||||
playlist_id = batch.get('playlist_id')
|
||||
|
|
@ -21087,7 +21796,20 @@ def _run_sync_task(playlist_id, playlist_name, tracks_json):
|
|||
"result": result.__dict__ # Keep result for backward compatibility
|
||||
}
|
||||
print(f"🏁 Sync finished for {playlist_id} - state updated")
|
||||
|
||||
|
||||
# Emit playlist_synced event for automation engine
|
||||
try:
|
||||
if automation_engine:
|
||||
automation_engine.emit('playlist_synced', {
|
||||
'playlist_name': playlist_name,
|
||||
'total_tracks': str(getattr(result, 'total_tracks', 0)),
|
||||
'matched_tracks': str(getattr(result, 'matched_tracks', 0)),
|
||||
'synced_tracks': str(getattr(result, 'synced_tracks', 0)),
|
||||
'failed_tracks': str(getattr(result, 'failed_tracks', 0)),
|
||||
})
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Save sync status to storage/sync_status.json (same as GUI)
|
||||
# Handle snapshot_id safely - may not exist in all playlist objects
|
||||
snapshot_id = getattr(playlist, 'snapshot_id', None)
|
||||
|
|
@ -22065,6 +22787,14 @@ def add_to_watchlist():
|
|||
socketio.emit('watchlist:count', _build_watchlist_count_payload(profile_id=pid), room=f'profile:{pid}')
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
if automation_engine:
|
||||
automation_engine.emit('watchlist_artist_added', {
|
||||
'artist': artist_name,
|
||||
'artist_id': str(artist_id),
|
||||
})
|
||||
except Exception:
|
||||
pass
|
||||
return jsonify({"success": True, "message": f"Added {artist_name} to watchlist"})
|
||||
else:
|
||||
return jsonify({"success": False, "error": "Failed to add artist to watchlist"}), 500
|
||||
|
|
@ -22093,10 +22823,18 @@ def remove_from_watchlist():
|
|||
socketio.emit('watchlist:count', _build_watchlist_count_payload(profile_id=pid), room=f'profile:{pid}')
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
if automation_engine:
|
||||
automation_engine.emit('watchlist_artist_removed', {
|
||||
'artist': data.get('artist_name', str(artist_id)),
|
||||
'artist_id': str(artist_id),
|
||||
})
|
||||
except Exception:
|
||||
pass
|
||||
return jsonify({"success": True, "message": "Removed artist from watchlist"})
|
||||
else:
|
||||
return jsonify({"success": False, "error": "Failed to remove artist from watchlist"}), 500
|
||||
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error removing from watchlist: {e}")
|
||||
return jsonify({"success": False, "error": str(e)}), 500
|
||||
|
|
@ -23510,6 +24248,18 @@ def _process_watchlist_scan_automatically():
|
|||
|
||||
print(f"✅ Scanned {artist.artist_name}: {artist_new_tracks} new tracks found, {artist_added_tracks} added to wishlist")
|
||||
|
||||
# Emit watchlist_new_release event if new tracks were found
|
||||
if artist_new_tracks > 0:
|
||||
try:
|
||||
if automation_engine:
|
||||
automation_engine.emit('watchlist_new_release', {
|
||||
'artist': artist.artist_name,
|
||||
'new_tracks': str(artist_new_tracks),
|
||||
'added_to_wishlist': str(artist_added_tracks),
|
||||
})
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Delay between artists
|
||||
if i < len(watchlist_artists) - 1:
|
||||
watchlist_scan_state['current_phase'] = 'rate_limiting'
|
||||
|
|
@ -23621,6 +24371,16 @@ def _process_watchlist_scan_automatically():
|
|||
if total_added_to_wishlist > 0:
|
||||
add_activity_item("👁️", "Watchlist Scan Complete", f"{total_added_to_wishlist} new tracks added to wishlist", "Now")
|
||||
|
||||
try:
|
||||
if automation_engine:
|
||||
automation_engine.emit('watchlist_scan_completed', {
|
||||
'artists_scanned': str(len(scan_results)),
|
||||
'new_tracks_found': str(total_new_tracks),
|
||||
'tracks_added': str(total_added_to_wishlist),
|
||||
})
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
except Exception as e:
|
||||
print(f"❌ Error in automatic watchlist scan: {e}")
|
||||
import traceback
|
||||
|
|
@ -27894,6 +28654,16 @@ def mirror_playlist_endpoint():
|
|||
if playlist_id is None:
|
||||
return jsonify({"error": "Failed to mirror playlist"}), 500
|
||||
|
||||
try:
|
||||
if automation_engine:
|
||||
automation_engine.emit('mirrored_playlist_created', {
|
||||
'playlist_name': name,
|
||||
'source': source,
|
||||
'track_count': str(len(tracks)),
|
||||
})
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return jsonify({"success": True, "playlist_id": playlist_id})
|
||||
except Exception as e:
|
||||
logger.error(f"Error mirroring playlist: {e}")
|
||||
|
|
@ -30080,6 +30850,16 @@ def import_album_process():
|
|||
|
||||
add_activity_item("📥", "Album Imported", f"{album_name} by {artist_name} ({processed}/{len(matches)} tracks)", "Now")
|
||||
|
||||
try:
|
||||
if automation_engine:
|
||||
automation_engine.emit('import_completed', {
|
||||
'track_count': str(processed),
|
||||
'album_name': album_name or '',
|
||||
'artist': artist_name or '',
|
||||
})
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Rebuild suggestions cache since staging contents changed
|
||||
if processed > 0:
|
||||
refresh_import_suggestions_cache()
|
||||
|
|
@ -30325,6 +31105,16 @@ def import_singles_process():
|
|||
|
||||
add_activity_item("📥", "Singles Imported", f"{processed}/{len(files)} tracks processed", "Now")
|
||||
|
||||
try:
|
||||
if automation_engine:
|
||||
automation_engine.emit('import_completed', {
|
||||
'track_count': str(processed),
|
||||
'album_name': '',
|
||||
'artist': 'Various',
|
||||
})
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Rebuild suggestions cache since staging contents changed
|
||||
if processed > 0:
|
||||
refresh_import_suggestions_cache()
|
||||
|
|
@ -30891,6 +31681,17 @@ if __name__ == '__main__':
|
|||
import time
|
||||
app.start_time = time.time()
|
||||
|
||||
# Register action handlers and start automation engine
|
||||
_register_automation_handlers()
|
||||
if automation_engine:
|
||||
print("🔧 Starting automation engine...")
|
||||
automation_engine.start()
|
||||
print("✅ Automation engine started")
|
||||
try:
|
||||
automation_engine.emit('app_started', {})
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Add startup activity
|
||||
add_activity_item("🚀", "System Started", "SoulSync Web UI Server initialized", "Now")
|
||||
|
||||
|
|
|
|||
|
|
@ -111,6 +111,10 @@
|
|||
<span class="nav-icon">🎵</span>
|
||||
<span class="nav-text">Artists</span>
|
||||
</button>
|
||||
<button class="nav-button" data-page="automations">
|
||||
<span class="nav-icon">⚡</span>
|
||||
<span class="nav-text">Automations</span>
|
||||
</button>
|
||||
<button class="nav-button" data-page="library">
|
||||
<span class="nav-icon">📚</span>
|
||||
<span class="nav-text">Library</span>
|
||||
|
|
@ -498,8 +502,12 @@
|
|||
<div class="tool-card" id="db-updater-card">
|
||||
<div class="tool-card-header">
|
||||
<h4 class="tool-card-title">Database Updater</h4>
|
||||
<button class="tool-help-button" data-tool="db-updater"
|
||||
title="Learn more about this tool">?</button>
|
||||
<div style="display:flex;gap:4px;">
|
||||
<button class="tool-help-button" id="db-backup-button"
|
||||
title="Create a rolling backup of the database"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z"/><polyline points="17 21 17 13 7 13 7 21"/><polyline points="7 3 7 8 15 8"/></svg></button>
|
||||
<button class="tool-help-button" data-tool="db-updater"
|
||||
title="Learn more about this tool">?</button>
|
||||
</div>
|
||||
</div>
|
||||
<p class="tool-card-info">Last Full Refresh: <span id="db-last-refresh">Never</span></p>
|
||||
<div class="tool-card-stats">
|
||||
|
|
@ -1947,6 +1955,49 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Automations Page -->
|
||||
<div class="page" id="automations-page">
|
||||
<!-- List View -->
|
||||
<div class="automations-list-view" id="automations-list-view">
|
||||
<div class="automations-container">
|
||||
<div class="dashboard-header">
|
||||
<div class="header-text">
|
||||
<h2 class="header-title">Automations</h2>
|
||||
<p class="header-subtitle">Configure scheduled tasks and automated workflows</p>
|
||||
</div>
|
||||
<div class="header-spacer"></div>
|
||||
<div class="header-actions">
|
||||
<button class="auto-new-btn" onclick="showAutomationBuilder()">+ New Automation</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="automations-stats" id="automations-stats"></div>
|
||||
<div class="automations-list" id="automations-list"></div>
|
||||
<div class="automations-empty" id="automations-empty" style="display:none;">
|
||||
<div class="automations-empty-icon">⚡</div>
|
||||
<div class="automations-empty-title">No automations yet</div>
|
||||
<div class="automations-empty-text">Create your first automation to schedule tasks and trigger actions automatically.</div>
|
||||
<button class="auto-new-btn" onclick="showAutomationBuilder()">+ New Automation</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Builder View -->
|
||||
<div class="automations-builder-view" id="automations-builder-view" style="display:none;">
|
||||
<div class="builder-header">
|
||||
<button class="builder-back-btn" onclick="hideAutomationBuilder()" title="Back to list">←</button>
|
||||
<input type="text" id="builder-name" class="builder-name-input" placeholder="Automation Name">
|
||||
<div class="builder-header-actions">
|
||||
<button class="btn-cancel" onclick="hideAutomationBuilder()">Cancel</button>
|
||||
<button class="btn-save" onclick="saveAutomation()">Save</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="builder-content">
|
||||
<div class="builder-sidebar" id="builder-sidebar"></div>
|
||||
<div class="builder-canvas" id="builder-canvas"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Library Page -->
|
||||
<div class="page" id="library-page">
|
||||
<div class="library-container">
|
||||
|
|
|
|||
|
|
@ -1522,6 +1522,9 @@ async function loadPageData(pageId) {
|
|||
// Load comparisons
|
||||
loadHydrabaseComparisons();
|
||||
break;
|
||||
case 'automations':
|
||||
await loadAutomations();
|
||||
break;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Error loading ${pageId} data:`, error);
|
||||
|
|
@ -16264,6 +16267,11 @@ async function loadDashboardData() {
|
|||
updateButton.addEventListener('click', handleDbUpdateButtonClick);
|
||||
}
|
||||
|
||||
const backupButton = document.getElementById('db-backup-button');
|
||||
if (backupButton) {
|
||||
backupButton.addEventListener('click', handleDbBackupButtonClick);
|
||||
}
|
||||
|
||||
// Attach event listeners for the metadata updater tool
|
||||
const metadataButton = document.getElementById('metadata-update-button');
|
||||
if (metadataButton) {
|
||||
|
|
@ -18372,6 +18380,26 @@ async function handleDbUpdateButtonClick() {
|
|||
}
|
||||
}
|
||||
|
||||
async function handleDbBackupButtonClick() {
|
||||
const button = document.getElementById('db-backup-button');
|
||||
const origText = button.textContent;
|
||||
button.disabled = true;
|
||||
button.textContent = 'Backing up...';
|
||||
try {
|
||||
const res = await fetch('/api/database/backup', { method: 'POST' });
|
||||
const data = await res.json();
|
||||
if (data.success) {
|
||||
showToast(`Database backed up (${data.size_mb} MB)`, 'success');
|
||||
} else {
|
||||
showToast(`Backup failed: ${data.error}`, 'error');
|
||||
}
|
||||
} catch (e) {
|
||||
showToast('Backup request failed', 'error');
|
||||
}
|
||||
button.disabled = false;
|
||||
button.textContent = origText;
|
||||
}
|
||||
|
||||
async function handleWishlistButtonClick() {
|
||||
try {
|
||||
const playlistId = 'wishlist';
|
||||
|
|
@ -42094,6 +42122,719 @@ async function discoverMirroredPlaylist(playlistId) {
|
|||
}
|
||||
}
|
||||
|
||||
// ===============================
|
||||
// AUTOMATIONS — Visual Builder
|
||||
// ===============================
|
||||
|
||||
let _autoBlocks = null; // cached block definitions from /api/automations/blocks
|
||||
let _autoBuilder = { editId: null, when: null, do: null, notify: null };
|
||||
|
||||
let _autoMirroredPlaylists = null; // cached mirrored playlist list
|
||||
|
||||
const _autoIcons = {
|
||||
schedule: '\u23F1\uFE0F', daily_time: '\u{1F570}\uFE0F', weekly_time: '\uD83D\uDCC5', app_started: '\uD83D\uDE80', track_downloaded: '\u2B07\uFE0F', batch_complete: '\u2705',
|
||||
watchlist_new_release: '\uD83D\uDD14', playlist_synced: '\uD83D\uDD04',
|
||||
playlist_changed: '\u270F\uFE0F',
|
||||
process_wishlist: '\uD83D\uDCCB', scan_watchlist: '\uD83D\uDC41\uFE0F',
|
||||
scan_library: '\uD83D\uDD04', refresh_mirrored: '\uD83D\uDCC2', sync_playlist: '\uD83D\uDD01',
|
||||
notify_only: '\uD83D\uDD14', discord_webhook: '\uD83D\uDCAC',
|
||||
// Phase 3
|
||||
wishlist_processing_completed: '\u2705', watchlist_scan_completed: '\u2705',
|
||||
database_update_completed: '\uD83D\uDDC4\uFE0F', download_failed: '\u274C',
|
||||
download_quarantined: '\u26A0\uFE0F', wishlist_item_added: '\u2795',
|
||||
watchlist_artist_added: '\uD83D\uDC64', watchlist_artist_removed: '\uD83D\uDC64',
|
||||
import_completed: '\uD83D\uDCE5', mirrored_playlist_created: '\uD83D\uDCC2',
|
||||
quality_scan_completed: '\uD83D\uDCCA', duplicate_scan_completed: '\uD83D\uDDC2\uFE0F',
|
||||
start_database_update: '\uD83D\uDDC4\uFE0F', run_duplicate_cleaner: '\uD83D\uDDC2\uFE0F',
|
||||
clear_quarantine: '\uD83D\uDDD1\uFE0F', cleanup_wishlist: '\uD83E\uDDF9',
|
||||
update_discovery_pool: '\uD83E\uDDED', start_quality_scan: '\uD83D\uDCCA',
|
||||
backup_database: '\uD83D\uDCBE',
|
||||
};
|
||||
|
||||
// --- Load & Render List ---
|
||||
|
||||
async function loadAutomations() {
|
||||
const list = document.getElementById('automations-list');
|
||||
const empty = document.getElementById('automations-empty');
|
||||
const statsBar = document.getElementById('automations-stats');
|
||||
if (!list || !empty) return;
|
||||
try {
|
||||
const res = await fetch('/api/automations');
|
||||
const automations = await res.json();
|
||||
if (automations.error) throw new Error(automations.error);
|
||||
if (!automations.length) {
|
||||
list.innerHTML = ''; empty.style.display = '';
|
||||
if (statsBar) statsBar.innerHTML = '';
|
||||
return;
|
||||
}
|
||||
empty.style.display = 'none';
|
||||
list.innerHTML = '';
|
||||
automations.forEach(a => list.appendChild(renderAutomationCard(a)));
|
||||
// Stats summary bar
|
||||
if (statsBar) {
|
||||
const total = automations.length;
|
||||
const active = automations.filter(a => a.enabled).length;
|
||||
const scheduled = automations.filter(a => a.enabled && a.trigger_type === 'schedule').length;
|
||||
const eventBased = automations.filter(a => a.enabled && a.trigger_type !== 'schedule').length;
|
||||
statsBar.innerHTML = `
|
||||
<span class="auto-stat"><strong>${total}</strong> Total</span>
|
||||
<span class="auto-stat"><strong>${active}</strong> Active</span>
|
||||
<span class="auto-stat"><strong>${scheduled}</strong> Scheduled</span>
|
||||
<span class="auto-stat"><strong>${eventBased}</strong> Event-Based</span>
|
||||
`;
|
||||
}
|
||||
} catch (err) {
|
||||
list.innerHTML = ''; empty.style.display = '';
|
||||
if (statsBar) statsBar.innerHTML = '';
|
||||
}
|
||||
}
|
||||
|
||||
function renderAutomationCard(a) {
|
||||
const card = document.createElement('div');
|
||||
card.className = 'automation-card' + (a.enabled ? '' : ' disabled');
|
||||
card.dataset.id = a.id;
|
||||
const tIcon = _autoIcons[a.trigger_type] || '\u2699\uFE0F';
|
||||
const aIcon = _autoIcons[a.action_type] || '\u2699\uFE0F';
|
||||
const tl = tIcon + ' ' + _autoFormatTrigger(a.trigger_type, a.trigger_config);
|
||||
const al = aIcon + ' ' + _autoFormatAction(a.action_type);
|
||||
const nl = a.notify_type ? _autoFormatNotify(a.notify_type) : '';
|
||||
const actionDelay = a.action_config && a.action_config.delay ? a.action_config.delay : 0;
|
||||
const metaParts = [];
|
||||
if (a.last_run) metaParts.push('Last: ' + _autoTimeAgo(a.last_run));
|
||||
const _timerTriggers = ['schedule', 'daily_time', 'weekly_time'];
|
||||
if (a.next_run && a.enabled && _timerTriggers.includes(a.trigger_type)) metaParts.push('Next: ' + _autoTimeUntil(a.next_run));
|
||||
if (!_timerTriggers.includes(a.trigger_type) && a.enabled) metaParts.push('Listening');
|
||||
if (a.run_count) metaParts.push('Runs: ' + a.run_count);
|
||||
if (a.last_error) metaParts.push('Error: ' + _esc(a.last_error));
|
||||
|
||||
card.innerHTML = `
|
||||
<div class="automation-status ${a.enabled ? 'enabled' : 'disabled'}"></div>
|
||||
<div class="automation-info">
|
||||
<div class="automation-name">${_esc(a.name)}</div>
|
||||
<div class="automation-flow">
|
||||
<span class="flow-trigger">${_esc(tl)}</span>
|
||||
<span class="flow-arrow">→</span>
|
||||
${actionDelay ? `<span class="flow-delay">\u23F3 ${actionDelay}m</span><span class="flow-arrow">→</span>` : ''}
|
||||
<span class="flow-action">${_esc(al)}</span>
|
||||
${nl ? `<span class="flow-arrow">→</span><span class="flow-notify">${_esc(nl)}</span>` : ''}
|
||||
</div>
|
||||
<div class="automation-meta">${metaParts.join(' · ')}</div>
|
||||
</div>
|
||||
<div class="automation-actions">
|
||||
<button class="automation-run-btn" title="Run now" onclick="event.stopPropagation(); runAutomation(${a.id})">▶</button>
|
||||
<label class="automation-toggle" onclick="event.stopPropagation();">
|
||||
<input type="checkbox" ${a.enabled ? 'checked' : ''} onchange="toggleAutomation(${a.id})">
|
||||
<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>
|
||||
</div>
|
||||
`;
|
||||
return card;
|
||||
}
|
||||
|
||||
function _autoFormatTrigger(type, config) {
|
||||
if (type === 'schedule' && config) return 'Every ' + (config.interval || 1) + ' ' + (config.unit || 'hours');
|
||||
if (type === 'daily_time' && config) return 'Daily at ' + (config.time || '00:00');
|
||||
if (type === 'weekly_time' && config) {
|
||||
const days = (config.days || []).map(d => d.charAt(0).toUpperCase() + d.slice(1)).join(', ');
|
||||
return (days || 'Every day') + ' at ' + (config.time || '00:00');
|
||||
}
|
||||
const labels = { app_started: 'App Started', track_downloaded: 'Track Downloaded', batch_complete: 'Batch Complete',
|
||||
watchlist_new_release: 'New Release Found', playlist_synced: 'Playlist Synced',
|
||||
playlist_changed: 'Playlist Changed',
|
||||
wishlist_processing_completed: 'Wishlist Processed', watchlist_scan_completed: 'Watchlist Scan Done',
|
||||
database_update_completed: 'Database Updated', download_failed: 'Download Failed',
|
||||
download_quarantined: 'File Quarantined', wishlist_item_added: 'Wishlist Item Added',
|
||||
watchlist_artist_added: 'Artist Watched', watchlist_artist_removed: 'Artist Unwatched',
|
||||
import_completed: 'Import Complete', mirrored_playlist_created: 'Playlist Mirrored',
|
||||
quality_scan_completed: 'Quality Scan Done', duplicate_scan_completed: 'Duplicate Scan Done' };
|
||||
let label = labels[type] || type || 'Unknown';
|
||||
if (config && config.conditions && config.conditions.length) {
|
||||
const first = config.conditions[0];
|
||||
label += ' (' + first.field + ' ' + first.operator + ' "' + first.value + '"' +
|
||||
(config.conditions.length > 1 ? ' +' + (config.conditions.length - 1) + ' more' : '') + ')';
|
||||
}
|
||||
return label;
|
||||
}
|
||||
function _autoFormatAction(type) {
|
||||
const labels = { process_wishlist: 'Process Wishlist', scan_watchlist: 'Scan Watchlist',
|
||||
scan_library: 'Scan Library', refresh_mirrored: 'Refresh Mirrored',
|
||||
sync_playlist: 'Sync Playlist', notify_only: 'Notify Only',
|
||||
start_database_update: 'Update Database', run_duplicate_cleaner: 'Run Duplicate Cleaner',
|
||||
clear_quarantine: 'Clear Quarantine', cleanup_wishlist: 'Clean Up Wishlist',
|
||||
update_discovery_pool: 'Update Discovery', start_quality_scan: 'Run Quality Scan',
|
||||
backup_database: 'Backup Database' };
|
||||
return labels[type] || type || 'Unknown';
|
||||
}
|
||||
function _autoFormatNotify(type) {
|
||||
if (type === 'discord_webhook') return 'Discord';
|
||||
return type || '';
|
||||
}
|
||||
function _autoTimeAgo(ts) {
|
||||
if (!ts) return 'Never';
|
||||
const d = (Date.now() - new Date(ts + 'Z').getTime()) / 1000;
|
||||
if (d < 60) return 'just now'; if (d < 3600) return Math.floor(d/60) + 'm ago';
|
||||
if (d < 86400) return Math.floor(d/3600) + 'h ago'; return Math.floor(d/86400) + 'd ago';
|
||||
}
|
||||
function _autoTimeUntil(ts) {
|
||||
if (!ts) return '';
|
||||
const d = (new Date(ts + 'Z').getTime() - Date.now()) / 1000;
|
||||
if (d <= 0) return 'soon'; if (d < 60) return 'in ' + Math.ceil(d) + 's';
|
||||
if (d < 3600) return 'in ' + Math.ceil(d/60) + 'm'; if (d < 86400) return 'in ' + Math.round(d/3600) + 'h';
|
||||
return 'in ' + Math.round(d/86400) + 'd';
|
||||
}
|
||||
|
||||
// --- CRUD ---
|
||||
|
||||
async function deleteAutomation(id, name) {
|
||||
if (!confirm('Delete automation "' + name + '"?')) return;
|
||||
try {
|
||||
const res = await fetch('/api/automations/' + id, { method: 'DELETE' });
|
||||
const data = await res.json();
|
||||
if (data.error) throw new Error(data.error);
|
||||
showToast('Automation deleted', 'success');
|
||||
await loadAutomations();
|
||||
} catch (err) { showToast('Error: ' + err.message, 'error'); }
|
||||
}
|
||||
|
||||
async function toggleAutomation(id) {
|
||||
try {
|
||||
const res = await fetch('/api/automations/' + id + '/toggle', { method: 'POST' });
|
||||
const data = await res.json();
|
||||
if (data.error) throw new Error(data.error);
|
||||
await loadAutomations();
|
||||
} catch (err) { showToast('Error: ' + err.message, 'error'); }
|
||||
}
|
||||
|
||||
async function runAutomation(id) {
|
||||
try {
|
||||
const res = await fetch('/api/automations/' + id + '/run', { method: 'POST' });
|
||||
const data = await res.json();
|
||||
if (data.error) throw new Error(data.error);
|
||||
showToast('Automation triggered', 'success');
|
||||
setTimeout(() => loadAutomations(), 1500);
|
||||
} catch (err) { showToast('Error: ' + err.message, 'error'); }
|
||||
}
|
||||
|
||||
async function saveAutomation() {
|
||||
const name = document.getElementById('builder-name').value.trim();
|
||||
if (!name) { showToast('Name is required', 'error'); return; }
|
||||
if (!_autoBuilder.when) { showToast('Add a trigger (WHEN)', 'error'); return; }
|
||||
if (!_autoBuilder.do) { showToast('Add an action (DO)', 'error'); return; }
|
||||
|
||||
// Read configs from DOM
|
||||
const triggerConfig = _readPlacedConfig('when');
|
||||
const actionConfig = _readPlacedConfig('do');
|
||||
const notifyConfig = _autoBuilder.notify ? _readPlacedConfig('notify') : {};
|
||||
|
||||
// Read optional delay from DO slot
|
||||
const delayEl = document.getElementById('cfg-do-delay');
|
||||
const delayVal = delayEl ? parseInt(delayEl.value) : 0;
|
||||
if (delayVal > 0) actionConfig.delay = delayVal;
|
||||
|
||||
const body = {
|
||||
name,
|
||||
trigger_type: _autoBuilder.when.type, trigger_config: triggerConfig,
|
||||
action_type: _autoBuilder.do.type, action_config: actionConfig,
|
||||
notify_type: _autoBuilder.notify ? _autoBuilder.notify.type : null,
|
||||
notify_config: _autoBuilder.notify ? notifyConfig : {},
|
||||
};
|
||||
|
||||
try {
|
||||
let res;
|
||||
if (_autoBuilder.editId) {
|
||||
res = await fetch('/api/automations/' + _autoBuilder.editId, { method: 'PUT', headers: {'Content-Type':'application/json'}, body: JSON.stringify(body) });
|
||||
} else {
|
||||
res = await fetch('/api/automations', { method: 'POST', headers: {'Content-Type':'application/json'}, body: JSON.stringify(body) });
|
||||
}
|
||||
const data = await res.json();
|
||||
if (data.error) throw new Error(data.error);
|
||||
showToast(_autoBuilder.editId ? 'Automation updated' : 'Automation created', 'success');
|
||||
hideAutomationBuilder();
|
||||
await loadAutomations();
|
||||
} catch (err) { showToast('Error: ' + err.message, 'error'); }
|
||||
}
|
||||
|
||||
// --- Builder View ---
|
||||
|
||||
async function showAutomationBuilder(editId) {
|
||||
// Load block definitions (always refresh)
|
||||
try {
|
||||
const res = await fetch('/api/automations/blocks');
|
||||
_autoBlocks = await res.json();
|
||||
} catch (e) {
|
||||
if (!_autoBlocks) { showToast('Failed to load blocks', 'error'); return; }
|
||||
}
|
||||
|
||||
_autoMirroredPlaylists = null; // invalidate so it re-fetches
|
||||
_autoBuilder = { editId: editId || null, when: null, do: null, notify: null };
|
||||
|
||||
// If editing, load automation data
|
||||
if (editId) {
|
||||
try {
|
||||
const res = await fetch('/api/automations/' + editId);
|
||||
const a = await res.json();
|
||||
if (a.error) throw new Error(a.error);
|
||||
document.getElementById('builder-name').value = a.name || '';
|
||||
_autoBuilder.when = { type: a.trigger_type, config: a.trigger_config || {} };
|
||||
_autoBuilder.do = { type: a.action_type, config: a.action_config || {} };
|
||||
if (a.notify_type) _autoBuilder.notify = { type: a.notify_type, config: a.notify_config || {} };
|
||||
} catch (err) { showToast('Failed to load automation', 'error'); return; }
|
||||
} else {
|
||||
document.getElementById('builder-name').value = '';
|
||||
}
|
||||
|
||||
_renderBuilderSidebar();
|
||||
_renderBuilderCanvas();
|
||||
|
||||
document.getElementById('automations-list-view').style.display = 'none';
|
||||
document.getElementById('automations-builder-view').style.display = '';
|
||||
}
|
||||
|
||||
function hideAutomationBuilder() {
|
||||
document.getElementById('automations-builder-view').style.display = 'none';
|
||||
document.getElementById('automations-list-view').style.display = '';
|
||||
_autoBuilder = { editId: null, when: null, do: null, notify: null };
|
||||
}
|
||||
|
||||
// --- Sidebar ---
|
||||
|
||||
function _renderBuilderSidebar() {
|
||||
const sidebar = document.getElementById('builder-sidebar');
|
||||
if (!sidebar || !_autoBlocks) return;
|
||||
|
||||
let html = '';
|
||||
const sections = [
|
||||
{ key: 'triggers', title: 'Triggers', slot: 'when' },
|
||||
{ key: 'actions', title: 'Actions', slot: 'do' },
|
||||
{ key: 'notifications', title: 'Notifications', slot: 'notify' },
|
||||
];
|
||||
|
||||
sections.forEach(sec => {
|
||||
html += `<div class="sidebar-section"><div class="sidebar-section-title">${sec.title}</div>`;
|
||||
(_autoBlocks[sec.key] || []).forEach(block => {
|
||||
const icon = _autoIcons[block.type] || '\u2699\uFE0F';
|
||||
const disabled = !block.available;
|
||||
html += `<div class="block-item${disabled ? ' coming-soon' : ''}" ${!disabled ? `draggable="true" ondragstart="_autoDragStart(event,'${block.type}','${sec.slot}')" onclick="_autoClickBlock('${block.type}','${sec.slot}')"` : ''}>
|
||||
<div class="block-item-icon">${icon}</div>
|
||||
<div class="block-item-text">
|
||||
<div class="block-item-label">${_esc(block.label)}</div>
|
||||
<div class="block-item-desc">${_esc(block.description)}</div>
|
||||
</div>
|
||||
${disabled ? '<span class="coming-soon-badge">Soon</span>' : ''}
|
||||
</div>`;
|
||||
});
|
||||
html += '</div>';
|
||||
});
|
||||
sidebar.innerHTML = html;
|
||||
}
|
||||
|
||||
// --- Canvas ---
|
||||
|
||||
function _renderBuilderCanvas() {
|
||||
const canvas = document.getElementById('builder-canvas');
|
||||
if (!canvas) return;
|
||||
|
||||
let html = '';
|
||||
const slots = [
|
||||
{ key: 'when', label: 'WHEN', labelClass: 'when', prompt: 'Drag a trigger here — WHEN does this run?' },
|
||||
{ key: 'do', label: 'DO', labelClass: 'do', prompt: 'Drag an action here — WHAT should it do?' },
|
||||
{ key: 'notify', label: 'NOTIFY', labelClass: 'notify', prompt: 'Drag a notification here (optional)' },
|
||||
];
|
||||
|
||||
slots.forEach((slot, i) => {
|
||||
if (i > 0) html += '<div class="flow-connector"></div>';
|
||||
const data = _autoBuilder[slot.key];
|
||||
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>`;
|
||||
}
|
||||
});
|
||||
|
||||
canvas.innerHTML = html;
|
||||
// Load mirrored playlist selects if any are present
|
||||
_autoLoadMirroredSelects();
|
||||
// Set up checkbox state for refresh_mirrored
|
||||
['when', 'do', 'notify'].forEach(sk => {
|
||||
const allCb = document.getElementById('cfg-' + sk + '-all');
|
||||
if (allCb) _autoTogglePlaylistSelect(sk);
|
||||
});
|
||||
}
|
||||
|
||||
function _renderPlacedBlock(slotKey, data) {
|
||||
const blockDef = _findBlockDef(data.type);
|
||||
const icon = _autoIcons[data.type] || '\u2699\uFE0F';
|
||||
const label = blockDef ? blockDef.label : data.type;
|
||||
const configHtml = _renderBlockConfigFields(slotKey, data.type, data.config || {});
|
||||
|
||||
// Add optional delay field for action blocks
|
||||
let delayHtml = '';
|
||||
if (slotKey === 'do') {
|
||||
const delayVal = (data.config && data.config.delay) || '';
|
||||
delayHtml = `<div class="placed-block-config"><div class="config-row">
|
||||
<label>Delay (minutes)</label>
|
||||
<input type="number" id="cfg-${slotKey}-delay" value="${delayVal}" min="0" placeholder="0" style="width:80px;" title="Wait before executing action">
|
||||
</div></div>`;
|
||||
}
|
||||
|
||||
return `<div class="placed-block" 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>
|
||||
</div>
|
||||
${configHtml ? '<div class="placed-block-config">' + configHtml + '</div>' : ''}
|
||||
${delayHtml}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function _renderBlockConfigFields(slotKey, blockType, config) {
|
||||
if (blockType === 'schedule') {
|
||||
const interval = config.interval || 6;
|
||||
const unit = config.unit || 'hours';
|
||||
return `<div class="config-row">
|
||||
<label>Every</label>
|
||||
<input type="number" id="cfg-${slotKey}-interval" value="${interval}" min="1" style="width:70px;">
|
||||
<select id="cfg-${slotKey}-unit">
|
||||
<option value="minutes"${unit==='minutes'?' selected':''}>Minutes</option>
|
||||
<option value="hours"${unit==='hours'?' selected':''}>Hours</option>
|
||||
<option value="days"${unit==='days'?' selected':''}>Days</option>
|
||||
</select>
|
||||
</div>`;
|
||||
}
|
||||
if (blockType === 'daily_time') {
|
||||
const timeVal = config.time || '03:00';
|
||||
return `<div class="config-row">
|
||||
<label>At</label>
|
||||
<input type="time" id="cfg-${slotKey}-time" value="${timeVal}">
|
||||
</div>`;
|
||||
}
|
||||
if (blockType === 'weekly_time') {
|
||||
const timeVal = config.time || '03:00';
|
||||
const selectedDays = config.days || [];
|
||||
const allDays = [['mon','Mon'],['tue','Tue'],['wed','Wed'],['thu','Thu'],['fri','Fri'],['sat','Sat'],['sun','Sun']];
|
||||
let dayHtml = '<div class="config-row"><label>Days</label><div class="day-picker" id="cfg-' + slotKey + '-days">';
|
||||
allDays.forEach(([val, lbl]) => {
|
||||
const active = selectedDays.includes(val) ? ' active' : '';
|
||||
dayHtml += `<button type="button" class="day-btn${active}" data-day="${val}" onclick="this.classList.toggle('active')">${lbl}</button>`;
|
||||
});
|
||||
dayHtml += '</div></div>';
|
||||
return `<div class="config-row">
|
||||
<label>At</label>
|
||||
<input type="time" id="cfg-${slotKey}-time" value="${timeVal}">
|
||||
</div>${dayHtml}`;
|
||||
}
|
||||
|
||||
// Event triggers with conditions
|
||||
const blockDef = _findBlockDef(blockType);
|
||||
if (blockDef && blockDef.has_conditions) {
|
||||
return _renderConditionBuilder(slotKey, blockDef, config);
|
||||
}
|
||||
|
||||
if (blockType === 'process_wishlist') {
|
||||
const cat = config.category || 'all';
|
||||
return `<div class="config-row">
|
||||
<label>Category</label>
|
||||
<select id="cfg-${slotKey}-category">
|
||||
<option value="all"${cat==='all'?' selected':''}>All</option>
|
||||
<option value="albums"${cat==='albums'?' selected':''}>Albums</option>
|
||||
<option value="singles"${cat==='singles'?' selected':''}>Singles</option>
|
||||
</select>
|
||||
</div>`;
|
||||
}
|
||||
if (blockType === 'scan_watchlist' || blockType === 'scan_library' || blockType === 'notify_only') {
|
||||
return '<div class="config-row" style="color:rgba(255,255,255,0.4);font-size:12px;">No configuration needed</div>';
|
||||
}
|
||||
if (blockType === 'refresh_mirrored') {
|
||||
const allChecked = config.all ? ' checked' : '';
|
||||
return `<div class="config-row">
|
||||
<label>Playlist</label>
|
||||
<select id="cfg-${slotKey}-playlist_id" class="mirrored-playlist-select" data-value="${_escAttr(config.playlist_id || '')}">
|
||||
<option value="">Loading...</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="config-row">
|
||||
<label><input type="checkbox" id="cfg-${slotKey}-all"${allChecked} onchange="_autoTogglePlaylistSelect('${slotKey}')"> Refresh all mirrored playlists</label>
|
||||
</div>`;
|
||||
}
|
||||
if (blockType === 'sync_playlist') {
|
||||
return `<div class="config-row">
|
||||
<label>Playlist</label>
|
||||
<select id="cfg-${slotKey}-playlist_id" class="mirrored-playlist-select" data-value="${_escAttr(config.playlist_id || '')}">
|
||||
<option value="">Loading...</option>
|
||||
</select>
|
||||
</div>`;
|
||||
}
|
||||
if (blockType === 'discord_webhook') {
|
||||
const url = _escAttr(config.webhook_url || '');
|
||||
// Merge base variables with trigger-specific variables
|
||||
let allVars = ['time', 'name', 'run_count', 'status'];
|
||||
const triggerDef = _autoBuilder.when ? _findBlockDef(_autoBuilder.when.type) : null;
|
||||
if (triggerDef && triggerDef.variables) {
|
||||
triggerDef.variables.forEach(v => { if (!allVars.includes(v)) allVars.push(v); });
|
||||
}
|
||||
let varHtml = '<div class="variable-tags">';
|
||||
allVars.forEach(v => { varHtml += `<span class="variable-tag" onclick="_autoInsertVar('cfg-${slotKey}-message','{${v}}')">{${v}}</span>`; });
|
||||
varHtml += '</div>';
|
||||
return `<div class="config-row">
|
||||
<label>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}`;
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
// --- Condition Builder ---
|
||||
|
||||
function _renderConditionBuilder(slotKey, blockDef, config) {
|
||||
const conditions = config.conditions || [];
|
||||
const match = config.match || 'all';
|
||||
const fields = blockDef.condition_fields || [];
|
||||
|
||||
let html = '<div class="condition-builder" id="conditions-' + slotKey + '">';
|
||||
html += `<div class="config-row condition-header">
|
||||
<label>Match</label>
|
||||
<select id="cfg-${slotKey}-match" class="condition-match-select">
|
||||
<option value="all"${match==='all'?' selected':''}>All conditions</option>
|
||||
<option value="any"${match==='any'?' selected':''}>Any condition</option>
|
||||
</select>
|
||||
</div>`;
|
||||
|
||||
html += '<div id="condition-rows-' + slotKey + '">';
|
||||
if (conditions.length) {
|
||||
conditions.forEach((cond, i) => {
|
||||
html += _renderConditionRow(slotKey, i, fields, cond);
|
||||
});
|
||||
}
|
||||
html += '</div>';
|
||||
|
||||
html += `<button class="add-condition-btn" onclick="_autoAddCondition('${slotKey}')">+ Add Condition</button>`;
|
||||
html += '</div>';
|
||||
|
||||
if (!conditions.length) {
|
||||
html += '<div class="config-row" style="color:rgba(255,255,255,0.35);font-size:12px;margin-top:4px;">No conditions = triggers on every event</div>';
|
||||
}
|
||||
|
||||
return html;
|
||||
}
|
||||
|
||||
function _renderConditionRow(slotKey, index, fields, cond) {
|
||||
const field = cond ? cond.field : (fields[0] || '');
|
||||
const operator = cond ? cond.operator : 'contains';
|
||||
const value = cond ? _escAttr(cond.value) : '';
|
||||
|
||||
let fieldOpts = '';
|
||||
fields.forEach(f => { fieldOpts += `<option value="${f}"${f===field?' selected':''}>${f}</option>`; });
|
||||
|
||||
return `<div class="condition-row" data-index="${index}">
|
||||
<select class="cond-field" data-slot="${slotKey}" data-idx="${index}">${fieldOpts}</select>
|
||||
<select class="cond-operator" data-slot="${slotKey}" data-idx="${index}">
|
||||
<option value="contains"${operator==='contains'?' selected':''}>contains</option>
|
||||
<option value="equals"${operator==='equals'?' selected':''}>equals</option>
|
||||
<option value="starts_with"${operator==='starts_with'?' selected':''}>starts with</option>
|
||||
<option value="not_contains"${operator==='not_contains'?' selected':''}>not contains</option>
|
||||
</select>
|
||||
<input type="text" class="cond-value" data-slot="${slotKey}" data-idx="${index}" value="${value}" placeholder="value...">
|
||||
<button class="remove-condition-btn" onclick="_autoRemoveCondition('${slotKey}',${index})">\u2715</button>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function _autoAddCondition(slotKey) {
|
||||
const data = _autoBuilder[slotKey];
|
||||
if (!data) return;
|
||||
if (!data.config) data.config = {};
|
||||
if (!data.config.conditions) data.config.conditions = [];
|
||||
|
||||
// Save existing conditions from DOM before re-render
|
||||
_autoSaveConditionsFromDOM(slotKey);
|
||||
|
||||
const blockDef = _findBlockDef(data.type);
|
||||
const fields = blockDef ? (blockDef.condition_fields || []) : [];
|
||||
data.config.conditions.push({ field: fields[0] || '', operator: 'contains', value: '' });
|
||||
_renderBuilderCanvas();
|
||||
// Re-populate mirrored playlist selects if needed
|
||||
_autoLoadMirroredSelects();
|
||||
}
|
||||
|
||||
function _autoRemoveCondition(slotKey, index) {
|
||||
const data = _autoBuilder[slotKey];
|
||||
if (!data || !data.config || !data.config.conditions) return;
|
||||
_autoSaveConditionsFromDOM(slotKey);
|
||||
data.config.conditions.splice(index, 1);
|
||||
_renderBuilderCanvas();
|
||||
_autoLoadMirroredSelects();
|
||||
}
|
||||
|
||||
function _autoSaveConditionsFromDOM(slotKey) {
|
||||
const data = _autoBuilder[slotKey];
|
||||
if (!data || !data.config) return;
|
||||
const container = document.getElementById('condition-rows-' + slotKey);
|
||||
if (!container) return;
|
||||
const rows = container.querySelectorAll('.condition-row');
|
||||
const conditions = [];
|
||||
rows.forEach(row => {
|
||||
const field = row.querySelector('.cond-field')?.value || '';
|
||||
const operator = row.querySelector('.cond-operator')?.value || 'contains';
|
||||
const value = row.querySelector('.cond-value')?.value || '';
|
||||
conditions.push({ field, operator, value });
|
||||
});
|
||||
data.config.conditions = conditions;
|
||||
// Also save match mode
|
||||
const matchEl = document.getElementById('cfg-' + slotKey + '-match');
|
||||
if (matchEl) data.config.match = matchEl.value;
|
||||
}
|
||||
|
||||
// --- Mirrored Playlist Select ---
|
||||
|
||||
function _autoTogglePlaylistSelect(slotKey) {
|
||||
const allCb = document.getElementById('cfg-' + slotKey + '-all');
|
||||
const sel = document.getElementById('cfg-' + slotKey + '-playlist_id');
|
||||
if (sel) sel.disabled = allCb && allCb.checked;
|
||||
}
|
||||
|
||||
async function _autoLoadMirroredSelects() {
|
||||
const selects = document.querySelectorAll('.mirrored-playlist-select');
|
||||
if (!selects.length) return;
|
||||
|
||||
if (!_autoMirroredPlaylists) {
|
||||
try {
|
||||
const res = await fetch('/api/mirrored-playlists/list');
|
||||
_autoMirroredPlaylists = await res.json();
|
||||
} catch (e) { _autoMirroredPlaylists = []; }
|
||||
}
|
||||
|
||||
selects.forEach(sel => {
|
||||
const savedValue = sel.dataset.value || '';
|
||||
sel.innerHTML = '<option value="">-- Select playlist --</option>';
|
||||
_autoMirroredPlaylists.forEach(p => {
|
||||
sel.innerHTML += `<option value="${p.id}"${String(p.id) === savedValue ? ' selected' : ''}>${_esc(p.name)}</option>`;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function _readPlacedConfig(slotKey) {
|
||||
const data = _autoBuilder[slotKey];
|
||||
if (!data) return {};
|
||||
const type = data.type;
|
||||
if (type === 'schedule') {
|
||||
return {
|
||||
interval: parseInt(document.getElementById('cfg-' + slotKey + '-interval')?.value) || 6,
|
||||
unit: document.getElementById('cfg-' + slotKey + '-unit')?.value || 'hours',
|
||||
};
|
||||
}
|
||||
if (type === 'daily_time') {
|
||||
return { time: document.getElementById('cfg-' + slotKey + '-time')?.value || '03:00' };
|
||||
}
|
||||
if (type === 'weekly_time') {
|
||||
const daysEl = document.getElementById('cfg-' + slotKey + '-days');
|
||||
const days = daysEl ? Array.from(daysEl.querySelectorAll('.day-btn.active')).map(b => b.dataset.day) : [];
|
||||
return {
|
||||
time: document.getElementById('cfg-' + slotKey + '-time')?.value || '03:00',
|
||||
days,
|
||||
};
|
||||
}
|
||||
// Event triggers with conditions
|
||||
const blockDef = _findBlockDef(type);
|
||||
if (blockDef && blockDef.has_conditions) {
|
||||
_autoSaveConditionsFromDOM(slotKey);
|
||||
return {
|
||||
conditions: (data.config && data.config.conditions) || [],
|
||||
match: document.getElementById('cfg-' + slotKey + '-match')?.value || 'all',
|
||||
};
|
||||
}
|
||||
if (type === 'process_wishlist') {
|
||||
return { category: document.getElementById('cfg-' + slotKey + '-category')?.value || 'all' };
|
||||
}
|
||||
if (type === 'refresh_mirrored') {
|
||||
const allCb = document.getElementById('cfg-' + slotKey + '-all');
|
||||
return {
|
||||
playlist_id: document.getElementById('cfg-' + slotKey + '-playlist_id')?.value || '',
|
||||
all: allCb ? allCb.checked : false,
|
||||
};
|
||||
}
|
||||
if (type === 'sync_playlist') {
|
||||
return { playlist_id: document.getElementById('cfg-' + slotKey + '-playlist_id')?.value || '' };
|
||||
}
|
||||
if (type === 'discord_webhook') {
|
||||
return {
|
||||
webhook_url: document.getElementById('cfg-' + slotKey + '-webhook_url')?.value?.trim() || '',
|
||||
message: document.getElementById('cfg-' + slotKey + '-message')?.value || '',
|
||||
};
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
function _findBlockDef(type) {
|
||||
if (!_autoBlocks) return null;
|
||||
for (const cat of ['triggers','actions','notifications']) {
|
||||
const found = (_autoBlocks[cat] || []).find(b => b.type === type);
|
||||
if (found) return found;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// --- Drag & Drop ---
|
||||
|
||||
function _autoDragStart(e, blockType, slotCategory) {
|
||||
e.dataTransfer.setData('text/plain', JSON.stringify({ type: blockType, slot: slotCategory }));
|
||||
e.dataTransfer.effectAllowed = 'copy';
|
||||
}
|
||||
|
||||
function _autoDragOver(e, slotKey) {
|
||||
e.preventDefault();
|
||||
e.dataTransfer.dropEffect = 'copy';
|
||||
document.getElementById('slot-' + slotKey)?.classList.add('drag-over');
|
||||
}
|
||||
|
||||
function _autoDragLeave(e, slotKey) {
|
||||
document.getElementById('slot-' + slotKey)?.classList.remove('drag-over');
|
||||
}
|
||||
|
||||
function _autoDrop(e, slotKey) {
|
||||
e.preventDefault();
|
||||
document.getElementById('slot-' + slotKey)?.classList.remove('drag-over');
|
||||
try {
|
||||
const data = JSON.parse(e.dataTransfer.getData('text/plain'));
|
||||
if (data.slot !== slotKey) { showToast('Wrong slot — drop ' + data.slot + ' blocks here', 'error'); return; }
|
||||
_autoBuilder[slotKey] = { type: data.type, config: {} };
|
||||
_renderBuilderCanvas();
|
||||
} catch (err) {}
|
||||
}
|
||||
|
||||
// Click-to-add (alternative to drag)
|
||||
function _autoClickBlock(blockType, slotCategory) {
|
||||
_autoBuilder[slotCategory] = { type: blockType, config: {} };
|
||||
_renderBuilderCanvas();
|
||||
}
|
||||
|
||||
function _autoRemoveBlock(slotKey) {
|
||||
_autoBuilder[slotKey] = null;
|
||||
_renderBuilderCanvas();
|
||||
}
|
||||
|
||||
// Variable insertion
|
||||
function _autoInsertVar(textareaId, variable) {
|
||||
const el = document.getElementById(textareaId);
|
||||
if (!el) return;
|
||||
const start = el.selectionStart, end = el.selectionEnd;
|
||||
el.value = el.value.substring(0, start) + variable + el.value.substring(end);
|
||||
el.selectionStart = el.selectionEnd = start + variable.length;
|
||||
el.focus();
|
||||
}
|
||||
|
||||
// --- Helpers ---
|
||||
|
||||
function _esc(str) {
|
||||
|
|
|
|||
|
|
@ -28275,4 +28275,368 @@ body {
|
|||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* ===========================
|
||||
AUTOMATIONS PAGE
|
||||
=========================== */
|
||||
|
||||
.automations-container { padding: 20px 24px; }
|
||||
.automations-list { display: flex; flex-direction: column; gap: 0; }
|
||||
|
||||
/* --- New Automation Button --- */
|
||||
.auto-new-btn {
|
||||
background: linear-gradient(135deg, rgb(var(--accent-rgb)) 0%, rgb(var(--accent-light-rgb)) 100%);
|
||||
color: #fff;
|
||||
border: none;
|
||||
padding: 10px 22px;
|
||||
border-radius: 12px;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
letter-spacing: 0.3px;
|
||||
}
|
||||
.auto-new-btn:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 8px 24px rgba(var(--accent-rgb), 0.4);
|
||||
filter: brightness(1.1);
|
||||
}
|
||||
|
||||
/* --- Stats Summary Bar --- */
|
||||
.automations-stats {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
padding: 0 0 16px 0;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.auto-stat {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 6px 14px;
|
||||
border-radius: 10px;
|
||||
background: rgba(255,255,255,0.04);
|
||||
border: 1px solid rgba(255,255,255,0.06);
|
||||
font-size: 12px;
|
||||
color: rgba(255,255,255,0.5);
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
.auto-stat:hover {
|
||||
background: rgba(255,255,255,0.06);
|
||||
border-color: rgba(255,255,255,0.1);
|
||||
}
|
||||
.auto-stat-value {
|
||||
font-weight: 700;
|
||||
color: rgb(var(--accent-light-rgb));
|
||||
}
|
||||
|
||||
/* --- Automation Card --- */
|
||||
.automation-card {
|
||||
background: linear-gradient(135deg, rgba(26, 26, 26, 0.95) 0%, rgba(18, 18, 18, 0.98) 100%);
|
||||
border-radius: 20px; border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.12);
|
||||
margin: 10px 0; padding: 20px 22px;
|
||||
display: flex; align-items: center; gap: 16px;
|
||||
transition: all 0.4s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
position: relative; overflow: hidden;
|
||||
box-shadow: 0 8px 32px rgba(0,0,0,0.4), 0 2px 8px rgba(0,0,0,0.2), inset 0 1px 0 rgba(255,255,255,0.1);
|
||||
}
|
||||
.automation-card::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 0; top: 50%;
|
||||
transform: translateY(-50%);
|
||||
width: 4px; height: 30%;
|
||||
background: linear-gradient(180deg, transparent 0%, rgba(var(--accent-rgb), 0.15) 100%);
|
||||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
border-radius: 0 4px 4px 0;
|
||||
}
|
||||
.automation-card:hover {
|
||||
background: linear-gradient(135deg, rgba(30,30,30,0.98) 0%, rgba(22,22,22,1) 100%);
|
||||
border-color: rgba(var(--accent-rgb), 0.3); border-top-color: rgba(var(--accent-rgb), 0.4);
|
||||
transform: translateY(-3px) scale(1.01);
|
||||
box-shadow: 0 16px 48px rgba(0,0,0,0.5), 0 8px 16px rgba(0,0,0,0.3), 0 0 20px rgba(var(--accent-rgb),0.12), inset 0 1px 0 rgba(255,255,255,0.15);
|
||||
}
|
||||
.automation-card:hover::before {
|
||||
height: 60%;
|
||||
background: linear-gradient(180deg, rgb(var(--accent-light-rgb)), rgb(var(--accent-rgb)));
|
||||
box-shadow: 0 0 8px rgba(var(--accent-rgb), 0.5);
|
||||
}
|
||||
.automation-card.disabled { opacity: 0.55; }
|
||||
.automation-card.disabled:hover { opacity: 0.75; }
|
||||
.automation-card.disabled .automation-name { color: rgba(255,255,255,0.5); }
|
||||
|
||||
.automation-status { width: 10px; height: 10px; border-radius: 50%; flex-shrink: 0; transition: background 0.3s ease; }
|
||||
.automation-status.enabled { background: #4ade80; box-shadow: 0 0 8px rgba(74,222,128,0.4); }
|
||||
.automation-status.disabled { background: #666; }
|
||||
|
||||
.automation-info { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 6px; }
|
||||
.automation-name { font-size: 15px; font-weight: 600; color: #fff; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.automation-flow { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
|
||||
.flow-trigger, .flow-action, .flow-notify {
|
||||
font-size: 12px; padding: 3px 10px; border-radius: 12px; white-space: nowrap;
|
||||
}
|
||||
.flow-trigger { background: rgba(var(--accent-rgb),0.15); color: rgb(var(--accent-light-rgb)); border: 1px solid rgba(var(--accent-rgb),0.25); }
|
||||
.flow-action { background: rgba(88,101,242,0.15); color: #7289da; border: 1px solid rgba(88,101,242,0.25); }
|
||||
.flow-notify { background: rgba(250,204,21,0.12); color: #fbbf24; border: 1px solid rgba(250,204,21,0.2); }
|
||||
.flow-arrow { color: rgba(255,255,255,0.3); font-size: 14px; font-weight: 600; }
|
||||
.flow-delay { font-size: 11px; padding: 2px 8px; border-radius: 10px; white-space: nowrap; background: rgba(255,255,255,0.06); color: rgba(255,255,255,0.45); border: 1px dashed rgba(255,255,255,0.15); }
|
||||
.day-picker { display: flex; gap: 4px; flex-wrap: wrap; }
|
||||
.day-btn { background: rgba(255,255,255,0.06); color: rgba(255,255,255,0.5); border: 1px solid rgba(255,255,255,0.12); border-radius: 8px; padding: 4px 10px; font-size: 12px; cursor: pointer; transition: all 0.2s ease; }
|
||||
.day-btn:hover { background: rgba(255,255,255,0.1); color: rgba(255,255,255,0.7); }
|
||||
.day-btn.active { background: rgba(var(--accent-rgb), 0.2); color: rgb(var(--accent-light-rgb)); border-color: rgba(var(--accent-rgb), 0.4); }
|
||||
.automation-meta { font-size: 11px; color: rgba(255,255,255,0.4); }
|
||||
|
||||
.automation-actions { display: flex; align-items: center; gap: 8px; flex-shrink: 0; }
|
||||
.automation-run-btn, .automation-edit-btn, .automation-delete-btn {
|
||||
background: rgba(255,255,255,0.06); border: 1px solid rgba(255,255,255,0.1);
|
||||
border-radius: 10px; width: 34px; height: 34px;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
cursor: pointer; font-size: 14px; transition: all 0.2s ease; color: rgba(255,255,255,0.6);
|
||||
}
|
||||
.automation-run-btn:hover { background: rgba(var(--accent-rgb),0.2); border-color: rgba(var(--accent-rgb),0.4); color: rgb(var(--accent-light-rgb)); }
|
||||
.automation-edit-btn:hover { background: rgba(255,255,255,0.1); border-color: rgba(255,255,255,0.2); color: #fff; }
|
||||
.automation-delete-btn:hover { background: rgba(239,68,68,0.2); border-color: rgba(239,68,68,0.4); color: #ef4444; }
|
||||
|
||||
/* Toggle switch */
|
||||
.automation-toggle { position: relative; display: inline-block; width: 40px; height: 22px; }
|
||||
.automation-toggle input { opacity: 0; width: 0; height: 0; }
|
||||
.toggle-slider {
|
||||
position: absolute; cursor: pointer; top: 0; left: 0; right: 0; bottom: 0;
|
||||
background: rgba(255,255,255,0.1); border-radius: 22px; transition: all 0.3s ease; border: 1px solid rgba(255,255,255,0.15);
|
||||
}
|
||||
.toggle-slider::before {
|
||||
content: ''; position: absolute; height: 16px; width: 16px; left: 2px; bottom: 2px;
|
||||
background: rgba(255,255,255,0.6); border-radius: 50%; transition: all 0.3s ease;
|
||||
}
|
||||
.automation-toggle input:checked + .toggle-slider { background: rgba(var(--accent-rgb),0.5); border-color: rgba(var(--accent-rgb),0.6); }
|
||||
.automation-toggle input:checked + .toggle-slider::before { transform: translateX(18px); background: rgb(var(--accent-light-rgb)); }
|
||||
|
||||
/* --- Empty State --- */
|
||||
.automations-empty {
|
||||
text-align: center; padding: 80px 24px;
|
||||
background: linear-gradient(135deg, rgba(var(--accent-rgb), 0.03) 0%, transparent 60%);
|
||||
border-radius: 20px;
|
||||
border: 1px dashed rgba(255,255,255,0.08);
|
||||
}
|
||||
.automations-empty-icon { font-size: 56px; margin-bottom: 20px; filter: grayscale(0.3); }
|
||||
.automations-empty-title {
|
||||
font-size: 22px; font-weight: 700; margin-bottom: 10px;
|
||||
background: linear-gradient(135deg, #fff 0%, rgb(var(--accent-light-rgb)) 100%);
|
||||
-webkit-background-clip: text; -webkit-text-fill-color: transparent; background-clip: text;
|
||||
}
|
||||
.automations-empty-text {
|
||||
font-size: 14px; color: rgba(255,255,255,0.4); margin-bottom: 28px;
|
||||
max-width: 420px; margin-left: auto; margin-right: auto; line-height: 1.6;
|
||||
}
|
||||
|
||||
/* --- Builder View --- */
|
||||
.automations-builder-view { display: flex; flex-direction: column; height: 100%; }
|
||||
|
||||
.builder-header {
|
||||
display: flex; align-items: center; gap: 12px;
|
||||
padding: 16px 24px; border-bottom: 1px solid rgba(255,255,255,0.08);
|
||||
background: rgba(18,18,18,0.95); flex-shrink: 0;
|
||||
}
|
||||
.builder-back-btn {
|
||||
background: rgba(255,255,255,0.06); border: 1px solid rgba(255,255,255,0.1);
|
||||
border-radius: 10px; width: 36px; height: 36px; display: flex; align-items: center; justify-content: center;
|
||||
cursor: pointer; font-size: 18px; color: rgba(255,255,255,0.6); transition: all 0.2s ease;
|
||||
}
|
||||
.builder-back-btn:hover { background: rgba(255,255,255,0.1); color: #fff; }
|
||||
.builder-name-input {
|
||||
flex: 1; background: rgba(255,255,255,0.06); border: 1px solid rgba(255,255,255,0.1);
|
||||
border-radius: 10px; padding: 8px 14px; color: #fff; font-size: 16px; font-weight: 600;
|
||||
outline: none; transition: border-color 0.2s ease;
|
||||
}
|
||||
.builder-name-input:focus { border-color: rgba(var(--accent-rgb),0.5); }
|
||||
.builder-name-input::placeholder { color: rgba(255,255,255,0.3); }
|
||||
.builder-header-actions { display: flex; gap: 8px; }
|
||||
.builder-header-actions .btn-cancel {
|
||||
background: rgba(255,255,255,0.06); border: 1px solid rgba(255,255,255,0.12);
|
||||
color: rgba(255,255,255,0.7); padding: 8px 20px; border-radius: 10px; cursor: pointer; font-size: 13px; transition: all 0.2s ease;
|
||||
}
|
||||
.builder-header-actions .btn-cancel:hover { background: rgba(255,255,255,0.1); color: #fff; }
|
||||
.builder-header-actions .btn-save {
|
||||
background: rgb(var(--accent-rgb)); border: none; color: #fff;
|
||||
padding: 8px 24px; border-radius: 10px; cursor: pointer; font-size: 13px; font-weight: 600; transition: all 0.2s ease;
|
||||
}
|
||||
.builder-header-actions .btn-save:hover { filter: brightness(1.15); transform: translateY(-1px); }
|
||||
|
||||
.builder-content { display: flex; flex: 1; overflow: hidden; }
|
||||
|
||||
/* --- Builder Sidebar --- */
|
||||
.builder-sidebar {
|
||||
width: 260px; flex-shrink: 0; overflow-y: auto;
|
||||
border-right: 1px solid rgba(255,255,255,0.06);
|
||||
padding: 16px; background: rgba(14,14,14,0.5);
|
||||
}
|
||||
.sidebar-section { margin-bottom: 20px; }
|
||||
.sidebar-section-title {
|
||||
font-size: 10px; font-weight: 700; text-transform: uppercase; letter-spacing: 1.2px;
|
||||
color: rgba(255,255,255,0.35); margin-bottom: 8px; padding-left: 4px;
|
||||
}
|
||||
.block-item {
|
||||
display: flex; align-items: center; gap: 10px;
|
||||
padding: 10px 12px; border-radius: 12px;
|
||||
background: rgba(255,255,255,0.04); border: 1px solid rgba(255,255,255,0.06);
|
||||
margin-bottom: 6px; cursor: grab; transition: all 0.2s ease; user-select: none;
|
||||
}
|
||||
.block-item:hover {
|
||||
background: rgba(255,255,255,0.08); border-color: rgba(var(--accent-rgb),0.3);
|
||||
transform: translateX(2px);
|
||||
}
|
||||
.block-item:active { cursor: grabbing; }
|
||||
.block-item.coming-soon {
|
||||
opacity: 0.35; cursor: default; pointer-events: none;
|
||||
}
|
||||
.block-item-icon { font-size: 18px; flex-shrink: 0; width: 28px; text-align: center; }
|
||||
.block-item-text { flex: 1; min-width: 0; }
|
||||
.block-item-label { font-size: 13px; font-weight: 500; color: #fff; }
|
||||
.block-item-desc { font-size: 10px; color: rgba(255,255,255,0.35); margin-top: 1px; }
|
||||
.block-item .coming-soon-badge {
|
||||
font-size: 8px; text-transform: uppercase; letter-spacing: 0.5px;
|
||||
color: rgba(255,255,255,0.3); background: rgba(255,255,255,0.06);
|
||||
padding: 2px 6px; border-radius: 6px; white-space: nowrap;
|
||||
}
|
||||
|
||||
/* --- Builder Canvas --- */
|
||||
.builder-canvas {
|
||||
flex: 1; overflow-y: auto; display: flex; flex-direction: column;
|
||||
align-items: center; padding: 40px 24px; gap: 0;
|
||||
}
|
||||
|
||||
.flow-slot {
|
||||
width: 100%; max-width: 480px; min-height: 70px;
|
||||
border: 2px dashed rgba(255,255,255,0.1); border-radius: 16px;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
transition: all 0.3s ease; position: relative;
|
||||
}
|
||||
.flow-slot.empty { cursor: default; }
|
||||
.flow-slot.empty .flow-slot-prompt {
|
||||
font-size: 13px; color: rgba(255,255,255,0.25); text-align: center; padding: 20px;
|
||||
}
|
||||
.flow-slot.drag-over {
|
||||
border-color: rgba(var(--accent-rgb),0.6); background: rgba(var(--accent-rgb),0.05);
|
||||
box-shadow: 0 0 20px rgba(var(--accent-rgb),0.1);
|
||||
}
|
||||
.flow-slot.filled { border: none; min-height: auto; }
|
||||
|
||||
.flow-connector {
|
||||
width: 2px; height: 32px; background: rgba(255,255,255,0.1); position: relative;
|
||||
}
|
||||
.flow-connector::after {
|
||||
content: ''; position: absolute; bottom: -4px; left: -4px;
|
||||
width: 0; height: 0;
|
||||
border-left: 5px solid transparent; border-right: 5px solid transparent;
|
||||
border-top: 6px solid rgba(255,255,255,0.15);
|
||||
}
|
||||
|
||||
.flow-slot-label {
|
||||
position: absolute; top: -10px; left: 16px;
|
||||
font-size: 9px; font-weight: 700; text-transform: uppercase; letter-spacing: 1px;
|
||||
padding: 2px 8px; border-radius: 6px;
|
||||
}
|
||||
.flow-slot-label.when { background: rgba(var(--accent-rgb),0.2); color: rgb(var(--accent-light-rgb)); }
|
||||
.flow-slot-label.do { background: rgba(88,101,242,0.2); color: #7289da; }
|
||||
.flow-slot-label.notify { background: rgba(250,204,21,0.15); color: #fbbf24; }
|
||||
|
||||
/* --- Placed Block (in flow) --- */
|
||||
.placed-block {
|
||||
width: 100%; max-width: 480px;
|
||||
background: linear-gradient(135deg, rgba(26,26,26,0.95) 0%, rgba(18,18,18,0.98) 100%);
|
||||
border-radius: 16px; border: 1px solid rgba(255,255,255,0.1);
|
||||
overflow: hidden; transition: all 0.3s ease;
|
||||
box-shadow: 0 4px 16px rgba(0,0,0,0.3);
|
||||
}
|
||||
.placed-block:hover { border-color: rgba(255,255,255,0.18); }
|
||||
.placed-block-header {
|
||||
display: flex; align-items: center; gap: 10px; padding: 14px 16px;
|
||||
border-bottom: 1px solid rgba(255,255,255,0.06);
|
||||
}
|
||||
.placed-block-icon { font-size: 18px; }
|
||||
.placed-block-label { flex: 1; font-size: 14px; font-weight: 600; color: #fff; }
|
||||
.placed-block-remove {
|
||||
background: none; border: none; color: rgba(255,255,255,0.3);
|
||||
font-size: 16px; cursor: pointer; padding: 2px 6px; border-radius: 6px; transition: all 0.2s ease;
|
||||
}
|
||||
.placed-block-remove:hover { background: rgba(239,68,68,0.2); color: #ef4444; }
|
||||
.placed-block-config { padding: 14px 16px; }
|
||||
.placed-block-config .config-row {
|
||||
display: flex; align-items: center; gap: 8px; margin-bottom: 10px;
|
||||
}
|
||||
.placed-block-config .config-row:last-child { margin-bottom: 0; }
|
||||
.placed-block-config label {
|
||||
font-size: 12px; color: rgba(255,255,255,0.5); text-transform: uppercase;
|
||||
letter-spacing: 0.5px; white-space: nowrap; min-width: 50px;
|
||||
}
|
||||
.placed-block-config input[type="text"],
|
||||
.placed-block-config input[type="number"],
|
||||
.placed-block-config textarea {
|
||||
flex: 1; background: rgba(255,255,255,0.06); border: 1px solid rgba(255,255,255,0.1);
|
||||
border-radius: 8px; padding: 7px 10px; color: #fff; font-size: 13px;
|
||||
outline: none; transition: border-color 0.2s ease;
|
||||
}
|
||||
.placed-block-config input:focus,
|
||||
.placed-block-config textarea:focus { border-color: rgba(var(--accent-rgb),0.5); }
|
||||
.placed-block-config select {
|
||||
background: rgba(255,255,255,0.06); border: 1px solid rgba(255,255,255,0.1);
|
||||
border-radius: 8px; padding: 7px 10px; color: #fff; font-size: 13px;
|
||||
outline: none; cursor: pointer;
|
||||
}
|
||||
.placed-block-config select option,
|
||||
.condition-match-select option,
|
||||
.condition-row select option {
|
||||
background: #1a1a1a; color: #fff;
|
||||
}
|
||||
.placed-block-config textarea { resize: vertical; min-height: 60px; font-family: inherit; }
|
||||
|
||||
/* Variable tags */
|
||||
.variable-tags { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 8px; }
|
||||
.variable-tag {
|
||||
font-size: 11px; padding: 3px 8px; border-radius: 8px; cursor: pointer;
|
||||
background: rgba(var(--accent-rgb),0.1); color: rgb(var(--accent-light-rgb));
|
||||
border: 1px solid rgba(var(--accent-rgb),0.2); transition: all 0.2s ease; font-family: monospace;
|
||||
}
|
||||
.variable-tag:hover { background: rgba(var(--accent-rgb),0.2); border-color: rgba(var(--accent-rgb),0.4); }
|
||||
|
||||
/* Condition builder */
|
||||
.condition-builder { margin-top: 8px; }
|
||||
.condition-header { margin-bottom: 8px; }
|
||||
.condition-match-select {
|
||||
background: rgba(255,255,255,0.06); border: 1px solid rgba(255,255,255,0.12);
|
||||
color: #fff; padding: 4px 8px; border-radius: 6px; font-size: 12px;
|
||||
}
|
||||
.condition-row {
|
||||
display: flex; gap: 6px; align-items: center; margin-bottom: 6px; padding: 8px 10px;
|
||||
background: rgba(255,255,255,0.03); border-radius: 6px; border: 1px solid rgba(255,255,255,0.06);
|
||||
}
|
||||
.condition-row select, .condition-row input[type="text"] {
|
||||
background: rgba(255,255,255,0.06); border: 1px solid rgba(255,255,255,0.12);
|
||||
color: #fff; padding: 5px 8px; border-radius: 5px; font-size: 12px;
|
||||
}
|
||||
.condition-row select { min-width: 80px; }
|
||||
.condition-row input[type="text"] { flex: 1; min-width: 80px; }
|
||||
.condition-row select:focus, .condition-row input:focus {
|
||||
border-color: rgba(var(--accent-rgb),0.5); outline: none;
|
||||
}
|
||||
.add-condition-btn {
|
||||
display: inline-block; margin-top: 6px; padding: 5px 12px; font-size: 12px;
|
||||
background: transparent; color: rgba(var(--accent-light-rgb),0.8);
|
||||
border: 1px dashed rgba(var(--accent-rgb),0.3); border-radius: 6px; cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
.add-condition-btn:hover {
|
||||
background: rgba(var(--accent-rgb),0.1); border-color: rgba(var(--accent-rgb),0.5);
|
||||
}
|
||||
.remove-condition-btn {
|
||||
background: none; border: none; color: rgba(255,255,255,0.3); cursor: pointer;
|
||||
font-size: 14px; padding: 2px 6px; border-radius: 4px; transition: all 0.2s ease;
|
||||
}
|
||||
.remove-condition-btn:hover { color: #ff6b6b; background: rgba(255,0,0,0.1); }
|
||||
|
||||
/* Mirrored playlist select */
|
||||
.placed-block-config input[type="checkbox"] {
|
||||
margin-right: 6px; accent-color: rgb(var(--accent-rgb));
|
||||
}
|
||||
Loading…
Reference in a new issue