JavBus Javdb library trailer (Dynamic)

dmm高清预告片

За да инсталирате този скрипт, трябва да имате инсталирано разширение като Tampermonkey, Greasemonkey или Violentmonkey.

За да инсталирате този скрипт, трябва да инсталирате разширение, като например Tampermonkey или Violentmonkey.

За да инсталирате този скрипт, трябва да имате инсталирано разширение като Tampermonkey или Violentmonkey.

За да инсталирате този скрипт, трябва да имате инсталирано разширение като Tampermonkey или Userscripts.

За да инсталирате скрипта, трябва да инсталирате разширение като Tampermonkey.

За да инсталирате този скрипт, трябва да имате инсталиран скриптов мениджър.

(Вече имам скриптов мениджър, искам да го инсталирам!)

За да инсталирате този стил, трябва да инсталирате разширение като Stylus.

За да инсталирате този стил, трябва да инсталирате разширение като Stylus.

За да инсталирате този стил, трябва да инсталирате разширение като Stylus.

За да инсталирате този стил, трябва да имате инсталиран мениджър на потребителски стилове.

За да инсталирате този стил, трябва да имате инсталиран мениджър на потребителски стилове.

За да инсталирате този стил, трябва да имате инсталиран мениджър на потребителски стилове.

(Вече имам инсталиран мениджър на стиловете, искам да го инсталирам!)

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

