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
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)

View file

@ -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 = '&#x1F4CB; Copy Debug Info';
const debugWrap = document.createElement('div');
debugWrap.className = 'docs-debug-wrap';
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 () => {
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 = '&#x1F4CB; Copy Debug Info'; }, 2000);
}
};
sidebarHeader.appendChild(debugBtn);
}
// Build content

View file

@ -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;