OverviewFilesCommitsIssuesDocsReleasesPeers

stremio-vsembed / node_helper / fmovies_resolve.mjs

225 lines · javascript ref:
#!/usr/bin/env node
// fmovies_resolve.mjs — resolves a title to an HLS master.m3u8.
//
// 2026-07-21 rewrite. fmovies' "Server 1" now just redirects (netoda.tech /get/)
// into the vsembed.ru embed keyed by the TMDB id, whose player is the vidsrc/
// cloudnestra "rcp" chain. Two entry modes, both ending in the same rcp sniff:
//
//   TT MODE (Jacktook's normal TMDB menu):
//     <movie|series> <tt-id> [s] [e]
//     imdb --TMDB find--> tmdb -> vsembed.ru/embed/{movie|tv}/<tmdb>[/s/e]
//
//   URL MODE (fmovies-native catalog, fmv: ids — stays fmovies-native so it
//     handles TMDB-fragmented titles the tt path can't):
//     url <fmoviesFilmUrl> <movie|series> [s] [e]
//     drive the fmovies film page (pick episode, click Server 1) and capture the
//     vsembed embed URL it redirects into (which carries fmovies' own tmdb id).
//
// Both then: capture the <rcp-host>/rcp/<hash> iframe -> drive it directly (ads
// blocked, Referer=embed) -> click #pl_but -> /prorcp/<hash> -> sniff master.m3u8.
// Crypto-agnostic (their JS mints the JWT/IP-locked token, we just sniff the wire)
// so it survives token/domain rotations. m3u8 is token+IP-CIDR locked (~4h), NOT
// referer-locked; playback works while the player shares the resolver's /24.
//
// Output (stdout, last line): {streams:[{name,title,url,referer,quality,behaviorHints}]}
//                          or {error:"..."}. Diagnostics on stderr.

import { chromium } from 'playwright';

const log = (...a) => console.error('[resolve]', ...a);
const TMDB_KEY = 'b70756b7083d9ee60f849d82d94a0d80'; // public Jacktook default key
const VSEMBED = 'https://vsembed.ru';
const UA = 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36';
const AD = /devilyquondam|dtscout|histats|\/cuid\/|popunder|propeller|adsco|doubleclick|onclick\.|popads|\/rum\?/i;
const M3U8 = /\.m3u8(\?|$)/i;
const RCP = /\/rcp\//i;
const EMBED_RE = /\/embed\//i;
const RCP_WAIT_MS = 16000;   // wait for the embed page to spawn its rcp iframe
const SNIFF_MS = 22000;      // wait after clicking play for the m3u8 to surface
const FMV_WAIT_MS = 18000;   // wait for the fmovies drive to redirect into the embed

// ---- argv: two modes -------------------------------------------------------
const ARGV = process.argv.slice(2);
const URL_MODE = ARGV[0] === 'url';
let TYPE, IMDB, FILM_URL, SEASON, EPISODE;
function die(error, code = 1) { console.log(JSON.stringify({ error })); process.exit(code); }

