Bring public providers more inline with private providers

Adds a few more sources and brings some features and functions over. Of course many sources and embeds have been left our so they don't get patched. But this should make some development eaiser.
This commit is contained in:
Pas 2025-12-09 23:30:18 -07:00
parent aa0e70cb69
commit e6b1a7ada8
50 changed files with 3379 additions and 1129 deletions

3
.gitignore vendored
View file

@ -3,4 +3,5 @@ node_modules/
coverage
.env
.eslintcache
/lib-obf
.DS_Store

View file

@ -73,7 +73,9 @@
"node-fetch": "^3.3.2",
"prettier": "^3.6.2",
"puppeteer": "^22.15.0",
"rollup-plugin-obfuscator": "^1.1.0",
"spinnies": "^0.5.1",
"terser": "^5.44.0",
"tsc-alias": "^1.8.16",
"tsconfig-paths": "^4.2.0",
"typescript": "^5.9.3",

File diff suppressed because it is too large Load diff

View file

@ -20,7 +20,7 @@ export function makeSimpleProxyFetcher(proxyUrl: string, f: FetchLike): Fetcher
const fetcher = makeStandardFetcher(async (a, b) => {
// AbortController
const controller = new AbortController();
const timeout = 15000; // 15s timeout
const timeout = 20000; // 20s timeout
const timeoutId = setTimeout(() => controller.abort(), timeout);
try {

View file

@ -12,10 +12,8 @@ import { fsharetvScraper } from '@/providers/sources/fsharetv';
import { fsOnlineEmbeds, fsOnlineScraper } from '@/providers/sources/fsonline/index';
import { insertunitScraper } from '@/providers/sources/insertunit';
import { mp4hydraScraper } from '@/providers/sources/mp4hydra';
import { nepuScraper } from '@/providers/sources/nepu';
import { pirxcyScraper } from '@/providers/sources/pirxcy';
import { tugaflixScraper } from '@/providers/sources/tugaflix';
import { vidsrcScraper } from '@/providers/sources/vidsrc';
import { vidsrcvipScraper } from '@/providers/sources/vidsrcvip';
import { zoechipScraper } from '@/providers/sources/zoechip';
@ -78,15 +76,12 @@ import { EightStreamScraper } from './sources/8stream';
import { animeflvScraper } from './sources/animeflv';
import { animetsuScraper } from './sources/animetsu';
import { cinehdplusScraper } from './sources/cinehdplus-es';
import { cinemaosScraper } from './sources/cinemaos';
import { coitusScraper } from './sources/coitus';
import { cuevana3Scraper } from './sources/cuevana3';
import { debridScraper } from './sources/debrid';
import { embedsuScraper } from './sources/embedsu';
import { fullhdfilmizleScraper } from './sources/fullhdfilmizle';
import { hdRezkaScraper } from './sources/hdrezka';
import { iosmirrorScraper } from './sources/iosmirror';
import { iosmirrorPVScraper } from './sources/iosmirrorpv';
import { lookmovieScraper } from './sources/lookmovie';
import { madplayScraper } from './sources/madplay';
import { movies4fScraper } from './sources/movies4f';
@ -123,13 +118,10 @@ export function gatherAllSources(): Array<Sourcerer> {
tugaflixScraper,
ee3Scraper,
fsharetvScraper,
vidsrcScraper,
zoechipScraper,
mp4hydraScraper,
embedsuScraper,
slidemoviesScraper,
iosmirrorScraper,
iosmirrorPVScraper,
vidapiClickScraper,
coitusScraper,
streamboxScraper,
@ -137,8 +129,6 @@ export function gatherAllSources(): Array<Sourcerer> {
EightStreamScraper,
wecimaScraper,
animeflvScraper,
cinemaosScraper,
nepuScraper,
pirxcyScraper,
vidsrcvipScraper,
madplayScraper,

View file

@ -1,48 +0,0 @@
import { flags } from '@/entrypoint/utils/targets';
import { makeEmbed } from '@/providers/base';
const linkRegex = /file: ?"(http.*?)"/;
// the white space charecters may seem useless, but without them it breaks
const tracksRegex = /\{file:\s"([^"]+)",\skind:\s"thumbnails"\}/g;
export const filelionsScraper = makeEmbed({
id: 'filelions',
name: 'filelions',
rank: 115,
flags: [],
async scrape(ctx) {
const mainPageRes = await ctx.proxiedFetcher.full<string>(ctx.url, {
headers: {
referer: ctx.url,
},
});
const mainPage = mainPageRes.body;
const mainPageUrl = new URL(mainPageRes.finalUrl);
const streamUrl = mainPage.match(linkRegex) ?? [];
const thumbnailTrack = tracksRegex.exec(mainPage);
const playlist = streamUrl[1];
if (!playlist) throw new Error('Stream url not found');
return {
stream: [
{
id: 'primary',
type: 'hls',
playlist,
flags: [flags.IP_LOCKED, flags.CORS_ALLOWED],
captions: [],
...(thumbnailTrack
? {
thumbnailTrack: {
type: 'vtt',
url: mainPageUrl.origin + thumbnailTrack[1],
},
}
: {}),
},
],
};
},
});

View file

@ -1,62 +1,117 @@
// import { load } from 'cheerio';
// import { unpack } from 'unpacker';
import { unpack } from 'unpacker';
// import { flags } from '@/entrypoint/utils/targets';
import { flags } from '@/entrypoint/utils/targets';
import { makeEmbed } from '@/providers/base';
import { Caption, getCaptionTypeFromUrl, labelToLanguageCode } from '@/providers/captions';
// import { SubtitleResult } from './types';
// import { makeEmbed } from '../../base';
// import { Caption, getCaptionTypeFromUrl, labelToLanguageCode } from '../../captions';
import type { SubtitleResult } from './types';
// const evalCodeRegex = /eval\((.*)\)/g;
// const fileRegex = /file:"(.*?)"/g;
const M3U8_REGEX = /https?:\/\/[^\s"'<>]+\.m3u8[^\s"'<>]*/gi;
// export const fileMoonScraper = makeEmbed({
// id: 'filemoon',
// name: 'Filemoon',
// rank: 300,
// scrape: async (ctx) => {
// const embedRes = await ctx.proxiedFetcher<string>(ctx.url, {
// headers: {
// referer: ctx.url,
// },
// });
// const embedHtml = load(embedRes);
// const evalCode = embedHtml('script').text().match(evalCodeRegex);
// if (!evalCode) throw new Error('Failed to find eval code');
// const unpacked = unpack(evalCode[0]);
// const file = fileRegex.exec(unpacked);
// if (!file?.[1]) throw new Error('Failed to find file');
function extractScripts(html: string): string[] {
const out: string[] = [];
const re = /<script[^>]*>([\s\S]*?)<\/script>/gi;
let m: RegExpExecArray | null;
// eslint-disable-next-line no-cond-assign
while ((m = re.exec(html))) {
out.push(m[1] ?? '');
}
return out;
}
// const url = new URL(ctx.url);
// const subtitlesLink = url.searchParams.get('sub.info');
// const captions: Caption[] = [];
// if (subtitlesLink) {
// const captionsResult = await ctx.proxiedFetcher<SubtitleResult>(subtitlesLink);
function unpackIfPacked(script: string): string | null {
try {
if (script.includes('eval(function(p,a,c,k,e,d)')) {
const once = unpack(script);
if (once && once !== script) return once;
// try one more time in case of double-pack
const twice = unpack(once ?? script);
return twice ?? once ?? null;
}
} catch {
// ignore
}
return null;
}
// for (const caption of captionsResult) {
// const language = labelToLanguageCode(caption.label);
// const captionType = getCaptionTypeFromUrl(caption.file);
// if (!language || !captionType) continue;
// captions.push({
// id: caption.file,
// url: caption.file,
// type: captionType,
// language,
// hasCorsRestrictions: false,
// });
// }
// }
function extractAllM3u8(text: string): string[] {
const seen: Record<string, true> = {};
const urls: string[] = [];
const body = text ?? '';
const unescaped = body
.replace(/\\x([0-9a-fA-F]{2})/g, (_s, h) => String.fromCharCode(parseInt(h, 16)))
.replace(/\\u([0-9a-fA-F]{4})/g, (_s, h) => String.fromCharCode(parseInt(h, 16)));
const matches = unescaped.match(M3U8_REGEX) ?? [];
for (const u of matches) {
if (!seen[u]) {
seen[u] = true;
urls.push(u);
}
}
return urls;
}
// return {
// stream: [
// {
// id: 'primary',
// type: 'hls',
// playlist: file[1],
// flags: [flags.IP_LOCKED],
// captions,
// },
// ],
// };
// },
// });
export const fileMoonScraper = makeEmbed({
id: 'filemoon',
name: 'Filemoon',
rank: 405,
flags: [flags.IP_LOCKED],
async scrape(ctx) {
// Load initial page
const page = await ctx.proxiedFetcher.full<string>(ctx.url, {
headers: { Referer: ctx.url },
});
const pageHtml = page.body;
// Prefer iframe payload
const iframeMatch = /<iframe[^>]+src=["']([^"']+)["']/i.exec(pageHtml);
const iframeUrl = iframeMatch ? new URL(iframeMatch[1], page.finalUrl || ctx.url).toString() : null;
const payloadResp = iframeUrl
? await ctx.proxiedFetcher.full<string>(iframeUrl, { headers: { Referer: page.finalUrl || ctx.url } })
: page;
const payloadHtml = payloadResp.body;
// Try unpacking packed JS
const scripts = extractScripts(payloadHtml);
let collected = payloadHtml;
for (const s of scripts) {
const unpacked = unpackIfPacked(s);
if (unpacked) collected += `\n${unpacked}`;
}
// Extract m3u8s
const m3u8s = extractAllM3u8(collected);
if (m3u8s.length === 0) throw new Error('Filemoon: no m3u8 found');
// Captions (optional)
const captions: Caption[] = [];
try {
const u = new URL(ctx.url);
const subtitlesLink = u.searchParams.get('sub.info');
if (subtitlesLink) {
const res = await ctx.proxiedFetcher<SubtitleResult>(subtitlesLink);
for (const c of res) {
const language = labelToLanguageCode(c.label);
const type = getCaptionTypeFromUrl(c.file);
if (!language || !type) continue;
captions.push({ id: c.file, url: c.file, type, language, hasCorsRestrictions: false });
}
}
} catch {
// ignore caption errors
}
return {
stream: [
{
id: 'primary',
type: 'hls',
playlist: m3u8s[0],
flags: [flags.IP_LOCKED],
captions,
},
],
};
},
});

View file

@ -1,38 +1,36 @@
// import { flags } from '@/entrypoint/utils/targets';
// import { NotFoundError } from '@/utils/errors';
import { flags } from '@/entrypoint/utils/targets';
import { makeEmbed } from '@/providers/base';
import { NotFoundError } from '@/utils/errors';
// import { makeEmbed } from '../../base';
import { fileMoonScraper } from './index';
// import { fileMoonScraper } from './index';
export const fileMoonMp4Scraper = makeEmbed({
id: 'filemoon-mp4',
name: 'Filemoon MP4',
rank: 406,
flags: [flags.IP_LOCKED],
async scrape(ctx) {
const result = await fileMoonScraper.scrape(ctx);
if (!result.stream || result.stream.length === 0) throw new NotFoundError('Failed to find result');
if (result.stream[0].type !== 'hls') throw new NotFoundError('Failed to find hls stream');
// export const fileMoonMp4Scraper = makeEmbed({
// id: 'filemoon-mp4',
// name: 'Filemoon MP4',
// rank: 400,
// scrape: async (ctx) => {
// const result = await fileMoonScraper.scrape(ctx);
const mp4Url = result.stream[0].playlist.replace(/\/hls2\//, '/download/').replace(/\.m3u8.*/, '.mp4');
// if (!result.stream) throw new NotFoundError('Failed to find result');
// if (result.stream[0].type !== 'hls') throw new NotFoundError('Failed to find hls stream');
// const url = result.stream[0].playlist.replace(/\/hls2\//, '/download/').replace(/\.m3u8/, '.mp4');
// return {
// stream: [
// {
// id: 'primary',
// type: 'file',
// qualities: {
// unknown: {
// type: 'mp4',
// url,
// },
// },
// flags: [flags.IP_LOCKED],
// captions: result.stream[0].captions,
// },
// ],
// };
// },
// });
return {
stream: [
{
id: 'primary',
type: 'file',
qualities: {
unknown: {
type: 'mp4',
url: mp4Url,
},
},
flags: [flags.IP_LOCKED],
captions: result.stream[0].captions,
},
],
};
},
});

View file

@ -1,102 +0,0 @@
import { flags } from '@/entrypoint/utils/targets';
import { makeEmbed } from '@/providers/base';
// StreamBucket makes use of https://github.com/nicxlau/hunter-php-javascript-obfuscator
const hunterRegex = /eval\(function\(h,u,n,t,e,r\).*?\("(.*?)",\d*?,"(.*?)",(\d*?),(\d*?),\d*?\)\)/;
const linkRegex = /file:"(.*?)"/;
// This is a much more simple and optimized version of the "h,u,n,t,e,r"
// obfuscation algorithm. It's just basic chunked+mask encoding.
// I have seen this same encoding used on some sites under the name
// "p,l,a,y,e,r" as well
function decodeHunter(encoded: string, mask: string, charCodeOffset: number, delimiterOffset: number) {
// The encoded string is made up of 'n' number of chunks.
// Each chunk is separated by a delimiter inside the mask.
// This offset is also used as the exponentiation base in
// the charCode calculations
const delimiter = mask[delimiterOffset];
// Split the 'encoded' string into chunks using the delimiter,
// and filter out any empty chunks.
const chunks = encoded.split(delimiter).filter((chunk) => chunk);
// Decode each chunk and concatenate the results to form the final 'decoded' string.
const decoded = chunks
.map((chunk) => {
// Chunks are in reverse order. 'reduceRight' removes the
// need to 'reverse' the array first
const charCode = chunk.split('').reduceRight((c, value, index) => {
// Calculate the character code for each character in the chunk.
// This involves finding the index of 'value' in the 'mask' and
// multiplying it by (delimiterOffset^position).
return c + mask.indexOf(value) * delimiterOffset ** (chunk.length - 1 - index);
}, 0);
// The actual character code is offset by the given amount
return String.fromCharCode(charCode - charCodeOffset);
})
.join('');
return decoded;
}
export const streambucketScraper = makeEmbed({
id: 'streambucket',
name: 'StreamBucket',
rank: 196,
// TODO - Disabled until ctx.fetcher and ctx.proxiedFetcher don't trigger bot detection
disabled: true,
flags: [],
async scrape(ctx) {
// Using the context fetchers make the site return just the string "No bots please!"?
// TODO - Fix this. Native fetch does not trigger this. No idea why right now
const response = await fetch(ctx.url);
const html = await response.text();
// This is different than the above mentioned bot detection
if (html.includes('captcha-checkbox')) {
// TODO - This doesn't use recaptcha, just really basic "image match". Maybe could automate?
throw new Error('StreamBucket got captchaed');
}
let regexResult = html.match(hunterRegex);
if (!regexResult) {
throw new Error('Failed to find StreamBucket hunter JavaScript');
}
const encoded = regexResult[1];
const mask = regexResult[2];
const charCodeOffset = Number(regexResult[3]);
const delimiterOffset = Number(regexResult[4]);
if (Number.isNaN(charCodeOffset)) {
throw new Error('StreamBucket hunter JavaScript charCodeOffset is not a valid number');
}
if (Number.isNaN(delimiterOffset)) {
throw new Error('StreamBucket hunter JavaScript delimiterOffset is not a valid number');
}
const decoded = decodeHunter(encoded, mask, charCodeOffset, delimiterOffset);
regexResult = decoded.match(linkRegex);
if (!regexResult) {
throw new Error('Failed to find StreamBucket HLS link');
}
return {
stream: [
{
id: 'primary',
type: 'hls',
playlist: regexResult[1],
flags: [flags.CORS_ALLOWED],
captions: [],
},
],
};
},
});

View file

@ -0,0 +1,85 @@
import { flags } from '@/entrypoint/utils/targets';
import { makeEmbed } from '@/providers/base';
import { NotFoundError } from '@/utils/errors';
import { createM3U8ProxyUrl } from '@/utils/proxy';
const providers = [
{
id: 'vidjoy-stream1',
name: 'Server 1',
rank: 110,
},
{
id: 'vidjoy-stream2',
name: 'Server 2',
rank: 109,
},
{
id: 'vidjoy-stream3',
name: 'Server 3',
rank: 108,
},
{
id: 'vidjoy-stream4',
name: 'Server 4',
rank: 107,
},
{
id: 'vidjoy-stream5',
name: 'Server 5',
rank: 106,
},
];
function embed(provider: { id: string; name: string; rank: number }) {
return makeEmbed({
id: provider.id,
name: provider.name,
rank: provider.rank,
flags: [flags.CORS_ALLOWED],
async scrape(ctx) {
// ctx.url contains the JSON stringified stream data (passed from source)
let streamData;
try {
streamData = JSON.parse(ctx.url);
} catch (error) {
throw new NotFoundError('Invalid stream data from vidjoy source');
}
if (!streamData.link) {
throw new NotFoundError('No stream URL found in vidjoy data');
}
// Validate that we have a proper URL
if (!streamData.link || streamData.link.trim() === '') {
throw new NotFoundError('Stream URL is empty');
}
// Create proxy URL with headers if provided
let playlistUrl = streamData.link;
if (streamData.headers && Object.keys(streamData.headers).length > 0) {
playlistUrl = createM3U8ProxyUrl(streamData.link, streamData.headers);
}
return {
stream: [
{
id: 'primary',
type: streamData.type || 'hls',
playlist: playlistUrl,
flags: [flags.CORS_ALLOWED],
captions: [],
},
],
};
},
});
}
export const [
vidjoyStream1Scraper,
vidjoyStream2Scraper,
vidjoyStream3Scraper,
vidjoyStream4Scraper,
vidjoyStream5Scraper,
] = providers.map(embed);

View file

@ -1,45 +0,0 @@
import { flags } from '@/entrypoint/utils/targets';
import { makeEmbed } from '@/providers/base';
const linkRegex = /'hls': ?'(http.*?)',/;
const tracksRegex = /previewThumbnails:\s{.*src:\["([^"]+)"]/;
export const voeScraper = makeEmbed({
id: 'voe',
name: 'voe.sx',
rank: 180,
flags: [flags.CORS_ALLOWED, flags.IP_LOCKED],
async scrape(ctx) {
const embedRes = await ctx.proxiedFetcher.full<string>(ctx.url);
const embed = embedRes.body;
const playerSrc = embed.match(linkRegex) ?? [];
const thumbnailTrack = embed.match(tracksRegex);
const streamUrl = playerSrc[1];
if (!streamUrl) throw new Error('Stream url not found in embed code');
return {
stream: [
{
type: 'hls',
id: 'primary',
playlist: streamUrl,
flags: [flags.CORS_ALLOWED, flags.IP_LOCKED],
captions: [],
headers: {
Referer: 'https://voe.sx',
},
...(thumbnailTrack
? {
thumbnailTrack: {
type: 'vtt',
url: new URL(embedRes.finalUrl).origin + thumbnailTrack[1],
},
}
: {}),
},
],
};
},
});

Binary file not shown.

View file

@ -1,158 +0,0 @@
// kinda based on m4uscraper by Paradox_77
// thanks Paradox_77
import { load } from 'cheerio';
import { SourcererEmbed, makeSourcerer } from '@/providers/base';
import { compareMedia } from '@/utils/compare';
import { MovieScrapeContext, ShowScrapeContext } from '@/utils/context';
import { makeCookieHeader, parseSetCookie } from '@/utils/cookie';
import { NotFoundError } from '@/utils/errors';
let baseUrl = 'https://m4ufree.se';
const universalScraper = async (ctx: MovieScrapeContext | ShowScrapeContext) => {
// this redirects to ww1.m4ufree.tv or ww2.m4ufree.tv
// if i explicitly keep the base ww1 while the load balancers thinks ww2 is optimal
// it will keep redirecting all the requests
// not only that but the last iframe request will fail
const homePage = await ctx.proxiedFetcher.full(baseUrl);
baseUrl = new URL(homePage.finalUrl).origin;
const searchSlug = ctx.media.title
.replace(/'/g, '')
.replace(/!|@|%|\^|\*|\(|\)|\+|=|<|>|\?|\/|,|\.|:|;|'| |"|&|#|\[|\]|~|$|_/g, '-')
.replace(/-+-/g, '-')
.replace(/^-+|-+$/g, '')
.replace(/Ă¢â‚¬â€œ/g, '');
const searchPage$ = load(
await ctx.proxiedFetcher<string>(`/search/${searchSlug}.html`, {
baseUrl,
query: {
type: ctx.media.type === 'movie' ? 'movie' : 'tvs',
},
}),
);
const searchResults: { title: string; year: number | undefined; url: string }[] = [];
searchPage$('.item').each((_, element) => {
const [, title, year] =
searchPage$(element)
// the title emement on their page is broken
// it just breaks when the titles are too big
.find('.imagecover a')
.attr('title')
// ex-titles: Home Alone 1990, Avengers Endgame (2019), The Curse (2023-)
?.match(/^(.*?)\s*(?:\(?\s*(\d{4})(?:\s*-\s*\d{0,4})?\s*\)?)?\s*$/) || [];
const url = searchPage$(element).find('a').attr('href');
if (!title || !url) return;
searchResults.push({ title, year: year ? parseInt(year, 10) : undefined, url });
});
const watchPageUrl = searchResults.find((x) => x && compareMedia(ctx.media, x.title, x.year))?.url;
if (!watchPageUrl) throw new NotFoundError('No watchable item found');
ctx.progress(25);
const watchPage = await ctx.proxiedFetcher.full(watchPageUrl, {
baseUrl,
readHeaders: ['Set-Cookie'],
});
ctx.progress(50);
let watchPage$ = load(watchPage.body);
const csrfToken = watchPage$('script:contains("_token:")')
.html()
?.match(/_token:\s?'(.*)'/m)?.[1];
if (!csrfToken) throw new Error('Failed to find csrfToken');
const laravelSession = parseSetCookie(watchPage.headers.get('Set-Cookie') ?? '').laravel_session;
if (!laravelSession?.value) throw new Error('Failed to find cookie');
const cookie = makeCookieHeader({ [laravelSession.name]: laravelSession.value });
if (ctx.media.type === 'show') {
const s = ctx.media.season.number < 10 ? `0${ctx.media.season.number}` : ctx.media.season.number.toString();
const e = ctx.media.episode.number < 10 ? `0${ctx.media.episode.number}` : ctx.media.episode.number.toString();
const episodeToken = watchPage$(`button:contains("S${s}-E${e}")`).attr('idepisode');
if (!episodeToken) throw new Error('Failed to find episodeToken');
watchPage$ = load(
await ctx.proxiedFetcher<string>('/ajaxtv', {
baseUrl,
method: 'POST',
body: new URLSearchParams({
idepisode: episodeToken,
_token: csrfToken,
}),
headers: {
cookie,
},
}),
);
}
ctx.progress(75);
const embeds: SourcererEmbed[] = [];
const sources: { name: string; data: string }[] = watchPage$('div.row.justify-content-md-center div.le-server')
.map((_, element) => {
const name = watchPage$(element).find('span').text().toLowerCase().replace('#', '');
const data = watchPage$(element).find('span').attr('data');
if (!data || !name) return null;
return { name, data };
})
.get();
for (const source of sources) {
let embedId;
if (source.name === 'm')
embedId = 'playm4u-m'; // TODO
else if (source.name === 'nm') embedId = 'playm4u-nm';
else if (source.name === 'h') embedId = 'hydrax';
else continue;
const iframePage$ = load(
await ctx.proxiedFetcher<string>('/ajax', {
baseUrl,
method: 'POST',
body: new URLSearchParams({
m4u: source.data,
_token: csrfToken,
}),
headers: {
cookie,
},
}),
);
const url = iframePage$('iframe').attr('src')?.trim();
if (!url) continue;
ctx.progress(100);
embeds.push({ embedId, url });
}
return {
embeds,
};
};
export const m4uScraper = makeSourcerer({
id: 'm4ufree',
name: 'M4UFree',
rank: 181,
disabled: true,
flags: [],
scrapeMovie: universalScraper,
scrapeShow: universalScraper,
});

View file

@ -0,0 +1,375 @@
/* eslint-disable no-console */
import { MovieScrapeContext, ShowScrapeContext } from '@/utils/context';
import { NotFoundError } from '@/utils/errors';
interface RealDebridUnrestrictResponse {
id: string;
filename: string;
mimeType: string;
filesize: number;
link: string;
host: string;
chunks: number;
download: string;
streamable: number;
}
interface RealDebridTorrentResponse {
id: string;
filename: string;
hash: string;
bytes: number;
host: string;
split: number;
progress: number;
status: string;
added: string;
files?: Array<{
id: number;
path: string;
bytes: number;
selected: number;
}>;
links?: string[];
}
const REALDEBRID_BASE_URL = 'https://api.real-debrid.com/rest/1.0';
// Add a magnet link to RealDebrid
async function addMagnetToRealDebrid(
magnetUrl: string,
token: string,
ctx: MovieScrapeContext | ShowScrapeContext,
): Promise<string> {
console.log('Adding magnet to RealDebrid:', `${magnetUrl.substring(0, 50)}...`);
const data = await ctx.proxiedFetcher.full(`${REALDEBRID_BASE_URL}/torrents/addMagnet`, {
method: 'POST',
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/x-www-form-urlencoded',
},
body: `magnet=${encodeURIComponent(magnetUrl)}`,
});
if (data.statusCode !== 201 || !data.body.id) {
throw new NotFoundError('Failed to add magnet to RealDebrid');
}
console.log('Magnet added successfully, torrent ID:', data.body.id);
return data.body.id;
}
// Select all files in a torrent
async function selectAllFiles(
torrentId: string,
token: string,
ctx: MovieScrapeContext | ShowScrapeContext,
): Promise<void> {
console.log('Selecting all files for torrent:', torrentId);
const data = await ctx.proxiedFetcher.full(`${REALDEBRID_BASE_URL}/torrents/selectFiles/${torrentId}`, {
method: 'POST',
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/x-www-form-urlencoded',
},
body: 'files=all',
});
if (data.statusCode !== 204 && data.statusCode !== 202) {
throw new NotFoundError('Failed to select all files for torrent');
}
console.log('All files selected successfully for torrent:', torrentId);
}
// Select specific file in a torrent
async function selectSpecificFile(
torrentId: string,
fileId: number,
token: string,
ctx: MovieScrapeContext | ShowScrapeContext,
): Promise<void> {
console.log(`Selecting specific file (${fileId}) for torrent:`, torrentId);
const data = await ctx.proxiedFetcher.full(`${REALDEBRID_BASE_URL}/torrents/selectFiles/${torrentId}`, {
method: 'POST',
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/x-www-form-urlencoded',
},
body: `files=${fileId}`,
});
// Success cases: 204 (No Content) or 202 (Already Done)
if (data.statusCode !== 204 && data.statusCode !== 202) {
throw new Error(`Unexpected status code: ${data.statusCode}`);
}
console.log(`File ${fileId} selected successfully for torrent:`, torrentId);
}
// Get torrent info from RealDebrid
async function getTorrentInfo(
torrentId: string,
token: string,
ctx: MovieScrapeContext | ShowScrapeContext,
): Promise<RealDebridTorrentResponse> {
const data = await ctx.proxiedFetcher.full<RealDebridTorrentResponse>(
`${REALDEBRID_BASE_URL}/torrents/info/${torrentId}`,
{
method: 'GET',
headers: {
Authorization: `Bearer ${token}`,
},
},
);
if (data.statusCode === 401 || data.statusCode === 403) {
throw new NotFoundError('Failed to get torrent info');
}
return data.body;
}
// Unrestrict a link on RealDebrid
async function unrestrictLink(
link: string,
token: string,
ctx: MovieScrapeContext | ShowScrapeContext,
): Promise<RealDebridUnrestrictResponse> {
console.log('Unrestricting link:', `${link.substring(0, 50)}...`);
const data = await ctx.proxiedFetcher.full<RealDebridUnrestrictResponse>(`${REALDEBRID_BASE_URL}/unrestrict/link`, {
method: 'POST',
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/x-www-form-urlencoded',
},
body: `link=${encodeURIComponent(link)}`,
});
if (data.statusCode === 401 || data.statusCode === 403 || !data.body) {
throw new NotFoundError('Failed to unrestrict link');
}
console.log('Link unrestricted successfully, got download URL');
return data.body;
}
// Process a magnet link through RealDebrid and get streaming URLs
export async function getUnrestrictedLink(
magnetUrl: string,
token: string,
ctx: MovieScrapeContext | ShowScrapeContext,
): Promise<string> {
try {
if (!magnetUrl || !magnetUrl.startsWith('magnet:?')) {
throw new NotFoundError(`Invalid magnet URL: ${magnetUrl}`);
}
// Add the magnet to RealDebrid
const torrentId = await addMagnetToRealDebrid(magnetUrl, token, ctx);
console.log('Torrent added to RealDebrid:', torrentId);
// Get initial torrent info
let torrentInfo = await getTorrentInfo(torrentId, token, ctx);
// console.log('Torrent info:', torrentInfo);
let waitAttempts = 0;
const maxWaitAttempts = 5;
// First wait until the torrent is ready for file selection or another actionable state
while (waitAttempts < maxWaitAttempts) {
console.log(
`Initial torrent status (wait attempt ${waitAttempts + 1}/${maxWaitAttempts}): ${torrentInfo.status}, progress: ${torrentInfo.progress}%`,
);
// If the torrent is ready for file selection or already being processed, break
if (
torrentInfo.status === 'waiting_files_selection' ||
torrentInfo.status === 'downloaded' ||
torrentInfo.status === 'downloading'
) {
break;
}
// Check for error states
const errorStatuses = ['error', 'virus', 'dead', 'magnet_error', 'magnet_conversion'];
if (errorStatuses.includes(torrentInfo.status)) {
throw new NotFoundError(`Torrent processing failed with status: ${torrentInfo.status}`);
}
// Wait 2 seconds before checking again
await new Promise<void>((resolve) => {
setTimeout(resolve, 2000);
});
waitAttempts++;
// Get updated torrent info
torrentInfo = await getTorrentInfo(torrentId, token, ctx);
// console.log('Torrent info attempt 2:', torrentInfo);
}
// Select files based on status
if (torrentInfo.status === 'waiting_files_selection') {
// If we have files info, try to select the best MP4 file
if (torrentInfo.files && torrentInfo.files.length > 0) {
const mp4Files = torrentInfo.files.filter((file) => file.path.toLowerCase().endsWith('.mp4'));
if (mp4Files.length > 0) {
console.log(`Found ${mp4Files.length} MP4 files, attempting to match title`);
const cleanMediaTitle = ctx.media.title.toLowerCase();
const firstWord = cleanMediaTitle.split(' ')[0];
// Try exact title match first
const exactMatches = mp4Files.filter((file) => {
const cleanFileName = file.path.split('/').pop()?.toLowerCase().replace(/\./g, ' ') || '';
return cleanFileName.includes(cleanMediaTitle);
});
let selectedFile;
if (exactMatches.length > 0) {
console.log(`Found ${exactMatches.length} files exactly matching title "${ctx.media.title}"`);
selectedFile = exactMatches.reduce((largest, current) =>
current.bytes > largest.bytes ? current : largest,
);
} else {
// Try matching first word
const firstWordMatches = mp4Files.filter((file) => {
return file.path.includes(firstWord);
});
if (firstWordMatches.length > 0) {
console.log(`Found ${firstWordMatches.length} files matching first word "${firstWord}"`);
selectedFile = firstWordMatches.reduce((largest, current) =>
current.bytes > largest.bytes ? current : largest,
);
} else {
// If no matching files, select the largest MP4
console.log(`No matching files found, selecting largest MP4`);
selectedFile = mp4Files.reduce((largest, current) => (current.bytes > largest.bytes ? current : largest));
}
}
const largestMp4 = selectedFile;
// Select only this specific file
await selectSpecificFile(torrentId, largestMp4.id, token, ctx);
console.log('Selected specific file:', largestMp4.id);
} else {
// If no MP4 files, select all files
await selectAllFiles(torrentId, token, ctx);
console.log('Selected all files');
}
} else {
// If no file info available, select all files
await selectAllFiles(torrentId, token, ctx);
console.log('Selected all files');
}
} else if (torrentInfo.status !== 'downloaded') {
// For any other non-completed status, select all files
await selectAllFiles(torrentId, token, ctx);
console.log('Selected all files');
}
// Wait for the torrent to be processed
let attempts = 0;
const maxAttempts = 30; // 60 seconds max wait time (2s * 30)
const validCompletedStatuses = ['downloaded', 'ready'];
const errorStatuses = ['error', 'virus', 'dead', 'magnet_error', 'magnet_conversion'];
console.log('Waiting for torrent to be processed...');
while (attempts < maxAttempts) {
torrentInfo = await getTorrentInfo(torrentId, token, ctx);
console.log(
`Torrent status (attempt ${attempts + 1}/${maxAttempts}): ${torrentInfo.status}, progress: ${torrentInfo.progress}%`,
);
// Check if torrent is ready
if (validCompletedStatuses.includes(torrentInfo.status) && torrentInfo.links && torrentInfo.links.length > 0) {
let targetLink = torrentInfo.links[0];
// If there are files, try to find the largest MP4
if (torrentInfo.files) {
const mp4Files = torrentInfo.files.filter(
(file) => file.path.toLowerCase().endsWith('.mp4') && file.selected === 1,
);
if (mp4Files.length > 0) {
console.log(`Found ${mp4Files.length} MP4 files, selecting largest`);
const largestMp4 = mp4Files.reduce((largest, current) =>
current.bytes > largest.bytes ? current : largest,
);
const linkIndex = torrentInfo.files.findIndex((f) => f.id === largestMp4.id);
if (linkIndex !== -1 && torrentInfo.links[linkIndex]) {
targetLink = torrentInfo.links[linkIndex];
console.log(`Selected largest MP4: ${largestMp4.path}, size: ${largestMp4.bytes} bytes`);
}
} else {
console.log('No MP4 files found, using first available link');
}
}
// Unrestrict the link
const unrestrictedData = await unrestrictLink(targetLink, token, ctx);
return unrestrictedData.download;
}
// Check for error states
if (errorStatuses.includes(torrentInfo.status)) {
throw new NotFoundError(`Torrent processing failed with status: ${torrentInfo.status}`);
}
// If torrent is stuck in downloading with 0% progress for a while, try to re-select files
if (torrentInfo.status === 'downloading' && torrentInfo.progress === 0 && attempts > 5 && attempts % 5 === 0) {
console.log('Torrent seems stuck at 0%, trying to re-select files...');
// Get fresh torrent info
torrentInfo = await getTorrentInfo(torrentId, token, ctx);
if (torrentInfo.files && torrentInfo.files.length > 0) {
const mp4Files = torrentInfo.files.filter((file) => file.path.toLowerCase().endsWith('.mp4'));
if (mp4Files.length > 0) {
const largestMp4 = mp4Files.reduce((largest, current) =>
current.bytes > largest.bytes ? current : largest,
);
// Re-select this specific file
await selectSpecificFile(torrentId, largestMp4.id, token, ctx);
} else {
// If no MP4 files, re-select all files
await selectAllFiles(torrentId, token, ctx);
}
} else {
// If no file info available, re-select all files
await selectAllFiles(torrentId, token, ctx);
}
}
// Special case: if we have reached 100% progress but status isn't "downloaded" yet
if (torrentInfo.progress === 100 && attempts > 10) {
console.log('Torrent is at 100% but status is not completed. Checking for links anyway...');
if (torrentInfo.links && torrentInfo.links.length > 0) {
const unrestrictedData = await unrestrictLink(torrentInfo.links[0], token, ctx);
return unrestrictedData.download;
}
}
// Wait 2 seconds before checking again
// eslint-disable-next-line no-await-in-loop
await new Promise((resolve) => {
setTimeout(resolve, 2000);
});
attempts++;
}
throw new NotFoundError(`Timeout waiting for torrent to be processed after ${maxAttempts * 2} seconds`);
} catch (error: unknown) {
if (error instanceof NotFoundError) throw error;
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
throw new NotFoundError(`Error processing magnet link: ${errorMessage}`);
}
}

View file

@ -0,0 +1,159 @@
/* eslint-disable no-console */
import { flags } from '@/entrypoint/utils/targets';
import { SourcererOutput, makeSourcerer } from '@/providers/base';
import { MovieScrapeContext, ShowScrapeContext } from '@/utils/context';
import { NotFoundError } from '@/utils/errors';
import { getUnrestrictedLink } from './debrid';
import { getTorrents } from './torrentio';
const OVERRIDE_TOKEN = '';
const getRealDebridToken = (): string | null => {
try {
if (OVERRIDE_TOKEN) return OVERRIDE_TOKEN;
} catch {
// Ignore
}
try {
if (typeof window === 'undefined') return null;
const prefData = window.localStorage.getItem('__MW::preferences');
if (!prefData) return null;
const parsedAuth = JSON.parse(prefData);
return parsedAuth?.state?.realDebridKey || null;
} catch (e) {
console.error('Error getting RealDebrid token:', e);
return null;
}
};
async function comboScraper(ctx: ShowScrapeContext | MovieScrapeContext): Promise<SourcererOutput> {
const apiToken = getRealDebridToken();
if (!apiToken) {
throw new NotFoundError('RealDebrid API token is required');
}
if (!ctx.media.imdbId) {
throw new NotFoundError('IMDB ID required');
}
// Get torrent magnet links from Torrentio
const streams = await getTorrents(ctx);
// console.log('streams', streams);
ctx.progress(20);
// Process each magnet link through RealDebrid in batches
const maxConcurrentQualities = 2;
const qualities = Object.keys(streams);
const processedStreams: Array<{ quality: string; url: string } | null> = [];
// Process qualities in batches to avoid too many concurrent requests
for (let i = 0; i < qualities.length; i += maxConcurrentQualities) {
const batch = qualities.slice(i, i + maxConcurrentQualities);
// progress
const progressStart = 40;
const progressEnd = 90;
const progressPerBatch = (progressEnd - progressStart) / Math.ceil(qualities.length / maxConcurrentQualities);
const currentBatchProgress = progressStart + progressPerBatch * (i / maxConcurrentQualities);
ctx.progress(Math.round(currentBatchProgress));
const batchPromises = batch.map((quality) => {
const magnetUrl = streams[quality];
return getUnrestrictedLink(magnetUrl, apiToken, ctx)
.then((downloadUrl) => ({ quality, url: downloadUrl }))
.catch((error) => {
console.error(`Failed to process ${quality} stream:`, error);
return null;
});
});
if (batchPromises.length > 0) {
const batchResults = await Promise.all(batchPromises);
processedStreams.push(...batchResults);
}
}
// Filter out failed streams and create quality map
const filteredStreams = processedStreams
.filter((stream): stream is { quality: string; url: string } => stream !== null)
.filter((stream) => stream.url.toLowerCase().endsWith('.mp4')) // only mp4
.reduce((acc: Record<string, string>, { quality, url }) => {
// Normalize quality format for the output
let qualityKey: number | string;
if (quality === '4K') {
qualityKey = 2160;
} else {
qualityKey = parseInt(quality.replace('P', ''), 10);
}
if (Number.isNaN(qualityKey)) qualityKey = 'unknown';
acc[qualityKey] = url;
return acc;
}, {});
if (Object.keys(filteredStreams).length === 0) {
throw new NotFoundError('No suitable streams found');
}
ctx.progress(100);
return {
stream: [
{
id: 'primary',
captions: [],
qualities: {
...(filteredStreams[2160] && {
'4k': {
type: 'mp4',
url: filteredStreams[2160],
},
}),
...(filteredStreams[1080] && {
1080: {
type: 'mp4',
url: filteredStreams[1080],
},
}),
...(filteredStreams[720] && {
720: {
type: 'mp4',
url: filteredStreams[720],
},
}),
...(filteredStreams[480] && {
480: {
type: 'mp4',
url: filteredStreams[480],
},
}),
...(filteredStreams[360] && {
360: {
type: 'mp4',
url: filteredStreams[360],
},
}),
...(filteredStreams.unknown && {
unknown: {
type: 'mp4',
url: filteredStreams.unknown,
},
}),
},
type: 'file',
flags: [flags.CORS_ALLOWED],
},
],
embeds: [],
};
}
export const realDebridScraper = makeSourcerer({
id: 'realdebrid',
name: 'RealDebrid (Beta)',
rank: 280,
disabled: !getRealDebridToken(),
flags: [flags.CORS_ALLOWED],
scrapeMovie: comboScraper,
scrapeShow: comboScraper,
});

View file

@ -0,0 +1,151 @@
/* eslint-disable no-console */
import { MovieScrapeContext, ShowScrapeContext } from '@/utils/context';
import { NotFoundError } from '@/utils/errors';
interface TorrentStream {
name: string;
title: string;
url: string;
infoHash?: string;
sources?: string[];
behaviorHints?: {
bingeGroup?: string;
countryWhitelist?: string[];
filename?: string;
};
}
export interface QualityTorrents {
[quality: string]: string;
}
// Filter out cam/ts/screener versions
function isAcceptableQuality(torrentName: string): boolean {
const lowerName = torrentName.toLowerCase();
const bannedTerms = [
'cam',
'camrip',
'hdcam',
'ts',
'telesync',
'hdts',
'dvdscr',
'screener',
'scr',
'r5',
'workprint',
];
return !bannedTerms.some((term) => lowerName.includes(term));
}
// Extract quality from torrent name
function extractQuality(torrentName: string): string {
const name = torrentName.toLowerCase();
if (name.includes('2160p') || name.includes('4k')) return '4K';
if (name.includes('1080p')) return '1080P';
if (name.includes('720p')) return '720P';
if (name.includes('480p')) return '480P';
if (name.includes('360p')) return '360P';
return 'unknown';
}
// Process torrents and group by quality
function processTorrents(streams: TorrentStream[]): QualityTorrents {
// Filter out bad quality torrents
const goodQualityStreams = streams.filter((stream) => isAcceptableQuality(stream.name));
const filteredStreams = goodQualityStreams.filter(
(stream) =>
stream.title?.toLowerCase().includes('mp4') || stream.behaviorHints?.filename?.toLowerCase().includes('mp4'),
);
if (filteredStreams.length === 0) {
throw new NotFoundError('No usable torrents found');
}
// if (filteredStreams.length > 0) {
// console.log('sample stream:', JSON.stringify(filteredStreams[0], null, 2)); // eslint-disable-line no-console
// }
// Group torrents by quality
const qualityGroups: { [quality: string]: TorrentStream[] } = {};
for (const stream of filteredStreams) {
const quality = extractQuality(stream.name);
if (!qualityGroups[quality]) {
qualityGroups[quality] = [];
}
qualityGroups[quality].push(stream);
}
// Select the best torrent for each quality (we just pick the first one)
const result: QualityTorrents = {};
for (const [quality, torrentStreams] of Object.entries(qualityGroups)) {
if (torrentStreams.length > 0) {
// Check if the URL is a magnet link, if not create one using infoHash if available
const stream = torrentStreams[0];
let magnetUrl = stream.url;
if (!magnetUrl && stream.infoHash) {
// Create a magnet link from the infoHash
magnetUrl = `magnet:?xt=urn:btih:${stream.infoHash}&dn=${encodeURIComponent(stream.name)}`;
}
if (magnetUrl) {
result[quality] = magnetUrl;
}
}
}
console.log('processed qualities:', Object.keys(result));
for (const [quality, url] of Object.entries(result)) {
console.log(`${quality}: ${url.substring(0, 30)}...`);
}
return result;
}
// Function to get torrents from Torrentio service
export async function getTorrents(ctx: MovieScrapeContext | ShowScrapeContext): Promise<QualityTorrents> {
if (!ctx.media.imdbId) {
throw new NotFoundError('IMDB ID required');
}
const imdbId = ctx.media.imdbId;
let searchPath: string;
if (ctx.media.type === 'show') {
const seasonNumber = ctx.media.season.number;
const episodeNumber = ctx.media.episode.number;
searchPath = `series/${imdbId}:${seasonNumber}:${episodeNumber}.json`;
} else {
searchPath = `movie/${imdbId}.json`;
}
try {
const torrentioUrl = `https://torrentio.strem.fun/providers=yts,eztv,rarbg,1337x,thepiratebay,kickasstorrents,torrentgalaxy,magnetdl/stream/${searchPath}`;
console.log('Fetching torrents from:', torrentioUrl);
const response = await ctx.fetcher.full(torrentioUrl);
if (response.statusCode !== 200) {
throw new NotFoundError(`Failed to fetch torrents: ${response.statusCode} ${response.body}`);
}
console.log('Found torrents:', response.body.streams?.length || 0);
if (!response.body.streams || response.body.streams.length === 0) {
throw new NotFoundError('No streams found');
}
// Process and group torrents by quality
return processTorrents(response.body.streams);
} catch (error: unknown) {
if (error instanceof NotFoundError) throw error;
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
throw new NotFoundError(`Error fetching torrents: ${errorMessage}`);
}
}

View file

@ -0,0 +1,95 @@
/* eslint-disable no-console */
import CryptoJS from 'crypto-js';
import { flags } from '@/entrypoint/utils/targets';
import { SourcererEmbed, SourcererOutput, makeSourcerer } from '@/providers/base';
import { MovieScrapeContext } from '@/utils/context';
import { NotFoundError } from '@/utils/errors';
const baseUrl = 'https://vidjoy.pro';
const decryptionKey = '029f3936fb744c4512e66d3a8150c6129472ccdff5b0dd5ec6e512fc06194ef1';
async function comboScraper(ctx: MovieScrapeContext): Promise<SourcererOutput> {
let apiUrl = `${baseUrl}/embed/api/fastfetch2/${ctx.media.tmdbId}?sr=0`;
let streamRes = await ctx.proxiedFetcher.full(apiUrl, {
method: 'GET',
headers: {
referer: 'https://vidjoy.pro/',
origin: 'https://vidjoy.pro',
'User-Agent':
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
},
});
if (streamRes.statusCode !== 200) {
apiUrl = `${baseUrl}/embed/api/fetch2/${ctx.media.tmdbId}?srName=Modread&sr=0`;
streamRes = await ctx.proxiedFetcher.full(apiUrl, {
method: 'GET',
headers: {
referer: 'https://vidjoy.pro/',
origin: 'https://vidjoy.pro',
'User-Agent':
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
},
});
}
if (streamRes.statusCode !== 200) {
throw new NotFoundError('Failed to fetch video source from both endpoints');
}
ctx.progress(50);
const encryptedData = streamRes.body;
const decrypted = CryptoJS.AES.decrypt(encryptedData, decryptionKey).toString(CryptoJS.enc.Utf8);
if (!decrypted) {
throw new NotFoundError('Failed to decrypt video source');
}
ctx.progress(70);
let parsedData;
try {
parsedData = JSON.parse(decrypted);
} catch (error) {
console.error('JSON parsing error:', error);
console.error('Decrypted data:', decrypted.substring(0, 200));
throw new NotFoundError('Failed to parse decrypted video data');
}
if (!parsedData.url || !Array.isArray(parsedData.url) || parsedData.url.length === 0) {
throw new NotFoundError('No video URLs found in response');
}
ctx.progress(90);
const embeds: SourcererEmbed[] = [];
// Create embeds for each available stream
parsedData.url.forEach((urlData: any, index: number) => {
embeds.push({
embedId: `vidjoy-stream${index + 1}`,
url: JSON.stringify({
link: urlData.link,
type: urlData.type || 'hls',
lang: urlData.lang || 'English',
headers: parsedData.headers || {},
}),
});
});
return {
embeds,
};
}
export const vidjoyScraper = makeSourcerer({
id: 'vidjoy',
name: 'vidjoy 🔥',
rank: 185,
disabled: true,
flags: [flags.CORS_ALLOWED],
scrapeMovie: comboScraper,
});

View file

@ -0,0 +1,142 @@
import crypto from 'crypto-js';
import { flags } from '@/entrypoint/utils/targets';
import { makeEmbed } from '@/providers/base';
import { Caption, getCaptionTypeFromUrl, labelToLanguageCode } from '@/providers/captions';
const origin = 'https://rabbitstream.net';
const referer = 'https://rabbitstream.net/';
const { AES, enc } = crypto;
interface StreamRes {
server: number;
sources: string;
tracks: {
file: string;
kind: 'captions' | 'thumbnails';
label: string;
}[];
}
function isJSON(json: string) {
try {
JSON.parse(json);
return true;
} catch {
return false;
}
}
/*
example script segment:
switch(I9){case 0x0:II=X,IM=t;break;case 0x1:II=b,IM=D;break;case 0x2:II=x,IM=f;break;case 0x3:II=S,IM=j;break;case 0x4:II=U,IM=G;break;case 0x5:II=partKeyStartPosition_5,IM=partKeyLength_5;}
*/
function extractKey(script: string): [number, number][] | null {
const startOfSwitch = script.lastIndexOf('switch');
const endOfCases = script.indexOf('partKeyStartPosition');
const switchBody = script.slice(startOfSwitch, endOfCases);
const nums: [number, number][] = [];
const matches = switchBody.matchAll(/:[a-zA-Z0-9]+=([a-zA-Z0-9]+),[a-zA-Z0-9]+=([a-zA-Z0-9]+);/g);
for (const match of matches) {
const innerNumbers: number[] = [];
for (const varMatch of [match[1], match[2]]) {
const regex = new RegExp(`${varMatch}=0x([a-zA-Z0-9]+)`, 'g');
const varMatches = [...script.matchAll(regex)];
const lastMatch = varMatches[varMatches.length - 1];
if (!lastMatch) return null;
const number = parseInt(lastMatch[1], 16);
innerNumbers.push(number);
}
nums.push([innerNumbers[0], innerNumbers[1]]);
}
return nums;
}
export const upcloudScraper = makeEmbed({
id: 'upcloud',
name: 'UpCloud',
rank: 200,
disabled: true,
flags: [flags.CORS_ALLOWED],
async scrape(ctx) {
// Example url: https://dokicloud.one/embed-4/{id}?z=
const parsedUrl = new URL(ctx.url.replace('embed-5', 'embed-4'));
const dataPath = parsedUrl.pathname.split('/');
const dataId = dataPath[dataPath.length - 1];
const streamRes = await ctx.proxiedFetcher<StreamRes>(`${parsedUrl.origin}/ajax/embed-4/getSources?id=${dataId}`, {
headers: {
Referer: parsedUrl.origin,
'X-Requested-With': 'XMLHttpRequest',
},
});
let sources: { file: string; type: string } | null = null;
if (!isJSON(streamRes.sources)) {
const scriptJs = await ctx.proxiedFetcher<string>(`https://rabbitstream.net/js/player/prod/e4-player.min.js`, {
query: {
// browser side caching on this endpoint is quite extreme. Add version query paramter to circumvent any caching
v: Date.now().toString(),
},
});
const decryptionKey = extractKey(scriptJs);
if (!decryptionKey) throw new Error('Key extraction failed');
let extractedKey = '';
let strippedSources = streamRes.sources;
let totalledOffset = 0;
decryptionKey.forEach(([a, b]) => {
const start = a + totalledOffset;
const end = start + b;
extractedKey += streamRes.sources.slice(start, end);
strippedSources = strippedSources.replace(streamRes.sources.substring(start, end), '');
totalledOffset += b;
});
const decryptedStream = AES.decrypt(strippedSources, extractedKey).toString(enc.Utf8);
const parsedStream = JSON.parse(decryptedStream)[0];
if (!parsedStream) throw new Error('No stream found');
sources = parsedStream;
}
if (!sources) throw new Error('upcloud source not found');
const captions: Caption[] = [];
streamRes.tracks.forEach((track) => {
if (track.kind !== 'captions') return;
const type = getCaptionTypeFromUrl(track.file);
if (!type) return;
const language = labelToLanguageCode(track.label.split(' ')[0]);
if (!language) return;
captions.push({
id: track.file,
language,
hasCorsRestrictions: false,
type,
url: track.file,
});
});
return {
stream: [
{
id: 'primary',
type: 'hls',
playlist: sources.file,
flags: [flags.CORS_ALLOWED],
captions,
preferredHeaders: {
Referer: referer,
Origin: origin,
},
},
],
};
},
});

Binary file not shown.

View file

@ -50,6 +50,9 @@ export const filelionsScraper = makeEmbed({
id: 'primary',
type: 'hls',
playlist: streamUrl,
headers: {
Referer: 'https://primesrc.me/',
},
flags: [],
captions: [],
},

View file

@ -1,7 +1,6 @@
import { load } from 'cheerio';
import { unpack } from 'unpacker';
import { flags } from '@/entrypoint/utils/targets';
import { makeEmbed } from '@/providers/base';
import { NotFoundError } from '@/utils/errors';
@ -12,7 +11,7 @@ export const filemoonScraper = makeEmbed({
id: 'filemoon',
name: 'Filemoon',
rank: 405,
flags: [flags.CORS_ALLOWED],
flags: [],
async scrape(ctx) {
const headers = {
Accept:
@ -68,13 +67,8 @@ export const filemoonScraper = makeEmbed({
stream: [
{
id: 'primary',
type: 'file',
qualities: {
unknown: {
type: 'mp4',
url: videoUrl,
},
},
type: 'hls',
playlist: videoUrl,
headers: {
Referer: `${new URL(ctx.url).origin}/`,
'User-Agent': userAgent,

View file

@ -0,0 +1,203 @@
import { load } from 'cheerio';
import { flags } from '@/entrypoint/utils/targets';
import { makeEmbed } from '@/providers/base';
import { EmbedScrapeContext } from '@/utils/context';
import { NotFoundError } from '@/utils/errors';
import { createM3U8ProxyUrl } from '@/utils/proxy';
export const streambucketScraper = makeEmbed({
id: 'streambucket',
name: 'Streambucket',
rank: 220,
disabled: true,
flags: [flags.CORS_ALLOWED],
async scrape(ctx: EmbedScrapeContext) {
// Handle redirects for multiembed/streambucket URLs
let baseUrl = ctx.url;
if (baseUrl.includes('multiembed') || baseUrl.includes('ghostplayer')) {
const redirectResp = await ctx.proxiedFetcher.full(baseUrl);
baseUrl = redirectResp.finalUrl;
}
const userAgent =
'Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Mobile Safari/537.36';
ctx.progress(20);
// Prepare POST data for requesting the page
const data = {
'button-click': 'ZEhKMVpTLVF0LVBTLVF0LVAtMGs1TFMtUXpPREF0TC0wLVYzTi0wVS1RTi0wQTFORGN6TmprLTU=',
'button-referer': '',
};
// Send POST request to fetch initial response
const postResp = await ctx.proxiedFetcher<string>(baseUrl, {
method: 'POST',
body: new URLSearchParams(data),
headers: {
Referer: 'https://multiembed.mov',
'X-Requested-With': 'XMLHttpRequest',
'User-Agent': userAgent,
'Content-Type': 'application/x-www-form-urlencoded',
},
});
ctx.progress(40);
// Extract the session token required to fetch sources
const tokenMatch = postResp.match(/load_sources\("([^"]+)"\)/);
if (!tokenMatch) throw new NotFoundError('Session token not found');
const token = tokenMatch[1];
// Request the sources list using the extracted token
const sourcesResp = await ctx.proxiedFetcher<string>('https://streamingnow.mov/response.php', {
method: 'POST',
body: new URLSearchParams({ token }),
headers: {
Referer: baseUrl,
'X-Requested-With': 'XMLHttpRequest',
'User-Agent': userAgent,
'Content-Type': 'application/x-www-form-urlencoded',
},
});
ctx.progress(60);
const $ = load(sourcesResp);
// Try to find VIP source first, then fallback to other sources
let selectedSource = $('li')
.filter((_, el) => {
const text = $(el).text().toLowerCase();
return text.includes('vipstream-s');
})
.first();
// If no VIP, try other high-quality sources
if (!selectedSource.length) {
// Try vidsrc first as fallback
selectedSource = $('li')
.filter((_, el) => {
const text = $(el).text().toLowerCase();
return text.includes('vidsrc');
})
.first();
// If no vidsrc, try any stream source
if (!selectedSource.length) {
selectedSource = $('li').first();
}
}
if (!selectedSource.length) {
throw new NotFoundError('No streams available');
}
// Extract server and video IDs from the selected source element
const serverId = selectedSource.attr('data-server');
const videoId = selectedSource.attr('data-id');
if (!serverId || !videoId) {
throw new NotFoundError('Server or video ID not found');
}
ctx.progress(70);
// Fetch VIP streaming page HTML
const vipUrl = `https://streamingnow.mov/playvideo.php?video_id=${videoId}&server_id=${serverId}&token=${token}&init=1`;
const vipResp = await ctx.proxiedFetcher<string>(vipUrl, {
headers: {
Referer: baseUrl,
'User-Agent': userAgent,
},
});
ctx.progress(80);
// Check if response contains CAPTCHA (anti-bot protection)
const hasCaptcha =
vipResp.includes('captcha') || vipResp.includes('Verification') || vipResp.includes('Select all images');
if (hasCaptcha) {
throw new NotFoundError('Stream protected by CAPTCHA');
}
// Look for direct video file URL in the response
const directVideoMatch =
vipResp.match(/file:"(https?:\/\/[^"]+)"/) ||
vipResp.match(/"(https?:\/\/[^"]+\.m3u8[^"]*)"/) ||
vipResp.match(/"(https?:\/\/[^"]+\.mp4[^"]*)"/);
if (directVideoMatch) {
const videoUrl = directVideoMatch[1];
// Extract domain for referer
const urlObj = new URL(baseUrl);
const defaultDomain = `${urlObj.protocol}//${urlObj.host}`;
const headers = {
Referer: defaultDomain,
'User-Agent': userAgent,
};
return {
stream: [
{
id: 'primary',
type: 'hls',
playlist: createM3U8ProxyUrl(videoUrl, { requires: [flags.CORS_ALLOWED], disallowed: [] }, headers),
headers,
flags: [flags.CORS_ALLOWED],
captions: [],
},
],
};
}
// Fallback to iframe method
const vip$ = load(vipResp);
const iframe = vip$('iframe.source-frame.show');
if (!iframe.length) throw new NotFoundError('Video iframe not found');
const iframeUrl = iframe.attr('src');
if (!iframeUrl) throw new NotFoundError('Iframe URL not found');
// Get video page
const videoResp = await ctx.proxiedFetcher<string>(iframeUrl, {
headers: {
Referer: vipUrl,
'User-Agent': userAgent,
},
});
ctx.progress(90);
// Extract video URL
const videoMatch = videoResp.match(/file:"(https?:\/\/[^"]+)"/);
if (!videoMatch) throw new NotFoundError('Video URL not found');
const videoUrl = videoMatch[1];
// Extract domain for referer
const urlObj = new URL(baseUrl);
const defaultDomain = `${urlObj.protocol}//${urlObj.host}`;
const headers = {
Referer: defaultDomain,
'User-Agent': userAgent,
};
return {
stream: [
{
id: 'primary',
type: 'hls',
playlist: createM3U8ProxyUrl(videoUrl, { requires: [flags.CORS_ALLOWED], disallowed: [] }, headers),
headers,
flags: [flags.CORS_ALLOWED],
captions: [],
},
],
};
},
});

Binary file not shown.

View file

@ -194,7 +194,7 @@ export const animeflvScraper = makeSourcerer({
id: 'animeflv',
name: 'AnimeFLV',
rank: 90,
disabled: true,
disabled: false,
flags: [flags.CORS_ALLOWED],
scrapeShow: comboScraper,
scrapeMovie: comboScraper,

View file

@ -1,85 +0,0 @@
import { flags } from '@/entrypoint/utils/targets';
import { SourcererOutput, makeSourcerer } from '@/providers/base';
import { MovieScrapeContext, ShowScrapeContext } from '@/utils/context';
// const baseUrl = atob('aHR0cHM6Ly9jaW5lbWFvcy12My52ZXJjZWwuYXBwLw==');
const CINEMAOS_SERVERS = [
// 'flowcast',
'shadow',
'asiacloud',
// 'hindicast',
// 'anime',
// 'animez',
// 'guard',
// 'hq',
// 'ninja',
// 'alpha',
// 'kaze',
// 'zenith',
// 'cast',
// 'ghost',
// 'halo',
// 'kinoecho',
// 'ee3',
// 'volt',
// 'putafilme',
'ophim',
// 'kage',
];
async function comboScraper(ctx: ShowScrapeContext | MovieScrapeContext): Promise<SourcererOutput> {
const embeds = [];
const query: any = {
type: ctx.media.type,
tmdbId: ctx.media.tmdbId,
};
if (ctx.media.type === 'show') {
query.season = ctx.media.season.number;
query.episode = ctx.media.episode.number;
}
// // V2 Embeds / Hexa
// try {
// const hexaUrl = `api/hexa?type=${query.type}&tmdbId=${query.tmdbId}`;
// const hexaRes = await ctx.proxiedFetcher(hexaUrl, { baseUrl });
// const hexaData = typeof hexaRes === 'string' ? JSON.parse(hexaRes) : hexaRes;
// if (hexaData && hexaData.sources && typeof hexaData.sources === 'object') {
// for (const [key, value] of Object.entries<any>(hexaData.sources)) {
// if (value && value.url) {
// embeds.push({
// embedId: `cinemaos-hexa-${key}`,
// url: JSON.stringify({ ...query, service: `hexa-${key}`, directUrl: value.url }),
// });
// }
// }
// }
// } catch (e: any) {
// // eslint-disable-next-line no-console
// console.error('Failed to fetch hexa sources');
// }
// V3 Embeds
for (const server of CINEMAOS_SERVERS) {
embeds.push({
embedId: `cinemaos-${server}`,
url: JSON.stringify({ ...query, service: server }),
});
}
ctx.progress(50);
return { embeds };
}
export const cinemaosScraper = makeSourcerer({
id: 'cinemaos',
name: 'CinemaOS',
rank: 149,
disabled: true,
flags: [flags.CORS_ALLOWED],
scrapeMovie: comboScraper,
scrapeShow: comboScraper,
});

View file

@ -0,0 +1,73 @@
import { flags } from '@/entrypoint/utils/targets';
import { SourcererOutput, makeSourcerer } from '@/providers/base';
import { ShowScrapeContext } from '@/utils/context';
import { InfoResponse, SearchResponse } from './types';
async function consumetScraper(ctx: ShowScrapeContext): Promise<SourcererOutput> {
// Search
const searchQuery = ctx.media.title;
const page = 1;
const searchUrl = `https://api.1anime.app/anime/zoro/${encodeURIComponent(searchQuery)}?page=${page}`;
const searchResponse = await ctx.fetcher<SearchResponse>(searchUrl);
if (!searchResponse?.results?.length) {
throw new Error('No results found');
}
const bestMatch =
searchResponse.results.find((result) => result.title.toLowerCase() === ctx.media.title.toLowerCase()) ||
searchResponse.results[0];
// Get episode list
const infoUrl = `https://api.1anime.app/anime/zoro/info?id=${bestMatch.id}`;
const infoResponse = await ctx.fetcher<InfoResponse>(infoUrl);
if (!infoResponse?.episodes?.length) {
throw new Error('No episodes found');
}
const targetEpisode = infoResponse.episodes.find((ep) => ep.number === ctx.media.episode.number);
if (!targetEpisode) {
throw new Error('Episode not found');
}
// Parse embeds
const query = {
episodeId: `${bestMatch.id}$${ctx.media.season.number}$${targetEpisode.id}$both`,
};
const embeds = [
{
embedId: 'consumet-vidcloud',
url: JSON.stringify({ ...query, server: 'vidcloud' }),
},
{
embedId: 'consumet-streamsb',
url: JSON.stringify({ ...query, server: 'streamsb' }),
},
{
embedId: 'consumet-vidstreaming',
url: JSON.stringify({ ...query, server: 'vidstreaming' }),
},
{
embedId: 'consumet-streamtape',
url: JSON.stringify({ ...query, server: 'streamtape' }),
},
];
return {
embeds,
};
}
export const ConsumetScraper = makeSourcerer({
id: 'consumet',
name: 'Consumet (Anime) 🔥',
rank: 5,
disabled: true,
flags: [flags.CORS_ALLOWED],
scrapeShow: consumetScraper,
});

View file

@ -0,0 +1,36 @@
export interface SearchResult {
id: string;
title: string;
image: string;
releaseDate: string | null;
subOrDub: 'sub' | 'dub';
}
export interface SearchResponse {
totalPages: number;
currentPage: number;
hasNextPage: boolean;
results: SearchResult[];
}
export interface Episode {
id: string;
number: number;
url: string;
}
export interface InfoResponse {
id: string;
title: string;
url: string;
image: string;
releaseDate: string | null;
description: string | null;
genres: string[];
subOrDub: 'sub' | 'dub';
type: string | null;
status: string;
otherName: string | null;
totalEpisodes: number;
episodes: Episode[];
}

View file

@ -0,0 +1,42 @@
import { MovieScrapeContext, ShowScrapeContext } from '@/utils/context';
import { getAddonStreams, parseStreamData } from './helpers';
import { DebridParsedStream, debridProviders } from './types';
export async function getCometStreams(
token: string,
debridProvider: debridProviders,
ctx: MovieScrapeContext | ShowScrapeContext,
): Promise<DebridParsedStream[]> {
const cometBaseUrl = 'https://comet.elfhosted.com'; // Free instance sponsored by ElfHosted, but you can customize it to your liking.
// If you're unfamiliar with Stremio addons, basically stremio addons are just api endpoints, and so they have to encode the config in the url to be able to have a config that works with stremio
// So this just constructs the user's config for Comet. It could be customized to your liking as well!
const cometConfig = btoa(
JSON.stringify({
maxResultsPerResolution: 0,
maxSize: 0,
cachedOnly: false,
removeTrash: true,
resultFormat: ['all'],
debridService: debridProvider,
debridApiKey: token,
debridStreamProxyPassword: '',
languages: { exclude: [], preferred: ['en'] },
resolutions: {},
options: { remove_ranks_under: -10000000000, allow_english_in_languages: false, remove_unknown_languages: false },
}),
);
const cometStreamsRaw = (await getAddonStreams(`${cometBaseUrl}/${cometConfig}`, ctx)).streams;
const newStreams: { title: string; url: string }[] = [];
for (let i = 0; i < cometStreamsRaw.length; i++) {
if (cometStreamsRaw[i].description !== undefined)
newStreams.push({
title: (cometStreamsRaw[i].description as string).replace(/\n/g, ''),
url: cometStreamsRaw[i].url,
});
}
const parsedData = await parseStreamData(newStreams, ctx);
return parsedData;
}

View file

@ -0,0 +1,48 @@
// Helpers for Stremio addon API Formats
import { MovieScrapeContext, ShowScrapeContext } from '@/utils/context';
import { DebridParsedStream, stremioStream } from './types';
export async function getAddonStreams(
addonUrl: string,
ctx: MovieScrapeContext | ShowScrapeContext,
): Promise<{ streams: stremioStream[] }> {
if (!ctx.media.imdbId) {
throw new Error('Error: ctx.media.imdbId is required.');
}
let addonResponse: { streams: stremioStream[] } | undefined;
if (ctx.media.type === 'show') {
addonResponse = await ctx.proxiedFetcher(
`${addonUrl}/stream/series/${ctx.media.imdbId}:${ctx.media.season.number}:${ctx.media.episode.number}.json`,
);
} else {
addonResponse = await ctx.proxiedFetcher(`${addonUrl}/stream/movie/${ctx.media.imdbId}.json`);
}
if (!addonResponse) {
throw new Error('Error: addon did not respond');
}
return addonResponse;
}
interface StreamInput {
title: string;
url: string;
[key: string]: any;
}
export async function parseStreamData(
streams: StreamInput[],
ctx: MovieScrapeContext | ShowScrapeContext,
): Promise<DebridParsedStream[]> {
return ctx.proxiedFetcher('https://torrent-parse.pstream.mov', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(streams),
});
}

View file

@ -4,10 +4,12 @@ import { SourcererOutput, makeSourcerer } from '@/providers/base';
import { MovieScrapeContext, ShowScrapeContext } from '@/utils/context';
import { NotFoundError } from '@/utils/errors';
import { debridProviders, torrentioResponse } from './types';
import { getCometStreams } from './comet';
import { getAddonStreams, parseStreamData } from './helpers';
import { DebridParsedStream, debridProviders } from './types';
const OVERRIDE_TOKEN = '';
const OVERRIDE_SERVICE = ''; // torbox or realdebrid (or real-debrid)
const OVERRIDE_SERVICE: debridProviders | '' = ''; // torbox or realdebrid (or real-debrid)
const getDebridToken = (): string | null => {
try {
@ -48,27 +50,6 @@ const getDebridService = (): debridProviders => {
}
};
type DebridParsedStream = {
resolution?: string;
year?: number;
source?: string;
bitDepth?: string;
codec?: string;
audio?: string;
container?: string;
seasons?: number[];
season?: number;
episodes?: number[];
episode?: number;
complete?: boolean;
unrated?: boolean;
remastered?: boolean;
languages?: string[];
dubbed?: boolean;
title: string;
url: string;
};
function normalizeQuality(resolution?: string): '4k' | 1080 | 720 | 480 | 360 | 'unknown' {
if (!resolution) return 'unknown';
const res = resolution.toLowerCase();
@ -104,51 +85,46 @@ async function comboScraper(ctx: ShowScrapeContext | MovieScrapeContext): Promis
const debridProvider: debridProviders = getDebridService();
let torrentioUrl = `https://torrentio.strem.fun/${debridProvider}=${apiKey}/stream/`;
if (ctx.media.type === 'show') {
torrentioUrl += `series/${ctx.media.imdbId}:${ctx.media.season.number}:${ctx.media.episode.number}.json`;
} else {
torrentioUrl += `movie/${ctx.media.imdbId}.json`;
}
const torrentioData = (await ctx.proxiedFetcher(torrentioUrl)) as torrentioResponse;
const torrentioStreams = torrentioData?.streams || [];
if (torrentioStreams.length === 0) {
console.log('No torrents found', torrentioData);
throw new NotFoundError('No torrents found');
}
const [torrentioResult, cometStreams] = await Promise.all([
getAddonStreams(`https://torrentio.strem.fun/${debridProvider}=${apiKey}`, ctx),
getCometStreams(apiKey, debridProvider, ctx).catch(() => {
return [] as DebridParsedStream[];
}),
]);
ctx.progress(33);
const response: DebridParsedStream[] = await ctx.proxiedFetcher('https://torrent-parse.pstream.mov/', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(torrentioStreams),
});
if (response.length === 0) {
console.log('No streams found or parse failed!', response);
const torrentioStreams = await parseStreamData(
torrentioResult.streams.map((s) => ({
...s,
title: s.title ?? '',
})),
ctx,
);
const allStreams = [...torrentioStreams, ...cometStreams];
if (allStreams.length === 0) {
console.log('No streams found from either source!');
throw new NotFoundError('No streams found or parse failed!');
}
console.log(
`Total streams: ${allStreams.length} (${torrentioStreams.length} from Torrentio, ${cometStreams.length} from Comet)`,
);
ctx.progress(66);
// Group by quality, pick the most compatible stream for each
const qualities: Partial<Record<'4k' | 1080 | 720 | 480 | 360 | 'unknown', { type: 'mp4'; url: string }>> = {};
// Group streams by normalized quality
const byQuality: Record<string, DebridParsedStream[]> = {};
for (const stream of response) {
for (const stream of allStreams) {
const quality = normalizeQuality(stream.resolution);
if (!byQuality[quality]) byQuality[quality] = [];
byQuality[quality].push(stream);
}
// For each quality, pick the best compatible stream (prefer mp4+aac, fallback to mkv)
for (const [quality, streams] of Object.entries(byQuality)) {
// Prefer mp4 + aac
const mp4Aac = streams.find((s) => s.container === 'mp4' && s.audio === 'aac');
if (mp4Aac) {
qualities[quality as keyof typeof qualities] = {
@ -157,7 +133,6 @@ async function comboScraper(ctx: ShowScrapeContext | MovieScrapeContext): Promis
};
continue;
}
// Fallback: any mp4
const mp4 = streams.find((s) => s.container === 'mp4');
if (mp4) {
qualities[quality as keyof typeof qualities] = {
@ -166,6 +141,7 @@ async function comboScraper(ctx: ShowScrapeContext | MovieScrapeContext): Promis
};
continue;
}
streams.sort((a, b) => scoreStream(b) - scoreStream(a));
const best = streams[0];
if (best) {

View file

@ -3,7 +3,9 @@ export type debridProviders = 'torbox' | 'real-debrid';
export interface stremioStream {
name: string;
title: string;
description?: string;
title?: string;
url: string;
infoHash: string;
fileIdx: number;
behaviorHints?: {
@ -15,9 +17,31 @@ export interface stremioStream {
sources?: string[];
}
export interface torrentioResponse {
streams: stremioStream[];
export type stremioAddonStreamResponse = Promise<{ streams: stremioStream[]; [key: string]: any }>;
export type torrentioResponse = Awaited<stremioAddonStreamResponse> & {
cacheMaxAge: number;
staleRevalidate: number;
staleError: number;
}
};
export type DebridParsedStream = {
resolution?: string;
year?: number;
source?: string;
bitDepth?: string;
codec?: string;
audio?: string;
container?: string;
seasons?: number[];
season?: number;
episodes?: number[];
episode?: number;
complete?: boolean;
unrated?: boolean;
remastered?: boolean;
languages?: string[];
dubbed?: boolean;
title: string;
url: string;
};

View file

@ -80,7 +80,7 @@ async function comboScraper(ctx: ShowScrapeContext | MovieScrapeContext): Promis
export const dopeboxScraper = makeSourcerer({
id: 'dopebox',
name: 'Dopebox',
rank: 210,
rank: 197,
flags: ['cors-allowed'],
scrapeMovie: comboScraper,
scrapeShow: comboScraper,

View file

@ -90,7 +90,7 @@ async function comboScraper(ctx: ShowScrapeContext | MovieScrapeContext): Promis
export const fsharetvScraper = makeSourcerer({
id: 'fsharetv',
name: 'FshareTV',
rank: 190,
rank: 200,
flags: [],
scrapeMovie: comboScraper,
});

View file

@ -121,7 +121,7 @@ async function comboScraper(ctx: ShowScrapeContext | MovieScrapeContext): Promis
export const fsOnlineScraper = makeSourcerer({
id: 'fsonline',
name: 'FSOnline',
rank: 200,
rank: 189,
flags: ['cors-allowed'],
scrapeMovie: comboScraper,
scrapeShow: comboScraper,

View file

@ -0,0 +1,244 @@
/* eslint-disable no-console */
import { load } from 'cheerio';
import CryptoJS from 'crypto-js';
import { flags } from '@/entrypoint/utils/targets';
import { makeSourcerer } from '@/providers/base';
import { compareMedia } from '@/utils/compare';
import { MovieScrapeContext, ShowScrapeContext } from '@/utils/context';
import { getSetCookieHeader, makeCookieHeader, parseSetCookie } from '@/utils/cookie';
import { NotFoundError } from '@/utils/errors';
import { createM3U8ProxyUrl } from '@/utils/proxy';
let baseUrl = 'https://m4ufree.page';
// AES Decryption Keys
// They rotate these keys occasionally. If it brakes chances are they rotated keys
// To get the keys, go on the site and find the req to something like:
// https://if6.ppzj-youtube.cfd/play/6119b02e9c0163458e94808e/648ff94023c354ba9eb2221d2ec81040.html
// Find the obfuscated script section, deobfuscate with webcrack, you'll find the keys
const KEYS = {
IDFILE_KEY: 'jcLycoRJT6OWjoWspgLMOZwS3aSS0lEn',
IDUSER_KEY: 'PZZ3J3LDbLT0GY7qSA5wW5vchqgpO36O',
REQUEST_KEY: 'vlVbUQhkOhoSfyteyzGeeDzU0BHoeTyZ',
RESPONSE_KEY: 'oJwmvmVBajMaRCTklxbfjavpQO7SZpsL',
MD5_SALT: 'KRWN3AdgmxEMcd2vLN1ju9qKe8Feco5h',
} as const;
function decryptHexToUtf8(encryptedHex: string, key: string): string {
const hexParsed = CryptoJS.enc.Hex.parse(encryptedHex);
const base64String = hexParsed.toString(CryptoJS.enc.Base64);
const decrypted = CryptoJS.AES.decrypt(base64String, key);
const result = decrypted.toString(CryptoJS.enc.Utf8);
return result;
}
function encryptUtf8ToHex(plainText: string, key: string): string {
const encrypted = CryptoJS.AES.encrypt(plainText, key).toString();
const base64Parsed = CryptoJS.enc.Base64.parse(encrypted);
const result = base64Parsed.toString(CryptoJS.enc.Hex);
return result;
}
async function fetchIframeUrl(
ctx: MovieScrapeContext | ShowScrapeContext,
watchPageHtml: string,
csrfToken: string,
cookie: string,
referer: string,
): Promise<string> {
const $ = load(watchPageHtml);
// Movie vs TV handling
let pageHtml = watchPageHtml;
if (ctx.media.type === 'show') {
const seasonPadded = String(ctx.media.season.number).padStart(2, '0');
const episodePadded = String(ctx.media.episode.number).padStart(2, '0');
const idepisode = $(`button:contains("S${seasonPadded}-E${episodePadded}")`).attr('idepisode');
if (!idepisode) throw new NotFoundError('idepisode not found');
// Load TV episode block
pageHtml = await ctx.proxiedFetcher<string>('/ajaxtv', {
baseUrl,
method: 'POST',
body: new URLSearchParams({ idepisode, _token: csrfToken }),
headers: {
Cookie: cookie,
Referer: referer,
'X-Requested-With': 'XMLHttpRequest',
},
});
}
const $$ = load(pageHtml);
const playhqData = $$('#playhq.singlemv.active').attr('data');
if (!playhqData) throw new NotFoundError('playhq data not found');
// Request iframe wrapper
const iframeWrapper = await ctx.proxiedFetcher<string>('/ajax', {
baseUrl,
method: 'POST',
body: new URLSearchParams({ m4u: playhqData, _token: csrfToken }),
headers: {
Cookie: cookie,
Referer: referer,
'X-Requested-With': 'XMLHttpRequest',
},
});
const $$$ = load(iframeWrapper);
const iframeUrl = $$$('iframe').attr('src');
if (!iframeUrl) throw new NotFoundError('iframe src not found');
return iframeUrl.startsWith('http') ? iframeUrl : new URL(iframeUrl, baseUrl).toString();
}
async function extractM3u8FromIframe(ctx: MovieScrapeContext | ShowScrapeContext, iframeUrl: string): Promise<string> {
const iframeHtml = await ctx.proxiedFetcher<string>(iframeUrl, {
headers: {
Referer: baseUrl,
},
});
const idfileEnc = iframeHtml.match(/const\s+idfile_enc\s*=\s*"([^"]+)"/)?.[1];
const idUserEnc = iframeHtml.match(/const\s+idUser_enc\s*=\s*"([^"]+)"/)?.[1];
const domainApi = iframeHtml.match(/const\s+DOMAIN_API\s*=\s*'([^']+)'/)?.[1];
if (!idfileEnc || !idUserEnc || !domainApi) throw new NotFoundError('Required data not found in iframe HTML');
const idfile = decryptHexToUtf8(idfileEnc, KEYS.IDFILE_KEY);
const iduser = decryptHexToUtf8(idUserEnc, KEYS.IDUSER_KEY);
const requestData = {
idfile,
iduser,
domain_play: 'https://my.playhq.net',
platform: 'Win32',
hlsSupport: true,
jwplayer: {},
} as const;
const encryptedData = encryptUtf8ToHex(JSON.stringify(requestData), KEYS.REQUEST_KEY);
const md5Hash = CryptoJS.MD5(encryptedData + KEYS.MD5_SALT).toString();
const responseBody = await ctx.proxiedFetcher<any>(`${domainApi}/playiframe`, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
Accept: 'application/json, text/javascript, */*; q=0.01',
'X-Requested-With': 'XMLHttpRequest',
'User-Agent':
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36',
Referer: iframeUrl,
Origin: new URL(domainApi).origin,
},
body: `data=${encryptedData}|${md5Hash}`,
});
let json: any;
try {
json = typeof responseBody === 'string' ? JSON.parse(responseBody) : responseBody;
} catch {
throw new NotFoundError('Invalid JSON from playiframe');
}
if (json?.status === 1 && json?.type === 'url-m3u8-encv1' && typeof json.data === 'string') {
const decryptedUrl = decryptHexToUtf8(json.data, KEYS.RESPONSE_KEY);
if (!decryptedUrl) throw new NotFoundError('Failed to decrypt stream URL');
return decryptedUrl;
}
throw new NotFoundError(json?.msg || 'Failed to get stream URL');
}
const comboScraper = async (ctx: MovieScrapeContext | ShowScrapeContext) => {
// Normalize base by following any redirects
const home = await ctx.proxiedFetcher.full(baseUrl);
baseUrl = new URL(home.finalUrl).origin;
// Build search slug from title
const searchSlug = ctx.media.title
.replace(/'/g, '')
.replace(/!|@|%|\^|\*|\(|\)|\+|=|<|>|\?|\/|,|\.|:|;|'| |"|&|#|\[|\]|~|$|_/g, '-')
.replace(/-+-/g, '-')
.replace(/^-+|-+$/g, '')
.replace(/Ă¢â‚¬â€œ/g, '');
const searchPageHtml = await ctx.proxiedFetcher<string>(`/search/${searchSlug}.html`, {
baseUrl,
query: {
type: ctx.media.type === 'movie' ? 'movie' : 'tvs',
},
});
const searchPage$ = load(searchPageHtml);
const results: { title: string; year: number | undefined; url: string }[] = [];
searchPage$('.item').each((_, el) => {
const [, title, year] =
searchPage$(el)
.find('.imagecover a')
.attr('title')
?.match(/^(.*?)\s*(?:\(?\s*(\d{4})(?:\s*-\s*\d{0,4})?\s*\)?)?\s*$/) || [];
const url = searchPage$(el).find('a').attr('href');
if (!title || !url) return;
results.push({ title, year: year ? parseInt(year, 10) : undefined, url });
});
const watchPath = results.find((x) => x && compareMedia(ctx.media, x.title, x.year))?.url;
if (!watchPath) throw new NotFoundError('No watchable item found');
const watchFinal = await ctx.proxiedFetcher.full(watchPath, {
baseUrl,
readHeaders: ['Set-Cookie'],
});
const watchHtml = watchFinal.body;
const watchUrl = new URL(watchFinal.finalUrl).toString();
const csrfToken = load(watchHtml).root().find('meta[name="csrf-token"]').attr('content');
if (!csrfToken) throw new NotFoundError('Token not found');
const cookies = parseSetCookie(getSetCookieHeader(watchFinal.headers));
const laravel = cookies.laravel_session;
if (!laravel?.value) throw new NotFoundError('Session cookie not found');
const cookieHeader = makeCookieHeader({ [laravel.name]: laravel.value });
ctx.progress(50);
const iframeUrl = await fetchIframeUrl(ctx, watchHtml, csrfToken, cookieHeader, watchUrl);
const m3u8Url = await extractM3u8FromIframe(ctx, iframeUrl);
ctx.progress(90);
// The stream headers aren't required, but they are used to trigger the extension to be used since the stream is only cors locked.
// BUT we are using the M3U8 proxy to bypass the cors lock, so we shouldn't remove the flag.
// We don't have handling for only cors locked streams with the extension.
const streamHeaders = {
Referer: baseUrl,
Origin: baseUrl,
};
return {
embeds: [],
stream: [
{
id: 'primary',
type: 'hls' as const,
playlist: createM3U8ProxyUrl(m3u8Url, ctx.features, streamHeaders),
headers: streamHeaders,
flags: [flags.CORS_ALLOWED],
captions: [],
},
],
};
};
export const m4ufreeScraper = makeSourcerer({
id: 'm4ufree',
name: 'M4UFree 🔥',
rank: 182,
disabled: true,
flags: [flags.CORS_ALLOWED],
scrapeMovie: comboScraper,
scrapeShow: comboScraper,
});

View file

@ -0,0 +1,37 @@
/* eslint-disable no-console */
import { flags } from '@/entrypoint/utils/targets';
import { SourcererOutput, makeSourcerer } from '@/providers/base';
import { MovieScrapeContext, ShowScrapeContext } from '@/utils/context';
const baseUrl = 'https://multiembed.mov';
async function comboScraper(ctx: ShowScrapeContext | MovieScrapeContext): Promise<SourcererOutput> {
ctx.progress(50);
let url: string;
if (ctx.media.type === 'show') {
url = `${baseUrl}/?video_id=${ctx.media.imdbId}&s=${ctx.media.season.number}&e=${ctx.media.episode.number}`;
} else {
url = `${baseUrl}/?video_id=${ctx.media.imdbId}`;
}
return {
embeds: [
{
embedId: 'streambucket',
url,
},
],
};
}
export const multiembedScraper = makeSourcerer({
id: 'multiembed',
name: 'MultiEmbed 🔥',
rank: 145,
disabled: true,
flags: [flags.CORS_ALLOWED],
scrapeMovie: comboScraper,
scrapeShow: comboScraper,
});

View file

@ -1,46 +0,0 @@
import { flags } from '@/entrypoint/utils/targets';
import { SourcererOutput, makeSourcerer } from '@/providers/base';
import { MovieScrapeContext, ShowScrapeContext } from '@/utils/context';
import { NotFoundError } from '@/utils/errors';
const nepuBase = 'https://nscrape.andresdev.org/api';
async function scrape(ctx: MovieScrapeContext | ShowScrapeContext): Promise<SourcererOutput> {
const tmdbId = ctx.media.tmdbId;
let url: string;
if (ctx.media.type === 'movie') {
url = `${nepuBase}/get-stream?tmdbId=${tmdbId}`;
} else {
url = `${nepuBase}/get-show-stream?tmdbId=${tmdbId}&season=${ctx.media.season.number}&episode=${ctx.media.episode.number}`;
}
const response = await ctx.proxiedFetcher<any>(url);
if (!response.success || !response.rurl) {
throw new NotFoundError('No stream found');
}
return {
stream: [
{
id: 'nepu',
type: 'hls',
playlist: response.rurl,
flags: [flags.CORS_ALLOWED],
captions: [],
},
],
embeds: [],
};
}
export const nepuScraper = makeSourcerer({
id: 'nepu',
name: 'Nepu',
rank: 201,
disabled: true,
flags: [flags.CORS_ALLOWED],
scrapeMovie: scrape,
scrapeShow: scrape,
});

View file

@ -181,7 +181,7 @@ async function scrapeShow(ctx: ShowScrapeContext): Promise<SourcererOutput> {
export const pirxcyScraper = makeSourcerer({
id: 'pirxcy',
name: 'Pirxcy',
rank: 230,
rank: 290,
disabled: true,
flags: [flags.CORS_ALLOWED],
scrapeMovie,

View file

@ -0,0 +1,74 @@
import { SourcererOutput, makeSourcerer } from '@/providers/base';
import { MovieScrapeContext, ShowScrapeContext } from '@/utils/context';
import { NotFoundError } from '@/utils/errors';
async function comboScraper(ctx: ShowScrapeContext | MovieScrapeContext): Promise<SourcererOutput> {
const baseApiUrl = 'https://primesrc.me/api/v1/';
let serverData;
try {
if (ctx.media.type === 'movie') {
const url = `${baseApiUrl}s?tmdb=${ctx.media.tmdbId}&type=movie`;
serverData = await fetch(url);
} else {
const url = `${baseApiUrl}s?tmdb=${ctx.media.tmdbId}&season=${ctx.media.season.number}&episode=${ctx.media.episode.number}&type=tv`;
serverData = await fetch(url);
}
} catch (error) {
return { embeds: [] };
}
let data;
try {
data = await serverData.json();
} catch (error) {
return { embeds: [] };
}
const nameToEmbedId: Record<string, string> = {
Filelions: 'filelions',
Dood: 'dood',
Streamwish: 'streamwish-english',
Filemoon: 'filemoon',
};
if (!data.servers || !Array.isArray(data.servers)) {
return { embeds: [] };
}
const embeds = [];
for (const server of data.servers) {
if (!server.name || !server.key) {
continue;
}
if (nameToEmbedId[server.name]) {
try {
const linkData = await fetch(`${baseApiUrl}l?key=${server.key}`);
if (linkData.status !== 200) {
continue;
}
const linkJson = await linkData.json();
if (linkJson.link) {
const embed = {
embedId: nameToEmbedId[server.name],
url: linkJson.link,
};
embeds.push(embed);
}
} catch (error) {
throw new NotFoundError(`Error: ${error}`);
}
}
}
return { embeds };
}
export const primesrcScraper = makeSourcerer({
id: 'primesrc',
name: 'PrimeSrc',
rank: 105,
flags: [],
scrapeMovie: comboScraper,
scrapeShow: comboScraper,
});

View file

@ -1,74 +1,123 @@
import { SourcererOutput, makeSourcerer } from '@/providers/base';
import { flags } from '@/entrypoint/utils/targets';
import { SourcererEmbed, SourcererOutput, makeSourcerer } from '@/providers/base';
import { MovieScrapeContext, ShowScrapeContext } from '@/utils/context';
import { NotFoundError } from '@/utils/errors';
interface PStreamResponse {
imdb_id: string;
streams: Array<{
headers: Record<string, string>;
link: string;
quality: string;
server: string;
type: string;
}>;
title: string;
}
async function comboScraper(ctx: ShowScrapeContext | MovieScrapeContext): Promise<SourcererOutput> {
const baseApiUrl = 'https://primesrc.me/api/v1/';
// Build the API URL based on media type
let apiUrl: string;
if (ctx.media.type === 'movie') {
// For movies, we need IMDB ID
if (!ctx.media.imdbId) throw new NotFoundError('IMDB ID required for movies');
apiUrl = `https://primewire.pstream.mov/movie/${ctx.media.imdbId}`;
} else {
// For TV shows, we need IMDB ID, season, and episode
if (!ctx.media.imdbId) throw new NotFoundError('IMDB ID required for TV shows');
apiUrl = `https://primewire.pstream.mov/tv/${ctx.media.imdbId}/${ctx.media.season.number}/${ctx.media.episode.number}`;
}
let serverData;
try {
if (ctx.media.type === 'movie') {
const url = `${baseApiUrl}s?tmdb=${ctx.media.tmdbId}&type=movie`;
serverData = await fetch(url);
ctx.progress(30);
// Fetch the stream data
const response = await ctx.fetcher<PStreamResponse>(apiUrl, {
headers: {
'User-Agent':
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.0.0 Safari/537.36',
},
});
if (!response.streams || !Array.isArray(response.streams) || response.streams.length === 0) {
throw new NotFoundError('No streams found');
}
ctx.progress(60);
// Process each stream as a separate embed using server-mirrors
const embeds: SourcererEmbed[] = [];
for (const stream of response.streams) {
if (!stream.link || !stream.quality) continue;
let mirrorContext: any;
if (stream.type === 'm3u8') {
// Handle HLS streams
mirrorContext = {
type: 'hls',
stream: stream.link,
headers: stream.headers || [],
captions: [],
flags: !stream.headers || Object.keys(stream.headers).length === 0 ? [flags.CORS_ALLOWED] : [],
};
} else {
const url = `${baseApiUrl}s?tmdb=${ctx.media.tmdbId}&season=${ctx.media.season.number}&episode=${ctx.media.episode.number}&type=tv`;
serverData = await fetch(url);
}
} catch (error) {
return { embeds: [] };
}
let data;
try {
data = await serverData.json();
} catch (error) {
return { embeds: [] };
}
const nameToEmbedId: Record<string, string> = {
Filelions: 'filelions',
Dood: 'dood',
Streamwish: 'streamwish-english',
Filemoon: 'filemoon',
};
if (!data.servers || !Array.isArray(data.servers)) {
return { embeds: [] };
}
const embeds = [];
for (const server of data.servers) {
if (!server.name || !server.key) {
continue;
}
if (nameToEmbedId[server.name]) {
try {
const linkData = await fetch(`${baseApiUrl}l?key=${server.key}`);
if (linkData.status !== 200) {
continue;
// Handle file streams
// Convert quality string to numeric key for the qualities object
let qualityKey: string;
if (stream.quality === 'ORG') {
// Handle original quality - check if it's an MP4
const urlPath = stream.link.split('?')[0];
if (urlPath.toLowerCase().endsWith('.mp4')) {
qualityKey = 'unknown';
} else {
continue; // Skip non-MP4 original quality
}
const linkJson = await linkData.json();
if (linkJson.link) {
const embed = {
embedId: nameToEmbedId[server.name],
url: linkJson.link,
};
embeds.push(embed);
}
} catch (error) {
throw new NotFoundError(`Error: ${error}`);
} else if (stream.quality === '4K') {
qualityKey = '4k';
} else {
// Parse numeric qualities like "720", "1080", etc.
const parsed = parseInt(stream.quality.replace('P', ''), 10);
if (Number.isNaN(parsed)) continue;
qualityKey = parsed.toString();
}
// Create the mirror context for server-mirrors embed
mirrorContext = {
type: 'file',
qualities: {
[qualityKey === 'unknown' || qualityKey === '4k' ? qualityKey : parseInt(qualityKey, 10)]: {
type: 'mp4',
url: stream.link,
},
},
flags: !stream.headers || Object.keys(stream.headers).length === 0 ? [flags.CORS_ALLOWED] : [],
headers: stream.headers || [],
captions: [],
};
}
embeds.push({
embedId: 'mirror',
url: JSON.stringify(mirrorContext),
});
}
if (embeds.length === 0) {
throw new NotFoundError('No valid streams found');
}
ctx.progress(90);
return { embeds };
}
export const primewireScraper = makeSourcerer({
id: 'primewire',
name: 'PrimeWire',
rank: 105,
flags: [],
name: 'PrimeWire 🔥',
rank: 206,
disabled: true,
flags: [flags.CORS_ALLOWED],
scrapeMovie: comboScraper,
scrapeShow: comboScraper,
});

View file

@ -1,71 +1,50 @@
import { flags } from '@/entrypoint/utils/targets';
import { SourcererOutput, makeSourcerer } from '@/providers/base';
import { MovieScrapeContext, ShowScrapeContext } from '@/utils/context';
const VIDIFY_SERVERS = [
{ name: 'Mbox', sr: 17 },
{ name: 'Xprime', sr: 15 },
{ name: 'Hexo', sr: 8 },
{ name: 'Prime', sr: 9 },
{ name: 'Nitro', sr: 20 },
{ name: 'Meta', sr: 6 },
{ name: 'Veasy', sr: 16 },
{ name: 'Lux', sr: 26 },
{ name: 'Vfast', sr: 11 },
{ name: 'Zozo', sr: 7 },
{ name: 'Tamil', sr: 13 },
{ name: 'Telugu', sr: 14 },
{ name: 'Beta', sr: 5 },
{ name: 'Alpha', sr: 1 },
{ name: 'Vplus', sr: 18 },
{ name: 'Cobra', sr: 12 },
];
async function comboScraper(ctx: ShowScrapeContext | MovieScrapeContext): Promise<SourcererOutput> {
const query = {
type: ctx.media.type,
title: ctx.media.title,
tmdbId: ctx.media.tmdbId,
imdbId: ctx.media.imdbId,
...(ctx.media.type === 'show' && {
season: ctx.media.season.number,
episode: ctx.media.episode.number,
}),
releaseYear: ctx.media.releaseYear,
};
return {
embeds: [
{
embedId: 'vidify-alfa',
url: JSON.stringify(query),
},
{
embedId: 'vidify-bravo',
url: JSON.stringify(query),
},
{
embedId: 'vidify-charlie',
url: JSON.stringify(query),
},
{
embedId: 'vidify-delta',
url: JSON.stringify(query),
},
{
embedId: 'vidify-echo',
url: JSON.stringify(query),
},
{
embedId: 'vidify-foxtrot',
url: JSON.stringify(query),
},
{
embedId: 'vidify-golf',
url: JSON.stringify(query),
},
{
embedId: 'vidify-hotel',
url: JSON.stringify(query),
},
{
embedId: 'vidify-india',
url: JSON.stringify(query),
},
{
embedId: 'vidify-juliett',
url: JSON.stringify(query),
},
],
embeds: VIDIFY_SERVERS.map((server) => ({
embedId: `vidify-${server.name.toLowerCase()}`,
url: JSON.stringify({ ...query, sr: server.sr }),
})),
};
}
export const vidifyScraper = makeSourcerer({
id: 'vidify',
name: 'Vidify',
rank: 124,
name: 'Vidify 🔥',
rank: 204,
disabled: true,
flags: [],
flags: [flags.CORS_ALLOWED],
scrapeMovie: comboScraper,
scrapeShow: comboScraper,
});

View file

@ -1,228 +0,0 @@
const abc = String.fromCharCode(
65,
66,
67,
68,
69,
70,
71,
72,
73,
74,
75,
76,
77,
97,
98,
99,
100,
101,
102,
103,
104,
105,
106,
107,
108,
109,
78,
79,
80,
81,
82,
83,
84,
85,
86,
87,
88,
89,
90,
110,
111,
112,
113,
114,
115,
116,
117,
118,
119,
120,
121,
122,
);
const dechar = (x: number): string => String.fromCharCode(x);
const salt = {
_keyStr: `${abc}0123456789+/=`,
e(input: string): string {
let t = '';
let n: number;
let r: number;
let i: number;
let s: number;
let o: number;
let u: number;
let a: number;
let f = 0;
input = salt._ue(input); // eslint-disable-line no-param-reassign
while (f < input.length) {
n = input.charCodeAt(f++);
r = input.charCodeAt(f++);
i = input.charCodeAt(f++);
s = n >> 2;
o = ((n & 3) << 4) | (r >> 4);
u = ((r & 15) << 2) | (i >> 6);
a = i & 63;
if (Number.isNaN(r)) {
u = 64;
a = 64;
} else if (Number.isNaN(i)) {
a = 64;
}
t += this._keyStr.charAt(s) + this._keyStr.charAt(o) + this._keyStr.charAt(u) + this._keyStr.charAt(a);
}
return t;
},
d(encoded: string): string {
let t = '';
let n: number;
let r: number;
let i: number;
let s: number;
let o: number;
let u: number;
let a: number;
let f = 0;
encoded = encoded.replace(/[^A-Za-z0-9+/=]/g, ''); // eslint-disable-line no-param-reassign
while (f < encoded.length) {
s = this._keyStr.indexOf(encoded.charAt(f++));
o = this._keyStr.indexOf(encoded.charAt(f++));
u = this._keyStr.indexOf(encoded.charAt(f++));
a = this._keyStr.indexOf(encoded.charAt(f++));
n = (s << 2) | (o >> 4);
r = ((o & 15) << 4) | (u >> 2);
i = ((u & 3) << 6) | a;
t += dechar(n);
if (u !== 64) t += dechar(r);
if (a !== 64) t += dechar(i);
}
t = salt._ud(t);
return t;
},
_ue(input: string): string {
input = input.replace(/\r\n/g, '\n'); // eslint-disable-line no-param-reassign
let t = '';
for (let n = 0; n < input.length; n++) {
const r = input.charCodeAt(n);
if (r < 128) {
t += dechar(r);
} else if (r > 127 && r < 2048) {
t += dechar((r >> 6) | 192);
t += dechar((r & 63) | 128);
} else {
t += dechar((r >> 12) | 224);
t += dechar(((r >> 6) & 63) | 128);
t += dechar((r & 63) | 128);
}
}
return t;
},
_ud(input: string): string {
let t = '';
let n = 0;
let r: number;
let c2: number;
let c3: number;
while (n < input.length) {
r = input.charCodeAt(n);
if (r < 128) {
t += dechar(r);
n++;
} else if (r > 191 && r < 224) {
c2 = input.charCodeAt(n + 1);
t += dechar(((r & 31) << 6) | (c2 & 63));
n += 2;
} else {
c2 = input.charCodeAt(n + 1);
c3 = input.charCodeAt(n + 2);
t += dechar(((r & 15) << 12) | ((c2 & 63) << 6) | (c3 & 63));
n += 3;
}
}
return t;
},
};
const sugar = (input: string): string => {
const parts = input.split(dechar(61));
let result = '';
const c1 = dechar(120);
for (const part of parts) {
let encoded = '';
for (let i = 0; i < part.length; i++) {
encoded += part[i] === c1 ? dechar(49) : dechar(48);
}
const chr = parseInt(encoded, 2);
result += dechar(chr);
}
return result.substring(0, result.length - 1);
};
const pepper = (s: string, n: number): string => {
s = s.replace(/\+/g, '#'); // eslint-disable-line no-param-reassign
s = s.replace(/#/g, '+'); // eslint-disable-line no-param-reassign
// Default value for vidsrc player
const yValue = 'xx??x?=xx?xx?=';
let a = Number(sugar(yValue)) * n;
if (n < 0) a += abc.length / 2;
const r = abc.substr(a * 2) + abc.substr(0, a * 2);
return s.replace(/[A-Za-z]/g, (c) => r.charAt(abc.indexOf(c)));
};
export const decode = (x: string): string => {
if (x.substr(0, 2) === '#1') {
return salt.d(pepper(x.substr(2), -1));
}
if (x.substr(0, 2) === '#0') {
return salt.d(x.substr(2));
}
return x;
};
export const mirza = (encodedUrl: string, v: any): string => {
let a = encodedUrl.substring(2);
for (let i = 4; i >= 0; i--) {
if (v[`bk${i}`]) {
const b1 = (str: string) =>
btoa(encodeURIComponent(str).replace(/%([0-9A-F]{2})/g, (_, p1) => String.fromCharCode(parseInt(p1, 16))));
a = a.replace(v.file3_separator + b1(v[`bk${i}`]), '');
}
}
const b2 = (str: string) =>
decodeURIComponent(
atob(str)
.split('')
.map((c) => `%${`00${c.charCodeAt(0).toString(16)}`.slice(-2)}`)
.join(''),
);
return b2(a);
};

View file

@ -1,124 +0,0 @@
import type { ShowMedia } from '@/entrypoint/utils/media';
import { SourcererOutput, makeSourcerer } from '@/providers/base';
import { MovieScrapeContext, ShowScrapeContext } from '@/utils/context';
import { NotFoundError } from '@/utils/errors';
import { decode, mirza } from './decrypt';
// Default player configuration
const o = {
y: 'xx??x?=xx?xx?=',
u: '#1RyJzl3JYmljm0mkJWOGYWNyI6MfwVNGYXmj9uQj5tQkeYIWoxLCJXNkawOGF5QZ9sQj1YIWowLCJXO20VbVJ1OZ11QGiSlni0QG9uIn19',
};
async function vidsrcScrape(ctx: MovieScrapeContext | ShowScrapeContext): Promise<SourcererOutput> {
const imdbId = ctx.media.imdbId;
if (!imdbId) throw new NotFoundError('IMDb ID not found');
const isShow = ctx.media.type === 'show';
let season: number | undefined;
let episode: number | undefined;
if (isShow) {
const show = ctx.media as ShowMedia;
season = show.season?.number;
episode = show.episode?.number;
}
const embedUrl = isShow
? `https://vidsrc.net/embed/tv?imdb=${imdbId}&season=${season}&episode=${episode}`
: `https://vidsrc.net/embed/${imdbId}`;
ctx.progress(10);
const embedHtml = await ctx.proxiedFetcher<string>(embedUrl, {
headers: {
Referer: 'https://vidsrc.net/',
'User-Agent': 'Mozilla/5.0',
},
});
ctx.progress(30);
// Extract the iframe source using regex
const iframeMatch = embedHtml.match(/<iframe[^>]*id="player_iframe"[^>]*src="([^"]*)"[^>]*>/);
if (!iframeMatch) throw new NotFoundError('Initial iframe not found');
const rcpUrl = iframeMatch[1].startsWith('//') ? `https:${iframeMatch[1]}` : iframeMatch[1];
ctx.progress(50);
const rcpHtml = await ctx.proxiedFetcher<string>(rcpUrl, {
headers: { Referer: embedUrl, 'User-Agent': 'Mozilla/5.0' },
});
// Find the script with prorcp
const scriptMatch = rcpHtml.match(/src\s*:\s*['"]([^'"]+)['"]/);
if (!scriptMatch) throw new NotFoundError('prorcp iframe not found');
const prorcpUrl = scriptMatch[1].startsWith('/') ? `https://cloudnestra.com${scriptMatch[1]}` : scriptMatch[1];
ctx.progress(70);
const finalHtml = await ctx.proxiedFetcher<string>(prorcpUrl, {
headers: { Referer: rcpUrl, 'User-Agent': 'Mozilla/5.0' },
});
// Find script containing Playerjs
const scripts = finalHtml.split('<script');
let scriptWithPlayer = '';
for (const script of scripts) {
if (script.includes('Playerjs')) {
scriptWithPlayer = script;
break;
}
}
if (!scriptWithPlayer) throw new NotFoundError('No Playerjs config found');
const m3u8Match = scriptWithPlayer.match(/file\s*:\s*['"]([^'"]+)['"]/);
if (!m3u8Match) throw new NotFoundError('No file field in Playerjs');
let streamUrl = m3u8Match[1];
if (!streamUrl.includes('.m3u8')) {
// Check if we need to decode the URL
const v = JSON.parse(decode(o.u));
streamUrl = mirza(streamUrl, v);
}
ctx.progress(90);
const headers = {
referer: 'https://cloudnestra.com/',
origin: 'https://cloudnestra.com',
};
return {
stream: [
{
id: 'vidsrc-cloudnestra',
type: 'hls',
playlist: streamUrl,
headers,
proxyDepth: 2,
flags: [],
captions: [],
},
],
embeds: [],
};
}
export const vidsrcScraper = makeSourcerer({
id: 'cloudnestra',
name: 'Cloudnestra',
disabled: true,
rank: 180,
flags: [],
scrapeMovie: vidsrcScrape,
scrapeShow: vidsrcScrape,
});
// thanks Mirzya for this scraper!

41
src/utils/browser.ts Normal file
View file

@ -0,0 +1,41 @@
/**
* Browser detection utilities
*/
export function detectBrowser(): 'chrome' | 'firefox' | 'safari' | 'unknown' {
// Check if we're in a browser environment
if (typeof navigator === 'undefined') {
return 'unknown';
}
const userAgent = navigator.userAgent.toLowerCase();
// Detect Chrome/Brave (Brave includes "Chrome" in its user agent)
if (userAgent.includes('chrome') && !userAgent.includes('edg')) {
return 'chrome';
}
// Detect Firefox
if (userAgent.includes('firefox')) {
return 'firefox';
}
// Detect Safari
if (userAgent.includes('safari') && !userAgent.includes('chrome')) {
return 'safari';
}
return 'unknown';
}
export function isChromeOrBrave(): boolean {
return detectBrowser() === 'chrome';
}
export function isFirefox(): boolean {
return detectBrowser() === 'firefox';
}
export function isSafari(): boolean {
return detectBrowser() === 'safari';
}

View file

@ -19,3 +19,8 @@ export function parseSetCookie(headerValue: string): Record<string, Cookie> {
});
return parsedCookies;
}
export function getSetCookieHeader(headers: Headers): string {
// Try Set-Cookie first, then x-set-cookie (for proxy scenarios)
return headers.get('Set-Cookie') ?? headers.get('x-set-cookie') ?? '';
}

107
src/utils/obfuscate.ts Normal file
View file

@ -0,0 +1,107 @@
// Obfuscation utilities for protecting sensitive code and data
// These utilities help protect WASM loading, decryption keys and functions
// XOR key - this adds another small layer of protection
const KEY = [0x5a, 0xf1, 0x9e, 0x3d, 0x24, 0xb7, 0x6c];
/**
* Encodes a string to obfuscate it in the source code
* DO NOT modify this encoding algorithm without updating the decode function
*/
export function encode(str: string): string {
const encoded = [];
for (let i = 0; i < str.length; i++) {
const charCode = str.charCodeAt(i);
const keyChar = KEY[i % KEY.length];
encoded.push(String.fromCharCode(charCode ^ keyChar));
}
// Convert to base64 for better text safety
return btoa(encoded.join(''));
}
/**
* Decodes a previously encoded string
*/
export function decode(encoded: string): string {
try {
const str = atob(encoded);
const decoded = [];
for (let i = 0; i < str.length; i++) {
const charCode = str.charCodeAt(i);
const keyChar = KEY[i % KEY.length];
decoded.push(String.fromCharCode(charCode ^ keyChar));
}
return decoded.join('');
} catch (e) {
// Fallback in case of decoding errors
console.error('Failed to decode string:', e);
return '';
}
}
/**
* Decrypt a WASM binary
* This is a placeholder implementation - replace with your actual decryption logic
*/
function decryptWasmBinary(encryptedBuffer: ArrayBuffer, key: Uint8Array): ArrayBuffer {
// Create a view of the encrypted buffer
const encryptedView = new Uint8Array(encryptedBuffer);
const decrypted = new Uint8Array(encryptedBuffer.byteLength);
// Simple XOR decryption - replace with stronger algorithm in production
for (let i = 0; i < encryptedView.length; i++) {
decrypted[i] = encryptedView[i] ^ key[i % key.length];
}
return decrypted.buffer;
}
/**
* Creates a function from encoded string
* This allows obfuscating function code and only constructing it at runtime
*
* @param encodedFn - The encoded function string (encode(functionString))
* @returns The decoded and constructed function
*/
export function createFunction<T extends (...args: any[]) => any>(encodedFn: string): T {
try {
// Decode the function string
const fnStr = decode(encodedFn);
// Create the function dynamically
// eslint-disable-next-line no-new-func
return new Function(`return ${fnStr}`)() as T;
} catch (e) {
console.error('Failed to create function from encoded string:', e);
return (() => null) as unknown as T;
}
}
/**
* Loads and decrypts a WASM module from an encoded URL
*
* @param encodedUrl - The encoded URL to the WASM file
* @param decryptionKey - Key to decrypt the WASM binary (if encrypted)
* @returns A promise resolving to the instantiated WebAssembly instance
*/
export async function loadWasmModule(
encodedUrl: string,
decryptionKey?: Uint8Array,
): Promise<WebAssembly.WebAssemblyInstantiatedSource> {
const url = decode(encodedUrl);
// Fetch the WASM module
const response = await fetch(url);
let buffer = await response.arrayBuffer();
// Decrypt the WASM binary if a key is provided
if (decryptionKey) {
buffer = decryptWasmBinary(buffer, decryptionKey);
}
// Compile and instantiate the WASM module
return WebAssembly.instantiate(buffer);
}

View file

@ -4,7 +4,7 @@ import { Stream } from '@/providers/streams';
// Default proxy URL for general purpose proxying
const DEFAULT_PROXY_URL = 'https://proxy.nsbx.ru/proxy';
// Default M3U8 proxy URL for HLS stream proxying
let CONFIGURED_M3U8_PROXY_URL = 'https://proxy2.pstream.mov';
let CONFIGURED_M3U8_PROXY_URL = 'https://proxy.pstream.mov';
/**
* Set a custom M3U8 proxy URL to use for all M3U8 proxy requests

170
src/utils/turnstile.ts Normal file
View file

@ -0,0 +1,170 @@
/**
* Cloudflare Turnstile utility for handling invisible CAPTCHA verification
*/
interface TurnstileConfig {
sitekey: string;
callback?: (token: string) => void;
'error-callback'?: (error: string) => void;
'expired-callback'?: () => void;
'timeout-callback'?: () => void;
}
declare global {
interface Window {
turnstile?: {
render: (container: string | HTMLElement, config: TurnstileConfig) => string;
reset: (widgetId: string) => void;
remove: (widgetId: string) => void;
getResponse: (widgetId: string) => string;
};
}
}
/**
* Loads the Cloudflare Turnstile script if not already loaded
*/
function loadTurnstileScript(): Promise<void> {
return new Promise((resolve, reject) => {
// Check if Turnstile is already loaded
if (window.turnstile) {
resolve();
return;
}
// Check if script is already being loaded
if (document.querySelector('script[src*="challenges.cloudflare.com/turnstile"]')) {
// Wait for it to load
const checkLoaded = () => {
if (window.turnstile) {
resolve();
} else {
setTimeout(checkLoaded, 100);
}
};
checkLoaded();
return;
}
const script = document.createElement('script');
script.src = 'https://challenges.cloudflare.com/turnstile/v0/api.js';
script.async = true;
script.defer = true;
script.onload = () => resolve();
script.onerror = () => reject(new Error('Failed to load Turnstile script'));
document.head.appendChild(script);
});
}
/**
* Creates an invisible Turnstile widget and returns a promise that resolves with the token
* @param sitekey The Turnstile site key
* @param timeout Optional timeout in milliseconds (default: 30000)
* @returns Promise that resolves with the Turnstile token
*/
export async function getTurnstileToken(sitekey: string, timeout: number = 30000): Promise<string> {
// Only run in browser environment
if (typeof window === 'undefined') {
throw new Error('Turnstile verification requires browser environment');
}
try {
// Load Turnstile script
await loadTurnstileScript();
// Create a hidden container for the Turnstile widget
const container = document.createElement('div');
container.style.position = 'absolute';
container.style.left = '-9999px';
container.style.top = '-9999px';
container.style.width = '1px';
container.style.height = '1px';
container.style.overflow = 'hidden';
container.style.opacity = '0';
container.style.pointerEvents = 'none';
document.body.appendChild(container);
return new Promise<string>((resolve, reject) => {
let widgetId: string;
let timeoutId: NodeJS.Timeout;
const cleanup = () => {
if (timeoutId) clearTimeout(timeoutId);
if (widgetId && window.turnstile) {
try {
window.turnstile.remove(widgetId);
} catch (e) {
// Ignore errors during cleanup
}
}
if (container.parentNode) {
container.parentNode.removeChild(container);
}
};
// Set up timeout
timeoutId = setTimeout(() => {
cleanup();
reject(new Error('Turnstile verification timed out'));
}, timeout);
try {
// Render the Turnstile widget
widgetId = window.turnstile!.render(container, {
sitekey,
callback: (token: string) => {
cleanup();
resolve(token);
},
'error-callback': (error: string) => {
cleanup();
reject(new Error(`Turnstile error: ${error}`));
},
'expired-callback': () => {
cleanup();
reject(new Error('Turnstile token expired'));
},
'timeout-callback': () => {
cleanup();
reject(new Error('Turnstile verification timed out'));
},
});
} catch (error) {
cleanup();
reject(new Error(`Failed to render Turnstile widget: ${error}`));
}
});
} catch (error) {
throw new Error(`Turnstile verification failed: ${error}`);
}
}
/**
* Validates a Turnstile token by making a request to Cloudflare's verification endpoint
* @param token The Turnstile token to validate
* @param secret The Turnstile secret key (server-side only)
* @returns Promise that resolves with validation result
*/
export async function validateTurnstileToken(token: string, secret: string): Promise<boolean> {
try {
const response = await fetch('https://challenges.cloudflare.com/turnstile/v0/siteverify', {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
body: new URLSearchParams({
secret,
response: token,
}),
});
const result = await response.json();
return result.success === true;
} catch (error) {
console.error('Turnstile validation error:', error);
return false;
}
}