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;