☰

javdb plus

JavDB 美化增强:一键预览缩略图、一键推送下载、一键屏蔽片商、一键划词屏蔽,Emby 入库同步显示

이 스크립트를 설치하려면 Tampermonkey, Greasemonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Violentmonkey와 같은 확장 프로그램을 먼저 설치해야 합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Userscripts와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 유저 스크립트 관리자 확장 프로그램이 필요합니다.

(이미 유저 스크립트 관리자가 설치되어 있습니다. 설치를 진행합니다!)

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

(이미 유저 스타일 관리자가 설치되어 있습니다. 설치를 진행합니다!)

// ==UserScript==
// @name         javdb plus
// @namespace    javdb plus
// @version      2.2.1
// @author       Vithur
// @description  JavDB 美化增强:一键预览缩略图、一键推送下载、一键屏蔽片商、一键划词屏蔽,Emby 入库同步显示
// @license      MIT
// @icon         https://javdb.com/favicon-32x32.png
// @match        https://javdb.com/*
// @match        https://javdb580.com/*
// @include      https://javdb*.com/*
// @grant        GM_xmlhttpRequest
// @grant        GM_openInTab
// @grant        GM_getValue
// @grant        GM_setValue
// @grant        GM_notification
// @grant        GM_registerMenuCommand
// @connect      javstore.net
// @connect      javdb.com
// @run-at       document-idle
// ==/UserScript==

