Live automation progress tracking with real-time output panels

This commit is contained in:
Broque Thomas 2026-03-05 20:39:26 -08:00
parent 20ee504245
commit 9f416475e2
5 changed files with 461 additions and 16 deletions

View file

@ -49,6 +49,10 @@ class AutomationEngine:
# Format: {type: {'handler': fn(config)->dict, 'guard': fn()->bool or None}}
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, ...]
self._event_automations = {}
self._event_cache_dirty = True
@ -73,6 +77,11 @@ class AutomationEngine:
}
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 ---
def ensure_system_automations(self):
@ -278,6 +287,11 @@ class AutomationEngine:
action_config = json.loads(auto.get('action_config') or '{}')
except json.JSONDecodeError:
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)
if delay_minutes and delay_minutes > 0:
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'}
logger.info(f"Event automation '{auto.get('name')}' skipped — {action_type} busy")
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:
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}")
# 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
merged = {**event_data, **result}
@ -353,6 +375,10 @@ class AutomationEngine:
except json.JSONDecodeError:
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)
delay_minutes = action_config.get('delay', 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)
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
error = None
result = {}
@ -380,6 +411,11 @@ class AutomationEngine:
result = {'status': 'error', 'error': error}
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
try:
self._send_notification(auto, result)

View file

@ -269,12 +269,18 @@ def _register_automation_handlers():
return
def _auto_process_wishlist(config):
_process_wishlist_automatically()
return {'status': 'completed'}
try:
_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):
_process_watchlist_scan_automatically()
return {'status': 'completed'}
try:
_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):
if web_scan_manager:
@ -292,6 +298,7 @@ def _register_automation_handlers():
db = get_database()
playlist_id = config.get('playlist_id')
refresh_all = config.get('all', False)
auto_id = config.get('_automation_id')
if refresh_all:
playlists = db.get_mirrored_playlists()
@ -303,10 +310,14 @@ def _register_automation_handlers():
refreshed = 0
errors = []
for pl in playlists:
for idx, pl in enumerate(playlists):
try:
source = pl.get('source', '')
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
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
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:
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)),
'added': str(added_count),
'removed': str(removed_count),
})
except Exception:
pass
else:
_update_automation_progress(auto_id,
log_line=f'No changes: "{pl.get("name", "")}"', log_type='skip')
except Exception as 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))}
def _auto_sync_playlist(config):
"""Sync a mirrored playlist to media server.
Uses discovered metadata when available, skips undiscovered tracks."""
auto_id = config.get('_automation_id')
playlist_id = config.get('playlist_id')
if not playlist_id:
return {'status': 'error', 'reason': 'No playlist specified'}
@ -452,7 +473,14 @@ def _register_automation_handlers():
# NOT discovered — skip to prevent garbage in wishlist
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:
_update_automation_progress(auto_id,
log_line=f'No discovered tracks — {skipped_count} need discovery first', log_type='skip')
return {
'status': 'skipped',
'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}"
_update_automation_progress(auto_id, progress=90,
log_line=f'Starting sync: {len(tracks_json)} tracks', log_type='success')
threading.Thread(
target=_run_sync_task,
args=(sync_id, pl['name'], json.dumps(tracks_json)),
@ -492,12 +522,13 @@ def _register_automation_handlers():
threading.Thread(
target=_run_playlist_discovery_worker,
args=(playlists,),
args=(playlists, config.get('_automation_id')),
daemon=True,
name='auto-discover-playlist'
).start()
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('sync_playlist', _auto_sync_playlist)
@ -600,6 +631,24 @@ def _register_automation_handlers():
lambda: quality_scanner_state.get('status') == 'running')
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")
@ -729,6 +778,46 @@ sync_states = {} # Key: playlist_id, Value: dict with progress info
sync_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 ---
# Thread-safe storage for matched download contexts
# 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}")
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'])
def get_automation_blocks():
"""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.
"""
version_data = {
"version": "1.7",
"version": "1.8",
"title": "What's New in SoulSync",
"subtitle": "Version 1.7 — Latest Changes",
"subtitle": "Version 1.8 — Latest Changes",
"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",
"description": "Persistent cross-service playlist archive on the Sync page",
@ -13205,7 +13358,7 @@ def _classify_wishlist_track(track):
return 'albums'
def _process_wishlist_automatically():
def _process_wishlist_automatically(automation_id=None):
"""Main automatic processing logic that runs in background thread."""
global wishlist_auto_processing, wishlist_auto_processing_timestamp
@ -13253,6 +13406,8 @@ def _process_wishlist_automatically():
all_profiles = database.get_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")
_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:
print(" [Auto-Wishlist] No tracks in wishlist for auto-processing.")
with wishlist_timer_lock:
@ -13337,6 +13492,11 @@ def _process_wishlist_automatically():
if cleanup_removed > 0:
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
raw_wishlist_tracks = []
@ -13408,6 +13568,8 @@ def _process_wishlist_automatically():
print(f"🔄 [Auto-Wishlist] Current cycle: {current_cycle}")
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 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")
_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
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}")
import traceback
traceback.print_exc()
_update_automation_progress(automation_id, log_line=f'Error: {str(e)}', log_type='error')
with wishlist_timer_lock:
wishlist_auto_processing = False
wishlist_auto_processing_timestamp = 0
raise # re-raise so automation wrapper returns error status
# ===============================
# == DATABASE UPDATER API ==
@ -19891,7 +20057,7 @@ def update_tidal_playlist_phase(playlist_id):
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
mirrored playlist tracks. Stores results in extra_data for use by sync."""
try:
@ -19905,6 +20071,9 @@ def _run_playlist_discovery_worker(playlists):
itunes_client_instance = iTunesClient()
except Exception:
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
total_discovered = 0
@ -19913,6 +20082,16 @@ def _run_playlist_discovery_worker(playlists):
total_tracks = 0
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:
pl_id = pl['id']
pl_name = pl.get('name', '')
@ -19929,6 +20108,8 @@ def _run_playlist_discovery_worker(playlists):
continue
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):
total_tracks += 1
@ -19947,6 +20128,7 @@ def _run_playlist_discovery_worker(playlists):
if existing_extra.get('discovered'):
total_skipped += 1
_update_automation_progress(automation_id, log_line=f'Already discovered: {track_name}', log_type='skip')
continue
# 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)
total_discovered += 1
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
except Exception:
pass
@ -20055,6 +20241,11 @@ def _run_playlist_discovery_worker(playlists):
pass
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:
extra_data = {
'discovered': False,
@ -20064,6 +20255,11 @@ def _run_playlist_discovery_worker(playlists):
db.update_mirrored_track_extra_data(track_id, extra_data)
total_failed += 1
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)
@ -20081,11 +20277,18 @@ def _run_playlist_discovery_worker(playlists):
pass
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:
print(f"❌ Error in playlist discovery worker: {e}")
import traceback
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):
@ -24070,7 +24273,7 @@ watchlist_scan_state = {
'error': None
}
def _process_watchlist_scan_automatically():
def _process_watchlist_scan_automatically(automation_id=None):
"""Main automatic scanning logic that runs in background thread."""
global watchlist_auto_scanning, watchlist_auto_scanning_timestamp, watchlist_scan_state
@ -24125,6 +24328,8 @@ def _process_watchlist_scan_automatically():
return
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)
watchlist_artists = []
@ -24205,6 +24410,12 @@ def _process_watchlist_scan_automatically():
# Fetch artist image using provider-aware method
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
watchlist_scan_state.update({
'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")
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
if artist_new_tracks > 0:
@ -24350,6 +24567,8 @@ def _process_watchlist_scan_automatically():
except Exception as 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
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"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)
print("🎵 Starting discovery pool population...")
@ -24464,9 +24686,11 @@ def _process_watchlist_scan_automatically():
print(f"❌ Error in automatic watchlist scan: {e}")
import traceback
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['error'] = str(e)
raise # re-raise so automation wrapper returns error status
finally:
# Resume enrichment workers if we paused them
@ -31702,6 +31926,50 @@ def _emit_scan_status_loop():
except Exception as 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
# ================================================================================================
@ -31780,6 +32048,8 @@ if __name__ == '__main__':
socketio.start_background_task(_emit_sync_progress_loop)
socketio.start_background_task(_emit_discovery_progress_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)

View file

@ -215,7 +215,7 @@
<!-- 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>
<!-- Status Section -->

View file

@ -213,6 +213,8 @@ function initializeWebSocket() {
socket.on('scan:watchlist', (data) => updateWatchlistScanFromData(data));
socket.on('scan:media', (data) => updateMediaScanFromData(data));
socket.on('wishlist:stats', (data) => updateWishlistStatsFromData(data));
// Phase 6: Automation progress
socket.on('automation:progress', (data) => updateAutomationProgressFromData(data));
}
function handleServiceStatusUpdate(data) {
@ -42184,6 +42186,12 @@ async function loadAutomations() {
<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) {
list.innerHTML = ''; empty.style.display = '';
if (statsBar) statsBar.innerHTML = '';
@ -42315,6 +42323,86 @@ async function toggleAutomation(id) {
} 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) {
try {
const res = await fetch('/api/automations/' + id + '/run', { method: 'POST' });

View file

@ -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::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 --- */
.automations-empty {
text-align: center; padding: 80px 24px;