Radarr/Sonarr - Interactive Search NFO Viewer (only supports omgwtfnzbs)

Interactive Search NFO Viewer. 支持 ASCII Art 装饰风格的 NFO,深度优化 MediaInfo/BDInfo 字幕解析,严格限制 API 调用。支持指定特定站点运行。

您需要先安裝使用者腳本管理器擴展,如 TampermonkeyGreasemonkeyViolentmonkey 之後才能安裝該腳本。

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyViolentmonkey 後才能安裝該腳本。

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyViolentmonkey 後才能安裝該腳本。

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyUserscripts 後才能安裝該腳本。

你需要先安裝一款使用者腳本管理器擴展,比如 Tampermonkey,才能安裝此腳本

您需要先安裝使用者腳本管理器擴充功能後才能安裝該腳本。

(我已經安裝了使用者腳本管理器,讓我安裝!)

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

(我已經安裝了使用者樣式管理器,讓我安裝!)

// ==UserScript==
// @name         Radarr/Sonarr - Interactive Search NFO Viewer (only supports omgwtfnzbs)
// @name:zh-CN   Radarr/Sonarr - Interactive Search NFO Viewer (only supports omgwtfnzbs)
// @namespace    http://tampermonkey.net/
// @version      2.9.2
// @description  Interactive Search NFO Viewer. 支持 ASCII Art 装饰风格的 NFO,深度优化 MediaInfo/BDInfo 字幕解析,严格限制 API 调用。支持指定特定站点运行。
// @description:zh-CN Interactive Search NFO Viewer. 支持 ASCII Art 装饰风格的 NFO,深度优化 MediaInfo/BDInfo 字幕解析,严格限制 API 调用。支持指定特定站点运行。
// @description:en Interactive Search NFO Viewer. Supports ASCII Art decorated NFO, optimizes MediaInfo/BDInfo subtitle parsing, strictly limits API calls, and supports running on specific sites only.
// @author       Gemini 3
// @license      MIT
// @match        http://localhost:8989/*
// @match        http://localhost:7878/*
// @grant        GM_xmlhttpRequest
// @grant        GM_addStyle
// @connect      api.omgwtfnzbs.org
// @connect      omgwtfnzbs.org
// @connect      localhost
// @connect      127.0.0.1
// @run-at       document-idle
// ==/UserScript==

