☰

JavBus Javdb trailer

双源

スクリプトをインストールするには、Tampermonkey, Greasemonkey や Violentmonkey のような拡張機能のインストールが必要です。

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

スクリプトをインストールするには、Tampermonkey や Violentmonkey のような拡張機能のインストールが必要です。

スクリプトをインストールするには、Tampermonkey や Userscripts のような拡張機能のインストールが必要です。

このスクリプトをインストールするには、Tampermonkeyなどの拡張機能をインストールする必要があります。

このスクリプトをインストールするには、ユーザースクリプト管理ツールの拡張機能をインストールする必要があります。

(ユーザースクリプト管理ツールは設定済みなのでインストール!)

このスタイルをインストールするには、Stylusなどの拡張機能をインストールする必要があります。

このスタイルをインストールするには、Stylus などの拡張機能をインストールする必要があります。

このスタイルをインストールするには、Stylus tなどの拡張機能をインストールする必要があります。

このスタイルをインストールするには、ユーザースタイル管理用の拡張機能をインストールする必要があります。

このスタイルをインストールするには、ユーザースタイル管理用の拡張機能をインストールする必要があります。

このスタイルをインストールするには、ユーザースタイル管理用の拡張機能をインストールする必要があります。

(ユーザースタイル管理ツールは設定済みなのでインストール!)

このスクリプトの質問や評価の投稿はこちら、通報はこちらへお寄せください
// ==UserScript==
// @name         JavBus Javdb trailer
// @name:zh-CN   JavBus/Javdb 预告片
// @namespace    https://greasyfork.org/zh-CN/scripts/596086
// @version      2026.09.24
// @description         双源
// @description:zh-cn   双源
// @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
// @require      https://cdn.jsdelivr.net/npm/[email protected]/dist/hls.min.js
// @connect      *
// @connect      javtrailers.com
// @connect      media.javtrailers.com
// @connect      dmm.co.jp
// @connect      video.dmm.co.jp
// @connect      cc3001.dmm.co.jp
// @connect      pv3001.dmm.co.jp
// @connect      cc3001.dmm.com
// @connect      api.video.dmm.co.jp
// @run-at       document-end
// ==/UserScript==

