Expand debug info with library stats, service status, config, and configurable log output

This commit is contained in:
Broque Thomas 2026-03-13 08:47:04 -07:00
parent fc05412a13
commit f9d80606e3
3 changed files with 268 additions and 32 deletions

View file

@ -3839,12 +3839,26 @@ def get_system_stats():
return jsonify({'error': str(e)}), 500 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') @app.route('/api/debug-info')
def get_debug_info(): def get_debug_info():
"""Collect system diagnostics for troubleshooting support requests.""" """Collect system diagnostics for troubleshooting support requests."""
import platform import platform
import sys import sys
import psutil 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 = {} info = {}
@ -3854,6 +3868,11 @@ def get_debug_info():
info['python'] = sys.version.split()[0] info['python'] = sys.version.split()[0]
info['docker'] = os.path.exists('/.dockerenv') 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 # Paths
download_path = config_manager.get('soulseek.download_path', './downloads') download_path = config_manager.get('soulseek.download_path', './downloads')
transfer_folder = config_manager.get('soulseek.transfer_path', './Transfer') 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), 'media_server_connected': media_server_cache.get('connected', False),
'soulseek_connected': soulseek_cache.get('connected', False), 'soulseek_connected': soulseek_cache.get('connected', False),
'download_source': config_manager.get('download_source.mode', 'soulseek'), '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 # Enrichment workers
@ -3891,6 +3912,68 @@ def get_debug_info():
workers[name] = 'paused' if config_manager.get(paused_key, False) else 'active' workers[name] = 'paused' if config_manager.get(paused_key, False) else 'active'
info['enrichment_workers'] = workers 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 # Database size
db_path = os.path.join('database', 'music_library.db') db_path = os.path.join('database', 'music_library.db')
if os.path.exists(db_path): if os.path.exists(db_path):
@ -3899,22 +3982,50 @@ def get_debug_info():
else: else:
info['database_size'] = 'not found' info['database_size'] = 'not found'
# Memory # Memory & CPU
process = psutil.Process(os.getpid()) process = psutil.Process(os.getpid())
mem = process.memory_info() mem = process.memory_info()
info['memory_usage'] = f"{mem.rss / (1024 * 1024):.0f} MB" 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 lines
log_path = os.path.join('logs', 'app.log') 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'] = [] info['recent_logs'] = []
if os.path.exists(log_path): if os.path.exists(log_path):
try: try:
with open(log_path, 'r', encoding='utf-8', errors='replace') as f: with open(log_path, 'r', encoding='utf-8', errors='replace') as f:
lines = f.readlines() 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: except Exception:
info['recent_logs'] = ['(could not read log file)'] 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) return jsonify(info)

View file