if (URL_MODE) {
  FILM_URL = ARGV[1];
  TYPE = ARGV[2] === 'series' ? 'series' : 'movie';
  SEASON = ARGV[3] ? parseInt(ARGV[3], 10) : null;
  EPISODE = ARGV[4] ? parseInt(ARGV[4], 10) : null;
  if (!FILM_URL || !/^https?:\/\//.test(FILM_URL)) die('usage: url <filmUrl> <movie|series> [season] [episode]');
  if (TYPE === 'series' && (!SEASON || !EPISODE)) die('series url mode requires season and episode');
} else {
  TYPE = ARGV[0] === 'series' ? 'series' : 'movie';
  IMDB = ARGV[1];
  SEASON = ARGV[2] ? parseInt(ARGV[2], 10) : null;
  EPISODE = ARGV[3] ? parseInt(ARGV[3], 10) : null;
  if (!IMDB || !/^tt\d+$/.test(IMDB)) die('usage: <movie|series> <imdb_tt_id> [season] [episode]');
  if (TYPE === 'series' && (!SEASON || !EPISODE)) die('series requires season and episode');
}

// ---- shared helpers --------------------------------------------------------
async function imdbToTmdb(type, imdb) {
  const r = await fetch(`https://api.themoviedb.org/3/find/${imdb}?external_source=imdb_id&api_key=${TMDB_KEY}`);
  if (!r.ok) throw new Error(`tmdb find ${r.status}`);
  const d = await r.json();
  const tv = (d.tv_results || [])[0];
  const mv = (d.movie_results || [])[0];
  if (type === 'series' && tv) return tv.id;
  if (type === 'movie' && mv) return mv.id;
  const any = mv || tv;                       // tolerate a type mismatch from cinemeta
  if (any) return any.id;
  throw new Error('no tmdb match for ' + imdb);
}

function embedUrl(type, tmdb) {
  return type === 'series'
    ? `${VSEMBED}/embed/tv/${tmdb}/${SEASON}/${EPISODE}?autoplay=1`
    : `${VSEMBED}/embed/movie/${tmdb}?autoplay=1`;
}

// Load the vsembed embed and capture the rcp iframe URL it spawns.
async function captureRcp(ctx, embed) {
  const p = await ctx.newPage();
  let rcp = null;
  p.on('framenavigated', f => { const u = f.url(); if (RCP.test(u) && !rcp) rcp = u; });
  await p.setExtraHTTPHeaders({ Referer: 'https://netoda.tech/' });
  await p.goto(embed, { waitUntil: 'domcontentloaded', timeout: 30000 }).catch(e => log('embed goto warn:', e.message));
  const deadline = Date.now() + RCP_WAIT_MS;
  while (Date.now() < deadline && !rcp) await p.waitForTimeout(1000);
  await p.close().catch(() => {});
  return rcp;
}

// Drive the rcp page directly, click play, sniff the master.m3u8.
async function sniffM3u8(ctx, rcpUrl, referer) {
  const p = await ctx.newPage();
  const found = new Map();  // m3u8 url -> referer header seen on the request
  p.on('request', r => { const u = r.url(); if (M3U8.test(u)) found.set(u, (r.headers() || {}).referer || null); });
  await p.setExtraHTTPHeaders({ Referer: referer });
  await p.goto(rcpUrl, { waitUntil: 'domcontentloaded', timeout: 30000 }).catch(e => log('rcp goto warn:', e.message));
  await p.waitForTimeout(2500);
  const clicked = await p.evaluate(() => {
    const el = document.querySelector('#pl_but') || document.querySelector('.play') || document.querySelector('[onclick]');
    if (el) { el.click(); return el.id || el.className || 'onclick'; }
    if (document.body) { document.body.click(); return 'body'; }
    return null;
  }).catch(() => null);
  log('play click:', clicked);
  const deadline = Date.now() + SNIFF_MS;
  while (Date.now() < deadline && found.size === 0) await p.waitForTimeout(1000);
  if (found.size) await p.waitForTimeout(1500);  // grace so we prefer master over a variant
  await p.close().catch(() => {});
  if (!found.size) return null;
  const url = [...found.keys()].find(u => /master/i.test(u)) || [...found.keys()][0];
  // The CDN's video segments (/content/.../page-N.html) 403 on a vsembed/netoda/
  // m3u8-host Referer but 200 on the rcp PLAYER origin (or none). The captured
  // request referer is masked (we force Referer=embed to load rcp), so ignore it
  // and hand the player its own origin — rotation-agnostic (cloudnestra/cloudorchestra/…).
  const rcpOrigin = (() => { try { return new URL(rcpUrl).origin + '/'; } catch { return 'https://cloudnestra.com/'; } })();
  return { url, referer: rcpOrigin };
}

// URL MODE: drive the fmovies film page and capture the vsembed embed URL (and,
// as a fallback, the rcp URL directly). fmovies maps its own mid -> tmdb, so the
// embed URL it redirects into carries the correct tmdb id for this exact title
// (and, for series, the exact episode).
async function driveFmovies(ctx, filmUrl, type, season, episode) {
  const p = await ctx.newPage();
  let embed = null, rcp = null;
  p.on('framenavigated', f => {
    const u = f.url();
    if (EMBED_RE.test(u) && !embed) embed = u;
    if (RCP.test(u) && !rcp) rcp = u;
  });
  await p.goto(filmUrl, { waitUntil: 'domcontentloaded', timeout: 30000 }).catch(e => log('film goto warn:', e.message));
  await p.waitForTimeout(2500);
  // open the player (populates the internal mid; skipping it encrypts mid=undefined)
  await p.evaluate(() => { const a = document.getElementById('play-now'); if (a) a.click(); }).catch(() => {});
  await p.waitForTimeout(2000);
  if (type === 'series') {
    const tag = `s${String(season).padStart(2, '0')}e${String(episode).padStart(2, '0')}`;
    const chose = await p.evaluate(({ tag, ep }) => {
      const eps = [...document.querySelectorAll('.episode')];
      const norm = (s) => (s || '').toLowerCase().replace(/[^a-z0-9]/g, '');
      let t = eps.find(b => norm(b.id).includes(tag) || norm(b.textContent).includes(tag))
           || eps.find(b => (b.id || '').split('-').pop() === String(ep));
      if (t) { t.click(); return t.id || t.textContent.trim().slice(0, 30); }
      return null;
    }, { tag, ep: episode }).catch(() => null);
    log('episode pick:', chose);
    await p.waitForTimeout(1500);
  }
  // click Server 1 (srv-1 -> netoda -> vsembed); fall through to alternates
  for (const id of ['srv-1', 'srv-2', 'srv-5']) {
    const ok = await p.evaluate((s) => { const b = document.getElementById(s); if (b) { b.click(); return true; } return false; }, id).catch(() => false);
    if (ok) { log('clicked', id); break; }
  }
  const deadline = Date.now() + FMV_WAIT_MS;
  while (Date.now() < deadline && !embed && !rcp) await p.waitForTimeout(1000);
  await p.close().catch(() => {});
  return { embed, rcp };
}

// ---- main ------------------------------------------------------------------
(async () => {
  const browser = await chromium.launch({ headless: true, args: ['--no-sandbox'] });
  const ctx = await browser.newContext({ userAgent: UA });
  await ctx.route('**/*', route => { AD.test(route.request().url()) ? route.abort() : route.continue(); });

  const fail = async (msg, code) => { await browser.close().catch(() => {}); die(msg, code); };

  // Resolve to an rcp URL + the referer to drive it with.
  let rcp = null, referer = null, dbg = {};
  try {
    if (URL_MODE) {
      log('url mode:', FILM_URL);
      const { embed, rcp: directRcp } = await driveFmovies(ctx, FILM_URL, TYPE, SEASON, EPISODE);
      log('fmovies -> embed:', embed, 'rcp:', directRcp ? directRcp.slice(0, 60) + '...' : null);
      if (embed) {
        // Re-derive a fresh rcp from the embed (most reliable path).
        rcp = await captureRcp(ctx, embed);
        referer = embed;
        dbg = { embed };
      }
      if (!rcp && directRcp) { rcp = directRcp; referer = embed || FILM_URL; dbg = { embed, viaDirectRcp: true }; }
      if (!rcp) return fail('no embed/rcp from fmovies film page (title may be unavailable)', 3);
    } else {
      const tmdb = await imdbToTmdb(TYPE, IMDB);
      log(`tmdb: ${IMDB} -> ${tmdb} (${TYPE})`);
      const embed = embedUrl(TYPE, tmdb);
      log('embed:', embed);
      rcp = await captureRcp(ctx, embed);
      referer = embed;
      dbg = { tmdb, embed };
      if (!rcp) return fail('no rcp iframe from vsembed (title may be unavailable)', 3);
    }
  } catch (e) {
    return fail((URL_MODE ? 'fmovies drive failed: ' : 'lookup failed: ') + e.message, 2);
  }

  log('rcp:', rcp.slice(0, 90) + '...');
  let stream = null;
  try { stream = await sniffM3u8(ctx, rcp, referer); }
  catch (e) { return fail('sniff failed: ' + e.message, 5); }
  await browser.close().catch(() => {});

  if (!stream) die('no playable stream (m3u8 did not surface)', 5);

  const labelTag = TYPE === 'series'
    ? `S${String(SEASON).padStart(2, '0')}E${String(EPISODE).padStart(2, '0')} ` : '';
  console.log(JSON.stringify({
    streams: [{
      name: 'vsembed',
      title: `${labelTag}1080p\nvsembed (cloudnestra)`,
      url: stream.url,
      referer: stream.referer,
      quality: '1080p',
      behaviorHints: { notWebReady: true },
    }],
    debug: { ...dbg, referer: stream.referer },
  }));
})();