mirror of
https://github.com/p-stream/providers.git
synced 2026-01-11 12:00:46 +00:00
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:
parent
aa0e70cb69
commit
e6b1a7ada8
50 changed files with 3379 additions and 1129 deletions
3
.gitignore
vendored
3
.gitignore
vendored
|
|
@ -3,4 +3,5 @@ node_modules/
|
|||
coverage
|
||||
.env
|
||||
.eslintcache
|
||||
|
||||
/lib-obf
|
||||
.DS_Store
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
964
pnpm-lock.yaml
964
pnpm-lock.yaml
File diff suppressed because it is too large
Load diff
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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],
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
],
|
||||
};
|
||||
},
|
||||
});
|
||||
|
|
@ -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,
|
||||
},
|
||||
],
|
||||
};
|
||||
},
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
],
|
||||
};
|
||||
},
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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: [],
|
||||
},
|
||||
],
|
||||
};
|
||||
},
|
||||
});
|
||||
85
src/providers/archive/embeds/vidjoy.ts
Normal file
85
src/providers/archive/embeds/vidjoy.ts
Normal 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);
|
||||
|
|
@ -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],
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
],
|
||||
};
|
||||
},
|
||||
});
|
||||
BIN
src/providers/archive/sources/.DS_Store
vendored
BIN
src/providers/archive/sources/.DS_Store
vendored
Binary file not shown.
|
|
@ -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,
|
||||
});
|
||||
375
src/providers/archive/sources/realdebrid/debrid.ts
Normal file
375
src/providers/archive/sources/realdebrid/debrid.ts
Normal 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}`);
|
||||
}
|
||||
}
|
||||
159
src/providers/archive/sources/realdebrid/index.ts
Normal file
159
src/providers/archive/sources/realdebrid/index.ts
Normal 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,
|
||||
});
|
||||
151
src/providers/archive/sources/realdebrid/torrentio.ts
Normal file
151
src/providers/archive/sources/realdebrid/torrentio.ts
Normal 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}`);
|
||||
}
|
||||
}
|
||||
95
src/providers/archive/sources/vidjoy.ts
Normal file
95
src/providers/archive/sources/vidjoy.ts
Normal 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,
|
||||
});
|
||||
142
src/providers/archive/upcloud.ts
Normal file
142
src/providers/archive/upcloud.ts
Normal 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,
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
},
|
||||
});
|
||||
BIN
src/providers/embeds/.DS_Store
vendored
BIN
src/providers/embeds/.DS_Store
vendored
Binary file not shown.
|
|
@ -50,6 +50,9 @@ export const filelionsScraper = makeEmbed({
|
|||
id: 'primary',
|
||||
type: 'hls',
|
||||
playlist: streamUrl,
|
||||
headers: {
|
||||
Referer: 'https://primesrc.me/',
|
||||
},
|
||||
flags: [],
|
||||
captions: [],
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
203
src/providers/embeds/streambucket.ts
Normal file
203
src/providers/embeds/streambucket.ts
Normal 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: [],
|
||||
},
|
||||
],
|
||||
};
|
||||
},
|
||||
});
|
||||
BIN
src/providers/sources/.DS_Store
vendored
BIN
src/providers/sources/.DS_Store
vendored
Binary file not shown.
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
73
src/providers/sources/consumet/index.ts
Normal file
73
src/providers/sources/consumet/index.ts
Normal 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,
|
||||
});
|
||||
36
src/providers/sources/consumet/types.ts
Normal file
36
src/providers/sources/consumet/types.ts
Normal 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[];
|
||||
}
|
||||
42
src/providers/sources/debrid/comet.ts
Normal file
42
src/providers/sources/debrid/comet.ts
Normal 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;
|
||||
}
|
||||
48
src/providers/sources/debrid/helpers.ts
Normal file
48
src/providers/sources/debrid/helpers.ts
Normal 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),
|
||||
});
|
||||
}
|
||||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
244
src/providers/sources/m4ufree.ts
Normal file
244
src/providers/sources/m4ufree.ts
Normal 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,
|
||||
});
|
||||
37
src/providers/sources/multiembed.ts
Normal file
37
src/providers/sources/multiembed.ts
Normal 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,
|
||||
});
|
||||
|
|
@ -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,
|
||||
});
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
74
src/providers/sources/primesrc.ts
Normal file
74
src/providers/sources/primesrc.ts
Normal 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,
|
||||
});
|
||||
|
|
@ -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,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
};
|
||||
|
|
@ -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
41
src/utils/browser.ts
Normal 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';
|
||||
}
|
||||
|
|
@ -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
107
src/utils/obfuscate.ts
Normal 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);
|
||||
}
|
||||
|
|
@ -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
170
src/utils/turnstile.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue