JavBus Javdb library trailer (Dynamic)

dmm高清预告片

이 스크립트를 설치하려면 Tampermonkey, Greasemonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

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

이 스크립트를 설치하려면 Tampermonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Userscripts와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 유저 스크립트 관리자 확장 프로그램이 필요합니다.

(이미 유저 스크립트 관리자가 설치되어 있습니다. 설치를 진행합니다!)

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

(이미 유저 스타일 관리자가 설치되어 있습니다. 설치를 진행합니다!)

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

})();