@ -1726,50 +1726,115 @@ function initializeDocsPage() {
}); });
nav.innerHTML = navHTML; nav.innerHTML = navHTML;
// Add debug info button to sidebar header // Add debug info panel to sidebar header
const sidebarHeader = document.querySelector('.docs-sidebar-header'); const sidebarHeader = document.querySelector('.docs-sidebar-header');
if (sidebarHeader) { if (sidebarHeader) {
const debugBtn = document.createElement('button'); const debugWrap = document.createElement('div');
debugBtn.className = 'docs-debug-button'; debugWrap.className = 'docs-debug-wrap';
debugBtn.innerHTML = '&#x1F4CB; Copy Debug Info'; debugWrap.innerHTML = `
<button class="docs-debug-button">&#x1F4CB; Copy Debug Info</button>
<div class="docs-debug-options">
<div class="docs-debug-row">
<label>Log lines</label>
<select class="docs-debug-select" id="debug-log-lines">
<option value="20">20</option>
<option value="50">50</option>
<option value="100" selected>100</option>
<option value="200">200</option>
<option value="500">500</option>
</select>
</div>
<div class="docs-debug-row">
<label>Log source</label>
<select class="docs-debug-select" id="debug-log-source">
<option value="app">app.log</option>
<option value="post_processing">post_processing.log</option>
<option value="acoustid">acoustid.log</option>
<option value="source_reuse">source_reuse.log</option>
</select>
</div>
</div>
`;
sidebarHeader.appendChild(debugWrap);
const debugBtn = debugWrap.querySelector('.docs-debug-button');
debugBtn.onclick = async () => { debugBtn.onclick = async () => {
const logLines = document.getElementById('debug-log-lines').value;
const logSource = document.getElementById('debug-log-source').value;
try { try {
debugBtn.textContent = 'Collecting...'; 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 data = await resp.json();
const ck = '\u2713';
const ex = '\u2717';
let text = 'SoulSync Debug Info\n'; let text = 'SoulSync Debug Info\n';
text += '===================================\n\n'; text += '═══════════════════════════════════\n\n';
text += `Version: ${data.version}\n`;
text += `OS: ${data.os}${data.docker ? ' (Docker)' : ''}\n`;
text += `Python: ${data.python}\n\n`;
text += '-- Services --\n'; text += '── System ──\n';
text += `Music Source: ${data.services?.music_source || 'unknown'}\n`; text += `Version: ${data.version}\n`;
text += `Spotify: ${data.services?.spotify_connected ? 'Connected' : 'Disconnected'}${data.services?.spotify_rate_limited ? ' (Rate Limited)' : ''}\n`; text += `OS: ${data.os}${data.docker ? ' (Docker)' : ''}\n`;
text += `Media Server: ${data.services?.media_server_type || 'none'} (${data.services?.media_server_connected ? 'Connected' : 'Disconnected'})\n`; text += `Python: ${data.python}\n`;
text += `Soulseek: ${data.services?.soulseek_connected ? 'Connected' : 'Disconnected'}\n`; text += `Uptime: ${data.uptime || 'unknown'}\n`;
text += `Download Source: ${data.services?.download_source || 'unknown'}\n\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 += '── Services ──\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 += `Music Source: ${data.services?.music_source || 'unknown'}\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 += `Spotify: ${data.services?.spotify_connected ? ck + ' Connected' : ex + ' Disconnected'}${data.services?.spotify_rate_limited ? ' (RATE LIMITED)' : ''}\n`;
text += `Staging: ${data.paths?.staging_folder || '(not set)'} ${data.paths?.staging_folder_exists ? '\u2713 exists' : '\u2717 missing'}\n\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'; text += '── Library ──\n';
if (data.enrichment_workers) { text += `Artists: ${data.library?.artists?.toLocaleString() || '0'}\n`;
Object.entries(data.enrichment_workers).forEach(([name, status]) => { text += `Albums: ${data.library?.albums?.toLocaleString() || '0'}\n`;
text += `${name}: ${status}\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 += '\n';
text += `Database: ${data.database_size || 'unknown'}\n`; text += '── Enrichment Workers ──\n';
text += `Memory: ${data.memory_usage || 'unknown'}\n\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) { if (data.recent_logs?.length) {
data.recent_logs.forEach(line => { text += line + '\n'; }); data.recent_logs.forEach(line => { text += line + '\n'; });
} else {
text += '(no log lines)\n';
} }
await navigator.clipboard.writeText(text); await navigator.clipboard.writeText(text);
@ -1785,7 +1850,6 @@ function initializeDocsPage() {
setTimeout(() => { debugBtn.innerHTML = '&#x1F4CB; Copy Debug Info'; }, 2000); setTimeout(() => { debugBtn.innerHTML = '&#x1F4CB; Copy Debug Info'; }, 2000);
} }
}; };
sidebarHeader.appendChild(debugBtn);
} }
// Build content // Build content

View file

@ -34942,6 +34942,67 @@ tr.tag-diff-same {
border-color: rgb(var(--accent-rgb)); 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 */
.toast-help-link { .toast-help-link {
display: inline-block; display: inline-block;