(function () {
    'use strict';

    /************************** 默认配置 **************************/
    const DEFAULTS = {
        USE_PROXY: false,
        PROXY_URL: "http://localhost:8989/proxy?url=",
        API_KEY: "",
        CACHE_TTL_HOURS: 24,
        BUTTON_TEXT: "NFO",
        // 默认关心: 中文(简/繁)、英文
        TARGET_LANGS: ['CHS', 'CHT', 'ENG']
    };
    const SETTINGS_KEY = 'tm_nfo_settings_v3';

    /************************** 基础工具 **************************/
    function loadSettings() {
        try {
            const raw = localStorage.getItem(SETTINGS_KEY);
            if (!raw) return { ...DEFAULTS };
            return Object.assign({}, DEFAULTS, JSON.parse(raw));
        } catch (e) { return { ...DEFAULTS }; }
    }
    function saveSettings(obj) {
        const toSave = Object.assign({}, loadSettings(), obj);
        localStorage.setItem(SETTINGS_KEY, JSON.stringify(toSave));
    }
    let SETTINGS = loadSettings();

    // 语言映射表
    const LANG_MAP = {
        'chinese': 'CHS', 'chi': 'CHS', 'zho': 'CHS', 'chs': 'CHS', 'sc': 'CHS', 'cn': 'CHS',
        'simplified': 'CHS', 'hans': 'CHS', 'mandarin': 'CHS', 'sg': 'CHS',
        'traditional': 'CHT', 'cht': 'CHT', 'tc': 'CHT', 'tw': 'CHT', 'hant': 'CHT',
        'cantonese': 'CHT', 'hong': 'CHT', 'kong': 'CHT', 'hk': 'CHT',
        'english': 'ENG', 'eng': 'ENG', 'en': 'ENG', 'ing': 'ENG', 'us': 'ENG', 'uk': 'ENG',
        'japanese': 'JPN', 'jap': 'JPN', 'jpn': 'JPN', 'jp': 'JPN',
        'korean': 'KOR', 'kor': 'KOR', 'kr': 'KOR',
        'french': 'FRE', 'fre': 'FRE', 'fr': 'FRE', 'fra': 'FRE', 'vff': 'FRE',
        'spanish': 'SPA', 'spa': 'SPA', 'es': 'SPA', 'esp': 'SPA', 'latin': 'SPA',
        'german': 'GER', 'ger': 'GER', 'de': 'GER', 'deutsch': 'GER',
        'italian': 'ITA', 'ita': 'ITA', 'it': 'ITA',
        'russian': 'RUS', 'rus': 'RUS', 'ru': 'RUS',
        'portuguese': 'POR', 'por': 'POR', 'pt': 'POR', 'br': 'POR', 'brazil': 'POR',
        'dutch': 'DUT', 'dut': 'DUT', 'nl': 'DUT',
        'polish': 'POL', 'pol': 'POL', 'pl': 'POL',
        'bulgarian': 'BUL', 'bul': 'BUL', 'bg': 'BUL',
        'catalan': 'CAT', 'cat': 'CAT',
        'czech': 'CZE', 'cze': 'CZE', 'cz': 'CZE',
        'danish': 'DAN', 'dan': 'DAN', 'da': 'DAN',
        'greek': 'GRE', 'gre': 'GRE', 'el': 'GRE',
        'estonian': 'EST', 'est': 'EST', 'et': 'EST',
        'finnish': 'FIN', 'fin': 'FIN', 'fi': 'FIN',
        'hebrew': 'HEB', 'heb': 'HEB',
        'croatian': 'HRV', 'hrv': 'HRV', 'hr': 'HRV',
        'hungarian': 'HUN', 'hun': 'HUN', 'hu': 'HUN',
        'indonesian': 'IND', 'ind': 'IND', 'id': 'IND',
        'icelandic': 'ICE', 'ice': 'ICE', 'is': 'ICE',
        'lithuanian': 'LIT', 'lit': 'LIT', 'lt': 'LIT',
        'latvian': 'LAT', 'lat': 'LAT', 'lv': 'LAT',
        'macedonian': 'MAC', 'mac': 'MAC', 'mk': 'MAC',
        'malay': 'MAL', 'may': 'MAL', 'ms': 'MAL', 'my': 'MAL',
        'norwegian': 'NOR', 'nor': 'NOR', 'no': 'NOR', 'bokmal': 'NOR',
        'romanian': 'ROM', 'rum': 'ROM', 'ro': 'ROM',
        'slovak': 'SLO', 'slo': 'SLO', 'sk': 'SLO',
        'slovenian': 'SLV', 'slv': 'SLV', 'si': 'SLV',
        'serbian': 'SRP', 'srp': 'SRP', 'sr': 'SRP',
        'swedish': 'SWE', 'swe': 'SWE', 'sv': 'SWE',
        'thai': 'THA', 'tha': 'THA', 'th': 'THA',
        'turkish': 'TUR', 'tur': 'TUR', 'tr': 'TUR',
        'ukrainian': 'UKR', 'ukr': 'UKR', 'ua': 'UKR',
        'vietnamese': 'VIE', 'vie': 'VIE', 'vi': 'VIE'
    };

    /************************** CSS 样式 **************************/
    GM_addStyle(`
        .tm-nfo-col-header { width: 70px; text-align: center !important; }
        .tm-nfo-col-cell { text-align: center !important; vertical-align: middle !important; }

        .tm_nfo_button_v3 {
            display: inline-block; box-sizing: border-box;
            background-color: #3b82f6 !important; color: #ffffff !important;
            border: 1px solid #2563eb !important; border-radius: 4px;
            padding: 4px 10px; font-size: 11px; font-weight: 700;
            cursor: pointer; line-height: 1.2; box-shadow: 0 1px 2px rgba(0,0,0,0.15);
            transition: all 0.2s ease; text-transform: uppercase; font-family: inherit;
        }
        .tm_nfo_button_v3:hover { background-color: #2563eb !important; transform: translateY(-1px); box-shadow: 0 3px 6px rgba(0,0,0,0.2); }
        .tm_nfo_button_v3:active { transform: translateY(0); }
        .tm_nfo_button_v3:disabled {
            background-color: #64748b !important; border-color: #475569 !important;
            color: #cbd5e1 !important; cursor: not-allowed; transform: none; box-shadow: none;
        }
        .tm_nfo_button_nonfo {
            background-color: #334155 !important; border-color: #1e293b !important; opacity: 0.7; color: #94a3b8 !important; cursor: default !important;
        }
        .tm_nfo_badge_dot {
            display: block; width: 8px; height: 8px; border-radius: 50%;
            position: absolute; top: -3px; right: -3px; border: 1px solid #fff;
        }
        .tm_badget_hit { background-color: #4ade80; box-shadow: 0 0 4px #4ade80; }
        .tm_badget_miss { background-color: #facc15; }

        .tm_sub_tag {
            display: inline-block; padding: 2px 6px; border-radius: 4px;
            font-size: 11px; font-weight: bold; margin-right: 6px;
            background: #374151; color: #d1d5db; border: 1px solid #4b5563;
        }
        .tm_sub_tag.highlight {
            background: #059669; color: #ffffff; border-color: #10b981;
        }
    `);

    function sleep(ms) { return new Promise(r => setTimeout(r, ms)); }
    function cacheKey(id) { return `tm_nfo_c_${id}`; }

    function setCache(id, text) {
        try { localStorage.setItem(cacheKey(id), JSON.stringify({ t: Date.now(), text })); } catch (e) {}
    }

    function getCache(id) {
        try {
            const raw = localStorage.getItem(cacheKey(id));
            if (!raw) return null;
            const obj = JSON.parse(raw);
            if ((Date.now() - obj.t) / (36e5) > SETTINGS.CACHE_TTL_HOURS) { localStorage.removeItem(cacheKey(id)); return null; }
            return obj.text;
        } catch (e) { return null; }
    }

    function gmFetch(url) {
        if (SETTINGS.API_KEY && !url.includes('apikey=')) {
            const separator = url.includes('?') ? '&' : '?';
            url = `${url}${separator}apikey=${SETTINGS.API_KEY}`;
        }
        const finalUrl = SETTINGS.USE_PROXY ? (SETTINGS.PROXY_URL + encodeURIComponent(url)) : url;
        return new Promise((resolve, reject) => {
            GM_xmlhttpRequest({
                method: 'GET', url: finalUrl, timeout: 20000,
                onload: res => resolve(res),
                onerror: err => reject(err),
                ontimeout: () => reject(new Error('timeout'))
            });
        });
    }

    /************************** NFO 解析器 **************************/
    function stripTags(s) { return s.replace(/<\s*br\s*\/?>/gi, '\n').replace(/<\/?[^>]+(>|$)/g, ""); }
    function decodeHtmlEntities(str) {
        if (!str) return str;
        const txt = document.createElement("textarea");
        txt.innerHTML = str;
        return txt.value;
    }

    function isNoNfo(text) {
        if (!text || text.length < 15) return true;
        if (/Sorry no \.NFO/i.test(text)) return true;
        if (/NFO Information Unavailable/i.test(text)) return true;
        if (/No NFO file found/i.test(text)) return true;
        return false;
    }

    function parseNfoSummary(nfoText) {
        const safe = (nfoText || '').replace(/\r/g, '\n');
        const lines = safe.split(/\n/);
        const foundSubs = new Set();
        let inSubBlock = false; // 是否在字幕块(多行模式)

        // 正则解释:
        // 1. (?:^|[\s@*>\-#\|\[\]]+): 行首可以有空白、装饰符、括号
        // 2. (?:SUBTITLES?|SUBS?|TEXT|CAPTIONS?|字幕|Sous-titre(?:s)?|Untertiteln?): 核心关键词
        // 3. (?:[\s\.]*\d*:?)?: 可选的数字编号 (如 "Subs: 38:")
        // 4. (?:[\s\.]*[:=\-\|]): 分隔符,允许前面有很多点 (针对 ZeroTwo 格式: [Subtitles]............:)
        // 5. (.*): 捕获内容
        const regexSubLine = /^(?:[\s@*>\-#\|\[\]]*)(?:SUBTITLES?|SUBS?|TEXT|CAPTIONS?|字幕|Sous-titre(?:s)?|Untertiteln?)(?:\]?)(?:[\s\.]*(?:\d+)?[\s\.]*)?[:=\-\|]\s*(.*)/i;

        // BDInfo 格式 (Presentation Graphics 等)
        const regexBDInfo = /^(?:Presentation Graphics|PGS|SupRip|Subtitle|Text)\s+([a-zA-Z]+)(?:\s+\d+|\s*$)/i;
        const regexBlockHeader = /^(?:Text|Subtitle|Sous-titre)(?:\s*#\d+)?$/i; // MediaInfo 块标题
        const regexProperty = /^(?:Title|Language|Langue|Sprache)\s*[:\.]\s*(.*)/i; // MediaInfo 属性

        for (let i = 0; i < lines.length && i < 800; i++) {
            const line = lines[i];
            const trimmed = line.trim();
            if (!trimmed) {
                // 空行可能意味着字幕块结束,但有些 NFO 会用空行分隔,先不强制结束 inSubBlock,除非遇到新的 key
                if (inSubBlock && i + 1 < lines.length) {
                    // 如果下一行看起来像新的 Key (包含 :),则结束
                    if (lines[i+1].includes(':') && !lines[i+1].startsWith(' ')) inSubBlock = false;
                }
                continue;
            }

            // 1. 处理 BDInfo/MediaInfo 块模式
            if (regexBlockHeader.test(trimmed)) {
                // 进入 MediaInfo 详情块,寻找 Language
                // 这里简单处理:往下读几行找 Language
                for (let k = 1; k < 10; k++) {
                    if (i + k >= lines.length) break;
                    const nextLine = lines[i+k];
                    const mProp = nextLine.match(regexProperty);
                    if (mProp && mProp[1]) {
                        parseAndAdd(mProp[1], foundSubs);
                    }
                    if (!nextLine.trim()) break; // 空行退出块
                }
                continue;
            }

            // 2. 处理单行 BDInfo 格式 (e.g. "Presentation Graphics English ...")
            const mBD = trimmed.match(regexBDInfo);
            if (mBD && mBD[1]) {
                mapAndAdd(mBD[1], foundSubs);
                continue;
            }

            // 3. 处理常规 Key: Value 模式 (包括 ZeroTwo 和 ETHEL 格式)
            const mSub = line.match(regexSubLine);
            if (mSub) {
                const content = mSub[1];
                // 移除可能的计数器前缀 (如 "38: English")
                const cleanContent = content.replace(/^\s*\d+\s*:\s*/, '');
                parseAndAdd(cleanContent, foundSubs);
                inSubBlock = true; // 标记可能进入多行字幕列表
                continue;
            }

            // 4. 处理多行列表延续 (针对 ETHEL 格式)
            // 如果上一行是字幕行,且当前行有明显缩进,且不包含新的 Key (冒号)
            if (inSubBlock) {
                // 判定延续行:以空格/tab开头,或者虽然没有缩进但全是语言词汇
                const isIndented = /^\s+/.test(line);
                const hasColon = line.includes(':');

                if (isIndented && !hasColon) {
                    parseAndAdd(trimmed, foundSubs);
                    continue;
                } else {
                    // 如果不符合延续条件,关闭 flag
                    inSubBlock = false;
                }
            }
        }

        // 兜底检测:如果没找到任何字幕,尝试全文搜索文件名或常见标签
        if (foundSubs.size === 0) {
            if (/简体|Chinese|CHS/i.test(safe)) mapAndAdd('CHS', foundSubs);
            else if (/繁体|Traditional|CHT/i.test(safe)) mapAndAdd('CHT', foundSubs);
            if (/Japanese|JPN/i.test(safe)) mapAndAdd('JPN', foundSubs);
        }

        return { subs: Array.from(foundSubs) };
    }

    function parseAndAdd(text, setObj) {
        if (!text) return;
        // 移除干扰词
        let s = text.replace(/\d+\.?\d*\s*kbps/gi, '')
                    .replace(/[\(\[\{].*?[\)\]\}]/g, ' ') // 移除括号内容 (SDH), (forced)
                    .replace(/[^\w\s,\.\-\/&]/g, ' '); // 移除非文字字符,保留分隔符

        // 分割符:逗号、斜杠、点号(如果在单词间)、空格(如果很多)
        const tokens = s.split(/[ \/,\.\-\_&]+/);
        tokens.forEach(t => mapAndAdd(t, setObj));
    }

    function mapAndAdd(rawToken, setObj) {
        if (!rawToken || rawToken.length > 20) return;
        const lower = rawToken.trim().toLowerCase();
        if (lower.length < 2) return;
        // 过滤垃圾词
        if (/kbps|bit|fps|forced|default|yes|no|complete|main|plain|utf|text|sdh|cc|comment|title/i.test(lower)) return;

        // 1. 精确匹配
        if (LANG_MAP[lower]) {
            setObj.add(LANG_MAP[lower]);
            return;
        }
        // 2. 前缀匹配 (处理像 "Portuguese (Brazil)" 这种漏网之鱼)
        for (const k in LANG_MAP) {
            if (lower === k) {
                setObj.add(LANG_MAP[k]);
                return;
            }
        }
        // 3. 再次尝试前缀匹配
        for (const k in LANG_MAP) {
            if (lower.startsWith(k)) {
                setObj.add(LANG_MAP[k]);
                return;
            }
        }
    }

    function extractNfoFromHtml(htmlText) {
        if (!htmlText) return null;
        const regexes = [
            /<pre[^>]*>([\s\S]*?)<\/pre>/i,
            /<textarea[^>]*>([\s\S]*?)<\/textarea>/i,
            /<div[^>]*(?:class|id)\s*=\s*["']?([^"'>]*nfo[^"'>]*)["']?[^>]*>([\s\S]*?)<\/div>/i,
            /<description[^>]*>([\s\S]*?)<\/description>/i
        ];
        for (const r of regexes) { const m = htmlText.match(r); if (m && m[1] && m[1].length > 10) return decodeHtmlEntities(stripTags(m[1])); }
        const xmlNfo = htmlText.match(/<nfo[^>]*>([\s\S]*?)<\/nfo>/i);
        if (xmlNfo) return decodeHtmlEntities(stripTags(xmlNfo[1]));
        const cleaned = stripTags(htmlText);
        if (/SUBTITLE|AUDIO|VIDEO|字幕|MKV|x264|x265|HEVC|Bluray/i.test(cleaned)) return cleaned.substring(0, 30000);
        return null;
    }

    /************************** UI 展示 **************************/
    function showNfoModal(title, text, summary) {
        const id = 'tm_nfo_modal_v3';
        if (document.getElementById(id)) document.getElementById(id).remove();

        const modal = document.createElement('div');
        modal.id = id;
        Object.assign(modal.style, {
            position: 'fixed', left: 0, top: 0, right: 0, bottom: 0,
            background: 'rgba(0,0,0,0.7)', zIndex: 999999,
            display: 'flex', alignItems: 'center', justifyContent: 'center'
        });

        const content = document.createElement('div');
        Object.assign(content.style, {
            background: '#1f2937', color: '#f3f4f6', width: '85%', maxWidth: '1100px',
            maxHeight: '85vh', borderRadius: '8px', display: 'flex', flexDirection: 'column',
            boxShadow: '0 10px 25px rgba(0,0,0,0.5)', fontFamily: 'monospace'
        });

        const header = document.createElement('div');
        header.style.cssText = 'padding:16px;border-bottom:1px solid #374151;display:flex;justify-content:space-between;align-items:center;';

        const userTargets = SETTINGS.TARGET_LANGS || [];
        let subsHtml = '';
        let hitCount = 0;
        let otherSubs = [];
        const sortedSubs = summary.subs.sort((a, b) => {
            const aHit = userTargets.includes(a);
            const bHit = userTargets.includes(b);
            if (aHit && !bHit) return -1;
            if (!aHit && bHit) return 1;
            return a.localeCompare(b);
        });

        sortedSubs.forEach(sub => {
            if (userTargets.includes(sub)) {
                subsHtml += `<span class="tm_sub_tag highlight">${sub}</span>`;
                hitCount++;
            } else {
                otherSubs.push(sub);
            }
        });

        if (summary.subs.length === 0) {
            subsHtml = `<span style="color:#6b7280;font-size:11px;">None detected</span>`;
        } else if (otherSubs.length > 0) {
            const limit = 6;
            const displayOther = otherSubs.slice(0, limit).join(', ');
            const more = otherSubs.length > limit ? `...` : '';
            subsHtml += `<span class="tm_sub_tag" style="opacity:0.8">Other: ${displayOther}${more}</span>`;
        }

        header.innerHTML = `
            <div style="flex:1;min-width:0;">
                <div style="font-size:15px;font-weight:bold;margin-bottom:6px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:#fff;">${title}</div>
                <div style="display:flex;align-items:center;flex-wrap:wrap;gap:4px;">
                    <span style="font-size:12px;color:#9ca3af;margin-right:4px;">Subs:</span>
                    ${subsHtml}
                </div>
            </div>
        `;

        const closeBtn = document.createElement('button');
        closeBtn.textContent = '✕';
        closeBtn.style.cssText = 'background:none;border:none;color:#9ca3af;font-size:20px;cursor:pointer;padding:0 8px;margin-left:10px;';
        closeBtn.onclick = () => modal.remove();
        header.appendChild(closeBtn);

        const body = document.createElement('div');
        body.style.cssText = 'flex:1;overflow:auto;padding:16px;background:#111827;';
        const pre = document.createElement('pre');
        pre.style.cssText = 'white-space:pre-wrap;font-size:12px;color:#e5e7eb;font-family:Consolas, "Courier New", monospace;';
        pre.textContent = text;

        body.appendChild(pre);
        content.appendChild(header);
        content.appendChild(body);
        modal.appendChild(content);

        modal.onclick = (e) => { if(e.target === modal) modal.remove(); };
        document.body.appendChild(modal);
    }

    /************************** 逻辑控制 **************************/
    function findDetailsUrl(row) {
        const link = row.querySelector('a[href*="details?id="], a[href*="/release/"], a[href*="/details/"]');
        if (link) return link.href;
        return null;
    }

    function setButtonNoNfo(btn) {
        btn.textContent = 'No NFO';
        btn.disabled = true;
        btn.className = 'tm_nfo_button_v3 tm_nfo_button_nonfo';
        const dot = btn.querySelector('.tm_nfo_badge_dot');
        if(dot) dot.remove();
    }

    function updateButtonStatus(btn, nfoText) {
        if (isNoNfo(nfoText)) {
            setButtonNoNfo(btn);
            return;
        }

        btn.disabled = false;
        btn.textContent = SETTINGS.BUTTON_TEXT;
        btn.className = 'tm_nfo_button_v3';

        const oldDot = btn.parentElement.querySelector('.tm_nfo_badge_dot');
        if (oldDot) oldDot.remove();

        const summary = parseNfoSummary(nfoText);
        const userTargets = SETTINGS.TARGET_LANGS || [];
        const isHit = summary.subs.some(s => userTargets.includes(s));

        if (summary.subs.length > 0) {
            const dot = document.createElement('span');
            dot.className = 'tm_nfo_badge_dot ' + (isHit ? 'tm_badget_hit' : 'tm_badget_miss');
            btn.style.position = 'relative';
            btn.appendChild(dot);
        }
    }

    async function onButtonClick(btn, row, title) {
        const url = findDetailsUrl(row);
        if (!url) { alert('Err: Cannot find details URL.'); return; }

        let id = url.match(/[?&]id=([^&]+)/);
        id = id ? id[1] : url;

        const cached = getCache(id);
        if (cached) {
            if (isNoNfo(cached)) { setButtonNoNfo(btn); return; }
            showNfoModal(title, cached, parseNfoSummary(cached));
            updateButtonStatus(btn, cached);
            return;
        }

        btn.disabled = true; btn.textContent = '...';
        try {
            const res = await gmFetch(url);
            let nfo = extractNfoFromHtml(res.responseText);

            if (!nfo || nfo.length < 50) {
                 const apiIdMatch = res.responseText.match(/<id[^>]*>([^<]+)<\/id>/i);
                 if (apiIdMatch) {
                     const newUrl = `https://api.omgwtfnzbs.org/api?t=getnfo&id=${apiIdMatch[1]}`;
                     const res2 = await gmFetch(newUrl);
                     const nfo2 = extractNfoFromHtml(res2.responseText) || stripTags(res2.responseText);
                     if (nfo2 && nfo2.length > 20) nfo = nfo2;
                 }
            }
            if (!nfo || nfo.length < 20) nfo = stripTags(res.responseText).substring(0, 30000);

            setCache(id, nfo);

            if (isNoNfo(nfo)) {
                setButtonNoNfo(btn);
            } else {
                showNfoModal(title, nfo, parseNfoSummary(nfo));
                updateButtonStatus(btn, nfo);
            }
        } catch (e) {
            console.error(e);
            btn.textContent = 'Err';
            setTimeout(() => { btn.disabled = false; btn.textContent = SETTINGS.BUTTON_TEXT; }, 3000);
        }
    }

    function injectNfoColumn() {
        const tables = document.querySelectorAll('table[class*="Table-table"]');
        if (!tables.length) return;

        tables.forEach(table => {
            const ths = Array.from(table.querySelectorAll('thead th'));
            const headerTexts = ths.map(th => th.textContent.trim().toLowerCase());
            const isSearchTable = headerTexts.includes('indexer') || headerTexts.includes('age') || headerTexts.includes('peers');
            if (!isSearchTable) return;

            const theadRow = table.querySelector('thead tr');
            if (theadRow && !theadRow.querySelector('.tm-nfo-col-header')) {
                const th = document.createElement('th');
                th.className = 'tm-nfo-col-header';
                th.textContent = 'NFO';
                if (theadRow.children.length > 2) theadRow.insertBefore(th, theadRow.children[2]);
                else theadRow.appendChild(th);
            }

            const rows = table.querySelectorAll('tbody tr');
            rows.forEach(row => {
                if (row.querySelector('.tm-nfo-col-cell')) return;

                const td = document.createElement('td');
                td.className = 'tm-nfo-col-cell';
                const titleCell = row.cells[0] || row.cells[1];
                const titleText = titleCell ? titleCell.innerText.trim() : 'Release';

                const btn = document.createElement('button');
                btn.className = 'tm_nfo_button_v3';
                btn.textContent = SETTINGS.BUTTON_TEXT;
                btn.onclick = (e) => { e.stopPropagation(); e.preventDefault(); onButtonClick(btn, row, titleText); };

                td.appendChild(btn);
                if (row.children.length > 2) row.insertBefore(td, row.children[2]);
                else row.appendChild(td);

                const url = findDetailsUrl(row);
                if (url) {
                    let id = url.match(/[?&]id=([^&]+)/);
                    id = id ? id[1] : url;
                    const cached = getCache(id);
                    if (cached) {
                        if (isNoNfo(cached)) setButtonNoNfo(btn);
                        else updateButtonStatus(btn, cached);
                    }
                }
            });
        });
    }

    /************************** 设置面板 **************************/
    function createSettingsButton() {
        if (document.getElementById('tm_nfo_settings_btn')) return;
        const btn = document.createElement('div');
        btn.id = 'tm_nfo_settings_btn';
        Object.assign(btn.style, {
            position: 'fixed', right: '20px', bottom: '20px', width: '40px', height: '40px',
            background: '#0f172a', borderRadius: '50%', color: '#fff', fontSize: '20px',
            display: 'flex', alignItems: 'center', justifyContent: 'center', cursor: 'pointer',
            boxShadow: '0 4px 12px rgba(0,0,0,0.5)', zIndex: 999999
        });
        btn.innerHTML = '⚙';
        btn.onclick = openSettings;
        document.body.appendChild(btn);
    }

    function openSettings() {
        const id = 'tm_nfo_settings_panel';
        if (document.getElementById(id)) return;

        const overlay = document.createElement('div');
        overlay.id = id;
        Object.assign(overlay.style, { position: 'fixed', left: 0, top: 0, right: 0, bottom: 0, background: 'rgba(0,0,0,0.6)', zIndex: 1000000, display: 'flex', alignItems: 'center', justifyContent: 'center' });

        const panel = document.createElement('div');
        Object.assign(panel.style, { background: '#1e293b', padding: '24px', borderRadius: '12px', width: '500px', maxHeight:'90vh', overflow:'auto', color: '#e2e8f0', fontFamily: 'sans-serif', boxShadow: '0 10px 25px rgba(0,0,0,0.5)' });

        const rowStyle = 'margin-bottom:12px;display:flex;flex-direction:column;gap:4px;';
        const inputStyle = 'padding:6px;border-radius:4px;border:1px solid #334155;background:#0f172a;color:#fff;outline:none;';

        const availableLangs = [
            {code: 'CHS', label: 'Chinese (Simple)'}, {code: 'CHT', label: 'Chinese (Trad)'},
            {code: 'ENG', label: 'English'}, {code: 'JPN', label: 'Japanese'},
            {code: 'KOR', label: 'Korean'}, {code: 'FRE', label: 'French'},
            {code: 'SPA', label: 'Spanish'}, {code: 'GER', label: 'German'},
            {code: 'ITA', label: 'Italian'}, {code: 'RUS', label: 'Russian'},
            {code: 'POR', label: 'Portuguese'}, {code: 'DUT', label: 'Dutch'},
            {code: 'THA', label: 'Thai'}, {code: 'TUR', label: 'Turkish'}
        ];

        let langChecks = '<div style="display:grid;grid-template-columns:1fr 1fr;gap:8px;background:#0f172a;padding:10px;border-radius:6px;border:1px solid #334155;">';
        availableLangs.forEach(l => {
            const checked = (SETTINGS.TARGET_LANGS || []).includes(l.code) ? 'checked' : '';
            langChecks += `<div style="display:flex;align-items:center;gap:6px;">
                <input type="checkbox" id="tm_lang_${l.code}" value="${l.code}" ${checked}>
                <label for="tm_lang_${l.code}" style="font-size:12px;cursor:pointer">${l.label}</label>
            </div>`;
        });
        langChecks += '</div>';

        panel.innerHTML = `
            <h3 style="margin:0 0 16px 0;border-bottom:1px solid #334155;padding-bottom:8px;">NFO Viewer Settings (v2.9.2)</h3>

            <div style="${rowStyle}">
                <label style="font-size:12px;color:#94a3b8;">API Key (Optional)</label>
                <input id="tm_set_apikey" type="password" style="${inputStyle}" placeholder="Indexer API Key" />
            </div>

            <div style="margin-bottom:12px;">
                <label style="font-size:12px;color:#94a3b8;font-weight:bold;display:block;margin-bottom:4px;">Highlight Subtitles:</label>
                ${langChecks}
            </div>

            <div style="${rowStyle}">
                <label style="font-size:12px;color:#94a3b8;">Proxy URL</label>
                <input id="tm_set_proxy_url" type="text" style="${inputStyle}" />
            </div>
             <div style="display:flex;align-items:center;gap:8px;margin-bottom:12px;">
                <input id="tm_set_proxy" type="checkbox" />
                <label for="tm_set_proxy" style="font-size:13px">Use Local Proxy</label>
            </div>

            <div style="display:flex;justify-content:flex-end;gap:10px;margin-top:20px;">
                <button id="tm_set_clear" style="padding:6px 12px;border-radius:4px;border:none;background:#7f1d1d;color:white;cursor:pointer;margin-right:auto;font-size:12px">Clear Cache</button>
                <button id="tm_set_cancel" style="padding:6px 16px;border-radius:4px;border:none;background:#334155;color:white;cursor:pointer;">Cancel</button>
                <button id="tm_set_save" style="padding:6px 16px;border-radius:4px;border:none;background:#2563eb;color:white;cursor:pointer;">Save</button>
            </div>
        `;
        overlay.appendChild(panel);
        document.body.appendChild(overlay);

        document.getElementById('tm_set_apikey').value = SETTINGS.API_KEY || '';
        document.getElementById('tm_set_proxy').checked = SETTINGS.USE_PROXY;
        document.getElementById('tm_set_proxy_url').value = SETTINGS.PROXY_URL;

        document.getElementById('tm_set_cancel').onclick = () => overlay.remove();
        document.getElementById('tm_set_clear').onclick = () => { Object.keys(localStorage).forEach(k => { if(k.startsWith('tm_nfo_c_')) localStorage.removeItem(k); }); alert('Cache cleared.'); };

        document.getElementById('tm_set_save').onclick = () => {
            const selectedLangs = [];
            availableLangs.forEach(l => {
                if (document.getElementById(`tm_lang_${l.code}`).checked) selectedLangs.push(l.code);
            });

            saveSettings({
                API_KEY: document.getElementById('tm_set_apikey').value.trim(),
                USE_PROXY: document.getElementById('tm_set_proxy').checked,
                PROXY_URL: document.getElementById('tm_set_proxy_url').value.trim(),
                TARGET_LANGS: selectedLangs
            });
            SETTINGS = loadSettings();
            overlay.remove();
            alert('Settings Saved.');
        };
    }

    const observer = new MutationObserver(() => injectNfoColumn());
    (async () => {
        await sleep(500);
        injectNfoColumn();
        createSettingsButton();
        observer.observe(document.body, { childList: true, subtree: true });
    })();

})();