Add rich stats to automation run history

This commit is contained in:
Broque Thomas 2026-03-08 12:20:50 -07:00
parent f9e8c8dadd
commit 5daa8c0596
3 changed files with 290 additions and 8 deletions

View file

@ -274,10 +274,22 @@ def _register_automation_handlers():
return {'status': 'completed'}
except Exception as e:
return {'status': 'error', 'error': str(e)}
# Note: wishlist processing is async (batch submitted to executor), stats come via batch completion
def _auto_scan_watchlist(config):
try:
pre_state_id = id(watchlist_scan_state)
_process_watchlist_scan_automatically(automation_id=config.get('_automation_id'))
# Only report stats if a fresh scan actually ran (state dict was reassigned)
if id(watchlist_scan_state) != pre_state_id:
summary = watchlist_scan_state.get('summary', {})
return {
'status': 'completed',
'artists_scanned': summary.get('total_artists', 0),
'successful_scans': summary.get('successful_scans', 0),
'new_tracks_found': summary.get('new_tracks_found', 0),
'tracks_added_to_wishlist': summary.get('tracks_added_to_wishlist', 0),
}
return {'status': 'completed'}
except Exception as e:
return {'status': 'error', 'error': str(e)}
@ -347,11 +359,12 @@ def _register_automation_handlers():
phase='Timed out', log_line='Library scan timed out after 30 minutes', log_type='error')
return {'status': 'error', 'reason': 'Timed out', '_manages_own_progress': True}
elapsed = round(time.time() - poll_start, 1)
_update_automation_progress(automation_id, status='finished', progress=100,
phase='Complete',
log_line='Library scan completed', log_type='success')
return {'status': 'completed', '_manages_own_progress': True}
return {'status': 'completed', '_manages_own_progress': True, 'scan_duration_seconds': elapsed}
except Exception as e:
_update_automation_progress(automation_id, status='error',
@ -659,7 +672,17 @@ def _register_automation_handlers():
final_status = db_update_state.get('status', 'unknown')
if final_status == 'error':
return {'status': 'error', 'reason': db_update_state.get('error_message', 'Unknown error'), '_manages_own_progress': True}
return {'status': 'completed', 'full_refresh': str(full), '_manages_own_progress': True}
with db_update_lock:
stats = {
'status': 'completed', 'full_refresh': str(full), '_manages_own_progress': True,
'artists': db_update_state.get('total', 0),
'albums': db_update_state.get('total_albums', 0),
'tracks': db_update_state.get('total_tracks', 0),
'removed_artists': db_update_state.get('removed_artists', 0),
'removed_albums': db_update_state.get('removed_albums', 0),
'removed_tracks': db_update_state.get('removed_tracks', 0),
}
return stats
def _auto_deep_scan_library(config):
global _db_update_automation_id
@ -703,7 +726,17 @@ def _register_automation_handlers():
final_status = db_update_state.get('status', 'unknown')
if final_status == 'error':
return {'status': 'error', 'reason': db_update_state.get('error_message', 'Unknown error'), '_manages_own_progress': True}
return {'status': 'completed', '_manages_own_progress': True}
with db_update_lock:
stats = {
'status': 'completed', '_manages_own_progress': True,
'artists': db_update_state.get('total', 0),
'albums': db_update_state.get('total_albums', 0),
'tracks': db_update_state.get('total_tracks', 0),
'removed_artists': db_update_state.get('removed_artists', 0),
'removed_albums': db_update_state.get('removed_albums', 0),
'removed_tracks': db_update_state.get('removed_tracks', 0),
}
return stats
def _auto_run_duplicate_cleaner(config):
automation_id = config.get('_automation_id')
@ -748,10 +781,18 @@ def _register_automation_handlers():
dupes = duplicate_cleaner_state.get('duplicates_found', 0)
removed = duplicate_cleaner_state.get('deleted', 0)
space_freed = duplicate_cleaner_state.get('space_freed', 0)
scanned = duplicate_cleaner_state.get('files_scanned', 0)
_update_automation_progress(automation_id, status='finished', progress=100,
phase='Complete',
log_line=f'Found {dupes} duplicates, removed {removed} files', log_type='success')
return {'status': 'completed', '_manages_own_progress': True}
return {
'status': 'completed', '_manages_own_progress': True,
'files_scanned': scanned,
'duplicates_found': dupes,
'files_deleted': removed,
'space_freed_mb': round(space_freed / (1024 * 1024), 1),
}
def _auto_clear_quarantine(config):
import shutil as _shutil
@ -848,7 +889,13 @@ def _register_automation_handlers():
_update_automation_progress(automation_id, status='finished', progress=100,
phase='Complete',
log_line=f'Quality scan complete — {issues} issues found', log_type='success')
return {'status': 'completed', 'scope': scope, '_manages_own_progress': True}
return {
'status': 'completed', 'scope': scope, '_manages_own_progress': True,
'tracks_scanned': quality_scanner_state.get('processed', 0),
'quality_met': quality_scanner_state.get('quality_met', 0),
'low_quality': issues,
'matched': quality_scanner_state.get('matched', 0),
}
def _auto_backup_database(config):
import sqlite3, glob as _glob
@ -14175,6 +14222,8 @@ def _db_update_finished_callback(total_artists, total_albums, total_tracks, succ
with db_update_lock:
db_update_state["status"] = "finished"
db_update_state["phase"] = f"Completed: {successful} successful, {failed} failed{removal_msg}."
db_update_state["total_albums"] = total_albums
db_update_state["total_tracks"] = total_tracks
db_update_state["removed_artists"] = removed_artists
db_update_state["removed_albums"] = removed_albums
db_update_state["removed_tracks"] = removed_tracks

View file

@ -16385,7 +16385,7 @@ const TOOL_HELP_CONTENT = {
<p>Creates a timestamped backup of SoulSync's SQLite database. Uses the SQLite backup API for a safe hot-copy while the app is running.</p>
<h4>Retention</h4>
<p>Keeps the last 3 backups automatically. Older backups are cleaned up to save disk space.</p>
<p>Keeps the last 5 backups automatically. Older backups are cleaned up to save disk space.</p>
<h4>Good for</h4>
<ul>
@ -16395,6 +16395,139 @@ const TOOL_HELP_CONTENT = {
</ul>
`
},
'auto-refresh_beatport_cache': {
title: 'Refresh Beatport Cache',
content: `
<h4>What does this action do?</h4>
<p>Scrapes the Beatport homepage for top charts and caches the results locally. Keeps the Beatport charts page loading instantly without needing to scrape on every visit.</p>
<h4>Cache duration</h4>
<p>Cache lasts 24 hours. This action refreshes it early so it's always warm when you visit the charts page.</p>
<h4>Good for</h4>
<ul>
<li>Keeping Beatport charts available instantly</li>
<li>Scheduling daily cache refreshes (e.g. every morning)</li>
</ul>
`
},
'auto-clean_search_history': {
title: 'Clean Search History',
content: `
<h4>What does this action do?</h4>
<p>Removes old search queries from Soulseek. This keeps your search history clean and prevents buildup over time.</p>
<h4>Good for</h4>
<ul>
<li>Periodic housekeeping</li>
<li>Keeping Soulseek search history tidy</li>
</ul>
`
},
'auto-clean_completed_downloads': {
title: 'Clean Completed Downloads',
content: `
<h4>What does this action do?</h4>
<p>Clears completed downloads from the transfer list and removes any empty directories left behind in the staging folder.</p>
<h4>Good for</h4>
<ul>
<li>Automatic cleanup after batch downloads</li>
<li>Preventing staging folder clutter</li>
<li>Chaining after a batch complete trigger</li>
</ul>
`
},
'auto-deep_scan_library': {
title: 'Deep Scan Library',
content: `
<h4>What does this action do?</h4>
<p>Walks your entire media server library and compares it against SoulSync's database. Adds any new tracks found and removes stale entries that no longer exist on the server.</p>
<h4>How is this different from Database Update?</h4>
<ul>
<li><strong>Database Update:</strong> Incremental only looks for new artists/albums added since last update</li>
<li><strong>Deep Scan:</strong> Full comparison checks every track on the server against the database, catches anything missed</li>
</ul>
<h4>Safety</h4>
<ul>
<li>Never overwrites existing enrichment data (genres, Spotify IDs, artwork)</li>
<li>Only inserts tracks that don't already exist in the database</li>
<li>Stale track removal has a 50% safety threshold if more than half the library appears missing, removal is skipped</li>
</ul>
`
},
// ==================== Notification/Then-Action Help ====================
'auto-discord_webhook': {
title: 'Discord Webhook',
content: `
<h4>What does this then-action do?</h4>
<p>Sends a notification to a Discord channel via webhook when the automation's action completes.</p>
<h4>Configuration</h4>
<ul>
<li><strong>Webhook URL:</strong> The Discord webhook URL for your channel (found in Channel Settings Integrations Webhooks)</li>
<li><strong>Message Template:</strong> Custom message with variable placeholders</li>
</ul>
<h4>Available variables</h4>
<p>Use these in your message template:</p>
<ul>
<li><code>{time}</code> When the automation ran</li>
<li><code>{name}</code> Automation name</li>
<li><code>{run_count}</code> How many times this automation has run</li>
<li><code>{status}</code> Result status of the action</li>
</ul>
`
},
'auto-pushbullet': {
title: 'Pushbullet',
content: `
<h4>What does this then-action do?</h4>
<p>Sends a push notification to your phone or desktop via Pushbullet when the automation's action completes.</p>
<h4>Configuration</h4>
<ul>
<li><strong>API Key:</strong> Your Pushbullet access token (found in Pushbullet Settings Access Tokens)</li>
<li><strong>Message Template:</strong> Custom message with variable placeholders</li>
</ul>
<h4>Available variables</h4>
<p>Use these in your message template:</p>
<ul>
<li><code>{time}</code> When the automation ran</li>
<li><code>{name}</code> Automation name</li>
<li><code>{run_count}</code> How many times this automation has run</li>
<li><code>{status}</code> Result status of the action</li>
</ul>
`
},
'auto-telegram': {
title: 'Telegram',
content: `
<h4>What does this then-action do?</h4>
<p>Sends a message to a Telegram chat via bot when the automation's action completes.</p>
<h4>Configuration</h4>
<ul>
<li><strong>Bot Token:</strong> Your Telegram bot token (from @BotFather)</li>
<li><strong>Chat ID:</strong> The chat/group ID to send messages to</li>
<li><strong>Message Template:</strong> Custom message with variable placeholders</li>
</ul>
<h4>Available variables</h4>
<p>Use these in your message template:</p>
<ul>
<li><code>{time}</code> When the automation ran</li>
<li><code>{name}</code> Automation name</li>
<li><code>{run_count}</code> How many times this automation has run</li>
<li><code>{status}</code> Result status of the action</li>
</ul>
`
},
// ==================== Signal System Help ====================
@ -43723,7 +43856,7 @@ function renderAutomationCard(a) {
const _timerTriggers = ['schedule', 'daily_time', 'weekly_time'];
if (a.next_run && a.enabled && _timerTriggers.includes(a.trigger_type)) metaParts.push('<span class="auto-next-run" data-next="' + _escAttr(a.next_run) + '">Next: ' + _autoTimeUntil(a.next_run) + '</span>');
if (!_timerTriggers.includes(a.trigger_type) && a.enabled) metaParts.push('Listening');
if (a.run_count) metaParts.push('<span class="auto-runs-link" onclick="event.stopPropagation(); showAutomationHistory(' + a.id + ', \'' + _escAttr(a.name) + '\')" title="View run history">Runs: ' + a.run_count + '</span>');
if (a.run_count) metaParts.push('<span class="auto-runs-link" onclick="event.stopPropagation(); showAutomationHistory(' + a.id + ', \'' + _escAttr(a.name) + '\', \'' + _escAttr(a.action_type || '') + '\')" title="View run history">Runs: ' + a.run_count + '</span>');
if (a.last_error) metaParts.push('Error: ' + _esc(a.last_error));
const deleteBtn = a.is_system ? '' :
@ -43964,7 +44097,88 @@ async function runAutomation(id) {
} catch (err) { showToast('Error: ' + err.message, 'error'); }
}
async function showAutomationHistory(automationId, automationName) {
const _RESULT_DISPLAY_MAP = {
'start_database_update': [
{ key: 'artists', label: 'Artists' },
{ key: 'albums', label: 'Albums' },
{ key: 'tracks', label: 'Tracks' },
{ key: 'removed_artists', label: 'Removed Artists', hideZero: true },
{ key: 'removed_albums', label: 'Removed Albums', hideZero: true },
{ key: 'removed_tracks', label: 'Removed Tracks', hideZero: true },
],
'deep_scan_library': [
{ key: 'artists', label: 'Artists' },
{ key: 'albums', label: 'Albums' },
{ key: 'tracks', label: 'Tracks' },
{ key: 'removed_artists', label: 'Removed Artists', hideZero: true },
{ key: 'removed_albums', label: 'Removed Albums', hideZero: true },
{ key: 'removed_tracks', label: 'Removed Tracks', hideZero: true },
],
'scan_watchlist': [
{ key: 'artists_scanned', label: 'Artists Scanned' },
{ key: 'successful_scans', label: 'Successful' },
{ key: 'new_tracks_found', label: 'New Tracks' },
{ key: 'tracks_added_to_wishlist', label: 'Added to Wishlist' },
],
'run_duplicate_cleaner': [
{ key: 'files_scanned', label: 'Files Scanned' },
{ key: 'duplicates_found', label: 'Duplicates Found' },
{ key: 'files_deleted', label: 'Files Deleted' },
{ key: 'space_freed_mb', label: 'Space Freed (MB)' },
],
'start_quality_scan': [
{ key: 'tracks_scanned', label: 'Tracks Scanned' },
{ key: 'quality_met', label: 'Quality Met' },
{ key: 'low_quality', label: 'Low Quality' },
{ key: 'matched', label: 'Added to Wishlist' },
],
'scan_library': [
{ key: 'scan_duration_seconds', label: 'Duration (s)' },
],
'backup_database': [
{ key: 'size_mb', label: 'Backup Size (MB)' },
],
'refresh_mirrored': [
{ key: 'refreshed', label: 'Playlists Refreshed' },
{ key: 'errors', label: 'Errors', hideZero: true },
],
'clear_quarantine': [
{ key: 'removed', label: 'Items Removed' },
],
'cleanup_wishlist': [
{ key: 'removed', label: 'Duplicates Removed' },
],
};
function _renderResultStats(resultJson, actionType) {
if (!resultJson || typeof resultJson !== 'object') return '';
var fields = _RESULT_DISPLAY_MAP[actionType];
var items = [];
if (fields) {
fields.forEach(function(f) {
var val = resultJson[f.key];
if (val == null) return;
if (f.hideZero && (val === 0 || val === '0')) return;
items.push({ label: f.label, value: val });
});
} else {
// Generic fallback: show all non-status, non-underscore keys
Object.keys(resultJson).forEach(function(k) {
if (k === 'status' || k.startsWith('_')) return;
var label = k.replace(/_/g, ' ').replace(/\b\w/g, function(c) { return c.toUpperCase(); });
items.push({ label: label, value: resultJson[k] });
});
}
if (items.length === 0) return '';
var html = '<div class="history-stats-grid">';
items.forEach(function(it) {
html += '<div class="history-stat-item"><div class="history-stat-label">' + _esc(it.label) + '</div><div class="history-stat-value">' + _esc(String(it.value)) + '</div></div>';
});
html += '</div>';
return html;
}
async function showAutomationHistory(automationId, automationName, actionType) {
let modal = document.getElementById('automation-history-modal');
if (!modal) {
modal = document.createElement('div');
@ -44003,6 +44217,9 @@ async function showAutomationHistory(automationId, automationName) {
if (hasLogs) html += '<span class="history-expand-icon">&#9660;</span>';
html += '</div>';
if (summary) html += '<div class="history-summary">' + summary + '</div>';
if (entry.result_json && typeof entry.result_json === 'object') {
html += _renderResultStats(entry.result_json, actionType);
}
if (hasLogs) {
html += '<div id="' + entryId + '" class="history-log-section">';
entry.log_lines.forEach(function(log) {

View file

@ -29597,6 +29597,22 @@ body {
.history-log-error { color: #ef4444; }
.history-log-warning { color: #facc15; }
.history-stats-grid {
display: grid; grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
gap: 8px; padding: 10px 14px;
border-top: 1px solid rgba(255,255,255,0.06);
}
.history-stat-item {
background: rgba(0,0,0,0.25); border-radius: 8px; padding: 8px 12px;
}
.history-stat-label {
font-size: 11px; color: rgba(255,255,255,0.45); margin-bottom: 2px;
}
.history-stat-value {
font-size: 14px; font-weight: 600; color: #e0e0e0;
}
.history-log-skip { color: rgba(255,255,255,0.35); }
.history-total {
text-align: center; padding: 12px 0 4px; font-size: 11px; color: rgba(255,255,255,0.3);
}