Live automation progress tracking with real-time output panels
This commit is contained in:
parent
20ee504245
commit
9f416475e2
5 changed files with 461 additions and 16 deletions
|
|
@ -49,6 +49,10 @@ class AutomationEngine:
|
||||||
# Format: {type: {'handler': fn(config)->dict, 'guard': fn()->bool or None}}
|
# Format: {type: {'handler': fn(config)->dict, 'guard': fn()->bool or None}}
|
||||||
self._action_handlers = {}
|
self._action_handlers = {}
|
||||||
|
|
||||||
|
# Progress tracking callbacks (registered by web_server.py)
|
||||||
|
self._progress_init_fn = None
|
||||||
|
self._progress_finish_fn = None
|
||||||
|
|
||||||
# Event trigger cache: trigger_type → [automation_id, ...]
|
# Event trigger cache: trigger_type → [automation_id, ...]
|
||||||
self._event_automations = {}
|
self._event_automations = {}
|
||||||
self._event_cache_dirty = True
|
self._event_cache_dirty = True
|
||||||
|
|
@ -73,6 +77,11 @@ class AutomationEngine:
|
||||||
}
|
}
|
||||||
logger.debug(f"Registered action handler: {action_type}")
|
logger.debug(f"Registered action handler: {action_type}")
|
||||||
|
|
||||||
|
def register_progress_callbacks(self, init_fn, finish_fn):
|
||||||
|
"""Register callbacks for live progress tracking from web_server.py."""
|
||||||
|
self._progress_init_fn = init_fn
|
||||||
|
self._progress_finish_fn = finish_fn
|
||||||
|
|
||||||
# --- System Automations ---
|
# --- System Automations ---
|
||||||
|
|
||||||
def ensure_system_automations(self):
|
def ensure_system_automations(self):
|
||||||
|
|
@ -278,6 +287,11 @@ class AutomationEngine:
|
||||||
action_config = json.loads(auto.get('action_config') or '{}')
|
action_config = json.loads(auto.get('action_config') or '{}')
|
||||||
except json.JSONDecodeError:
|
except json.JSONDecodeError:
|
||||||
action_config = {}
|
action_config = {}
|
||||||
|
|
||||||
|
# Inject automation identity for progress tracking
|
||||||
|
action_config['_automation_id'] = automation_id
|
||||||
|
action_config['_automation_name'] = auto.get('name', '')
|
||||||
|
|
||||||
delay_minutes = action_config.get('delay', 0)
|
delay_minutes = action_config.get('delay', 0)
|
||||||
if delay_minutes and delay_minutes > 0:
|
if delay_minutes and delay_minutes > 0:
|
||||||
logger.info(f"Event automation '{auto.get('name')}' delaying {delay_minutes}m before action")
|
logger.info(f"Event automation '{auto.get('name')}' delaying {delay_minutes}m before action")
|
||||||
|
|
@ -299,12 +313,20 @@ class AutomationEngine:
|
||||||
result = {'status': 'skipped', 'reason': f'{action_type} already running'}
|
result = {'status': 'skipped', 'reason': f'{action_type} already running'}
|
||||||
logger.info(f"Event automation '{auto.get('name')}' skipped — {action_type} busy")
|
logger.info(f"Event automation '{auto.get('name')}' skipped — {action_type} busy")
|
||||||
else:
|
else:
|
||||||
|
# Initialize progress tracking
|
||||||
|
if self._progress_init_fn:
|
||||||
|
try: self._progress_init_fn(automation_id, auto.get('name', ''), action_type)
|
||||||
|
except Exception: pass
|
||||||
try:
|
try:
|
||||||
result = handler_info['handler'](action_config) or {}
|
result = handler_info['handler'](action_config) or {}
|
||||||
logger.info(f"Event automation '{auto.get('name')}' executed: {result.get('status', 'ok')}")
|
logger.info(f"Event automation '{auto.get('name')}' executed: {result.get('status', 'ok')}")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
result = {'status': 'error', 'error': str(e)}
|
result = {'status': 'error', 'error': str(e)}
|
||||||
logger.error(f"Event automation '{auto.get('name')}' action failed: {e}")
|
logger.error(f"Event automation '{auto.get('name')}' action failed: {e}")
|
||||||
|
# Finalize progress tracking
|
||||||
|
if self._progress_finish_fn:
|
||||||
|
try: self._progress_finish_fn(automation_id, result)
|
||||||
|
except Exception: pass
|
||||||
|
|
||||||
# Merge event data into result for notification variables
|
# Merge event data into result for notification variables
|
||||||
merged = {**event_data, **result}
|
merged = {**event_data, **result}
|
||||||
|
|
@ -353,6 +375,10 @@ class AutomationEngine:
|
||||||
except json.JSONDecodeError:
|
except json.JSONDecodeError:
|
||||||
action_config = {}
|
action_config = {}
|
||||||
|
|
||||||
|
# Inject automation identity for progress tracking
|
||||||
|
action_config['_automation_id'] = automation_id
|
||||||
|
action_config['_automation_name'] = auto.get('name', '')
|
||||||
|
|
||||||
# Action delay (skipped for manual run_now)
|
# Action delay (skipped for manual run_now)
|
||||||
delay_minutes = action_config.get('delay', 0)
|
delay_minutes = action_config.get('delay', 0)
|
||||||
if not skip_delay and delay_minutes and delay_minutes > 0:
|
if not skip_delay and delay_minutes and delay_minutes > 0:
|
||||||
|
|
@ -369,6 +395,11 @@ class AutomationEngine:
|
||||||
self._finish_run(auto, automation_id, result, error=None)
|
self._finish_run(auto, automation_id, result, error=None)
|
||||||
return
|
return
|
||||||
|
|
||||||
|
# Initialize progress tracking
|
||||||
|
if self._progress_init_fn:
|
||||||
|
try: self._progress_init_fn(automation_id, auto.get('name', ''), action_type)
|
||||||
|
except Exception: pass
|
||||||
|
|
||||||
# Execute the action
|
# Execute the action
|
||||||
error = None
|
error = None
|
||||||
result = {}
|
result = {}
|
||||||
|
|
@ -380,6 +411,11 @@ class AutomationEngine:
|
||||||
result = {'status': 'error', 'error': error}
|
result = {'status': 'error', 'error': error}
|
||||||
logger.error(f"Automation '{auto['name']}' (id={automation_id}) failed: {e}")
|
logger.error(f"Automation '{auto['name']}' (id={automation_id}) failed: {e}")
|
||||||
|
|
||||||
|
# Finalize progress tracking
|
||||||
|
if self._progress_finish_fn:
|
||||||
|
try: self._progress_finish_fn(automation_id, result)
|
||||||
|
except Exception: pass
|
||||||
|
|
||||||
# Send notification if configured
|
# Send notification if configured
|
||||||
try:
|
try:
|
||||||
self._send_notification(auto, result)
|
self._send_notification(auto, result)
|
||||||
|
|
|
||||||
300
web_server.py
300
web_server.py
|
|
@ -269,12 +269,18 @@ def _register_automation_handlers():
|
||||||
return
|
return
|
||||||
|
|
||||||
def _auto_process_wishlist(config):
|
def _auto_process_wishlist(config):
|
||||||
_process_wishlist_automatically()
|
try:
|
||||||
return {'status': 'completed'}
|
_process_wishlist_automatically(automation_id=config.get('_automation_id'))
|
||||||
|
return {'status': 'completed'}
|
||||||
|
except Exception as e:
|
||||||
|
return {'status': 'error', 'error': str(e)}
|
||||||
|
|
||||||
def _auto_scan_watchlist(config):
|
def _auto_scan_watchlist(config):
|
||||||
_process_watchlist_scan_automatically()
|
try:
|
||||||
return {'status': 'completed'}
|
_process_watchlist_scan_automatically(automation_id=config.get('_automation_id'))
|
||||||
|
return {'status': 'completed'}
|
||||||
|
except Exception as e:
|
||||||
|
return {'status': 'error', 'error': str(e)}
|
||||||
|
|
||||||
def _auto_scan_library(config):
|
def _auto_scan_library(config):
|
||||||
if web_scan_manager:
|
if web_scan_manager:
|
||||||
|
|
@ -292,6 +298,7 @@ def _register_automation_handlers():
|
||||||
db = get_database()
|
db = get_database()
|
||||||
playlist_id = config.get('playlist_id')
|
playlist_id = config.get('playlist_id')
|
||||||
refresh_all = config.get('all', False)
|
refresh_all = config.get('all', False)
|
||||||
|
auto_id = config.get('_automation_id')
|
||||||
|
|
||||||
if refresh_all:
|
if refresh_all:
|
||||||
playlists = db.get_mirrored_playlists()
|
playlists = db.get_mirrored_playlists()
|
||||||
|
|
@ -303,10 +310,14 @@ def _register_automation_handlers():
|
||||||
|
|
||||||
refreshed = 0
|
refreshed = 0
|
||||||
errors = []
|
errors = []
|
||||||
for pl in playlists:
|
for idx, pl in enumerate(playlists):
|
||||||
try:
|
try:
|
||||||
source = pl.get('source', '')
|
source = pl.get('source', '')
|
||||||
source_id = pl.get('source_playlist_id', '')
|
source_id = pl.get('source_playlist_id', '')
|
||||||
|
_update_automation_progress(auto_id,
|
||||||
|
progress=(idx / max(1, len(playlists))) * 100,
|
||||||
|
phase=f'Refreshing: "{pl.get("name", "")}"',
|
||||||
|
current_item=pl.get('name', ''))
|
||||||
tracks = None
|
tracks = None
|
||||||
|
|
||||||
if source == 'spotify' and spotify_client and spotify_client.is_spotify_authenticated():
|
if source == 'spotify' and spotify_client and spotify_client.is_spotify_authenticated():
|
||||||
|
|
@ -393,24 +404,34 @@ def _register_automation_handlers():
|
||||||
|
|
||||||
# Emit playlist_changed if tracks actually changed
|
# Emit playlist_changed if tracks actually changed
|
||||||
if old_ids != new_ids:
|
if old_ids != new_ids:
|
||||||
|
added_count = len(new_ids - old_ids)
|
||||||
|
removed_count = len(old_ids - new_ids)
|
||||||
|
_update_automation_progress(auto_id,
|
||||||
|
log_line=f'"{pl.get("name", "")}" — {added_count} added, {removed_count} removed', log_type='success')
|
||||||
try:
|
try:
|
||||||
if automation_engine:
|
if automation_engine:
|
||||||
automation_engine.emit('playlist_changed', {
|
automation_engine.emit('playlist_changed', {
|
||||||
'playlist_name': pl.get('name', ''),
|
'playlist_name': pl.get('name', ''),
|
||||||
'old_count': str(len(old_ids)),
|
'old_count': str(len(old_ids)),
|
||||||
'new_count': str(len(new_ids)),
|
'new_count': str(len(new_ids)),
|
||||||
'added': str(len(new_ids - old_ids)),
|
'added': str(added_count),
|
||||||
'removed': str(len(old_ids - new_ids)),
|
'removed': str(removed_count),
|
||||||
})
|
})
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
else:
|
||||||
|
_update_automation_progress(auto_id,
|
||||||
|
log_line=f'No changes: "{pl.get("name", "")}"', log_type='skip')
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
errors.append(f"{pl.get('name', '?')}: {str(e)}")
|
errors.append(f"{pl.get('name', '?')}: {str(e)}")
|
||||||
|
_update_automation_progress(auto_id,
|
||||||
|
log_line=f'Error: {pl.get("name", "?")} — {str(e)}', log_type='error')
|
||||||
return {'status': 'completed', 'refreshed': str(refreshed), 'errors': str(len(errors))}
|
return {'status': 'completed', 'refreshed': str(refreshed), 'errors': str(len(errors))}
|
||||||
|
|
||||||
def _auto_sync_playlist(config):
|
def _auto_sync_playlist(config):
|
||||||
"""Sync a mirrored playlist to media server.
|
"""Sync a mirrored playlist to media server.
|
||||||
Uses discovered metadata when available, skips undiscovered tracks."""
|
Uses discovered metadata when available, skips undiscovered tracks."""
|
||||||
|
auto_id = config.get('_automation_id')
|
||||||
playlist_id = config.get('playlist_id')
|
playlist_id = config.get('playlist_id')
|
||||||
if not playlist_id:
|
if not playlist_id:
|
||||||
return {'status': 'error', 'reason': 'No playlist specified'}
|
return {'status': 'error', 'reason': 'No playlist specified'}
|
||||||
|
|
@ -452,7 +473,14 @@ def _register_automation_handlers():
|
||||||
# NOT discovered — skip to prevent garbage in wishlist
|
# NOT discovered — skip to prevent garbage in wishlist
|
||||||
skipped_count += 1
|
skipped_count += 1
|
||||||
|
|
||||||
|
_update_automation_progress(auto_id, progress=50,
|
||||||
|
phase=f'Syncing "{pl["name"]}"',
|
||||||
|
log_line=f'{len(tracks_json)} discovered, {skipped_count} skipped',
|
||||||
|
log_type='info' if tracks_json else 'skip')
|
||||||
|
|
||||||
if not tracks_json:
|
if not tracks_json:
|
||||||
|
_update_automation_progress(auto_id,
|
||||||
|
log_line=f'No discovered tracks — {skipped_count} need discovery first', log_type='skip')
|
||||||
return {
|
return {
|
||||||
'status': 'skipped',
|
'status': 'skipped',
|
||||||
'reason': f'No discovered tracks to sync ({skipped_count} tracks need discovery first)',
|
'reason': f'No discovered tracks to sync ({skipped_count} tracks need discovery first)',
|
||||||
|
|
@ -460,6 +488,8 @@ def _register_automation_handlers():
|
||||||
}
|
}
|
||||||
|
|
||||||
sync_id = f"auto_mirror_{playlist_id}"
|
sync_id = f"auto_mirror_{playlist_id}"
|
||||||
|
_update_automation_progress(auto_id, progress=90,
|
||||||
|
log_line=f'Starting sync: {len(tracks_json)} tracks', log_type='success')
|
||||||
threading.Thread(
|
threading.Thread(
|
||||||
target=_run_sync_task,
|
target=_run_sync_task,
|
||||||
args=(sync_id, pl['name'], json.dumps(tracks_json)),
|
args=(sync_id, pl['name'], json.dumps(tracks_json)),
|
||||||
|
|
@ -492,12 +522,13 @@ def _register_automation_handlers():
|
||||||
|
|
||||||
threading.Thread(
|
threading.Thread(
|
||||||
target=_run_playlist_discovery_worker,
|
target=_run_playlist_discovery_worker,
|
||||||
args=(playlists,),
|
args=(playlists, config.get('_automation_id')),
|
||||||
daemon=True,
|
daemon=True,
|
||||||
name='auto-discover-playlist'
|
name='auto-discover-playlist'
|
||||||
).start()
|
).start()
|
||||||
names = ', '.join(p['name'] for p in playlists[:3])
|
names = ', '.join(p['name'] for p in playlists[:3])
|
||||||
return {'status': 'started', 'playlist_count': str(len(playlists)), 'playlists': names}
|
return {'status': 'started', 'playlist_count': str(len(playlists)), 'playlists': names,
|
||||||
|
'_manages_own_progress': True}
|
||||||
|
|
||||||
automation_engine.register_action_handler('refresh_mirrored', _auto_refresh_mirrored)
|
automation_engine.register_action_handler('refresh_mirrored', _auto_refresh_mirrored)
|
||||||
automation_engine.register_action_handler('sync_playlist', _auto_sync_playlist)
|
automation_engine.register_action_handler('sync_playlist', _auto_sync_playlist)
|
||||||
|
|
@ -600,6 +631,24 @@ def _register_automation_handlers():
|
||||||
lambda: quality_scanner_state.get('status') == 'running')
|
lambda: quality_scanner_state.get('status') == 'running')
|
||||||
automation_engine.register_action_handler('backup_database', _auto_backup_database)
|
automation_engine.register_action_handler('backup_database', _auto_backup_database)
|
||||||
|
|
||||||
|
# Register progress tracking callbacks
|
||||||
|
def _progress_init(aid, name, action_type):
|
||||||
|
_init_automation_progress(aid, name, action_type)
|
||||||
|
|
||||||
|
def _progress_finish(aid, result):
|
||||||
|
result_status = result.get('status', '')
|
||||||
|
# Skip for handlers that manage their own progress lifecycle
|
||||||
|
# (they call _update_automation_progress(status='finished') themselves)
|
||||||
|
if result.get('_manages_own_progress'):
|
||||||
|
return
|
||||||
|
status = 'error' if result_status == 'error' else 'finished'
|
||||||
|
msg = result.get('error', result.get('reason', result_status or 'done'))
|
||||||
|
_update_automation_progress(aid, status=status, progress=100,
|
||||||
|
phase='Error' if status == 'error' else 'Complete',
|
||||||
|
log_line=msg, log_type='error' if status == 'error' else 'success')
|
||||||
|
|
||||||
|
automation_engine.register_progress_callbacks(_progress_init, _progress_finish)
|
||||||
|
|
||||||
print("✅ Automation action handlers registered")
|
print("✅ Automation action handlers registered")
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -729,6 +778,46 @@ sync_states = {} # Key: playlist_id, Value: dict with progress info
|
||||||
sync_lock = threading.Lock()
|
sync_lock = threading.Lock()
|
||||||
db_update_lock = threading.Lock()
|
db_update_lock = threading.Lock()
|
||||||
|
|
||||||
|
# --- Automation Progress Tracking ---
|
||||||
|
automation_progress_states = {} # automation_id (int) -> state dict
|
||||||
|
automation_progress_lock = threading.Lock()
|
||||||
|
|
||||||
|
def _init_automation_progress(automation_id, automation_name, action_type):
|
||||||
|
"""Initialize progress state when an automation starts running."""
|
||||||
|
with automation_progress_lock:
|
||||||
|
automation_progress_states[automation_id] = {
|
||||||
|
'status': 'running',
|
||||||
|
'action_type': action_type,
|
||||||
|
'progress': 0, 'phase': 'Starting...', 'current_item': '',
|
||||||
|
'processed': 0, 'total': 0,
|
||||||
|
'log': [{'type': 'info', 'text': f'Starting {automation_name}'}],
|
||||||
|
'started_at': datetime.now().isoformat(),
|
||||||
|
'finished_at': None,
|
||||||
|
}
|
||||||
|
|
||||||
|
def _update_automation_progress(automation_id, **kwargs):
|
||||||
|
"""Update progress state from handler threads. Thread-safe."""
|
||||||
|
if automation_id is None:
|
||||||
|
return
|
||||||
|
with automation_progress_lock:
|
||||||
|
state = automation_progress_states.get(automation_id)
|
||||||
|
if not state:
|
||||||
|
return
|
||||||
|
for k, v in kwargs.items():
|
||||||
|
if k == 'log_line':
|
||||||
|
state['log'].append({'type': kwargs.get('log_type', 'info'), 'text': v})
|
||||||
|
if len(state['log']) > 50:
|
||||||
|
state['log'] = state['log'][-50:]
|
||||||
|
elif k != 'log_type':
|
||||||
|
state[k] = v
|
||||||
|
# Immediate emit on finish so frontend gets final state without waiting for loop
|
||||||
|
if kwargs.get('status') in ('finished', 'error'):
|
||||||
|
state['finished_at'] = datetime.now().isoformat()
|
||||||
|
try:
|
||||||
|
socketio.emit('automation:progress', {str(automation_id): dict(state)})
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
# --- Global Matched Downloads Context Management ---
|
# --- Global Matched Downloads Context Management ---
|
||||||
# Thread-safe storage for matched download contexts
|
# Thread-safe storage for matched download contexts
|
||||||
# Key: slskd download ID, Value: dict containing Spotify artist/album data
|
# Key: slskd download ID, Value: dict containing Spotify artist/album data
|
||||||
|
|
@ -3642,6 +3731,21 @@ def run_automation_endpoint(automation_id):
|
||||||
logger.error(f"Error running automation: {e}")
|
logger.error(f"Error running automation: {e}")
|
||||||
return jsonify({"error": str(e)}), 500
|
return jsonify({"error": str(e)}), 500
|
||||||
|
|
||||||
|
@app.route('/api/automations/progress', methods=['GET'])
|
||||||
|
def get_automation_progress():
|
||||||
|
"""Get current progress state for all running/recently finished automations."""
|
||||||
|
try:
|
||||||
|
with automation_progress_lock:
|
||||||
|
result = {}
|
||||||
|
for aid, state in automation_progress_states.items():
|
||||||
|
if state['status'] in ('running', 'finished', 'error'):
|
||||||
|
cp = dict(state)
|
||||||
|
cp['log'] = list(state['log'])
|
||||||
|
result[str(aid)] = cp
|
||||||
|
return jsonify(result)
|
||||||
|
except Exception as e:
|
||||||
|
return jsonify({"error": str(e)}), 500
|
||||||
|
|
||||||
@app.route('/api/automations/blocks', methods=['GET'])
|
@app.route('/api/automations/blocks', methods=['GET'])
|
||||||
def get_automation_blocks():
|
def get_automation_blocks():
|
||||||
"""Return available block types for the automation builder sidebar."""
|
"""Return available block types for the automation builder sidebar."""
|
||||||
|
|
@ -12846,10 +12950,59 @@ def get_version_info():
|
||||||
This provides the same data that the GUI version modal displays.
|
This provides the same data that the GUI version modal displays.
|
||||||
"""
|
"""
|
||||||
version_data = {
|
version_data = {
|
||||||
"version": "1.7",
|
"version": "1.8",
|
||||||
"title": "What's New in SoulSync",
|
"title": "What's New in SoulSync",
|
||||||
"subtitle": "Version 1.7 — Latest Changes",
|
"subtitle": "Version 1.8 — Latest Changes",
|
||||||
"sections": [
|
"sections": [
|
||||||
|
{
|
||||||
|
"title": "🤖 Automation Engine",
|
||||||
|
"description": "Visual drag-and-drop automation builder with 20+ triggers and 14 actions",
|
||||||
|
"features": [
|
||||||
|
"• Drag-and-drop builder: connect WHEN triggers → DO actions → NOTIFY alerts",
|
||||||
|
"• Timer triggers: Schedule (interval), Daily Time, Weekly Schedule",
|
||||||
|
"• Event triggers: Track Downloaded, Batch Complete, New Release Found, Playlist Changed, Discovery Complete, and 15 more",
|
||||||
|
"• Actions: Process Wishlist, Scan Watchlist, Refresh/Discover/Sync Playlists, Update Database, Quality Scan, Backup, and more",
|
||||||
|
"• Conditions with match modes (All/Any) and operators (contains, equals, starts_with, not_contains)",
|
||||||
|
"• Configurable delay on actions — wait N minutes after trigger fires before executing",
|
||||||
|
"• System automations for wishlist (every 30 min) and watchlist (every 24 hr) with cross-guards",
|
||||||
|
"• Run Now button on every card for instant testing"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "🔍 Playlist Discovery Pipeline",
|
||||||
|
"description": "Official Spotify/iTunes metadata enforcement for mirrored playlist sync",
|
||||||
|
"features": [
|
||||||
|
"• New 'Discover Playlist' automation action matches raw YouTube/Tidal tracks to official Spotify or iTunes metadata",
|
||||||
|
"• Fuzzy title/artist matching with confidence scoring — minimum 0.7 threshold",
|
||||||
|
"• Discovery results cached globally across playlists for instant repeat lookups",
|
||||||
|
"• Sync Playlist now only includes discovered tracks — undiscovered tracks are skipped entirely",
|
||||||
|
"• Prevents garbage data (wrong artist, no album, no cover art) from reaching the wishlist",
|
||||||
|
"• Spotify-sourced playlists auto-discovered during refresh at confidence 1.0",
|
||||||
|
"• Chain automations: Refresh → Playlist Changed → Discover → Discovery Complete → Sync"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "🔔 Notification Integrations",
|
||||||
|
"description": "Get alerted when automations run via Discord, Pushbullet, or Telegram",
|
||||||
|
"features": [
|
||||||
|
"• Discord Webhook — post automation results to any Discord channel",
|
||||||
|
"• Pushbullet — push notifications to phone and desktop",
|
||||||
|
"• Telegram Bot — send alerts via Telegram Bot API",
|
||||||
|
"• Variable substitution in messages: {artist}, {title}, {album}, {quality}, {time}, and more",
|
||||||
|
"• Attach notifications to any automation — event-based or scheduled"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "🔄 YouTube & Tidal Playlist Refresh",
|
||||||
|
"description": "Automated re-fetching of mirrored playlists from any source",
|
||||||
|
"features": [
|
||||||
|
"• Refresh Mirrored Playlist action now supports YouTube and Tidal alongside Spotify",
|
||||||
|
"• YouTube URLs reconstructed from stored playlist ID for seamless re-parsing",
|
||||||
|
"• Tidal playlists refreshed via Tidal API using stored source ID",
|
||||||
|
"• Discovery data preserved across refreshes — previously matched tracks keep their official metadata",
|
||||||
|
"• Change detection emits Playlist Changed event for automation chaining"
|
||||||
|
]
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"title": "📋 Mirrored Playlists",
|
"title": "📋 Mirrored Playlists",
|
||||||
"description": "Persistent cross-service playlist archive on the Sync page",
|
"description": "Persistent cross-service playlist archive on the Sync page",
|
||||||
|
|
@ -13205,7 +13358,7 @@ def _classify_wishlist_track(track):
|
||||||
return 'albums'
|
return 'albums'
|
||||||
|
|
||||||
|
|
||||||
def _process_wishlist_automatically():
|
def _process_wishlist_automatically(automation_id=None):
|
||||||
"""Main automatic processing logic that runs in background thread."""
|
"""Main automatic processing logic that runs in background thread."""
|
||||||
global wishlist_auto_processing, wishlist_auto_processing_timestamp
|
global wishlist_auto_processing, wishlist_auto_processing_timestamp
|
||||||
|
|
||||||
|
|
@ -13253,6 +13406,8 @@ def _process_wishlist_automatically():
|
||||||
all_profiles = database.get_all_profiles()
|
all_profiles = database.get_all_profiles()
|
||||||
count = sum(wishlist_service.get_wishlist_count(profile_id=p['id']) for p in all_profiles)
|
count = sum(wishlist_service.get_wishlist_count(profile_id=p['id']) for p in all_profiles)
|
||||||
print(f"🔍 [Auto-Wishlist] Wishlist count check: {count} tracks found across {len(all_profiles)} profiles")
|
print(f"🔍 [Auto-Wishlist] Wishlist count check: {count} tracks found across {len(all_profiles)} profiles")
|
||||||
|
_update_automation_progress(automation_id, progress=10, phase='Checking wishlist',
|
||||||
|
log_line=f'{count} tracks across {len(all_profiles)} profiles', log_type='info')
|
||||||
if count == 0:
|
if count == 0:
|
||||||
print("ℹ️ [Auto-Wishlist] No tracks in wishlist for auto-processing.")
|
print("ℹ️ [Auto-Wishlist] No tracks in wishlist for auto-processing.")
|
||||||
with wishlist_timer_lock:
|
with wishlist_timer_lock:
|
||||||
|
|
@ -13337,6 +13492,11 @@ def _process_wishlist_automatically():
|
||||||
|
|
||||||
if cleanup_removed > 0:
|
if cleanup_removed > 0:
|
||||||
print(f"✅ [Auto-Wishlist] Cleaned up {cleanup_removed} already-owned tracks from wishlist")
|
print(f"✅ [Auto-Wishlist] Cleaned up {cleanup_removed} already-owned tracks from wishlist")
|
||||||
|
_update_automation_progress(automation_id, progress=25, phase='Cleaned up duplicates',
|
||||||
|
log_line=f'Removed {cleanup_removed} already-owned tracks', log_type='success')
|
||||||
|
else:
|
||||||
|
_update_automation_progress(automation_id, progress=25, phase='Cleanup done',
|
||||||
|
log_line='No duplicates or already-owned tracks found', log_type='skip')
|
||||||
|
|
||||||
# Get wishlist tracks for processing (after cleanup) - combine all profiles
|
# Get wishlist tracks for processing (after cleanup) - combine all profiles
|
||||||
raw_wishlist_tracks = []
|
raw_wishlist_tracks = []
|
||||||
|
|
@ -13408,6 +13568,8 @@ def _process_wishlist_automatically():
|
||||||
|
|
||||||
print(f"🔄 [Auto-Wishlist] Current cycle: {current_cycle}")
|
print(f"🔄 [Auto-Wishlist] Current cycle: {current_cycle}")
|
||||||
print(f"📊 [Auto-Wishlist] Filtered {len(filtered_tracks)}/{len(wishlist_tracks)} tracks for '{current_cycle}' category")
|
print(f"📊 [Auto-Wishlist] Filtered {len(filtered_tracks)}/{len(wishlist_tracks)} tracks for '{current_cycle}' category")
|
||||||
|
_update_automation_progress(automation_id, progress=40, phase=f'Processing {current_cycle}',
|
||||||
|
log_line=f'Cycle: {current_cycle} — {len(filtered_tracks)} tracks to process', log_type='info')
|
||||||
|
|
||||||
# If no tracks in this category, skip to next cycle immediately
|
# If no tracks in this category, skip to next cycle immediately
|
||||||
if len(filtered_tracks) == 0:
|
if len(filtered_tracks) == 0:
|
||||||
|
|
@ -13464,6 +13626,8 @@ def _process_wishlist_automatically():
|
||||||
}
|
}
|
||||||
|
|
||||||
print(f"🚀 Starting automatic wishlist batch {batch_id} with {len(wishlist_tracks)} tracks")
|
print(f"🚀 Starting automatic wishlist batch {batch_id} with {len(wishlist_tracks)} tracks")
|
||||||
|
_update_automation_progress(automation_id, progress=50, phase=f'Downloading {len(wishlist_tracks)} tracks',
|
||||||
|
log_line=f'Started batch: {len(wishlist_tracks)} {current_cycle}', log_type='success')
|
||||||
|
|
||||||
# Submit the wishlist processing job using existing infrastructure
|
# Submit the wishlist processing job using existing infrastructure
|
||||||
missing_download_executor.submit(_run_full_missing_tracks_process, batch_id, playlist_id, wishlist_tracks)
|
missing_download_executor.submit(_run_full_missing_tracks_process, batch_id, playlist_id, wishlist_tracks)
|
||||||
|
|
@ -13474,10 +13638,12 @@ def _process_wishlist_automatically():
|
||||||
print(f"❌ Error in automatic wishlist processing: {e}")
|
print(f"❌ Error in automatic wishlist processing: {e}")
|
||||||
import traceback
|
import traceback
|
||||||
traceback.print_exc()
|
traceback.print_exc()
|
||||||
|
_update_automation_progress(automation_id, log_line=f'Error: {str(e)}', log_type='error')
|
||||||
|
|
||||||
with wishlist_timer_lock:
|
with wishlist_timer_lock:
|
||||||
wishlist_auto_processing = False
|
wishlist_auto_processing = False
|
||||||
wishlist_auto_processing_timestamp = 0
|
wishlist_auto_processing_timestamp = 0
|
||||||
|
raise # re-raise so automation wrapper returns error status
|
||||||
|
|
||||||
# ===============================
|
# ===============================
|
||||||
# == DATABASE UPDATER API ==
|
# == DATABASE UPDATER API ==
|
||||||
|
|
@ -19891,7 +20057,7 @@ def update_tidal_playlist_phase(playlist_id):
|
||||||
return jsonify({"error": str(e)}), 500
|
return jsonify({"error": str(e)}), 500
|
||||||
|
|
||||||
|
|
||||||
def _run_playlist_discovery_worker(playlists):
|
def _run_playlist_discovery_worker(playlists, automation_id=None):
|
||||||
"""Background worker that discovers Spotify/iTunes metadata for undiscovered
|
"""Background worker that discovers Spotify/iTunes metadata for undiscovered
|
||||||
mirrored playlist tracks. Stores results in extra_data for use by sync."""
|
mirrored playlist tracks. Stores results in extra_data for use by sync."""
|
||||||
try:
|
try:
|
||||||
|
|
@ -19905,6 +20071,9 @@ def _run_playlist_discovery_worker(playlists):
|
||||||
itunes_client_instance = iTunesClient()
|
itunes_client_instance = iTunesClient()
|
||||||
except Exception:
|
except Exception:
|
||||||
print("❌ Neither Spotify nor iTunes available for discovery")
|
print("❌ Neither Spotify nor iTunes available for discovery")
|
||||||
|
_update_automation_progress(automation_id, status='error', progress=100,
|
||||||
|
phase='Error', log_line='Neither Spotify nor iTunes available',
|
||||||
|
log_type='error')
|
||||||
return
|
return
|
||||||
|
|
||||||
total_discovered = 0
|
total_discovered = 0
|
||||||
|
|
@ -19913,6 +20082,16 @@ def _run_playlist_discovery_worker(playlists):
|
||||||
total_tracks = 0
|
total_tracks = 0
|
||||||
last_playlist_name = ''
|
last_playlist_name = ''
|
||||||
|
|
||||||
|
# Pre-compute grand total for progress tracking (skip spotify playlists)
|
||||||
|
grand_total = 0
|
||||||
|
db_init = get_database()
|
||||||
|
for pl in playlists:
|
||||||
|
if pl.get('source', '') != 'spotify':
|
||||||
|
t = db_init.get_mirrored_playlist_tracks(pl['id'])
|
||||||
|
if t:
|
||||||
|
grand_total += len(t)
|
||||||
|
_update_automation_progress(automation_id, total=grand_total)
|
||||||
|
|
||||||
for pl in playlists:
|
for pl in playlists:
|
||||||
pl_id = pl['id']
|
pl_id = pl['id']
|
||||||
pl_name = pl.get('name', '')
|
pl_name = pl.get('name', '')
|
||||||
|
|
@ -19929,6 +20108,8 @@ def _run_playlist_discovery_worker(playlists):
|
||||||
continue
|
continue
|
||||||
|
|
||||||
print(f"🔍 Starting discovery for playlist '{pl_name}' ({len(tracks)} tracks, using {discovery_source.upper()})")
|
print(f"🔍 Starting discovery for playlist '{pl_name}' ({len(tracks)} tracks, using {discovery_source.upper()})")
|
||||||
|
_update_automation_progress(automation_id, phase=f'Discovering: "{pl_name}"',
|
||||||
|
log_line=f'Playlist "{pl_name}" — {len(tracks)} tracks ({discovery_source.upper()})', log_type='info')
|
||||||
|
|
||||||
for i, track in enumerate(tracks):
|
for i, track in enumerate(tracks):
|
||||||
total_tracks += 1
|
total_tracks += 1
|
||||||
|
|
@ -19947,6 +20128,7 @@ def _run_playlist_discovery_worker(playlists):
|
||||||
|
|
||||||
if existing_extra.get('discovered'):
|
if existing_extra.get('discovered'):
|
||||||
total_skipped += 1
|
total_skipped += 1
|
||||||
|
_update_automation_progress(automation_id, log_line=f'Already discovered: {track_name}', log_type='skip')
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# Step 1: Check discovery cache
|
# Step 1: Check discovery cache
|
||||||
|
|
@ -19963,6 +20145,10 @@ def _run_playlist_discovery_worker(playlists):
|
||||||
db.update_mirrored_track_extra_data(track_id, extra_data)
|
db.update_mirrored_track_extra_data(track_id, extra_data)
|
||||||
total_discovered += 1
|
total_discovered += 1
|
||||||
print(f"⚡ CACHE [{i+1}/{len(tracks)}]: {track_name} → {cached_match.get('name', '?')}")
|
print(f"⚡ CACHE [{i+1}/{len(tracks)}]: {track_name} → {cached_match.get('name', '?')}")
|
||||||
|
_update_automation_progress(automation_id,
|
||||||
|
progress=(total_tracks / max(1, grand_total)) * 100,
|
||||||
|
current_item=track_name,
|
||||||
|
log_line=f'{track_name} → {cached_match.get("name", "?")} (cache)', log_type='success')
|
||||||
continue
|
continue
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
@ -20055,6 +20241,11 @@ def _run_playlist_discovery_worker(playlists):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
print(f"✅ [{i+1}/{len(tracks)}] {track_name} → {matched_data['name']} ({best_confidence:.2f})")
|
print(f"✅ [{i+1}/{len(tracks)}] {track_name} → {matched_data['name']} ({best_confidence:.2f})")
|
||||||
|
_update_automation_progress(automation_id,
|
||||||
|
progress=(total_tracks / max(1, grand_total)) * 100,
|
||||||
|
processed=total_discovered + total_failed,
|
||||||
|
current_item=f'{track_name} - {artist_name}',
|
||||||
|
log_line=f'{track_name} → {matched_data["name"]} ({best_confidence:.2f})', log_type='success')
|
||||||
else:
|
else:
|
||||||
extra_data = {
|
extra_data = {
|
||||||
'discovered': False,
|
'discovered': False,
|
||||||
|
|
@ -20064,6 +20255,11 @@ def _run_playlist_discovery_worker(playlists):
|
||||||
db.update_mirrored_track_extra_data(track_id, extra_data)
|
db.update_mirrored_track_extra_data(track_id, extra_data)
|
||||||
total_failed += 1
|
total_failed += 1
|
||||||
print(f"❌ [{i+1}/{len(tracks)}] No match: {track_name} by {artist_name}")
|
print(f"❌ [{i+1}/{len(tracks)}] No match: {track_name} by {artist_name}")
|
||||||
|
_update_automation_progress(automation_id,
|
||||||
|
progress=(total_tracks / max(1, grand_total)) * 100,
|
||||||
|
processed=total_discovered + total_failed,
|
||||||
|
current_item=f'{track_name} - {artist_name}',
|
||||||
|
log_line=f'{track_name} by {artist_name} → no match', log_type='error')
|
||||||
|
|
||||||
time.sleep(0.15)
|
time.sleep(0.15)
|
||||||
|
|
||||||
|
|
@ -20081,11 +20277,18 @@ def _run_playlist_discovery_worker(playlists):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
print(f"✅ Playlist discovery complete: {total_discovered} discovered, {total_failed} failed, {total_skipped} skipped")
|
print(f"✅ Playlist discovery complete: {total_discovered} discovered, {total_failed} failed, {total_skipped} skipped")
|
||||||
|
_update_automation_progress(automation_id, status='finished', progress=100,
|
||||||
|
phase='Discovery complete',
|
||||||
|
log_line=f'Done: {total_discovered} discovered, {total_failed} failed, {total_skipped} skipped',
|
||||||
|
log_type='success')
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"❌ Error in playlist discovery worker: {e}")
|
print(f"❌ Error in playlist discovery worker: {e}")
|
||||||
import traceback
|
import traceback
|
||||||
traceback.print_exc()
|
traceback.print_exc()
|
||||||
|
_update_automation_progress(automation_id, status='error', progress=100,
|
||||||
|
phase='Error',
|
||||||
|
log_line=f'Error: {str(e)}', log_type='error')
|
||||||
|
|
||||||
|
|
||||||
def _get_discovery_cache_key(title, artist):
|
def _get_discovery_cache_key(title, artist):
|
||||||
|
|
@ -24070,7 +24273,7 @@ watchlist_scan_state = {
|
||||||
'error': None
|
'error': None
|
||||||
}
|
}
|
||||||
|
|
||||||
def _process_watchlist_scan_automatically():
|
def _process_watchlist_scan_automatically(automation_id=None):
|
||||||
"""Main automatic scanning logic that runs in background thread."""
|
"""Main automatic scanning logic that runs in background thread."""
|
||||||
global watchlist_auto_scanning, watchlist_auto_scanning_timestamp, watchlist_scan_state
|
global watchlist_auto_scanning, watchlist_auto_scanning_timestamp, watchlist_scan_state
|
||||||
|
|
||||||
|
|
@ -24125,6 +24328,8 @@ def _process_watchlist_scan_automatically():
|
||||||
return
|
return
|
||||||
|
|
||||||
print(f"👁️ [Auto-Watchlist] Found {watchlist_count} artists in watchlist, starting automatic scan...")
|
print(f"👁️ [Auto-Watchlist] Found {watchlist_count} artists in watchlist, starting automatic scan...")
|
||||||
|
_update_automation_progress(automation_id, progress=5, phase='Loading watchlist',
|
||||||
|
log_line=f'{watchlist_count} artists across {len(all_profiles)} profiles', log_type='info')
|
||||||
|
|
||||||
# Get list of artists to scan (all profiles combined for auto-scan)
|
# Get list of artists to scan (all profiles combined for auto-scan)
|
||||||
watchlist_artists = []
|
watchlist_artists = []
|
||||||
|
|
@ -24205,6 +24410,12 @@ def _process_watchlist_scan_automatically():
|
||||||
# Fetch artist image using provider-aware method
|
# Fetch artist image using provider-aware method
|
||||||
artist_image_url = scanner.get_artist_image_url(artist) or ''
|
artist_image_url = scanner.get_artist_image_url(artist) or ''
|
||||||
|
|
||||||
|
pct = 5 + (i / max(1, len(watchlist_artists))) * 90
|
||||||
|
_update_automation_progress(automation_id, progress=pct,
|
||||||
|
phase=f'Scanning: {artist.artist_name} ({i+1}/{len(watchlist_artists)})',
|
||||||
|
current_item=artist.artist_name,
|
||||||
|
processed=i, total=len(watchlist_artists))
|
||||||
|
|
||||||
# Update progress
|
# Update progress
|
||||||
watchlist_scan_state.update({
|
watchlist_scan_state.update({
|
||||||
'current_artist_index': i + 1,
|
'current_artist_index': i + 1,
|
||||||
|
|
@ -24326,6 +24537,12 @@ def _process_watchlist_scan_automatically():
|
||||||
})())
|
})())
|
||||||
|
|
||||||
print(f"✅ Scanned {artist.artist_name}: {artist_new_tracks} new tracks found, {artist_added_tracks} added to wishlist")
|
print(f"✅ Scanned {artist.artist_name}: {artist_new_tracks} new tracks found, {artist_added_tracks} added to wishlist")
|
||||||
|
if artist_new_tracks > 0:
|
||||||
|
_update_automation_progress(automation_id,
|
||||||
|
log_line=f'{artist.artist_name} — {artist_new_tracks} new, {artist_added_tracks} added', log_type='success')
|
||||||
|
else:
|
||||||
|
_update_automation_progress(automation_id,
|
||||||
|
log_line=f'{artist.artist_name} — no new tracks', log_type='skip')
|
||||||
|
|
||||||
# Emit watchlist_new_release event if new tracks were found
|
# Emit watchlist_new_release event if new tracks were found
|
||||||
if artist_new_tracks > 0:
|
if artist_new_tracks > 0:
|
||||||
|
|
@ -24350,6 +24567,8 @@ def _process_watchlist_scan_automatically():
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"Error scanning artist {artist.artist_name}: {e}")
|
print(f"Error scanning artist {artist.artist_name}: {e}")
|
||||||
|
_update_automation_progress(automation_id,
|
||||||
|
log_line=f'{artist.artist_name} — error: {str(e)[:60]}', log_type='error')
|
||||||
|
|
||||||
# Circuit breaker: detect consecutive rate-limit failures
|
# Circuit breaker: detect consecutive rate-limit failures
|
||||||
error_str = str(e).lower()
|
error_str = str(e).lower()
|
||||||
|
|
@ -24392,6 +24611,9 @@ def _process_watchlist_scan_automatically():
|
||||||
|
|
||||||
print(f"Automatic watchlist scan completed: {len(successful_scans)}/{len(scan_results)} artists scanned successfully")
|
print(f"Automatic watchlist scan completed: {len(successful_scans)}/{len(scan_results)} artists scanned successfully")
|
||||||
print(f"Found {total_new_tracks} new tracks, added {total_added_to_wishlist} to wishlist")
|
print(f"Found {total_new_tracks} new tracks, added {total_added_to_wishlist} to wishlist")
|
||||||
|
_update_automation_progress(automation_id, progress=95, phase='Scan complete',
|
||||||
|
log_line=f'Scanned {len(successful_scans)} artists — {total_new_tracks} new tracks, {total_added_to_wishlist} added to wishlist',
|
||||||
|
log_type='success' if total_new_tracks > 0 else 'info')
|
||||||
|
|
||||||
# Populate discovery pool from similar artists (per-profile)
|
# Populate discovery pool from similar artists (per-profile)
|
||||||
print("🎵 Starting discovery pool population...")
|
print("🎵 Starting discovery pool population...")
|
||||||
|
|
@ -24464,9 +24686,11 @@ def _process_watchlist_scan_automatically():
|
||||||
print(f"❌ Error in automatic watchlist scan: {e}")
|
print(f"❌ Error in automatic watchlist scan: {e}")
|
||||||
import traceback
|
import traceback
|
||||||
traceback.print_exc()
|
traceback.print_exc()
|
||||||
|
_update_automation_progress(automation_id, log_line=f'Error: {str(e)}', log_type='error')
|
||||||
|
|
||||||
watchlist_scan_state['status'] = 'error'
|
watchlist_scan_state['status'] = 'error'
|
||||||
watchlist_scan_state['error'] = str(e)
|
watchlist_scan_state['error'] = str(e)
|
||||||
|
raise # re-raise so automation wrapper returns error status
|
||||||
|
|
||||||
finally:
|
finally:
|
||||||
# Resume enrichment workers if we paused them
|
# Resume enrichment workers if we paused them
|
||||||
|
|
@ -31702,6 +31926,50 @@ def _emit_scan_status_loop():
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.debug(f"Error emitting wishlist stats: {e}")
|
logger.debug(f"Error emitting wishlist stats: {e}")
|
||||||
|
|
||||||
|
def _emit_automation_progress_loop():
|
||||||
|
"""Push automation:progress events every 1 second for running automations."""
|
||||||
|
while True:
|
||||||
|
socketio.sleep(1)
|
||||||
|
try:
|
||||||
|
with automation_progress_lock:
|
||||||
|
active = {}
|
||||||
|
stale = []
|
||||||
|
now = datetime.now()
|
||||||
|
for aid, state in automation_progress_states.items():
|
||||||
|
if state['status'] == 'running':
|
||||||
|
# Timeout zombie running states after 2 hours
|
||||||
|
try:
|
||||||
|
started = datetime.fromisoformat(state.get('started_at', ''))
|
||||||
|
if (now - started).total_seconds() > 7200:
|
||||||
|
state['status'] = 'error'
|
||||||
|
state['phase'] = 'Timed out'
|
||||||
|
state['finished_at'] = now.isoformat()
|
||||||
|
state['log'].append({'type': 'error', 'text': 'Timed out after 2 hours'})
|
||||||
|
# Emit error state before cleanup so frontend sees it
|
||||||
|
cp = dict(state)
|
||||||
|
cp['log'] = list(state['log'])
|
||||||
|
active[str(aid)] = cp
|
||||||
|
continue
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
pass
|
||||||
|
cp = dict(state)
|
||||||
|
cp['log'] = list(state['log'])
|
||||||
|
active[str(aid)] = cp
|
||||||
|
elif state['status'] in ('finished', 'error') and state.get('finished_at'):
|
||||||
|
# Clean up finished states after 60 seconds (frontend already got final emit)
|
||||||
|
try:
|
||||||
|
finished_time = datetime.fromisoformat(state['finished_at'])
|
||||||
|
if (now - finished_time).total_seconds() > 60:
|
||||||
|
stale.append(aid)
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
stale.append(aid)
|
||||||
|
for aid in stale:
|
||||||
|
del automation_progress_states[aid]
|
||||||
|
if active:
|
||||||
|
socketio.emit('automation:progress', active)
|
||||||
|
except Exception as e:
|
||||||
|
logger.debug(f"Error emitting automation progress: {e}")
|
||||||
|
|
||||||
# ================================================================================================
|
# ================================================================================================
|
||||||
# END WEBSOCKET HANDLERS
|
# END WEBSOCKET HANDLERS
|
||||||
# ================================================================================================
|
# ================================================================================================
|
||||||
|
|
@ -31780,6 +32048,8 @@ if __name__ == '__main__':
|
||||||
socketio.start_background_task(_emit_sync_progress_loop)
|
socketio.start_background_task(_emit_sync_progress_loop)
|
||||||
socketio.start_background_task(_emit_discovery_progress_loop)
|
socketio.start_background_task(_emit_discovery_progress_loop)
|
||||||
socketio.start_background_task(_emit_scan_status_loop)
|
socketio.start_background_task(_emit_scan_status_loop)
|
||||||
print("✅ WebSocket emitters started (Phase 1-5: global/dashboard/enrichment/tools/sync)")
|
# Phase 6: Automation progress
|
||||||
|
socketio.start_background_task(_emit_automation_progress_loop)
|
||||||
|
print("✅ WebSocket emitters started (Phase 1-6: global/dashboard/enrichment/tools/sync/automations)")
|
||||||
|
|
||||||
socketio.run(app, host='0.0.0.0', port=8008, debug=False, allow_unsafe_werkzeug=True)
|
socketio.run(app, host='0.0.0.0', port=8008, debug=False, allow_unsafe_werkzeug=True)
|
||||||
|
|
|
||||||
|
|
@ -215,7 +215,7 @@
|
||||||
|
|
||||||
<!-- Version Section -->
|
<!-- Version Section -->
|
||||||
<div class="version-section">
|
<div class="version-section">
|
||||||
<button class="version-button" onclick="showVersionInfo()">v1.7</button>
|
<button class="version-button" onclick="showVersionInfo()">v1.8</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Status Section -->
|
<!-- Status Section -->
|
||||||
|
|
|
||||||
|
|
@ -213,6 +213,8 @@ function initializeWebSocket() {
|
||||||
socket.on('scan:watchlist', (data) => updateWatchlistScanFromData(data));
|
socket.on('scan:watchlist', (data) => updateWatchlistScanFromData(data));
|
||||||
socket.on('scan:media', (data) => updateMediaScanFromData(data));
|
socket.on('scan:media', (data) => updateMediaScanFromData(data));
|
||||||
socket.on('wishlist:stats', (data) => updateWishlistStatsFromData(data));
|
socket.on('wishlist:stats', (data) => updateWishlistStatsFromData(data));
|
||||||
|
// Phase 6: Automation progress
|
||||||
|
socket.on('automation:progress', (data) => updateAutomationProgressFromData(data));
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleServiceStatusUpdate(data) {
|
function handleServiceStatusUpdate(data) {
|
||||||
|
|
@ -42184,6 +42186,12 @@ async function loadAutomations() {
|
||||||
<span class="auto-stat"><strong>${eventBased}</strong> Event-Based</span>
|
<span class="auto-stat"><strong>${eventBased}</strong> Event-Based</span>
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
// Catch up on current automation progress
|
||||||
|
try {
|
||||||
|
const progRes = await fetch('/api/automations/progress');
|
||||||
|
const progData = await progRes.json();
|
||||||
|
if (!progData.error) updateAutomationProgressFromData(progData);
|
||||||
|
} catch (e) {}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
list.innerHTML = ''; empty.style.display = '';
|
list.innerHTML = ''; empty.style.display = '';
|
||||||
if (statsBar) statsBar.innerHTML = '';
|
if (statsBar) statsBar.innerHTML = '';
|
||||||
|
|
@ -42315,6 +42323,86 @@ async function toggleAutomation(id) {
|
||||||
} catch (err) { showToast('Error: ' + err.message, 'error'); }
|
} catch (err) { showToast('Error: ' + err.message, 'error'); }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Automation Progress Tracking ---
|
||||||
|
const _autoProgressLogCounts = {};
|
||||||
|
const _autoProgressHideTimers = {};
|
||||||
|
|
||||||
|
function updateAutomationProgressFromData(data) {
|
||||||
|
for (const [aidStr, state] of Object.entries(data)) {
|
||||||
|
const aid = parseInt(aidStr);
|
||||||
|
const card = document.querySelector(`.automation-card[data-id="${aid}"]`);
|
||||||
|
if (!card) continue;
|
||||||
|
|
||||||
|
let panel = card.querySelector('.automation-output');
|
||||||
|
if (!panel) {
|
||||||
|
panel = document.createElement('div');
|
||||||
|
panel.className = 'automation-output';
|
||||||
|
panel.innerHTML = `
|
||||||
|
<div class="auto-progress-bar-wrap"><div class="auto-progress-bar" style="width:0%"></div></div>
|
||||||
|
<div class="auto-progress-phase"></div>
|
||||||
|
<div class="auto-progress-log"></div>
|
||||||
|
`;
|
||||||
|
card.appendChild(panel);
|
||||||
|
_autoProgressLogCounts[aid] = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update progress bar
|
||||||
|
const bar = panel.querySelector('.auto-progress-bar');
|
||||||
|
bar.style.width = (state.progress || 0) + '%';
|
||||||
|
|
||||||
|
// Update phase text
|
||||||
|
const phaseEl = panel.querySelector('.auto-progress-phase');
|
||||||
|
phaseEl.textContent = state.phase || '';
|
||||||
|
|
||||||
|
// Status indicator on card
|
||||||
|
const statusDot = card.querySelector('.automation-status');
|
||||||
|
|
||||||
|
if (state.status === 'running') {
|
||||||
|
if (statusDot) statusDot.className = 'automation-status running';
|
||||||
|
panel.classList.add('visible');
|
||||||
|
panel.classList.remove('finished', 'error');
|
||||||
|
if (_autoProgressHideTimers[aid]) {
|
||||||
|
clearTimeout(_autoProgressHideTimers[aid]);
|
||||||
|
delete _autoProgressHideTimers[aid];
|
||||||
|
}
|
||||||
|
// Reset log for new run (handles re-run within hide window)
|
||||||
|
if (_autoProgressLogCounts[aid] > 0 && state.log && state.log.length < _autoProgressLogCounts[aid]) {
|
||||||
|
const existingLog = panel.querySelector('.auto-progress-log');
|
||||||
|
if (existingLog) existingLog.innerHTML = '';
|
||||||
|
_autoProgressLogCounts[aid] = 0;
|
||||||
|
}
|
||||||
|
} else if (state.status === 'finished' || state.status === 'error') {
|
||||||
|
if (statusDot) statusDot.className = 'automation-status ' + (card.querySelector('input[type=checkbox]')?.checked ? 'enabled' : 'disabled');
|
||||||
|
bar.style.width = '100%';
|
||||||
|
panel.classList.add('finished');
|
||||||
|
if (state.status === 'error') panel.classList.add('error');
|
||||||
|
if (!_autoProgressHideTimers[aid]) {
|
||||||
|
_autoProgressHideTimers[aid] = setTimeout(() => {
|
||||||
|
panel.classList.remove('visible');
|
||||||
|
delete _autoProgressHideTimers[aid];
|
||||||
|
_autoProgressLogCounts[aid] = 0;
|
||||||
|
}, 30000);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Diff-append log lines
|
||||||
|
const logEl = panel.querySelector('.auto-progress-log');
|
||||||
|
const rendered = _autoProgressLogCounts[aid] || 0;
|
||||||
|
const logLines = state.log || [];
|
||||||
|
if (logLines.length > rendered) {
|
||||||
|
for (let i = rendered; i < logLines.length; i++) {
|
||||||
|
const line = logLines[i];
|
||||||
|
const div = document.createElement('div');
|
||||||
|
div.className = 'auto-log-line ' + (line.type || 'info');
|
||||||
|
div.textContent = line.text;
|
||||||
|
logEl.appendChild(div);
|
||||||
|
}
|
||||||
|
_autoProgressLogCounts[aid] = logLines.length;
|
||||||
|
logEl.scrollTop = logEl.scrollHeight;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function runAutomation(id) {
|
async function runAutomation(id) {
|
||||||
try {
|
try {
|
||||||
const res = await fetch('/api/automations/' + id + '/run', { method: 'POST' });
|
const res = await fetch('/api/automations/' + id + '/run', { method: 'POST' });
|
||||||
|
|
|
||||||
|
|
@ -28399,6 +28399,57 @@ body {
|
||||||
.automation-toggle input:checked + .toggle-slider { background: rgba(var(--accent-rgb),0.5); border-color: rgba(var(--accent-rgb),0.5); }
|
.automation-toggle input:checked + .toggle-slider { background: rgba(var(--accent-rgb),0.5); border-color: rgba(var(--accent-rgb),0.5); }
|
||||||
.automation-toggle input:checked + .toggle-slider::before { transform: translateX(14px); background: rgb(var(--accent-light-rgb)); }
|
.automation-toggle input:checked + .toggle-slider::before { transform: translateX(14px); background: rgb(var(--accent-light-rgb)); }
|
||||||
|
|
||||||
|
/* Only wrap card layout when output panel is present */
|
||||||
|
.automation-card:has(.automation-output) { flex-wrap: wrap; }
|
||||||
|
|
||||||
|
/* Automation progress output panel */
|
||||||
|
.automation-output {
|
||||||
|
width: 100%; max-height: 0; overflow: hidden;
|
||||||
|
transition: max-height 0.3s ease, padding 0.3s ease, margin 0.3s ease;
|
||||||
|
border-top: none; padding: 0; margin: 0;
|
||||||
|
}
|
||||||
|
.automation-output.visible {
|
||||||
|
max-height: 300px; padding: 8px 0 4px; margin-top: 6px;
|
||||||
|
border-top: 1px solid rgba(255,255,255,0.06);
|
||||||
|
}
|
||||||
|
.auto-progress-bar-wrap {
|
||||||
|
width: 100%; height: 3px; background: rgba(255,255,255,0.06);
|
||||||
|
border-radius: 3px; overflow: hidden; margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
.auto-progress-bar {
|
||||||
|
height: 100%; border-radius: 3px;
|
||||||
|
background: linear-gradient(90deg, rgb(var(--accent-rgb)), rgb(var(--accent-light-rgb)));
|
||||||
|
transition: width 0.5s ease;
|
||||||
|
}
|
||||||
|
.automation-output.finished .auto-progress-bar { background: #4ade80; }
|
||||||
|
.automation-output.error .auto-progress-bar { background: #ef4444; }
|
||||||
|
.auto-progress-phase {
|
||||||
|
font-size: 10px; color: rgba(255,255,255,0.5);
|
||||||
|
margin-bottom: 4px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
.auto-progress-log {
|
||||||
|
max-height: 200px; overflow-y: auto;
|
||||||
|
font-family: 'SF Mono', 'Cascadia Code', 'Fira Code', monospace;
|
||||||
|
font-size: 10px; line-height: 1.5;
|
||||||
|
scrollbar-width: thin; scrollbar-color: rgba(255,255,255,0.1) transparent;
|
||||||
|
}
|
||||||
|
.auto-log-line { padding: 1px 0; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||||
|
.auto-log-line.success { color: #4ade80; }
|
||||||
|
.auto-log-line.error { color: #f87171; }
|
||||||
|
.auto-log-line.skip { color: rgba(255,255,255,0.35); }
|
||||||
|
.auto-log-line.info { color: rgba(255,255,255,0.55); }
|
||||||
|
|
||||||
|
/* Pulsing status indicator for running automation */
|
||||||
|
.automation-status.running {
|
||||||
|
background: rgb(var(--accent-rgb));
|
||||||
|
box-shadow: 0 0 6px rgba(var(--accent-rgb), 0.5);
|
||||||
|
animation: auto-pulse 1.5s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
@keyframes auto-pulse {
|
||||||
|
0%, 100% { opacity: 1; }
|
||||||
|
50% { opacity: 0.5; }
|
||||||
|
}
|
||||||
|
|
||||||
/* --- Empty State --- */
|
/* --- Empty State --- */
|
||||||
.automations-empty {
|
.automations-empty {
|
||||||
text-align: center; padding: 80px 24px;
|
text-align: center; padding: 80px 24px;
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue