diff --git a/web_server.py b/web_server.py
index ab7f94e7..57f0c54e 100644
--- a/web_server.py
+++ b/web_server.py
@@ -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
diff --git a/webui/index.html b/webui/index.html
index 29442291..605c8b9c 100644
--- a/webui/index.html
+++ b/webui/index.html
@@ -3843,6 +3843,7 @@
+
@@ -5484,6 +5485,8 @@
+
+
🔒 Security
@@ -5750,8 +5753,6 @@
-
-
Logging
@@ -5847,6 +5848,44 @@
+
+
+
+
+
+
Initializing log viewer...
+
+
+
+ 0 lines
+ ● Live
+
+
+
diff --git a/webui/static/helper.js b/webui/static/helper.js
index a34f4f14..eaf2775c 100644
--- a/webui/static/helper.js
+++ b/webui/static/helper.js
@@ -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' },
diff --git a/webui/static/script.js b/webui/static/script.js
index a20d8c69..319a8187 100644
--- a/webui/static/script.js
+++ b/webui/static/script.js
@@ -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 = '
Loading...
';
+ 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 {
diff --git a/webui/static/style.css b/webui/static/style.css
index 3c802c1d..2801b8d3 100644
--- a/webui/static/style.css
+++ b/webui/static/style.css
@@ -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;