From f9d80606e3d08ba54aad3f7c209357c929b09c4e Mon Sep 17 00:00:00 2001 From: Broque Thomas <26755000+Nezreka@users.noreply.github.com> Date: Fri, 13 Mar 2026 08:47:04 -0700 Subject: [PATCH] Expand debug info with library stats, service status, config, and configurable log output --- web_server.py | 119 ++++++++++++++++++++++++++++++++++++++-- webui/static/docs.js | 120 +++++++++++++++++++++++++++++++---------- webui/static/style.css | 61 +++++++++++++++++++++ 3 files changed, 268 insertions(+), 32 deletions(-) diff --git a/web_server.py b/web_server.py index 5c29520c..b78ccbff 100644 --- a/web_server.py +++ b/web_server.py @@ -3839,12 +3839,26 @@ def get_system_stats(): return jsonify({'error': str(e)}), 500 +def _safe_check(fn, default=False): + """Safely evaluate a check function, returning default on any error.""" + try: + return fn() + except Exception: + return default + + @app.route('/api/debug-info') def get_debug_info(): """Collect system diagnostics for troubleshooting support requests.""" import platform import sys import psutil + import time + from datetime import timedelta + + log_lines = request.args.get('lines', 20, type=int) + log_lines = max(10, min(log_lines, 500)) + log_source = request.args.get('log', 'app') info = {} @@ -3854,6 +3868,11 @@ def get_debug_info(): info['python'] = sys.version.split()[0] info['docker'] = os.path.exists('/.dockerenv') + # Uptime + start_time = getattr(app, 'start_time', time.time()) + uptime_seconds = time.time() - start_time + info['uptime'] = str(timedelta(seconds=int(uptime_seconds))) + # Paths download_path = config_manager.get('soulseek.download_path', './downloads') transfer_folder = config_manager.get('soulseek.transfer_path', './Transfer') @@ -3881,6 +3900,8 @@ def get_debug_info(): 'media_server_connected': media_server_cache.get('connected', False), 'soulseek_connected': soulseek_cache.get('connected', False), 'download_source': config_manager.get('download_source.mode', 'soulseek'), + 'tidal_connected': _safe_check(lambda: bool(tidal_client and tidal_client.is_authenticated())), + 'qobuz_connected': _safe_check(lambda: bool(qobuz_enrichment_worker and qobuz_enrichment_worker.client and qobuz_enrichment_worker.client.is_authenticated())), } # Enrichment workers @@ -3891,6 +3912,68 @@ def get_debug_info(): workers[name] = 'paused' if config_manager.get(paused_key, False) else 'active' info['enrichment_workers'] = workers + # Library stats + try: + db = get_db() + lib_stats = db.get_statistics() + info['library'] = { + 'artists': lib_stats.get('artists', 0), + 'albums': lib_stats.get('albums', 0), + 'tracks': lib_stats.get('tracks', 0), + } + except Exception: + info['library'] = {'artists': 0, 'albums': 0, 'tracks': 0} + + # Watchlist count + try: + db = get_db() + info['watchlist_count'] = db.get_watchlist_count() + except Exception: + info['watchlist_count'] = 0 + + # Automation count + try: + db = get_db() + automations = db.get_automations() + info['automations'] = { + 'total': len(automations), + 'enabled': len([a for a in automations if a.get('enabled', False)]), + } + except Exception: + info['automations'] = {'total': 0, 'enabled': 0} + + # Active downloads & syncs (use list() snapshots to avoid RuntimeError from concurrent mutation) + try: + active_downloads = len([bid for bid, bd in list(download_batches.items()) if bd.get('phase') == 'downloading']) + except Exception: + active_downloads = 0 + active_syncs = 0 + try: + for pid, ss in list(sync_states.items()): + if ss.get('status') == 'syncing': + active_syncs += 1 + for uh, st in list(youtube_playlist_states.items()): + if st.get('phase') == 'syncing': + active_syncs += 1 + for pid, st in list(tidal_discovery_states.items()): + if st.get('phase') == 'syncing': + active_syncs += 1 + except Exception: + pass + info['active_downloads'] = active_downloads + info['active_syncs'] = active_syncs + + # Config settings relevant to troubleshooting + info['config'] = { + 'source_mode': config_manager.get('download_source.mode', 'soulseek'), + 'quality_profile': config_manager.get('download_source.quality_profile', 'default'), + 'organization_template': config_manager.get('organization.folder_template', ''), + 'post_processing_enabled': config_manager.get('post_processing.enabled', True), + 'acoustid_enabled': bool(config_manager.get('acoustid.api_key', '')), + 'auto_scan_enabled': config_manager.get('watchlist.auto_scan', False), + 'm3u_export_enabled': config_manager.get('m3u.enabled', False), + } + # Database size db_path = os.path.join('database', 'music_library.db') if os.path.exists(db_path): @@ -3899,22 +3982,50 @@ def get_debug_info(): else: info['database_size'] = 'not found' - # Memory + # Memory & CPU process = psutil.Process(os.getpid()) mem = process.memory_info() info['memory_usage'] = f"{mem.rss / (1024 * 1024):.0f} MB" + info['system_memory'] = f"{psutil.virtual_memory().percent}%" + try: + info['cpu_percent'] = f"{process.cpu_percent(interval=0.1):.1f}%" + except Exception: + info['cpu_percent'] = 'unknown' + info['thread_count'] = process.num_threads() - # Recent log lines - log_path = os.path.join('logs', 'app.log') + # Log lines + log_map = { + 'app': os.path.join('logs', 'app.log'), + 'acoustid': os.path.join('logs', 'acoustid.log'), + 'post_processing': os.path.join('logs', 'post_processing.log'), + 'source_reuse': os.path.join('logs', 'source_reuse.log'), + } + log_path = log_map.get(log_source, log_map['app']) + info['log_source'] = log_source + info['log_lines_requested'] = log_lines info['recent_logs'] = [] if os.path.exists(log_path): try: with open(log_path, 'r', encoding='utf-8', errors='replace') as f: lines = f.readlines() - info['recent_logs'] = [line.rstrip() for line in lines[-20:]] + info['recent_logs'] = [line.rstrip() for line in lines[-log_lines:]] except Exception: info['recent_logs'] = ['(could not read log file)'] + # Available log files + info['available_logs'] = [] + logs_dir = 'logs' + if os.path.isdir(logs_dir): + for fname in sorted(os.listdir(logs_dir)): + if fname.endswith('.log'): + fpath = os.path.join(logs_dir, fname) + size_kb = os.path.getsize(fpath) / 1024 + info['available_logs'].append({ + 'name': fname.replace('.log', ''), + 'file': fname, + 'size': f"{size_kb:.0f} KB" if size_kb < 1024 else f"{size_kb/1024:.1f} MB", + }) + return jsonify(info) diff --git a/webui/static/docs.js b/webui/static/docs.js index aaaa1d43..0d95e9b3 100644 --- a/webui/static/docs.js +++ b/webui/static/docs.js @@ -1726,50 +1726,115 @@ function initializeDocsPage() { }); nav.innerHTML = navHTML; - // Add debug info button to sidebar header + // Add debug info panel to sidebar header const sidebarHeader = document.querySelector('.docs-sidebar-header'); if (sidebarHeader) { - const debugBtn = document.createElement('button'); - debugBtn.className = 'docs-debug-button'; - debugBtn.innerHTML = '📋 Copy Debug Info'; + const debugWrap = document.createElement('div'); + debugWrap.className = 'docs-debug-wrap'; + debugWrap.innerHTML = ` + +
+
+ + +
+
+ + +
+
+ `; + sidebarHeader.appendChild(debugWrap); + + const debugBtn = debugWrap.querySelector('.docs-debug-button'); debugBtn.onclick = async () => { + const logLines = document.getElementById('debug-log-lines').value; + const logSource = document.getElementById('debug-log-source').value; try { debugBtn.textContent = 'Collecting...'; - const resp = await fetch('/api/debug-info'); + const resp = await fetch(`/api/debug-info?lines=${logLines}&log=${logSource}`); const data = await resp.json(); + const ck = '\u2713'; + const ex = '\u2717'; let text = 'SoulSync Debug Info\n'; - text += '===================================\n\n'; - text += `Version: ${data.version}\n`; - text += `OS: ${data.os}${data.docker ? ' (Docker)' : ''}\n`; - text += `Python: ${data.python}\n\n`; + text += '═══════════════════════════════════\n\n'; - text += '-- Services --\n'; - text += `Music Source: ${data.services?.music_source || 'unknown'}\n`; - text += `Spotify: ${data.services?.spotify_connected ? 'Connected' : 'Disconnected'}${data.services?.spotify_rate_limited ? ' (Rate Limited)' : ''}\n`; - text += `Media Server: ${data.services?.media_server_type || 'none'} (${data.services?.media_server_connected ? 'Connected' : 'Disconnected'})\n`; - text += `Soulseek: ${data.services?.soulseek_connected ? 'Connected' : 'Disconnected'}\n`; - text += `Download Source: ${data.services?.download_source || 'unknown'}\n\n`; + text += '── System ──\n'; + text += `Version: ${data.version}\n`; + text += `OS: ${data.os}${data.docker ? ' (Docker)' : ''}\n`; + text += `Python: ${data.python}\n`; + text += `Uptime: ${data.uptime || 'unknown'}\n`; + text += `Memory: ${data.memory_usage || '?'} (system: ${data.system_memory || '?'})\n`; + text += `CPU: ${data.cpu_percent || '?'}\n`; + text += `Threads: ${data.thread_count || '?'}\n\n`; - text += '-- Paths --\n'; - text += `Download: ${data.paths?.download_path || '(not set)'} ${data.paths?.download_path_exists ? '\u2713 exists' : '\u2717 missing'}${data.paths?.download_path_writable ? ' \u2713 writable' : ' \u2717 not writable'}\n`; - text += `Transfer: ${data.paths?.transfer_folder || '(not set)'} ${data.paths?.transfer_folder_exists ? '\u2713 exists' : '\u2717 missing'}${data.paths?.transfer_folder_writable ? ' \u2713 writable' : ' \u2717 not writable'}\n`; - text += `Staging: ${data.paths?.staging_folder || '(not set)'} ${data.paths?.staging_folder_exists ? '\u2713 exists' : '\u2717 missing'}\n\n`; + text += '── Services ──\n'; + text += `Music Source: ${data.services?.music_source || 'unknown'}\n`; + text += `Spotify: ${data.services?.spotify_connected ? ck + ' Connected' : ex + ' Disconnected'}${data.services?.spotify_rate_limited ? ' (RATE LIMITED)' : ''}\n`; + text += `Media Server: ${data.services?.media_server_type || 'none'} ${data.services?.media_server_connected ? ck + ' Connected' : ex + ' Disconnected'}\n`; + text += `Soulseek: ${data.services?.soulseek_connected ? ck + ' Connected' : ex + ' Disconnected'}\n`; + text += `Tidal: ${data.services?.tidal_connected ? ck + ' Connected' : ex + ' Disconnected'}\n`; + text += `Qobuz: ${data.services?.qobuz_connected ? ck + ' Connected' : ex + ' Disconnected'}\n`; + text += `Download Mode: ${data.services?.download_source || 'unknown'}\n\n`; - text += '-- Enrichment Workers --\n'; - if (data.enrichment_workers) { - Object.entries(data.enrichment_workers).forEach(([name, status]) => { - text += `${name}: ${status}\n`; - }); + text += '── Library ──\n'; + text += `Artists: ${data.library?.artists?.toLocaleString() || '0'}\n`; + text += `Albums: ${data.library?.albums?.toLocaleString() || '0'}\n`; + text += `Tracks: ${data.library?.tracks?.toLocaleString() || '0'}\n`; + text += `Database: ${data.database_size || 'unknown'}\n`; + text += `Watchlist: ${data.watchlist_count || 0} artists\n`; + text += `Automations: ${data.automations?.enabled || 0} enabled / ${data.automations?.total || 0} total\n\n`; + + text += '── Active ──\n'; + text += `Downloads: ${data.active_downloads || 0}\n`; + text += `Syncs: ${data.active_syncs || 0}\n\n`; + + text += '── Paths ──\n'; + const pathStatus = (exists, writable) => exists ? (writable ? ck + ' ok' : ck + ' exists ' + ex + ' not writable') : ex + ' missing'; + text += `Download: ${data.paths?.download_path || '(not set)'} [${pathStatus(data.paths?.download_path_exists, data.paths?.download_path_writable)}]\n`; + text += `Transfer: ${data.paths?.transfer_folder || '(not set)'} [${pathStatus(data.paths?.transfer_folder_exists, data.paths?.transfer_folder_writable)}]\n`; + text += `Staging: ${data.paths?.staging_folder || '(not set)'} [${data.paths?.staging_folder_exists ? ck + ' ok' : ex + ' missing'}]\n\n`; + + text += '── Config ──\n'; + if (data.config) { + text += `Source Mode: ${data.config.source_mode || 'unknown'}\n`; + text += `Quality Profile: ${data.config.quality_profile || 'default'}\n`; + text += `Folder Template: ${data.config.organization_template || '(default)'}\n`; + text += `Post-Processing: ${data.config.post_processing_enabled ? 'enabled' : 'disabled'}\n`; + text += `AcoustID: ${data.config.acoustid_enabled ? 'enabled' : 'disabled'}\n`; + text += `Auto Scan: ${data.config.auto_scan_enabled ? 'enabled' : 'disabled'}\n`; + text += `M3U Export: ${data.config.m3u_export_enabled ? 'enabled' : 'disabled'}\n`; } text += '\n'; - text += `Database: ${data.database_size || 'unknown'}\n`; - text += `Memory: ${data.memory_usage || 'unknown'}\n\n`; + text += '── Enrichment Workers ──\n'; + if (data.enrichment_workers) { + const active = [], paused = []; + Object.entries(data.enrichment_workers).forEach(([name, status]) => { + (status === 'active' ? active : paused).push(name); + }); + text += `Active: ${active.length > 0 ? active.join(', ') : 'none'}\n`; + text += `Paused: ${paused.length > 0 ? paused.join(', ') : 'none'}\n`; + } + text += '\n'; - text += '-- Recent Logs (last 20 lines) --\n'; + text += `── Logs: ${data.log_source || 'app'}.log (last ${data.recent_logs?.length || 0} lines) ──\n`; if (data.recent_logs?.length) { data.recent_logs.forEach(line => { text += line + '\n'; }); + } else { + text += '(no log lines)\n'; } await navigator.clipboard.writeText(text); @@ -1785,7 +1850,6 @@ function initializeDocsPage() { setTimeout(() => { debugBtn.innerHTML = '📋 Copy Debug Info'; }, 2000); } }; - sidebarHeader.appendChild(debugBtn); } // Build content diff --git a/webui/static/style.css b/webui/static/style.css index 6467087b..4186cc1a 100644 --- a/webui/static/style.css +++ b/webui/static/style.css @@ -34942,6 +34942,67 @@ tr.tag-diff-same { border-color: rgb(var(--accent-rgb)); } +.docs-debug-wrap { + margin-top: 12px; +} + +.docs-debug-wrap .docs-debug-button { + margin-top: 0; +} + +.docs-debug-options { + display: flex; + gap: 8px; + margin-top: 8px; +} + +.docs-debug-row { + flex: 1; + display: flex; + flex-direction: column; + gap: 3px; +} + +.docs-debug-row label { + font-size: 10px; + color: rgba(255, 255, 255, 0.35); + text-transform: uppercase; + letter-spacing: 0.5px; + font-weight: 600; +} + +.docs-debug-select { + background: rgba(255, 255, 255, 0.06); + border: 1px solid rgba(255, 255, 255, 0.1); + border-radius: 6px; + color: rgba(255, 255, 255, 0.7); + font-size: 11.5px; + padding: 5px 8px; + font-family: 'SF Pro Text', -apple-system, sans-serif; + cursor: pointer; + transition: border-color 0.2s ease; + appearance: none; + -webkit-appearance: none; + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='6'%3E%3Cpath d='M0 0l5 6 5-6z' fill='rgba(255,255,255,0.4)'/%3E%3C/svg%3E"); + background-repeat: no-repeat; + background-position: right 8px center; + padding-right: 22px; +} + +.docs-debug-select:hover { + border-color: rgba(255, 255, 255, 0.2); +} + +.docs-debug-select:focus { + outline: none; + border-color: rgba(var(--accent-rgb), 0.4); +} + +.docs-debug-select option { + background: #1a1a2e; + color: rgba(255, 255, 255, 0.8); +} + /* Toast help link */ .toast-help-link { display: inline-block;