JavBus Javdb library trailer (Dynamic)

dmm预告片

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

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

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

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

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

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

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

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

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

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

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

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

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

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

// ==UserScript==
// @name         JavBus Javdb library trailer (Dynamic)
// @name:zh-CN   JavBus/Javdb 预告片
// @namespace    https://greasyfork.org/zh-CN/scripts/441120
// @version      2026.09.20
// @description         dmm预告片
// @description:zh-cn   dmm预告片
// @author       dynamic-rewrite
// @license      GPL
// @match        *://www.javbus.com/*
// @include      *://javdb*.com/*
// @include      *://javdb.com/*
// @match        *://*.javlib.com/*
// @match        *://*.javlibrary.com/*
// @include      *://avmoo.*/*
// @include      *://avsox.*/*
// @match        *://*.sehuatang.net/*
// @match        *://www.tanhuazu.com/*
// @match        *://db.msin.jp/*
// @match        *://*/works/detail/*
// @match        *://javbooks.com/*
// @match        *://jmvbt.com/*
// @include      *://*.com/content*censored/*.htm
// @match        *://xslist.org/*
// @grant        GM_download
// @grant        GM_xmlhttpRequest
// @grant        GM_getValue
// @grant        GM_setValue
// @grant        GM_notification
// @grant        GM_setClipboard
// @grant        GM_addStyle
// @grant        GM_deleteValue
// @require      https://code.jquery.com/jquery-3.6.3.min.js
// @connect      *
// @connect      dmm.co.jp
// @connect      cc3001.dmm.co.jp
// @connect      api.video.dmm.co.jp
// @run-at       document-end
// ==/UserScript==

