Radarr/Sonarr — Interactive Search NFO Viewer

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

当前为 2025-11-25 提交的版本,查看 最新版本

您需要先安装一个扩展,例如 篡改猴Greasemonkey暴力猴,之后才能安装此脚本。

You will need to install an extension such as Tampermonkey to install this script.

您需要先安装一个扩展,例如 篡改猴暴力猴,之后才能安装此脚本。

您需要先安装一个扩展,例如 篡改猴Userscripts ,之后才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。

您需要先安装用户脚本管理器扩展后才能安装此脚本。

(我已经安装了用户脚本管理器,让我安装!)

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

(我已经安装了用户样式管理器,让我安装!)

// ==UserScript==
// @name         Radarr/Sonarr — Interactive Search NFO Viewer
// @name:zh-CN   Radarr/Sonarr — Interactive Search NFO Viewer
// @namespace    http://tampermonkey.net/
// @version      2.9
// @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/).map(l => l.trim());
        const foundSubs = new Set();
        let inTextSection = false;

        const regexSummary = /(?:^|[\s@*>\-#\|]+)(?:SUBTITLES?|SUBS?|字幕|Sous-titre(?:s)?|Untertiteln?|Text)(?:\s+(?:\d+|#\d+))?\s*[:\-\|]\s*(.*)/i;
        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;
        const regexProperty = /^(?:Title|Language|Langue|Sprache)\s*[:\.]\s*(.*)/i;

        for (const line of lines.slice(0, 800)) {
            if (!line) { inTextSection = false; continue; }
            if (regexBlockHeader.test(line)) {
                inTextSection = true;
                continue;
            }
            if (inTextSection) {
                const mProp = line.match(regexProperty);
                if (mProp && mProp[1]) {
                    const vals = mProp[1].split(/[ \/,\.\-\(\)\[\]_&]+/);
                    vals.forEach(v => mapAndAdd(v, foundSubs));
                }
            }
            const mBD = line.match(regexBDInfo);
            if (mBD && mBD[1]) {
                mapAndAdd(mBD[1], foundSubs);
                continue;
            }
            const mSum = line.match(regexSummary);
            if (mSum && mSum[1]) {
                let content = mSum[1];
                content = content.replace(/\d+\.?\d*\s*kbps/gi, '').replace(/[\(\)\[\]]/g, '');
                content = content.replace(/[\s@*>\-#\|]+$/, '');
                const parts = content.split(/[ \/,\.\-\(\)\[\]_&]+/);
                parts.forEach(p => mapAndAdd(p, foundSubs));
            }
        }

        if (foundSubs.size === 0) {
            if (/简体|Chinese/i.test(safe)) mapAndAdd('CHS', foundSubs);
            if (/繁体/i.test(safe)) mapAndAdd('CHT', foundSubs);
            if (/Japanese/i.test(safe)) mapAndAdd('JPN', foundSubs);
        }
        return { subs: Array.from(foundSubs) };
    }

    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/i.test(lower)) return;
        if (LANG_MAP[lower]) {
            setObj.add(LANG_MAP[lower]);
            return;
        }
        for (const k in LANG_MAP) {
            if (lower === k) {
                setObj.add(LANG_MAP[k]);
                return;
            }
        }
        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.8)</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 });
    })();

})();