modified: src/providers/all.ts

modified:   src/providers/embeds/vidking.ts
	deleted:    src/providers/sources/coitus.ts
	modified:   src/providers/sources/disabled/coitus.ts
	modified:   src/providers/sources/disabled/moviebox.ts
	modified:   src/providers/sources/fed/xprime.ts
	new file:   src/providers/sources/florence/movielair.ts
	new file:   src/providers/sources/test.ts
This commit is contained in:
XSWY 2026-03-31 14:09:56 +00:00
parent e28a5ed4eb
commit 172597e4c1
8 changed files with 553 additions and 233 deletions

View file

@ -5,7 +5,10 @@ import { vidkingEmbedScraper } from './embeds/vidking';
import { vidkingScraper } from './sources/vidKing';
import { FedAPIScraper } from './sources/fed/fedapi';
//import { FedAPIDBScraper } from './sources/fed/fedapidb';
import { FEDIAPIScraper } from './sources/test';
import { coitusScraper } from './sources/disabled/coitus'
import { xprimeScraper } from './sources/fed/xprime';
import { movielairScrape } from './sources/florence/movielair'
import { doodScraper } from '@/providers/embeds/dood';
import { filemoonScraper } from '@/providers/embeds/filemoon';
import { mixdropScraper } from '@/providers/embeds/mixdrop';
@ -85,7 +88,6 @@ import { EightStreamScraper } from './sources/disabled/8stream';
import { animeflvScraper } from './sources/animeflv';
import { animetsuScraper } from './sources/animetsu';
import { cinehdplusScraper } from './sources/cinehdplus-es';
import { coitusScraper } from './sources/disabled/coitus';
import { cuevana3Scraper } from './sources/cuevana3';
import { debridScraper } from './sources/debrid';
import { embedsuScraper } from './sources/disabled/embedsu';
@ -121,6 +123,7 @@ export function gatherAllSources(): Array<Sourcerer> {
insertunitScraper,
zyonScraper,
soaperTvScraper,
FEDIAPIScraper,
smashyScraper,
autoembedScraper,
myanimeScraper,
@ -129,6 +132,7 @@ export function gatherAllSources(): Array<Sourcerer> {
fsharetvScraper,
zoechipScraper,
thunderleafScraper,
movielairScrape,
mp4hydraScraper,
embedsuScraper,
slidemoviesScraper,

View file

@ -18,57 +18,43 @@ export const vidkingEmbedScraper = makeEmbed({
'User-Agent': userAgent,
Referer: origin,
Origin: origin,
Accept: 'text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8',
'Accept-Language': 'en-US,en;q=0.9',
};
const html = await ctx.proxiedFetcher<string>(url, { headers });
const videoTagMatch = html.match(/<video[^>]+src=["']([^"']+)["']/i);
const m3u8Match = html.match(/["'](https?:\/\/[^"']+\.m3u8[^"']*)["']/i);
const sourceMatch = html.match(/src["']?\s*[:=]\s*["'](https?:\/\/[^"']+)["']/i);
const manifestMatch = html.match(/(https?:\/\/[^"']+\.m3u8[^"']*)/i);
const hlsMatch = html.match(/hls["']?\s*[:=]\s*["']([^"']+)["']/i);
const scriptMatch = html.match(/var\s+(?:source|video|url|hlsUrl|manifest|stream)\s*=\s*["']([^"']+)["']/i);
let videoUrl = m3u8Match?.[1] || manifestMatch?.[1] || hlsMatch?.[1] || sourceMatch?.[1];
if (!videoUrl) {
const scriptMatch = html.match(/var\s+(?:source|video|url|hlsUrl|manifest|stream)\s*=\s*["']([^"']+)["']/i);
if (scriptMatch) {
videoUrl = scriptMatch[1];
}
}
if (!videoUrl) {
const jsonMatch = html.match(/\{[^}]*"(?:src|source|url|hls|manifest)"\s*:\s*"([^"]+)"/i);
if (jsonMatch) {
videoUrl = jsonMatch[1];
}
}
let videoUrl = videoTagMatch?.[1] || m3u8Match?.[1] || scriptMatch?.[1];
if (!videoUrl) {
throw new Error('No video URL found in VidKing embed');
}
if (videoUrl.startsWith('//')) {
videoUrl = `https:${videoUrl}`;
} else if (videoUrl.startsWith('/')) {
videoUrl = `${origin}${videoUrl}`;
}
if (videoUrl.startsWith('//')) videoUrl = `https:${videoUrl}`;
if (videoUrl.startsWith('/')) videoUrl = `${origin}${videoUrl}`;
const isHls = videoUrl.includes('.m3u8') || /\.m3u8[?#]/.test(videoUrl);
const isMp4Worker = videoUrl.includes('workers.dev') || videoUrl.includes('/mp4/');
if (isHls) {
if (isMp4Worker) {
return {
stream: [
{
id: 'primary',
type: 'hls',
playlist: videoUrl,
type: 'file',
flags: [flags.CORS_ALLOWED],
captions: [],
headers: {
Referer: origin,
Origin: origin,
qualities: {
unknown: {
type: 'mp4',
url: videoUrl,
},
},
preferredHeaders: {
Referer: 'https://vidking.net/',
Origin: 'https://vidking.net/',
'User-Agent': userAgent,
},
},
@ -80,22 +66,17 @@ export const vidkingEmbedScraper = makeEmbed({
stream: [
{
id: 'primary',
type: 'file',
type: 'hls',
playlist: videoUrl,
flags: [flags.CORS_ALLOWED],
captions: [],
qualities: {
unknown: {
type: 'mp4',
url: videoUrl,
},
},
preferredHeaders: {
Referer: origin,
Origin: origin,
headers: {
Referer: 'https://vidking.net/',
Origin: 'https://vidking.net/',
'User-Agent': userAgent,
},
},
],
};
},
});
});

View file

@ -1,74 +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';
import { createM3U8ProxyUrl } from '@/utils/proxy';
const baseUrl = 'https://api.coitus.ca';
async function comboScraper(ctx: ShowScrapeContext | MovieScrapeContext): Promise<SourcererOutput> {
const apiUrl =
ctx.media.type === 'movie'
? `${baseUrl}/movie/${ctx.media.tmdbId}`
: `${baseUrl}/tv/${ctx.media.tmdbId}/${ctx.media.season.number}/${ctx.media.episode.number}`;
const apiRes = await ctx.proxiedFetcher(apiUrl);
if (!apiRes.videoSource) throw new NotFoundError('No watchable item found');
let processedUrl = apiRes.videoSource;
let streamHeaders: Record<string, string> = {};
if (processedUrl.includes('orbitproxy')) {
try {
const urlParts = processedUrl.split(/orbitproxy\.[^/]+\//);
if (urlParts.length >= 2) {
const encryptedPart = urlParts[1].split('.m3u8')[0];
try {
const decodedData = Buffer.from(encryptedPart, 'base64').toString('utf-8');
const jsonData = JSON.parse(decodedData);
const originalUrl = jsonData.u;
const referer = jsonData.r || '';
streamHeaders = { referer };
processedUrl = createM3U8ProxyUrl(originalUrl, ctx.features, streamHeaders);
} catch (jsonError) {
console.error('Error decoding/parsing orbitproxy data:', jsonError);
}
}
} catch (error) {
console.error('Error processing orbitproxy URL:', error);
}
}
// eslint-disable-next-line no-console
console.log(apiRes);
ctx.progress(90);
return {
embeds: [],
stream: [
{
id: 'primary',
captions: [],
playlist: processedUrl,
type: 'hls',
headers: streamHeaders,
flags: [flags.CORS_ALLOWED],
},
],
};
}
export const coitusScraper = makeSourcerer({
id: 'coitus',
name: 'Autoembed+',
rank: 94,
disabled: true,
flags: [flags.CORS_ALLOWED],
scrapeMovie: comboScraper,
scrapeShow: comboScraper,
});

View file

@ -65,7 +65,7 @@ async function comboScraper(ctx: ShowScrapeContext | MovieScrapeContext): Promis
export const coitusScraper = makeSourcerer({
id: 'coitus',
name: 'Autoembed+',
name: 'Coitus',
rank: 91,
disabled: true,
flags: [flags.CORS_ALLOWED],

View file

@ -1,114 +1,116 @@
import { AES, enc, mode, pad } from 'crypto-js';
import { flags } from '@/entrypoint/utils/targets';
import { makeSourcerer, SourcererOutput } from '@/providers/base';
import { MovieScrapeContext, ShowScrapeContext } from '@/utils/context';
import { NotFoundError } from '@/utils/errors';
const API_BASE = 'https://api.videasy.net';
// This salt must match the one currently in users.videasy.net/api/script.js
const XOR_SALT = '8c465aa8af6cbfd4c1f91bf0c8d678ba';
interface MovieboxApiResponse {
success: boolean;
data?: {
streamUrl?: string;
hls?: string;
url?: string;
sources?: Array<{ file: string; type: string }>;
};
/**
* Reverses the XOR shift.
* Using WordArray.create allows crypto-js to handle the raw bytes more reliably.
*/
function decodeVideasyHex(hex: string): any {
const words: number[] = [];
for (let i = 0; i < hex.length; i += 8) {
let word = 0;
for (let j = 0; j < 8; j += 2) {
const index = (i + j) / 2;
let byte = parseInt(hex.substring(i + j, i + j + 2), 16);
byte ^= XOR_SALT.charCodeAt(index % XOR_SALT.length);
word = (word << 8) | byte;
}
words.push(word);
}
return enc.Hex.parse(words.map(w => (w >>> 0).toString(16).padStart(8, '0')).join(''));
}
async function fetchMovieboxStream(
async function fetchVideasyStream(
ctx: MovieScrapeContext | ShowScrapeContext,
type: 'movie' | 'tv',
): Promise<SourcererOutput> {
const params = new URLSearchParams({
title: ctx.media.title,
mediaType: type,
year: String(ctx.media.releaseYear),
tmdbId: String(ctx.media.tmdbId),
imdbId: ctx.media.imdbId ?? '',
providerId: 'moviebox',
const tmdbId = String(ctx.media.tmdbId);
// Grey's Anatomy -> Grey%27s+Anatomy (matching your browser log)
const encodedTitle = encodeURIComponent(ctx.media.title).replace(/%20/g, '+');
const params = [
`title=${encodedTitle}`,
`mediaType=${type}`,
`year=${ctx.media.releaseYear}`,
`episodeId=${type === 'tv' ? (ctx as ShowScrapeContext).media.episode.number : ''}`,
`seasonId=${type === 'tv' ? (ctx as ShowScrapeContext).media.season.number : ''}`,
`tmdbId=${tmdbId}`,
`imdbId=${ctx.media.imdbId ?? ''}`
].join('&');
const url = `${API_BASE}/moviebox/sources-with-title?${params}`;
console.log(`[MovieBox] Sending Request: ${url}`);
const response: any = await ctx.proxiedFetcher(url, {
headers: {
'Origin': 'https://player.videasy.net',
'Referer': 'https://player.videasy.net/',
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:149.0) Gecko/20100101 Firefox/149.0',
'Accept': '*/*',
'Cache-Control': 'no-cache',
}
});
if (type === 'tv') {
const showCtx = ctx as ShowScrapeContext;
if (showCtx.media?.season?.number && showCtx.media?.episode?.number) {
params.append('seasonId', String(showCtx.media.season.number));
params.append('episodeId', String(showCtx.media.episode.number));
}
}
const apiUrl = `${API_BASE}/moviebox/sources-with-title?${params.toString()}`;
// DEBUG: Log the request URL
console.log('[moviebox] Request URL:', apiUrl);
const encryptedData = typeof response === 'string' ? response : response?.sources;
if (!encryptedData || encryptedData.length < 100) throw new NotFoundError('No sources');
try {
// Fetch as text first to see raw response
const rawResponse = await ctx.proxiedFetcher<string>(apiUrl, {
headers: {
Accept: 'application/json',
'User-Agent': 'Mozilla/5.0',
},
});
// Videasy uses the raw string bytes of the salt as key
const key = enc.Utf8.parse(XOR_SALT);
// IV is the first 16 bytes (128 bits) of the salt
const iv = enc.Utf8.parse(XOR_SALT.substring(0, 16));
// DEBUG: Log raw response
console.log('[moviebox] Raw API Response:', rawResponse.slice(0, 2000));
const ciphertext = decodeVideasyHex(encryptedData);
const decrypted = AES.decrypt(
{ ciphertext } as any,
key,
{ iv, mode: mode.CBC, padding: pad.Pkcs7 }
);
// Try to parse as JSON
let response: MovieboxApiResponse;
try {
response = JSON.parse(rawResponse);
} catch (parseErr) {
console.error('[moviebox] JSON parse error:', parseErr);
console.error('[moviebox] Response was not valid JSON');
throw new Error('API response is not valid JSON');
const decryptedText = decrypted.toString(enc.Utf8);
// If this is empty, the XOR_SALT provided doesn't match the server's current salt
if (!decryptedText) {
console.log(`[MovieBox] Decryption failed. Raw Start: ${encryptedData.substring(0, 20)}`);
throw new Error("Invalid Decryption Result");
}
console.log('[moviebox] Parsed response:', JSON.stringify(response, null, 2));
if (!response?.success || !response?.data) {
console.error('[moviebox] API error:', response?.error || 'No data field');
throw new Error('API returned no data');
}
const hlsUrl =
response.data.streamUrl ||
response.data.hls ||
response.data.url ||
response.data.sources?.find((s) => s.type === 'hls')?.file;
if (!hlsUrl || typeof hlsUrl !== 'string' || !hlsUrl.toLowerCase().includes('.m3u8')) {
console.error('[moviebox] No HLS URL found in:', response.data);
throw new Error('No valid HLS URL found');
}
const sources = JSON.parse(decryptedText);
console.log(`[MovieBox] Found ${sources.length} sources.`);
return {
embeds: [],
stream: [
{
id: 'moviebox-primary',
type: 'hls',
playlist: hlsUrl,
headers: {
referer: 'https://videasy.net/',
origin: 'https://videasy.net',
},
flags: [flags.CORS_ALLOWED],
captions: [],
},
],
stream: sources.map((s: any) => ({
id: 'videasy',
type: s.file.includes('m3u8') ? 'hls' : 'file',
playlist: s.file,
quality: s.label || 'Unknown',
flags: [flags.CORS_ALLOWED],
captions: [],
...(s.file.includes('m3u8') ? {} : { qualities: { "unknown": { type: "mp4", url: s.file } } })
}))
};
} catch (err) {
console.error('[moviebox] Fetch error:', err);
throw new NotFoundError('Moviebox stream not found');
} catch (err: any) {
console.error(`[MovieBox] Critical Decryption Failure: ${err.message}`);
throw new NotFoundError('Failed to process stream data');
}
}
export const movieboxScraper = makeSourcerer({
id: 'moviebox',
name: 'Moviebox',
rank: 99,
name: 'MovieBox',
rank: 110,
disabled: true,
flags: [flags.CORS_ALLOWED],
scrapeMovie: (ctx) => fetchMovieboxStream(ctx, 'movie'),
scrapeShow: (ctx) => fetchMovieboxStream(ctx, 'tv'),
});
scrapeMovie: (ctx) => fetchVideasyStream(ctx, 'movie'),
scrapeShow: (ctx) => fetchVideasyStream(ctx, 'tv'),
});

View file

@ -5,15 +5,14 @@ import { flags } from '@/entrypoint/utils/targets';
import { getTurnstileToken } from '@/utils/turnstile';
const baseUrl = 'mznxiwqjdiq00239q.space';
const UA = 'Windows NT 10.0 Very nice person';
const SITEKEY = '0x4AAAAAACuH31Fvud7uaIMf';
async function comboScraper(ctx: ShowScrapeContext | MovieScrapeContext): Promise<SourcererOutput> {
let turnstileToken: string;
try {
turnstileToken = await getTurnstileToken('0x4AAAAAACuH31Fvud7uaIMf');
} catch {
throw new NotFoundError('Turnstile verification failed');
}
const UA =
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/120 Safari/537.36";
async function comboScraper(
ctx: ShowScrapeContext | MovieScrapeContext
): Promise<SourcererOutput> {
const name = encodeURIComponent(ctx.media.title);
const year = ctx.media.releaseYear;
@ -23,36 +22,68 @@ async function comboScraper(ctx: ShowScrapeContext | MovieScrapeContext): Promis
const season = (ctx as ShowScrapeContext).media.season?.number || 1;
const episode = (ctx as ShowScrapeContext).media.episode?.number || 1;
const endpoints = ['primebox', 'fed', 'vento', 'bomber', 'blackout'];
const endpoints = [
"primebox",
"fed",
"vento",
"bomber",
"blackout"
];
const commonHeaders = {
'User-Agent': UA,
Referer: 'https://xprime.today/',
Origin: 'https://xprime.today',
Accept: 'application/json',
'cf-turnstile-response': turnstileToken,
};
console.log(`[XPRIME] Starting scrape for "${ctx.media.title}" (${year})`);
for (const ep of endpoints) {
try {
let url =
`https://${baseUrl}/${ep}?name=${name}&id=${tmdbId}&imdb=${imdbId}` +
`&season=${season}&episode=${episode}&year=${year}`;
if (ep === 'primebox') {
url = `https://${baseUrl}/${ep}?name=${name}` + `&fallback_year=${year}&season=${season}&episode=${episode}`;
console.log(`[XPRIME] Generating Turnstile token for endpoint "${ep}"...`);
let turnstileToken: string;
try {
turnstileToken = await getTurnstileToken(SITEKEY);
console.log(`[XPRIME] Token generated successfully.`);
} catch (err) {
console.warn(`[XPRIME] Turnstile token generation failed for "${ep}", skipping endpoint.`);
continue;
}
await new Promise(r => setTimeout(r, 350));
let url =
`https://${baseUrl}/${ep}?name=${name}` +
`&id=${tmdbId}&imdb=${imdbId}` +
`&season=${season}&episode=${episode}&year=${year}`;
if (ep === "primebox") {
url =
`https://${baseUrl}/${ep}?name=${name}` +
`&fallback_year=${year}` +
`&season=${season}&episode=${episode}`;
}
console.log(`[XPRIME] Fetching from endpoint "${ep}" -> ${url}`);
const res = await ctx.proxiedFetcher(url, {
headers: commonHeaders,
headers: {
'User-Agent': UA,
'Referer': 'https://xprime.today/',
'Origin': 'https://xprime.today',
'Accept': 'application/json',
'cf-turnstile-response': turnstileToken,
},
});
let streamUrl = '';
if (res?.servers?.length) {
streamUrl = res.servers[0].url;
console.log(`[XPRIME] Found server stream URL: ${streamUrl}`);
} else if (res?.url) {
streamUrl = res.url;
console.log(`[XPRIME] Found direct stream URL: ${streamUrl}`);
} else {
console.log(`[XPRIME] No stream URL found at this endpoint, continuing...`);
continue;
}
if (res?.servers?.length) streamUrl = res.servers[0].url;
else if (res?.url) streamUrl = res.url;
if (streamUrl?.includes('.m3u8')) {
// HLS stream
if (streamUrl.includes('.m3u8')) {
console.log(`[XPRIME] Returning HLS stream.`);
return {
embeds: [],
stream: [
@ -61,8 +92,9 @@ async function comboScraper(ctx: ShowScrapeContext | MovieScrapeContext): Promis
type: 'hls',
playlist: streamUrl,
headers: {
...commonHeaders,
'User-Agent': UA,
Referer: `https://${baseUrl}/`,
Origin: `https://${baseUrl}`,
},
flags: [flags.CORS_ALLOWED],
captions: [],
@ -70,10 +102,37 @@ async function comboScraper(ctx: ShowScrapeContext | MovieScrapeContext): Promis
],
};
}
} catch {}
// MP4 fallback
if (streamUrl.includes('.mp4')) {
console.log(`[XPRIME] Returning MP4 stream.`);
return {
embeds: [],
stream: [
{
id: 'primary',
type: 'file',
qualities: {
1080: {
type: 'mp4',
url: streamUrl,
},
},
flags: [flags.CORS_ALLOWED],
captions: [],
},
],
};
}
} catch (err) {
console.warn(`[XPRIME] Error scraping endpoint "${ep}":`, err);
continue;
}
}
throw new NotFoundError('No valid streams found');
console.error(`[XPRIME] No valid streams found for "${ctx.media.title}"`);
throw new NotFoundError('No valid Xprime streams found');
}
export const xprimeScraper = makeSourcerer({
@ -83,4 +142,4 @@ export const xprimeScraper = makeSourcerer({
flags: [flags.CORS_ALLOWED],
scrapeMovie: comboScraper,
scrapeShow: comboScraper,
});
});

View file

@ -0,0 +1,124 @@
import { load } from "cheerio";
import { flags } from "@/entrypoint/utils/targets";
import { SourcererOutput, makeSourcerer } from "@/providers/base";
import { MovieScrapeContext, ShowScrapeContext } from "@/utils/context";
import { NotFoundError } from "@/utils/errors";
const BASE = "https://movielair.cc";
const HEADERS = {
"user-agent":
"Mozilla/5.0 (X11; Linux x86_64) Gecko/20100101 Firefox/145.0",
referer: BASE,
};
async function searchFirst(
ctx: ShowScrapeContext | MovieScrapeContext,
query: string
): Promise<string | null> {
// IMPORTANT: /1 is required
const url = `${BASE}/search/${encodeURIComponent(query)}/1`;
const html = await ctx.proxiedFetcher(url, { headers: HEADERS });
const $ = load(html);
const first = $(".film-poster a").first().attr("href");
if (!first) return null;
return first;
}
function buildWatchUrl(
ctx: ShowScrapeContext | MovieScrapeContext,
path: string
) {
const id = path.split("/").pop();
if (ctx.media.type === "movie") {
return `${BASE}/watch-movie/${id}`;
}
const season = (ctx as ShowScrapeContext).media.season.number;
const episode = (ctx as ShowScrapeContext).media.episode.number;
return `${BASE}/watch-tv/${id}?season=${season}&episode=${episode}`;
}
async function getIframe(
ctx: ShowScrapeContext | MovieScrapeContext,
watchUrl: string
) {
const html = await ctx.proxiedFetcher(watchUrl, { headers: HEADERS });
const $ = load(html);
const iframe = $("iframe").attr("src");
if (!iframe) return null;
return iframe.startsWith("http") ? iframe : `https:${iframe}`;
}
async function extractStream(
ctx: ShowScrapeContext | MovieScrapeContext,
iframeUrl: string
) {
const html = await ctx.proxiedFetcher(iframeUrl, { headers: HEADERS });
const match = html.match(/file:\s*"(https?:\/\/[^"]+\.m3u8[^"]*)"/);
if (match) return match[1];
const match2 = html.match(/sources:\s*\[\{file:"([^"]+)"/);
if (match2) return match2[1];
return null;
}
async function movielairScraper(
ctx: ShowScrapeContext | MovieScrapeContext
): Promise<SourcererOutput> {
ctx.progress(10);
const title = ctx.media.title;
const path = await searchFirst(ctx, title);
if (!path) throw new NotFoundError("MovieLair search failed");
ctx.progress(30);
const watchUrl = buildWatchUrl(ctx, path);
ctx.progress(50);
const iframe = await getIframe(ctx, watchUrl);
if (!iframe) throw new NotFoundError("MovieLair iframe not found");
ctx.progress(70);
const stream = await extractStream(ctx, iframe);
if (!stream) throw new NotFoundError("MovieLair stream not found");
ctx.progress(90);
return {
embeds: [],
stream: [
{
id: "primary",
type: "hls" as const,
playlist: stream,
flags: [flags.CORS_ALLOWED],
captions: [],
},
],
};
}
export const movielairScrape = makeSourcerer({
id: "movielair",
name: "MovieLair",
rank: 30,
disabled: true,
flags: [flags.CORS_ALLOWED],
scrapeMovie: movielairScraper,
scrapeShow: movielairScraper,
});

View file

@ -0,0 +1,224 @@
import { flags } from '@/entrypoint/utils/targets';
import { SourcererOutput, makeSourcerer } from '@/providers/base';
import { MovieScrapeContext, ShowScrapeContext } from '@/utils/context';
import { NotFoundError } from '@/utils/errors';
import { getTurnstileToken } from '@/utils/turnstile';
import { Caption, labelToLanguageCode } from '../captions';
// ── Config ────────────────────────────────────────────────────────────────────
const API_BASE = 'https://z6mgd9v3-8787.euw.devtunnels.ms';
const TURNSTILE_SITE_KEY = '0x4AAAAAACuH31Fvud7uaIMf';
let FebToken =
"eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpYXQiOjE3NzIyMTQxODAsIm5iZiI6MTc3MjIxNDE4MCwiZXhwIjoxODAzMzE4MjAwLCJkYXRhIjp7InVpZCI6OTMxMzA0LCJ0b2tlbiI6Ijc5NjhmYTc2ZDYzNTFjZTJjMDAxMjE4NzYyNjg4M2VkIn19.zeGmJN0gdC4ObOGIBMiHSqM0M6JtdJeT0dQELGUvVhA";
// ── Turnstile Cache ───────────────────────────────────────────────────────────
let cachedTurnstile: { token: string; ts: number } | null = null;
async function getCachedTurnstile(): Promise<string> {
const now = Date.now();
if (cachedTurnstile && now - cachedTurnstile.ts < 120000) {
return cachedTurnstile.token;
}
const token = await getTurnstileToken(TURNSTILE_SITE_KEY);
cachedTurnstile = { token, ts: now };
return token;
}
// ── Types ─────────────────────────────────────────────────────────────────────
interface StreamEntry {
type: 'hls' | 'mp4';
url: string;
download?: string;
}
interface ApiResponse {
streams: Record<string, StreamEntry>;
subtitles: Record<
string,
{
subtitle_link: string;
language: string;
type: 'vtt' | 'srt';
}
>;
name?: string;
error?: string;
}
// Quality key → numeric for player
const QUALITY_MAP: Record<string, number | 'unknown'> = {
'4K': 2160,
'2160P': 2160,
'1080P': 1080,
'720P': 720,
'480P': 480,
'360P': 360,
'ORG': 'unknown',
};
// ── Main scraper ──────────────────────────────────────────────────────────────
async function comboScraper(
ctx: ShowScrapeContext | MovieScrapeContext,
): Promise<SourcererOutput> {
const userToken = FebToken;
if (!userToken)
throw new NotFoundError('Febbox token not set');
// 1. Turnstile
let turnstileToken: string;
try {
turnstileToken = await getCachedTurnstile();
} catch (err) {
console.log('Turnstile verification failed');
throw new NotFoundError(`Turnstile failed: ${err}`);
}
ctx.progress(25);
// 2. Require IMDB ID
const imdbId = ctx.media.imdbId;
if (!imdbId) throw new NotFoundError('No IMDB ID available');
// 3. Build params
const params = new URLSearchParams({
name: ctx.media.title,
year: String(ctx.media.releaseYear ?? ''),
ui: userToken,
imdb_id: imdbId,
type: ctx.media.type === 'show' ? 'show' : 'movie',
});
if (ctx.media.type === 'show') {
params.set('season', String(ctx.media.season.number));
params.set('episode', String(ctx.media.episode.number));
}
ctx.progress(40);
// 4. Fetch
const res = await fetch(`${API_BASE}/fedapi?${params}`, {
headers: {
'cf-turnstile-response': turnstileToken,
},
credentials: 'omit',
});
if (res.status === 401)
throw new NotFoundError('Invalid Febbox token');
if (res.status === 403)
throw new NotFoundError('Turnstile rejected');
if (res.status === 404)
throw new NotFoundError('Content not found');
if (!res.ok)
throw new NotFoundError(`API error ${res.status}`);
const data: ApiResponse = await res.json();
if (data?.error)
throw new NotFoundError(data.error);
if (!data?.streams)
throw new NotFoundError('No streams');
ctx.progress(80);
// 5. Normalize streams
type StreamInfo = { url: string; type: 'hls' | 'mp4' };
const streams: Record<string | number, StreamInfo> = {};
for (const [qualityKey, entry] of Object.entries(data.streams)) {
const normKey = QUALITY_MAP[qualityKey.toUpperCase()];
if (normKey === undefined) continue;
if (streams[normKey]) continue;
streams[normKey] = { url: entry.url, type: entry.type };
}
// 6. Captions
const captions: Caption[] = [];
for (const sub of Object.values(data.subtitles ?? {})) {
const url = sub.subtitle_link;
if (!url) continue;
const langCode =
labelToLanguageCode(sub.language)?.toLowerCase() ??
'unknown';
captions.push({
type: sub.type,
id: url,
url,
language: langCode,
hasCorsRestrictions: false,
});
}
ctx.progress(95);
// 7. Return
const orderedKeys = [2160, 1080, 720, 480, 360, 'unknown'] as const;
const hlsStream = orderedKeys
.map((k) => streams[k])
.find((s) => s?.type === 'hls');
if (hlsStream) {
return {
embeds: [],
stream: [
{
id: 'primary',
captions,
playlist: hlsStream.url,
type: 'hls',
flags: [flags.CORS_ALLOWED],
},
],
};
}
return {
embeds: [],
stream: [
{
id: 'primary',
captions,
qualities: {
...(streams[2160] && {
'4k': { type: 'mp4', url: streams[2160].url },
}),
...(streams[1080] && {
1080: { type: 'mp4', url: streams[1080].url },
}),
...(streams[720] && {
720: { type: 'mp4', url: streams[720].url },
}),
...(streams[480] && {
480: { type: 'mp4', url: streams[480].url },
}),
...(streams[360] && {
360: { type: 'mp4', url: streams[360].url },
}),
...(streams['unknown'] && {
unknown: {
type: 'mp4',
url: streams['unknown'].url,
},
}),
},
type: 'file',
flags: [flags.CORS_ALLOWED],
},
],
};
}
export const FEDIAPIScraper = makeSourcerer({
id: 'fediapi',
name: 'FEDI API 🔥',
rank: 105,
flags: [flags.CORS_ALLOWED],
scrapeMovie: comboScraper,
scrapeShow: comboScraper,
});