(function () {
    "use strict";

    const SITE = detectSite();
    if (!SITE) return;

    const IS_TOP = window.top === window;
    const DB_NAME = "JAV-JHS";
    const DB_STORE = "appData";
    const CACHE_SCREENSHOT = "jhs_screenShot";
    const LOCAL_PREFIX = "jhs_lite_";
    const STYLE_ID = "jhs-lite-style";
    const VERTICAL_STYLE_ID = "jhs-lite-vertical-style";
    const LIST_ITEM_CLASS = "jhs-lite-filtered";
    const THUMB_CLASS = "jhs-lite-thumb";
    const COVER_THUMB_CLASS = "jhs-lite-cover-thumb";
    const MODAL_CLASS = "jhs-lite-popup";
    const WIDTH_STYLE_ID = "jhs-lite-width-style";
    const CHROME_STYLE_ID = "jhs-lite-chrome-style";
    const POPUP_EMBED_STYLE_ID = "jhs-lite-popup-embed-style";
    const DARK_THEME_STYLE_ID = "jhs-lite-dark-theme-style";
    const DEFAULT_CONTAINER_WIDTH = 100;
    const CHANNEL_NAME = "jhs-lite-channel";
    const CARD_CLASS = "jhs-lite-card";
    const CARD_READY_ATTR = "jhsLiteCard";
    const EMBY_CLASS = "jhs-lite-emby";
    const EMBY_DOT_CLASS = "jhs-lite-emby-dot";
    const LIGHTBOX_FIT_SCALE = 2.5;
    const PUSH_CLASS = "jhs-lite-push";
    const PUSH_SPIN_CLASS = "jhs-lite-push-spin";
    const PUSH_LABEL_CLASS = "jhs-lite-push-label";
    const CONFIG = {
        emby: {
            url: "",
            token: "",
            libraryId: "",
        },
        qb: {
            host: "",
            apiKey: "",
            savePath: "",
            category: "jav"
        },
        embySyncTtl: 60 * 60 * 1000,
        queryConcurrency: 3,
        pageSize: 200,
        maxPages: 500
    };

    const state = {
        settings: {},
        titleKeywords: [],
        blockedPrefixes: [],
        dbPromise: null,
        listObserver: null,
        modal: null,
        gallery: null,
        keywordDialog: null,
        confirmDialog: null,
        embySettingsDialog: null,
        qbSettingsDialog: null,
        embyIndex: {},
        embySyncError: "",
        embyLastSync: 0,
        embySyncing: false,
        embyPending: new Map(),
        embyMissed: new Set(),
        pushedCodes: new Set(),
        cardObserver: null,
        channel: null,
        ready: false
    };

    const titleSelector = ".title strong, .current-title";

    function detectSite() {
        const host = window.location.hostname.toLowerCase();
        return host.includes("javdb") ? "javdb" : null;
    }

    function isYes(value, defaultValue) {
        if (value === undefined || value === null || value === "") return defaultValue;
        if (value === true || value === 1) return true;
        const normalized = String(value).trim().toLowerCase();
        return normalized === "yes" || normalized === "true" || normalized === "1";
    }

    function localKey(key) {
        return LOCAL_PREFIX + key;
    }

    function readLocalJson(key, fallback) {
        try {
            const raw = localStorage.getItem(key);
            return raw === null ? fallback : JSON.parse(raw);
        } catch (error) {
            return fallback;
        }
    }

    function writeLocalJson(key, value) {
        try {
            localStorage.setItem(key, JSON.stringify(value));
        } catch (error) {
            console.warn("[JHS Lite] localStorage 写入失败", error);
        }
    }

    function openJhsDb() {
        if (state.dbPromise) return state.dbPromise;
        state.dbPromise = new Promise(function (resolve, reject) {
            if (!window.indexedDB) {
                reject(new Error("当前浏览器不支持 IndexedDB"));
                return;
            }
            const request = indexedDB.open(DB_NAME, 1);
            request.onupgradeneeded = function () {
                const db = request.result;
                if (!db.objectStoreNames.contains(DB_STORE)) {
                    db.createObjectStore(DB_STORE);
                }
            };
            request.onsuccess = function () {
                const db = request.result;
                db.onversionchange = function () {
                    db.close();
                };
                resolve(db);
            };
            request.onerror = function () {
                reject(request.error || new Error("IndexedDB 打开失败"));
            };
            request.onblocked = function () {
                reject(new Error("IndexedDB 被其他页面占用"));
            };
        });
        return state.dbPromise;
    }

    async function readJhsValue(key) {
        try {
            const db = await openJhsDb();
            return await new Promise(function (resolve, reject) {
                const transaction = db.transaction(DB_STORE, "readonly");
                const request = transaction.objectStore(DB_STORE).get(key);
                request.onsuccess = function () {
                    resolve(request.result);
                };
                request.onerror = function () {
                    reject(request.error || new Error("IndexedDB 读取失败"));
                };
            });
        } catch (error) {
            return undefined;
        }
    }

    async function writeJhsValue(key, value) {
        try {
            const db = await openJhsDb();
            await new Promise(function (resolve, reject) {
                const transaction = db.transaction(DB_STORE, "readwrite");
                transaction.objectStore(DB_STORE).put(value, key);
                transaction.oncomplete = function () {
                    resolve();
                };
                transaction.onerror = function () {
                    reject(transaction.error || new Error("IndexedDB 写入失败"));
                };
                transaction.onabort = function () {
                    reject(transaction.error || new Error("IndexedDB 写入被中止"));
                };
            });
        } catch (error) {
            console.warn("[JHS Lite] IndexedDB 写入失败,已使用 localStorage 备份", key, error);
        }
    }

    function normalizeKeywordList(value) {
        if (!Array.isArray(value)) return [];
        const seen = new Set();
        const result = [];
        for (const item of value) {
            if (typeof item !== "string") continue;
            const keyword = item.trim();
            if (!keyword) continue;
            const identity = keyword.toLowerCase();
            if (seen.has(identity)) continue;
            seen.add(identity);
            result.push(keyword);
        }
        return result;
    }

    function normalizeKeyword(value) {
        return String(value || "").trim();
    }

    async function loadState() {
        const storedSettings = await readJhsValue("setting");
        const storedTitleKeywords = await readJhsValue("filter_keyword_title");
        const localSettings = readLocalJson(localKey("settings"), {});
        const localTitleKeywords = readLocalJson(localKey("titleKeywords"), []);

        state.settings = Object.assign(
            {},
            localSettings && typeof localSettings === "object" ? localSettings : {},
            storedSettings && typeof storedSettings === "object" ? storedSettings : {}
        );
        state.titleKeywords = normalizeKeywordList(
            Array.isArray(storedTitleKeywords) ? storedTitleKeywords : localTitleKeywords
        );
        state.blockedPrefixes = normalizeKeywordList(
            Array.isArray(state.settings.blockedPrefixes) ? state.settings.blockedPrefixes : []
        );
        applyEmbySettings(getEmbySettings());
        applyQbSettings(getQbSettings());
    }

    async function saveSetting(key, value) {
        state.settings[key] = value;
        writeLocalJson(localKey("settings"), state.settings);
        await writeJhsValue("setting", state.settings);
        broadcastState();
    }

    async function addBlockedPrefix(prefix) {
        const value = normalizeKeyword(prefix).toUpperCase();
        if (!value) return false;
        if (!state.blockedPrefixes.some(function (item) {
            return item.toUpperCase() === value;
        })) {
            await saveSetting("blockedPrefixes", state.blockedPrefixes.concat(value));
            state.blockedPrefixes = normalizeKeywordList(state.settings.blockedPrefixes);
        }
        return true;
    }

    async function saveKeywordList(keywords) {
        const normalized = normalizeKeywordList(keywords);
        state.titleKeywords = normalized;
        writeLocalJson(localKey("titleKeywords"), normalized);
        await writeJhsValue("filter_keyword_title", normalized);
        filterListItems();
        broadcastState();
    }

    function addKeyword(keyword) {
        const value = String(keyword || "").trim();
        if (!value) return Promise.resolve(false);
        const source = state.titleKeywords;
        if (source.some(function (item) {
            return item.toLowerCase() === value.toLowerCase();
        })) {
            toast("关键词已存在", "info");
            return Promise.resolve(false);
        }
        return saveKeywordList(source.concat(value)).then(function () {
            return true;
        });
    }

    function broadcastState() {
        if (!state.channel) return;
        try {
            state.channel.postMessage({
                type: "state",
                settings: Object.assign({}, state.settings),
                titleKeywords: state.titleKeywords.slice(),
            });
        } catch (error) {
            console.warn("[JHS Lite] 状态广播失败", error);
        }
    }

    function initChannel() {
        if (typeof BroadcastChannel === "undefined") return;
        state.channel = new BroadcastChannel(CHANNEL_NAME);
        state.channel.addEventListener("message", function (event) {
            const data = event.data;
            if (!data || data.type !== "state") return;
            state.settings = data.settings && typeof data.settings === "object"
                ? data.settings
                : state.settings;
            state.titleKeywords = normalizeKeywordList(data.titleKeywords);
            state.blockedPrefixes = normalizeKeywordList(data.settings && data.settings.blockedPrefixes);
            applyVerticalMode(true, true);
            applyContainerWidth();
            filterListItems();
        });
    }

    function injectStyle(id, css) {
        const oldStyle = document.getElementById(id);
        if (oldStyle) oldStyle.remove();
        const style = document.createElement("style");
        style.id = id;
        style.textContent = css;
        document.documentElement.appendChild(style);
    }

    function injectBaseStyle() {
        injectStyle(STYLE_ID, [
            "." + THUMB_CLASS + "{display:block;overflow:hidden;text-align:center;cursor:zoom-in}",
            "." + THUMB_CLASS + " img{display:block;width:100%;height:auto}",
            "." + THUMB_CLASS + " .jhs-lite-thumb-message{padding:24px 8px;color:#333;background:#f5f5f5;height:100%;box-sizing:border-box;font-size:13px;line-height:1.7;cursor:auto}",
            "." + THUMB_CLASS + " a{color:#337ab7}",
            ".movie-list{display:grid!important;grid-template-columns:repeat(auto-fill,minmax(280px,1fr))!important;gap:32px!important;align-items:stretch!important}",
            ".movie-list." + CARD_CLASS + "-grid>." + CARD_CLASS + ",.masonry." + CARD_CLASS + "-grid>." + CARD_CLASS + "{width:auto!important;height:auto!important;min-height:0!important;margin:0!important;padding:0!important;float:none!important;top:auto!important;left:auto!important;box-sizing:border-box}",
            ".masonry." + CARD_CLASS + "-grid{display:grid!important;grid-template-columns:repeat(4,minmax(0,1fr))!important;column-gap:32px!important;row-gap:32px!important;height:auto!important;align-items:start!important}",
            "." + CARD_CLASS + "{--jhs-i:0;--jhs-font-display:\"Wix Madefor Display\",ui-sans-serif,sans-serif;--jhs-font-body:\"Wix Madefor Text\",ui-sans-serif,sans-serif;position:relative!important;display:flex!important;flex-direction:column!important;border:0!important;outline:0!important;background:oklch(22% .018 268/.92)!important;color:oklch(85% .01 262)!important;border-radius:18px!important;box-shadow:inset 0 1px 0 oklch(100% 0 0/.1),inset 0 0 0 1px oklch(100% 0 0/.06),0 40px 80px -32px oklch(8% .02 268/.7)!important;overflow:visible!important;transition:transform .22s cubic-bezier(.16,1,.3,1),box-shadow .22s cubic-bezier(.16,1,.3,1);animation:jhs-lite-card-enter .6s cubic-bezier(.16,1,.3,1) both;animation-delay:calc(var(--jhs-i)*70ms)}",
            "." + CARD_CLASS + "::before{content:\"\";position:absolute;inset:0;border-radius:18px;box-shadow:inset 0 0 0 1px oklch(96% .006 75/.2),inset 0 0 24px oklch(94% .012 70/.16);opacity:0;transition:opacity .22s cubic-bezier(.16,1,.3,1);pointer-events:none;z-index:10}",
            "." + CARD_CLASS + ":hover::before{opacity:1}",
            "." + CARD_CLASS + ":hover{transform:translateY(-4px);box-shadow:inset 0 1px 0 oklch(100% 0 0/.1),0 40px 80px -32px oklch(8% .02 268/.7)!important}",
            "@keyframes jhs-lite-card-enter{from{opacity:0;transform:translateY(14px)}to{opacity:1;transform:translateY(0)}}",
            "." + CARD_CLASS + " .card__poster{position:relative;display:block;text-decoration:none!important;color:inherit!important;outline:0!important}",
            "." + CARD_CLASS + " .card__figure{margin:0;aspect-ratio:800/538;overflow:clip;border-radius:18px 18px 0 0;background:oklch(17.5% .014 268)}",
            "." + CARD_CLASS + " .card__figure>img{display:block;width:100%;height:100%;object-fit:cover;filter:contrast(1.04) saturate(1.03);transition:filter .42s cubic-bezier(.16,1,.3,1)}",
            "." + CARD_CLASS + ":hover .card__figure>img{filter:contrast(1.04) saturate(1.03)}",
            "." + COVER_THUMB_CLASS + "{position:absolute!important;left:16px;bottom:6px;display:inline-flex;align-items:center;gap:4px;min-height:0;margin:0;padding:8px 12px;border:0!important;outline:0!important;border-radius:999px;background:oklch(10% .02 268/.4)!important;-webkit-backdrop-filter:blur(8px);backdrop-filter:blur(8px);box-shadow:inset 0 0 0 1px oklch(96% .006 75/.14);color:oklch(85% .01 262)!important;font:500 13px/1 var(--jhs-font-body)!important;white-space:nowrap;cursor:pointer;pointer-events:auto;z-index:12;transition:background .22s,color .22s,box-shadow .22s}",
            "." + COVER_THUMB_CLASS + ":hover," + "." + COVER_THUMB_CLASS + ":focus-visible{background:oklch(10% .02 268/.62)!important;color:oklch(96% .006 75)!important;box-shadow:inset 0 0 0 1px oklch(96% .006 75/.3)}",
            "." + COVER_THUMB_CLASS + "[data-state=error]{color:oklch(72% .15 27)!important;box-shadow:inset 0 0 0 1px oklch(72% .15 27/.45)}",
            "." + COVER_THUMB_CLASS + " .jhs-lite-chip-icon svg," + "." + COVER_THUMB_CLASS + " .jhs-lite-chip-spinner{display:block;width:16px;height:16px;fill:none;stroke:currentColor;stroke-width:2;stroke-linecap:round;pointer-events:none}",
            "." + COVER_THUMB_CLASS + " .jhs-lite-chip-spinner{animation:jhs-lite-spin .9s linear infinite}",
            "." + EMBY_CLASS + "{position:absolute;top:12px;right:16px;display:none;align-items:center;gap:6px;padding:8px 10px;border-radius:999px;background:oklch(10% .02 268/.4);-webkit-backdrop-filter:blur(8px);backdrop-filter:blur(8px);box-shadow:inset 0 0 0 1px oklch(96% .006 75/.14);color:oklch(96% .006 75);font:500 14px/1 var(--jhs-font-body);white-space:nowrap;z-index:12;text-decoration:none!important}",
            "." + EMBY_CLASS + "[data-state=exists]{display:inline-flex;cursor:pointer}",
            "." + EMBY_CLASS + " ." + EMBY_DOT_CLASS + "{width:8px;height:8px;border-radius:999px;background:currentColor;box-shadow:0 0 0 3px color-mix(in oklab,currentColor 22%,transparent);animation:jhs-lite-dot 2.8s cubic-bezier(.65,0,.35,1) infinite}",
            "." + EMBY_CLASS + "[data-state=exists][data-played=true] ." + EMBY_DOT_CLASS + "{animation:none;opacity:.65}",
            "." + PUSH_CLASS + ".is-emby{background:oklch(34% .012 262);color:oklch(96% .006 75)}",
            "." + PUSH_CLASS + ".is-emby[data-played=true]{background:oklch(30% .012 262);color:oklch(62% .012 262)}",
            "." + PUSH_CLASS + ".is-emby[data-played=false]{background:oklch(83% .095 62);color:oklch(24% .06 62)}",
            "." + PUSH_CLASS + ".is-emby:hover{background:oklch(40% .012 262);color:oklch(96% .006 75)}",
            "." + PUSH_CLASS + ".is-emby[data-played=false]:hover{background:oklch(88% .085 62)}",
            "." + PUSH_CLASS + ".is-emby:disabled{opacity:1;cursor:pointer}",
            "." + PUSH_CLASS + ".is-emby .jhs-lite-push-icon{display:none}",
            "@keyframes jhs-lite-dot{0%,100%{opacity:1;transform:scale(1)}50%{opacity:.55;transform:scale(.82)}}",
            "." + CARD_CLASS + " .card__body{display:flex;flex-direction:column;gap:12px;padding:16px 16px 16px;min-width:0}",
            "." + CARD_CLASS + " .card__meta{display:flex;justify-content:space-between;align-items:baseline;gap:8px;min-width:0}",
            "." + CARD_CLASS + " .card__code{font:600 14px/1 var(--jhs-font-body);color:oklch(96% .006 75);overflow-wrap:break-word;min-width:0}",
            "." + CARD_CLASS + " .card__date{font:400 13px/1 var(--jhs-font-body);color:oklch(70% .016 262);font-variant-numeric:tabular-nums;white-space:nowrap}",
            "." + CARD_CLASS + " .card__row{display:grid;grid-template-columns:minmax(0,1fr) max-content;align-items:center;gap:12px}",
            "." + CARD_CLASS + " .card__title{margin:0;font:500 .875rem/1.285 var(--jhs-font-display);letter-spacing:-.01em;color:oklch(85% .01 262);overflow-wrap:break-word;min-width:0;text-wrap:balance;display:-webkit-box!important;white-space:normal!important;-webkit-box-orient:vertical;-webkit-line-clamp:2;overflow:hidden!important;text-overflow:ellipsis}",
            "." + CARD_CLASS + " .card__actions{display:flex;justify-content:flex-end;gap:8px;margin-top:auto}",
            "." + CARD_CLASS + " .card__figure .card__cover-image{object-fit:cover!important}",
            "." + CARD_CLASS + ".jhs-lite-in-library .card__cover-image{filter:grayscale(1)!important}",
            "." + CARD_CLASS + ".jhs-lite-in-library:hover .card__cover-image{filter:none!important}",
            "." + PUSH_CLASS + "{position:relative;display:inline-flex;align-items:center;gap:4px;min-height:36px;padding:0 16px;border:1px solid transparent;border-radius:999px;background:oklch(77% .10 274);color:oklch(19% .035 274);font:600 14px/1 \"Wix Madefor Text\",ui-sans-serif,sans-serif;white-space:nowrap;cursor:pointer;transition:background-color .22s cubic-bezier(.16,1,.3,1),transform .12s cubic-bezier(.16,1,.3,1),box-shadow .22s cubic-bezier(.16,1,.3,1)}",
            "." + PUSH_CLASS + ":hover{background:oklch(84% .085 274);transform:translateY(-1px);box-shadow:0 6px 18px -8px oklch(77% .10 274/.55)}",
            "." + PUSH_CLASS + ":active{transform:translateY(0)}",
            "." + PUSH_CLASS + ":focus-visible{outline:2px solid oklch(80% .10 274);outline-offset:2px}",
            "." + PUSH_CLASS + "[aria-busy=true]{cursor:progress;background:oklch(44% .13 274);color:oklch(96% .006 75)}",
            "." + PUSH_CLASS + "[aria-busy=true] .jhs-lite-push-icon{opacity:0}",
            "." + PUSH_CLASS + " ." + PUSH_SPIN_CLASS + "{position:absolute;left:16px;top:50%;margin-top:-8px;width:16px;height:16px;border-radius:999px;border:2px solid oklch(96% .006 75/.25);border-top-color:oklch(96% .006 75);opacity:0;pointer-events:none}",
            "." + PUSH_CLASS + "[aria-busy=true] ." + PUSH_SPIN_CLASS + "{opacity:1;animation:jhs-lite-spin .8s linear infinite}",
            "@keyframes jhs-lite-spin{to{transform:rotate(360deg)}}",
            "." + PUSH_CLASS + ".is-done{background:transparent;color:oklch(70% .016 262);border-color:oklch(30% .016 266);cursor:default}",
            "." + PUSH_CLASS + ".is-error{background:oklch(72% .15 27);color:oklch(10% .022 268)}",
            "." + PUSH_CLASS + ":disabled{opacity:.55;cursor:not-allowed;transform:none}",
            "@media(max-width:700px){." + CARD_CLASS + "{--jhs-i:0}}",
            "@media(prefers-reduced-motion:reduce){." + CARD_CLASS + "{animation:none;transition:none}." + EMBY_DOT_CLASS + ",.jhs-lite-push-spin{animation:none!important}}",
            "." + MODAL_CLASS + "-mask{position:fixed;inset:0;z-index:2147483000;background:rgba(0,0,0,.6);display:flex;align-items:center;justify-content:center;padding:18px}",
            "." + MODAL_CLASS + "-panel{width:90vw;height:90vh;max-width:1800px;background:oklch(17.5% .014 268);box-shadow:0 16px 48px rgba(0,0,0,.55);display:flex;flex-direction:column;overflow:hidden}",
            "." + MODAL_CLASS + "-bar{height:42px;min-height:42px;display:flex;align-items:center;gap:12px;padding:0 12px 0 16px;background:oklch(22% .018 268);border-bottom:1px solid oklch(30% .016 266);color:oklch(96% .006 75)}",
            "." + MODAL_CLASS + "-title{font-size:14px;font-weight:700;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}",
            "." + MODAL_CLASS + "-close{margin-left:auto;border:0;background:#de3333;color:#fff;height:28px;padding:0 12px;cursor:pointer;border-radius:3px}",
            "." + MODAL_CLASS + "-frame{width:100%;height:100%;border:0;background:oklch(17.5% .014 268)}",
            ".jhs-lite-lightbox{position:fixed;inset:0;z-index:2147483600;background:rgba(0,0,0,.92);overflow-y:auto;overscroll-behavior:contain;cursor:zoom-out}",
            ".jhs-lite-lightbox-frame{display:flex;min-height:100%;padding:20px;box-sizing:border-box}",
            ".jhs-lite-lightbox-frame img{margin:auto;display:block;height:auto;background:#fff;box-shadow:0 12px 48px rgba(0,0,0,.55)}",
            ".jhs-lite-lightbox-hint{position:fixed;left:50%;bottom:18px;transform:translateX(-50%);z-index:2;padding:8px 14px;border-radius:999px;background:rgba(12,12,16,.78);color:#f4f4f6;font:500 12.5px/1 system-ui,sans-serif;white-space:nowrap;pointer-events:none;transition:opacity .5s;max-width:92vw;overflow:hidden;text-overflow:ellipsis}",
            ".jhs-lite-lightbox-hint.is-fading{opacity:0}",
            ".jhs-lite-gallery{position:fixed;inset:0;z-index:2147483600;background:rgba(0,0,0,.92);display:flex;flex-direction:column;align-items:center;justify-content:center;padding:96px 24px;cursor:default}",
            ".jhs-lite-gallery-stage{width:min(1200px,94vw);flex:1 1 auto;min-height:0;overflow-y:auto;scrollbar-width:none;display:grid;grid-template-columns:repeat(auto-fill,minmax(340px,1fr));gap:14px;align-items:start}",
            ".jhs-lite-gallery img{display:block;width:100%;height:auto;object-fit:contain;background:#fff;box-shadow:0 10px 40px rgba(0,0,0,.5);cursor:zoom-in}",
            ".jhs-lite-gallery-stage::-webkit-scrollbar{display:none}",
            "@media(max-width:700px){.jhs-lite-gallery{padding:56px 10px}.jhs-lite-gallery-stage{grid-template-columns:repeat(auto-fill,minmax(220px,1fr));gap:8px}}",
            ".jhs-lite-toast{position:fixed;top:18px;left:50%;transform:translateX(-50%);z-index:2147483647;background:#222;color:#fff;padding:10px 16px;border-radius:5px;font-size:13px;box-shadow:0 5px 20px rgba(0,0,0,.25);max-width:80vw;text-align:center}",
            ".jhs-lite-toast.error{background:#c62828}",
            ".jhs-lite-toast.info{background:#1565c0}",
            ".jhs-lite-dialog-mask{position:fixed;inset:0;z-index:2147483646;background:rgba(0,0,0,.6);display:flex;align-items:center;justify-content:center;padding:18px}",
            ".jhs-lite-dialog{background:oklch(22% .018 268);color:oklch(96% .006 75);box-shadow:0 12px 40px rgba(0,0,0,.5);border-radius:4px;width:min(520px,94vw);max-height:88vh;display:flex;flex-direction:column}",
            ".jhs-lite-dialog h3{margin:0;padding:16px 18px;font-size:17px;border-bottom:1px solid oklch(30% .016 266)}",
            ".jhs-lite-dialog-body{padding:16px 18px;overflow:auto;line-height:1.65;font-size:14px}",
            ".jhs-lite-dialog-body label{display:block;font-weight:700;margin:0 0 6px;color:oklch(85% .010 262)}",
            ".jhs-lite-dialog-body input{width:100%;min-height:34px;padding:7px 8px;border:1px solid oklch(30% .016 266);border-radius:3px;font:13px/1.5 Consolas,monospace;box-sizing:border-box;background:oklch(17.5% .014 268);color:oklch(96% .006 75)}",
            ".jhs-lite-dialog-body textarea{width:100%;min-height:130px;resize:vertical;padding:8px;border:1px solid oklch(30% .016 266);border-radius:3px;font:13px/1.5 Consolas,monospace;box-sizing:border-box;background:oklch(17.5% .014 268);color:oklch(96% .006 75)}",
            ".jhs-lite-dialog-body .jhs-lite-emby-status{margin:0;padding:8px 10px;border-radius:3px;background:oklch(25% .017 268);color:oklch(85% .010 262);font-size:12.5px;line-height:1.6;word-break:break-word}",
            ".jhs-lite-dialog-body .jhs-lite-field+.jhs-lite-field{margin-top:16px}",
            ".jhs-lite-dialog-actions{display:flex;justify-content:flex-end;gap:8px;padding:12px 18px;border-top:1px solid oklch(30% .016 266)}",
            ".jhs-lite-dialog-actions button{min-width:78px;padding:7px 14px;border-radius:3px;border:1px solid oklch(30% .016 266);background:oklch(25% .017 268);color:oklch(85% .010 262);cursor:pointer}",
            ".jhs-lite-dialog-actions button:hover{background:oklch(30% .016 266);color:oklch(96% .006 75)}",
            ".jhs-lite-dialog-actions button.primary{border-color:oklch(77% .10 274);background:oklch(77% .10 274);color:oklch(19% .035 274)}",
            ".jhs-lite-dialog-actions button.primary:hover{background:oklch(84% .085 274)}",
            ".jhs-lite-dialog-actions button:focus-visible,.jhs-lite-dialog-body input:focus-visible,.jhs-lite-dialog-body textarea:focus-visible{outline:2px solid oklch(80% .10 274);outline-offset:1px}",
            "@media(max-width:700px){." + MODAL_CLASS + "-mask{padding:0}." + MODAL_CLASS + "-panel{width:100vw;height:100vh}.jhs-lite-dialog-mask{padding:10px}}",
            ".jhs-lite-filtered{display:none!important}",
            "." + CARD_CLASS + " .card__code{cursor:pointer}",
            "." + CARD_CLASS + " .card__code:hover{color:#ff5a5a!important}",
            "." + CARD_CLASS + " .card__code.jhs-lite-blocked-code{color:#ff5a5a!important}",
            "." + CARD_CLASS + " .card__title.jhs-lite-blocked-title{color:#ff5a5a!important}"
        ].join(""));
    }

    function applyVerticalMode(enabled, force) {
        const existing = document.getElementById(VERTICAL_STYLE_ID);
        const mode = enabled ? "vertical" : "horizontal";
        if (!force && existing && existing.dataset.mode === mode) return;
        if (existing) existing.remove();

        const imgPosition = window.location.href.includes("/search_advanced?type=100")
            ? "50% 50% !important"
            : "100% 50% !important";
        const css = enabled
            ? [
                ".cover{min-height:350px!important;overflow:hidden!important;padding-top:142%!important}",
                ".cover img{object-fit:cover!important;object-position:" + imgPosition + "}",
                ".jhs-lite-card .card__figure{aspect-ratio:100/142!important}",
                ".jhs-lite-card .card__figure>img{object-fit:cover!important;object-position:100% 50%!important}",
                ".masonry .movie-box img{min-height:500px!important;object-fit:cover!important;object-position:top right}"
            ].join("")
            : [
                ".cover{min-height:auto!important;padding-top:67%!important}",
                ".cover img{object-fit:contain!important;object-position:50% 50%!important}",
                ".jhs-lite-card .card__figure{aspect-ratio:800/538!important}",
                ".jhs-lite-card .card__figure>img{object-fit:cover!important;object-position:50% 50%!important}",
                ".masonry .movie-box img{min-height:auto!important;object-fit:contain!important;object-position:top}"
            ].join("");

        injectStyle(VERTICAL_STYLE_ID, css);
        document.getElementById(VERTICAL_STYLE_ID).dataset.mode = mode;
    }

    function normalizeContainerWidth(value) {
        const number = Number.parseInt(value, 10);
        if (!Number.isFinite(number)) return DEFAULT_CONTAINER_WIDTH;
        return Math.min(100, Math.max(70, number));
    }

    function getContainerWidth() {
        return normalizeContainerWidth(state.settings.containerWidth || DEFAULT_CONTAINER_WIDTH);
    }

    function applyContainerWidth() {
        const width = getContainerWidth();
        const css = [
            "section .container{max-width:1000px!important;min-width:" + width + "%}",
            ".movie-list,.movie-list.v{grid-template-columns:repeat(4,minmax(0,1fr))}"
        ].join("");
        injectStyle(WIDTH_STYLE_ID, css);
    }

    function injectChromeStyle() {
        if (SITE !== "javdb") return;
        const base = [
            ".pagination{gap:6px;justify-content:center!important;flex-wrap:wrap}",
            ".pagination-next,.pagination-previous{",
            "display:inline-flex!important;align-items:center;justify-content:center;gap:8px;",
            "min-width:132px!important;min-height:44px!important;padding:0 22px!important;margin:0!important;",
            "border:1px solid oklch(30% .016 266)!important;border-radius:999px!important;",
            "background:oklch(25% .017 268)!important;color:oklch(96% .006 75)!important;",
            "font:600 14px/1 var(--jhs-font-body)!important;text-decoration:none!important;",
            "transition:background-color .2s,color .2s,border-color .2s;",
            "}",
            ".pagination-next:hover,.pagination-previous:hover{",
            "background:oklch(44% .13 274)!important;border-color:oklch(44% .13 274)!important;color:oklch(96% .006 75)!important;",
            "}",
            ".pagination-next:focus-visible,.pagination-previous:focus-visible{",
            "outline:2px solid oklch(80% .10 274)!important;outline-offset:2px!important;",
            "}",
            ".pagination-list{gap:6px;align-items:center;margin:0!important}",
            ":root[data-theme=dark] .pagination .pagination-list .pagination-link:not(.is-current){",
            "background-color:oklch(25% .017 268)!important;border-color:oklch(30% .016 266)!important;color:oklch(85% .010 262)!important;",
            "}",
            ":root[data-theme=dark] .pagination .pagination-list .pagination-link:not(.is-current):hover{",
            "background-color:oklch(34% .012 262)!important;color:oklch(96% .006 75)!important;",
            "}",
            ":root[data-theme=dark] .pagination .pagination-list .pagination-link.is-current{",
            "background-color:oklch(83% .095 62)!important;border-color:oklch(83% .095 62)!important;color:oklch(20% .07 62)!important;",
            "}",
            ":root[data-theme=dark] .pagination .pagination-list .pagination-link.is-current:hover{",
            "background-color:oklch(88% .085 62)!important;border-color:oklch(88% .085 62)!important;color:oklch(20% .07 62)!important;",
            "}",
            ".pagination-link{",
            "min-width:44px!important;min-height:44px!important;padding:0 12px!important;margin:0!important;",
            "border:1px solid oklch(30% .016 266)!important;border-radius:999px!important;",
            "background-color:oklch(25% .017 268)!important;color:oklch(85% .010 262)!important;",
            "font:600 14px/1 var(--jhs-font-body)!important;",
            "}",
            ".pagination-link:hover{background-color:oklch(34% .012 262)!important;color:oklch(96% .006 75)!important}",
            ".pagination-link:focus-visible{outline:2px solid oklch(80% .10 274)!important;outline-offset:2px!important}",
            ".pagination-ellipsis{color:oklch(70% .016 262)!important;padding:0 4px}",
            "html,body{background:oklch(17.5% .014 268)!important;color:oklch(96% .006 75)!important}",
            ".main-nav{background:oklch(17.5% .014 268)!important;border-bottom:1px solid oklch(30% .016 266)!important}",
            ".main-nav .navbar-brand>.navbar-item{color:oklch(96% .006 75)!important}",
            ".main-nav .navbar-brand>.navbar-item:hover{background:oklch(25% .017 268)!important;color:oklch(96% .006 75)!important}",
            ".main-nav .navbar-link{color:oklch(85% .010 262)!important;background:transparent!important}",
            ".main-nav .navbar-link:hover{background:oklch(25% .017 268)!important;color:oklch(96% .006 75)!important}",
            ".main-nav .navbar-dropdown{background:oklch(21% .016 268)!important;border:1px solid oklch(30% .016 266)!important}",
            ".main-nav .navbar-dropdown .navbar-item{color:oklch(85% .010 262)!important}",
            ".main-nav .navbar-dropdown .navbar-item:hover{background:oklch(25% .017 268)!important;color:oklch(96% .006 75)!important}"
        ];
        base.push("#navbar-menu-user .jhs-lite-user-extra.is-hidden{display:none!important}");
        const top = isTopHidden()
            ? [
                "#navbar-menu-hero .navbar-start{display:none!important}",
                "#navbar-menu-hero .navbar-end{display:none!important}",
                ".sub-header{display:none!important}",
                ".moj-header{display:none!important}",
                ".search-bar-container{display:none!important}",
                "#search-image-modal,.modal#select-search-image-modal{display:none!important}",
                ".main-tabs-wrap{display:none!important}",
                ".toolbar{display:none!important}",
                "#footer{display:none!important}",
                ".float-buttons{display:none!important}",
                ".over18-modal{display:none!important}",
                ".app-desktop-banner{display:none!important}",
                ".section{padding:20px 18px 36px!important}",
                "section .container{background:transparent!important}",
                ".movie-list{margin-bottom:26px!important}",
                ".pagination{margin:4px 0 8px!important}"
            ]
            : [];
        injectStyle(CHROME_STYLE_ID, base.concat(top).join(""));
    }

    function isTopHidden() {
        return isYes(state.settings.hideTopChrome, true);
    }
    function hideUserMenuExtras() {
        if (SITE !== "javdb") return;
        const menu = document.getElementById("navbar-menu-user");
        if (!menu) return;
        menu.querySelectorAll(".navbar-item.has-dropdown").forEach(function (item) {
            const link = item.querySelector(":scope > a.navbar-link");
            const href = link ? (link.getAttribute("href") || "") : "";
            if (href.indexOf("?theme=") >= 0 || href.indexOf("?locale=") >= 0) {
                item.classList.add("jhs-lite-user-extra");
            }
        });
    }

    function applyUserMenuExtras() {
        if (SITE !== "javdb") return;
        const menu = document.getElementById("navbar-menu-user");
        if (!menu) return;
        menu.querySelectorAll(".jhs-lite-user-extra").forEach(function (item) {
            item.classList.toggle("is-hidden", isTopHidden());
        });
    }

    function registerMenuCommands() {
        if (!IS_TOP || typeof GM_registerMenuCommand !== "function") return;
        GM_registerMenuCommand("管理屏蔽词…", openKeywordManager);
        GM_registerMenuCommand("Emby 设置…", openEmbySettings);
        GM_registerMenuCommand("qBittorrent 设置…", openQbSettings);
        GM_registerMenuCommand("隐藏 / 显示顶部内容", toggleTopChrome);
    }

    async function toggleTopChrome() {
        const next = !isTopHidden();
        await saveSetting("hideTopChrome", next ? "yes" : "no");
        hideUserMenuExtras();
        injectChromeStyle();
        applyUserMenuExtras();
        toast(next ? "已隐藏顶部内容" : "已显示顶部内容");
    }

    function normalizeEmbyUrl(value) {
        return String(value || "").trim().replace(/\/+$/, "");
    }

    function getEmbySettings() {
        const stored = state.settings && typeof state.settings.emby === "object"
            ? state.settings.emby
            : {};
        return {
            url: normalizeEmbyUrl(stored.url) || CONFIG.emby.url,
            token: String(stored.token || CONFIG.emby.token),
            libraryId: Object.prototype.hasOwnProperty.call(stored, "libraryId")
                ? String(stored.libraryId || "")
                : String(CONFIG.emby.libraryId || ""),
            syncTtlHours: Number(stored.syncTtlHours) > 0
                ? Number(stored.syncTtlHours)
                : Math.round(CONFIG.embySyncTtl / (60 * 60 * 1000))
        };
    }

    function getQbSettings() {
        const stored = state.settings && typeof state.settings.qb === "object"
            ? state.settings.qb
            : {};
        return {
            host: normalizeQbHost(stored.host) || CONFIG.qb.host,
            apiKey: Object.prototype.hasOwnProperty.call(stored, "apiKey")
                ? String(stored.apiKey || "")
                : String(CONFIG.qb.apiKey || ""),
            savePath: Object.prototype.hasOwnProperty.call(stored, "savePath")
                ? String(stored.savePath || "")
                : String(CONFIG.qb.savePath || "")
        };
    }

    function normalizeQbHost(value) {
        return String(value || "").trim().replace(/\/+$/, "");
    }

    function applyQbSettings(settings) {
        CONFIG.qb.host = normalizeQbHost(settings.host);
        CONFIG.qb.apiKey = String(settings.apiKey || "").trim();
        CONFIG.qb.savePath = String(settings.savePath || "").trim();
        CONFIG.qb.category = "jav";
    }

    function applyEmbySettings(settings) {
        CONFIG.emby.url = normalizeEmbyUrl(settings.url);
        CONFIG.emby.token = String(settings.token || "").trim();
        CONFIG.emby.libraryId = String(settings.libraryId || "").trim();
        CONFIG.embySyncTtl = Math.max(1, Number(settings.syncTtlHours) || 1) * 60 * 60 * 1000;
    }

    function describeEmbyCacheAge() {
        if (!state.embyLastSync) return "尚未同步";
        const minutes = Math.max(0, Math.round((Date.now() - state.embyLastSync) / 60000));
        if (minutes < 1) return "刚刚同步";
        if (minutes < 60) return minutes + " 分钟前";
        const hours = Math.round(minutes / 60);
        if (hours < 48) return hours + " 小时前";
        return Math.round(hours / 24) + " 天前";
    }

    function openEmbySettings() {
        if (state.embySettingsDialog) return;
        const current = getEmbySettings();
        const mask = document.createElement("div");
        mask.className = "jhs-lite-dialog-mask";
        const dialog = document.createElement("div");
        dialog.className = "jhs-lite-dialog";
        dialog.setAttribute("role", "dialog");
        dialog.setAttribute("aria-modal", "true");
        dialog.innerHTML = [
            "<h3>JHS Lite · Emby 设置</h3>",
            "<div class=\"jhs-lite-dialog-body\">",
            "<div class=\"jhs-lite-field\"><label>服务器地址</label><input type=\"text\" class=\"jhs-lite-emby-url\" placeholder=\"http://<Emby 服务器地址>:8096\" spellcheck=\"false\"></div>",
            "<div class=\"jhs-lite-field\"><label>API 密钥</label><input type=\"password\" class=\"jhs-lite-emby-token\" placeholder=\"X-Emby-Token\" spellcheck=\"false\"></div>",
            "<div class=\"jhs-lite-field\"><label>JAV 媒体库 ID(留空为全部电影库)</label><input type=\"text\" class=\"jhs-lite-emby-library\" placeholder=\"例如 56777\" spellcheck=\"false\"></div>",
            "<div class=\"jhs-lite-field\"><label>库缓存有效期(小时)</label><input type=\"number\" min=\"1\" max=\"168\" class=\"jhs-lite-emby-ttl\"></div>",
            "<div class=\"jhs-lite-field\"><label>缓存状态</label><div class=\"jhs-lite-emby-status\"></div></div>",
            "</div>",
            "<div class=\"jhs-lite-dialog-actions\">",
            "<button type=\"button\" class=\"jhs-lite-emby-test\">测试连接</button>",
            "<button type=\"button\" class=\"jhs-lite-emby-refresh\">刷新缓存</button>",
            "<button type=\"button\" class=\"jhs-lite-cancel\">取消</button>",
            "<button type=\"button\" class=\"primary jhs-lite-save\">保存</button>",
            "</div>"
        ].join("");
        const urlInput = dialog.querySelector(".jhs-lite-emby-url");
        const tokenInput = dialog.querySelector(".jhs-lite-emby-token");
        const libraryInput = dialog.querySelector(".jhs-lite-emby-library");
        const ttlInput = dialog.querySelector(".jhs-lite-emby-ttl");
        const statusNode = dialog.querySelector(".jhs-lite-emby-status");
        urlInput.value = current.url;
        tokenInput.value = current.token;
        libraryInput.value = current.libraryId;
        ttlInput.value = String(current.syncTtlHours);
        statusNode.textContent = "已缓存 " + Object.keys(state.embyIndex).length + " 个番号 · "
            + describeEmbyCacheAge()
            + (state.embySyncError ? " · 上次同步失败:" + state.embySyncError : "");
        mask.appendChild(dialog);
        document.body.appendChild(mask);

        const currentDialog = {
            element: mask,
            onKeyDown: null
        };
        function close() {
            removeDialog(currentDialog);
            if (state.embySettingsDialog === currentDialog) state.embySettingsDialog = null;
        }
        currentDialog.onKeyDown = function (event) {
            if (event.key !== "Escape") return;
            event.preventDefault();
            event.stopPropagation();
            close();
        };
        document.addEventListener("keydown", currentDialog.onKeyDown, true);
        mask.addEventListener("click", function (event) {
            if (event.target === mask) close();
        });
        dialog.querySelector(".jhs-lite-cancel").addEventListener("click", close);
        dialog.querySelector(".jhs-lite-emby-test").addEventListener("click", async function () {
            const button = this;
            if (button.getAttribute("aria-busy") === "true") return;
            button.setAttribute("aria-busy", "true");
            button.textContent = "测试中…";
            const probe = {
                url: normalizeEmbyUrl(urlInput.value),
                token: tokenInput.value.trim(),
                libraryId: libraryInput.value.trim()
            };
            const previous = { url: CONFIG.emby.url, token: CONFIG.emby.token, libraryId: CONFIG.emby.libraryId };
            CONFIG.emby.url = probe.url;
            CONFIG.emby.token = probe.token;
            CONFIG.emby.libraryId = probe.libraryId;
            try {
                const info = await embyApi("/emby/System/Info", 8000);
                const count = await embyCountLibrary();
                statusNode.textContent = "连接成功:" + (info.ServerName || "Emby")
                    + " v" + (info.Version || "?")
                    + (count === null ? "" : " · 媒体库共 " + count + " 部影片");
            } catch (error) {
                statusNode.textContent = "连接失败:" + String(error.message || error);
            } finally {
                CONFIG.emby.url = previous.url;
                CONFIG.emby.token = previous.token;
                CONFIG.emby.libraryId = previous.libraryId;
                button.removeAttribute("aria-busy");
                button.textContent = "测试连接";
            }
        });
        dialog.querySelector(".jhs-lite-emby-refresh").addEventListener("click", async function () {
            const button = this;
            if (button.getAttribute("aria-busy") === "true") return;
            if (!isEmbyConfigured()) {
                statusNode.textContent = "请先填写地址与密钥";
                return;
            }
            if (state.embySyncing) {
                statusNode.textContent = "正在同步中,请稍候…";
                return;
            }
            button.setAttribute("aria-busy", "true");
            button.textContent = "刷新中…";
            statusNode.textContent = "正在拉取 Emby 媒体库…";
            await embySyncFull(true);
            if (state.embySyncError) {
                statusNode.textContent = "刷新失败:" + state.embySyncError;
            } else {
                statusNode.textContent = "已缓存 " + Object.keys(state.embyIndex).length
                    + " 个番号 · " + describeEmbyCacheAge();
                toast("Emby 缓存已更新");
            }
            button.removeAttribute("aria-busy");
            button.textContent = "刷新缓存";
        });
        dialog.querySelector(".jhs-lite-save").addEventListener("click", async function () {
            const settings = {
                url: normalizeEmbyUrl(urlInput.value),
                token: tokenInput.value.trim(),
                libraryId: libraryInput.value.trim(),
                syncTtlHours: Number(ttlInput.value) || 1
            };
            if (!settings.url || !settings.token) {
                toast("Emby 地址与密钥不能为空", "error");
                return;
            }
            if (!/^https?:\/\//i.test(settings.url)) {
                toast("Emby 地址需以 http:// 或 https:// 开头", "error");
                return;
            }
            const previous = Object.assign({}, state.settings.emby || {});
            applyEmbySettings(settings);
            await saveSetting("emby", settings);
            const identityChanged = previous.url !== settings.url
                || previous.libraryId !== settings.libraryId
                || previous.token !== settings.token;
            close();
            toast("Emby 设置已保存");
            refreshEmbyBadges();
            if (identityChanged) {
                state.embyIndex = {};
                state.embyLastSync = 0;
                state.embyMissed = new Set();
                saveEmbyCache();
                writeEmbyMissed();
                await embySyncFull(true);
                if (state.embySyncError) {
                    toast("Emby 同步失败:" + state.embySyncError, "error");
                } else {
                    toast("Emby 缓存已重建,共 " + Object.keys(state.embyIndex).length + " 个番号");
                }
                renderPushButtons();
            }
        });
        state.embySettingsDialog = currentDialog;
        urlInput.focus();
    }
    function openQbSettings() {
        if (state.qbSettingsDialog) return;
        const current = getQbSettings();
        const mask = document.createElement("div");
        mask.className = "jhs-lite-dialog-mask";
        const dialog = document.createElement("div");
        dialog.className = "jhs-lite-dialog";
        dialog.setAttribute("role", "dialog");
        dialog.setAttribute("aria-modal", "true");
        dialog.innerHTML = [
            "<h3>JHS Lite · qBittorrent 设置</h3>",
            "<div class=\"jhs-lite-dialog-body\">",
            "<div class=\"jhs-lite-field\"><label>Web UI 地址</label><input type=\"text\" class=\"jhs-lite-qb-host\" placeholder=\"http://<qB WebUI 地址>:8080\" spellcheck=\"false\"></div>",
            "<div class=\"jhs-lite-field\"><label>API 密钥</label><input type=\"password\" class=\"jhs-lite-qb-apikey\" placeholder=\"qbt_ 开头\" spellcheck=\"false\"></div>",
            "<div class=\"jhs-lite-field\"><label>保存路径</label><input type=\"text\" class=\"jhs-lite-qb-savepath\" placeholder=\"/data/downloads/jav\" spellcheck=\"false\"></div>",
            "<div class=\"jhs-lite-field\"><label>状态</label><div class=\"jhs-lite-qb-status\"></div></div>",
            "</div>",
            "<div class=\"jhs-lite-dialog-actions\">",
            "<button type=\"button\" class=\"jhs-lite-qb-test\">测试连接</button>",
            "<button type=\"button\" class=\"jhs-lite-cancel\">取消</button>",
            "<button type=\"button\" class=\"primary jhs-lite-save\">保存</button>",
            "</div>"
        ].join("");
        const hostInput = dialog.querySelector(".jhs-lite-qb-host");
        const apiKeyInput = dialog.querySelector(".jhs-lite-qb-apikey");
        const savePathInput = dialog.querySelector(".jhs-lite-qb-savepath");
        const statusNode = dialog.querySelector(".jhs-lite-qb-status");
        hostInput.value = current.host;
        apiKeyInput.value = current.apiKey;
        savePathInput.value = current.savePath;
        statusNode.textContent = CONFIG.qb.apiKey
            ? "已配置 API 密钥"
            : "尚未配置 API 密钥";
        mask.appendChild(dialog);
        document.body.appendChild(mask);

        const currentDialog = {
            element: mask,
            onKeyDown: null
        };
        function close() {
            removeDialog(currentDialog);
            if (state.qbSettingsDialog === currentDialog) state.qbSettingsDialog = null;
        }
        currentDialog.onKeyDown = function (event) {
            if (event.key !== "Escape") return;
            event.preventDefault();
            event.stopPropagation();
            close();
        };
        document.addEventListener("keydown", currentDialog.onKeyDown, true);
        mask.addEventListener("click", function (event) {
            if (event.target === mask) close();
        });
        dialog.querySelector(".jhs-lite-cancel").addEventListener("click", close);
        dialog.querySelector(".jhs-lite-qb-test").addEventListener("click", async function () {
            const button = this;
            if (button.getAttribute("aria-busy") === "true") return;
            button.setAttribute("aria-busy", "true");
            button.textContent = "测试中…";
            const previous = { host: CONFIG.qb.host, apiKey: CONFIG.qb.apiKey };
            CONFIG.qb.host = normalizeQbHost(hostInput.value);
            CONFIG.qb.apiKey = apiKeyInput.value.trim();
            try {
                const version = await qbProbe();
                statusNode.textContent = "连接成功:qBittorrent v" + version;
            } catch (error) {
                statusNode.textContent = "连接失败:" + String(error.message || error);
            } finally {
                CONFIG.qb.host = previous.host;
                CONFIG.qb.apiKey = previous.apiKey;
                button.removeAttribute("aria-busy");
                button.textContent = "测试连接";
            }
        });
        dialog.querySelector(".jhs-lite-save").addEventListener("click", async function () {
            const settings = {
                host: normalizeQbHost(hostInput.value),
                apiKey: apiKeyInput.value.trim(),
                savePath: savePathInput.value.trim()
            };
            if (!settings.host) {
                toast("qBittorrent 地址不能为空", "error");
                return;
            }
            if (!/^https?:\/\//i.test(settings.host)) {
                toast("qBittorrent 地址需以 http:// 或 https:// 开头", "error");
                return;
            }
            if (!settings.apiKey) {
                toast("请填写 API 密钥", "error");
                return;
            }
            if (!settings.savePath) {
                toast("保存路径不能为空", "error");
                return;
            }
            if (settings.savePath.indexOf("/") !== 0) {
                toast("保存路径需为绝对路径(以 / 开头)", "error");
                return;
            }
            applyQbSettings(settings);
            await saveSetting("qb", settings);
            close();
            toast("qBittorrent 设置已保存");
        });
        state.qbSettingsDialog = currentDialog;
        hostInput.focus();
    }

    function qbProbe() {
        return new Promise(function (resolve, reject) {
            if (!CONFIG.qb.apiKey) {
                reject(new Error("未配置 API 密钥"));
                return;
            }
            if (CONFIG.qb.apiKey.indexOf("qbt_") !== 0) {
                reject(new Error("API 密钥格式不正确,应以 qbt_ 开头"));
                return;
            }
            qbApiGet("/api/v2/app/version").then(resolve, reject);
        });
    }

    function qbApiGet(path) {
        return new Promise(function (resolve, reject) {
            GM_xmlhttpRequest({
                method: "GET",
                url: CONFIG.qb.host + path,
                headers: qbHeaders({}),
                timeout: 8000,
                onload: function (response) {
                    if (response.status === 401 || response.status === 403) {
                        reject(new Error("API 密钥无效或已撤销"));
                        return;
                    }
                    if (response.status < 200 || response.status >= 300) {
                        reject(new Error("HTTP " + response.status));
                        return;
                    }
                    resolve(String(response.responseText || "").trim().replace(/^"|"$/g, ""));
                },
                onerror: function () {
                    reject(new Error("无法连接 qBittorrent"));
                },
                ontimeout: function () {
                    reject(new Error("连接超时"));
                }
            });
        });
    }
    async function embyCountLibrary() {
        const params = [
            "Recursive=true",
            "IncludeItemTypes=Movie",
            "Fields=Path",
            "Limit=1"
        ];
        if (CONFIG.emby.libraryId) params.push("ParentId=" + encodeURIComponent(CONFIG.emby.libraryId));
        const data = await embyApi("/emby/Items?" + params.join("&"), 10000);
        return Number(data.TotalRecordCount) || null;
    }

    function toast(message, type, extraNode) {
        const old = document.querySelector(".jhs-lite-toast");
        if (old) old.remove();
        const element = document.createElement("div");
        element.className = "jhs-lite-toast" + (type ? " " + type : "");
        element.appendChild(document.createTextNode(message));
        if (extraNode) element.appendChild(extraNode);
        document.body.appendChild(element);
        window.setTimeout(function () {
            element.remove();
        }, 3000);
    }

    function removeDialog(dialog) {
        if (dialog && dialog.element && dialog.element.parentNode) {
            dialog.element.parentNode.removeChild(dialog.element);
        }
        if (dialog && dialog.onKeyDown) {
            document.removeEventListener("keydown", dialog.onKeyDown, true);
        }
    }

    function askConfirm(message) {
        return new Promise(function (resolve) {
            if (state.confirmDialog) {
                removeDialog(state.confirmDialog);
                state.confirmDialog.resolve(false);
                state.confirmDialog = null;
            }

            const mask = document.createElement("div");
            mask.className = "jhs-lite-dialog-mask";
            const dialog = document.createElement("div");
            dialog.className = "jhs-lite-dialog";
            dialog.setAttribute("role", "dialog");
            dialog.setAttribute("aria-modal", "true");

            const title = document.createElement("h3");
            title.textContent = "提示";
            const body = document.createElement("div");
            body.className = "jhs-lite-dialog-body";
            body.textContent = message;
            const actions = document.createElement("div");
            actions.className = "jhs-lite-dialog-actions";
            const cancel = document.createElement("button");
            cancel.type = "button";
            cancel.textContent = "取消";
            const confirm = document.createElement("button");
            confirm.type = "button";
            confirm.className = "primary";
            confirm.textContent = "确定";
            actions.appendChild(cancel);
            actions.appendChild(confirm);
            dialog.appendChild(title);
            dialog.appendChild(body);
            dialog.appendChild(actions);
            mask.appendChild(dialog);
            document.body.appendChild(mask);

            const current = {
                element: mask,
                resolve: resolve,
                onKeyDown: null
            };
            function finish(value) {
                removeDialog(current);
                if (state.confirmDialog === current) state.confirmDialog = null;
                resolve(value);
            }
            current.onKeyDown = function (event) {
                if (event.key !== "Escape") return;
                event.preventDefault();
                event.stopPropagation();
                finish(false);
            };
            document.addEventListener("keydown", current.onKeyDown, true);
            cancel.addEventListener("click", function () {
                finish(false);
            });
            confirm.addEventListener("click", function () {
                finish(true);
            });
            mask.addEventListener("click", function (event) {
                if (event.target === mask) finish(false);
            });
            state.confirmDialog = current;
            confirm.focus();
        });
    }

    function openKeywordManager() {
        if (state.keywordDialog) return;
        const mask = document.createElement("div");
        mask.className = "jhs-lite-dialog-mask";
        const dialog = document.createElement("div");
        dialog.className = "jhs-lite-dialog";
        dialog.setAttribute("role", "dialog");
        dialog.setAttribute("aria-modal", "true");
        dialog.innerHTML = [
            "<h3>JHS Lite 屏蔽词</h3>",
            "<div class=\"jhs-lite-dialog-body\">",
            "<div class=\"jhs-lite-field\"><label>标题屏蔽词(每行一个)</label><textarea class=\"jhs-lite-title-keywords\"></textarea></div>",
            "</div>",
            "<div class=\"jhs-lite-dialog-actions\"><button type=\"button\" class=\"jhs-lite-cancel\">取消</button><button type=\"button\" class=\"primary jhs-lite-save\">保存</button></div>"
        ].join("");
        const titleArea = dialog.querySelector(".jhs-lite-title-keywords");
        titleArea.value = state.titleKeywords.join("\n");
        mask.appendChild(dialog);
        document.body.appendChild(mask);

        const current = {
            element: mask,
            onKeyDown: null
        };
        function close() {
            removeDialog(current);
            if (state.keywordDialog === current) state.keywordDialog = null;
        }
        current.onKeyDown = function (event) {
            if (event.key !== "Escape") return;
            event.preventDefault();
            event.stopPropagation();
            close();
        };
        document.addEventListener("keydown", current.onKeyDown, true);
        dialog.querySelector(".jhs-lite-cancel").addEventListener("click", close);
        dialog.querySelector(".jhs-lite-save").addEventListener("click", async function () {
            await saveKeywordList(titleArea.value.split(/\r?\n/));
            close();
            toast("屏蔽词已保存");
        });
        mask.addEventListener("click", function (event) {
            if (event.target === mask) close();
        });
        state.keywordDialog = current;
        titleArea.focus();
    }
    function normalizeForMatch(value) {
        return String(value || "").toLowerCase();
    }

    function itemMatchesKeywords(item, keywords) {
        if (!keywords.length) return false;
        if (item.querySelector(":scope > .card__poster")) {
            const cardTitle = item.querySelector(".card__title");
            const cardCode = item.querySelector(".card__code");
            const cardText = [
                cardCode ? cardCode.textContent : "",
                cardTitle ? cardTitle.textContent : ""
            ].join(" ").toLowerCase();
            return keywords.some(function (keyword) {
                return cardText.includes(keyword.toLowerCase());
            });
        }
        const strong = item.querySelector(".video-title strong");
        const text = [
            getItemCarNum(item),
            getItemTitleText(item),
            strong ? strong.textContent : "",
            getItemImage(item)?.getAttribute("data-title") || ""
        ].join(" ").toLowerCase();
        return keywords.some(function (keyword) {
            return text.includes(keyword.toLowerCase());
        });
    }

    function itemMatchesBlocked(item) {
        const code = String(getItemCarNum(item) || "").trim().toUpperCase();
        if (!code) return false;
        if (/^\d{6,}/.test(code)) return true;
        const prefix = getCodePrefix(code);
        if (!prefix) return false;
        return state.blockedPrefixes.some(function (blocked) {
            const value = String(blocked || "").trim().toUpperCase();
            return value && code.startsWith(value);
        });
    }

    function getCodePrefix(code) {
        const match = String(code || "").toUpperCase().match(/^(?:FC2[- ]?(?:PPV[- ]?)?)?[A-Z]{2,}/);
        return match ? match[0] : "";
    }

    function initCodeBlockMenu() {
        document.addEventListener("click", function (event) {
            const codeNode = event.target.closest("." + CARD_CLASS + " .card__code");
            if (!codeNode) return;
            const item = codeNode.closest(getListItemSelector());
            if (!item) return;
            event.preventDefault();
            event.stopPropagation();
            const code = getItemCarNum(item);
            const prefix = getCodePrefix(code);
            if (!prefix) return;
            const title = item.querySelector(".card__title");
            addBlockedPrefix(prefix).then(function () {
                const currentItem = codeNode.closest(getListItemSelector());
                const currentTitle = currentItem ? currentItem.querySelector(".card__title") : title;
                if (currentTitle) {
                    currentTitle.textContent = "已加入屏蔽列表";
                    currentTitle.classList.add("jhs-lite-blocked-title");
                }
                const currentCode = currentItem ? currentItem.querySelector(".card__code") : codeNode;
                if (currentCode) currentCode.classList.add("jhs-lite-blocked-code");
                toast("已屏蔽前缀 " + prefix);
            });
        }, true);
    }

    function getListItemSelector() {
        return ".movie-list .item";
    }

    function getItemLink(item) {
        return item.querySelector("a[href]");
    }

    function getItemTitleNode(item) {
        return item.querySelector(".video-title");
    }

    function getItemTagsNode(item) {
        return item.querySelector(".tags");
    }

    function getItemImage(item) {
        return item.querySelector(".cover img, img");
    }

    function getItemTitleText(item) {
        const titleNode = getItemTitleNode(item);
        const image = getItemImage(item);
        const link = getItemLink(item);
        return [
            titleNode ? titleNode.textContent : "",
            image ? image.getAttribute("title") : "",
            image ? image.getAttribute("alt") : "",
            link ? link.getAttribute("title") : ""
        ].join(" ");
    }

    function getItemCardTitle(item) {
        const code = String(getItemCarNum(item) || "").trim();
        const image = getItemImage(item);
        const imageTitle = image
            ? String(image.getAttribute("title") || image.getAttribute("alt") || "").trim()
            : "";
        const titleNode = getItemTitleNode(item);
        let text = "";
        if (titleNode) {
            const clone = titleNode.cloneNode(true);
            clone.querySelectorAll(
                "strong, date, time, .item-tag, .tag, .meta, .score, .tags"
            ).forEach(function (node) {
                node.remove();
            });
            text = String(clone.textContent || "").replace(/\s+/g, " ").trim();
        }
        if (!text || (code && text === code)) text = imageTitle;
        if (code && text.toUpperCase().startsWith(code.toUpperCase())) {
            text = text.slice(code.length).replace(/^[\s/|·—-]+/, "").trim();
        }
        return text || imageTitle || code;
    }

    function filterListItems() {
        const items = document.querySelectorAll(getListItemSelector());
        for (const item of items) {
            if (item.querySelector(".avatar-box")) {
                item.classList.remove(LIST_ITEM_CLASS);
                removeCoverThumb(item);
                continue;
            }
            item.classList.toggle(LIST_ITEM_CLASS, itemMatchesKeywords(item, state.titleKeywords) || itemMatchesBlocked(item));
        }
        decorateCards();
        addCoverThumbs();
    }

    const COVER_THUMB_SVG = [
        "<svg viewBox=\"0 0 24 24\" aria-hidden=\"true\">",
        "<path d=\"M19 3H5a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2V5a2 2 0 0 0-2-2zm0 2v9.2l-3.3-3.3-4.3 4.3-2.2-2.2L5 17.4V5h14zM8 10a2 2 0 1 0 0-4 2 2 0 0 0 0 4z\"/>",
        "</svg>"
    ].join("");
    const COVER_SPINNER_SVG = [
        "<svg class=\"jhs-lite-chip-spinner\" viewBox=\"0 0 24 24\" aria-hidden=\"true\">",
        "<path d=\"M12 3a9 9 0 1 0 9 9\"/>",
        "</svg>"
    ].join("");
    const PUSH_ICON_SVG = [
        "<svg class=\"jhs-lite-push-icon\" viewBox=\"0 0 24 24\" width=\"16\" height=\"16\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" aria-hidden=\"true\">",
        "<path d=\"M12 4v10m0 0l-4-4m4 4l4-4M5 19h14\"/>",
        "</svg>"
    ].join("");

    function setThumbState(button, stateName) {
        if (!button) return;
        const label = button.querySelector(".jhs-lite-chip-label");
        if (!label) return;
        button.dataset.state = stateName;
        const icon = button.querySelector(".jhs-lite-chip-icon");
        const spinner = button.querySelector(".jhs-lite-chip-spinner");
        if (stateName === "loading") {
            if (icon) icon.style.display = "none";
            if (spinner) spinner.style.display = "block";
            label.textContent = "加载中";
            button.disabled = true;
            button.style.opacity = "1";
            return;
        }
        if (icon) icon.style.display = "";
        if (spinner) spinner.style.display = "none";
        label.textContent = stateName === "error" ? "加载失败" : "预览图";
        button.disabled = stateName === "error";
        button.style.opacity = "";
    }

    function removeCoverThumb(item) {
        item.querySelectorAll("." + COVER_THUMB_CLASS).forEach(function (button) {
            button.remove();
        });
    }

    function addCoverThumbs() {
        const items = document.querySelectorAll(getListItemSelector());
        for (const item of items) {
            if (item.querySelector(".avatar-box")) {
                removeCoverThumb(item);
                continue;
            }
            const poster = item.querySelector(".card__poster");
            if (!poster || poster.querySelector("." + COVER_THUMB_CLASS)) continue;

            const link = getItemLink(item);
            const url = link ? absoluteUrl(link.getAttribute("href")) : "";
            if (!url) continue;

            const button = document.createElement("button");
            button.type = "button";
            button.className = COVER_THUMB_CLASS;
            button.title = "预览图";
            button.setAttribute("aria-label", "预览图");
            button.dataset.state = "idle";
            button.innerHTML = [
                "<span class=\"jhs-lite-chip-icon\">", COVER_THUMB_SVG, "</span>",
                "<svg class=\"jhs-lite-chip-spinner\" viewBox=\"0 0 24 24\" aria-hidden=\"true\" style=\"display:none\">",
                "<path d=\"M12 3a9 9 0 1 0 9 9\"/></svg>",
                "<span class=\"jhs-lite-chip-label\">预览图</span>"
            ].join("");
            button.dataset.url = url;
            button.addEventListener("pointerup", function (event) {
                event.preventDefault();
                event.stopPropagation();
                openCoverThumbnail(button);
            });
            button.addEventListener("click", function (event) {
                event.preventDefault();
                event.stopPropagation();
                openCoverThumbnail(button);
            });
            poster.appendChild(button);
        }
    }

    async function openCoverThumbnail(button) {
        if (!button || button.dataset.loading === "yes") return;
        button.dataset.loading = "yes";
        setThumbState(button, "loading");
        const item = button.closest(".item");
        if (!item) {
            delete button.dataset.loading;
            setThumbState(button, "error");
            return;
        }
        const rawCarNum = getItemCarNum(item);
        const carNum = String(rawCarNum || "").trim();
        if (!carNum) {
            delete button.dataset.loading;
            setThumbState(button, "error");
            window.setTimeout(function () {
                if (button.isConnected && button.dataset.state === "error") setThumbState(button, "idle");
            }, 2500);
            return;
        }
        try {
            let stitchUrl = "";
            try {
                stitchUrl = await getScreenshot(carNum.replace(/^FC2-/, ""));
            } catch (error) {
                console.warn("[JHS Lite] JavStore 不可用,改用 JavDB 备用图源", error);
            }
            if (stitchUrl) {
                const loaded = await new Promise(function (resolve) {
                    const probe = new Image();
                    probe.onload = function () {
                        resolve(true);
                    };
                    probe.onerror = function () {
                        resolve(false);
                    };
                    probe.src = stitchUrl;
                });
                if (loaded) {
                    showLightbox(stitchUrl);
                    setThumbState(button, "idle");
                    return;
                }
                deleteScreenshotCache(carNum);
            }
            const currentSamples = collectSamplesFromDocument(document, window.location.href);
            const samples = currentSamples.length ? currentSamples : await getJavDbSampleFallback(carNum);
            if (!samples.length) throw new Error("未找到预览大图");
            showGallery(samples, 0, "备用图源:JavDB");
            setThumbState(button, "idle");
        } catch (error) {
            console.warn("[JHS Lite] 获取长缩略图失败", error);
            setThumbState(button, "error");
            window.setTimeout(function () {
                if (button.isConnected && button.dataset.state === "error") {
                    setThumbState(button, "idle");
                }
            }, 2500);
        } finally {
            delete button.dataset.loading;
            if (button.dataset.state !== "error") {
                button.disabled = false;
                button.style.opacity = "";
            }
        }
    }

    function renderPushButtons() {
        document.querySelectorAll("." + PUSH_CLASS).forEach(function (button) {
            const code = button.dataset.code;
            if (code) renderPushButton(button, code);
        });
    }

    function renderPushButton(button, code) {
        if (!button || !button.isConnected) return;
        const label = button.querySelector("." + PUSH_LABEL_CLASS);
        const item = getEmbyItem(code);
        const inEmby = Boolean(item);
        const pushed = isPushedCode(code);

        button.classList.remove("is-done", "is-error", "is-emby");
        button.disabled = false;
        button.removeAttribute("disabled");
        button.classList.toggle("is-emby", inEmby);
        button.classList.toggle("is-done", !inEmby && pushed);

        if (inEmby) {
            button.dataset.played = item.played ? "true" : "false";
            button.title = (item.played ? "已播放" : "未播放")
                + " · " + (item.name || code)
                + "\n点击在 Emby 中打开";
            button.setAttribute("aria-label", (item.played ? "已播放" : "未播放") + " " + code + ",点击在 Emby 中打开");
            if (label) label.textContent = item.played ? "已播放" : "未播放";
            const icon = button.querySelector(".jhs-lite-push-icon");
            if (icon) icon.style.display = "none";
            return;
        }

        button.dataset.played = "";
        if (pushed) {
            button.classList.add("is-done");
            button.title = code + " 已推送到 qBittorrent";
            button.setAttribute("aria-label", code + " 已推送到 qBittorrent");
            if (label) label.textContent = "已推送";
            const icon = button.querySelector(".jhs-lite-push-icon");
            if (icon) icon.style.display = "none";
            return;
        }

        button.title = "下载 " + code + " 到 qBittorrent";
        button.setAttribute("aria-label", "下载 " + code + " 到 qBittorrent");
        if (label) label.textContent = "下载";
        const icon = button.querySelector(".jhs-lite-push-icon");
        if (icon) icon.style.display = "";
    }

    function activatePushButton(code, button, item) {
        const embyItem = getEmbyItem(code);
        if (embyItem) {
            window.open(embyItemUrl(embyItem), "_blank");
            return;
        }
        if (isPushedCode(code)) return;
        pushItem(code, button, button.querySelector("." + PUSH_LABEL_CLASS), item);
    }

    function initCoverThumbs() {
        function handle(event) {
            const button = event.target.closest("." + COVER_THUMB_CLASS);
            if (!button) return;
            event.preventDefault();
            event.stopPropagation();
            openCoverThumbnail(button);
        }
        document.addEventListener("pointerdown", handle, true);
        document.addEventListener("click", handle, true);
    }

    function decorateCards() {
        renderPushButtons();
        const items = document.querySelectorAll(getListItemSelector());
        let index = 0;
        for (const item of items) {
            if (item.querySelector(".avatar-box")) {
                item.classList.remove(CARD_CLASS);
                delete item.dataset[CARD_READY_ATTR];
                continue;
            }
            const existingPoster = item.querySelector(".card__poster");
            if (existingPoster) {
                item.classList.add(CARD_CLASS);
                item.style.setProperty("--jhs-i", String(index % 8));
                index++;
                continue;
            }
            item.classList.add(CARD_CLASS);
            item.style.position = "relative";
            item.style.top = "auto";
            item.style.left = "auto";
            item.style.float = "none";
            item.style.setProperty("--jhs-i", String(index % 8));
            index++;

            const box = item.querySelector("a.box") || getItemLink(item);
            if (!box) continue;

            const tags = getItemTagsNode(item);
            const title = getItemTitleNode(item);
            const image = getItemImage(item);
            const code = getItemCarNum(item);
            if (!title || !image) continue;
            item.dataset[CARD_READY_ATTR] = "yes";

            const targetDate = item.querySelector(".meta, .photo-info date");
            const titleText = getItemCardTitle(item);
        const card = document.createElement("div");
            card.className = "card__body";

            const meta = document.createElement("div");
            meta.className = "card__meta";
            const codeElement = document.createElement("span");
            codeElement.className = "card__code";
            codeElement.textContent = code || "";
            const dateElement = document.createElement("time");
            dateElement.className = "card__date";
            const dateText = targetDate ? String(targetDate.textContent || "").trim().slice(0, 10) : "";
            if (dateText) {
                dateElement.textContent = dateText;
                dateElement.dateTime = dateText;
            }
            meta.appendChild(codeElement);
            meta.appendChild(dateElement);

            const row = document.createElement("div");
            row.className = "card__row";
            const titleElement = document.createElement("h3");
            titleElement.className = "card__title";
            titleElement.textContent = titleText;

            const actions = document.createElement("div");
            actions.className = "card__actions";
            const hasMagnet = Boolean(
                tags && tags.querySelector(
                    ".tag.is-success, .tag.is-warning"
                )
            );
            if (hasMagnet && code) {
                const push = document.createElement("button");
                push.type = "button";
                push.className = PUSH_CLASS;
                push.dataset.code = code;
                push.innerHTML = [
                    PUSH_ICON_SVG,
                    "<span class=\"" + PUSH_LABEL_CLASS + "\">下载</span>",
                    "<span class=\"" + PUSH_SPIN_CLASS + "\" aria-hidden=\"true\"></span>"
                ].join("");
                renderPushButton(push, code);
                push.addEventListener("click", function (event) {
                    event.preventDefault();
                    event.stopPropagation();
                    activatePushButton(code, push, item);
                });
                actions.appendChild(push);
            }

            card.appendChild(meta);
            row.appendChild(titleElement);
            row.appendChild(actions);
            card.appendChild(row);

            const poster = document.createElement("a");
            poster.className = "card__poster";
            poster.href = box.getAttribute("href") || "#";
            poster.setAttribute("aria-label", (code || "影片") + " 详情页");
            const figure = document.createElement("figure");
            figure.className = "card__figure";
            const coverImage = image.cloneNode(true);
            coverImage.removeAttribute("class");
            coverImage.removeAttribute("style");
            coverImage.classList.add("card__cover-image");
            coverImage.removeAttribute("width");
            coverImage.removeAttribute("height");
            figure.appendChild(coverImage);
            poster.appendChild(figure);

            const emby = document.createElement("span");
            emby.className = EMBY_CLASS;
            emby.dataset.state = "loading";
            emby.dataset.code = code;
            poster.appendChild(emby);

            item.replaceChildren();
            item.appendChild(poster);
            item.appendChild(card);

            if (code) decorateEmbyBadge(emby, code);
            else emby.remove();
        }
        const list = document.querySelector(".movie-list");
        if (list) list.classList.add(CARD_CLASS + "-grid");
        addCoverThumbs();
    }

    function observeListItems() {
        filterListItems();
        const root = document.querySelector(".movie-list");
        if (!root) {
            window.setTimeout(observeListItems, 800);
            return;
        }
        if (state.listObserver) state.listObserver.disconnect();
        let scheduled = false;
        state.listObserver = new MutationObserver(function () {
            if (scheduled) return;
            scheduled = true;
            window.setTimeout(function () {
                scheduled = false;
                filterListItems();
            }, 120);
        });
        state.listObserver.observe(root, { childList: true, subtree: true });
    }

    function isDetailPage() {
        return window.location.pathname.includes("/v/");
        return Boolean(document.querySelector("#magnet-table, #sample-waterfall, .movie-information"));
    }

    function getSelectedTextInTarget(event, selector) {
        const target = event.target.closest(selector);
        if (!target) return null;
        const selection = window.getSelection();
        if (!selection || selection.isCollapsed) return null;
        const text = selection.toString().trim();
        if (!text) return null;
        const anchor = selection.anchorNode;
        if (anchor && !target.contains(anchor)) return null;
        return text;
    }

    function initSelectionFilter() {
        document.addEventListener("contextmenu", async function (event) {
            if (!isDetailPage()) return;

            const titleText = getSelectedTextInTarget(event, titleSelector);
            if (!titleText) return;
            event.preventDefault();
            event.stopPropagation();
            const confirmed = await askConfirm(
                "是否屏蔽标题关键词“" + titleText + "”?"
            );
            if (!confirmed) return;
            const added = await addKeyword(titleText);
            if (!added) return;
            filterListItems();
            toast("已加入标题屏蔽词");
            closeParentPopup();
        }, true);
    }

    function closeParentPopup() {
        if (IS_TOP) return;
        window.setTimeout(function () {
            try {
                if (window.parent && typeof window.parent.__jhsLiteClosePopup === "function") {
                    window.parent.__jhsLiteClosePopup();
                }
            } catch (error) {
                console.warn("[JHS Lite] 关闭父页面弹窗失败", error);
            }
        }, 180);
    }

    function getItemCarNum(item) {
        const cardCode = item.querySelector(".card__code");
        if (cardCode && cardCode.textContent.trim()) return cardCode.textContent.trim();
        const link = getItemLink(item);
        if (link) {
            const box = item.querySelector(".box");
            if (box) {
                const direct = box.querySelector(":scope > .video-title strong");
                if (direct && direct.textContent.trim()) return direct.textContent.trim();
                const date = box.querySelector("date");
                if (date && date.textContent.trim()) return date.textContent.trim();
                if (SITE !== "javdb") {
                    const match = String(link.getAttribute("href") || "").match(/[^/?#]+$/);
                    if (match) return decodeURIComponent(match[0]);
                }
            }
        }
        const strong = item.querySelector(".video-title strong");
        const titleNode = item.querySelector(".video-title");
        const image = item.querySelector("img");
        const titledLink = item.querySelector("a[title]");
        const candidates = [
            strong ? strong.textContent : "",
            titleNode ? titleNode.textContent : "",
            image ? image.getAttribute("title") : "",
            image ? image.getAttribute("alt") : "",
            titledLink ? titledLink.getAttribute("title") : ""
        ];
        for (const candidate of candidates) {
            const match = String(candidate || "").match(/(?:FC2[- ]?(?:PPV[- ]?)?)?[A-Z]{2,}[-_]?\d{2,}/i);
            if (match) return match[0];
        }
        return "";
    }

    function absoluteUrl(url) {
        try {
            return new URL(url, window.location.origin).href;
        } catch (error) {
            return "";
        }
    }

    function withHideNav(url) {
        try {
            const parsed = new URL(url, window.location.origin);
            if (parsed.pathname.includes("/actors/") || parsed.pathname.includes("/star/")) {
                return parsed.href;
            }
            parsed.searchParams.set("hideNav", "1");
            return parsed.href;
        } catch (error) {
            return url;
        }
    }

    function applyPopupEmbedTheme() {
        if (SITE !== "javdb") return;
        if (window.top === window) return;
        forceDarkTheme();
        let inPopup = false;
        try {
            inPopup = new URLSearchParams(window.location.search).get("hideNav") === "1";
        } catch (error) {
            inPopup = false;
        }
        if (!inPopup) return;
        const css = [
            "html,body{background:oklch(17.5% .014 268)!important;color:oklch(96% .006 75)!important}",
            ".section{padding:0!important}",
            ".app-desktop-banner{display:none!important}",
            ".sub-header{display:none!important}",
            ".moj-header{display:none!important}",
            "html{scrollbar-width:none!important;-ms-overflow-style:none!important}",
            "html::-webkit-scrollbar,body::-webkit-scrollbar{width:0!important;height:0!important;display:none!important}",
            ".top-meta{display:none!important}",
            ".sda-content{display:none!important}",
            ".main-nav{display:none!important}",
            "#footer{display:none!important}",
            ".float-buttons{display:none!important}",
            "body{padding-top:0!important}",
            ".movie-meta,.video-meta{flex-direction:column!important}",
            ".preview-images,.sample-box,.tile-item{background:transparent!important}",
            ".box,.panel,.message{background:oklch(22% .018 268)!important;color:oklch(85% .01 262)!important}",
            "table,td,th{color:oklch(85% .01 262)!important;border-color:oklch(30% .016 266)!important}",
            "a{color:oklch(80% .10 274)!important}",
            "a:hover{color:oklch(84% .085 274)!important}"
        ].join("");
        injectStyle(POPUP_EMBED_STYLE_ID, css);
    }

    function forceDarkTheme() {
        if (SITE !== "javdb") return;
        const root = document.documentElement;
        if (!root) return;
        const meta = document.querySelector("meta[name='theme']");
        if (meta) meta.setAttribute("content", "dark");
        root.setAttribute("data-theme", "dark");
        injectStyle(DARK_THEME_STYLE_ID, [
            ":root{color-scheme:dark}",
            "html,body{background-color:#0a0a0a!important;color:#dbdbdb!important}"
        ].join(""));
    }

    function openPopup(url, title) {
        closePopup();
        const mask = document.createElement("div");
        mask.className = MODAL_CLASS + "-mask";
        const panel = document.createElement("div");
        panel.className = MODAL_CLASS + "-panel";
        const bar = document.createElement("div");
        bar.className = MODAL_CLASS + "-bar";
        const titleElement = document.createElement("div");
        titleElement.className = MODAL_CLASS + "-title";
        titleElement.textContent = title || "详情";
        const closeButton = document.createElement("button");
        closeButton.type = "button";
        closeButton.className = MODAL_CLASS + "-close";
        closeButton.textContent = "关闭";
        const frame = document.createElement("iframe");
        frame.className = MODAL_CLASS + "-frame";
        frame.src = withHideNav(url);
        frame.setAttribute("allowfullscreen", "");
        frame.setAttribute("referrerpolicy", "same-origin");
        bar.appendChild(titleElement);
        bar.appendChild(closeButton);
        panel.appendChild(bar);
        panel.appendChild(frame);
        mask.appendChild(panel);

        const previousOverflow = document.documentElement.style.overflow;
        document.documentElement.style.overflow = "hidden";
        const current = {
            element: mask,
            previousOverflow: previousOverflow,
            onKeyDown: null
        };
        function close() {
            if (state.modal !== current) return;
            if (current.onKeyDown) {
                document.removeEventListener("keydown", current.onKeyDown, true);
            }
            if (mask.parentNode) mask.parentNode.removeChild(mask);
            document.documentElement.style.overflow = current.previousOverflow;
            state.modal = null;
        }
        current.onKeyDown = function (event) {
            if (event.key !== "Escape") return;
            event.preventDefault();
            event.stopPropagation();
            close();
        };
        closeButton.addEventListener("click", close);
        mask.addEventListener("click", function (event) {
            if (event.target === mask) close();
        });
        document.addEventListener("keydown", current.onKeyDown, true);
        document.body.appendChild(mask);
        state.modal = current;
        window.__jhsLiteClosePopup = close;
        closeButton.focus();
    }

    function closePopup() {
        if (!state.modal) return;
        const modal = state.modal;
        if (modal.onKeyDown) {
            document.removeEventListener("keydown", modal.onKeyDown, true);
        }
        if (modal.element && modal.element.parentNode) {
            modal.element.parentNode.removeChild(modal.element);
        }
        document.documentElement.style.overflow = modal.previousOverflow;
        state.modal = null;
    }

    function openInNewTab(url) {
        if (typeof GM_openInTab === "function") {
            GM_openInTab(url, { active: true, insert: true, setParent: true });
        } else {
            window.open(url, "_blank", "noopener");
        }
    }

    function initPopupOpen() {
        document.addEventListener("click", function (event) {
            if (event.defaultPrevented || event.button !== 0) return;
            if (event.ctrlKey || event.metaKey || event.shiftKey || event.altKey) {
                const image = event.target.closest(".item img");
                if (!image) return;
                const item = image.closest(".item");
                if (!item || item.querySelector(".avatar-box")) return;
                const link = image.closest("a[href]") || item.querySelector("a[href]");
                const url = link ? absoluteUrl(link.getAttribute("href")) : "";
                if (!url) return;
                event.preventDefault();
                event.stopPropagation();
                openInNewTab(url);
                return;
            }

            const image = event.target.closest(".item img");
            if (!image) return;
            const item = image.closest(".item");
            if (!item || item.querySelector(".avatar-box")) return;
            if (event.target.closest(".meta-buttons, .jhs-lite-thumb, ." + COVER_THUMB_CLASS)) return;
            const link = image.closest("a[href]") || item.querySelector("a[href]");
            const url = link ? absoluteUrl(link.getAttribute("href")) : "";
            if (!url) return;
            event.preventDefault();
            event.stopPropagation();
            openPopup(url, getItemCarNum(item));
        }, true);
    }

    function waitForElement(selector, timeout) {
        return new Promise(function (resolve) {
            const immediate = document.querySelector(selector);
            if (immediate) {
                resolve(immediate);
                return;
            }
            let settled = false;
            const observer = new MutationObserver(function () {
                const element = document.querySelector(selector);
                if (!element || settled) return;
                settled = true;
                observer.disconnect();
                clearTimeout(timer);
                resolve(element);
            });
            observer.observe(document.documentElement, { childList: true, subtree: true });
            const timer = window.setTimeout(function () {
                if (settled) return;
                settled = true;
                observer.disconnect();
                resolve(null);
            }, timeout || 12000);
        });
    }

    function getDetailCarNum() {
        const copyLink = document.querySelector(
            "a[title='複製番號'], a[title='复制番号'], a[title='Copy number']"
        );
        const direct = copyLink
            ? copyLink.getAttribute("data-clipboard-text") || copyLink.textContent
            : "";
        if (direct && direct.trim()) return direct.trim();
        const source = document.querySelector(".title strong, .current-title, h2.title");
        const match = source && source.textContent.match(
            /\b(?:FC2[- ]?(?:PPV[- ]?)?)?[A-Z]{2,}[-_]?\d{2,}\b/i
        );
        return match ? match[0] : "";
    }

    function createThumbPlaceholder() {
        const element = document.createElement("a");
        element.className = THUMB_CLASS + " tile-item";
        if (SITE === "javdb") {
            element.style.maxHeight = "215px";
        } else {
            element.style.height = "110px";
        }
        element.setAttribute("role", "button");
        element.setAttribute("tabindex", "0");
        element.dataset.role = "loading";
        element.innerHTML = "<div class=\"jhs-lite-thumb-message\">正在加载缩略图</div>";

        if (SITE === "javdb") {
            const container = document.querySelector(".preview-images");
            if (!container) return null;
            const firstTile = container.querySelector(".tile-item");
            container.insertBefore(element, firstTile || container.firstChild);
        } else {
            const container = document.querySelector("#sample-waterfall");
            if (!container) return null;
            const firstSample = container.querySelector(".sample-box");
            container.insertBefore(element, firstSample || container.firstChild);
        }
        return element;
    }

    function closeGallery() {
        if (!state.gallery) return;
        const gallery = state.gallery;
        state.gallery = null;
        document.removeEventListener("keydown", gallery.onKeyDown, true);
        if (gallery.element && gallery.onWheel) {
            gallery.element.removeEventListener("wheel", gallery.onWheel);
        }
        if (gallery.element && gallery.element.parentNode) gallery.element.remove();
        document.documentElement.style.overflow = gallery.previousOverflow;
    }

    function showGallery(images, startIndex, sourceLabel) {
        closeGallery();
        if (!images || !images.length) return;

        const root = document.createElement("div");
        root.className = "jhs-lite-gallery";
        root.setAttribute("role", "dialog");
        root.setAttribute("aria-modal", "true");
        root.setAttribute("aria-label", "长缩略图");

        const stage = document.createElement("div");
        stage.className = "jhs-lite-gallery-stage";
        for (const url of images) {
            const image = document.createElement("img");
            image.src = url;
            image.alt = "长缩略图";
            image.loading = "lazy";
            image.addEventListener("click", function (event) {
                event.stopPropagation();
                showLightbox(url);
            });
            stage.appendChild(image);
        }

        root.appendChild(stage);
        document.body.appendChild(root);

        const previousOverflow = document.documentElement.style.overflow;
        document.documentElement.style.overflow = "hidden";

        const gallery = {
            element: root,
            previousOverflow: previousOverflow,
            onKeyDown: null
        };
        state.gallery = gallery;

        gallery.onKeyDown = function (event) {
            if (event.key === "Escape") {
                event.preventDefault();
                event.stopPropagation();
                closeGallery();
            }
        };
        document.addEventListener("keydown", gallery.onKeyDown, true);
        gallery.onWheel = function (event) {
            if (!root.contains(event.target)) return;
            event.preventDefault();
            stage.scrollTop += event.deltaY;
        };
        root.addEventListener("wheel", gallery.onWheel, { passive: false });
        root.addEventListener("click", function (event) {
            if (event.target === root) closeGallery();
        });
        stage.addEventListener("click", function (event) {
            if (event.target === stage) closeGallery();
        });
    }

    function showLightbox(imgUrl) {
        const old = document.querySelector(".jhs-lite-lightbox");
        if (old) old.remove();
        const previousBodyOverflow = document.body.style.overflow;
        document.body.style.overflow = "hidden";
        const mask = document.createElement("div");
        mask.className = "jhs-lite-lightbox";
        const image = document.createElement("img");
        image.src = imgUrl;
        image.alt = "长缩略图";
        image.decoding = "async";
        const frame = document.createElement("div");
        frame.className = "jhs-lite-lightbox-frame";
        frame.appendChild(image);
        mask.appendChild(frame);
        const hint = document.createElement("div");
        hint.className = "jhs-lite-lightbox-hint";
        hint.textContent = "滚轮 / 拖动滚动 · Esc 关闭";
        mask.appendChild(hint);
        let hintTimer = null;
        function fadeHint() {
            if (hintTimer) window.clearTimeout(hintTimer);
            hintTimer = window.setTimeout(function () {
                hint.classList.add("is-fading");
            }, 1600);
        }
        fadeHint();
        mask.addEventListener("wheel", function () {
            hint.classList.add("is-fading");
        }, { passive: true });
        function applyZoom() {
            const viewportWidth = Math.max(320, window.innerWidth - 40);
            const viewportHeight = Math.max(320, window.innerHeight - 40);
            const naturalWidth = image.naturalWidth;
            const naturalHeight = image.naturalHeight;
            if (!naturalWidth || !naturalHeight) {
                image.style.width = viewportWidth + "px";
                image.style.maxWidth = "none";
                return;
            }
            const ratio = naturalWidth / naturalHeight;
            const fitByWidth = viewportWidth;
            const fitByHeight = viewportHeight * ratio;
            const fitWidth = Math.min(fitByWidth, fitByHeight);
            const finalWidth = Math.round(fitWidth * LIGHTBOX_FIT_SCALE);
            image.style.width = Math.round(finalWidth) + "px";
            image.style.maxWidth = "none";
        }
        if (image.complete && image.naturalWidth) applyZoom();
        image.addEventListener("load", applyZoom);
        window.setTimeout(applyZoom, 0);
        function close() {
            mask.remove();
            if (!state.gallery) document.body.style.overflow = previousBodyOverflow;
            if (hintTimer) window.clearTimeout(hintTimer);
            document.removeEventListener("keydown", onKeyDown, true);
        }
        function onKeyDown(event) {
            if (event.key !== "Escape") return;
            event.preventDefault();
            event.stopPropagation();
            close();
        }
        mask.addEventListener("click", close);
        image.addEventListener("click", function (event) {
            event.stopPropagation();
            close();
        });
        document.addEventListener("keydown", onKeyDown, true);
        document.body.appendChild(mask);
    }

    function renderLongThumbnail(element, imgUrl) {
        element.dataset.role = "image";
        element.replaceChildren();
        const image = document.createElement("img");
        image.src = imgUrl;
        image.alt = "缩略图";
        image.loading = "lazy";
        if (SITE === "javdb") {
            element.appendChild(image);
        } else {
            const frame = document.createElement("div");
            frame.className = "photo-frame";
            image.title = "缩略图";
            image.style.height = "inherit";
            image.style.width = "100%";
            frame.appendChild(image);
            element.appendChild(frame);
        }
        element.onclick = function (event) {
            event.preventDefault();
            event.stopPropagation();
            showLightbox(imgUrl);
        };
    }

    function renderThumbnailError(element, carNum) {
        element.dataset.role = "error";
        const searchUrl = "https://javstore.net/search?q=" + encodeURIComponent(
            String(carNum).toLowerCase().replace("fc2-", "")
        );
        element.replaceChildren();
        const message = document.createElement("div");
        message.className = "jhs-lite-thumb-message";
        message.appendChild(document.createTextNode("获取缩略图失败"));
        message.appendChild(document.createElement("br"));
        const retry = document.createElement("a");
        retry.href = "#";
        retry.className = "jhs-lite-retry";
        retry.textContent = "点击重试";
        const confirm = document.createElement("a");
        confirm.href = searchUrl;
        confirm.target = "_blank";
        confirm.rel = "noopener noreferrer";
        confirm.textContent = "前往确认";
        message.appendChild(retry);
        message.appendChild(document.createTextNode(" 或 "));
        message.appendChild(confirm);
        element.appendChild(message);
        element.onclick = function (event) {
            if (!event.target.closest(".jhs-lite-retry")) return;
            event.preventDefault();
            event.stopPropagation();
            loadLongThumbnail(element, carNum);
        };
    }

    function gmRequestText(url, timeout) {
        const request = typeof GM_xmlhttpRequest === "function"
            ? GM_xmlhttpRequest
            : (typeof GM !== "undefined" && GM && typeof GM.xmlHttpRequest === "function"
                ? GM.xmlHttpRequest.bind(GM)
                : null);
        if (!request) {
            return fetch(url, { credentials: "omit" }).then(function (response) {
                if (!response.ok) throw new Error("HTTP " + response.status);
                return response.text();
            });
        }
        return new Promise(function (resolve, reject) {
            request({
                method: "GET",
                url: url,
                timeout: timeout || 12000,
                headers: { Accept: "text/html,application/xhtml+xml" },
                onload: function (response) {
                    if (response.status >= 200 && response.status < 300) {
                        resolve(response.responseText);
                    } else {
                        reject(new Error("HTTP " + response.status));
                    }
                },
                onerror: function () {
                    reject(new Error("网络请求失败"));
                },
                ontimeout: function () {
                    reject(new Error("网络请求超时"));
                }
            });
        });
    }

    function parseHtml(html) {
        return new DOMParser().parseFromString(html, "text/html");
    }

    function normalizeCarNumForSearch(carNum) {
        return String(carNum)
            .toLowerCase()
            .replace(/fc2-(ppv-)?/g, "")
            .replace(/-/g, "");
    }

    async function fetchScreenshot(carNum) {
        const searchKeyword = String(carNum).toLowerCase().replace("fc2-", "");
        const searchUrl = "https://javstore.net/search?q=" + encodeURIComponent(searchKeyword);
        const searchHtml = await gmRequestText(searchUrl);
        const searchDoc = parseHtml(searchHtml);
        const target = normalizeCarNumForSearch(carNum);
        const isResultLink = function (link) {
            const href = link.getAttribute("href") || "";
            const text = String(link.textContent || "");
            if (!href || href.startsWith("#") || href === "/") return false;
            return /javstore\.net|\/archives\/|\/article\/|\/\?p=\d+/i.test(href)
                || target && normalizeCarNumForSearch(href + " " + text).includes(target);
        };
        const links = Array.from(searchDoc.querySelectorAll("a[href]")).filter(isResultLink);
        let detailUrl = "";
        for (const link of links) {
            const href = link.getAttribute("href") || "";
            const normalizedHref = href
                .toLowerCase()
                .replace(/fc2-(ppv-)?/g, "")
                .replace(/-/g, "");
            if (normalizedHref.includes(target)
                || normalizeCarNumForSearch(link.textContent || "").includes(target)) {
                detailUrl = new URL(href, "https://javstore.net").href;
                break;
            }
        }
        if (!detailUrl) throw new Error("JavStore 未找到对应番号");

        const detailHtml = await gmRequestText(detailUrl);
        const detailDoc = parseHtml(detailHtml);
        let imageUrl = "";
        const detailLinks = detailDoc.querySelectorAll("a[href]");
        for (const link of detailLinks) {
            if (/CLICK\s+HERE/i.test(link.textContent || "")) {
                imageUrl = link.getAttribute("href") || "";
                if (imageUrl) break;
            }
        }
        if (!imageUrl) {
            for (const selector of [
                "img[src*='_s.jpg']",
                "img[src*='/imgs/']",
                "main img[src]",
                ".entry-content img[src]"
            ]) {
                const image = detailDoc.querySelector(selector);
                const src = image ? image.getAttribute("src") || "" : "";
                if (/\.(?:jpe?g|png)(?:[?#]|$)/i.test(src)) {
                    imageUrl = src;
                    break;
                }
            }
        }
        if (!imageUrl) throw new Error("JavStore 页面未找到缩略图");
        imageUrl = new URL(imageUrl, detailUrl).href.replace(".th", "");
        if (imageUrl.startsWith("http://")) imageUrl = imageUrl.replace(/^http:\/\//, "https://");
        return imageUrl;
    }

    async function getScreenshot(carNum) {
        const cache = readLocalJson(CACHE_SCREENSHOT, {});
        if (cache && cache[carNum]) return cache[carNum];
        const imageUrl = await fetchScreenshot(carNum);
        if (!cache || typeof cache !== "object" || Array.isArray(cache)) {
            writeLocalJson(CACHE_SCREENSHOT, { [carNum]: imageUrl });
        } else {
            cache[carNum] = imageUrl;
            writeLocalJson(CACHE_SCREENSHOT, cache);
        }
        return imageUrl;
    }

    function deleteScreenshotCache(carNum) {
        const cache = readLocalJson(CACHE_SCREENSHOT, {});
        if (!cache || typeof cache !== "object") return;
        let changed = false;
        for (const key of [carNum, String(carNum).replace(/^FC2-/, ""), carNum.startsWith("FC2-") ? carNum : "FC2-" + carNum]) {
            if (Object.prototype.hasOwnProperty.call(cache, key)) {
                delete cache[key];
                changed = true;
            }
        }
        if (changed) writeLocalJson(CACHE_SCREENSHOT, cache);
    }

    function embyCodeKey(value) {
        return String(value || "").toUpperCase()
            .replace(/[_-—]/g, "-")
            .replace(/\s+/g, "")
            .replace(/^-+|-+$/g, "")
            .replace(/[^A-Z0-9]/g, "");
    }

    function loadPushedSet() {
        try {
            const raw = typeof GM_getValue === "function"
                ? GM_getValue("jhs_lite_pushed_codes", "[]")
                : (localStorage.getItem(localKey("pushedCodes")) || "[]");
            const parsed = JSON.parse(raw);
            state.pushedCodes = new Set(Array.isArray(parsed) ? parsed.filter(function (item) {
                return typeof item === "string";
            }) : []);
        } catch (error) {
            state.pushedCodes = new Set();
        }
    }

    function savePushedCode(code) {
        const key = embyCodeKey(code);
        if (!key) return;
        state.pushedCodes.add(key);
        const list = Array.from(state.pushedCodes);
        if (typeof GM_setValue === "function") {
            GM_setValue("jhs_lite_pushed_codes", JSON.stringify(list));
            return;
        }
        writeLocalJson(localKey("pushedCodes"), list);
    }

    function isPushedCode(code) {
        return state.pushedCodes.has(embyCodeKey(code));
    }

    function loadEmbyCache() {
        state.embyMissed = new Set();
        try {
            state.embyIndex = JSON.parse(
                typeof GM_getValue === "function"
                    ? GM_getValue("emby_library_index", "{}")
                    : (localStorage.getItem(localKey("embyIndex")) || "{}")
            ) || {};
        } catch (error) {
            state.embyIndex = {};
        }
        try {
            const rawMissed = typeof GM_getValue === "function"
                ? GM_getValue("emby_missed_codes", "[]")
                : (localStorage.getItem(localKey("embyMissed")) || "[]");
            const parsedMissed = JSON.parse(rawMissed);
            state.embyMissed = new Set(Array.isArray(parsedMissed)
                ? parsedMissed.filter(function (item) {
                    return typeof item === "string";
                })
                : []);
        } catch (error) {
            state.embyMissed = new Set();
        }
        const rawLastSync = typeof GM_getValue === "function"
            ? Number(GM_getValue("emby_last_sync", 0)) || 0
            : Number(localStorage.getItem(localKey("embyLastSync")) || 0);
        state.embyLastSync = rawLastSync;
        state.embySyncError = typeof GM_getValue === "function"
            ? String(GM_getValue("emby_sync_error", "") || "")
            : String(localStorage.getItem(localKey("embySyncError")) || "");
        state.embyPending = new Map();
        state.embySyncing = false;
    }

    function writeEmbyMissed() {
        const list = Array.from(state.embyMissed);
        if (typeof GM_setValue === "function") {
            GM_setValue("emby_missed_codes", JSON.stringify(list));
            return;
        }
        writeLocalJson(localKey("embyMissed"), list);
    }

    function saveEmbyCache() {
        if (typeof GM_setValue === "function") {
            GM_setValue("emby_library_index", JSON.stringify(state.embyIndex));
            GM_setValue("emby_last_sync", state.embyLastSync);
            GM_setValue("emby_sync_error", state.embySyncError);
            return;
        }
        writeLocalJson(localKey("embyIndex"), state.embyIndex);
        localStorage.setItem(localKey("embyLastSync"), String(state.embyLastSync));
        localStorage.setItem(localKey("embySyncError"), state.embySyncError);
    }

    function isEmbyCacheFresh() {
        const ttl = Number(CONFIG.embySyncTtl) || 0;
        if (!ttl || !state.embyLastSync) return false;
        return Date.now() - state.embyLastSync < ttl;
    }

    function embyApi(path, timeout) {
        return new Promise(function (resolve, reject) {
            if (typeof GM_xmlhttpRequest !== "function") {
                reject(new Error("当前环境不支持 GM_xmlhttpRequest"));
                return;
            }
            GM_xmlhttpRequest({
                method: "GET",
                url: CONFIG.emby.url + path,
                headers: {
                    "X-Emby-Token": CONFIG.emby.token,
                    "Accept": "application/json"
                },
                timeout: timeout || 10000,
                onload: function (response) {
                    if (response.status === 401) {
                        reject(new Error("Emby 密钥无效"));
                        return;
                    }
                    if (response.status === 403) {
                        reject(new Error("Emby 拒绝访问(密钥无该媒体库权限)"));
                        return;
                    }
                    if (response.status < 200 || response.status >= 300) {
                        reject(new Error("Emby HTTP " + response.status));
                        return;
                    }
                    try {
                        resolve(JSON.parse(response.responseText));
                    } catch (error) {
                        reject(new Error("Emby 返回非 JSON"));
                    }
                },
                onerror: function () {
                    reject(new Error("无法连接 Emby"));
                },
                ontimeout: function () {
                    reject(new Error("Emby 超时"));
                }
            });
        });
    }

    async function embySyncFull(force) {
        if (state.embySyncing) return state.embySyncing;
        if (!force && isEmbyCacheFresh()) return null;
        state.embySyncing = (async function () {
            state.embySyncError = "";
            const next = {};
            let offset = 0;
            try {
                for (let page = 0; page < CONFIG.maxPages; page++) {
                    const params = [
                        "Recursive=true",
                        "IncludeItemTypes=Movie",
                        "Fields=Path,UserData,ProviderIds",
                        "Limit=" + CONFIG.pageSize,
                        "StartIndex=" + offset
                    ];
                    if (CONFIG.emby.libraryId) params.push("ParentId=" + encodeURIComponent(CONFIG.emby.libraryId));
                    const data = await embyApi("/emby/Items?" + params.join("&"), 15000);
                    const items = Array.isArray(data.Items) ? data.Items : [];
                    const total = Number(data.TotalRecordCount) || 0;
                    for (const item of items) {
                        const key = embyCodeKey(item.Name);
                        if (!key) continue;
                        const userData = item.UserData || {};
                        next[key] = {
                            id: item.Id,
                            name: item.Name,
                            path: item.Path || "",
                            played: Boolean(userData.Played) || Number(userData.PlayCount || 0) > 0,
                            playCount: Number(userData.PlayCount || 0),
                            positionTicks: Number(userData.PlaybackPositionTicks || 0),
                            serverId: item.ServerId || ""
                        };
                    }
                    offset += items.length;
                    if (!items.length || (total && offset >= total)) break;
                }
                state.embyIndex = next;
                state.embyLastSync = Date.now();
                console.log("[JHS Lite] Emby 同步完成,共 " + Object.keys(next).length + " 个番号");
                saveEmbyCache();
                state.embyMissed = new Set();
                writeEmbyMissed();
                refreshEmbyBadges();
                renderPushButtons();
            } catch (error) {
                state.embySyncError = String(error.message || error);
                console.warn("[JHS Lite] Emby 同步失败", error);
            } finally {
                state.embySyncing = false;
            }
            return null;
        })();
        return state.embySyncing;
    }

    function isEmbyConfigured() {
        return Boolean(CONFIG.emby.url && CONFIG.emby.token);
    }

    function getEmbyItem(code) {
        const key = embyCodeKey(code);
        if (!key) return null;
        return state.embyIndex[key] || null;
    }

    function embyItemUrl(info) {
        if (!info) return CONFIG.emby.url;
        return CONFIG.emby.url + "/web/#!/item?id=" + encodeURIComponent(info.id)
            + "&serverId=" + encodeURIComponent(info.serverId || "");
    }

    async function embyQuery(code) {
        const key = embyCodeKey(code);
        if (!key) return null;
        if (state.embyPending.has(key)) return state.embyPending.get(key);
        if (state.embyMissed && state.embyMissed.has(key)) return null;
        if (state.embyPending.size >= CONFIG.queryConcurrency) return null;
        const task = embyApi(
            "/emby/Items?Recursive=true&IncludeItemTypes=Movie&Fields=Path,UserData"
            + "&Limit=20&SearchTerm=" + encodeURIComponent(code)
            + (CONFIG.emby.libraryId ? "&ParentId=" + encodeURIComponent(CONFIG.emby.libraryId) : ""),
            6000
        ).then(function (data) {
            const items = Array.isArray(data.Items) ? data.Items : [];
            let match = items.find(function (item) {
                return embyCodeKey(item.Name) === key;
            });
            if (!match) {
                match = items.find(function (item) {
                    const fromPath = embyCodeKey(String(item.Path || "").split("/").filter(Boolean).pop() || "");
                    return fromPath === key;
                });
            }
            if (match) {
                const userData = match.UserData || {};
                state.embyIndex[key] = {
                    id: match.Id,
                    name: match.Name,
                    path: match.Path || "",
                    played: Boolean(userData.Played) || Number(userData.PlayCount || 0) > 0,
                    playCount: Number(userData.PlayCount || 0),
                    positionTicks: Number(userData.PlaybackPositionTicks || 0),
                    serverId: match.ServerId || ""
                };
                saveEmbyCache();
            }
            if (!match) {
                state.embyMissed.add(key);
                writeEmbyMissed();
            }
            return match ? state.embyIndex[key] : null;
        }).catch(function () {
            return state.embyIndex[key] || null;
        }).finally(function () {
            state.embyPending.delete(key);
        });
        state.embyPending.set(key, task);
        return task;
    }

    function renderEmbyBadge(badge, code, stateName, info) {
        if (!badge || !badge.isConnected) return;
        badge.dataset.state = stateName;
        const card = badge.closest("." + CARD_CLASS);
        if (card) card.classList.toggle("jhs-lite-in-library", stateName === "exists");
        if (stateName !== "exists") {
            badge.replaceChildren();
            badge.onclick = null;
            badge.removeAttribute("title");
            return;
        }
        badge.dataset.code = code;
        badge.dataset.played = info && info.played ? "true" : "false";
        badge.replaceChildren();
        const dot = document.createElement("span");
        dot.className = EMBY_DOT_CLASS;
        const text = document.createElement("span");
        text.textContent = info && info.played ? "已播放" : "已入库";
        badge.title = [info && info.name || code, "点击在 Emby 中打开"]
            .filter(Boolean).join("\n");
        badge.onclick = function (event) {
            event.preventDefault();
            event.stopPropagation();
            window.open(embyItemUrl(info), "_blank");
        };
        badge.appendChild(dot);
        badge.appendChild(text);
    }

    function refreshEmbyBadges() {
        document.querySelectorAll("." + EMBY_CLASS).forEach(function (badge) {
            const item = badge.closest(getListItemSelector());
            const code = badge.dataset.code
                || item && item.querySelector(".card__code") && item.querySelector(".card__code").textContent
                || item && getItemCarNum(item);
            if (code) decorateEmbyBadge(badge, code);
        });
    }

    async function decorateEmbyBadge(badge, code) {
        if (!badge || !badge.isConnected) return;
        if (!isEmbyConfigured()) {
            renderEmbyBadge(badge, code, "unconfigured", "");
            return;
        }
        const cached = getEmbyItem(code);
        if (cached) {
            renderEmbyBadge(badge, code, "exists", cached);
            return;
        }
        if (state.embySyncError) {
            renderEmbyBadge(badge, code, "error", state.embySyncError);
            return;
        }
        renderEmbyBadge(badge, code, "missing", null);
        if (state.embySyncing) return;
        const fresh = await embyQuery(code);
        if (!badge.isConnected) return;
        if (fresh) {
            renderEmbyBadge(badge, code, "exists", fresh);
        } else if (state.embySyncError) {
            renderEmbyBadge(badge, code, "error", state.embySyncError);
        } else {
            renderEmbyBadge(badge, code, "missing", null);
        }
    }

    function qbHeaders(extra) {
        const headers = Object.assign({}, extra);
        if (CONFIG.qb.apiKey && CONFIG.qb.apiKey.indexOf("qbt_") === 0) {
            headers.Authorization = "Bearer " + CONFIG.qb.apiKey;
        }
        headers.Referer = CONFIG.qb.host + "/";
        return headers;
    }

    function qbEnsure() {
        if (!CONFIG.qb.apiKey) {
            return Promise.reject(new Error("未配置 qBittorrent API 密钥"));
        }
        if (CONFIG.qb.apiKey.indexOf("qbt_") !== 0) {
            return Promise.reject(new Error("API 密钥格式不正确,应以 qbt_ 开头"));
        }
        return Promise.resolve();
    }

    function qbPush(magnet) {
        return new Promise(function (resolve, reject) {
            GM_xmlhttpRequest({
                method: "POST",
                url: CONFIG.qb.host + "/api/v2/torrents/add",
                headers: qbHeaders({ "Content-Type": "application/x-www-form-urlencoded" }),
                data: "urls=" + encodeURIComponent(magnet)
                    + "&savepath=" + encodeURIComponent(CONFIG.qb.savePath)
                    + "&category=" + encodeURIComponent(CONFIG.qb.category),
                timeout: 8000,
                onload: function (response) {
                    if (response.status !== 200) {
                        if (response.status === 401 || response.status === 403) {
                            reject(new Error("API 密钥无效或已撤销"));
                            return;
                        }
                        reject(new Error("推送失败 HTTP " + response.status));
                        return;
                    }
                    const text = (response.responseText || "").trim();
                    if (!text || text.startsWith("Ok")) {
                        resolve();
                        return;
                    }
                    try {
                        const data = JSON.parse(text);
                        if (Number(data.failure_count) > 0) {
                            reject(new Error("推送失败: " + text));
                            return;
                        }
                        if (Number(data.success_count) >= 1) {
                            resolve();
                            return;
                        }
                    } catch (error) {
                    }
                    reject(new Error("推送失败: " + (text.length > 120 ? text.slice(0, 120) + "…" : text)));
                },
                onerror: function () {
                    reject(new Error("推送请求失败"));
                }
            });
        });
    }

    function extractMagnets(html) {
        const doc = parseHtml(html);
        const list = [];
        const area = doc.querySelector("#magnets-content") || doc.body;
        area.querySelectorAll("a[href^='magnet:']").forEach(function (anchor) {
            list.push({ url: anchor.getAttribute("href"), text: anchor.textContent || "" });
        });
        if (!list.length) {
            const regex = /magnet:\?xt=urn:btih:[a-z0-9]+[^\s"'<>]*/gi;
            let match;
            while ((match = regex.exec(html)) !== null) {
                list.push({ url: match[0], text: "" });
            }
        }
        return list;
    }

    function pickBestMagnet(list) {
        if (!list.length) return null;
        const hd = /2160p|4k|1080p|hevc|h\.?265|10bit/i;
        const best = list.find(function (item) {
            return hd.test(item.url) || hd.test(item.text);
        });
        return (best || list[0]).url;
    }

    async function fetchMagnetFor(code) {
        const searchUrl = "https://javdb.com/search?q=" + encodeURIComponent(code) + "&f=all";
        const searchHtml = await gmRequestText(searchUrl);
        const doc = parseHtml(searchHtml);
        const target = String(code).toUpperCase();
        let detailUrl = "";
        const cards = Array.from(doc.querySelectorAll(".movie-list .item, .item"));
        for (const card of cards) {
            const uid = card.querySelector(".video-title strong");
            const text = uid ? uid.textContent.trim().toUpperCase() : "";
            const link = card.querySelector("a[href*='/v/']");
            if (link && (text === target || text.startsWith(target))) {
                detailUrl = new URL(link.getAttribute("href"), "https://javdb.com").href;
                break;
            }
        }
        if (!detailUrl) throw new Error("JavDB 未找到 " + target);
        const detailHtml = await gmRequestText(detailUrl);
        const magnet = pickBestMagnet(extractMagnets(detailHtml));
        if (!magnet) throw new Error("未找到磁力链接");
        return magnet;
    }

    function notify(title, text) {
        try {
            if (typeof GM_notification === "function") {
                GM_notification({ title: title, text: text, timeout: 3000 });
            }
        } catch (error) {
            console.warn("[JHS Lite] 通知失败", error);
        }
    }

    async function pushItem(code, button, label, card) {
        if (!CONFIG.qb.apiKey) {
            button.classList.add("is-error");
            label.textContent = "未配置 qbit";
            notify("未配置 qBittorrent", "请通过脚本菜单「qBittorrent 设置…」配置后再推送");
            window.setTimeout(function () {
                if (!card.isConnected) return;
                renderPushButton(button, code);
            }, 2500);
            return;
        }
        if (button.getAttribute("aria-busy") === "true") return;
        button.setAttribute("aria-busy", "true");
        button.classList.remove("is-done", "is-error");
        const stages = ["获取磁力", "连接 qbit", "推送中"];
        let stage = 0;
        label.textContent = stages[0];
        try {
            const magnet = await fetchMagnetFor(code);
            if (!card.isConnected) return;
            stage = 1;
            label.textContent = stages[1];
            await qbEnsure();
            if (!card.isConnected) return;
            stage = 2;
            label.textContent = stages[2];
            await qbPush(magnet);
            if (!card.isConnected) return;
            button.removeAttribute("aria-busy");
            button.disabled = true;
            label.textContent = "已推送";
            savePushedCode(code);
            renderPushButton(button, code);
            notify("推送成功", code + " 已推送到 qBittorrent");
        } catch (error) {
            if (!card.isConnected) return;
            button.removeAttribute("aria-busy");
            button.classList.add("is-error");
            label.textContent = "推送失败";
            console.warn("[JHS Lite] 推送失败", error);
            const raw = String(error.message || error);
            const prefix = stage === 0
                ? "获取磁力失败:"
                : (raw.indexOf("API 密钥") >= 0 || raw.indexOf("qBittorrent") >= 0
                    ? "qBittorrent:"
                    : "qBittorrent 连接失败:");
            notify("推送失败", prefix + raw);
            window.setTimeout(function () {
                if (!card.isConnected) return;
                renderPushButton(button, code);
            }, 2500);
        }
    }

    function normalizeSampleUrl(url, pageUrl) {
        try {
            const parsed = new URL(url, pageUrl || "https://javdb.com/");
            return parsed.href.replace(/\/samples\/(.{2})\/([^/]+)_s_(\d+)\.(jpe?g|png|webp)$/i, "/samples/$1/$2_l_$3.$4");
        } catch (error) {
            return "";
        }
    }

    function collectSamplesFromDocument(doc, pageUrl) {
        const seen = new Set();
        const result = [];
        const nodes = doc.querySelectorAll(
            ".preview-images a[href], .preview-images img[src], .sample-box a[href], .sample-box img[src], #sample-waterfall a[href], #sample-waterfall img[src]"
        );
        for (const node of nodes) {
            const raw = node.getAttribute("href") || node.getAttribute("src") || "";
            if (!raw || raw === "#" || raw.startsWith("data:") || raw.startsWith("javascript:")) continue;
            const normalized = normalizeSampleUrl(raw, pageUrl);
            if (!/\/samples\//i.test(normalized) || !/\.(?:jpe?g|png|webp)(?:[?#]|$)/i.test(normalized)) continue;
            const key = normalized.replace(/[?#].*$/, "");
            if (seen.has(key)) continue;
            seen.add(key);
            result.push(normalized);
        }
        return result;
    }

    async function getJavDbSampleFallback(carNum) {
        const current = collectSamplesFromDocument(document, window.location.href);
        if (current.length) return current;

        const keyword = encodeURIComponent(String(carNum).trim());
        const searchUrls = [
            "https://javdb.com/search?q=" + keyword + "&f=all",
            "https://javdb.com/search?q=" + keyword
        ];
        let detailUrl = "";
        for (const searchUrl of searchUrls) {
            const html = await gmRequestText(searchUrl);
            const doc = parseHtml(html);
            const anchors = Array.from(doc.querySelectorAll("a[href*='/v/']"));
            const target = normalizeCarNumForSearch(carNum);
            const hit = anchors.find(function (anchor) {
                const box = anchor.closest(".item") || anchor.parentElement;
                const text = (anchor.textContent || "") + " " + (box ? box.textContent : "");
                return normalizeCarNumForSearch(text).includes(target);
            });
            if (hit) {
                detailUrl = new URL(hit.getAttribute("href"), "https://javdb.com/").href;
                break;
            }
        }
        if (!detailUrl) throw new Error("JavDB 未找到对应番号");

        const detailHtml = await gmRequestText(detailUrl);
        const detailDoc = parseHtml(detailHtml);
        let samples = collectSamplesFromDocument(detailDoc, detailUrl);
        if (!samples.length) {
            samples = Array.from(detailDoc.querySelectorAll(".tile-item img[src], .preview-images img[src]"))
                .map(function (image) {
                    return image.getAttribute("src") || "";
                })
                .filter(Boolean)
                .map(function (src) {
                    return new URL(src, detailUrl).href;
                });
        }
        if (!samples.length) throw new Error("JavDB 未找到预览大图");
        return samples;
    }

    async function loadLongThumbnail(existingElement, existingCarNum) {
        if (!isDetailPage()) return;
        const carNum = existingCarNum || getDetailCarNum();
        if (!carNum) return;
        if (!existingElement) {
            const selector = ".preview-images";
            await waitForElement(selector, 12000);
            if (document.querySelector("." + THUMB_CLASS)) return;
        }
        const element = existingElement || createThumbPlaceholder();
        if (!element) return;
        element.dataset.carNum = carNum;
        element.innerHTML = "<div class=\"jhs-lite-thumb-message\">正在加载缩略图</div>";
        try {
            const imageUrl = await getScreenshot(carNum);
            renderLongThumbnail(element, imageUrl);
        } catch (error) {
            console.warn("[JHS Lite] 获取长缩略图失败", error);
            renderThumbnailError(element, carNum);
        }
    }

    async function init() {
        injectBaseStyle();
        forceDarkTheme();
        applyPopupEmbedTheme();
        initChannel();
        await loadState();
        injectChromeStyle();
        hideUserMenuExtras();
        applyUserMenuExtras();
        loadEmbyCache();
        loadPushedSet();
        applyVerticalMode(true, true);
        applyContainerWidth();
        if (IS_TOP) registerMenuCommands();
        if (document.querySelector(".movie-list")) {
            observeListItems();
        } else {
            window.setTimeout(function () {
                if (document.querySelector(".movie-list")) {
                    observeListItems();
                }
            }, 800);
        }
        initSelectionFilter();
        initPopupOpen();
        initCodeBlockMenu();
        initCoverThumbs();
        decorateCards();
        addCoverThumbs();
        if (isDetailPage()) {
            await loadLongThumbnail();
        } else {
            const detailWatcher = setInterval(function () {
                if (!isDetailPage()) return;
                clearInterval(detailWatcher);
                loadLongThumbnail();
            }, 800);
            window.setTimeout(function () {
                clearInterval(detailWatcher);
            }, 30000);
        }
        state.ready = true;
        if (isEmbyConfigured()) {
            embySyncFull(false);
        }
        window.__JHS_LITE__ = {
            state: state,
            getSetting: function (key, defaultValue) {
                return isYes(state.settings[key], defaultValue);
            },
            setSetting: async function (key, value) {
                await saveSetting(key, value);
            },
            saveKeywords: saveKeywordList,
            filterListItems: filterListItems,
            openPopup: openPopup,
            closePopup: closePopup,
            loadLongThumbnail: loadLongThumbnail,
            addCoverThumbs: addCoverThumbs,
            applyContainerWidth: applyContainerWidth,
            applyVerticalMode: applyVerticalMode,
            showGallery: showGallery,
            openCover: openCoverThumbnail,
            askConfirm: askConfirm
        };
    }

    init().catch(function (error) {
        console.error("[JHS Lite] 初始化失败", error);
    });
})();