☰

javdb plus

竖图 Dusk 卡片、预览图、划词屏蔽、弹窗打开、Emby 入库检测与 qBittorrent 推送。

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.

(У мене вже є менеджер скриптів, дайте мені встановити його!)

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.2
// @author       Vithur
// @description  竖图 Dusk 卡片、预览图、划词屏蔽、弹窗打开、Emby 入库检测与 qBittorrent 推送。
// @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
// @connect      *
// @run-at       document-idle
// ==/UserScript==

(function () {
    "use strict";

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

    const IS_TOP = window.top === window;
    const DB_NAME = "JAVDB-PLUS";
    const DB_STORE = "appData";
    const CACHE_SCREENSHOT = "javdb_plus_screenShot";
    const LOCAL_PREFIX = "javdb_plus_";
    const STYLE_ID = "javdb-plus-style";
    const VERTICAL_STYLE_ID = "javdb-plus-vertical-style";
    const LIST_ITEM_CLASS = "javdb-plus-filtered";
    const THUMB_CLASS = "javdb-plus-thumb";
    const COVER_THUMB_CLASS = "javdb-plus-cover-thumb";
    const MODAL_CLASS = "javdb-plus-popup";
    const WIDTH_STYLE_ID = "javdb-plus-width-style";
    const CHROME_STYLE_ID = "javdb-plus-chrome-style";
    const POPUP_EMBED_STYLE_ID = "javdb-plus-popup-embed-style";
    const DARK_THEME_STYLE_ID = "javdb-plus-dark-theme-style";
    const DEFAULT_CONTAINER_WIDTH = 100;
    const CHANNEL_NAME = "javdb-plus-channel";
    const CARD_CLASS = "javdb-plus-card";
    const CARD_READY_ATTR = "javdbPlusCard";
    const EMBY_CLASS = "javdb-plus-emby";
    const EMBY_DOT_CLASS = "javdb-plus-emby-dot";
    const LIGHTBOX_FIT_SCALE = 2.5;
    const PUSH_CLASS = "javdb-plus-push";
    const PUSH_SPIN_CLASS = "javdb-plus-push-spin";
    const PUSH_LABEL_CLASS = "javdb-plus-push-label";
    const CONFIG = {
        emby: {
            url: "",
            token: "",
            libraryId: ""
        },
        qb: {
            host: "",
            apiKey: "",
            savePath: "/downloads",
            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();
        // 仅适配 javdb(含 javdb580.com 等镜像域名)
        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("[javdb plus] localStorage 写入失败", error);
        }
    }

    function openJavdbPlusDb() {
        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 readJavdbPlusValue(key) {
        try {
            const db = await openJavdbPlusDb();
            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 writeJavdbPlusValue(key, value) {
        try {
            const db = await openJavdbPlusDb();
            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("[javdb plus] 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 readJavdbPlusValue("setting");
        const storedTitleKeywords = await readJavdbPlusValue("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 writeJavdbPlusValue("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 writeJavdbPlusValue("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("[javdb plus] 状态广播失败", 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 + " .javdb-plus-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 + "{--javdb-plus-i:0;--javdb-plus-font-display:\"Wix Madefor Display\",ui-sans-serif,sans-serif;--javdb-plus-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:javdb-plus-card-enter .6s cubic-bezier(.16,1,.3,1) both;animation-delay:calc(var(--javdb-plus-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 javdb-plus-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(--javdb-plus-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 + " .javdb-plus-chip-icon svg," + "." + COVER_THUMB_CLASS + " .javdb-plus-chip-spinner{display:block;width:16px;height:16px;fill:none;stroke:currentColor;stroke-width:2;stroke-linecap:round;pointer-events:none}",
            "." + COVER_THUMB_CLASS + " .javdb-plus-chip-spinner{animation:javdb-plus-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(--javdb-plus-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:javdb-plus-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 .javdb-plus-push-icon{display:none}",
            "@keyframes javdb-plus-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(--javdb-plus-font-body);color:oklch(96% .006 75);overflow-wrap:break-word;min-width:0}",
            "." + CARD_CLASS + " .card__date{font:400 13px/1 var(--javdb-plus-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(--javdb-plus-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 + ".javdb-plus-in-library .card__cover-image{filter:grayscale(1)!important}",
            "." + CARD_CLASS + ".javdb-plus-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] .javdb-plus-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:javdb-plus-spin .8s linear infinite}",
            "@keyframes javdb-plus-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 + "{--javdb-plus-i:0}}",
            "@media(prefers-reduced-motion:reduce){." + CARD_CLASS + "{animation:none;transition:none}." + EMBY_DOT_CLASS + ",.javdb-plus-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)}",
            ".javdb-plus-lightbox{position:fixed;inset:0;z-index:2147483600;background:rgba(0,0,0,.92);overflow-y:auto;overscroll-behavior:contain;cursor:zoom-out}",
            ".javdb-plus-lightbox-frame{display:flex;min-height:100%;padding:20px;box-sizing:border-box}",
            ".javdb-plus-lightbox-frame img{margin:auto;display:block;height:auto;background:#fff;box-shadow:0 12px 48px rgba(0,0,0,.55)}",
            ".javdb-plus-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}",
            ".javdb-plus-lightbox-hint.is-fading{opacity:0}",
            ".javdb-plus-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}",
            ".javdb-plus-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}",
            ".javdb-plus-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}",
            ".javdb-plus-gallery-stage::-webkit-scrollbar{display:none}",
            "@media(max-width:700px){.javdb-plus-gallery{padding:56px 10px}.javdb-plus-gallery-stage{grid-template-columns:repeat(auto-fill,minmax(220px,1fr));gap:8px}}",
            ".javdb-plus-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}",
            ".javdb-plus-toast.error{background:#c62828}",
            ".javdb-plus-toast.info{background:#1565c0}",
            // 所有弹窗统一 Dusk 暗色:面板 slate、输入框 paper、主按钮 periwinkle
            ".javdb-plus-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}",
            ".javdb-plus-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}",
            ".javdb-plus-dialog h3{margin:0;padding:16px 18px;font-size:17px;border-bottom:1px solid oklch(30% .016 266)}",
            ".javdb-plus-dialog-body{padding:16px 18px;overflow:auto;line-height:1.65;font-size:14px}",
            ".javdb-plus-dialog-body label{display:block;font-weight:700;margin:0 0 6px;color:oklch(85% .010 262)}",
            ".javdb-plus-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)}",
            ".javdb-plus-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)}",
            ".javdb-plus-dialog-body .javdb-plus-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}",
            ".javdb-plus-dialog-body .javdb-plus-field+.javdb-plus-field{margin-top:16px}",
            ".javdb-plus-dialog-actions{display:flex;justify-content:flex-end;gap:8px;padding:12px 18px;border-top:1px solid oklch(30% .016 266)}",
            ".javdb-plus-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}",
            ".javdb-plus-dialog-actions button:hover{background:oklch(30% .016 266);color:oklch(96% .006 75)}",
            ".javdb-plus-dialog-actions button.primary{border-color:oklch(77% .10 274);background:oklch(77% .10 274);color:oklch(19% .035 274)}",
            ".javdb-plus-dialog-actions button.primary:hover{background:oklch(84% .085 274)}",
            ".javdb-plus-dialog-actions button:focus-visible,.javdb-plus-dialog-body input:focus-visible,.javdb-plus-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}.javdb-plus-dialog-mask{padding:10px}}",
            ".javdb-plus-filtered{display:none!important}",
            "." + CARD_CLASS + " .card__code{cursor:pointer}",
            "." + CARD_CLASS + " .card__code:hover{color:#ff5a5a!important}",
            "." + CARD_CLASS + " .card__code.javdb-plus-blocked-code{color:#ff5a5a!important}",
            "." + CARD_CLASS + " .card__title.javdb-plus-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 + "}",
            ".javdb-plus-card .card__figure{aspect-ratio:100/142!important}",
            ".javdb-plus-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}",
            ".javdb-plus-card .card__figure{aspect-ratio:800/538!important}",
            ".javdb-plus-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);
    }

    // 页面框架精简 + 分页加长 + Dusk 底色适配,一次注入。
    // javbus 系站点结构不同,这里只对 javdb 生效。
    function injectChromeStyle() {
        if (SITE !== "javdb") return;
        // 常驻样式:分页加长、页码金黄、Dusk 底色,与「隐藏顶部」开关无关。
        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(--javdb-plus-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-link{background-color:#4a4a4a!important}
            // 以及 [data-theme=dark] .pagination-link.is-current{background-color:#3850b7},
            // 特异性均为 (0,3,0),高于普通二类选择器,所以这里统一挂 :root[data-theme=dark] 前缀对齐。
            // 声明顺序:先未选中、后选中,保证选中态最终胜出。
            ":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(--javdb-plus-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}",
            // 页面底色与 Dusk 的 paper 对齐
            "html,body{background:oklch(17.5% .014 268)!important;color:oklch(96% .006 75)!important}",
            // 导航栏自身压到 Dusk 的 paper,避免和页面底色出现色差分界
            ".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}"
        ];
        // 主题/语言两个下拉由 hideUserMenuExtras() 打标,这里按开关决定显隐。
        base.push("#navbar-menu-user .javdb-plus-user-extra.is-hidden{display:none!important}");
        // 可开关:隐藏顶部内容。导航栏本身保留(徽标/语言/用户名不动)。
        const top = isTopHidden()
            ? [
                // 中间那排菜单(类别/排行榜/演员/系列/片商/反馈/ThePornDude)
                "#navbar-menu-hero .navbar-start{display:none!important}",
                // 移动端汉堡菜单展开的也是同一批菜单,一并收起
                "#navbar-menu-hero .navbar-end{display:none!important}",
                // 域名提示条(App/新域名/永久域名 那一行)
                ".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}",
                // 主标签页(资讯/热播/手气不错/全部/有码/无码/欧美/FC2/动漫)
                ".main-tabs-wrap{display:none!important}",
                // 筛选工具条(大封面/小封面、可播放、中字、含磁链、排序方式)
                ".toolbar{display:none!important}",
                // 页脚(关于/条款/隐私/广告投放/RTA、版权、TOP 角标)
                "#footer{display:none!important}",
                ".float-buttons{display:none!important}",
                // 18 岁确认弹窗与 App 推广横幅
                ".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);
    }
    // 摘除右侧用户区里的「跟隨系統」主题下拉与「正體中文」语言下拉。
    // 用 href 精准定位:登录后用户名本身也是 .navbar-item.has-dropdown,
    // 按类名一刀切会连用户名一起藏掉,所以只认 ?theme= 和 ?locale= 这两个入口。
    function hideUserMenuExtras() {
        if (SITE !== "javdb") return;
        const menu = document.getElementById("navbar-menu-user");
        if (!menu) return;
        // 打标记而不是 remove():关闭开关后去掉类名即可恢复,不必刷新页面。
        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("javdb-plus-user-extra");
            }
        });
    }

    // 主题/语言两个下拉也归「隐藏顶部」开关管,恢复时去掉类名即可。
    function applyUserMenuExtras() {
        if (SITE !== "javdb") return;
        const menu = document.getElementById("navbar-menu-user");
        if (!menu) return;
        menu.querySelectorAll(".javdb-plus-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 允许显式清空(= 扫全部电影库),不能因 falsy 回退到默认值。
            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 允许显式清空,不能因为 falsy 回退到内置密钥。
            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();
        // 分类固定 jav,不作为可选项暴露。
        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 = "javdb-plus-dialog-mask";
        const dialog = document.createElement("div");
        dialog.className = "javdb-plus-dialog";
        dialog.setAttribute("role", "dialog");
        dialog.setAttribute("aria-modal", "true");
        dialog.innerHTML = [
            "<h3>javdb plus · Emby 设置</h3>",
            "<div class=\"javdb-plus-dialog-body\">",
            "<div class=\"javdb-plus-field\"><label>服务器地址</label><input type=\"text\" class=\"javdb-plus-emby-url\" placeholder=\"http://<Emby 服务器>:8096\" spellcheck=\"false\"></div>",
            "<div class=\"javdb-plus-field\"><label>API 密钥</label><input type=\"password\" class=\"javdb-plus-emby-token\" placeholder=\"X-Emby-Token\" spellcheck=\"false\"></div>",
            "<div class=\"javdb-plus-field\"><label>JAV 媒体库 ID(留空为全部电影库)</label><input type=\"text\" class=\"javdb-plus-emby-library\" placeholder=\"Emby 媒体库 ID\" spellcheck=\"false\"></div>",
            "<div class=\"javdb-plus-field\"><label>库缓存有效期(小时)</label><input type=\"number\" min=\"1\" max=\"168\" class=\"javdb-plus-emby-ttl\"></div>",
            "<div class=\"javdb-plus-field\"><label>缓存状态</label><div class=\"javdb-plus-emby-status\"></div></div>",
            "</div>",
            "<div class=\"javdb-plus-dialog-actions\">",
            "<button type=\"button\" class=\"javdb-plus-emby-test\">测试连接</button>",
            "<button type=\"button\" class=\"javdb-plus-emby-refresh\">刷新缓存</button>",
            "<button type=\"button\" class=\"javdb-plus-cancel\">取消</button>",
            "<button type=\"button\" class=\"primary javdb-plus-save\">保存</button>",
            "</div>"
        ].join("");
        const urlInput = dialog.querySelector(".javdb-plus-emby-url");
        const tokenInput = dialog.querySelector(".javdb-plus-emby-token");
        const libraryInput = dialog.querySelector(".javdb-plus-emby-library");
        const ttlInput = dialog.querySelector(".javdb-plus-emby-ttl");
        const statusNode = dialog.querySelector(".javdb-plus-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(".javdb-plus-cancel").addEventListener("click", close);
        dialog.querySelector(".javdb-plus-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(".javdb-plus-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(".javdb-plus-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 = "javdb-plus-dialog-mask";
        const dialog = document.createElement("div");
        dialog.className = "javdb-plus-dialog";
        dialog.setAttribute("role", "dialog");
        dialog.setAttribute("aria-modal", "true");
        dialog.innerHTML = [
            "<h3>javdb plus · qBittorrent 设置</h3>",
            "<div class=\"javdb-plus-dialog-body\">",
            "<div class=\"javdb-plus-field\"><label>Web UI 地址</label><input type=\"text\" class=\"javdb-plus-qb-host\" placeholder=\"http://<qBittorrent 服务器>:8080\" spellcheck=\"false\"></div>",
            "<div class=\"javdb-plus-field\"><label>API 密钥</label><input type=\"password\" class=\"javdb-plus-qb-apikey\" placeholder=\"qbt_ 开头\" spellcheck=\"false\"></div>",
            "<div class=\"javdb-plus-field\"><label>保存路径</label><input type=\"text\" class=\"javdb-plus-qb-savepath\" placeholder=\"/downloads\" spellcheck=\"false\"></div>",
            "<div class=\"javdb-plus-field\"><label>状态</label><div class=\"javdb-plus-qb-status\"></div></div>",
            "</div>",
            "<div class=\"javdb-plus-dialog-actions\">",
            "<button type=\"button\" class=\"javdb-plus-qb-test\">测试连接</button>",
            "<button type=\"button\" class=\"javdb-plus-cancel\">取消</button>",
            "<button type=\"button\" class=\"primary javdb-plus-save\">保存</button>",
            "</div>"
        ].join("");
        const hostInput = dialog.querySelector(".javdb-plus-qb-host");
        const apiKeyInput = dialog.querySelector(".javdb-plus-qb-apikey");
        const savePathInput = dialog.querySelector(".javdb-plus-qb-savepath");
        const statusNode = dialog.querySelector(".javdb-plus-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(".javdb-plus-cancel").addEventListener("click", close);
        dialog.querySelector(".javdb-plus-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(".javdb-plus-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();
    }

    // 探测 qBittorrent:只走 API 密钥一条路。
    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(".javdb-plus-toast");
        if (old) old.remove();
        const element = document.createElement("div");
        element.className = "javdb-plus-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 = "javdb-plus-dialog-mask";
            const dialog = document.createElement("div");
            dialog.className = "javdb-plus-dialog";
            dialog.setAttribute("role", "dialog");
            dialog.setAttribute("aria-modal", "true");

            const title = document.createElement("h3");
            title.textContent = "提示";
            const body = document.createElement("div");
            body.className = "javdb-plus-dialog-body";
            body.textContent = message;
            const actions = document.createElement("div");
            actions.className = "javdb-plus-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 = "javdb-plus-dialog-mask";
        const dialog = document.createElement("div");
        dialog.className = "javdb-plus-dialog";
        dialog.setAttribute("role", "dialog");
        dialog.setAttribute("aria-modal", "true");
        dialog.innerHTML = [
            "<h3>javdb plus 屏蔽词</h3>",
            "<div class=\"javdb-plus-dialog-body\">",
            "<div class=\"javdb-plus-field\"><label>标题屏蔽词(每行一个)</label><textarea class=\"javdb-plus-title-keywords\"></textarea></div>",
            "</div>",
            "<div class=\"javdb-plus-dialog-actions\"><button type=\"button\" class=\"javdb-plus-cancel\">取消</button><button type=\"button\" class=\"primary javdb-plus-save\">保存</button></div>"
        ].join("");
        const titleArea = dialog.querySelector(".javdb-plus-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(".javdb-plus-cancel").addEventListener("click", close);
        dialog.querySelector(".javdb-plus-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("javdb-plus-blocked-title");
                }
                const currentCode = currentItem ? currentItem.querySelector(".card__code") : codeNode;
                if (currentCode) currentCode.classList.add("javdb-plus-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=\"javdb-plus-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=\"javdb-plus-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(".javdb-plus-chip-label");
        if (!label) return;
        button.dataset.state = stateName;
        const icon = button.querySelector(".javdb-plus-chip-icon");
        const spinner = button.querySelector(".javdb-plus-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=\"javdb-plus-chip-icon\">", COVER_THUMB_SVG, "</span>",
                "<svg class=\"javdb-plus-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=\"javdb-plus-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("[javdb plus] 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("[javdb plus] 获取长缩略图失败", 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);
        });
    }

    // 按钮状态机:未入库=下载;已推送且未入库=已推送;已入库=已播放/未播放(点击进 Emby)
    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(".javdb-plus-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(".javdb-plus-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(".javdb-plus-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("--javdb-plus-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("--javdb-plus-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.__javdbPlusClosePopup === "function") {
                    window.parent.__javdbPlusClosePopup();
                }
            } catch (error) {
                console.warn("[javdb plus] 关闭父页面弹窗失败", 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;
        }
    }

    // iframe 内的详情页:userscript 会因 @match 再次运行。
    // 这里把「弹窗内嵌模式」下的页面改成暗色、去掉顶部白边与右侧滚动条,只留内容。
    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 的内边距与手机端 app 横幅
            ".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}",
            // 磁鏈页签里的「官方App / 公告」按钮行与广告横幅
            ".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);
    }

    // 站点主题默认「跟随系统」,这里强制切成暗色。
    // 站点自己的初始化函数读 <meta name='theme'> 的 content,所以从源头改 meta 最干净;
    // 同时直接给 <html> 打上 data-theme=dark,覆盖脚本尚未执行的窗口。
    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.__javdbPlusClosePopup = 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, .javdb-plus-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=\"javdb-plus-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 = "javdb-plus-gallery";
        root.setAttribute("role", "dialog");
        root.setAttribute("aria-modal", "true");
        root.setAttribute("aria-label", "长缩略图");

        const stage = document.createElement("div");
        stage.className = "javdb-plus-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(".javdb-plus-lightbox");
        if (old) old.remove();
        const previousBodyOverflow = document.body.style.overflow;
        document.body.style.overflow = "hidden";
        const mask = document.createElement("div");
        mask.className = "javdb-plus-lightbox";
        const image = document.createElement("img");
        image.src = imgUrl;
        image.alt = "长缩略图";
        image.decoding = "async";
        // 默认按适配尺寸放大,长图不再被压成窄条;超出的部分靠滚轮上下滚。
        const frame = document.createElement("div");
        frame.className = "javdb-plus-lightbox-frame";
        frame.appendChild(image);
        mask.appendChild(frame);
        const hint = document.createElement("div");
        hint.className = "javdb-plus-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;
            // contain 适配:宽和高各自不超出视口
            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 = "javdb-plus-thumb-message";
        message.appendChild(document.createTextNode("获取缩略图失败"));
        message.appendChild(document.createElement("br"));
        const retry = document.createElement("a");
        retry.href = "#";
        retry.className = "javdb-plus-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(".javdb-plus-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");
    }

    // GM 请求从扩展后台发出,会被 JavDB 的 Cloudflare 挑战拦下;
    // 在 JavDB 页面内改走同源 fetch,自带登录态且不触发挑战。
    function isJavdbOrigin(origin) {
        return /^https:\/\/javdb[a-z0-9]*\.com$/.test(origin);
    }

    async function fetchJavdbText(url) {
        if (isJavdbOrigin(location.origin) && isJavdbOrigin(new URL(url).origin)) {
            const response = await fetch(url, {
                credentials: "include",
                headers: { Accept: "text/html,application/xhtml+xml" }
            });
            if (!response.ok) throw new Error("HTTP " + response.status);
            return response.text();
        }
        return gmRequestText(url);
    }

    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("javdb_plus_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("javdb_plus_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 超时"));
                }
            });
        });
    }

    // 全量同步:分页拉取 JAV 库,写入番号 -> 影片信息 的缓存。仅在缓存过期时由外部触发。
    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("[javdb plus] Emby 同步完成,共 " + Object.keys(next).length + " 个番号");
                saveEmbyCache();
                state.embyMissed = new Set();
                writeEmbyMissed();
                refreshEmbyBadges();
                renderPushButtons();
            } catch (error) {
                state.embySyncError = String(error.message || error);
                console.warn("[javdb plus] 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("javdb-plus-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);
    }

    // 同步后统一刷新页面上所有徽标,不重复请求 Emby。
    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() {
        // 只走 API 密钥。校验标准与 qbHeaders 保持一致:
        // 非 qbt_ 开头的密钥不会被带上认证头,提前拦下并说明原因。
        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);
                        // failure_count > 0 一律算失败:重复种子、磁链失效都走这里,
                        // 不能因为 success_count 为 0 且 failure_count 为 0 就当成成功。
                        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 base = isJavdbOrigin(location.origin) ? location.origin : "https://javdb.com";
        const searchUrl = base + "/search?q=" + encodeURIComponent(code) + "&f=all";
        const searchHtml = await fetchJavdbText(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"), searchUrl).href;
                break;
            }
        }
        if (!detailUrl) throw new Error("JavDB 未找到 " + target);
        const detailHtml = await fetchJavdbText(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("[javdb plus] 通知失败", error);
        }
    }

    async function pushItem(code, button, label, card) {
        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("[javdb plus] 推送失败", error);
            // 按当前阶段给错误加前缀,避免把「JavDB 没访问到」误显示成 qBittorrent 的问题。
            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=\"javdb-plus-thumb-message\">正在加载缩略图</div>";
        try {
            const imageUrl = await getScreenshot(carNum);
            renderLongThumbnail(element, imageUrl);
        } catch (error) {
            console.warn("[javdb plus] 获取长缩略图失败", 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.__JAVDB_PLUS__ = {
            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("[javdb plus] 初始化失败", error);
    });
})();