#!/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);
}
})();