(function () {
  'use strict';

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

  GM_addStyle(`
    .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;
      padding: 6px 0; 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-loading {
      color: #ddd; font-size: 14px; padding: 10px; min-height: 40px;
      display: flex; align-items: center; justify-content: center;
      background: #222; border-bottom: 1px solid #333;
    }
    .trailer-video { display: none; }
    .trailer-box.ready .trailer-loading { display: none; }
    .trailer-box.ready .trailer-video { display: block !important; }
    .trailer-box.error .trailer-loading { background: #4a1010; color: #f88; }
    .video-meta-panel { height: auto !important; max-height: none !important; overflow: visible !important; padding-bottom: 0 !important; }
    #video_jacket_info { width: 100% !important; }
  `);

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

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

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

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

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

  // ==================== 从 HTML 提取 PV ====================
  function extractPvFromHtml (html) {
    if (!html) return '';
    const decoded = html.replace(/\\\//g, '/');
    const patterns = [
      /https?:\/\/cc3001\.dmm\.co\.jp\/pv\/[^"'\s<>\\]+\.(?:mp4|m3u8)/gi,
      /https?:\/\/cc3001\.dmm\.com\/pv\/[^"'\s<>\\]+\.(?:mp4|m3u8)/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 mhb = m.find(u => /mhb\.(?:mp4|m3u8)$/i.test(u))
                 || m.find(u => /hhb\.(?:mp4|m3u8)$/i.test(u));
        return mhb || m[0];
      }
    }
    try {
      const ldRegex = /<script[^>]*type="application\/ld\+json"[^>]*>([\s\S]*?)<\/script>/gi;
      let match;
      while ((match = ldRegex.exec(decoded)) !== null) {
        const jsonText = match[1];
        const pvMatch = jsonText.match(/"contentUrl"\s*:\s*"([^"]+\.mp4[^"]*)"/i)
                     || jsonText.match(/"sampleUrl"\s*:\s*"([^"]+\.mp4[^"]*)"/i);
        if (pvMatch && pvMatch[1] && pvMatch[1].startsWith('http')) return pvMatch[1];
      }
    } catch (e) {}
    return '';
  }

  // ==================== 从 HTML 提取所有 CID ====================
  function extractCidsFromHtml (html) {
    if (!html) return [];
    const decoded = html.replace(/\\\//g, '/');
    const cids = new Set();
    let m;

    const re1 = /[?&]cid=([a-z0-9_]+)/gi;
    while ((m = re1.exec(decoded)) !== null) cids.add(m[1].toLowerCase());

    const re2 = /data-cid="([a-z0-9_]+)"/gi;
    while ((m = re2.exec(decoded)) !== null) cids.add(m[1].toLowerCase());

    const re3 = /"cid"\s*:\s*"([a-z0-9_]+)"/gi;
    while ((m = re3.exec(decoded)) !== null) cids.add(m[1].toLowerCase());

    const re4 = /pics\.dmm\.co\.jp\/(?:digital|mono|rental)\/(?:video|videoa|movie|adult)\/(?:adult\/)?([a-z0-9_]+)\//gi;
    while ((m = re4.exec(decoded)) !== null) cids.add(m[1].toLowerCase());

    const re5 = /\/cid\/([a-z0-9_]+)\//gi;
    while ((m = re5.exec(decoded)) !== null) cids.add(m[1].toLowerCase());

    return Array.from(cids);
  }

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

  // ==================== 从 DMM 搜索页动态获取 CID(核心) ====================
  function fetchCidsFromDmmSearch (code) {
    return new Promise((resolve) => {
      const parts = code.split(/-/);
      if (parts.length < 2) return resolve([]);
      const series = parts[0].toLowerCase();
      const num = parts[1];

      const searchWords = [
        `${series}${num}`,
        `${series}-${num}`,
        `${series}${num.padStart(5, '0')}`
      ];

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

      let resolved = false;
      let pending = searchUrls.length;
      const allCids = new Set();

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

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

  // ==================== 快速路径:规则猜测 ====================
  function buildCidCandidates (series, num) {
    const n = String(num);
    const stripped = String(parseInt(n, 10));
    const n3 = n.padStart(3, '0');
    const n5 = n.padStart(5, '0');
    const out = [];
    const push = (c) => { if (c && !out.includes(c)) out.push(c); };
    push(series + n);
    push(series + n3);
    push(series + n5);
    if (stripped !== n) push(series + stripped);
    return out;
  }

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

  // ==================== 请求 DMM 详情页拿 PV ====================
  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();
    });
  }

  // ==================== FANZA GraphQL API ====================
  function fetchFanzaTrailerUrl (code) {
    return new Promise((resolve) => {
      const parts = code.split(/-/);
      if (parts.length < 2) return resolve(null);
      const series = parts[0].toLowerCase();
      const num = parts[1];
      const searchWords = [
        `${parts[0]}-${num}`,
        `${series}${num}`,
        `${series}${num.padStart(5, '0')}`
      ];

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

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

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

      // 线 A(0ms):快速规则猜测
      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();

      // 线 B(100ms):DMM 搜索页(主力)
      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);

      // 线 C(500ms):FANZA API
      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);
    });
  }

  // ==================== JavDB 兜底 ====================
  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|m3u8)[^"]*)"/i,
                  /<source[^>]*src="([^"]+\.(?:mp4|m3u8)[^"]*)"/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)
      });
    });
  }

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

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

    _injectPlaceholder (obj, code) {
      const html = `
        <div class="trailer-box" data-code="${code || ''}">
          <div class="trailer-loading">加载预告片中…</div>
          <video class="trailer-video" controls playsinline muted loop preload="metadata"></video>
        </div>`;
      $(obj).before(html);
      const boxes = document.querySelectorAll('.trailer-box');
      const box = boxes[boxes.length - 1];
      const videoEl = box.querySelector('video.trailer-video');
      applyVolumeState(videoEl);
      this._lastVideo = videoEl;
      return { box, videoEl };
    }

    _fillSource (box, videoEl, url) {
      if (!box || !videoEl || !url) return;
      console.log('[Trailer] Fill source:', url);
      videoEl.querySelectorAll('source').forEach(s => s.remove());
      const s = document.createElement('source');
      s.src = url; s.type = 'video/mp4';
      videoEl.appendChild(s);
      box.classList.add('ready');
      try { videoEl.load(); } catch (e) {}
      videoEl.play().catch(() => {});
      videoEl.addEventListener('playing', () => {
        setCache((box.getAttribute('data-code') || '').toUpperCase(), url);
      }, { once: true });
    }

    _fillSources (box, videoEl, urls) {
      if (!box || !videoEl || !urls || !urls.length) return;
      videoEl.querySelectorAll('source').forEach(s => s.remove());
      urls.forEach(u => {
        const s = document.createElement('source');
        s.src = u; s.type = 'video/mp4';
        videoEl.appendChild(s);
      });
      box.classList.add('ready');
      try { videoEl.load(); } catch (e) {}
      videoEl.play().catch(() => {});
      videoEl.addEventListener('playing', () => {
        const src = videoEl.currentSrc || videoEl.querySelector('source')?.src;
        if (src) setCache((box.getAttribute('data-code') || '').toUpperCase(), src);
      }, { once: true });
    }

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

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

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

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

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

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

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

  // ==================== 跳转链接 & emby ====================
  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 (req) { super(req); if ($('.col-md-3.info').length > 0) this.detailPage(); }
    detailPage () {
      const info = $('.col-md-3.info');
      const yulan = $('.row.movie');
      const title = $('.container > h3').text();
      const code = info.find('p').eq(0).find('span').eq(1).html();
      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 (req) { super(req); if ($('#video_info').length > 0) this.detailPage(); }
    detailPage () {
      const info = $('#video_info');
      const yulan = $('#video_jacket_info');
      const title = $('.post-title').text();
      const code = info.find('.item').eq(0).find('.text').html();
      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 (req) { super(req); if ($('.video-meta-panel').length > 0) this.detailPage(); }
    detailPage () {
      const info = $('.panel.movie-panel-info');
      const yulan = $('.video-meta-panel');
      const changyulan = $('#modal-comment-warning');
      const title = $('.title.is-4').text().trim();
      let code = $('body > section > div > div.video-detail > h2 > strong')
        .text().trim()
        .replace("10musu_", "").replace("ALOVE", "LOVE").replace("AAQUA", "AQUA").replace("AMCMA", "MCMA")
        .split(' ')[0];

      const parts = code.split(/-/);
      const videoSeries = parts[0].toLowerCase();
      const videoNo = parts[1] ? String(parts[1]).padStart(5, '0') : '';
      const codeUpper = code.toUpperCase();
      const self = this;

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

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

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

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

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

      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">
            <a href='https://image.memojav.com/image/screenshot/${code}.jpg' target='_blank'>
              <img src='https://image.memojav.com/image/screenshot/${code}.jpg' style='max-width:100%;'></a>
          </div></div></article></div></div>`);
      }
    }
  }

  class Javbooks extends Base {
    constructor (req) { super(req); if ($('#info').length > 0) this.detailPage(); }
    detailPage () {
      const info = $('#info');
      const yulan = $('#info');
      $('#Preview_vedio_area > a > img').remove();
      $('body > p > a > img').remove();
      const title = $('#title').text().trim();
      const code = $('#info > div:nth-child(2) > font').text().trim().replace("10musu_", "").split(' ')[0];
      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 (req) { super(req); if ($('.col-md-3.info').length > 0) this.detailPage(); }
    detailPage () {
      const info = $('.col-md-3.info');
      const yulan = $('.row.movie');
      const title = $('.container > h3').text();
      const code = info.find('p').eq(0).find('span').eq(1).html();
      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 (req) { super(req); if ($('#pgt').length > 0) this.detailPage(); }
    detailPage () {
      const yulan = $('#pgt');
      const reg = /([a-zA-Z]{2,15}[-\s]?\d{2,15}|FC2PPV-[^\d]{0,5}\d{6,7})/i;
      const str = document.title.split(" ")[0].split("   ")[0].split("【")[0].split("[")[0]
        .split("-carib")[0].split("-10mu-")[0].split("-paco-")[0].split("-1pon-")[0]
        .replace("SSSIS-", "SSIS-").replace("BBOBB-", "BOBB-").replace("SET-628", "FSET-628");
      const m = str.match(reg);
      if (!m) return;
      const code = m[0];
      const title = document.title;
      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 (req) { super(req); this.detailPage(); }
    detailPage () {
      const info = $('#top_content');
      const yulan = $('#breadcrumb');
      let code;
      if (/db\.msin\.jp\/jp\.page\/movie/.test(location.href)) {
        code = $('div.mv_pn').text().trim().split(' ')[0];
      } else {
        code = $('div.mv_fileName').text().trim().split(' ')[0].replace("fc2-ppv-", "fc2-");
      }
      const 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-workPage__table').append(
      `<div class='item'><div class='th'>识别码</div><div class='td'>${code}</div></div>`
    );
    embyQuery(code, ".p-workPage__text");
  }

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

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

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

})();