(function () {
  'use strict';

  var embyAPI = "";
  var embyBaseUrl = "";

  GM_addStyle(`
    .trailer-box {
      display: block; width: 100%; box-sizing: border-box; text-align: center;
      border-radius: 4px; border: 1px solid #ccc;
      margin: 8px 0; background: #000; position: relative;
    }
    .trailer-box video.trailer-video {
      width: 100% !important; max-width: 100% !important; height: auto !important;
      aspect-ratio: 16 / 9; max-height: 80vh; min-height: 320px;
      display: block !important; background: #000;
    }
    .trailer-toolbar {
      display: flex; align-items: center; justify-content: space-between;
      padding: 6px 12px; background: #1a1a1a; color: #bbb;
      font-size: 13px; border-bottom: 1px solid #333;
      min-height: 32px; box-sizing: border-box;
      border-radius: 4px 4px 0 0;
    }
    .trailer-box.ready .trailer-status { display: none; }
    .trailer-box:not(.ready) .trailer-quality-label { display: none !important; }
    .trailer-quality-label { display: flex; align-items: center; gap: 4px; }
    .trailer-quality {
      background: #333; color: #ddd; border: 1px solid #555;
      border-radius: 3px; padding: 3px 6px;
      font-size: 12px; cursor: pointer;
    }
    .trailer-video { display: none; }
    .trailer-box.ready .trailer-video { display: block !important; }
    .trailer-box.error .trailer-toolbar { background: #4a1010; color: #f88; }
    .trailer-quality-probing { font-size: 12px; color: #888; margin-left: 6px; }
    .video-meta-panel { height: auto !important; max-height: none !important; overflow: visible !important; padding-bottom: 0 !important; }
    #video_jacket_info { width: 100% !important; }
  `);

  console.log('[Trailer] script loaded at', location.href);

  const CACHE_PREFIX = 'jvl_trailer_v21_';
  const CACHE_TTL = 5 * 24 * 60 * 60 * 1000;

  // ==================== 音量记忆 ====================
  function getSavedVolume () {
    try {
      const v = GM_getValue('jvl_trailer_volume', null);
      if (v === null || v === undefined) return 0.5;
      const n = parseFloat(v);
      return isNaN(n) ? 0.5 : Math.max(0, Math.min(1, n));
    } catch (e) { return 0.5; }
  }
  function getSavedMuted () {
    try {
      const m = GM_getValue('jvl_trailer_muted', null);
      if (m === null || m === undefined) return true;
      return String(m) === 'true';
    } catch (e) { return true; }
  }
  function saveVolumeState (videoEl) {
    try {
      GM_setValue('jvl_trailer_volume', String(videoEl.volume));
      GM_setValue('jvl_trailer_muted', String(videoEl.muted));
    } catch (e) {}
  }
  function applyVolumeState (videoEl) {
    try {
      videoEl.volume = getSavedVolume();
      videoEl.muted = getSavedMuted();
      let timer = null;
      videoEl.addEventListener('volumechange', () => {
        if (timer) clearTimeout(timer);
        timer = setTimeout(() => saveVolumeState(videoEl), 300);
      });
    } catch (e) {}
  }

  // ==================== 清晰度偏好 ====================
  const QUALITY_KEY = 'jvl_trailer_quality';
  const VALID_PRESETS = ['auto-high', 'auto-low'];
  const SUPPORTED_HEIGHTS = [1080, 720, 576, 432, 288, 144];

  function getPreferredQuality () {
    try {
      let q = GM_getValue(QUALITY_KEY, 'auto-high');
      if (q === 'auto') q = 'auto-high';
      if (typeof q !== 'string' || !q.length) return 'auto-high';
      if (VALID_PRESETS.includes(q)) return q;
      const n = parseInt(q, 10);
      if (!isNaN(n) && SUPPORTED_HEIGHTS.includes(n)) return String(n);
    } catch (e) {}
    return 'auto-high';
  }
  function setPreferredQuality (q) {
    try { GM_setValue(QUALITY_KEY, String(q)); } catch (e) {}
  }

  // ==================== 下拉框选项 ====================
  function buildQualityOptionsHtml () {
    return `
      <option value="auto-high">默认最高</option>
      <option value="auto-low">默认最低</option>
      <option value="1080">1080p</option>
      <option value="720">720p</option>
      <option value="576">576p</option>
      <option value="432">432p</option>
      <option value="288">288p</option>
      <option value="144">144p</option>`;
  }

  // ==================== mp4 候选 URL 生成 ====================
  // DMM 后缀 → 分辨率映射(根据实测样例):
  //   hhb → 1080 | hmb → 720 | mhb → 576
  //   mmb → 432  | dm  → 288 | sm  → 144
  const DMM_SUFFIX_ORDER = [
    { height: 1080, sfx: 'hhb' },
    { height: 720,  sfx: 'hmb' },
    { height: 576,  sfx: 'mhb' },
    { height: 432,  sfx: 'mmb' },
    { height: 288,  sfx: 'dm'  },
    { height: 144,  sfx: 'sm'  }
  ];

  function buildCandidates (rawUrl) {
    if (!rawUrl) return [];
    const url = String(rawUrl).replace(/\?.*$/, '');

    // 模式 1:xxx/1080p.mp4 (caribbean / 1pondo / 10musume / pacopacomama 等)
    let m = url.match(/^(.+?)\/(\d{3,4})p\.mp4$/i);
    if (m) {
      const base = m[1];
      return SUPPORTED_HEIGHTS.map(h => ({ height: h, url: `${base}/${h}p.mp4` }));
    }

    // 模式 2:xxx_{sfx}_{w}.mp4 (DMM litevideo freepv)
    m = url.match(/^(.+?)_(hhb|hmb|mhb|mmb|dm|sm)_([ws])\.mp4$/i);
    if (m) {
      const base = m[1] + '_';
      const w = m[3];
      return DMM_SUFFIX_ORDER.map(e => ({ height: e.height, url: `${base}${e.sfx}_${w}.mp4` }));
    }

    // 模式 3:xxx_{sfx}.mp4
    m = url.match(/^(.+?)_(hhb|hmb|mhb|mmb|dm|sm)\.mp4$/i);
    if (m) {
      const base = m[1] + '_';
      return DMM_SUFFIX_ORDER.map(e => ({ height: e.height, url: `${base}${e.sfx}.mp4` }));
    }

    // 模式 4:xxx{sfx}.mp4 —— DMM pv 主模式(id 直接紧跟后缀)
    m = url.match(/^(.+?)(hhb|hmb|mhb|mmb|dm|sm)\.mp4$/i);
    if (m) {
      const base = m[1];
      return DMM_SUFFIX_ORDER.map(e => ({ height: e.height, url: `${base}${e.sfx}.mp4` }));
    }

    // 未知 → 原样
    return [{ height: 0, url }];
  }

  // ==================== 单次快速探测 ====================
  function probeQuick (url) {
    return new Promise((resolve) => {
      const v = document.createElement('video');
      v.muted = true;
      v.preload = 'metadata';
      v.style.cssText = 'position:absolute;left:-99999px;top:-99999px;width:1px;height:1px;opacity:0;pointer-events:none;';
      let done = false;
      const cleanup = () => {
        if (done) return; done = true;
        try { v.pause(); } catch (e) {}
        try { v.removeAttribute('src'); v.load(); } catch (e) {}
        try { v.remove(); } catch (e) {}
      };
      v.addEventListener('loadedmetadata', () => {
        const h = v.videoHeight || 0;
        cleanup();
        resolve({ ok: h > 0, height: h, url });
      }, { once: true });
      v.addEventListener('error', () => { cleanup(); resolve({ ok: false, url }); }, { once: true });
      v.src = url;
      document.body.appendChild(v);
      setTimeout(() => { if (!done) { cleanup(); resolve({ ok: false, url, timeout: true }); } }, 2500);
    });
  }

  // ==================== 按偏好选择 mp4 ====================
  async function pickForPref (originalUrl, pref) {
    const candidates = buildCandidates(originalUrl);

    if (candidates.length === 1 && candidates[0].height === 0) {
      return candidates[0];
    }

    let order;
    if (pref === 'auto-high') {
      order = [1080, 720, 576, 432, 288, 144];
    } else if (pref === 'auto-low') {
      order = [144, 288, 432, 576, 720, 1080];
    } else {
      const n = parseInt(pref, 10);
      if (isNaN(n) || !SUPPORTED_HEIGHTS.includes(n)) {
        order = [1080, 720, 576, 432, 288, 144];
      } else {
        const rest = SUPPORTED_HEIGHTS
          .filter(h => h !== n)
          .sort((a, b) => Math.abs(a - n) - Math.abs(b - n));
        order = [n, ...rest];
      }
    }

    for (const h of order) {
      const matches = candidates.filter(c => c.height === h);
      if (!matches.length) continue;
      for (const c of matches) {
        const r = await probeQuick(c.url);
        if (r.ok) {
          const tag = (String(h) === String(pref)) ? 'exact' : 'fallback';
          console.log('[Trailer] picked', h + 'p', '(' + tag + ', pref=' + pref + ')', c.url);
          return { height: h, url: c.url };
        }
      }
    }

    console.log('[Trailer] pickForPref: all probes failed, fallback to original');
    return { height: 0, url: originalUrl };
  }

  // ==================== 缓存 ====================
  function getCached (code) {
    try {
      const key = CACHE_PREFIX + code.toUpperCase();
      const raw = GM_getValue(key, null);
      if (!raw) return null;
      const data = typeof raw === 'string' ? JSON.parse(raw) : raw;
      if (data && data.expire > Date.now() && data.url) return data.url;
      try { GM_deleteValue(key); } catch (e) {}
    } catch (e) {}
    return null;
  }
  function setCache (code, url) {
    try {
      const key = CACHE_PREFIX + code.toUpperCase();
      GM_setValue(key, JSON.stringify({ url, expire: Date.now() + CACHE_TTL }));
    } catch (e) {}
  }

  // ==================== JavDB 原生 src ====================
  function getNativePreviewSrc () {
    try {
      const nv = document.querySelector('#preview-video');
      if (!nv) return '';
      let src =
        nv.querySelector('source')?.getAttribute('src') ||
        nv.getAttribute('src') ||
        nv.getAttribute('data-src') || '';
      src = String(src).trim();
      if (!src || src === 'about:blank') {
        const wrap = nv.closest('.preview-video-wrapper, .video-meta-panel, .video-detail');
        if (wrap) {
          const innerSrc = wrap.querySelector('video source')?.getAttribute('src') ||
                           wrap.querySelector('video')?.getAttribute('src') || '';
          if (innerSrc) src = innerSrc;
        }
      }
      return src.startsWith('http') ? src : '';
    } catch (e) { return ''; }
  }

  // ==================== 从 HTML 提取 PV (只保留 mp4) ====================
  function extractPvFromHtml (html) {
    if (!html) return '';
    const decoded = html.replace(/\\\//g, '/');
    const patterns = [
      /https?:\/\/cc3001\.dmm\.co\.jp\/pv\/[^"'\s<>\\]+\.mp4/gi,
      /https?:\/\/cc3001\.dmm\.com\/pv\/[^"'\s<>\\]+\.mp4/gi,
      /https?:\/\/cc3001\.dmm\.co\.jp\/litevideo\/freepv\/[^"'\s<>\\]+\.mp4/gi,
      /https?:\/\/cc3001\.dmm\.com\/litevideo\/freepv\/[^"'\s<>\\]+\.mp4/gi,
    ];
    for (const p of patterns) {
      const m = decoded.match(p);
      if (m && m.length) {
        const best = m.find(u => /hhb\.mp4$/i.test(u))
                  || m.find(u => /hmb\.mp4$/i.test(u))
                  || m.find(u => /mhb\.mp4$/i.test(u))
                  || m.find(u => /mmb\.mp4$/i.test(u))
                  || m.find(u => /dm\.mp4$/i.test(u))
                  || m.find(u => /sm\.mp4$/i.test(u));
        return best || m[0];
      }
    }
    return '';
  }

  // ==================== 提取 CID ====================
  function extractCidsFromHtml (html) {
    if (!html) return [];
    const decoded = html.replace(/\\\//g, '/');
    const cids = new Set();
    let m;
    const re1 = /[?&]cid=([a-z0-9_]+)/gi;
    while ((m = re1.exec(decoded)) !== null) cids.add(m[1].toLowerCase());
    const re2 = /data-cid="([a-z0-9_]+)"/gi;
    while ((m = re2.exec(decoded)) !== null) cids.add(m[1].toLowerCase());
    const re3 = /"cid"\s*:\s*"([a-z0-9_]+)"/gi;
    while ((m = re3.exec(decoded)) !== null) cids.add(m[1].toLowerCase());
    const re4 = /pics\.dmm\.co\.jp\/(?:digital|mono|rental)\/(?:video|videoa|movie|adult)\/(?:adult\/)?([a-z0-9_]+)\//gi;
    while ((m = re4.exec(decoded)) !== null) cids.add(m[1].toLowerCase());
    const re5 = /\/cid\/([a-z0-9_]+)\//gi;
    while ((m = re5.exec(decoded)) !== null) cids.add(m[1].toLowerCase());
    return Array.from(cids);
  }

  function filterMatchingCids (cids, series, num) {
    const s = series.toLowerCase();
    const n = String(num);
    const n3 = n.padStart(3, '0');
    const n5 = n.padStart(5, '0');
    const stripped = String(parseInt(n, 10));
    const matches = [];
    for (const cid of cids) {
      if (cid.includes(s) && (cid.includes(n) || cid.includes(n3) || cid.includes(n5) || cid.includes(stripped))) {
        matches.push(cid);
      }
    }
    matches.sort((a, b) => {
      const score = (x) => {
        if (x === s + n3) return 0;
        if (x === s + n5) return 1;
        if (x === s + n) return 2;
        if (x.endsWith(s + n3)) return 3;
        if (x.endsWith(s + n5)) return 4;
        if (x.endsWith(s + n)) return 5;
        return 10;
      };
      return score(a) - score(b);
    });
    return matches;
  }

  function fetchCidsFromDmmSearch (code) {
    return new Promise((resolve) => {
      const parts = code.split(/-/);
      if (parts.length < 2) return resolve([]);
      const series = parts[0].toLowerCase();
      const num = parts[1];

      const searchWords = [`${series}${num}`, `${series}-${num}`, `${series}${num.padStart(5, '0')}`];
      const searchUrls = [];
      for (const kw of searchWords) {
        const enc = encodeURIComponent(kw);
        searchUrls.push(`https://www.dmm.co.jp/search/=/searchstr=${enc}/`);
        searchUrls.push(`https://www.dmm.co.jp/digital/videoa/-/search/=/searchstr=${enc}/`);
        searchUrls.push(`https://www.dmm.co.jp/mono/dvd/-/search/=/searchstr=${enc}/`);
      }

      let resolved = false;
      let pending = searchUrls.length;
      const allCids = new Set();
      const finish = () => {
        if (resolved) return;
        resolved = true;
        const filtered = filterMatchingCids(Array.from(allCids), series, num);
        console.log('[Trailer] DMM search → cids(raw/filtered):', allCids.size, '/', filtered.length, filtered.slice(0, 5));
        resolve(filtered);
      };

      searchUrls.forEach((url) => {
        GM_xmlhttpRequest({
          url, method: 'GET', timeout: 6000,
          headers: {
            'User-Agent': navigator.userAgent,
            'Accept': 'text/html,application/xhtml+xml',
            'Accept-Language': 'ja,en-US;q=0.7,en;q=0.3',
            'Referer': 'https://www.dmm.co.jp/'
          },
          onload: (res) => {
            if (resolved) return;
            const cids = extractCidsFromHtml(res.responseText || '');
            cids.forEach(c => allCids.add(c));
            pending--;
            const filtered = filterMatchingCids(Array.from(allCids), series, num);
            if (filtered.length > 0) setTimeout(finish, 200);
            else if (pending === 0) finish();
          },
          onerror: () => { pending--; if (pending === 0) finish(); },
          ontimeout: () => { pending--; if (pending === 0) finish(); }
        });
      });
    });
  }

  function buildCidCandidates (series, num) {
    const n = String(num);
    const stripped = String(parseInt(n, 10));
    const n3 = n.padStart(3, '0');
    const n5 = n.padStart(5, '0');
    const out = [];
    const push = (c) => { if (c && !out.includes(c)) out.push(c); };
    push(series + n);
    push(series + n3);
    push(series + n5);
    if (stripped !== n) push(series + stripped);
    return out;
  }
  function buildDmmUrlList (series, num) {
    const cids = buildCidCandidates(series, num);
    const urls = [];
    const add = (c, path) => urls.push(`https://www.dmm.co.jp/${path}/-/detail/=/cid=${c}/`);
    const addLite = (c) => urls.push(`https://www.dmm.co.jp/litevideo/-/part/=/cid=${c}/size=720_480/affi_id=ProgramDMM-001/`);
    for (const c of cids) add(c, 'mono/dvd');
    for (const c of cids) add(c, 'digital/videoa');
    for (const c of cids) addLite(c);
    return urls;
  }

  function fetchPvFromCid (cid) {
    return new Promise((resolve) => {
      const urls = [
        `https://www.dmm.co.jp/mono/dvd/-/detail/=/cid=${cid}/`,
        `https://www.dmm.co.jp/digital/videoa/-/detail/=/cid=${cid}/`,
        `https://www.dmm.co.jp/litevideo/-/part/=/cid=${cid}/size=720_480/affi_id=ProgramDMM-001/`
      ];
      let idx = 0;
      const tryNext = () => {
        if (idx >= urls.length) return resolve(null);
        const url = urls[idx++];
        GM_xmlhttpRequest({
          url, method: 'GET', timeout: 5000,
          headers: {
            'User-Agent': navigator.userAgent,
            'Accept': 'text/html,application/xhtml+xml',
            'Accept-Language': 'ja,en-US;q=0.7,en;q=0.3',
            'Referer': 'https://www.dmm.co.jp/'
          },
          onload: (res) => {
            const pv = extractPvFromHtml(res.responseText || '');
            if (pv) return resolve(pv);
            tryNext();
          },
          onerror: () => tryNext(),
          ontimeout: () => tryNext()
        });
      };
      tryNext();
    });
  }

  function fetchFanzaTrailerUrl (code) {
    return new Promise((resolve) => {
      const parts = code.split(/-/);
      if (parts.length < 2) return resolve(null);
      const series = parts[0].toLowerCase();
      const num = parts[1];
      const searchWords = [`${parts[0]}-${num}`, `${series}${num}`, `${series}${num.padStart(5, '0')}`];
      let idx = 0;
      const tryNext = () => {
        if (idx >= searchWords.length) return resolve(null);
        const sw = searchWords[idx++];
        const query = `{ legacySearchPPV(limit: 10, searchWord: "${sw}") { items { cid title sampleUrl sampleMovieUrl } } }`;
        console.log('[Trailer] FANZA search:', sw);
        GM_xmlhttpRequest({
          url: 'https://api.video.dmm.co.jp/graphql',
          method: 'POST', timeout: 8000,
          headers: {
            'Content-Type': 'application/json',
            'User-Agent': navigator.userAgent,
            'Origin': 'https://www.dmm.co.jp',
            'Referer': 'https://www.dmm.co.jp/'
          },
          data: JSON.stringify({ query }),
          onload: (res) => {
            try {
              const data = JSON.parse(res.responseText);
              const items = data?.data?.legacySearchPPV?.items || [];
              for (const it of items) {
                if (it.sampleUrl && it.sampleUrl.startsWith('http')) return resolve(it.sampleUrl);
                if (it.sampleMovieUrl && it.sampleMovieUrl.startsWith('http')) return resolve(it.sampleMovieUrl);
              }
              const cids = items.map(it => it.cid).filter(Boolean);
              if (cids.length) {
                const filtered = filterMatchingCids(cids, series, num);
                const target = filtered.length ? filtered : cids;
                let pending = target.length;
                target.forEach(cid => {
                  fetchPvFromCid(cid).then(pv => {
                    if (pv) return resolve(pv);
                    pending--;
                    if (pending === 0) tryNext();
                  });
                });
                return;
              }
              tryNext();
            } catch (e) { tryNext(); }
          },
          onerror: () => tryNext(),
          ontimeout: () => tryNext()
        });
      };
      tryNext();
    });
  }

  function fetchDmmTrailerUrlParallel (code) {
    return new Promise((resolve) => {
      const parts = code.split(/-/);
      if (parts.length < 2 || !/^\d+$/.test(parts[1])) return resolve(null);
      const series = parts[0].toLowerCase();

      let resolved = false;
      const finish = (url, from) => {
        if (resolved || !url) return;
        resolved = true;
        console.log('[Trailer] ✓ DMM resolved via', from, ':', url);
        resolve(url);
      };

      const guessUrls = buildDmmUrlList(series, parts[1]);
      const CONCURRENT = 4;
      let nextIdx = 0;
      const pickAndRun = () => {
        if (resolved) return;
        if (nextIdx >= guessUrls.length) return;
        const url = guessUrls[nextIdx++];
        GM_xmlhttpRequest({
          url, method: 'GET', timeout: 5000,
          headers: {
            'User-Agent': navigator.userAgent,
            'Accept': 'text/html,application/xhtml+xml',
            'Accept-Language': 'ja,en-US;q=0.7,en;q=0.3',
            'Referer': 'https://www.dmm.co.jp/'
          },
          onload: (res) => {
            if (resolved) return;
            const pv = extractPvFromHtml(res.responseText || '');
            if (pv) return finish(pv, 'guess');
            pickAndRun();
          },
          onerror: () => { if (!resolved) pickAndRun(); },
          ontimeout: () => { if (!resolved) pickAndRun(); }
        });
      };
      for (let i = 0; i < Math.min(CONCURRENT, guessUrls.length); i++) pickAndRun();

      setTimeout(() => {
        if (resolved) return;
        fetchCidsFromDmmSearch(code).then((cids) => {
          if (resolved) return;
          if (!cids.length) { console.log('[Trailer] DMM search: no matching cids'); return; }
          const MAX = Math.min(5, cids.length);
          for (let i = 0; i < MAX; i++) {
            if (resolved) return;
            fetchPvFromCid(cids[i]).then((pv) => { if (pv) finish(pv, 'dmm-search'); });
          }
        });
      }, 100);

      setTimeout(() => {
        if (resolved) return;
        fetchFanzaTrailerUrl(code).then((url) => { if (url) finish(url, 'fanza-api'); });
      }, 500);

      setTimeout(() => {
        if (!resolved) { resolved = true; console.log('[Trailer] DMM all paths timeout'); resolve(null); }
      }, 10000);
    });
  }

  function fetchJavdbTrailerUrl (code) {
    return new Promise((resolve) => {
      const searchUrl = 'https://javdb.com/search?q=' + encodeURIComponent(code) + '&f=all';
      GM_xmlhttpRequest({
        url: searchUrl, method: 'GET', timeout: 15000,
        headers: {
          'User-Agent': navigator.userAgent,
          'Accept': 'text/html,application/xhtml+xml',
          'Referer': 'https://javdb.com/'
        },
        onload: (res) => {
          try {
            const doc = new DOMParser().parseFromString(res.responseText, 'text/html');
            let links = doc.querySelectorAll('.movie-list .item a[href*="/v/"]');
            if (!links.length) links = doc.querySelectorAll('a[href*="/v/"]');
            if (!links.length) return resolve(null);
            const norm = code.toUpperCase().replace(/_/g, '-');
            let href = null;
            for (const a of links) {
              const uid = a.querySelector('.uid');
              const uidText = uid ? uid.textContent.trim().toUpperCase().replace(/_/g, '-') : '';
              const allTxt = (a.textContent || '').toUpperCase().replace(/_/g, '-');
              if (uidText === norm || allTxt.includes(norm)) { href = a.getAttribute('href'); break; }
            }
            if (!href) href = links[0].getAttribute('href');
            if (!href) return resolve(null);
            const url = href.startsWith('http') ? href : 'https://javdb.com' + href;
            GM_xmlhttpRequest({
              url, method: 'GET', timeout: 15000,
              headers: {
                'User-Agent': navigator.userAgent,
                'Accept': 'text/html,application/xhtml+xml',
                'Referer': 'https://javdb.com/search'
              },
              onload: (r2) => {
                const html = r2.responseText || '';
                const pv = extractPvFromHtml(html);
                if (pv) return resolve(pv);
                const patterns = [
                  /<video[^>]*id="preview-video"[^>]*src="([^"]+\.mp4[^"]*)"/i,
                  /<source[^>]*src="([^"]+\.mp4[^"]*)"/i,
                ];
                for (const p of patterns) {
                  const m = html.match(p);
                  if (m && m[1] && m[1].startsWith('http')) return resolve(m[1]);
                }
                resolve(null);
              },
              onerror: () => resolve(null),
              ontimeout: () => resolve(null)
            });
          } catch (e) { resolve(null); }
        },
        onerror: () => resolve(null),
        ontimeout: () => resolve(null)
      });
    });
  }

  class Request {
    constructor () { this.lock = []; }
    send (url, cb) {
      let _this = this;
      return new Promise((resolve, reject) => {
        const idx = _this.lock.indexOf(url);
        if (idx !== -1) return reject('发送请求ing');
        _this.lock.push(url);
        GM_xmlhttpRequest({
          url, method: 'GET',
          headers: { "Cache-Control": "no-cache" },
          timeout: 30000,
          onload: (r) => { _this.lock.splice(idx, 1); resolve(r); },
          onabort: () => reject('wrong'),
          onerror: () => reject('wrong'),
          ontimeout: () => reject('wrong')
        });
      }).then(cb, (e) => console.log(e));
    }
  }

  // ==================== Base 类 ====================
  class Base {
    constructor () { this._lastVideo = null; }

    _injectPlaceholder (obj, code) {
      const html = `
        <div class="trailer-box" data-code="${code || ''}">
          <div class="trailer-toolbar">
            <span class="trailer-status">加载预告片中…</span>
            <label class="trailer-quality-label" style="display:none;">
              清晰度:
              <select class="trailer-quality">
                ${buildQualityOptionsHtml()}
              </select>
              <span class="trailer-quality-probing" style="display:none;">探测中…</span>
            </label>
          </div>
          <video class="trailer-video" controls playsinline muted loop preload="metadata"></video>
        </div>`;
      $(obj).before(html);
      const boxes = document.querySelectorAll('.trailer-box');
      const box = boxes[boxes.length - 1];
      const videoEl = box.querySelector('video.trailer-video');
      applyVolumeState(videoEl);
      this._lastVideo = videoEl;
      return { box, videoEl };
    }

    async _fillSource (box, videoEl, originalUrl) {
      if (!box || !videoEl || !originalUrl) return;
      console.log('[Trailer] Original source:', originalUrl);

      box._originalUrl = originalUrl;

      const label = box.querySelector('.trailer-quality-label');
      const probing = box.querySelector('.trailer-quality-probing');
      if (probing) probing.style.display = '';

      const pref = getPreferredQuality();
      let picked;
      try {
        picked = await pickForPref(originalUrl, pref);
      } catch (e) {
        picked = { height: 0, url: originalUrl };
      }
      if (!picked) picked = { height: 0, url: originalUrl };

      if (probing) probing.style.display = 'none';
      if (label) label.style.display = '';

      const useUrl = picked.url;
      videoEl.querySelectorAll('source').forEach(s => s.remove());
      const s = document.createElement('source');
      s.src = useUrl;
      s.type = 'video/mp4';
      videoEl.appendChild(s);
      box.classList.add('ready');
      try { videoEl.load(); } catch (e) {}
      videoEl.play().catch(() => {});
      videoEl.addEventListener('playing', () => {
        setCache((box.getAttribute('data-code') || '').toUpperCase(), originalUrl);
      }, { once: true });

      this._setupQualitySelect(box, videoEl, originalUrl);
    }

    _setupQualitySelect (box, videoEl, originalUrl) {
      const select = box.querySelector('.trailer-quality');
      const label = box.querySelector('.trailer-quality-label');
      if (!select) return;

      if (!box._qualityBound) {
        box._qualityBound = true;
        select.addEventListener('change', async () => {
          const q = select.value;
          setPreferredQuality(q);
          const orig = box._originalUrl;
          if (!orig) return;
          const probing = box.querySelector('.trailer-quality-probing');
          if (probing) probing.style.display = '';
          const target = await pickForPref(orig, q);
          if (probing) probing.style.display = 'none';
          if (!target) return;
          this._switchSource(videoEl, target.url);
        });
      }

      const pref = getPreferredQuality();
      if (Array.from(select.options).some(o => o.value === pref)) {
        select.value = pref;
      } else {
        select.value = 'auto-high';
      }
      if (label) label.style.display = '';
    }

    _switchSource (videoEl, url) {
      if (!videoEl || !url) return;
      const t = videoEl.currentTime || 0;
      const wasPlaying = !videoEl.paused;
      videoEl.querySelectorAll('source').forEach(s => s.remove());
      const s = document.createElement('source');
      s.src = url;
      s.type = 'video/mp4';
      videoEl.appendChild(s);
      try { videoEl.load(); } catch (e) {}
      const onMeta = () => {
        try {
          if (t > 0.5 && t < (videoEl.duration || 1e9) - 0.5) videoEl.currentTime = t;
        } catch (e) {}
        if (wasPlaying) videoEl.play().catch(() => {});
        videoEl.removeEventListener('loadedmetadata', onMeta);
      };
      videoEl.addEventListener('loadedmetadata', onMeta);
    }

    async _fillSources (box, videoEl, urls) {
      if (!box || !videoEl || !urls || !urls.length) return;
      let picked = null;
      const probing = box.querySelector('.trailer-quality-probing');
      if (probing) probing.style.display = '';
      for (const u of urls) {
        const r = await probeQuick(u);
        if (r.ok) { picked = u; break; }
      }
      if (probing) probing.style.display = 'none';
      if (!picked) picked = urls[0];
      return this._fillSource(box, videoEl, picked);
    }

    _fillError (box, msg) {
      if (!box) return;
      box.classList.add('error');
      const status = box.querySelector('.trailer-status');
      if (status) status.textContent = msg || '未找到预告片';
    }

    _resolveAndFill (code, box, videoEl) {
      const codeUpper = code.toUpperCase();
      let resolved = false;
      const tryResolve = (url, source) => {
        if (resolved || !url) return false;
        resolved = true;
        console.log('[Trailer] ✓ Resolved via', source);
        setCache(codeUpper, url);
        this._fillSource(box, videoEl, url);
        return true;
      };

      fetchDmmTrailerUrlParallel(code).then((url) => {
        if (tryResolve(url, 'DMM')) return;
        console.log('[Trailer] DMM failed, trying JavDB...');
        fetchJavdbTrailerUrl(code).then((jdbUrl) => {
          if (tryResolve(jdbUrl, 'JavDB')) return;
          this._fillError(box, '未找到预告片');
        });
      });
    }

    addVideo (code, obj) {
      const codeUpper = code.toUpperCase();

      if (/^HEYZO-/i.test(code)) {
        const n = code.split(/-/)[1];
        const { box, videoEl } = this._injectPlaceholder(obj, code);
        return this._fillSource(box, videoEl, `https://sample.heyzo.com/contents/3000/${n}/sample.mp4`);
      }
      if (/^HEYZ-/i.test(code)) {
        const n = code.split(/-/)[1];
        const { box, videoEl } = this._injectPlaceholder(obj, code);
        return this._fillSource(box, videoEl, `https://www.heyzo.com/contents/3000/${n}/heyzo_hd_${n}_sample.mp4`);
      }
      if (/^FC2-/i.test(code)) {
        const n = code.replace(/FC2(-PPV)?-/, '');
        $(obj).before(`<div class="trailer-box ready"><iframe src="https://contents.fc2.com/embed/${n}" style="width:100%;height:78vh;min-height:500px;border:none;"></iframe></div>`);
        return;
      }

      const { box, videoEl } = this._injectPlaceholder(obj, code);
      const cached = getCached(codeUpper);
      if (cached) {
        console.log('[Trailer] Cache hit:', cached);
        return this._fillSource(box, videoEl, cached);
      }
      this._resolveAndFill(code, box, videoEl);
    }

    addVideoC (code, obj) {
      const { box, videoEl } = this._injectPlaceholder(obj, code);
      this._fillSource(box, videoEl, `https://smovie.caribbeancom.com/sample/movies/${code}/1080p.mp4`);
    }
    addVideoY (code, obj) {
      const { box, videoEl } = this._injectPlaceholder(obj, code);
      this._fillSource(box, videoEl, `https://smovie.1pondo.tv/sample/movies/${code}/1080p.mp4`);
    }
    addVideoH (code, obj) {
      const { box, videoEl } = this._injectPlaceholder(obj, code);
      this._fillSource(box, videoEl, `https://smovie.10musume.com/sample/movies/${code}/1080p.mp4`);
    }
    addVideoPM (code, obj) {
      const { box, videoEl } = this._injectPlaceholder(obj, code);
      this._fillSource(box, videoEl, `https://fms.pacopacomama.com/hls/sample/pacopacomama.com/${code}/1080p.mp4`);
    }
    addVideoN (code, obj) {
      const urls = [
        `https://my.cdn.tokyo-hot.com/media/samples/${code}.mp4`,
        `https://my.cdn.tokyo-hot.com/media/samples/${code.toLowerCase()}.mp4`
      ];
      if (/RED048/i.test(code)) urls.unshift('https://my.cdn.tokyo-hot.com/media/samples/5923.mp4');
      if (/RED065/i.test(code)) urls.unshift('https://my.cdn.tokyo-hot.com/media/samples/5924.mp4');
      const { box, videoEl } = this._injectPlaceholder(obj, code);
      this._fillSources(box, videoEl, urls);
    }
    addVideolegsjapan (code, obj) { this.addVideo(code, obj); }
    addVideoVR (code, obj) { this.addVideo(code, obj); }
    addVideoMGS (code, obj) {
      $(obj).before(`<div class="trailer-box ready">
        <iframe src="https://www.mgstage.com/api/affiliate_sample_movie.php?p=${code}&w=1060&h=630"
                style="width:100%;height:78vh;min-height:500px;border:none;"></iframe>
      </div>`);
    }
    addVideoMSIN (code, obj) {
      const parts = code.split(/-/);
      const series = parts[0].toLowerCase();
      const num = parts[1];
      const num5 = num ? String(num).padStart(5, '0') : '';
      $(obj).before(`<div class="trailer-box ready">
        <iframe src="https://db.msin.jp/.play/sample.fanza?id=${series}${num5}"
                style="width:100%;height:78vh;min-height:500px;border:none;"></iframe>
      </div>`);
    }
  }

  // ==================== Emby 查询(需自行填 embyAPI / embyBaseUrl)====================
  function embyQuery (code, insertAfter) {
    if (!embyAPI || !embyBaseUrl) return;
    GM_xmlhttpRequest({
      method: "GET",
      url: embyBaseUrl + "emby/Users/" + embyAPI + "/Items?api_key=" + embyAPI +
           "&Recursive=true&IncludeItemTypes=Movie&SearchTerm=" + code,
      headers: { accept: "application/json" },
      onload: (res) => {
        try {
          const rr = JSON.parse(res.responseText);
          for (let i = 0; i < rr.Items.length; i++) {
            const url = embyBaseUrl + "web/index.html#!/item?id=" + rr.Items[i].Id +
                        "&serverId=" + rr.Items[i].ServerId;
            $(insertAfter).after(
              '<div style="border:3px solid HotPink;padding:20px;"><a href="' + url +
              '" target="_blank"><b><font size=6>&nbsp;&nbsp;跳转到emby👉</font></b></a></div>'
            );
          }
        } catch (e) {}
      }
    });
  }

  function renderTrailer (ctx, code, yulan, title) {
    if (/^[01]\d{5}[-_](?:1)?\d{2,3}$/i.test(code)) return ctx.addVideoC(code, yulan);
    if (/^[01]\d{5}_\d{3}$/.test(code))              return ctx.addVideoY(code, yulan);
    if (/^[01]\d{5}_0[12]$/.test(code))              return ctx.addVideoH(code, yulan);
    if (/^[01]\d{5}_\d{3}$/.test(code))              return ctx.addVideoPM(code, yulan);
    if (/legsjapan/i.test(code))                     return ctx.addVideolegsjapan(code, yulan);
    if (/VR-/i.test(code) || /\【VR/i.test(title))   return ctx.addVideoVR(code, yulan);
    if (/FC2-|FC2PPV-/i.test(code)) {
      const n = code.replace(/FC2(-PPV)?-/, '');
      $(yulan).before(`<div class="trailer-box ready"><iframe src="https://contents.fc2.com/embed/${n}" style="width:100%;height:78vh;min-height:500px;border:none;"></iframe></div>`);
      return;
    }
    if (/^[a-zA-Z]{1,16}\d{4}$|^\d{5}$|^(RED-|NKD-|RHJ-)\d{3}$/i.test(code)) {
      return ctx.addVideoN(code, yulan);
    }
    ctx.addVideo(code, yulan);
  }

  // ==================== 站点类 ====================
  class JavBus extends Base {
    constructor (req) { super(req); if ($('.col-md-3.info').length > 0) this.detailPage(); }
    detailPage () {
      const info = $('.col-md-3.info');
      const yulan = $('.row.movie');
      const title = $('.container > h3').text();
      const code = info.find('p').eq(0).find('span').eq(1).html();
      renderTrailer(this, code, yulan, title);
      embyQuery(code, ".star-show");
    }
  }

  class JavLibrary extends Base {
    constructor (req) { super(req); if ($('#video_info').length > 0) this.detailPage(); }
    detailPage () {
      const info = $('#video_info');
      const yulan = $('#video_jacket_info');
      const title = $('.post-title').text();
      const code = info.find('.item').eq(0).find('.text').html();
      renderTrailer(this, code, yulan, title);
      info.find('a').attr('target', '_blank');
      embyQuery(code, "#video_info");
    }
  }

  class Javdb extends Base {
    constructor (req) { super(req); if ($('.video-meta-panel').length > 0) this.detailPage(); }
    detailPage () {
      const info = $('.panel.movie-panel-info');
      const yulan = $('.video-meta-panel');
      const changyulan = $('#modal-comment-warning');
      const title = $('.title.is-4').text().trim();
      let code = $('body > section > div > div.video-detail > h2 > strong')
        .text().trim()
        .replace("10musu_", "").replace("ALOVE", "LOVE").replace("AAQUA", "AQUA").replace("AMCMA", "MCMA")
        .split(' ')[0];

      const codeUpper = code.toUpperCase();
      const self = this;

      const { box, videoEl } = this._injectPlaceholder(yulan, code);

      let resolved = false;
      const tryResolve = (url, source) => {
        if (resolved || !url) return false;
        resolved = true;
        console.log('[Trailer] ✓ JavDB resolved via', source, ':', url);
        setCache(codeUpper, url);
        self._fillSource(box, videoEl, url);
        return true;
      };

      const cached = getCached(codeUpper);
      if (cached) return tryResolve(cached, 'cache');
      const nativeSrc = getNativePreviewSrc();
      if (nativeSrc) return tryResolve(nativeSrc, 'native-immediate');

      let attempts = 0;
      const pollNative = () => {
        if (resolved) return;
        const s = getNativePreviewSrc();
        if (s) return tryResolve(s, 'native-poll');
        attempts++;
        if (attempts < 4) setTimeout(pollNative, 300);
      };
      setTimeout(pollNative, 300);

      fetchDmmTrailerUrlParallel(code).then((url) => {
        if (tryResolve(url, 'DMM')) return;
        fetchJavdbTrailerUrl(code).then((jdbUrl) => {
          if (tryResolve(jdbUrl, 'JavDB-fetch')) return;
          self._fillError(box, '未找到预告片');
        });
      });

      embyQuery(code, ".panel.movie-panel-info");

      if (changyulan.length) {
        changyulan.before(`<div class='columns'><div class='column'><article class='message video-panel'>
          <div class='message-header'><p>长缩略图 ${code} 在
            <a href='https://img.javstore.net/search/images/?q=%22${code}%22' target='_blank'>javstore</a>搜索</p></div>
          <div class='message-body'><div class="trailer-box" style="border:1px solid #ccc;">
            <a href='https://image.memojav.com/image/screenshot/${code}.jpg' target='_blank'>
              <img src='https://image.memojav.com/image/screenshot/${code}.jpg' style='max-width:100%;'></a>
          </div></div></article></div></div>`);
      }
    }
  }

  class Javbooks extends Base {
    constructor (req) { super(req); if ($('#info').length > 0) this.detailPage(); }
    detailPage () {
      const info = $('#info');
      const yulan = $('#info');
      $('#Preview_vedio_area > a > img').remove();
      $('body > p > a > img').remove();
      const title = $('#title').text().trim();
      const code = $('#info > div:nth-child(2) > font').text().trim().replace("10musu_", "").split(' ')[0];
      renderTrailer(this, code, yulan, title);
      embyQuery(code, "#info");
    }
  }

  class Avmoo extends Base {
    constructor (req) { super(req); if ($('.col-md-3.info').length > 0) this.detailPage(); }
    detailPage () {
      const info = $('.col-md-3.info');
      const yulan = $('.row.movie');
      const title = $('.container > h3').text();
      const code = info.find('p').eq(0).find('span').eq(1).html();
      renderTrailer(this, code, yulan, title);
      embyQuery(code, ".col-md-3.info");
    }
  }

  class Sehuatang extends Base {
    constructor (req) { super(req); if ($('#pgt').length > 0) this.detailPage(); }
    detailPage () {
      const yulan = $('#pgt');
      const reg = /([a-zA-Z]{2,15}[-\s]?\d{2,15}|FC2PPV-[^\d]{0,5}\d{6,7})/i;
      const str = document.title.split(" ")[0].split("   ")[0].split("【")[0].split("[")[0]
        .split("-carib")[0].split("-10mu-")[0].split("-paco-")[0].split("-1pon-")[0]
        .replace("SSSIS-", "SSIS-").replace("BBOBB-", "BOBB-").replace("SET-628", "FSET-628");
      const m = str.match(reg);
      if (!m) return;
      const code = m[0];
      const title = document.title;
      if (!/(高清中文字幕|亚洲有码原创|亚洲无码原创|4K原版|素人有码系列|PPV)/i.test(title)) return;
      renderTrailer(this, code, yulan, title);
      embyQuery(code, "#pgt");
    }
  }

  class Msin extends Base {
    constructor (req) { super(req); this.detailPage(); }
    detailPage () {
      const info = $('#top_content');
      const yulan = $('#breadcrumb');
      let code;
      if (/db\.msin\.jp\/jp\.page\/movie/.test(location.href)) {
        code = $('div.mv_pn').text().trim().split(' ')[0];
      } else {
        code = $('div.mv_fileName').text().trim().split(' ')[0].replace("fc2-ppv-", "fc2-");
      }
      const title = document.title;
      renderTrailer(this, code, yulan, title);
      embyQuery(code, "#top_content");
    }
  }

  function handleMakerDetail () {
    const info = $('body > main > section:nth-child(3) > div > p');
    let code = location.pathname.slice(location.pathname.lastIndexOf('/') + 1).toUpperCase();
    if (/^[a-z|A-Z]{2,8}\d{2,5}$/i.test(code)) {
      const n = code.search(/\d/);
      if (n > 0) code = code.slice(0, n) + "-" + code.slice(n);
    }
    const ctx = new Base();
    renderTrailer(ctx, code, info, document.title);
    $('.p-workPage__table').append(
      `<div class='item'><div class='th'>识别码</div><div class='td'>${code}</div></div>`
    );
    embyQuery(code, ".p-workPage__text");
  }

  function autoJumpHandler () {
    if (/javdb/i.test(location.hostname)) {
      const a = document.querySelectorAll('.item a[href*="/v/"]');
      const b = document.querySelectorAll('.box.actor-box a[href*="/actors/"]');
      if (a.length === 1) { location.href = a[0].href; return; }
      if (b.length === 1) { location.href = b[0].href; return; }
    }
    if (/\/search\//.test(location.href)) {
      const boxes = $('.movie-box');
      if (boxes.length === 1) { location.href = boxes[0].href; return; }
    }
    if (/xslist\.org\/search/.test(location.href)) {
      const rs = document.querySelectorAll('.clearfix');
      if (rs.length === 1) {
        const a = rs[0].querySelector('a');
        if (a) a.click();
      }
    }
  }

  class Main {
    constructor () {
      if ($("footer:contains('JavBus')").length) this.site = 'javBus';
      else if ($("#bottomcopyright:contains('JAVLibrary')").length) this.site = 'javLibrary';
      else if (/javdb/i.test(location.hostname)) this.site = 'javdb';
      else if ($("#footer:contains('javdb')").length) this.site = 'javdb';
      else if ($("#Declare_box:contains('javbooks')").length) this.site = 'javbooks';
      else if ($("footer:contains('AVMOO')").length) this.site = 'avmoo';
      else if ($("#flk:contains('色花堂')").length) this.site = 'sehuatang';
      else if ($("#footer:contains('db.msin.jp')").length) this.site = 'msin';
    }
    make () {
      const req = new Request();
      switch (this.site) {
        case 'javBus':       new JavBus(req); break;
        case 'javLibrary':   new JavLibrary(req); break;
        case 'javdb':        new Javdb(req); break;
        case 'javbooks':     new Javbooks(req); break;
        case 'avmoo':        new Avmoo(req); break;
        case 'sehuatang':    new Sehuatang(req); break;
        case 'msin':         new Msin(req); break;
      }
    }
  }

  try {
    console.log('[Trailer] start, site detection...');
    autoJumpHandler();
    if (/\/works\/detail/i.test(location.pathname)) {
      handleMakerDetail();
    } else {
      new Main().make();
    }
    console.log('[Trailer] done.');
  } catch (e) {
    console.error('[Trailer] init error:', e);
  }

})();