Add live log viewer on Settings → Logs tab

Terminal-style real-time log viewer with:
- Log file selector (app, post-processing, acoustid, source reuse)
- Color-coded log levels (DEBUG gray, INFO blue, WARNING yellow, ERROR red)
- Level filter buttons (All/Debug/Info/Warn/Error)
- Auto-scroll with toggle, copy and clear buttons
- Live updates via WebSocket (2s polling, pushes new lines)
- Initial load fetches last 200 lines via REST API
- 1000-line display cap with oldest lines trimmed

Also fixes Advanced tab settings (Discovery Pool, Security, etc.) being
hidden inside collapsed Library Preferences section body — misplaced
closing div caused them to be invisible.
pull/324/head
Broque Thomas 4 weeks ago
parent c0c38268f5
commit 8b0e619fa1

@ -6256,6 +6256,75 @@ def handle_log_level():
logger.error(f"Error getting log level: {e}")
return jsonify({"success": False, "error": str(e)}), 500
# ===========================
# LIVE LOG VIEWER API
# ===========================
# In-memory ring buffer for live log streaming via WebSocket
_live_log_buffer = []
_live_log_buffer_lock = threading.Lock()
_LIVE_LOG_BUFFER_MAX = 500
@app.route('/api/logs/tail', methods=['GET'])
def get_log_tail():
"""Return the last N lines from a log file, optionally filtered by level."""
log_source = request.args.get('source', 'app')
lines = request.args.get('lines', 200, type=int)
lines = max(10, min(lines, 1000))
level_filter = request.args.get('level', '').upper() # DEBUG, INFO, WARNING, ERROR or empty
log_map = {
'app': os.path.join('logs', 'app.log'),
'post_processing': os.path.join('logs', 'post_processing.log'),
'acoustid': os.path.join('logs', 'acoustid.log'),
'source_reuse': os.path.join('logs', 'source_reuse.log'),
}
log_path = log_map.get(log_source, log_map['app'])
result_lines = []
if os.path.exists(log_path):
try:
with open(log_path, 'r', encoding='utf-8', errors='replace') as f:
all_lines = f.readlines()
# Read more lines than requested so filtering has enough to work with
tail = all_lines[-(lines * 3):] if level_filter else all_lines[-lines:]
for line in tail:
stripped = line.rstrip()
if not stripped:
continue
if level_filter and level_filter in ('DEBUG', 'INFO', 'WARNING', 'ERROR'):
# Match lines like "2026-04-18 12:00:00 - name - INFO - message"
if f' - {level_filter} - ' not in stripped:
continue
result_lines.append(stripped)
# Trim to requested count after filtering
result_lines = result_lines[-lines:]
except Exception as e:
result_lines = [f'Error reading log file: {e}']
# Available log files
available = []
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
available.append({
'key': fname.replace('.log', ''),
'file': fname,
'size': f"{size_kb:.0f} KB" if size_kb < 1024 else f"{size_kb/1024:.1f} MB",
})
return jsonify({
'lines': result_lines,
'source': log_source,
'total': len(result_lines),
'available_logs': available,
})
# ===========================
# AUTOMATIONS API
# ===========================
@ -54390,6 +54459,69 @@ def _emit_tool_progress_loop():
except Exception as e:
logger.debug(f"Error emitting logs: {e}")
def _emit_live_log_loop():
"""Background thread that tails app.log and pushes new lines via WebSocket."""
_last_pos = {} # {source: file_position}
_active_source = 'app'
log_map = {
'app': os.path.join('logs', 'app.log'),
'post_processing': os.path.join('logs', 'post_processing.log'),
'acoustid': os.path.join('logs', 'acoustid.log'),
'source_reuse': os.path.join('logs', 'source_reuse.log'),
}
while not globals().get('IS_SHUTTING_DOWN', False):
socketio.sleep(2)
try:
# Read which source clients want (stored by subscribe handler)
source = getattr(_emit_live_log_loop, '_source', 'app')
log_path = log_map.get(source, log_map['app'])
if not os.path.exists(log_path):
continue
file_size = os.path.getsize(log_path)
last_pos = _last_pos.get(source, 0)
# File was truncated or rotated
if file_size < last_pos:
last_pos = 0
if file_size == last_pos:
continue # No new data
new_lines = []
with open(log_path, 'r', encoding='utf-8', errors='replace') as f:
f.seek(last_pos)
for line in f:
stripped = line.rstrip()
if stripped:
new_lines.append(stripped)
_last_pos[source] = f.tell()
if new_lines:
# Cap at 50 lines per push to avoid flooding
socketio.emit('logs:live', {
'lines': new_lines[-50:],
'source': source,
})
except Exception as e:
logger.debug(f"Error in live log emitter: {e}")
_emit_live_log_loop._source = 'app'
@socketio.on('logs:subscribe')
def handle_logs_subscribe(data):
"""Client subscribes to live log stream with optional source."""
source = data.get('source', 'app')
_emit_live_log_loop._source = source
join_room('logs:live')
@socketio.on('logs:unsubscribe')
def handle_logs_unsubscribe(data):
leave_room('logs:live')
@socketio.on('sync:subscribe')
def handle_sync_subscribe(data):
for pid in data.get('playlist_ids', []):
@ -54680,7 +54812,9 @@ def start_runtime_services():
socketio.start_background_task(_hydrabase_reconnect_loop)
# API Rate Monitor — 1s push for speedometer gauges
socketio.start_background_task(_emit_rate_monitor_loop)
print("WebSocket emitters started (Phase 1-7: global/dashboard/enrichment/tools/sync/automations/repair + rate monitor)")
# Live log tail — streams new log lines to the log viewer
socketio.start_background_task(_emit_live_log_loop)
print("WebSocket emitters started (Phase 1-7: global/dashboard/enrichment/tools/sync/automations/repair + rate monitor + live logs)")
_runtime_started = True

@ -3843,6 +3843,7 @@
<button class="stg-tab" data-tab="library" onclick="switchSettingsTab('library')">Library</button>
<button class="stg-tab" data-tab="appearance" onclick="switchSettingsTab('appearance')">Appearance</button>
<button class="stg-tab" data-tab="advanced" onclick="switchSettingsTab('advanced')">Advanced</button>
<button class="stg-tab" data-tab="logs" onclick="switchSettingsTab('logs')">Logs</button>
</div>
<!-- Settings Panels -->
<div class="settings-columns">
@ -5484,6 +5485,8 @@
</div>
</div>
</div><!-- end Library Preferences body -->
<!-- Security Settings -->
<div class="settings-group" data-stg="advanced">
<h3>🔒 Security</h3>
@ -5750,8 +5753,6 @@
</div>
</div>
</div><!-- end Library Preferences body -->
<!-- Logging Settings -->
<div class="settings-group" data-stg="advanced">
<h3>Logging</h3>
@ -5847,6 +5848,44 @@
</div>
</div>
<!-- ═══ LOGS TAB ═══ -->
<div class="settings-group" data-stg="logs" style="max-width:100%;">
<div class="log-viewer-header">
<div class="log-viewer-controls">
<select id="log-viewer-source" class="log-viewer-select" onchange="_logViewerChangeSource()">
<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 class="log-viewer-filters">
<button class="log-filter-btn active" data-level="" onclick="_logViewerFilterLevel(this)">All</button>
<button class="log-filter-btn lvl-debug" data-level="DEBUG" onclick="_logViewerFilterLevel(this)">Debug</button>
<button class="log-filter-btn lvl-info" data-level="INFO" onclick="_logViewerFilterLevel(this)">Info</button>
<button class="log-filter-btn lvl-warning" data-level="WARNING" onclick="_logViewerFilterLevel(this)">Warn</button>
<button class="log-filter-btn lvl-error" data-level="ERROR" onclick="_logViewerFilterLevel(this)">Error</button>
</div>
</div>
<div class="log-viewer-actions">
<button class="log-action-btn" onclick="_logViewerCopy()" title="Copy visible logs">Copy</button>
<button class="log-action-btn" onclick="_logViewerClear()" title="Clear display">Clear</button>
<label class="log-autoscroll-label">
<input type="checkbox" id="log-viewer-autoscroll" checked>
Auto-scroll
</label>
</div>
</div>
<div class="log-viewer-terminal" id="log-viewer-terminal">
<div class="log-viewer-lines" id="log-viewer-lines">
<div class="log-line log-info">Initializing log viewer...</div>
</div>
</div>
<div class="log-viewer-status">
<span id="log-viewer-line-count">0 lines</span>
<span id="log-viewer-live-indicator" class="log-live-dot">● Live</span>
</div>
</div>
<!-- Save Button -->
<div class="settings-actions">
<button class="save-button" id="save-settings">Save Settings</button>

@ -3602,6 +3602,11 @@ const WHATS_NEW = {
'2.32': [
// --- April 18, 2026 ---
{ date: 'April 18, 2026' },
{ title: 'Live Log Viewer', desc: 'New Logs tab on the Settings page — real-time terminal-style log viewer with color-coded log levels. Filter by DEBUG/INFO/WARNING/ERROR, switch between log files (app, post-processing, acoustid, source reuse), auto-scroll, copy, and clear. Live updates via WebSocket', page: 'settings' },
{ title: 'ReplayGain Post-Processing', desc: 'Optional ReplayGain tag analysis during post-processing. Enable in Settings → Library → Post-Processing. Analyzes loudness via ffmpeg and writes track-level gain/peak tags. Runs before lossy copy so both files get tagged. Off by default' },
{ title: 'Fix Your Albums Using Playlist Modal', desc: 'Albums in the Discover page "Your Albums" section now open with the proper album-style download modal instead of the playlist-style modal. Shows artist image, album art, and uses album download context for correct file organization', page: 'discover' },
{ title: 'Fix Tool Help Modal Not Closable', desc: 'The help "?" modal on automation triggers/actions could not be closed if the Tools page hadn\'t been visited first. Close button, backdrop click, and Escape key now work from any page' },
{ title: 'Fix Spotify OAuth Port Steal in Docker', desc: 'On fresh installs, Spotify auth probe silently started an HTTP server that stole port 8008 (crash loop) or bound loopback-only on 8888 (unreachable from host). Now skips the probe when no cached token exists' },
{ title: 'Genre Whitelist', desc: 'Filter junk genre tags (artist names, radio shows, playlist names) from enrichment. Enable strict mode in Settings → Library Preferences → Genre Whitelist. 272 curated default genres, fully customizable — add, remove, search, reset. Applied across all 10 enrichment sources. Off by default', page: 'settings' },
{ title: 'Per-Artist Watchlist Scan Source', desc: 'Override which metadata provider (Spotify, Deezer, Apple Music, Discogs) is used when scanning a specific watchlist artist for new releases. Source selector in the artist config modal only shows providers the artist has enrichment matches for. Global default unchanged unless explicitly overridden', page: 'watchlist' },
{ title: 'Standalone Full Refresh', desc: 'Full Refresh now works for SoulSync Standalone mode — clears all soulsync library records and rebuilds from audio file tags in the output folder. Previously did nothing for standalone users', page: 'tools' },

@ -5755,6 +5755,12 @@ function switchSettingsTab(tab) {
if (tab === 'advanced' && typeof loadDbMaintenanceInfo === 'function') {
try { loadDbMaintenanceInfo(); } catch (e) { }
}
// Initialize live log viewer when switching to Logs tab
if (tab === 'logs') {
_logViewerInit();
} else {
_logViewerStop();
}
}
function toggleStgService(el) {
@ -6974,6 +6980,139 @@ function _collectGenreWhitelist() {
return _genreWhitelistCache;
}
// ── Live Log Viewer ──
let _logViewerActive = false;
let _logViewerFilter = '';
let _logViewerSource = 'app';
const _LOG_MAX_LINES = 1000;
async function _logViewerInit() {
if (_logViewerActive) return;
_logViewerActive = true;
_logViewerSource = document.getElementById('log-viewer-source')?.value || 'app';
// Fetch initial tail
try {
const params = new URLSearchParams({ source: _logViewerSource, lines: 200 });
if (_logViewerFilter) params.set('level', _logViewerFilter);
const resp = await fetch(`/api/logs/tail?${params}`);
const data = await resp.json();
if (data.lines) {
const container = document.getElementById('log-viewer-lines');
if (container) {
container.innerHTML = '';
_logViewerAppendLines(data.lines);
}
}
} catch (e) {
console.warn('Failed to load initial logs:', e);
}
// Subscribe to live updates
if (typeof socket !== 'undefined' && socket && socket.connected) {
socket.emit('logs:subscribe', { source: _logViewerSource });
socket.on('logs:live', _logViewerOnLive);
}
}
function _logViewerStop() {
if (!_logViewerActive) return;
_logViewerActive = false;
if (typeof socket !== 'undefined' && socket) {
socket.off('logs:live', _logViewerOnLive);
socket.emit('logs:unsubscribe', {});
}
}
function _logViewerOnLive(data) {
if (!_logViewerActive || !data.lines) return;
if (data.source !== _logViewerSource) return;
const filtered = _logViewerFilter
? data.lines.filter(l => l.includes(` - ${_logViewerFilter} - `))
: data.lines;
if (filtered.length > 0) _logViewerAppendLines(filtered);
}
function _logViewerAppendLines(lines) {
const container = document.getElementById('log-viewer-lines');
if (!container) return;
const autoScroll = document.getElementById('log-viewer-autoscroll')?.checked;
const terminal = document.getElementById('log-viewer-terminal');
for (const line of lines) {
const div = document.createElement('div');
div.className = 'log-line ' + _logViewerGetClass(line);
div.textContent = line;
container.appendChild(div);
}
// Trim old lines
while (container.children.length > _LOG_MAX_LINES) {
container.removeChild(container.firstChild);
}
// Update count
const countEl = document.getElementById('log-viewer-line-count');
if (countEl) countEl.textContent = `${container.children.length} lines`;
// Auto-scroll
if (autoScroll && terminal) {
terminal.scrollTop = terminal.scrollHeight;
}
}
function _logViewerGetClass(line) {
if (line.includes(' - DEBUG - ')) return 'log-debug';
if (line.includes(' - INFO - ')) return 'log-info';
if (line.includes(' - WARNING - ')) return 'log-warning';
if (line.includes(' - ERROR - ') || line.includes(' - CRITICAL - ')) return 'log-error';
return 'log-plain';
}
async function _logViewerChangeSource() {
_logViewerStop();
_logViewerSource = document.getElementById('log-viewer-source')?.value || 'app';
const container = document.getElementById('log-viewer-lines');
if (container) container.innerHTML = '<div class="log-line log-info">Loading...</div>';
await _logViewerInit();
}
function _logViewerFilterLevel(btn) {
document.querySelectorAll('.log-filter-btn').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
_logViewerFilter = btn.dataset.level || '';
// Reload with filter
_logViewerStop();
const container = document.getElementById('log-viewer-lines');
if (container) container.innerHTML = '';
_logViewerInit();
}
function _logViewerCopy() {
const container = document.getElementById('log-viewer-lines');
if (!container) return;
const text = Array.from(container.children).map(el => el.textContent).join('\n');
if (navigator.clipboard && window.isSecureContext) {
navigator.clipboard.writeText(text).then(() => showToast('Logs copied', 'success'));
} else {
const ta = document.createElement('textarea');
ta.value = text;
ta.style.cssText = 'position:fixed;left:-9999px';
document.body.appendChild(ta);
ta.select();
document.execCommand('copy');
document.body.removeChild(ta);
showToast('Logs copied', 'success');
}
}
function _logViewerClear() {
const container = document.getElementById('log-viewer-lines');
if (container) container.innerHTML = '';
const countEl = document.getElementById('log-viewer-line-count');
if (countEl) countEl.textContent = '0 lines';
}
// ── Database Maintenance ──
async function loadDbMaintenanceInfo() {
try {

@ -17213,6 +17213,136 @@ body.helper-mode-active #dashboard-activity-feed:hover {
color: #ef5350;
}
/* ── Live Log Viewer ── */
.log-viewer-header {
display: flex;
justify-content: space-between;
align-items: center;
flex-wrap: wrap;
gap: 10px;
margin-bottom: 10px;
}
.log-viewer-controls {
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
}
.log-viewer-select {
background: rgba(255, 255, 255, 0.06);
border: 1px solid rgba(255, 255, 255, 0.12);
border-radius: 6px;
color: #e0e0e0;
padding: 6px 10px;
font-size: 13px;
font-family: 'JetBrains Mono', 'Fira Code', 'Cascadia Code', monospace;
}
.log-viewer-filters {
display: flex;
gap: 4px;
}
.log-filter-btn {
padding: 4px 10px;
border-radius: 4px;
border: 1px solid rgba(255, 255, 255, 0.1);
background: transparent;
color: rgba(255, 255, 255, 0.5);
font-size: 12px;
cursor: pointer;
transition: all 0.15s;
}
.log-filter-btn:hover { background: rgba(255, 255, 255, 0.06); color: #fff; }
.log-filter-btn.active { background: rgba(255, 255, 255, 0.1); color: #fff; border-color: rgba(255, 255, 255, 0.25); }
.log-filter-btn.lvl-debug.active { color: #888; border-color: #666; }
.log-filter-btn.lvl-info.active { color: #4fc3f7; border-color: #4fc3f7; }
.log-filter-btn.lvl-warning.active { color: #ffd54f; border-color: #ffd54f; }
.log-filter-btn.lvl-error.active { color: #ef5350; border-color: #ef5350; }
.log-viewer-actions {
display: flex;
align-items: center;
gap: 8px;
}
.log-action-btn {
padding: 4px 12px;
border-radius: 4px;
border: 1px solid rgba(255, 255, 255, 0.1);
background: transparent;
color: rgba(255, 255, 255, 0.6);
font-size: 12px;
cursor: pointer;
transition: all 0.15s;
}
.log-action-btn:hover { background: rgba(255, 255, 255, 0.08); color: #fff; }
.log-autoscroll-label {
display: flex;
align-items: center;
gap: 4px;
font-size: 12px;
color: rgba(255, 255, 255, 0.5);
cursor: pointer;
}
.log-viewer-terminal {
background: #0a0a14;
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 8px;
height: 520px;
overflow-y: auto;
overflow-x: hidden;
padding: 12px;
font-family: 'JetBrains Mono', 'Fira Code', 'Cascadia Code', 'Courier New', monospace;
font-size: 12px;
line-height: 1.5;
}
.log-viewer-terminal::-webkit-scrollbar { width: 6px; }
.log-viewer-terminal::-webkit-scrollbar-track { background: transparent; }
.log-viewer-terminal::-webkit-scrollbar-thumb { background: rgba(255,255,255,0.12); border-radius: 3px; }
.log-viewer-lines {
word-break: break-all;
}
.log-line {
padding: 1px 0;
white-space: pre-wrap;
word-break: break-all;
}
.log-debug { color: #666; }
.log-info { color: #4fc3f7; }
.log-warning { color: #ffd54f; }
.log-error { color: #ef5350; font-weight: 500; }
.log-plain { color: #aaa; }
.log-viewer-status {
display: flex;
justify-content: space-between;
align-items: center;
margin-top: 6px;
font-size: 11px;
color: rgba(255, 255, 255, 0.35);
}
.log-live-dot {
color: #4caf50;
animation: log-pulse 2s ease-in-out infinite;
}
@keyframes log-pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.4; }
}
.config-options {
display: flex;
flex-direction: column;

Loading…
Cancel
Save