JavBus Javdb trailer

双源

Você precisará instalar uma extensão como Tampermonkey, Greasemonkey ou Violentmonkey para instalar este script.

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

Você precisará instalar uma extensão como Tampermonkey ou Violentmonkey para instalar este script.

Você precisará instalar uma extensão como Tampermonkey ou Userscripts para instalar este script.

Você precisará instalar uma extensão como o Tampermonkey para instalar este script.

Você precisará instalar um gerenciador de scripts de usuário para instalar este script.

(Eu já tenho um gerenciador de scripts de usuário, me deixe instalá-lo!)

Você precisará instalar uma extensão como o Stylus para instalar este estilo.

Você precisará instalar uma extensão como o Stylus para instalar este estilo.

Você precisará instalar uma extensão como o Stylus para instalar este estilo.

Você precisará instalar um gerenciador de estilos de usuário para instalar este estilo.

Você precisará instalar um gerenciador de estilos de usuário para instalar este estilo.

Você precisará instalar um gerenciador de estilos de usuário para instalar este estilo.

(Eu já possuo um gerenciador de estilos de usuário, me deixar fazer a instalação!)

// ==UserScript==
// @name         JavBus Javdb trailer
// @name:zh-CN   JavBus/Javdb 预告片
// @namespace    https://greasyfork.org/zh-CN/scripts/596086
// @version      2026.09.23
// @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      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-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-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] ====== VERSION 2026.09.38 (Normalize Code) ======');

  // ==================== 缓存 ====================
  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_v12_';
  const CACHE_PREFIX_MP4  = 'jvl_trailer_jt_mp4_v12_';
  const CID_CACHE_PREFIX  = 'jvl_trailer_jt_cid_v4_';

  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 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) {}
  }

  // ==================== 播放源记忆 ====================
  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) {} }

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

  // ============================================================
  // ★ 规范化番号:字母 + 数字(去掉前导零)
  //   HRSM-159 / hrsm159 / hrsm00159 / HRSM_159 → "HRSM:159"
  // ============================================================
  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;
    const series = m[1];
    const num = String(parseInt(m[2], 10));
    return series + ':' + num;
  }

  // 检查一个文本是否包含目标番号
  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) {
      const series = m[1];
      const num = String(parseInt(m[2], 10));
      if (series + ':' + num === target) return true;
    }
    return false;
  }

  // ============================================================
  // 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 {
            console.log('[Trailer][JT] 非2xx:', s, url);
            resolve(null);
          }
        },
        onerror: () => resolve(null),
        ontimeout: () => resolve(null)
      });
    });
  }

  // ============================================================
  // ★ 验证 CID 是否匹配番号
  // ============================================================
  async function verifyJavTrailersCid (cid, code) {
    if (!cid || !code) return false;
    try {
      const html = await fetchText(`https://javtrailers.com/video/${cid}`, 5000);
      if (!html) {
        console.log('[Trailer][JT-Verify] 页面加载失败:', cid);
        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;
    }
  }

  // ============================================================
  // ★ 从搜索页 HTML 提取 CID(必须匹配番号)
  // ============================================================
  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;
  }

  // ============================================================
  // 搜索 JavTrailers 拿真实 CID(带验证)
  // ============================================================
  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) {
          console.log('[Trailer][JT-Search] HTML', html.length, 'bytes 但无匹配链接:', url);
          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 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-M3U8] ✓ 命中 via', from, ':', url);
      }
      return url;
    };

    const cachedCid = getCachedJavTrailersCid(code);
    if (cachedCid) {
      console.log('[Trailer][JT-M3U8] 使用缓存的 CID:', cachedCid);
      const url = await probeCidsAll([cachedCid]);
      if (url) return finish(url, 'CID缓存');
      console.log('[Trailer][JT-M3U8] 缓存 CID 探测失败,继续');
    }

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

      const allCandidates = [];
      for (const cid of cids) {
        for (const u of buildJavTrailersM3u8Candidates(cid)) {
          allCandidates.push({ url: u, cid: cid });
        }
      }
      const probeResults = await Promise.all(allCandidates.map(async (c) => {
        const ok = await probeM3u8(c.url);
        return ok ? c : null;
      }));
      const hits = probeResults.filter(r => r);
      if (!hits.length) return null;

      console.log('[Trailer][JT-M3U8] 探测命中', hits.length, '个,逐个验证');
      for (const hit of hits) {
        if (finished) return null;
        const valid = await verifyJavTrailersCid(hit.cid, code);
        if (valid) return finish(hit.url, '直接构造+验证');
      }
      console.log('[Trailer][JT-M3U8] 所有命中均验证失败');
      return null;
    })();

    const searchPromise = new Promise((resolve) => {
      setTimeout(async () => {
        if (finished) return resolve(null);
        console.log('[Trailer][JT-M3U8] 搜索页兜底启动…');
        try {
          const realCid = await fetchJavTrailersCidFromSearch(code);
          if (finished || !realCid) return resolve(null);
          const url = await probeCidsAll([realCid]);
          resolve(finish(url, '搜索页'));
        } 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,
    ];
    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 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); };
    push(series + n); push(series + n3); push(series + n5);
    if (stripped !== n) push(series + stripped);
    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 };

  function buildCandidates (rawUrl) {
    if (!rawUrl) return [];
    const url = String(rawUrl).replace(/\?.*$/, '');
    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 = [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 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/`
      ];
      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/`);
    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 done = false;
      const finish = (url) => { if (done || !url) return; done = true; resolve(url); };
      const guessUrls = buildDmmUrlList(series, parts[1]);
      const CONC = 4; let nextIdx = 0;
      const run = () => {
        if (done || 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/', '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); } }, 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, '-') : '';
              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, 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 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);
          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) {
      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);
      }
      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) {
        box.classList.remove('ready');
        const msg = srcPref === 'm3u8'
          ? '未找到 m3u8,请手动切「源二 (MP4)」重试'
          : '未找到 MP4,请手动切「源一 (M3U8)」重试';
        this._showPersistentNotice(box, msg);
      }
    }

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

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

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

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

      const ok = await this._playM3u8(box, videoEl, url, 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 > 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();
          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();
      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);
      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);
    }

    _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) { box.classList.add('ready'); label.style.setProperty('display', 'flex', 'important'); }
    }

    async _onQualityChange (box, videoEl, q) {
      setPreferredQuality(q);
      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);
      }
    }

    _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, { 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) {
      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);
  }

})();