(function () {
  'use strict';

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

  GM_addStyle(`
    .header a.red {color:red;padding-left:2px;padding-right:2px;line-height:22px;}
    #tiaozhuan a.red {margin-right:15px;padding:3px 5px;background-color:rgb(255,215,0);font-size:large;color:rgb(255,0,0)!important;}
    .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 { display: block !important; width: 100% !important; max-width: 100% !important; height: auto !important; aspect-ratio: 16 / 9; max-height: 80vh; min-height: 320px; background: #000; }
    .trailer-toolbar { display: flex; align-items: center; justify-content: space-between; gap: 10px; flex-wrap: wrap; 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-controls { display: flex; align-items: center; gap: 14px; flex-wrap: wrap; }
    .trailer-quality-label { display: flex; align-items: center; gap: 6px; }
    .trailer-box.ready .trailer-status { display: none; }
    .trailer-box.ready.error .trailer-status { display: block !important; color: #f88; }
    .trailer-box.ready.error .trailer-toolbar { background: #3a1010; }
    .trailer-box:not(.ready) .trailer-quality-label { display: none !important; }
    .trailer-quality { background: #333; color: #ddd; border: 1px solid #555; border-radius: 3px; padding: 3px 6px; font-size: 12px; cursor: pointer; }
    .trailer-quality option { background: #222; color: #ddd; }
    .trailer-box.error .trailer-toolbar { background: #3a1010; color: #f88; }
    .trailer-quality-probing { font-size: 12px; color: #888; }
    .trailer-source-badge { font-size: 12px; color: #6c6; opacity: 0.85; }
    .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] ====== VERSION 2026.09.49 (4K downgrade support) ======');

  // ==================== 缓存 ====================
  const CACHE_TTL = 5 * 24 * 60 * 60 * 1000;
  const CID_CACHE_TTL = 30 * 24 * 60 * 60 * 1000;
  const CACHE_PREFIX_M3U8 = 'jvl_trailer_jt_m3u8_v23_';
  const CACHE_PREFIX_MP4  = 'jvl_trailer_jt_mp4_v23_';
  const CID_CACHE_PREFIX  = 'jvl_trailer_jt_cid_v15_';

  function _cacheGet (prefix, code) {
    try {
      const key = prefix + String(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 _cacheSet (prefix, code, url) {
    try {
      const key = prefix + String(code || '').toUpperCase();
      GM_setValue(key, JSON.stringify({ url, expire: Date.now() + CACHE_TTL }));
    } catch (e) {}
  }
  function _cacheDel (prefix, code) {
    try { GM_deleteValue(prefix + String(code || '').toUpperCase()); } catch (e) {}
  }
  const getCachedM3u8 = (code) => _cacheGet(CACHE_PREFIX_M3U8, code);
  const setCacheM3u8  = (code, url) => _cacheSet(CACHE_PREFIX_M3U8, code, url);
  const delCacheM3u8  = (code) => _cacheDel(CACHE_PREFIX_M3U8, code);
  const getCachedMp4  = (code) => _cacheGet(CACHE_PREFIX_MP4, code);
  const setCacheMp4   = (code, url) => _cacheSet(CACHE_PREFIX_MP4, code, url);

  function getCachedJavTrailersCid (code) {
    try {
      const key = CID_CACHE_PREFIX + String(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.cid) return data.cid;
      try { GM_deleteValue(key); } catch (e) {}
    } catch (e) {}
    return null;
  }
  function setCachedJavTrailersCid (code, cid) {
    try {
      const key = CID_CACHE_PREFIX + String(code || '').toUpperCase();
      GM_setValue(key, JSON.stringify({ cid, expire: Date.now() + CID_CACHE_TTL }));
    } catch (e) {}
  }

  // ==================== 音量记忆 ====================
  function getSavedVolume () {
    try { const v = GM_getValue('jvl_trailer_volume', null); if (v == null) 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) return true; return String(m) === 'true'; } catch (e) { return true; }
  }
  function saveVolumeState (v) {
    try { GM_setValue('jvl_trailer_volume', String(v.volume)); GM_setValue('jvl_trailer_muted', String(v.muted)); } catch (e) {}
  }
  function applyVolumeState (v) {
    try {
      v.volume = getSavedVolume(); v.muted = getSavedMuted();
      let t = null;
      v.addEventListener('volumechange', () => { if (t) clearTimeout(t); t = setTimeout(() => saveVolumeState(v), 300); });
    } catch (e) {}
  }

  // ==================== 清晰度偏好(★ m3u8 和 mp4 各自独立记忆) ====================
  const QUALITY_KEY_M3U8 = 'jvl_trailer_quality_m3u8_v2';
  const QUALITY_KEY_MP4  = 'jvl_trailer_quality_mp4_v2';
  const VALID_PRESETS = ['auto-high', 'auto-low'];
  // ★ 新增 2160(4K);4K 视频会显示 2160p 选项
  const SUPPORTED_HEIGHTS = [2160, 1080, 720, 576, 432, 288, 144];

  function _qualityKey (mode) { return mode === 'mp4' ? QUALITY_KEY_MP4 : QUALITY_KEY_M3U8; }

  function getPreferredQuality (mode) {
    try {
      const key = _qualityKey(mode);
      let q = GM_getValue(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) && n > 0) return String(n);
    } catch (e) {}
    return 'auto-high';
  }
  function setPreferredQuality (q, mode) {
    try { GM_setValue(_qualityKey(mode), String(q)); } catch (e) {}
  }

  function resolvePreference (variants, pref) {
    if (!variants || !variants.length) return null;
    if (pref === 'auto-high') return variants[0];
    if (pref === 'auto-low') return variants[variants.length - 1];
    const nPref = parseInt(pref, 10);
    if (isNaN(nPref)) return variants[0];
    const exact = variants.find(v => v.height === nPref);
    if (exact) return exact;
    const higher = variants.filter(v => v.height > nPref).sort((a, b) => a.height - b.height);
    if (higher.length) return higher[0];
    return variants[0];
  }

  function firstTruthy (promises) {
    return new Promise((resolve) => {
      if (!promises.length) return resolve(null);
      let remaining = promises.length;
      let done = false;
      const finish = (v) => { if (done) return; done = true; resolve(v); };
      promises.forEach(p => {
        Promise.resolve(p).then(v => {
          if (v) return finish(v);
          if (--remaining === 0) finish(null);
        }, () => {
          if (--remaining === 0) finish(null);
        });
      });
    });
  }

  function normalizeCode (code) {
    if (!code) return '';
    const s = String(code).toUpperCase().replace(/[^A-Z0-9]/g, '');
    const m = s.match(/^([A-Z]+)(\d+)$/);
    if (!m) return s;
    return m[1] + ':' + String(parseInt(m[2], 10));
  }

  function containsCode (text, code) {
    const target = normalizeCode(code);
    if (!target) return false;
    const norm = String(text).toUpperCase().replace(/[^A-Z0-9]/g, '');
    const re = /([A-Z]+)(\d+)/g;
    let m;
    while ((m = re.exec(norm)) !== null) {
      if (m[1] + ':' + String(parseInt(m[2], 10)) === target) return true;
    }
    return false;
  }

  // ============================================================
  // CID 变体扩展(javtrailers 用)
  // ============================================================
  function expandCidVariants (cid) {
    cid = String(cid || '').trim().toLowerCase();
    if (!cid) return [];
    const out = new Set();
    const add = (c) => { if (c && /^[a-z0-9_]+$/i.test(c) && c.length >= 3) out.add(c); };

    add(cid);
    if (/^\d+[a-z]/.test(cid)) {
      add(cid.replace(/^\d+/, ''));
    }
    if (/^[a-z]/.test(cid)) {
      add('1' + cid);
    }

    const current = Array.from(out);
    for (const c of current) {
      const m = c.match(/^([a-z0-9_]+?)(\d+)([a-z0-9_]*)$/i);
      if (m) {
        const series = m[1];
        const num = m[2];
        const suffix = m[3] || '';
        const numStripped = String(parseInt(num, 10));
        add(series + numStripped + suffix);
        add(series + num.padStart(3, '0') + suffix);
        add(series + num.padStart(5, '0') + suffix);
        add(series + num + suffix);
      }
    }
    return Array.from(out);
  }

  // ============================================================
  // GM_xmlhttpRequest 版 hls.js Loader
  // ============================================================
  function createGmLoader () {
    return class GmLoader {
      constructor (config) {
        this.config = config || {};
        this.stats = {
          aborted: false, loaded: 0, retry: 0, total: 0, chunkCount: 0, bwEstimate: 0,
          loading: { start: 0, first: 0, end: 0 },
          parsing: { start: 0, end: 0 },
          buffering: { start: 0, first: 0, end: 0 }
        };
        this.callbacks = null;
        this.context = null;
      }
      destroy () { this.abort(); }
      abort () { this.stats.aborted = true; }
      load (context, config, callbacks) {
        this.context = context;
        if (config) this.config = config;
        this.callbacks = callbacks;
        const url = context.url;
        const wantBinary = context.responseType === 'arraybuffer';
        const timeoutMs = (config && config.timeout) || (this.config && this.config.timeout) || 20000;
        this.stats.loading.start = performance.now();
        GM_xmlhttpRequest({
          url, method: 'GET', timeout: timeoutMs,
          responseType: wantBinary ? 'arraybuffer' : 'text',
          headers: {
            'Referer': 'https://javtrailers.com/',
            'Origin': 'https://javtrailers.com',
            'Accept': '*/*'
          },
          onload: (res) => {
            if (this.stats.aborted) return;
            const status = Number(res.status || 0);
            const now = performance.now();
            this.stats.loading.first = now;
            this.stats.loading.end = now;
            if (status >= 200 && status < 400) {
              let data;
              if (wantBinary) {
                data = res.response instanceof ArrayBuffer ? res.response : new ArrayBuffer(0);
                this.stats.loaded = data.byteLength || 0;
              } else {
                data = String(res.responseText || '');
                this.stats.loaded = data.length;
              }
              this.stats.total = this.stats.loaded;
              this.stats.chunkCount = 1;
              callbacks.onSuccess({ url: res.finalUrl || url, data }, this.stats, context, res);
            } else {
              callbacks.onError({ code: status, text: 'HTTP ' + status }, context, res, this.stats);
            }
          },
          onerror: () => { if (this.stats.aborted) return; callbacks.onError({ code: 0, text: 'network' }, context, null, this.stats); },
          ontimeout: () => { if (this.stats.aborted) return; callbacks.onError({ code: 0, text: 'timeout' }, context, null, this.stats); }
        });
      }
    };
  }

  // ==================== M3U8 探测 ====================
  function probeM3u8 (url) {
    return new Promise((resolve) => {
      if (!url) return resolve(false);
      GM_xmlhttpRequest({
        url, method: 'GET', timeout: 3500,
        headers: {
          'User-Agent': navigator.userAgent,
          'Accept': 'application/vnd.apple.mpegurl, application/x-mpegURL, */*',
          'Referer': 'https://javtrailers.com/'
        },
        onload: (res) => {
          const status = Number(res.status || 0);
          const text = String(res.responseText || '');
          const ok = status >= 200 && status < 400 && /#EXTM3U/i.test(text);
          console.log('[Trailer] M3U8 probe:', ok ? 'OK' : 'FAIL', status, url);
          resolve(ok);
        },
        onerror: () => resolve(false),
        ontimeout: () => resolve(false)
      });
    });
  }

  function buildJavTrailersCids (code) {
    if (!code) return [];
    const parts = String(code).split(/-/);
    if (parts.length < 2) return [];
    const series = parts[0].toLowerCase().replace(/[^a-z0-9_]/g, '');
    const numRaw = parts[1].replace(/[^0-9]/g, '');
    if (!series || !numRaw) return [];

    const out = new Set();
    out.add(series + numRaw);
    out.add(series + numRaw.padStart(3, '0'));
    out.add(series + numRaw.padStart(5, '0'));
    const stripped = String(parseInt(numRaw, 10));
    if (stripped !== numRaw) out.add(series + stripped);

    return Array.from(out);
  }

  function buildJavTrailersM3u8Candidates (cid) {
    cid = String(cid || '').trim().toLowerCase();
    if (!cid || cid.length < 3) return [];
    const first = cid[0];
    const first3 = cid.substring(0, 3);
    return [
      `https://media.javtrailers.com/hlsvideo/freepv/${first}/${first3}/${cid}/playlist.m3u8`,
      `https://media.javtrailers.com/hlsvideo/freepv/${first}/${first3}/${cid}/${cid}.m3u8`,
      `https://media.javtrailers.com/hlsvideo/freepv/${first}/${first3}/${cid}/master.m3u8`
    ];
  }

  function fetchText (url, timeoutMs) {
    return new Promise((resolve) => {
      GM_xmlhttpRequest({
        url, method: 'GET', timeout: timeoutMs || 6000,
        headers: {
          'User-Agent': navigator.userAgent,
          'Accept': 'text/html,application/xhtml+xml,application/json,*/*',
          'Referer': 'https://javtrailers.com/'
        },
        onload: (res) => {
          const s = Number(res.status || 0);
          if (s >= 200 && s < 400) resolve(String(res.responseText || ''));
          else resolve(null);
        },
        onerror: () => resolve(null),
        ontimeout: () => resolve(null)
      });
    });
  }

  async function verifyJavTrailersCidSingle (cid, code) {
    if (!cid || !code) return false;
    try {
      const html = await fetchText(`https://javtrailers.com/video/${cid}`, 5000);
      if (!html) return false;
      const sources = [];
      let m = html.match(/<title[^>]*>([^<]+)<\/title>/i);
      if (m) sources.push(m[1]);
      m = html.match(/<meta[^>]+property=["']og:title["'][^>]+content=["']([^"]+)["']/i);
      if (m) sources.push(m[1]);
      m = html.match(/<h1[^>]*>([^<]+)<\/h1>/i);
      if (m) sources.push(m[1]);
      m = html.match(/<meta[^>]+name=["']description["'][^>]+content=["']([^"]+)["']/i);
      if (m) sources.push(m[1]);

      for (const s of sources) {
        if (containsCode(s, code)) {
          console.log('[Trailer][JT-Verify] ✓ 匹配:', cid, '←', s.slice(0, 60));
          return true;
        }
      }
      console.log('[Trailer][JT-Verify] ✗ 不匹配:', cid, '| 期望:', normalizeCode(code), '| 页面:', (sources[0] || '').slice(0, 60));
      return false;
    } catch (e) {
      return false;
    }
  }

  async function verifyJavTrailersCid (cid, code) {
    if (!cid || !code) return false;
    if (await verifyJavTrailersCidSingle(cid, code)) return true;
    const variants = expandCidVariants(cid);
    for (const v of variants) {
      if (v === cid) continue;
      if (await verifyJavTrailersCidSingle(v, code)) {
        console.log('[Trailer][JT-Verify] ✓ 变体匹配:', cid, '→', v);
        return true;
      }
    }
    return false;
  }

  function extractJavTrailersCid (html, code) {
    if (!html) return null;
    if (html.indexOf('/video/') === -1) return null;

    const linkRe = /<a[^>]+href=["'](?:\/ja)?\/video\/([a-z0-9_]+)["'][^>]*>([\s\S]{0,500}?)<\/a>/gi;
    let m;
    while ((m = linkRe.exec(html)) !== null) {
      const cid = m[1];
      const innerText = m[2].replace(/<[^>]+>/g, ' ');
      if (containsCode(innerText, code)) {
        console.log('[Trailer][JT-Search] ✓ 链接文本匹配:', cid, '←', innerText.slice(0, 60).replace(/\s+/g, ' '));
        return cid;
      }
      if (containsCode(m[2], code) || containsCode(cid, code)) {
        console.log('[Trailer][JT-Search] ✓ 链接属性匹配:', cid);
        return cid;
      }
    }

    const allVideos = [...html.matchAll(/\/video\/([a-z0-9_]+)/gi)].map(x => x[1]);
    const unique = [...new Set(allVideos)];
    for (const cid of unique) {
      if (containsCode(cid, code)) {
        console.log('[Trailer][JT-Search] ✓ CID 本身匹配:', cid);
        return cid;
      }
    }
    console.log('[Trailer][JT-Search] 找到', unique.length, '个 /video/ 链接但都不匹配:', unique.slice(0, 5));
    return null;
  }

  async function fetchJavTrailersCidFromSearch (code) {
    const cached = getCachedJavTrailersCid(code);
    if (cached) {
      console.log('[Trailer][JT-Search] ✓ 从缓存拿到 CID:', cached);
      return cached;
    }

    const clean = String(code).trim();
    const searchUrls = [
      `https://javtrailers.com/ja/search/${clean}`,
      `https://javtrailers.com/search/${clean}`
    ];

    console.log('[Trailer][JT-Search] 并行搜索:', searchUrls, '| 期望:', normalizeCode(code));

    const results = await Promise.all(searchUrls.map(async (url) => {
      try {
        const html = await fetchText(url, 6000);
        if (!html) return null;
        const cid = extractJavTrailersCid(html, code);
        if (!cid) return null;
        const valid = await verifyJavTrailersCid(cid, code);
        if (!valid) {
          console.log('[Trailer][JT-Search] ✗ 验证失败,跳过:', cid);
          return null;
        }
        console.log('[Trailer][JT-Search] ✓', url, '→', cid);
        return cid;
      } catch (e) {}
      return null;
    }));

    const cid = results.find(r => r);
    if (cid) {
      setCachedJavTrailersCid(code, cid);
      console.log('[Trailer][JT-Search] ✓ 已缓存 CID');
    }
    return cid;
  }

  async function probeCidsAll (cids) {
    if (!cids || !cids.length) return null;
    const allUrls = [];
    for (const cid of cids) {
      for (const u of buildJavTrailersM3u8Candidates(cid)) allUrls.push(u);
    }
    console.log('[Trailer][JT] m3u8 候选 URL 数:', allUrls.length);
    const results = await Promise.all(allUrls.map(async (url) => ({
      url, ok: await probeM3u8(url)
    })));
    const hit = results.find(r => r.ok);
    return hit ? hit.url : null;
  }

  async function probeLitevideoMp4 (cids) {
    if (!cids || !cids.length) return [];
    const candidates = [];
    const seen = new Set();

    for (const cid of cids) {
      const variants = expandCidVariants(cid);
      for (const c of variants) {
        if (!c || c.length < 3) continue;
        const first = c[0];
        const first3 = c.substring(0, 3);
        const urls = [
          `https://media.javtrailers.com/litevideo/freepv/${first}/${first3}/${c}/${c}_dmb_w.mp4`,
          `https://media.javtrailers.com/litevideo/freepv/${first}/${first3}/${c}/${c}_dmb.mp4`,
          `https://media.javtrailers.com/litevideo/freepv/${first}/${first3}/${c}/${c}.mp4`
        ];
        for (const u of urls) {
          if (seen.has(u)) continue;
          seen.add(u);
          candidates.push({ url: u, cid: c });
        }
      }
    }
    console.log('[Trailer][JT] litevideo MP4 候选数:', candidates.length);

    const results = await Promise.all(candidates.map(async (c) => {
      try {
        const r = await new Promise((resolve) => {
          GM_xmlhttpRequest({
            url: c.url, method: 'GET', timeout: 3500,
            headers: {
              'User-Agent': navigator.userAgent,
              'Accept': 'video/mp4,*/*',
              'Referer': 'https://javtrailers.com/'
            },
            onload: (res) => resolve({ status: Number(res.status || 0) }),
            onerror: () => resolve(null),
            ontimeout: () => resolve(null)
          });
        });
        if (r && r.status >= 200 && r.status < 400) {
          console.log('[Trailer][JT] MP4 probe OK:', c.url);
          return c;
        }
        return null;
      } catch (e) { return null; }
    }));
    return results.filter(r => r);
  }

  async function resolveJavTrailersM3u8 (code) {
    if (!code) return null;

    let finished = false;
    const finish = (url, from) => {
      if (finished) return null;
      if (url) {
        finished = true;
        console.log('[Trailer][JT] ✓ 命中 via', from, ':', url);
      }
      return url;
    };

    const cachedCid = getCachedJavTrailersCid(code);
    if (cachedCid) {
      console.log('[Trailer][JT] 使用缓存的 CID:', cachedCid);
      const url = await probeCidsAll([cachedCid]);
      if (url) return finish(url, 'CID缓存 m3u8');
      const mp4Hits = await probeLitevideoMp4([cachedCid]);
      for (const hit of mp4Hits) {
        if (await verifyJavTrailersCid(hit.cid, code)) return finish(hit.url, 'CID缓存 MP4');
      }
    }

    const directPromise = (async () => {
      const cids = buildJavTrailersCids(code);
      if (!cids.length) return null;
      console.log('[Trailer][JT] 直接构造 CID:', cids);

      const m3u8Candidates = [];
      for (const cid of cids) {
        for (const u of buildJavTrailersM3u8Candidates(cid)) {
          m3u8Candidates.push({ url: u, cid: cid });
        }
      }
      const m3u8Results = await Promise.all(m3u8Candidates.map(async (c) => {
        const ok = await probeM3u8(c.url);
        return ok ? c : null;
      }));
      const m3u8Hits = m3u8Results.filter(r => r);

      if (m3u8Hits.length) {
        console.log('[Trailer][JT] m3u8 探测命中', m3u8Hits.length, '个,逐个验证');
        for (const hit of m3u8Hits) {
          if (finished) return null;
          if (await verifyJavTrailersCid(hit.cid, code)) return finish(hit.url, '直接构造 m3u8');
        }
      }

      const mp4Hits = await probeLitevideoMp4(cids);
      if (mp4Hits.length) {
        console.log('[Trailer][JT] litevideo MP4 探测命中', mp4Hits.length, '个,逐个验证');
        for (const hit of mp4Hits) {
          if (finished) return null;
          if (await verifyJavTrailersCid(hit.cid, code)) return finish(hit.url, 'litevideo MP4');
        }
        console.log('[Trailer][JT] litevideo MP4 命中均验证失败');
      }
      return null;
    })();

    const searchPromise = new Promise((resolve) => {
      setTimeout(async () => {
        if (finished) return resolve(null);
        console.log('[Trailer][JT] 搜索页兜底启动…');
        try {
          const realCid = await fetchJavTrailersCidFromSearch(code);
          if (finished || !realCid) return resolve(null);
          const url = await probeCidsAll([realCid]);
          if (url) return resolve(finish(url, '搜索页 m3u8'));
          const mp4Hits = await probeLitevideoMp4([realCid]);
          for (const hit of mp4Hits) {
            if (finished) return resolve(null);
            if (await verifyJavTrailersCid(realCid, code)) return resolve(finish(hit.url, '搜索页 MP4'));
          }
          resolve(null);
        } catch (e) { resolve(null); }
      }, 300);
    });

    return await firstTruthy([directPromise, searchPromise]);
  }

  // ==================== DMM 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,
      /https?:\/\/cc3001\.dmm\.co\.jp\/pv\/[^"'\s<>\\]+\/([a-z0-9_]+)\.mp4/gi,
      /https?:\/\/cc3001\.dmm\.com\/pv\/[^"'\s<>\\]+\/([a-z0-9_]+)\.mp4/gi,
    ];
    // ★ 优先选带 _hhb/_hmb/_mhb/_mmb/_dm/_sm 后缀的(有分级),
    //    其次才是 4k 版本,再次才是任意 .mp4
    let fallback = '';
    for (const p of patterns) {
      const m = decoded.match(p);
      if (m && m.length) {
        const preferred = m.find(u => /(hhb|hmb|mhb|mmb|dm|sm)\.mp4$/i.test(u));
        if (preferred) return preferred;
        const any4k = m.find(u => /4k\.mp4$/i.test(u));
        if (any4k && !fallback) fallback = any4k;
        const anyMp4 = m.find(u => /\.mp4$/i.test(u));
        if (anyMp4 && !fallback) fallback = anyMp4;
      }
    }
    return fallback;
  }

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

  function filterMatchingCids (cids, series, num) {
    const s = series.toLowerCase(), n = String(num);
    const n3 = n.padStart(3, '0'), n5 = n.padStart(5, '0');
    const stripped = String(parseInt(n, 10));
    const matches = cids.filter(c => c.includes(s) && (c.includes(n) || c.includes(n3) || c.includes(n5) || c.includes(stripped)));
    matches.sort((a, b) => {
      const sc = (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 sc(a) - sc(b);
    });
    return matches;
  }

  const _cidCache = new Map();
  function fetchCidsFromDmmSearch (code) {
    const c = _cidCache.get(code);
    if (c && Date.now() - c.t < 60000) return Promise.resolve(c.cids);
    return new Promise((resolve) => {
      const parts = code.split(/-/);
      if (parts.length < 2) return resolve([]);
      const series = parts[0].toLowerCase(), num = parts[1];
      const urls = [];
      for (const kw of [`${series}${num}`, `${series}-${num}`, `${series}${num.padStart(5, '0')}`]) {
        const enc = encodeURIComponent(kw);
        urls.push(`https://www.dmm.co.jp/digital/videoa/-/search/=/searchstr=${enc}/`);
        urls.push(`https://www.dmm.co.jp/mono/dvd/-/search/=/searchstr=${enc}/`);
      }
      let done = false, pending = urls.length;
      const all = new Set();
      const finish = () => {
        if (done) return; done = true;
        const f = filterMatchingCids(Array.from(all), series, num);
        _cidCache.set(code, { cids: f, t: Date.now() });
        resolve(f);
      };
      urls.forEach((url) => {
        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/', 'Cookie': 'age_check_done=1;'
          },
          onload: (res) => {
            if (done) return;
            extractCidsFromHtml(res.responseText || '').forEach(c => all.add(c));
            pending--;
            const f = filterMatchingCids(Array.from(all), series, num);
            if (f.length > 0) setTimeout(finish, 100);
            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), stripped = String(parseInt(n, 10));
    const n3 = n.padStart(3, '0'), n5 = n.padStart(5, '0');
    const out = []; const push = (c) => { if (c && !out.includes(c)) out.push(c); };
    const bases = [series + n, series + n3, series + n5];
    if (stripped !== n) bases.push(series + stripped);
    bases.forEach(push);

    for (const base of bases) {
      for (const rejo of ['rejo01', 'rejo02', 'rejo03']) {
        push(base + rejo);
      }
    }

    const snapshot = Array.from(out);
    for (const c of snapshot) {
      if (c && !/^\d/.test(c)) {
        push('1' + c);
      }
    }

    return out;
  }

  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' }
  ];
  const SUFFIX_TO_HEIGHT = { hhb: 1080, hmb: 720, mhb: 576, mmb: 432, dm: 288, sm: 144 };

  // ★ 支持 4K URL 降级:形如 xxx4k.mp4 → 剥离 4k 后构造 hhb/hmb/... 变体
  function buildCandidates (rawUrl) {
    if (!rawUrl) return [];
    const url = String(rawUrl).replace(/\?.*$/, '');

    // 4K 版本:xxx4k.mp4
    // 剥离 4k 得到基础 CID,再构造各分辨率变体;4K 本身作为 2160
    const m4k = url.match(/^(.+?[a-z0-9_])4k\.mp4$/i);
    if (m4k) {
      const base = m4k[1];
      // 简单校验 base 结尾为字母数字,避免误剥离
      return [
        { height: 2160, url: url },                       // 4K 原始
        { height: 1080, url: `${base}hhb.mp4` },
        { height: 720,  url: `${base}hmb.mp4` },
        { height: 576,  url: `${base}mhb.mp4` },
        { height: 432,  url: `${base}mmb.mp4` },
        { height: 288,  url: `${base}dm.mp4` },
        { height: 144,  url: `${base}sm.mp4` }
      ];
    }

    let m = url.match(/^(.+?)\/(\d{3,4})p\.mp4$/i);
    if (m) return SUPPORTED_HEIGHTS.map(h => ({ height: h, url: `${m[1]}/${h}p.mp4` }));
    m = url.match(/^(.+?)_(hhb|hmb|mhb|mmb|dm|sm)_([ws])\.mp4$/i);
    if (m) { const base = m[1] + '_', w = m[3]; return DMM_SUFFIX_ORDER.map(e => ({ height: e.height, url: `${base}${e.sfx}_${w}.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` })); }
    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 }); } }, 2500);
    });
  }

  async function pickForPref (url, pref) {
    const cs = buildCandidates(url);
    if (cs.length === 1 && cs[0].height === 0) return cs[0];
    let order;
    if (pref === 'auto-high') order = [2160, 1080, 720, 576, 432, 288, 144];
    else if (pref === 'auto-low') order = [144, 288, 432, 576, 720, 1080, 2160];
    else {
      const n = parseInt(pref, 10);
      if (isNaN(n)) order = [2160, 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 ms = cs.filter(c => c.height === h);
      for (const c of ms) { const r = await probeQuick(c.url); if (r.ok) return { height: h, url: c.url }; }
    }
    return { height: 0, url };
  }

  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/`,
        `https://video.dmm.co.jp/av/content/?id=${cid}`
      ];
      let idx = 0;
      const next = () => {
        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/', 'Cookie': 'age_check_done=1;'
          },
          onload: (res) => { const pv = extractPvFromHtml(res.responseText || ''); if (pv) resolve(pv); else next(); },
          onerror: next, ontimeout: next
        });
      };
      next();
    });
  }

  function fetchFanzaTrailerUrl (code) {
    return new Promise((resolve) => {
      const parts = code.split(/-/);
      if (parts.length < 2) return resolve(null);
      const series = parts[0].toLowerCase(), num = parts[1];
      const words = [`${parts[0]}-${num}`, `${series}${num}`, `${series}${num.padStart(5, '0')}`];
      let idx = 0;
      const next = () => {
        if (idx >= words.length) return resolve(null);
        const sw = words[idx++];
        const q = `{ legacySearchPPV(limit: 10, searchWord: "${sw}") { items { cid title sampleUrl sampleMovieUrl } } }`;
        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: q }),
          onload: (res) => {
            try {
              const data = JSON.parse(res.responseText);
              const items = (data && data.data && data.data.legacySearchPPV && 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 f = filterMatchingCids(cids, series, num);
                const target = f.length ? f : cids;
                let pending = target.length;
                target.forEach(cid => fetchPvFromCid(cid).then(pv => {
                  if (pv) return resolve(pv);
                  pending--; if (pending === 0) next();
                }));
                return;
              }
              next();
            } catch (e) { next(); }
          },
          onerror: next, ontimeout: next
        });
      };
      next();
    });
  }

  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/`);
    const addVideo = (c) => urls.push(`https://video.dmm.co.jp/av/content/?id=${c}`);

    for (const c of cids) {
      if (/^1/.test(c) && /rejo\d+$/.test(c)) addVideo(c);
    }
    for (const c of cids) addVideo(c);
    for (const c of cids) addLite(c);
    for (const c of cids) add(c, 'mono/dvd');
    for (const c of cids) add(c, 'digital/videoa');
    return urls;
  }

  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 done = false;
      const finish = (url) => { if (done || !url) return; done = true; resolve(url); };
      const guessUrls = buildDmmUrlList(series, parts[1]);
      const CONC = 6; let nextIdx = 0;
      const run = () => {
        if (done || nextIdx >= guessUrls.length) return;
        const url = guessUrls[nextIdx++];
        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/', 'Cookie': 'age_check_done=1;'
          },
          onload: (res) => { if (done) return; const pv = extractPvFromHtml(res.responseText || ''); if (pv) finish(pv); else run(); },
          onerror: () => { if (!done) run(); },
          ontimeout: () => { if (!done) run(); }
        });
      };
      for (let i = 0; i < Math.min(CONC, guessUrls.length); i++) run();
      setTimeout(() => {
        if (done) return;
        fetchCidsFromDmmSearch(code).then(cids => {
          if (done || !cids.length) return;
          cids.slice(0, 5).forEach(cid => fetchPvFromCid(cid).then(pv => { if (pv) finish(pv); }));
        });
      }, 100);
      setTimeout(() => {
        if (done) return;
        fetchFanzaTrailerUrl(code).then(url => { if (url) finish(url); });
      }, 500);
      setTimeout(() => { if (!done) { done = true; resolve(null); } }, 12000);
    });
  }

  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, '-') : '';
              if (uidText === norm || (a.textContent || '').toUpperCase().replace(/_/g, '-').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 pats = [/<video[^>]*id="preview-video"[^>]*src="([^"]+\.mp4[^"]*)"/i, /<source[^>]*src="([^"]+\.mp4[^"]*)"/i];
                for (const p of pats) { 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)
      });
    });
  }

  // ==================== 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>
            <span class="trailer-controls">
              <span class="trailer-source-badge" style="display:none;"></span>
              <label class="trailer-quality-label" style="display:none;">清晰度:
                <select class="trailer-quality"></select>
              </label>
              <span class="trailer-quality-probing" style="display:none;">探测中…</span>
            </span>
          </div>
          <video class="trailer-video" controls playsinline muted preload="metadata"></video>
        </div>`;
      const $html = $(html);
      $(obj).before($html);
      const box = $html[0];
      const videoEl = box.querySelector('video.trailer-video');
      applyVolumeState(videoEl);
      this._lastVideo = videoEl;

      const qSel = box.querySelector('.trailer-quality');
      qSel.addEventListener('change', () => { this._onQualityChange(box, videoEl, qSel.value); });
      return { box, videoEl };
    }

    _setSourceBadge (box, text) {
      const el = box.querySelector('.trailer-source-badge');
      if (!el) return;
      if (text) { el.textContent = text; el.style.display = ''; }
      else { el.textContent = ''; el.style.display = 'none'; }
    }

    addVideo (code, obj) {
      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`, code);
      }
      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`, code);
      }
      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);
      this._startLoad(box, videoEl);
    }

    _startLoad (box, videoEl) {
      const code = box.getAttribute('data-code') || '';
      box._gen = (box._gen || 0) + 1;
      this._loadTrailer(box, videoEl, code, box._gen);
    }

    async _loadTrailer (box, videoEl, code, gen) {
      const status = box.querySelector('.trailer-status');
      const probing = box.querySelector('.trailer-quality-probing');
      const qLabel = box.querySelector('.trailer-quality-label');
      const stale = () => box._gen !== gen;

      box.classList.remove('ready', 'error');
      if (qLabel) qLabel.style.display = 'none';
      if (probing) { probing.style.display = ''; probing.textContent = '探测中…'; }
      if (status) status.textContent = '加载预告片中…';
      this._setSourceBadge(box, '');

      this._resetVideo(videoEl);
      console.log('[Trailer] 番号:', code, '(先 m3u8,失败自动回退 mp4)');

      let ok = false;
      let viaM3u8 = false;
      if (code) {
        ok = await this._tryM3u8(box, videoEl, code, probing, stale, gen);
        viaM3u8 = ok;
      }
      if (stale()) return;

      if (!ok) {
        console.log('[Trailer] m3u8 不可用,自动回退 mp4…');
        this._resetVideo(videoEl);
        if (probing) { probing.style.display = ''; probing.textContent = '回退 MP4 中…'; }
        if (status) status.textContent = '加载预告片中…';
        ok = await this._tryMp4(box, videoEl, code, probing, stale, gen);
        if (stale()) return;
      }

      if (probing) probing.style.display = 'none';
      if (!ok) {
        box.classList.remove('ready');
        this._showPersistentNotice(box, '未找到预告片(m3u8 / mp4 均无)');
      } else {
        this._setSourceBadge(box, viaM3u8 ? '源: m3u8' : '源: mp4');
      }
    }

    _resetVideo (v) {
      if (v._hls) { try { v._hls.destroy(); } catch (e) {} v._hls = null; }
      v._mode = null; v._variants = null; v._originalUrl = null;
      v.querySelectorAll('source').forEach(s => s.remove());
      v.removeAttribute('src');
      try { v.load(); } catch (e) {}
    }

    async _tryM3u8 (box, videoEl, code, probing, stale, gen) {
      if (!code) return false;
      let url = '';
      const cached = getCachedM3u8(code);
      if (cached) {
        console.log('[Trailer] m3u8 缓存命中:', cached);
        if (probing) probing.textContent = '验证缓存…';
        url = cached;
      }
      if (stale()) return false;

      if (!url) {
        if (probing) probing.textContent = 'JavTrailers 解析中…';
        url = (await resolveJavTrailersM3u8(code)) || '';
      }
      if (stale()) return false;

      if (!url) {
        console.log('[Trailer] JavTrailers 未找到任何源');
        return false;
      }

      console.log('[Trailer] ✓ m3u8/URL:', url);
      if (probing) probing.style.display = 'none';
      box.classList.add('ready');

      const isM3u8 = /\.m3u8(?:\?|$)/i.test(url);
      let ok = false;
      if (isM3u8) {
        ok = await this._playM3u8(box, videoEl, url, probing, stale, gen);
        if (!ok && !stale()) { try { delCacheM3u8(code); } catch (e) {} }
      } else {
        ok = await this._fillSource(box, videoEl, url, code, probing, stale, gen);
      }
      if (ok && !stale()) setCacheM3u8(code, url);
      if (!ok && !stale()) box.classList.remove('ready');
      return ok;
    }

    _guessHeightFromLevel (lv, idx) {
      if (lv.height && lv.height > 0) return lv.height;
      const url = String(lv.url || lv.uri || '');
      const m = url.match(/(hhb|hmb|mhb|mmb|dm|sm)\.m3u8(?:\?|$)/i);
      if (m) { const h = SUFFIX_TO_HEIGHT[m[1].toLowerCase()]; if (h) return h; }
      const br = Number(lv.bitrate || (lv.attrs && lv.attrs.BANDWIDTH) || 0);
      if (br > 0) {
        if (br > 1.2e7) return 2160;
        if (br > 5e6) return 1080;
        if (br > 3e6) return 720;
        if (br > 1.5e6) return 432;
        if (br > 8e5) return 288;
        return 144;
      }
      return 720 + idx;
    }

    _playM3u8 (box, videoEl, m3u8Url, probing, stale, gen) {
      return new Promise((resolve) => {
        if (typeof Hls === 'undefined') return resolve(false);
        if (!Hls.isSupported()) {
          videoEl._mode = 'm3u8';
          videoEl._variants = [{ height: 0, source: 'hls', levelIdx: 0 }];
          videoEl.src = m3u8Url;
          videoEl.play().catch(() => {});
          this._setQualityOptions(box, videoEl, [], 'm3u8');
          return resolve(true);
        }

        const GmLoader = createGmLoader();
        const hls = new Hls({
          maxBufferLength: 30, maxMaxBufferLength: 60,
          enableWorker: false, lowLatencyMode: false,
          loader: GmLoader, pLoader: GmLoader, fLoader: GmLoader
        });
        videoEl._hls = hls;
        let done = false;
        let uiApplied = false;

        const applyQualityUI = (from) => {
          if (stale()) return;
          const lv = hls.levels || [];
          let ui;
          if (lv.length > 0) ui = lv.map((l, i) => ({ height: this._guessHeightFromLevel(l, i), source: 'hls', levelIdx: i }));
          else ui = [{ height: 720, source: 'hls', levelIdx: 0 }];
          ui.sort((a, b) => b.height - a.height);
          console.log('[Trailer] applyQualityUI(' + from + '):', ui);
          videoEl._mode = 'm3u8';
          videoEl._variants = ui;
          this._setQualityOptions(box, videoEl, ui, 'm3u8');
          const pref = getPreferredQuality('m3u8');
          const t = resolvePreference(ui, pref);
          if (t && typeof t.levelIdx === 'number' && lv.length > 0) {
            try { hls.currentLevel = t.levelIdx; } catch (e) {}
            try { hls.loadLevel = t.levelIdx; } catch (e) {}
          }
        };

        const levelPoller = setInterval(() => {
          if (stale()) { clearInterval(levelPoller); return; }
          if (uiApplied) { clearInterval(levelPoller); return; }
          const lv = hls.levels || [];
          if (lv.length > 0) {
            applyQualityUI('poll');
            uiApplied = true;
            clearInterval(levelPoller);
          }
        }, 200);

        const safetyTimer = setTimeout(() => {
          if (!uiApplied && !stale()) {
            applyQualityUI('safety');
            uiApplied = true;
            clearInterval(levelPoller);
          }
        }, 30000);

        const finishOk = () => {
          if (done) return; done = true;
          clearInterval(levelPoller); clearTimeout(safetyTimer);
          if (stale()) { try { hls.destroy(); } catch (e) {} if (videoEl._hls === hls) videoEl._hls = null; return resolve(false); }
          if (!uiApplied) { applyQualityUI('manifest'); uiApplied = true; }
          videoEl.play().catch(() => {});
          resolve(true);
        };

        const onPlaying = () => { if (!uiApplied) { applyQualityUI('playing'); uiApplied = true; } };
        videoEl.addEventListener('playing', onPlaying);
        const onLoadedData = () => { if (!uiApplied) { applyQualityUI('loadeddata'); uiApplied = true; } };
        videoEl.addEventListener('loadeddata', onLoadedData);

        const finishFail = (reason) => {
          if (done) return; done = true;
          clearInterval(levelPoller); clearTimeout(safetyTimer);
          console.log('[Trailer] m3u8 播放失败(' + reason + ')');
          try { hls.destroy(); } catch (e) {}
          if (videoEl._hls === hls) videoEl._hls = null;
          resolve(false);
        };

        hls.on(Hls.Events.MANIFEST_PARSED, finishOk);
        hls.on(Hls.Events.LEVEL_LOADED, () => {
          if (!uiApplied && (hls.levels || []).length > 0) {
            applyQualityUI('level-loaded');
            uiApplied = true;
          }
        });
        hls.on(Hls.Events.ERROR, (e, d) => {
          console.log('[Trailer] hls error:', d.type, d.details, 'fatal:', d.fatal);
          if (d.fatal) finishFail(d.details);
        });
        setTimeout(() => { if (!done) finishFail('timeout'); }, 15000);
        hls.loadSource(m3u8Url);
        hls.attachMedia(videoEl);
      });
    }

    async _tryMp4 (box, videoEl, code, probing, stale, gen) {
      if (!code) return false;
      let url = getCachedMp4(code);
      if (url) console.log('[Trailer] mp4 缓存命中:', url);
      if (!url) {
        if (probing) probing.textContent = 'DMM MP4 解析中…';
        url = await fetchDmmTrailerUrlParallel(code);
      }
      if (stale()) return false;
      if (!url) {
        if (probing) probing.textContent = 'JavDB 兜底中…';
        url = await fetchJavdbTrailerUrl(code);
      }
      if (stale()) return false;
      if (!url) return false;
      console.log('[Trailer] ✓ MP4 found:', url);
      if (probing) probing.style.display = 'none';
      box.classList.add('ready');
      const ok = await this._fillSource(box, videoEl, url, code, probing, stale, gen);
      if (ok && !stale()) setCacheMp4(code, url);
      if (!ok && !stale()) box.classList.remove('ready');
      return ok;
    }

    async _fillSource (box, videoEl, originalUrl, code, probing, stale, gen) {
      if (!box || !videoEl || !originalUrl) return false;
      console.log('[Trailer] Original mp4 source:', originalUrl);
      const pref = getPreferredQuality('mp4');
      let picked;
      try { picked = await pickForPref(originalUrl, pref); } catch (e) { picked = { height: 0, url: originalUrl }; }
      if (!picked) picked = { height: 0, url: originalUrl };
      if (stale && stale()) return false;

      console.log('[Trailer] pickForPref 返回:', picked.height, picked.url);

      videoEl._mode = 'mp4';
      videoEl._originalUrl = originalUrl;
      videoEl._variants = buildCandidates(originalUrl).filter(c => c.height > 0);
      videoEl.querySelectorAll('source').forEach(s => s.remove());
      videoEl.removeAttribute('src');
      const s = document.createElement('source');
      s.src = picked.url; s.type = 'video/mp4';
      videoEl.appendChild(s);
      this._clearNotice(box);
      if (probing) probing.style.display = 'none';
      try { videoEl.load(); } catch (e) {}
      videoEl.play().catch(() => {});
      this._setQualityOptions(box, videoEl, videoEl._variants, 'mp4');

      // ★ 若实际选中的高度 != 用户偏好(说明偏好不可用,回退到近似高度),
      //   把下拉重置为实际播放的高度,让 UI 与真实分辨率一致
      if (picked.height > 0) {
        const select = box.querySelector('.trailer-quality');
        if (select) {
          const val = String(picked.height);
          if (Array.from(select.options).some(o => o.value === val)) {
            select.value = val;
          }
        }
      }
      return true;
    }

    _switchSource (videoEl, url) {
      if (!videoEl || !url) return;
      const t = videoEl.currentTime || 0;
      const wasPlaying = !videoEl.paused;
      videoEl.querySelectorAll('source').forEach(s => s.remove());
      videoEl.removeAttribute('src');
      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);
    }

    _setQualityOptions (box, videoEl, variants, mode) {
      const select = box.querySelector('.trailer-quality');
      const label = box.querySelector('.trailer-quality-label');
      if (!select) return;
      let html = '<option value="auto-high">默认最高</option><option value="auto-low">默认最低</option>';
      if (mode === 'm3u8') {
        const list = (variants && variants.length) ? variants : [];
        for (const v of list) html += `<option value="${v.height}">${v.height}p</option>`;
      } else {
        for (const h of SUPPORTED_HEIGHTS) html += `<option value="${h}">${h}p</option>`;
      }
      select.innerHTML = html;
      const pref = getPreferredQuality(mode);
      if (Array.from(select.options).some(o => o.value === pref)) select.value = pref;
      else select.value = 'auto-high';
      if (label) { box.classList.add('ready'); label.style.setProperty('display', 'flex', 'important'); }
    }

    async _onQualityChange (box, videoEl, q) {
      const mode = videoEl._mode === 'mp4' ? 'mp4' : 'm3u8';
      setPreferredQuality(q, mode);
      const probing = box.querySelector('.trailer-quality-probing');
      if (videoEl._mode === 'm3u8') {
        const target = resolvePreference(videoEl._variants || [], q);
        if (target && typeof target.levelIdx === 'number' && videoEl._hls) {
          try { videoEl._hls.currentLevel = target.levelIdx; } catch (e) {}
          try { videoEl._hls.loadLevel = target.levelIdx; } catch (e) {}
        }
        return;
      }
      if (videoEl._mode === 'mp4') {
        const orig = videoEl._originalUrl;
        if (!orig) return;
        if (probing) { probing.style.display = ''; probing.textContent = '切换清晰度…'; }
        const target = await pickForPref(orig, q);
        if (probing) probing.style.display = 'none';
        if (target) {
          this._switchSource(videoEl, target.url);
          if (target.height > 0) {
            const select = box.querySelector('.trailer-quality');
            const val = String(target.height);
            if (select && Array.from(select.options).some(o => o.value === val)) {
              select.value = val;
            }
          }
        }
      }
    }

    _showPersistentNotice (box, msg) {
      const status = box.querySelector('.trailer-status');
      if (!status) return;
      status.textContent = msg;
      box.classList.add('error');
    }
    _clearNotice (box) {
      const status = box.querySelector('.trailer-status');
      if (status) status.textContent = '加载预告片中…';
      box.classList.remove('error');
    }

    addVideoC (code, obj) { const { box, videoEl } = this._injectPlaceholder(obj, code); this._fillSource(box, videoEl, `https://smovie.caribbeancom.com/sample/movies/${code}/1080p.mp4`, code); }
    addVideoY (code, obj) { const { box, videoEl } = this._injectPlaceholder(obj, code); this._fillSource(box, videoEl, `https://smovie.1pondo.tv/sample/movies/${code}/1080p.mp4`, code); }
    addVideoH (code, obj) { const { box, videoEl } = this._injectPlaceholder(obj, code); this._fillSource(box, videoEl, `https://smovie.10musume.com/sample/movies/${code}/1080p.mp4`, code); }
    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`, code); }
    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, code);
    }
    async _fillSources (box, videoEl, urls, code) {
      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, code);
    }
    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>`);
    }
  }

  function buildJumpLinks (code, videoSeries, videoNo) {
    return `
      <a class='red' href='https://javtrailers.com/ja/search/${code}' target='_blank'>trai</a>
      <a class='red' href='http://www.javlibrary.com/cn/vl_searchbyid.php?keyword=${code}' target='_blank'>lib</a>
      <a class='red' href='https://www.javbus.com/${code}' target='_blank'>bus</a>
      <a class='red' href='https://javdb.com/search?q=${code.replace('-', '_')}' target='_blank'>db</a>
      <a class='red' href='https://javspyl.eu.org/${code}' target='_blank'>spyl</a>
      <a class='red' href='https://www.sehuatang.net/search.php?mod=forum&srchtype=title&srchtxt=${code}&searchsubmit=true' target='_blank'>98</a>
      <a class='red' href='https://btsow.motorcycles/search/${code}' target='_blank'>btsow</a>
      <a class='red' href='https://xslist.org/search?query=${code}&lg=tw' target='_blank'>xslist</a>
      <a class='red' href='https://db.msin.jp/jp.search/movie?str=${code}' target='_blank'>msin</a>
      <a class='red' href='https://www.dmm.co.jp/digital/videoa/-/detail/=/cid=${videoSeries}${videoNo}/' target='_blank'>dmm<sup>JP代</sup></a>
      <a class='red' href='https://www.mgstage.com/search/cSearch.php?search_word=${code}' target='_blank'>mgstage<sup>SG代</sup></a>
      <a class='red' href='https://missav.com/search/${code}' target='_blank'>missav</a>`;
  }

  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 () { super(); 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();
      const parts = code.split(/-/);
      const videoSeries = parts[0].toLowerCase();
      const videoNo = parts[1] ? String(parts[1]).padStart(5, '0') : '';
      document.querySelectorAll('h3').forEach(h => { h.insertAdjacentHTML('beforeend', ` <b><font color=blue>全片:</font></b> <a href="https://missav.com/search/${code}" target="_blank">missav</a>&nbsp; <a href="https://thisav.com/cn/${code}" target="_blank">thisav</a>`); });
      renderTrailer(this, code, yulan, title);
      info.append("<p>" + buildJumpLinks(code, videoSeries, videoNo) + "</p>");
      embyQuery(code, ".star-show");
    }
  }

  class JavLibrary extends Base {
    constructor () { super(); 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();
      const parts = code.split(/-/);
      const videoSeries = parts[0].toLowerCase();
      const videoNo = parts[1] ? String(parts[1]).padStart(5, '0') : '';
      document.querySelectorAll('h3').forEach(h => { h.insertAdjacentHTML('beforeend', ` <b><font color=blue>全片:</font></b> <a href="https://missav.com/search/${code}" target="_blank">missav</a>&nbsp; <a href="https://thisav.com/cn/${code}" target="_blank">thisav</a>`); });
      renderTrailer(this, code, yulan, title);
      info.find('a').attr('target', '_blank');
      info.append("<div class='item'><table><tbody><tr><td class='header'>" + buildJumpLinks(code, videoSeries, videoNo) + "</td></tr></tbody></table></div>");
      embyQuery(code, "#video_info");
    }
  }

  class Javdb extends Base {
    constructor () { super(); 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();
      const 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 parts = code.split(/-/);
      const videoSeries = parts[0].toLowerCase();
      const videoNo = parts[1] ? String(parts[1]).padStart(5, '0') : '';
      this.addVideo(code, yulan);
      document.querySelectorAll('.current-title').forEach(h => { h.insertAdjacentHTML('beforeend', ` <b><font color=blue>全片:</font></b> <a href="https://missav.com/search/${code}" target="_blank">missav</a>&nbsp; <a href="https://thisav.com/cn/${code}" target="_blank">thisav</a>`); });
      info.append("<div class='item'><table><tbody><tr><td class='header'>" + buildJumpLinks(code, videoSeries, videoNo) + "</td></tr></tbody></table></div>");
      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 () { super(); 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];
      const parts = code.split(/-/);
      const videoSeries = parts[0].toLowerCase();
      const videoNo = parts[1] ? String(parts[1]).padStart(5, '0') : '';
      document.querySelectorAll('#title').forEach(h => { h.insertAdjacentHTML('beforeend', ` <b><font color=blue>全片:</font></b> <a href="https://missav.com/search/${code}" target="_blank">missav</a>&nbsp; <a href="https://thisav.com/cn/${code}" target="_blank">thisav</a>`); });
      renderTrailer(this, code, yulan, title);
      info.append("<div class='infobox'><b>跳转:</b>" + buildJumpLinks(code, videoSeries, videoNo) + "</div>");
      embyQuery(code, "#info");
    }
  }

  class Avmoo extends Base {
    constructor () { super(); 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();
      const parts = code.split(/-/);
      const videoSeries = parts[0].toLowerCase();
      const videoNo = parts[1] ? String(parts[1]).padStart(5, '0') : '';
      document.querySelectorAll('h3').forEach(h => { h.insertAdjacentHTML('beforeend', ` <b><font color=blue>全片:</font></b> <a href="https://missav.com/search/${code}" target="_blank">missav</a>&nbsp; <a href="https://thisav.com/cn/${code}" target="_blank">thisav</a>`); });
      renderTrailer(this, code, yulan, title);
      info.append("<p>" + buildJumpLinks(code, videoSeries, videoNo) + "</p>");
      embyQuery(code, ".col-md-3.info");
    }
  }

  class Sehuatang extends Base {
    constructor () { super(); 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;
      const parts = code.split(/-/);
      const videoSeries = parts[0].toLowerCase();
      const videoNo = parts[1] ? String(parts[1]).padStart(5, '0') : '';
      if (!/(高清中文字幕|亚洲有码原创|亚洲无码原创|4K原版|素人有码系列|PPV)/i.test(title)) return;
      document.querySelectorAll('#thread_subject').forEach(h => { h.insertAdjacentHTML('beforeend', ` <b><font color=blue>全片:</font></b> <a href="https://missav.com/search/${code}" target="_blank">missav</a>&nbsp; <a href="https://thisav.com/cn/${code}" target="_blank">thisav</a>`); });
      yulan.before(`<div id='tiaozhuan'>${buildJumpLinks(code, videoSeries, videoNo)}</div>`);
      renderTrailer(this, code, yulan, title);
      embyQuery(code, "#pgt");
    }
  }

  class Msin extends Base {
    constructor () { super(); 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 parts = code.split(/-/);
      const videoSeries = parts[0].toLowerCase();
      const videoNo = parts[1] ? String(parts[1]).padStart(5, '0') : '';
      const title = document.title;
      document.querySelectorAll('.mv_title').forEach(h => { h.insertAdjacentHTML('beforeend', ` <b><font color=blue>全片:</font></b> <a href="https://missav.com/search/${code}" target="_blank">missav</a>&nbsp; <a href="https://thisav.com/cn/${code}" target="_blank">thisav</a>`); });
      info.append("<div id='tiaozhuan'>" + buildJumpLinks(code, videoSeries, videoNo) + "</div>");
      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 parts = code.split(/-/);
    const videoSeries = parts[0].toLowerCase();
    const videoNo = parts[1] ? String(parts[1]).padStart(5, '0') : '';
    document.querySelectorAll('.p-workPage__title').forEach(h => { h.insertAdjacentHTML('beforeend', ` <b><font color=blue>全片:</font></b> <a href="https://missav.com/search/${code}" target="_blank">missav</a>&nbsp; <a href="https://thisav.com/cn/${code}" target="_blank">thisav</a>`); });
    info.append("<div id='tiaozhuan'>" + buildJumpLinks(code, videoSeries, videoNo) + "</div>");
    const ctx = new Base();
    renderTrailer(ctx, code, info, document.title);
    $('.p-workTable').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 () {
      switch (this.site) {
        case 'javBus': new JavBus(); break;
        case 'javLibrary': new JavLibrary(); break;
        case 'javdb': new Javdb(); break;
        case 'javbooks': new Javbooks(); break;
        case 'avmoo': new Avmoo(); break;
        case 'sehuatang': new Sehuatang(); break;
        case 'msin': new Msin(); 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);
  }

})();