☰

javdb plus

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

You will need to install an extension such as Tampermonkey, Greasemonkey or Violentmonkey to install this script.

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

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

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

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

You will need to install a user script manager extension to install this script.

(Tôi đã có Trình quản lý tập lệnh người dùng, hãy cài đặt nó!)

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

(I already have a user style manager, let me install it!)

// ==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);
    });
})();