竖图 Dusk 卡片、预览图、划词屏蔽、弹窗打开、Emby 入库检测与 qBittorrent 推送。
// ==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);
});
})();