From 156c37d90781b903c36e2870cecb4e90192c2c63 Mon Sep 17 00:00:00 2001 From: Broque Thomas <26755000+Nezreka@users.noreply.github.com> Date: Fri, 6 Mar 2026 19:48:48 -0800 Subject: [PATCH] Replace hardcoded post-download chain with system automations --- core/automation_engine.py | 24 ++++++-- web_server.py | 96 ++++++++--------------------- webui/static/script.js | 125 ++++++++++++++++---------------------- 3 files changed, 99 insertions(+), 146 deletions(-) diff --git a/core/automation_engine.py b/core/automation_engine.py index 6b3945e3..753d43be 100644 --- a/core/automation_engine.py +++ b/core/automation_engine.py @@ -37,6 +37,19 @@ SYSTEM_AUTOMATIONS = [ 'action_type': 'scan_watchlist', 'initial_delay': 300, # 5 minutes after startup }, + # Event-based system automations (no initial_delay/next_run needed) + { + 'name': 'Auto-Scan After Downloads', + 'trigger_type': 'batch_complete', + 'trigger_config': {}, + 'action_type': 'scan_library', + }, + { + 'name': 'Auto-Update Database After Scan', + 'trigger_type': 'library_scan_completed', + 'trigger_config': {}, + 'action_type': 'start_database_update', + }, ] @@ -120,10 +133,13 @@ class AutomationEngine: existing = self.db.get_system_automation_by_action(spec['action_type']) if existing: - # Reset next_run to initial delay on every startup - next_run = (datetime.now() + timedelta(seconds=spec['initial_delay'])).strftime('%Y-%m-%d %H:%M:%S') - self.db.update_automation(existing['id'], next_run=next_run) - logger.info(f"System automation '{spec['name']}' next_run reset to {spec['initial_delay']}s from now") + # Only reset next_run for timer-based triggers that have an initial delay + if spec.get('initial_delay') is not None: + next_run = (datetime.now() + timedelta(seconds=spec['initial_delay'])).strftime('%Y-%m-%d %H:%M:%S') + self.db.update_automation(existing['id'], next_run=next_run) + logger.info(f"System automation '{spec['name']}' next_run reset to {spec['initial_delay']}s from now") + else: + logger.info(f"System automation '{spec['name']}' ready (event-based)") def get_system_automation_next_run_seconds(self, action_type): """Get seconds until next run for a system automation. Returns 0 if not found or disabled.""" diff --git a/web_server.py b/web_server.py index 2c2e568a..db5d8f38 100644 --- a/web_server.py +++ b/web_server.py @@ -652,6 +652,17 @@ def _register_automation_handlers(): automation_engine.register_progress_callbacks(_progress_init, _progress_finish) + # Register permanent callback: when any scan completes, emit library_scan_completed event + # This replaces the hardcoded scan_completion_callback → trigger_automatic_database_update chain + if web_scan_manager: + def _on_library_scan_completed(): + if automation_engine: + server_type = getattr(web_scan_manager, '_current_server_type', None) or 'unknown' + automation_engine.emit('library_scan_completed', { + 'server_type': server_type, + }) + web_scan_manager.add_scan_completion_callback(_on_library_scan_completed) + print("✅ Automation action handlers registered") @@ -3908,6 +3919,9 @@ def get_automation_blocks(): {"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": "library_scan_completed", "label": "Library Scan Done", "icon": "hard-drive", + "description": "When media library scan finishes", "available": True, + "variables": ["server_type"]}, {"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"], @@ -6490,7 +6504,8 @@ def clear_quarantine(): @app.route('/api/scan/request', methods=['POST']) def request_media_scan(): """ - Request a media library scan with automatic completion callback support. + Request a media library scan. + Database update is handled automatically by the 'Auto-Update Database After Scan' system automation. """ try: if not web_scan_manager: @@ -6498,33 +6513,13 @@ def request_media_scan(): data = request.get_json() or {} reason = data.get('reason', 'Web UI download completed') - auto_database_update = data.get('auto_database_update', True) - def scan_completion_callback(): - """Callback to trigger automatic database update after scan completes""" - if auto_database_update: - try: - logger.info("🔄 Starting automatic incremental database update after scan completion") - # Start database update in a separate thread to avoid blocking - threading.Thread( - target=trigger_automatic_database_update, - args=("Post-scan automatic update",), - daemon=True - ).start() - except Exception as e: - logger.error(f"Error starting automatic database update: {e}") - - # Request scan with callback - result = web_scan_manager.request_scan( - reason=reason, - callback=scan_completion_callback if auto_database_update else None - ) + result = web_scan_manager.request_scan(reason=reason) add_activity_item("📡", "Media Scan", f"Scan requested: {reason}", "Now") return jsonify({ "success": True, "scan_info": result, - "auto_database_update": auto_database_update }) except Exception as e: @@ -6581,7 +6576,16 @@ def request_incremental_database_update(): }), 400 # Start incremental update - result = trigger_automatic_database_update(reason) + if db_update_state.get('status') == 'running': + return jsonify({"success": False, "error": "Database update already running"}), 409 + + 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, False, active_server) add_activity_item("🔄", "Database Update", f"Incremental update started: {reason}", "Now") return jsonify({ @@ -6596,52 +6600,6 @@ def request_incremental_database_update(): logger.error(f"Error requesting incremental database update: {e}") return jsonify({"success": False, "error": str(e)}), 500 -def trigger_automatic_database_update(reason="Automatic update"): - """ - Helper function to trigger automatic incremental database update. - """ - try: - from config.settings import config_manager - active_server = config_manager.get_active_media_server() - - # Get the appropriate media client - media_client = None - if active_server == "jellyfin" and jellyfin_client: - media_client = jellyfin_client - elif active_server == "navidrome" and navidrome_client: - media_client = navidrome_client - else: - media_client = plex_client # Default fallback - - if not media_client or not media_client.is_connected(): - logger.error(f"No connected {active_server} client for automatic database update") - return False - - # Create and start database update worker - worker = DatabaseUpdateWorker( - media_client=media_client, - server_type=active_server, - full_refresh=False # Always incremental for automatic updates - ) - - def update_completion_callback(): - logger.info(f"✅ Automatic incremental database update completed for {active_server}") - add_activity_item("✅", "Database Update", f"Automatic update completed ({active_server})", "Now") - - # Start update in background thread - update_thread = threading.Thread( - target=lambda: worker.run_with_callback(update_completion_callback), - daemon=True - ) - update_thread.start() - - logger.info(f"🔄 Started automatic incremental database update for {active_server}") - return True - - except Exception as e: - logger.error(f"Error in automatic database update: {e}") - return False - @app.route('/api/test/automation', methods=['POST']) def test_automation_workflow(): """ diff --git a/webui/static/script.js b/webui/static/script.js index abad2d99..00657030 100644 --- a/webui/static/script.js +++ b/webui/static/script.js @@ -12935,86 +12935,35 @@ window.matchedDownloadAlbum = matchedDownloadAlbum; window.matchedDownloadAlbumTrack = matchedDownloadAlbumTrack; /** - * Handle automatic post-download operations: cleanup → scan → database update - * This replicates the GUI's automatic functionality after download modal completion + * Handle post-download cleanup: clear finished downloads from slskd. + * Scan and database update are now handled by system automations + * (batch_complete → scan_library → library_scan_completed → start_database_update). */ async function handlePostDownloadAutomation(playlistId, process) { try { - // Check if we have successful downloads that warrant automation const successfulDownloads = getSuccessfulDownloadCount(process); - if (successfulDownloads === 0) { - console.log(`🔄 [AUTO] No successful downloads for ${playlistId} - skipping automation`); + console.log(`🔄 [AUTO] No successful downloads for ${playlistId} - skipping cleanup`); return; } + console.log(`🔄 [AUTO] Post-download cleanup for ${playlistId} (${successfulDownloads} successful downloads)`); - console.log(`🔄 [AUTO] Starting automatic post-download operations for ${playlistId} (${successfulDownloads} successful downloads)`); - - // Step 1: Clear completed downloads from slskd - console.log(`🗑️ [AUTO] Step 1: Clearing completed downloads...`); - showToast('🗑️ Clearing completed downloads...', 'info', 3000); - + // Clear completed downloads from slskd try { const clearResponse = await fetch('/api/downloads/clear-finished', { method: 'POST', headers: { 'Content-Type': 'application/json' } }); - if (clearResponse.ok) { - console.log(`✅ [AUTO] Step 1 complete: Downloads cleared`); + console.log(`✅ [AUTO] Completed downloads cleared`); } else { - console.warn(`⚠️ [AUTO] Step 1 warning: Clear downloads failed, continuing anyway`); + console.warn(`⚠️ [AUTO] Clear downloads failed, continuing anyway`); } } catch (error) { - console.warn(`⚠️ [AUTO] Step 1 error: ${error.message}, continuing anyway`); + console.warn(`⚠️ [AUTO] Clear error: ${error.message}`); } - - // Step 2: Request media server scan - console.log(`📡 [AUTO] Step 2: Requesting media server scan...`); - showToast('📡 Scanning media server library...', 'info', 5000); - - try { - const scanResponse = await fetch('/api/scan/request', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - reason: `Download modal completed for ${playlistId} (${successfulDownloads} tracks)`, - auto_database_update: true // This will trigger step 3 automatically after scan completes - }) - }); - - const scanResult = await scanResponse.json(); - - if (scanResponse.ok && scanResult.success) { - console.log(`✅ [AUTO] Step 2 complete: Media scan requested`); - console.log(`🔄 [AUTO] Scan info:`, scanResult.scan_info); - - // Show success toast with scan details - if (scanResult.scan_info.status === 'scheduled') { - showToast(`📡 Media scan scheduled (${scanResult.scan_info.delay_seconds}s delay)`, 'success', 5000); - } else { - showToast('📡 Media scan requested successfully', 'success', 3000); - } - - // Database update will be triggered automatically by the scan completion callback - if (scanResult.auto_database_update) { - console.log(`🔄 [AUTO] Step 3 will run automatically after scan completes`); - showToast('🔄 Database update will follow automatically', 'info', 3000); - } - } else { - console.error(`❌ [AUTO] Step 2 failed: ${scanResult.error || 'Unknown scan error'}`); - showToast('❌ Media scan failed', 'error', 5000); - } - } catch (error) { - console.error(`❌ [AUTO] Step 2 error: ${error.message}`); - showToast('❌ Media scan request failed', 'error', 5000); - } - - console.log(`🏁 [AUTO] Automatic post-download operations initiated for ${playlistId}`); - } catch (error) { - console.error(`❌ [AUTO] Error in post-download automation: ${error.message}`); - showToast('❌ Automatic operations failed', 'error', 5000); + console.error(`❌ [AUTO] Error in post-download cleanup: ${error.message}`); } } @@ -15998,6 +15947,31 @@ const TOOL_HELP_CONTENT = {
{files_scanned}, {duplicates_found}, {space_freed}
Fires when a media server library scan is considered complete. This only happens after a Scan Library action was triggered — it cannot fire on its own.
+ +Your media server (Plex, Jellyfin, Navidrome) doesn't send a "scan finished" signal back to SoulSync. So after telling the server to scan, SoulSync waits approximately 5 minutes and then assumes the scan has finished. This is a generous estimate that works for most libraries.
+ +From the moment a download finishes to when this trigger fires, expect roughly 6-7 minutes:
+The system automation Auto-Update Database After Scan listens for this trigger to start an incremental database update, keeping your SoulSync library in sync with your media server.
+ +{server_type} — which media server was scanned (plex, jellyfin, navidrome)
Triggers your media server (Plex) to scan its music library folder for new or changed files. This makes newly downloaded music appear in your media server.
+Tells your media server (Plex, Jellyfin, or Navidrome) to scan its music library folder for new or changed files. This makes newly downloaded music appear in your media server.
+ +The system automation Auto-Scan After Downloads uses this action to automatically scan your library when a batch download completes. You can disable that automation if you prefer to scan manually.
Jellyfin and Navidrome detect new files automatically in real-time, so this action is primarily useful for Plex.
+Jellyfin and Navidrome often detect new files automatically, but the scan ensures nothing is missed.
` }, 'auto-refresh_mirrored': { @@ -29674,13 +29659,12 @@ async function handleMediaScanButtonClick() { statusValue.textContent = 'Scanning...'; statusValue.style.color = 'rgb(var(--accent-rgb))'; - // Request scan + // Request scan (database update handled by system automation) const response = await fetch('/api/scan/request', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ - reason: 'Manual scan triggered from dashboard', - auto_database_update: true + reason: 'Manual scan triggered from dashboard' }) }); @@ -29693,11 +29677,6 @@ async function handleMediaScanButtonClick() { let countdownInterval = null; let pollInterval = null; - // Show auto database update message - if (result.auto_database_update) { - showToast('🔄 Database will update automatically after scan', 'info', 3000); - } - // Update last scan time const lastTimeEl = document.getElementById('media-scan-last-time'); if (lastTimeEl) { @@ -43233,7 +43212,7 @@ const _autoIcons = { 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', + quality_scan_completed: '\uD83D\uDCCA', duplicate_scan_completed: '\uD83D\uDDC2\uFE0F', library_scan_completed: '\uD83D\uDCE1', 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', @@ -43352,7 +43331,7 @@ function _autoFormatTrigger(type, config) { 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', - signal_received: 'Signal Received' }; + library_scan_completed: 'Library Scan Done', signal_received: 'Signal Received' }; let label = labels[type] || type || 'Unknown'; if (config && config.conditions && config.conditions.length) { const first = config.conditions[0];