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

` }, + '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

+ + ` + }, + '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

+ + ` + }, + '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

+ + ` + }, + '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?

+ + +

Safety

+ + ` + }, + + // ==================== 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

+ + +

Available variables

+

Use these in your message template:

+ + ` + }, + '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

+ + +

Available variables

+

Use these in your message template:

+ + ` + }, + '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

+ + +

Available variables

+

Use these in your message template:

+ + ` + }, // ==================== 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