OverviewFilesCommitsIssuesDocsReleasesPeers

stremio-vsembed / node_helper / fmovies_browse.mjs

138 lines · javascript ref:
#!/usr/bin/env node
// fmovies_browse.mjs — fmovies-native catalog + meta for the Stremio addon.
// Lets Jacktook (or any Stremio client) browse fmovies directly instead of going
// through TMDB/cinemeta, so the catalog == exactly what fmovies can play.
//
// Usage:
//   node fmovies_browse.mjs search "<query>"        -> {results:[{mid,slug,title,type,season,poster}]}
//   node fmovies_browse.mjs meta <mid> <slug> <type> -> {name,type,season,poster,episodes:[{episode,title}]}
// All diagnostics on stderr; one JSON line on stdout.

import { chromium } from 'playwright';

const log = (...a) => console.error('[browse]', ...a);
const FMOVIES = 'https://fmoviess.org';
const UA = 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36';

const [, , CMD, A1, A2, A3] = process.argv;

function humanize(slug) {
  return (slug || '').replace(/-/g, ' ').replace(/\b\w/g, c => c.toUpperCase());
}

// fmovies models each SEASON as its own /film/ page (slug "...-season-N").
// Treat a season-suffixed slug as a series (that one season); everything else as a movie.
function classify(slug) {
  const m = /-season-(\d+)$/.exec(slug || '');
  if (m) return { type: 'series', season: parseInt(m[1], 10) };
  return { type: 'movie', season: null };
}

async function withPage(fn) {
  const browser = await chromium.launch({ headless: true, args: ['--no-sandbox'] });
  const ctx = await browser.newContext({ userAgent: UA });
  const page = await ctx.newPage();
  try { return await fn(page); }
  finally { await browser.close(); }
}

async function doSearch(query) {
  return withPage(async (page) => {
    const url = FMOVIES + '/search/?q=' + encodeURIComponent(query);
    await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 25000 });
    await page.waitForTimeout(1200);
    const raw = await page.evaluate(() => {
      const slugRe = new RegExp('^/film/(.+?)-([0-9]+)/?$');
      const out = [], seen = new Set();
      for (const a of document.querySelectorAll('a[href*="/film/"]')) {
        const href = a.getAttribute('href') || '';
        if (seen.has(href)) continue;
        const m = slugRe.exec(href);
        if (!m) continue;
        seen.add(href);
        // find a poster + title near the anchor (card layout varies)
        const card = a.closest('.item, .movie, .film, li, article, div') || a;
        const img = card.querySelector('img');
        let poster = '';
        if (img) poster = img.getAttribute('data-src') || img.getAttribute('src') || '';
        const titleAttr = a.getAttribute('title') || (img && img.getAttribute('alt')) || '';
        const text = (a.textContent || '').trim();
        out.push({ mid: m[2], slug: m[1], poster, titleRaw: (titleAttr || text || '').trim() });
      }
      return out;
    });
    const results = raw.map(r => {
      const { type, season } = classify(r.slug);
      let poster = r.poster || '';
      if (poster.startsWith('//')) poster = 'https:' + poster;
      else if (poster.startsWith('/')) poster = FMOVIES + poster;
      const title = r.titleRaw && r.titleRaw.length > 1 ? r.titleRaw : humanize(r.slug);
      return { mid: r.mid, slug: r.slug, title, type, season, poster };
    });
    return { results };
  });
}

async function doMeta(mid, slug, type) {
  const filmUrl = `${FMOVIES}/film/${slug}-${mid}/`;
  return withPage(async (page) => {
    await page.goto(filmUrl, { waitUntil: 'domcontentloaded', timeout: 30000 });
    await page.waitForTimeout(1500);
    const info = await page.evaluate(() => {
      const midEl = document.getElementById('mid');
      const poster = (document.querySelector('meta[property="og:image"]') || {}).content
        || (document.querySelector('.poster img, .film-poster img, img') || {}).src || '';
      const eps = [...document.querySelectorAll('.episode')].map(e => ({
        id: e.id || '',
        text: (e.textContent || '').trim().slice(0, 60),
      }));
      return {
        title: (document.title || '')
          .replace(/^watch\s+/i, '')
          .replace(/\s+full movie.*$/i, '')
          .replace(/\s+on fmovies.*$/i, '')
          .trim(),
        mode: midEl ? midEl.getAttribute('data-mode') : null,
        poster,
        eps,
      };
    });
    const { season } = classify(slug);
    let episodes = [];
    if (type === 'series') {
      const list = info.eps.length ? info.eps : [{ id: 'ep-1', text: '' }];
      episodes = list.map((e, i) => {
        const num = parseInt((e.id.split('-').pop() || ''), 10) || (i + 1);
        const txt = (e.text || '').trim();
        const title = (!txt || /^\d+$/.test(txt)) ? `Episode ${num}` : txt;
        return { episode: num, title };
      });
      // de-dup + sort
      const byNum = new Map();
      for (const e of episodes) if (!byNum.has(e.episode)) byNum.set(e.episode, e);
      episodes = [...byNum.values()].sort((a, b) => a.episode - b.episode);
    }
    let poster = info.poster || '';
    if (poster.startsWith('//')) poster = 'https:' + poster;
    return { name: info.title || humanize(slug), type, season: season || 1, poster, episodes };
  });
}

(async () => {
  try {
    if (CMD === 'search') {
      if (!A1) { console.log(JSON.stringify({ error: 'search requires a query' })); process.exit(1); }
      console.log(JSON.stringify(await doSearch(A1)));
    } else if (CMD === 'meta') {
      if (!A1 || !A2) { console.log(JSON.stringify({ error: 'meta requires <mid> <slug> [type]' })); process.exit(1); }
      console.log(JSON.stringify(await doMeta(A1, A2, A3 === 'series' ? 'series' : (classify(A2).type))));
    } else {
      console.log(JSON.stringify({ error: 'usage: search <query> | meta <mid> <slug> <type>' }));
      process.exit(1);
    }
  } catch (e) {
    console.log(JSON.stringify({ error: String(e && e.message || e) }));
    process.exit(2);
  }
})();