Jellyfin 日志美化 + 双筛选菜单(含模块排序和行数)

美化 Jellyfin 日志并支持“等级+模块”双筛选,模块名按字母排序并附带行数

// ==UserScript==
// @name         Jellyfin 日志美化 + 双筛选菜单(含模块排序和行数)
// @namespace    https://github.com/banned2054/jellyfin-log-beautifier
// @version      1.0
// @description  美化 Jellyfin 日志并支持“等级+模块”双筛选,模块名按字母排序并附带行数
// @match        http://127.0.0.1:8096/System/Logs/Log?name=log_*
// @icon         https://encrypted-tbn0.gstatic.com/images?q=tbn:ANd9GcRzrq2XhIw7in73q4tTa6PTaQRO6KxAJ_XLZwgrZ7i8pkYdoJBk2NMUMBuqal72A0YyAbo&usqp=CAU
// @author       banned
// @homepageURL  https://github.com/banned2054/jellyfin-log-beautifier
// @supportURL   https://github.com/banned2054/jellyfin-log-beautifier/issues
// @grant        none
// ==/UserScript==

(function () {
    'use strict';

    const logSelector = 'pre, code, .log, .terminal, body';
    const container = document.querySelector(logSelector);
    if (!container) return;

    const logText = container.innerText;
    const lines = logText.trim().split('\n');

    const table = document.createElement('table');
    table.style.borderCollapse = 'collapse';
    table.style.width = '100%';
    table.style.fontFamily = 'monospace';
    table.style.fontSize = '14px';

    const thead = document.createElement('thead');
    thead.innerHTML = `
        <tr>
            <th style="border:1px solid #ccc;padding:4px;width:120px">时间</th>
            <th style="border:1px solid #ccc;padding:4px;width:90px">等级</th>
            <th style="border:1px solid #ccc;padding:4px;width:25%">模块</th>
            <th style="border:1px solid #ccc;padding:4px">讯息</th>
        </tr>`;
    table.appendChild(thead);

    const tbody = document.createElement('tbody');
    table.appendChild(tbody);

    const levelMap = {
        '[INF]': {text: '信息', color: '#2c7'},
        '[WRN]': {text: '警告', color: '#fa0'},
        '[ERR]': {text: '错误', color: '#e44'},
        '[DBG]': {text: '调试', color: '#0cf'},
        '[TRC]': {text: '追踪', color: '#ccc'},
        '[FTL]': {text: '严重', color: '#f55'},
        'UNKNOWN': {text: '无', color: '#999'}
    };

    const rows = [];
    const levelSet = new Set();
    const moduleMap = new Map(); // 模块名 -> 计数

    for (const line of lines) {
        const match = line.match(/^\[(.*?)\] \[(\w+)\] \[\d+\] (.*)$/);
        if (!match) continue;

        const [_, datetime, levelTag, fullMsg] = match;

        const dateStr = datetime.slice(0, 10);
        const timeStr = datetime.slice(11, 19);

        const levelKey = `[${levelTag}]`;
        const levelInfo = levelMap[levelKey] || levelMap['UNKNOWN'];
        const levelText = levelInfo.text;
        levelSet.add(levelText);

        let module = fullMsg;
        let message = '';
        const colonIndex = fullMsg.indexOf(':');
        if (colonIndex !== -1) {
            module = fullMsg.slice(0, colonIndex).trim();
            message = fullMsg.slice(colonIndex + 1).trim();
        }

        moduleMap.set(module, (moduleMap.get(module) || 0) + 1);

        const row = document.createElement('tr');
        row.dataset.level = levelText;
        row.dataset.module = module;

        row.innerHTML = `
            <td style="border:1px solid #ccc;padding:4px;text-align:center;">
                <div>${dateStr}</div><div>${timeStr}</div>
            </td>
            <td style="border:1px solid #ccc;padding:4px;text-align:center;color:${levelInfo.color};font-weight:bold;">
                ${levelText}
            </td>
            <td style="border:1px solid #ccc;padding:4px;word-break:break-word;">${module}</td>
            <td style="border:1px solid #ccc;padding:4px;white-space:pre-wrap;">${message}</td>
        `;

        rows.push(row);
    }

    function createFilterPanel(title, values, dataKey) {
        const wrapper = document.createElement('div');
        wrapper.style.position = 'relative';
        wrapper.style.display = 'inline-block';
        wrapper.style.marginRight = '20px';

        const toggleBtn = document.createElement('button');
        toggleBtn.textContent = `筛选 ${title}`;
        toggleBtn.style.padding = '6px 12px';
        toggleBtn.style.cursor = 'pointer';
        toggleBtn.style.marginBottom = '6px';

        const dropdown = document.createElement('div');
        dropdown.style.display = 'none';
        dropdown.style.position = 'absolute';
        dropdown.style.top = '100%';
        dropdown.style.left = '0';
        dropdown.style.backgroundColor = '#333';
        dropdown.style.border = '1px solid #555';
        dropdown.style.padding = '10px';
        dropdown.style.zIndex = '999';
        dropdown.style.color = '#fff';
        dropdown.style.borderRadius = '6px';
        dropdown.style.boxShadow = '0 2px 8px rgba(0,0,0,0.2)';
        dropdown.style.maxHeight = '250px';
        dropdown.style.overflowY = 'auto';

        const checkboxes = [];
        let sortedList = [];

        if (dataKey === 'module') {
            sortedList = [...values.entries()]
                .sort((a, b) => a[0].localeCompare(b[0], 'en'))  // 排序
                .map(([key, count]) => ({label: key, count}));
        } else {
            sortedList = [...values].sort().map(key => ({label: key}));
        }

        // 动态测宽度
        const longest = sortedList.reduce((a, b) =>
                (a.label + (b.count ? ` (${b.count})` : '')).length > (b.label + (b.count ? ` (${b.count})` : '')).length ? a : b
            , {label: ''});
        const tmpSpan = document.createElement('span');
        tmpSpan.style.position = 'absolute';
        tmpSpan.style.visibility = 'hidden';
        tmpSpan.style.fontFamily = 'monospace';
        tmpSpan.textContent = longest.label + ' (999)';
        document.body.appendChild(tmpSpan);
        dropdown.style.minWidth = tmpSpan.offsetWidth + 30 + 'px';
        tmpSpan.remove();

        sortedList.forEach(({label, count}) => {
            const labelEl = document.createElement('label');
            labelEl.style.display = 'block';
            labelEl.style.margin = '4px 0';
            labelEl.style.cursor = 'pointer';

            const checkbox = document.createElement('input');
            checkbox.type = 'checkbox';
            checkbox.checked = true;
            checkbox.value = label;
            checkbox.style.marginRight = '8px';

            checkboxes.push(checkbox);

            const text = count ? `${label} (${count})` : label;
            labelEl.appendChild(checkbox);
            labelEl.appendChild(document.createTextNode(text));
            dropdown.appendChild(labelEl);
        });

        toggleBtn.onclick = () => {
            dropdown.style.display = dropdown.style.display === 'none' ? 'block' : 'none';
        };

        document.addEventListener('click', (e) => {
            if (!wrapper.contains(e.target)) dropdown.style.display = 'none';
        });

        wrapper.appendChild(toggleBtn);
        wrapper.appendChild(dropdown);

        return {wrapper, checkboxes, dataKey};
    }

    const levelPanel = createFilterPanel('等级', levelSet, 'level');
    const modulePanel = createFilterPanel('模块', moduleMap, 'module');

    function filterRows() {
        const selectedLevels = new Set(levelPanel.checkboxes.filter(c => c.checked).map(c => c.value));
        const selectedModules = new Set(modulePanel.checkboxes.filter(c => c.checked).map(c => c.value));

        tbody.innerHTML = '';
        for (const row of rows) {
            if (selectedLevels.has(row.dataset.level) && selectedModules.has(row.dataset.module)) {
                tbody.appendChild(row);
            }
        }
    }

    levelPanel.checkboxes.forEach(cb => cb.addEventListener('change', filterRows));
    modulePanel.checkboxes.forEach(cb => cb.addEventListener('change', filterRows));

    const controls = document.createElement('div');
    controls.style.marginBottom = '1em';
    controls.appendChild(levelPanel.wrapper);
    controls.appendChild(modulePanel.wrapper);

    container.innerHTML = '';
    container.appendChild(controls);
    container.appendChild(table);

    filterRows();
})();