JavBus Javdb trailer

dmm预告片

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

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

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

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

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

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

(Tôi đã có Trình quản lý tập lệnh người dùng, hãy cài đặt nó!)

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

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

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

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

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

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

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

// ==UserScript==
// @name         JavBus Javdb trailer
// @name:zh-CN   JavBus/Javdb 预告片
// @namespace    https://greasyfork.org/zh-CN/scripts/596086
// @version      2026.09.21
// @description         dmm预告片
// @description:zh-cn   dmm预告片
// @author       dynamic-rewrite
// @license      GPL
// @match        *://www.javbus.com/*
// @include      *://javdb*.com/*
// @include      *://javdb.com/*
// @match        *://*.javlib.com/*
// @match        *://*.javlibrary.com/*
// @include      *://avmoo.*/*
// @include      *://avsox.*/*
// @match        *://*.sehuatang.net/*
// @match        *://www.tanhuazu.com/*
// @match        *://db.msin.jp/*
// @match        *://*/works/detail/*
// @match        *://javbooks.com/*
// @match        *://jmvbt.com/*
// @include      *://*.com/content*censored/*.htm
// @match        *://xslist.org/*
// @grant        GM_download
// @grant        GM_xmlhttpRequest
// @grant        GM_getValue
// @grant        GM_setValue
// @grant        GM_notification
// @grant        GM_setClipboard
// @grant        GM_addStyle
// @grant        GM_deleteValue
// @require      https://code.jquery.com/jquery-3.6.3.min.js
// @require      https://cdn.jsdelivr.net/npm/[email protected]/dist/hls.min.js
// @connect      *
// @connect      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 { width: 100% !important; max-width: 100% !important; height: auto !important; aspect-ratio: 16 / 9; max-height: 80vh; min-height: 320px; display: block !important; background: #000; }

    .trailer-toolbar { display: flex; align-items: center; justify-content: space-between; 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-source-label, .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, .trailer-source { background: #333; color: #ddd; border: 1px solid #555; border-radius: 3px; padding: 3px 6px; font-size: 12px; cursor: pointer; }
    .trailer-quality option, .trailer-source option { background: #222; color: #ddd; }

    .trailer-video { display: none; }
    .trailer-box.ready .trailer-video { display: block !important; }
    .trailer-box.error .trailer-toolbar { background: #3a1010; color: #f88; }
    .trailer-quality-probing { font-size: 12px; color: #888; }

    .video-meta-panel { height: auto !important; max-height: none !important; overflow: visible !important; padding-bottom: 0 !important; }
    #video_jacket_info { width: 100% !important; }
  `);

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

  // ==================== 缓存 ====================
  const CACHE_TTL = 5 * 24 * 60 * 60 * 1000;
  const CACHE_PREFIX_M3U8 = 'jvl_trailer_m3u8_v1_';
  const CACHE_PREFIX_MP4  = 'jvl_trailer_mp4_v1_';

  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) {}
  }
  const getCachedM3u8 = (code) => _cacheGet(CACHE_PREFIX_M3U8, code);
  const setCacheM3u8  = (code, url) => _cacheSet(CACHE_PREFIX_M3U8, code, url);
  const getCachedMp4  = (code) => _cacheGet(CACHE_PREFIX_MP4, code);
  const setCacheMp4   = (code, url) => _cacheSet(CACHE_PREFIX_MP4, code, url);

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

  // ==================== 播放源记忆 ====================
  const SOURCE_KEY = 'jvl_trailer_source';
  const VALID_SOURCES = ['m3u8', 'mp4'];

  function getPreferredSource () {
    try {
      const s = GM_getValue(SOURCE_KEY, 'm3u8');
      if (typeof s === 'string' && VALID_SOURCES.includes(s)) return s;
    } catch (e) {}
    return 'm3u8';
  }
  function setPreferredSource (s) {
    if (!VALID_SOURCES.includes(s)) return;
    try { GM_setValue(SOURCE_KEY, String(s)); } catch (e) {}
  }

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

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

  // hls 变体(含 levelIdx)的偏好解析
  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];
  }

  // ==================== DMM M3U8 相关 ====================

  // 从 HTML / JS 中尽可能提取所有 m3u8
  function extractM3u8Urls (html, baseUrl = '') {
    if (!html) return [];
    const decoded = String(html)
      .replace(/\\\//g, '/')
      .replace(/&/gi, '&')
      .replace(/\\u002F/gi, '/')
      .replace(/\\x2F/gi, '/');

    const out = new Set();
    let m;

    const absRe = /https?:\/\/[^"'<>\\\s]+?\.m3u8(?:\?[^"'<>\\\s]*)?/gi;
    while ((m = absRe.exec(decoded)) !== null) out.add(m[0]);

    const relRe = /["'`](\/[^"'`<>\\\s]*?\.m3u8(?:\?[^"'`<>\\\s]*)?)["'`]/gi;
    while ((m = relRe.exec(decoded)) !== null) {
      try { if (baseUrl) out.add(new URL(m[1], baseUrl).href); } catch (e) {}
    }

    const attrRe = /(?:src|href|data-src|data-url|data-video|playlist)\s*=\s*["']([^"']+\.m3u8(?:\?[^"']*)?)["']/gi;
    while ((m = attrRe.exec(decoded)) !== null) {
      try {
        const u = m[1].replace(/&amp;/gi, '&');
        out.add(/^https?:\/\//i.test(u) ? u : new URL(u, baseUrl).href);
      } catch (e) {}
    }

    return Array.from(out);
  }

  function probeM3u8 (url) {
    return new Promise((resolve) => {
      if (!url) return resolve(false);
      GM_xmlhttpRequest({
        url,
        method: 'GET',
        timeout: 8000,
        headers: {
          'User-Agent': navigator.userAgent,
          'Accept': 'application/vnd.apple.mpegurl, application/x-mpegURL, application/octet-stream, */*',
          'Referer': 'https://www.dmm.co.jp/',
          'Origin': 'https://www.dmm.co.jp/'
        },
        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: () => { console.log('[Trailer] M3U8 probe error:', url); resolve(false); },
        ontimeout: () => { console.log('[Trailer] M3U8 probe timeout:', url); resolve(false); }
      });
    });
  }

  function buildDmmHlsCandidates (cid) {
    cid = String(cid || '').trim().toLowerCase();
    if (!/^[a-z0-9_]+$/i.test(cid) || cid.length < 3) return [];

    const first = cid.substring(0, 1);
    const first3 = cid.substring(0, 3);

    const hosts = ['cc3001.dmm.co.jp', 'pv3001.dmm.co.jp', 'cc3001.dmm.com'];
    const paths = [
      `/hlsvideo/freepv/${first}/${first3}/${cid}/playlist.m3u8`,
      `/hlsvideo/freepv/${first}/${first3}/${cid}/${cid}.m3u8`
    ];

    const out = [];
    for (const host of hosts) for (const path of paths) out.push(`https://${host}${path}`);
    return out;
  }

  async function fetchM3u8ByCid (cid) {
    const candidates = buildDmmHlsCandidates(cid);
    if (!candidates.length) return null;
    console.log('[Trailer] Trying DMM HLS CID:', cid, candidates);

    const results = await Promise.all(candidates.map(async (url) => ({ url, ok: await probeM3u8(url) })));
    const hit = results.find(r => r.ok);
    if (hit) {
      console.log('[Trailer] ✓ DMM HLS resolved by CID:', cid, hit.url);
      return hit.url;
    }
    return null;
  }

  async function resolveDmmM3u8 (code) {
    if (!code) return null;
    console.log('[Trailer] Resolving DMM HLS:', code);

    let cids = [];
    try { cids = await fetchCidsFromDmmSearch(code); } catch (e) { console.log('[Trailer] fetchCidsFromDmmSearch error:', e); }

    if (!cids || !cids.length) {
      const parts = String(code).split(/-/);
      if (parts.length >= 2 && /^\d+$/.test(parts[1])) cids = buildCidCandidates(parts[0].toLowerCase(), parts[1]);
    }

    cids = Array.from(new Set(cids || []));
    console.log('[Trailer] DMM HLS CID candidates:', cids);

    for (const cid of cids) {
      const m3u8 = await fetchM3u8ByCid(cid);
      if (m3u8) return m3u8;
    }
    return null;
  }

  async function fetchM3u8FromLitevideo (code) {
    if (!code) return null;
    const parts = String(code).split(/-/);
    if (parts.length < 2) return null;

    const series = parts[0].toLowerCase();
    const num = parts[1];

    const cids = Array.from(new Set([
      series + num,
      series + num.padStart(3, '0'),
      series + num.padStart(5, '0'),
      series + String(parseInt(num, 10))
    ]));

    const pages = [];
    for (const cid of cids) {
      pages.push(`https://www.dmm.co.jp/litevideo/-/part/=/cid=${cid}/size=720_480/affi_id=ProgramDMM-001/`);
      pages.push(`https://www.dmm.co.jp/litevideo/-/part/=/cid=${cid}/`);
    }

    console.log('[Trailer] Litevideo pages:', pages.length);

    for (const pageUrl of pages) {
      try {
        const html = await new Promise((resolve) => {
          GM_xmlhttpRequest({
            url: pageUrl, method: 'GET', timeout: 8000,
            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) => resolve({ status: Number(res.status || 0), text: String(res.responseText || '') }),
            onerror: () => resolve(null),
            ontimeout: () => resolve(null)
          });
        });

        if (!html || html.status < 200 || html.status >= 400) continue;

        const direct = extractM3u8Urls(html.text, pageUrl);
        for (const m3u8 of direct) {
          if (await probeM3u8(m3u8)) { console.log('[Trailer] ✓ Litevideo M3U8:', m3u8); return m3u8; }
        }

        const iframeRe = /<iframe[^>]+src=["']([^"']+)["']/gi;
        let iframeMatch;
        while ((iframeMatch = iframeRe.exec(html.text)) !== null) {
          let iframeUrl;
          try { iframeUrl = new URL(iframeMatch[1], pageUrl).href; } catch (e) { continue; }

          const iframeHtml = await new Promise((resolve) => {
            GM_xmlhttpRequest({
              url: iframeUrl, method: 'GET', timeout: 8000,
              headers: {
                'User-Agent': navigator.userAgent,
                'Accept': 'text/html,application/xhtml+xml',
                'Referer': pageUrl,
                'Cookie': 'age_check_done=1;'
              },
              onload: (res) => resolve({ status: Number(res.status || 0), text: String(res.responseText || '') }),
              onerror: () => resolve(null),
              ontimeout: () => resolve(null)
            });
          });
          if (!iframeHtml) continue;

          const iframeM3u8s = extractM3u8Urls(iframeHtml.text, iframeUrl);
          for (const m3u8 of iframeM3u8s) {
            if (await probeM3u8(m3u8)) { console.log('[Trailer] ✓ iframe M3U8:', m3u8); return m3u8; }
          }
        }
      } catch (e) {
        console.log('[Trailer] Litevideo error:', pageUrl, e);
      }
    }
    return null;
  }

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

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

  function fetchCidsFromDmmSearch (code) {
    return new Promise((resolve) => {
      const parts = code.split(/-/);
      if (parts.length < 2) return resolve([]);
      const series = parts[0].toLowerCase();
      const num = parts[1];
      const searchWords = [`${series}${num}`, `${series}-${num}`, `${series}${num.padStart(5, '0')}`];
      const searchUrls = [];
      for (const kw of searchWords) {
        const enc = encodeURIComponent(kw);
        searchUrls.push(`https://www.dmm.co.jp/search/=/searchstr=${enc}/`);
        searchUrls.push(`https://www.dmm.co.jp/digital/videoa/-/search/=/searchstr=${enc}/`);
        searchUrls.push(`https://www.dmm.co.jp/mono/dvd/-/search/=/searchstr=${enc}/`);
      }
      let resolved = false;
      let pending = searchUrls.length;
      const allCids = new Set();
      const finish = () => {
        if (resolved) return;
        resolved = true;
        const filtered = filterMatchingCids(Array.from(allCids), series, num);
        console.log('[Trailer] DMM search → cids(raw/filtered):', allCids.size, '/', filtered.length, filtered.slice(0, 5));
        resolve(filtered);
      };
      searchUrls.forEach((url) => {
        GM_xmlhttpRequest({
          url, method: 'GET', timeout: 6000,
          headers: {
            'User-Agent': navigator.userAgent,
            'Accept': 'text/html,application/xhtml+xml',
            'Accept-Language': 'ja,en-US;q=0.7,en;q=0.3',
            'Referer': 'https://www.dmm.co.jp/',
            'Cookie': 'age_check_done=1;'
          },
          onload: (res) => {
            if (resolved) return;
            const cids = extractCidsFromHtml(res.responseText || '');
            cids.forEach(c => allCids.add(c));
            pending--;
            const filtered = filterMatchingCids(Array.from(allCids), series, num);
            if (filtered.length > 0) setTimeout(finish, 200);
            else if (pending === 0) finish();
          },
          onerror: () => { pending--; if (pending === 0) finish(); },
          ontimeout: () => { pending--; if (pending === 0) finish(); }
        });
      });
    });
  }

  function buildCidCandidates (series, num) {
    const n = String(num);
    const stripped = String(parseInt(n, 10));
    const n3 = n.padStart(3, '0'), n5 = n.padStart(5, '0');
    const out = [];
    const push = (c) => { if (c && !out.includes(c)) out.push(c); };
    push(series + n); push(series + n3); push(series + n5);
    if (stripped !== n) push(series + stripped);
    return out;
  }

  // ==================== MP4 相关 ====================
  // DMM 后缀 → 分辨率映射:
  //   hhb → 1080 | hmb → 720 | mhb → 576
  //   mmb → 432  | dm  → 288 | sm  → 144
  const DMM_SUFFIX_ORDER = [
    { height: 1080, sfx: 'hhb' },
    { height: 720,  sfx: 'hmb' },
    { height: 576,  sfx: 'mhb' },
    { height: 432,  sfx: 'mmb' },
    { height: 288,  sfx: 'dm'  },
    { height: 144,  sfx: 'sm'  }
  ];

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

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

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

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

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

    return [{ height: 0, url }];
  }

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

  async function pickForPref (originalUrl, pref) {
    const candidates = buildCandidates(originalUrl);

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

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

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

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

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

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

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

  function buildDmmUrlList (series, num) {
    const cids = buildCidCandidates(series, num);
    const urls = [];
    const add = (c, path) => urls.push(`https://www.dmm.co.jp/${path}/-/detail/=/cid=${c}/`);
    const addLite = (c) => urls.push(`https://www.dmm.co.jp/litevideo/-/part/=/cid=${c}/size=720_480/affi_id=ProgramDMM-001/`);
    for (const c of cids) add(c, 'mono/dvd');
    for (const c of cids) add(c, 'digital/videoa');
    for (const c of cids) addLite(c);
    return urls;
  }

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

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

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

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

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

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

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

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

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

    // ---------------- 注入占位 UI ----------------
    _injectPlaceholder (obj, code, opts) {
      opts = opts || {};
      const html = `
        <div class="trailer-box" data-code="${code || ''}">
          <div class="trailer-toolbar">
            <span class="trailer-status">加载预告片中…</span>
            <span class="trailer-controls">
              <label class="trailer-source-label">播放源:
                <select class="trailer-source">
                  <option value="m3u8">源一 (M3U8)</option>
                  <option value="mp4">源二 (MP4)</option>
                </select>
              </label>
              <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 loop 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 srcSel = box.querySelector('.trailer-source');
      const srcLabel = box.querySelector('.trailer-source-label');
      if (opts.hideSource) {
        if (srcLabel) srcLabel.style.display = 'none';
      } else {
        srcSel.value = getPreferredSource();
        srcSel.addEventListener('change', () => {
          setPreferredSource(srcSel.value);
          console.log('[Trailer] 播放源切换为:', srcSel.value);
          this._startLoad(box, videoEl);
        });
      }

      // 清晰度选择器(事件只绑一次,具体逻辑按当前模式分派)
      const qSel = box.querySelector('.trailer-quality');
      qSel.addEventListener('change', () => { this._onQualityChange(box, videoEl, qSel.value); });

      return { box, videoEl };
    }

    // ---------------- 入口 ----------------
    addVideo (code, obj) {
      // HEYZO / HEYZ 固定 mp4
      if (/^HEYZO-/i.test(code)) {
        const n = code.split(/-/)[1];
        const { box, videoEl } = this._injectPlaceholder(obj, code, { hideSource: true });
        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, { hideSource: true });
        return this._fillSource(box, videoEl, `https://www.heyzo.com/contents/3000/${n}/heyzo_hd_${n}_sample.mp4`, code);
      }
      // FC2 用 iframe
      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._resetVideo(videoEl);

      const srcPref = getPreferredSource();
      console.log('[Trailer] 使用播放源:', srcPref, '| 番号:', code);

      let ok = false;
      if (srcPref === 'm3u8') ok = await this._tryM3u8(box, videoEl, code, probing, stale, gen);
      else                    ok = await this._tryMp4(box, videoEl, code, probing, stale, gen);

      if (stale()) return;
      if (probing) probing.style.display = 'none';

      if (!ok) {
        const msg = srcPref === 'm3u8'
          ? '未找到 m3u8 预告片,请手动切换播放源为「源二 (MP4)」重试'
          : '未找到 MP4 预告片,请手动切换播放源为「源一 (M3U8)」重试';
        console.log('[Trailer] ' + msg);
        this._showPersistentNotice(box, msg);
      }
    }

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

    // ---------------- 源一:m3u8 ----------------
    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 (await probeM3u8(cached)) url = cached;
        else console.log('[Trailer] m3u8 缓存已失效');
      }
      if (stale()) return false;

      if (!url) {
        if (probing) probing.textContent = '从 CID 解析 HLS…';
        url = (await resolveDmmM3u8(code)) || '';
      }
      if (stale()) return false;

      if (!url) {
        if (probing) probing.textContent = '从 litevideo 抓取…';
        url = (await fetchM3u8FromLitevideo(code)) || '';
      }
      if (stale()) return false;

      if (!url) return false;

      console.log('[Trailer] ✓ M3U8 found:', url);
      if (probing) probing.textContent = '加载播放器…';

      const ok = await this._playM3u8(box, videoEl, url, probing, stale, gen);
      if (ok && !stale()) setCacheM3u8(code, url);
      return ok;
    }

    _playM3u8 (box, videoEl, m3u8Url, probing, stale, gen) {
      return new Promise((resolve) => {
        if (typeof Hls === 'undefined') {
          console.log('[Trailer] Hls 未加载');
          return resolve(false);
        }

        // Safari 原生 HLS
        if (!Hls.isSupported()) {
          videoEl._mode = 'm3u8';
          videoEl._variants = [{ height: 0, source: 'hls', levelIdx: 0 }];
          videoEl.src = m3u8Url;
          videoEl.play().catch(() => {});
          box.classList.add('ready');
          this._clearNotice(box);
          this._setQualityOptions(box, videoEl, [], 'm3u8');
          if (probing) probing.style.display = 'none';
          return resolve(true);
        }

        const hls = new Hls({ maxBufferLength: 30, maxMaxBufferLength: 60, enableWorker: true, lowLatencyMode: false });
        videoEl._hls = hls;

        let done = false;

        const finishOk = () => {
          if (done) return; done = true;
          if (stale()) {
            try { hls.destroy(); } catch (e) {}
            if (videoEl._hls === hls) videoEl._hls = null;
            return resolve(false);
          }

          const hlsLevels = hls.levels || [];
          console.log('[Trailer] hls.levels:', hlsLevels.map(l => (l.height || 0) + 'p'));

          let uiVariants;
          if (hlsLevels.length > 0) {
            uiVariants = hlsLevels.map((lv, i) => ({ height: lv.height || 720, source: 'hls', levelIdx: i }));
            if (!uiVariants.some(v => v.height > 0)) uiVariants = hlsLevels.map((lv, i) => ({ height: 720 + i, source: 'hls', levelIdx: i }));
          } else {
            uiVariants = [{ height: 720, source: 'hls', levelIdx: 0 }];
          }
          uiVariants.sort((a, b) => b.height - a.height);

          videoEl._mode = 'm3u8';
          videoEl._variants = uiVariants;

          if (probing) probing.style.display = 'none';
          this._clearNotice(box);
          box.classList.add('ready');
          this._setQualityOptions(box, videoEl, uiVariants, 'm3u8');

          const pref = getPreferredQuality();
          const target = resolvePreference(uiVariants, pref);
          if (target && typeof target.levelIdx === 'number') {
            hls.currentLevel = target.levelIdx;
            console.log('[Trailer] hls auto-pick level', target.levelIdx, target.height + 'p');
          }
          videoEl.play().catch(() => {});
          resolve(true);
        };

        const finishFail = (reason) => {
          if (done) return; done = true;
          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.ERROR, (evt, data) => {
          console.log('[Trailer] hls error:', data.type, data.details, 'fatal:', data.fatal);
          if (data.fatal) finishFail(data.details);
        });

        setTimeout(() => { if (!done) finishFail('timeout'); }, 8000);

        hls.loadSource(m3u8Url);
        hls.attachMedia(videoEl);
      });
    }

    // ---------------- 源二:mp4 ----------------
    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 解析中…';
        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.textContent = '选择清晰度…';

      const ok = await this._fillSource(box, videoEl, url, code, probing, stale, gen);
      if (ok && !stale()) setCacheMp4(code, url);
      return ok;
    }

    // ---------------- mp4 填充 ----------------
    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();
      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;

      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);

      box.classList.add('ready');
      this._clearNotice(box);
      if (probing) probing.style.display = 'none';

      try { videoEl.load(); } catch (e) {}
      videoEl.play().catch(() => {});

      this._setQualityOptions(box, videoEl, videoEl._variants, 'mp4');
      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);
    }

    // ---------------- 清晰度 UI ----------------
    _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();
      if (Array.from(select.options).some(o => o.value === pref)) select.value = pref;
      else select.value = 'auto-high';

      if (label) label.style.display = '';
    }

    async _onQualityChange (box, videoEl, q) {
      setPreferredQuality(q);
      const probing = box.querySelector('.trailer-quality-probing');

      if (videoEl._mode === 'm3u8') {
        const list = videoEl._variants || [];
        const target = resolvePreference(list, q);
        if (target && typeof target.levelIdx === 'number' && videoEl._hls) {
          console.log('[Trailer] 切换 HLS level →', target.height + 'p');
          videoEl._hls.currentLevel = target.levelIdx;
        }
        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) return;
        console.log('[Trailer] 切换 MP4 →', target.height + 'p', target.url);
        this._switchSource(videoEl, target.url);
      }
    }

    // ---------------- 状态提示 ----------------
    _showPersistentNotice (box, msg) {
      const status = box.querySelector('.trailer-status');
      if (!status) return;
      if (status._hideTimer) { clearTimeout(status._hideTimer); status._hideTimer = null; }
      status.textContent = msg;
      box.classList.add('error');
    }

    _clearNotice (box) {
      const status = box.querySelector('.trailer-status');
      if (status) {
        if (status._hideTimer) { clearTimeout(status._hideTimer); status._hideTimer = null; }
        status.textContent = '加载预告片中…';
      }
      box.classList.remove('error');
    }

    // ---------------- 兼容旧接口 ----------------
    addVideoC (code, obj) {
      const { box, videoEl } = this._injectPlaceholder(obj, code, { hideSource: true });
      this._fillSource(box, videoEl, `https://smovie.caribbeancom.com/sample/movies/${code}/1080p.mp4`, code);
    }
    addVideoY (code, obj) {
      const { box, videoEl } = this._injectPlaceholder(obj, code, { hideSource: true });
      this._fillSource(box, videoEl, `https://smovie.1pondo.tv/sample/movies/${code}/1080p.mp4`, code);
    }
    addVideoH (code, obj) {
      const { box, videoEl } = this._injectPlaceholder(obj, code, { hideSource: true });
      this._fillSource(box, videoEl, `https://smovie.10musume.com/sample/movies/${code}/1080p.mp4`, code);
    }
    addVideoPM (code, obj) {
      const { box, videoEl } = this._injectPlaceholder(obj, code, { hideSource: true });
      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, { hideSource: true });
      this._fillSources(box, videoEl, urls, code);
    }
    async _fillSources (box, videoEl, urls, code) {
      if (!box || !videoEl || !urls || !urls.length) return;
      let picked = null;
      const probing = box.querySelector('.trailer-quality-probing');
      if (probing) probing.style.display = '';
      for (const u of urls) {
        const r = await probeQuick(u);
        if (r.ok) { picked = u; break; }
      }
      if (probing) probing.style.display = 'none';
      if (!picked) picked = urls[0];
      return this._fillSource(box, videoEl, picked, 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);
  }

})();