Replace hardcoded post-download chain with system automations

This commit is contained in:
Broque Thomas 2026-03-06 19:48:48 -08:00
parent 8b6a2c0adc
commit 156c37d907
3 changed files with 99 additions and 146 deletions

View file

@ -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."""

View file

@ -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():
"""

View file

@ -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 = {
<p><code>{files_scanned}</code>, <code>{duplicates_found}</code>, <code>{space_freed}</code></p>
`
},
'auto-library_scan_completed': {
title: 'Library Scan Done',
content: `
<h4>What is this trigger?</h4>
<p>Fires when a media server library scan is considered complete. This only happens after a <strong>Scan Library</strong> action was triggered it cannot fire on its own.</p>
<h4>How does it know the scan is done?</h4>
<p>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 <strong>approximately 5 minutes</strong> and then assumes the scan has finished. This is a generous estimate that works for most libraries.</p>
<h4>Timing</h4>
<p>From the moment a download finishes to when this trigger fires, expect roughly <strong>6-7 minutes</strong>:</p>
<ol>
<li>60 second debounce wait (groups multiple downloads together)</li>
<li>Media server scan triggered</li>
<li>~5 minute wait (assumed scan completion)</li>
<li>This event fires</li>
</ol>
<h4>Default use</h4>
<p>The system automation <strong>Auto-Update Database After Scan</strong> listens for this trigger to start an incremental database update, keeping your SoulSync library in sync with your media server.</p>
<h4>Available variables</h4>
<p><code>{server_type}</code> which media server was scanned (plex, jellyfin, navidrome)</p>
`
},
// ==================== Automation Action Help ====================
@ -16040,10 +16014,21 @@ const TOOL_HELP_CONTENT = {
title: 'Scan Library',
content: `
<h4>What does this action do?</h4>
<p>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.</p>
<p>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.</p>
<h4>How it works</h4>
<ol>
<li>A <strong>60 second debounce</strong> groups rapid requests if multiple downloads finish close together, only one scan is triggered</li>
<li>After the debounce, your media server is told to scan</li>
<li>SoulSync waits <strong>~5 minutes</strong> (your media server doesn't report when it's finished, so this is an assumed completion time)</li>
<li>The <strong>Library Scan Done</strong> event fires, which can trigger follow-up actions like a database update</li>
</ol>
<h4>Default use</h4>
<p>The system automation <strong>Auto-Scan After Downloads</strong> uses this action to automatically scan your library when a batch download completes. You can disable that automation if you prefer to scan manually.</p>
<h4>Note</h4>
<p>Jellyfin and Navidrome detect new files automatically in real-time, so this action is primarily useful for Plex.</p>
<p>Jellyfin and Navidrome often detect new files automatically, but the scan ensures nothing is missed.</p>
`
},
'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];