JavBus Javdb trailer

双源

Du musst eine Erweiterung wie Tampermonkey, Greasemonkey oder Violentmonkey installieren, um dieses Skript zu installieren.

Um dieses Skript zu installieren, müssen Sie eine Erweiterung wie Tampermonkey oder Violentmonkey installieren.

Um dieses Skript zu installieren, müssen Sie eine Erweiterung wie Tampermonkey oder Violentmonkey installieren.

Um dieses Skript zu installieren, müssen Sie eine Erweiterung wie Tampermonkey oder Userscripts installieren.

Um dieses Skript zu installieren, müssen Sie eine Erweiterung wie Tampermonkey installieren.

Sie müssten eine Skript Manager Erweiterung installieren damit sie dieses Skript installieren können

(Ich habe schon ein Skript Manager, Lass mich es installieren!)

Um dieses Design zu installieren, müssen Sie eine Erweiterung wie Stylus installieren.

Um dieses Design zu installieren, müssen Sie eine Erweiterung wie Stylus installieren.

Um dieses Design zu installieren, müssen Sie eine Erweiterung wie Stylus installieren.

Um diesen Stil zu installieren, müssen Sie eine Erweiterung für den Benutzerstil Verwaltung installieren.

Um diesen Stil zu installieren, müssen Sie eine Erweiterung für den Benutzerstil Verwaltung installieren.

Um diesen Stil zu installieren, müssen Sie eine Erweiterung für den Benutzerstil Verwaltung installieren.

(Ich habe bereits einen Benutzerstil Verwaltung, ich möchte ihn installieren!)

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

})();