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}

` }, + 'auto-library_scan_completed': { + title: 'Library Scan Done', + content: ` +

What is this trigger?

+

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.

+ +

How does it know the scan is done?

+

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.

+ +

Timing

+

From the moment a download finishes to when this trigger fires, expect roughly 6-7 minutes:

+
    +
  1. 60 second debounce wait (groups multiple downloads together)
  2. +
  3. Media server scan triggered
  4. +
  5. ~5 minute wait (assumed scan completion)
  6. +
  7. This event fires
  8. +
+ +

Default use

+

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.

+ +

Available variables

+

{server_type} — which media server was scanned (plex, jellyfin, navidrome)

+ ` + }, // ==================== Automation Action Help ==================== @@ -16040,10 +16014,21 @@ const TOOL_HELP_CONTENT = { title: 'Scan Library', content: `

What does this action do?

-

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.

+ +

How it works

+
    +
  1. A 60 second debounce groups rapid requests — if multiple downloads finish close together, only one scan is triggered
  2. +
  3. After the debounce, your media server is told to scan
  4. +
  5. SoulSync waits ~5 minutes (your media server doesn't report when it's finished, so this is an assumed completion time)
  6. +
  7. The Library Scan Done event fires, which can trigger follow-up actions like a database update
  8. +
+ +

Default use

+

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.

Note

-

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];