diff --git a/web_server.py b/web_server.py
index bd55d343..6cacde00 100644
--- a/web_server.py
+++ b/web_server.py
@@ -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
diff --git a/webui/static/script.js b/webui/static/script.js
index bb9ae142..d271ec07 100644
--- a/webui/static/script.js
+++ b/webui/static/script.js
@@ -16385,7 +16385,7 @@ const TOOL_HELP_CONTENT = {
Creates a timestamped backup of SoulSync's SQLite database. Uses the SQLite backup API for a safe hot-copy while the app is running.
Retention
- Keeps the last 3 backups automatically. Older backups are cleaned up to save disk space.
+ Keeps the last 5 backups automatically. Older backups are cleaned up to save disk space.
Good for
@@ -16395,6 +16395,139 @@ const TOOL_HELP_CONTENT = {
`
},
+ 'auto-refresh_beatport_cache': {
+ title: 'Refresh Beatport Cache',
+ content: `
+ What does this action do?
+ 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.
+
+ Cache duration
+ Cache lasts 24 hours. This action refreshes it early so it's always warm when you visit the charts page.
+
+ Good for
+
+ - Keeping Beatport charts available instantly
+ - Scheduling daily cache refreshes (e.g. every morning)
+
+ `
+ },
+ 'auto-clean_search_history': {
+ title: 'Clean Search History',
+ content: `
+ What does this action do?
+ Removes old search queries from Soulseek. This keeps your search history clean and prevents buildup over time.
+
+ Good for
+
+ - Periodic housekeeping
+ - Keeping Soulseek search history tidy
+
+ `
+ },
+ 'auto-clean_completed_downloads': {
+ title: 'Clean Completed Downloads',
+ content: `
+ What does this action do?
+ Clears completed downloads from the transfer list and removes any empty directories left behind in the staging folder.
+
+ Good for
+
+ - Automatic cleanup after batch downloads
+ - Preventing staging folder clutter
+ - Chaining after a batch complete trigger
+
+ `
+ },
+ 'auto-deep_scan_library': {
+ title: 'Deep Scan Library',
+ content: `
+ What does this action do?
+ 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.
+
+ How is this different from Database Update?
+
+ - Database Update: Incremental — only looks for new artists/albums added since last update
+ - Deep Scan: Full comparison — checks every track on the server against the database, catches anything missed
+
+
+ Safety
+
+ - Never overwrites existing enrichment data (genres, Spotify IDs, artwork)
+ - Only inserts tracks that don't already exist in the database
+ - Stale track removal has a 50% safety threshold — if more than half the library appears missing, removal is skipped
+
+ `
+ },
+
+ // ==================== Notification/Then-Action Help ====================
+
+ 'auto-discord_webhook': {
+ title: 'Discord Webhook',
+ content: `
+ What does this then-action do?
+ Sends a notification to a Discord channel via webhook when the automation's action completes.
+
+ Configuration
+
+ - Webhook URL: The Discord webhook URL for your channel (found in Channel Settings → Integrations → Webhooks)
+ - Message Template: Custom message with variable placeholders
+
+
+ Available variables
+ Use these in your message template:
+
+ {time} — When the automation ran
+ {name} — Automation name
+ {run_count} — How many times this automation has run
+ {status} — Result status of the action
+
+ `
+ },
+ 'auto-pushbullet': {
+ title: 'Pushbullet',
+ content: `
+ What does this then-action do?
+ Sends a push notification to your phone or desktop via Pushbullet when the automation's action completes.
+
+ Configuration
+
+ - API Key: Your Pushbullet access token (found in Pushbullet Settings → Access Tokens)
+ - Message Template: Custom message with variable placeholders
+
+
+ Available variables
+ Use these in your message template:
+
+ {time} — When the automation ran
+ {name} — Automation name
+ {run_count} — How many times this automation has run
+ {status} — Result status of the action
+
+ `
+ },
+ 'auto-telegram': {
+ title: 'Telegram',
+ content: `
+ What does this then-action do?
+ Sends a message to a Telegram chat via bot when the automation's action completes.
+
+ Configuration
+
+ - Bot Token: Your Telegram bot token (from @BotFather)
+ - Chat ID: The chat/group ID to send messages to
+ - Message Template: Custom message with variable placeholders
+
+
+ Available variables
+ Use these in your message template:
+
+ {time} — When the automation ran
+ {name} — Automation name
+ {run_count} — How many times this automation has run
+ {status} — Result status of the action
+
+ `
+ },
// ==================== 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('Next: ' + _autoTimeUntil(a.next_run) + '');
if (!_timerTriggers.includes(a.trigger_type) && a.enabled) metaParts.push('Listening');
- if (a.run_count) metaParts.push('Runs: ' + a.run_count + '');
+ if (a.run_count) metaParts.push('Runs: ' + a.run_count + '');
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 = '';
+ items.forEach(function(it) {
+ html += '
' + _esc(it.label) + '
' + _esc(String(it.value)) + '
';
+ });
+ html += '
';
+ 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 += '▼';
html += '';
if (summary) html += '' + summary + '
';
+ if (entry.result_json && typeof entry.result_json === 'object') {
+ html += _renderResultStats(entry.result_json, actionType);
+ }
if (hasLogs) {
html += '';
entry.log_lines.forEach(function(log) {
diff --git a/webui/static/style.css b/webui/static/style.css
index da1671b4..95df3721 100644
--- a/webui/static/style.css
+++ b/webui/static/style.css
@@ -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);
}
\ No newline at end of file